diff --git a/oceanarray/config/parameters.py b/oceanarray/config/parameters.py index 27c68c3..ae73120 100644 --- a/oceanarray/config/parameters.py +++ b/oceanarray/config/parameters.py @@ -558,7 +558,8 @@ #: where each variable is drawn as one line (not the multi-instrument stack, which #: uses :data:`LINE_CMAPS_BY_VARIABLE`). Physics use the Okabe-Ito palette (Wong, #: Nature Methods 8:441, 2011); biogeochemistry uses the Paul Tol palette. Look up -#: with ``VAR_COLORS.get(var, "#000000")``. Mirrors ctdcast's ``VAR_COLORS``. +#: with :func:`var_color` (single fallback, no scattered literals). Mirrors +#: ctdcast's ``VAR_COLORS``. VAR_COLORS: dict[str, str] = { # Physics — Okabe-Ito "temperature": "#56B4E9", # sky blue @@ -584,13 +585,40 @@ "turbidity": "#661100", # dark red } +#: Colour for a variable with no :data:`VAR_COLORS` entry — one canonical fallback +#: so callers don't scatter their own literal defaults (which drift). +VAR_COLOR_DEFAULT: str = "#000000" + + +def var_color(var: str) -> str: + """Return the line/marker colour for *var* from :data:`VAR_COLORS`. + + Falls back to :data:`VAR_COLOR_DEFAULT` for an unregistered variable, so every + caller shares one default rather than hard-coding its own. + + Parameters + ---------- + var : str + Variable name (key in :data:`VARIABLES` / :data:`VAR_COLORS`). + + Returns + ------- + str + A hex colour string. + + """ + return VAR_COLORS.get(var, VAR_COLOR_DEFAULT) + def vlabel(var: str, prefix: str = "") -> str: """Return a matplotlib axis label for *var* from the :data:`VARIABLES` registry. - Format is ``"{prefix}Label (units)"`` when ``label_units`` is non-empty, - or ``"{prefix}Label"`` for dimensionless quantities. The ``prefix`` is - prepended to the label component only, not the units, so + Format is ``"{prefix}Label (units)"`` when ``label_units`` is non-empty, or + ``"{prefix}Label (1)"`` for a dimensionless quantity — ``1`` is the CF / + UDUNITS unit string for dimensionless (e.g. practical salinity, whose NetCDF + ``units`` attribute is ``"1"``), so the label matches the stored metadata + rather than leaving the reader to wonder whether a unit was forgotten. The + ``prefix`` is prepended to the label component only, not the units, so ``vlabel("temperature", prefix="Δ")`` produces ``"ΔTemperature (°C)"``. Parameters @@ -609,8 +637,13 @@ def vlabel(var: str, prefix: str = "") -> str: """ entry = VARIABLES.get(var, {}) lbl = f"{prefix}{entry.get('label', var)}" + if var not in VARIABLES: + # Unknown variable: return the bare name. Do NOT append "( )" — that + # would falsely assert the quantity is dimensionless when we simply have + # no registry entry for it. + return lbl lu = entry.get("label_units", "") - return f"{lbl} ({lu})" if lu else lbl + return f"{lbl} ({lu})" if lu else f"{lbl} (1)" def vunit(var: str) -> str: diff --git a/oceanarray/plotters/current.py b/oceanarray/plotters/current.py index e54171b..0dbe71c 100644 --- a/oceanarray/plotters/current.py +++ b/oceanarray/plotters/current.py @@ -29,7 +29,7 @@ from matplotlib.collections import LineCollection from oceanarray.analysis.vector import xyz_to_enu_2d, progressive_vector -from oceanarray.plotters.helpers import tukey_smooth +from oceanarray.plotters.helpers import grid_despine, tukey_smooth from oceanarray.plotters.primitives import ( colorbar_norm, date_axis, @@ -118,6 +118,8 @@ def plot_temperature_trajectory( def plot_speed_boxplot( ds: xr.Dataset, speed_var: str = "current_speed", + *, + width_in: float = report_tokens.W_THIRD, ) -> object: """Boxplot of current speed with printed percentile statistics. @@ -130,6 +132,9 @@ def plot_speed_boxplot( Dataset containing the speed variable. speed_var : str Name of the current speed variable. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -149,7 +154,7 @@ def plot_speed_boxplot( val = np.percentile(speed_clean, p) print(f" {p:2d}th percentile: {val:.4f} {units}") - fig, ax = plt.subplots(figsize=(report_tokens.W_THIRD, 3.5)) + fig, ax = plt.subplots(figsize=(width_in, 3.5)) bp = ax.boxplot( speed_clean, vert=True, @@ -164,7 +169,7 @@ def plot_speed_boxplot( instr_id = ds.attrs.get("id", "") if instr_id: ax.set_title(instr_id, fontsize=9) - ax.grid(True, axis="y", linestyle="--", linewidth=0.6, alpha=0.5) + grid_despine(ax, axis="y") fig.tight_layout() return fig @@ -176,29 +181,33 @@ def plot_multi_aquadopp_trajectories( temp_var: str = "temperature", instr_type_var: str = "instrument_type", serial_var: str = "serial", - hab_var: str = "hab", title: str = "", + *, + width_in: float = report_tokens.W_HALF, ) -> Optional[plt.Figure]: """Multi-instrument Lagrangian trajectories for all Aquadopps, coloured by temperature. Each trajectory starts at the origin and is built by integrating the east/north velocity over time (Euler forward; NaN velocities set to zero). All trajectories share a single temperature colour scale so instruments can - be compared directly. End points are annotated with serial number and HAB. + be compared directly. End points are annotated with the instrument serial. Parameters ---------- ds : xr.Dataset Stacked mooring dataset with shape (time, N_LEVELS). Must contain - *instr_type_var*, *serial_var*, *hab_var*, *u_var*, *v_var*. + *instr_type_var*, *serial_var*, *u_var*, *v_var*. u_var, v_var : str Eastward and northward velocity variables (m s⁻¹). temp_var : str Temperature variable for colouring; omitted if not present in ds. - instr_type_var, serial_var, hab_var : str + instr_type_var, serial_var : str Dimension-coordinate variable names identifying each instrument. title : str Optional figure title; falls back to the dataset ``id`` attribute. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -213,7 +222,6 @@ def plot_multi_aquadopp_trajectories( """ instr_types = ds[instr_type_var].values serials = ds[serial_var].values - habs = ds[hab_var].values aqd_idx = [i for i, t in enumerate(instr_types) if str(t).lower() == "aquadopp"] if not aqd_idx: @@ -254,12 +262,11 @@ def plot_multi_aquadopp_trajectories( _bounds = _nice_colorbar_bounds(0.0, 1.0, n=20) norm: mcolors.BoundaryNorm = mcolors.BoundaryNorm(_bounds, ncolors=256) - fig, axes, cax = square_axes_grid(report_tokens.W_HALF, 1, 1, colorbar=has_temp) + fig, axes, cax = square_axes_grid(width_in, 1, 1, colorbar=has_temp) ax = axes[0, 0] for instr_i, x, y, temp in trajs: serial = str(serials[instr_i]) - hab = float(habs[instr_i]) if has_temp and temp is not None: points = np.array([x, y]).T.reshape(-1, 1, 2) @@ -294,7 +301,7 @@ def plot_multi_aquadopp_trajectories( markeredgewidth=0.5, ) ax.annotate( - f"s/n {serial} {hab:.0f} m hab", + f"{serial}", xy=(x[-1], y[-1]), xytext=(6, 3), textcoords="offset points", @@ -327,7 +334,7 @@ def plot_multi_aquadopp_trajectories( ax.axhline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4) ax.axvline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4) ax.set_aspect("equal", adjustable="datalim") - ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.4) + grid_despine(ax) if not title: title = ds.attrs.get("id", "") if title: @@ -341,6 +348,8 @@ def plot_hodograph( v_var: str = "north_velocity", lp_days: float = 4.0, smooth_hours: float = 3.0, + *, + width_in: float = report_tokens.W_FULL, ) -> plt.Figure: """Two-panel hodograph: Tukey-smoothed raw and eddy-only, coloured by time. @@ -368,6 +377,9 @@ def plot_hodograph( Low-pass window length in days for the eddy-component panel. smooth_hours : float Tukey smoothing window in hours applied to both panels. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -381,7 +393,7 @@ def plot_hodograph( # primitive used by the ADCP and grid hodographs, so all three render # identically and the colorbar height always matches the plotted square. fig, axes, cax = square_axes_grid( - report_tokens.W_FULL, 1, 2, top_pad_in=0.3 if instr_id else 0.0 + width_in, 1, 2, top_pad_in=0.3 if instr_id else 0.0 ) ax_raw, ax_eddy = axes[0, 0], axes[0, 1] if instr_id: @@ -471,6 +483,8 @@ def plot_aquadopp_speed_profile( instr_type_var: str = "instrument_type", serial_var: str = "serial", hab_var: str = "hab", + *, + width_in: float = report_tokens.W_HALF, ) -> Optional[plt.Figure]: """Horizontal speed boxplots for all Aquadopps, one per instrument at its HAB. @@ -491,6 +505,9 @@ def plot_aquadopp_speed_profile( Used to compute speed when *speed_var* is not present. instr_type_var, serial_var, hab_var : str Dimension-coordinate variable names. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -532,9 +549,7 @@ def plot_aquadopp_speed_profile( hab_range = max(hab_vals) - min(hab_vals) if len(hab_vals) > 1 else 10.0 box_width = max(2.0, hab_range * 0.06) - fig, ax = plt.subplots( - figsize=(report_tokens.W_HALF, max(3, len(records) * 0.7 + 1)) - ) + fig, ax = plt.subplots(figsize=(width_in, max(3, len(records) * 0.7 + 1))) for hab, serial, spd_clean in records: bp = ax.boxplot( @@ -568,7 +583,7 @@ def plot_aquadopp_speed_profile( ax.set_ylabel("Height above bottom (m)") ax.set_xlim(left=0) ax.set_ylim(min(hab_vals) - box_width * 1.5, max(hab_vals) + box_width * 1.5) - ax.grid(True, axis="x", linestyle="--", linewidth=0.5, alpha=0.5) + grid_despine(ax, axis="x") fig.tight_layout() return fig @@ -581,6 +596,8 @@ def plot_adcp_trajectories( hab_var: str = "hab", seabed_qc_var: str = "seabed_qc", percent_good_qc_var: str = "percent_good_qc", + *, + width_in: float = report_tokens.W_HALF, ) -> Optional[plt.Figure]: """Lagrangian per-bin trajectories for ADCP data, coloured by HAB. @@ -602,6 +619,9 @@ def plot_adcp_trajectories( QC variable for seabed proximity; bins with all values >= 3 are skipped. percent_good_qc_var : str Ping-quality QC; timesteps flagged >= 3 are zeroed before integration. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -673,7 +693,7 @@ def plot_adcp_trajectories( # Half width — shown in a 50% flex column beside the Aquadopp trajectory # (see stack.html), matching plot_multi_aquadopp_trajectories. - fig, axes, cax = square_axes_grid(report_tokens.W_HALF, 1, 1) + fig, axes, cax = square_axes_grid(width_in, 1, 1) ax = axes[0, 0] for hab, x, y in trajs: @@ -699,12 +719,16 @@ def plot_adcp_trajectories( ax.axhline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4) ax.axvline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4) ax.set_aspect("equal", adjustable="datalim") - ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.4) + grid_despine(ax) ax.set_title("ADCP bins coloured by HAB") return fig -def draw_instrument_rose(nc_path: Path) -> "Optional[plt.Figure]": +def draw_instrument_rose( + nc_path: Path, + *, + width_in: float = report_tokens.W_FULL, +) -> "Optional[plt.Figure]": """Rose diagram grid for a single Aquadopp instrument; return Figure or None. Loads the stage-3 NetCDF at *nc_path*, builds one polar panel per available @@ -715,6 +739,9 @@ def draw_instrument_rose(nc_path: Path) -> "Optional[plt.Figure]": ---------- nc_path : Path Path to a stage-3 NetCDF file for a single Aquadopp instrument. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -782,7 +809,7 @@ def _masked(flag_mask: "np.ndarray") -> "tuple[np.ndarray, np.ndarray]": # Full width — the figure is displayed at 100% (see instrument.html), so # figsize width == slot width and the browser does not rescale (which would # shrink the panel fonts). Was bumped 6"→9" after "too small" feedback. - figsize=(report_tokens.W_FULL, report_tokens.W_FULL / max(ncols, 1) + 0.4), + figsize=(width_in, width_in / max(ncols, 1) + 0.4), subplot_kw={"projection": "polar"}, squeeze=False, ) @@ -798,6 +825,8 @@ def _masked(flag_mask: "np.ndarray") -> "tuple[np.ndarray, np.ndarray]": def draw_rose_grid( ds: "xr.Dataset", serial_list: list, + *, + width_in: float = report_tokens.W_FULL, ) -> "Optional[tuple[plt.Figure, int]]": """Grid of current roses (max 4 per row) for instruments with ENU velocity data. @@ -807,6 +836,9 @@ def draw_rose_grid( Stack dataset with ``east_velocity`` and ``north_velocity``. serial_list : list Serial numbers corresponding to the instrument axis of the velocity arrays. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -887,19 +919,24 @@ def draw_rose_grid( if n == 0: return None - ncols = min(n, 4) + # Always a 4-wide grid; vary the number of rows and render at full width so + # every rose is the same size regardless of instrument count (empty trailing + # cells are hidden below). Row height tracks the ¼-width cell so roses stay + # square-ish. + ncols = 4 nrows = math.ceil(n / ncols) fig, axs = plt.subplots( nrows, ncols, - figsize=(report_tokens.W_FULL, nrows * 3.2), + figsize=(width_in, nrows * (width_in / ncols + 0.65)), subplot_kw={"projection": "polar"}, squeeze=False, ) - # Tighten the left-right gap between polar panels to match the instrument-page - # rose (encoder skips tight_layout for polar figures, so set it explicitly). - fig.subplots_adjust(wspace=0.5) + # Trim the outer left/right margins and the inter-panel gap (the tucked-in + # N/E/S/W labels no longer need the wide gap). Encoder skips tight_layout for + # polar figures, so set the margins explicitly. + fig.subplots_adjust(left=0.05, right=0.95, wspace=0.4) axs_flat = axs.flatten() for plot_i, instr_i in enumerate(aqd_idx): @@ -918,7 +955,12 @@ def draw_rose_grid( return fig, n -def draw_grid_rose(ds: "xr.Dataset", max_roses: int = 4) -> "Optional[plt.Figure]": +def draw_grid_rose( + ds: "xr.Dataset", + max_roses: int = 4, + *, + width_in: float = report_tokens.W_FULL, +) -> "Optional[plt.Figure]": """Grid of current roses, one per pressure level, for the grid report. Shows up to *max_roses* pressure levels (at most 1/5th of valid levels, @@ -933,6 +975,9 @@ def draw_grid_rose(ds: "xr.Dataset", max_roses: int = 4) -> "Optional[plt.Figure ``east_velocity`` and ``north_velocity`` in m s⁻¹. max_roses : int Maximum number of rose panels to draw (default 4). + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -973,7 +1018,7 @@ def draw_grid_rose(ds: "xr.Dataset", max_roses: int = 4) -> "Optional[plt.Figure fig, axs = plt.subplots( nrows, ncols, - figsize=(report_tokens.W_FULL, nrows * 3.2), + figsize=(width_in, nrows * 3.2), subplot_kw={"projection": "polar"}, squeeze=False, ) @@ -991,7 +1036,11 @@ def draw_grid_rose(ds: "xr.Dataset", max_roses: int = 4) -> "Optional[plt.Figure return fig -def draw_grid_trajectory(ds: "xr.Dataset") -> "Optional[plt.Figure]": +def draw_grid_trajectory( + ds: "xr.Dataset", + *, + width_in: float = report_tokens.W_HALF, +) -> "Optional[plt.Figure]": """Pseudo-Lagrangian current-vector integral by pressure level for the grid report. For each pressure level, integrates east and north velocity over time using @@ -1004,6 +1053,9 @@ def draw_grid_trajectory(ds: "xr.Dataset") -> "Optional[plt.Figure]": ds : xr.Dataset Gridded dataset with dimensions ``(time, pressure)``, containing ``east_velocity`` and ``north_velocity`` in m s⁻¹. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -1042,7 +1094,7 @@ def draw_grid_trajectory(ds: "xr.Dataset") -> "Optional[plt.Figure]": _bounds, norm = colorbar_norm(vmin=min(p_vals), vmax=max(p_vals)) cmap = plt.get_cmap("viridis_r") # shallow (low p) → light; deep → dark - fig, axes, cax = square_axes_grid(report_tokens.W_HALF, 1, 1) + fig, axes, cax = square_axes_grid(width_in, 1, 1) ax = axes[0, 0] for p_val, x, y in trajs: @@ -1068,11 +1120,15 @@ def draw_grid_trajectory(ds: "xr.Dataset") -> "Optional[plt.Figure]": ax.axhline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4) ax.axvline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4) ax.set_aspect("equal", adjustable="datalim") - ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.4) + grid_despine(ax) return fig -def draw_adcp_velocity(nc_path: str) -> "Optional[plt.Figure]": +def draw_adcp_velocity( + nc_path: str, + *, + width_in: float = report_tokens.W_FULL, +) -> "Optional[plt.Figure]": """Stacked colour panels for the ADCP per-instrument HTML report page; return a Figure. Reads the stage-3 NetCDF file at *nc_path* and produces a multi-panel @@ -1112,6 +1168,9 @@ def draw_adcp_velocity(nc_path: str) -> "Optional[plt.Figure]": ---------- nc_path : str Path to a stage-3 NetCDF file for a single ADCP instrument. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -1215,7 +1274,7 @@ def draw_adcp_velocity(nc_path: str) -> "Optional[plt.Figure]": n = len(present) fig, axes = plt.subplots( - n, 1, figsize=(report_tokens.W_FULL, 3.5 * n), sharex=True, squeeze=False + n, 1, figsize=(width_in, 3.5 * n), sharex=True, squeeze=False ) orientation = ds.attrs.get("orientation_yaml") or ds.attrs.get( @@ -1269,7 +1328,7 @@ def draw_adcp_velocity(nc_path: str) -> "Optional[plt.Figure]": cb.set_label(cb_label) ax.set_ylabel(ylabel) ax.set_title(label, loc="left") - ax.grid(True, linestyle="--", linewidth=0.3, alpha=0.4) + grid_despine(ax) # Show from 0 (includes blanking zone) to deepest valid bin. # set_ylim with reversed args inverts for downward-looking. if looking_down: @@ -1282,7 +1341,11 @@ def draw_adcp_velocity(nc_path: str) -> "Optional[plt.Figure]": return fig -def draw_adcp_rose(nc_path: str) -> "Optional[plt.Figure]": +def draw_adcp_rose( + nc_path: str, + *, + width_in: float = report_tokens.W_FULL, +) -> "Optional[plt.Figure]": """Current rose panels for an ADCP: depth-average plus percentile-selected bins. Selects the depth-average and up to four individual range bins at the 10th, @@ -1294,6 +1357,9 @@ def draw_adcp_rose(nc_path: str) -> "Optional[plt.Figure]": ---------- nc_path : str Path to a stage-3 ADCP NetCDF file. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -1366,7 +1432,7 @@ def draw_adcp_rose(nc_path: str) -> "Optional[plt.Figure]": fig, axs = plt.subplots( 1, ncols, - figsize=(report_tokens.W_FULL, 4.0), + figsize=(width_in, 4.0), subplot_kw={"projection": "polar"}, squeeze=False, ) @@ -1401,7 +1467,11 @@ def draw_adcp_rose(nc_path: str) -> "Optional[plt.Figure]": def draw_adcp_hodograph( - nc_path: str, lp_days: float = 4.0, smooth_hours: float = 24.0 + nc_path: str, + lp_days: float = 4.0, + smooth_hours: float = 24.0, + *, + width_in: float = report_tokens.W_FULL, ) -> "Optional[plt.Figure]": """Two-depth hodograph for an ADCP per-instrument report; return a Figure. @@ -1425,6 +1495,9 @@ def draw_adcp_hodograph( Low-pass filter cutoff in days for eddy extraction. smooth_hours : float Tukey smoothing window in hours for the raw panel. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -1498,7 +1571,7 @@ def draw_adcp_hodograph( # Deterministic square-panel grid: each hodograph is an exact square so the # single shared time colorbar (right) matches the panel height exactly. - fig, axes, cax = square_axes_grid(report_tokens.W_FULL, 2, 2) + fig, axes, cax = square_axes_grid(width_in, 2, 2) sm_far = _draw_hodograph_pair( axes[0, 0], @@ -1533,7 +1606,10 @@ def draw_adcp_hodograph( def draw_grid_hodograph( - ds: "xr.Dataset", smooth_hours: float = 24.0 + ds: "xr.Dataset", + smooth_hours: float = 24.0, + *, + width_in: float = report_tokens.W_FULL, ) -> "Optional[plt.Figure]": """Two-depth hodograph for the grid report; return a Figure. @@ -1549,6 +1625,9 @@ def draw_grid_hodograph( Gridded mooring dataset with ``east_velocity`` and ``north_velocity``. smooth_hours : float Tukey smoothing window in hours. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -1612,7 +1691,7 @@ def draw_grid_hodograph( # Same deterministic square-panel layout as the ADCP hodograph, so both # reports render hodographs and their shared time colorbar identically. - fig, axes, cax = square_axes_grid(report_tokens.W_FULL, 1, 2) + fig, axes, cax = square_axes_grid(width_in, 1, 2) ax_shallow, ax_deep = axes[0, 0], axes[0, 1] sm = None diff --git a/oceanarray/plotters/diagnostic.py b/oceanarray/plotters/diagnostic.py index 7e47476..cab0a77 100644 --- a/oceanarray/plotters/diagnostic.py +++ b/oceanarray/plotters/diagnostic.py @@ -41,7 +41,7 @@ import numpy as np from .helpers import grid_despine -from .primitives import date_offset_left +from .primitives import date_offset_left, square_axes_grid from .. import parameters as params from oceanarray.config import report_tokens @@ -318,7 +318,7 @@ def plot_knockdown_pressure( ax.legend(fontsize=9, loc="upper left") ax.set_xlabel("Nominal pressure (dbar)") ax.set_ylabel("Measured pressure (dbar)") - ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.5) + grid_despine(ax) plt.tight_layout() return fig @@ -331,6 +331,8 @@ def plot_knockdown_pressure( def plot_knockdown_hab( ds: "xr.Dataset", + *, + width_in: float = report_tokens.W_HALF, ) -> "Optional[matplotlib.figure.Figure]": """IQR of measured pressure vs. nominal HAB, equal aspect ratio. @@ -354,6 +356,9 @@ def plot_knockdown_hab( ``pressure``, ``hab``, ``serial``, ``instrument_type``, and optionally ``pressure_qc``. The ``waterdepth`` global attribute must be present and non-zero. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -390,7 +395,7 @@ def plot_knockdown_hab( with plt.style.context(str(params.MPLSTYLE)): # Half width — displayed side-by-side with the anomaly panel in a two-column # flex row (see mooring.html), so the slot is ~half the page, not full. - fig, ax = plt.subplots(figsize=(report_tokens.W_HALF, report_tokens.W_HALF)) + fig, ax = plt.subplots(figsize=(width_in, width_in)) p_max_all = 0.0 for hab_nom, _serial, actual_p in hab_records: @@ -439,6 +444,8 @@ def plot_knockdown_hab( def plot_knockdown_anomaly( ds: "xr.Dataset", + *, + width_in: float = report_tokens.W_HALF, ) -> "Optional[matplotlib.figure.Figure]": """IQR of pressure anomaly (measured − nominal) per instrument. @@ -465,6 +472,9 @@ def plot_knockdown_anomaly( ---------- ds : xr.Dataset Stack dataset; same requirements as :func:`plot_knockdown_pressure`. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -495,9 +505,7 @@ def plot_knockdown_anomaly( with plt.style.context(str(params.MPLSTYLE)): # Half width — side-by-side with the HAB panel in the mooring.html flex row. - fig, ax = plt.subplots( - figsize=(report_tokens.W_HALF, max(3, len(records) * 0.4 + 1)) - ) + fig, ax = plt.subplots(figsize=(width_in, max(3, len(records) * 0.4 + 1))) for p_nom, _serial, actual_p in records: anomaly = actual_p - p_nom # positive = knocked down deeper @@ -537,6 +545,8 @@ def plot_knockdown_anomaly( def plot_knockdown_displacement( ds: "xr.Dataset", + *, + width_in: float = report_tokens.W_FULL, ) -> "Optional[matplotlib.figure.Figure]": """Scatter and heatmap of estimated horizontal displacement vs. measured pressure. @@ -560,6 +570,9 @@ def plot_knockdown_displacement( ---------- ds : xr.Dataset Stack dataset; same requirements as :func:`plot_knockdown_pressure`. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -610,11 +623,30 @@ def plot_knockdown_displacement( x_max = max(float(np.nanmax(all_x)) if len(all_x) else 1.0, 1.0) p_max = float(np.nanmax(all_p)) * 1.05 if len(all_p) else 1.0 - with plt.style.context(str(params.MPLSTYLE)): - fig, (ax1, ax2) = plt.subplots( - 1, 2, figsize=(report_tokens.W_FULL, 4.5), sharey=True, sharex=True + # Deterministic square panels with the colorbar in its OWN reserved column. + # (plt.subplots + fig.colorbar(ax=ax2) took the colorbar's width out of the + # right panel, so set_box_aspect(1) then shrank its height and it no longer + # matched the left panel — square_axes_grid places both by construction.) + # Tight inter-panel gap (the heatmap shares the scatter's y-axis and hides + # its own y-labels) and a wider colorbar-text reserve for the long label. + fig, axs, cax = square_axes_grid( + width_in, 1, 2, colorbar=True, wgap_in=0.3, cbar_txt_in=1.0 ) + ax1, ax2 = axs[0, 0], axs[0, 1] + + # The panels are square (square_axes_grid), but each axis spans its OWN + # data range — displacement on x (0..x_max), pressure on y (0..p_max) — so + # the displacement structure fills the panel instead of being squeezed into + # a thin strip against the full depth range. This is NOT a 1:1 physical + # scale (true-scale, a mooring barely tilts and reads as an empty vertical + # sliver); readability wins for a QC diagnostic. + for _ax in (ax1, ax2): + _ax.set_xlim(0, x_max) + _ax.set_ylim(0, p_max) + _ax.invert_yaxis() + _ax.set_xlabel("Horizontal displacement (m)") + grid_despine(_ax) # --- left panel: scatter --- for serial, x_thin, p_thin, color in scatter_data: @@ -628,26 +660,13 @@ def plot_knockdown_displacement( label=str(serial), rasterized=True, ) - ax1.set_xlim(0, x_max) # sharex propagates to ax2 - ax1.set_xlabel("Horizontal displacement (m)") ax1.set_ylabel("Measured pressure (dbar)") ax1.legend(fontsize=9, loc="lower right", markerscale=3) - ax1.set_aspect("equal", adjustable="box") # 100 m on x = 100 m on y - ax1.grid(True, linestyle="--", linewidth=0.4, alpha=0.5) - - # Shared tick step so x and y gridlines fall at the same intervals - import matplotlib.ticker as mticker - - _ax_range = max(x_max, p_max) - _step = next( - s for s in [10, 20, 25, 50, 100, 200, 250, 500, 1000] if _ax_range / s <= 6 - ) - ax1.xaxis.set_major_locator(mticker.MultipleLocator(_step)) - ax1.yaxis.set_major_locator(mticker.MultipleLocator(_step)) # --- right panel: per-instrument normalised heatmap --- # Each instrument's 2-D histogram is divided by its own total before # summing, so all instruments contribute equally regardless of record length. + ax2.tick_params(labelleft=False) # same y-axis as the scatter panel n_bins = 40 x_edges = np.linspace(0, x_max, n_bins + 1) p_edges = np.linspace(0, p_max, n_bins + 1) @@ -676,18 +695,12 @@ def plot_knockdown_displacement( mesh = ax2.pcolormesh(x_edges, p_edges, H_norm.T, norm=norm, cmap="YlOrRd") fig.colorbar( mesh, - ax=ax2, + cax=cax, ticks=bounds[:: max(1, len(bounds) // 6)], label="Normalised density (sum = 1 per instrument)", ) - - ax2.set_ylim(0, p_max) # sharey propagates this to ax1 - ax2.set_aspect("equal", adjustable="box") - ax2.invert_yaxis() - ax2.set_xlabel("Horizontal displacement (m)") - ax2.grid(True, linestyle="--", linewidth=0.4, alpha=0.5, zorder=3) - - plt.tight_layout() + else: + cax.set_axis_off() # nothing to show; don't leave an empty colorbar box return fig @@ -701,6 +714,8 @@ def plot_clock_offset_check( deploy_dt: "Optional[datetime]", recover_dt: "Optional[datetime]", window_minutes: int = 30, + *, + width_in: float = report_tokens.W_FULL, ) -> "Optional[matplotlib.figure.Figure]": """Overlaid, per-instrument normalised temperature around deploy and recover. @@ -736,6 +751,9 @@ def plot_clock_offset_check( Recovery time (UTC). window_minutes : int Duration of each zoom window in minutes. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -794,9 +812,7 @@ def plot_clock_offset_check( n_panels = len(windows) with plt.style.context(str(params.MPLSTYLE)): - fig, axes = plt.subplots( - 1, n_panels, figsize=(report_tokens.W_FULL, 3.5), sharey=False - ) + fig, axes = plt.subplots(1, n_panels, figsize=(width_in, 3.5), sharey=False) if n_panels == 1: axes = [axes] @@ -819,7 +835,7 @@ def plot_clock_offset_check( ax.set_title(title) ax.set_ylabel("Normalised temperature (std)") - ax.grid(True) + grid_despine(ax) locator = mdates.AutoDateLocator() ax.xaxis.set_major_locator(locator) ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator)) @@ -868,6 +884,8 @@ def draw_windows( vlines: Optional[list] = None, stage1_nc: Optional[Path] = None, panels: Optional[list] = None, + *, + width_in: float = report_tokens.W_FULL, ) -> "Optional[plt.Figure]": """Combined start + end window figure: (nrows × 2) — left = first N h, right = last N h. @@ -900,6 +918,9 @@ def draw_windows( Subset of ``_instrument_panels`` tuples to draw. When given, only these rows are rendered (used to paginate a tall window figure across several images); otherwise every panel for the instrument is drawn on one figure. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -947,7 +968,7 @@ def draw_windows( for vname, *_ in panels ] nrows = len(panels) - fig = plt.figure(figsize=(report_tokens.W_FULL, sum(height_ratios))) + fig = plt.figure(figsize=(width_in, sum(height_ratios))) gs = GridSpec( nrows, 2, @@ -1174,7 +1195,11 @@ def _plot_grey( # noqa: ANN202 ds1.close() -def draw_data_histogram(nc_path: Path) -> "Optional[plt.Figure]": +def draw_data_histogram( + nc_path: Path, + *, + width_in: float = report_tokens.W_FULL, +) -> "Optional[plt.Figure]": """Histogram of data values for each main variable; return a Figure. Each panel shows grey bars (all finite data) and blue bars (kept, not bad/missing), @@ -1184,6 +1209,9 @@ def draw_data_histogram(nc_path: Path) -> "Optional[plt.Figure]": ---------- nc_path : Path Path to a stage-3 NetCDF file. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -1226,7 +1254,7 @@ def draw_data_histogram(nc_path: Path) -> "Optional[plt.Figure]": fig, axs_grid = plt.subplots( nrows, ncols, - figsize=(report_tokens.W_FULL, 2.5 * nrows), + figsize=(width_in, 2.5 * nrows), squeeze=False, sharey=True, layout="constrained", @@ -1293,7 +1321,10 @@ def draw_data_histogram(nc_path: Path) -> "Optional[plt.Figure]": else: bin_edges = 80 - # Grey: all finite data; blue: kept (not bad/missing) + # Grey: all finite data. Kept bars use the variable's own line colour + # (VAR_COLORS) so the distribution matches its time-series line, falling + # back to the default blue for variables without a registered colour. + _kept_color = params.var_color(vname) ax.hist( all_data, bins=bin_edges, @@ -1307,7 +1338,7 @@ def draw_data_histogram(nc_path: Path) -> "Optional[plt.Figure]": ax.hist( kept_data, bins=bin_edges, - color="#2980b9", + color=_kept_color, alpha=0.85, edgecolor="none", zorder=2, @@ -1388,7 +1419,11 @@ def draw_data_histogram(nc_path: Path) -> "Optional[plt.Figure]": return fig -def draw_velocity_iqr_profile(ds: "xr.Dataset") -> "Optional[plt.Figure]": +def draw_velocity_iqr_profile( + ds: "xr.Dataset", + *, + width_in: float = report_tokens.W_FULL, +) -> "Optional[plt.Figure]": """Percentile-profile figure for gridded ADCP velocity data; return a Figure. Three side-by-side panels, all with pressure (dbar) on the Y-axis (inverted, @@ -1421,6 +1456,9 @@ def draw_velocity_iqr_profile(ds: "xr.Dataset") -> "Optional[plt.Figure]": ds : xr.Dataset Gridded dataset with dimensions ``(time, pressure)`` containing at minimum one of ``current_speed``, ``east_velocity``, or ``north_velocity`` in m s⁻¹. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -1471,7 +1509,7 @@ def draw_velocity_iqr_profile(ds: "xr.Dataset") -> "Optional[plt.Figure]": fig, axs = plt.subplots( 1, n_panels, - figsize=(report_tokens.W_FULL, 4.5), + figsize=(width_in, 4.5), sharey=True, gridspec_kw={"width_ratios": [2] * (n_panels - 1) + [1]}, ) @@ -1534,7 +1572,7 @@ def draw_velocity_iqr_profile(ds: "xr.Dataset") -> "Optional[plt.Figure]": ) ax.set_xlim(left=0) ax.set_xlabel("Current speed (m s⁻¹)") - ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.5) + grid_despine(ax) ax.legend(loc="best") # ── Panel 2: east + north on shared axes ───────────────────────────────── @@ -1573,7 +1611,7 @@ def draw_velocity_iqr_profile(ds: "xr.Dataset") -> "Optional[plt.Figure]": if absmax > 0: ax.set_xlim(-absmax * 1.15, absmax * 1.15) ax.set_xlabel("Velocity (m s⁻¹)") - ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.5) + grid_despine(ax) ax.legend(loc="best") # ── Count panel (rightmost) ─────────────────────────────────────────────── @@ -1586,7 +1624,7 @@ def draw_velocity_iqr_profile(ds: "xr.Dataset") -> "Optional[plt.Figure]": alpha=0.6, ) ax_count.set_xlabel("N good") - ax_count.grid(True, linestyle="--", linewidth=0.4, alpha=0.5) + grid_despine(ax_count) axs[0].set_ylabel("Pressure (dbar)") axs[0].invert_yaxis() # shared y — invert once only diff --git a/oceanarray/plotters/helpers.py b/oceanarray/plotters/helpers.py index e597345..1361e10 100644 --- a/oceanarray/plotters/helpers.py +++ b/oceanarray/plotters/helpers.py @@ -23,20 +23,26 @@ import matplotlib.pyplot as plt -def grid_despine(ax: "plt.Axes") -> None: +def grid_despine(ax: "plt.Axes", *, axis: str = "both") -> None: """Turn the grid on and hide the top and right spines (report convention). The report style keeps ``axes.grid`` off by default and figures opt in; when they do, the top and right spines are redundant clutter. Call this instead of - ``ax.grid(True)`` so the two always travel together. + ``ax.grid(True)`` so the two always travel together. Grid appearance (dotted, + faint) comes from the active mplstyle, not hard-coded here, so a single style + change restyles every grid. Parameters ---------- ax : matplotlib.axes.Axes Axes to style. + axis : {"both", "x", "y"}, optional + Which gridlines to draw (default ``"both"``). Bar/profile plots that + want one-directional gridlines pass ``"x"`` or ``"y"`` and still get the + top/right spines hidden. """ - ax.grid(True) + ax.grid(True, axis=axis) ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) @@ -212,7 +218,9 @@ def _velocity_panel_style( if var == "current_direction": bounds = np.linspace(0, 360, 21) norm = mcolors.BoundaryNorm(bounds, ncolors=256) - return bounds, norm, "hsv", "°T" + # Cyclic colormap so 0° and 360° share a colour; twilight is perceptually + # uniform (hsv is not). Hard-coded here, not in parameters.py. + return bounds, norm, "twilight", "°T" if var == "bin_pressure": p_lo = float(np.percentile(finite_vals, 2)) if len(finite_vals) else 0.0 p_hi = float(np.percentile(finite_vals, 98)) if len(finite_vals) else 1000.0 @@ -308,6 +316,9 @@ def _rose_ax( ax.set_theta_direction(-1) ax.set_xticks(np.radians([0, 90, 180, 270])) ax.set_xticklabels(["N", "E", "S", "W"]) + # Tuck the N/E/S/W labels closer to the frame (about half the default ~3.5 pad) + # so panels can sit nearer each other without "W" crowding the next axis. + ax.tick_params(axis="x", pad=1.75) ax.set_rticks([]) ax.set_title(title, pad=2) return spd_edges, colors diff --git a/oceanarray/plotters/hydrography.py b/oceanarray/plotters/hydrography.py index 18a6c4b..eb801c6 100644 --- a/oceanarray/plotters/hydrography.py +++ b/oceanarray/plotters/hydrography.py @@ -24,7 +24,9 @@ from oceanarray.config import report_tokens -def draw_isopycnal_ts_fig(ds_iso: "xr.Dataset") -> "Optional[plt.Figure]": +def draw_isopycnal_ts_fig( + ds_iso: "xr.Dataset", *, width_in: float = report_tokens.W_FULL +) -> "Optional[plt.Figure]": """Isopycnal height-above-seabed time series; return a Figure. Plots a 1-hour running median of each σ₀ surface's height above seabed. @@ -37,6 +39,9 @@ def draw_isopycnal_ts_fig(ds_iso: "xr.Dataset") -> "Optional[plt.Figure]": Output of :func:`~oceanarray.tools.isopycnal_dataset` — must contain ``isopycnal_height`` ``(sigma0_level, time)`` and the ``sigma0_level`` coordinate. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -70,7 +75,7 @@ def draw_isopycnal_ts_fig(ds_iso: "xr.Dataset") -> "Optional[plt.Figure]": _dens_cmap = params.CMAPS_BY_VARIABLE.get("potential_density", "Blues") colors = ordered_line_colors(_dens_cmap, max(n_levels, 1)) - fig, ax = plt.subplots(figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN)) + fig, ax = plt.subplots(figsize=(width_in, params.GRID_PANEL_ROW_IN)) grid_despine(ax) for i, (sval, col) in enumerate(zip(sigma_vals, colors)): @@ -102,7 +107,9 @@ def draw_isopycnal_ts_fig(ds_iso: "xr.Dataset") -> "Optional[plt.Figure]": return fig -def draw_isopycnal_coverage(ds: "xr.Dataset") -> "Optional[plt.Figure]": +def draw_isopycnal_coverage( + ds: "xr.Dataset", *, width_in: float = report_tokens.W_FULL +) -> "Optional[plt.Figure]": """Three-panel isopycnal diagnostic; return a Figure. **Panel 0 — Distribution**: horizontal histogram of all gridded σ₀ values @@ -128,6 +135,9 @@ def draw_isopycnal_coverage(ds: "xr.Dataset") -> "Optional[plt.Figure]": ds: Gridded mooring xr.Dataset containing a variable whose name starts with ``"sigma"`` and has ``pressure`` and ``time`` dimensions. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -242,7 +252,7 @@ def _bar_color(p: float) -> str: fig, (ax0, ax1, ax2) = plt.subplots( 1, 3, - figsize=(report_tokens.W_FULL, fig_h), + figsize=(width_in, fig_h), sharey=True, gridspec_kw={"width_ratios": [0.8, 1.0, 1.2]}, ) @@ -327,7 +337,9 @@ def _bar_color(p: float) -> str: return fig -def draw_overflow_temperature_fig(ds: "xr.Dataset") -> "Optional[plt.Figure]": +def draw_overflow_temperature_fig( + ds: "xr.Dataset", *, width_in: float = report_tokens.W_FULL +) -> "Optional[plt.Figure]": """Temperature time series at ~100 m above the seabed; return a Figure. Selects the grid pressure level nearest to ``waterdepth - 100`` dbar and @@ -340,6 +352,9 @@ def draw_overflow_temperature_fig(ds: "xr.Dataset") -> "Optional[plt.Figure]": Gridded mooring xr.Dataset. Must have a ``waterdepth`` global attribute (metres) and a ``temperature`` variable with ``pressure`` and ``time`` dimensions. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -385,12 +400,12 @@ def draw_overflow_temperature_fig(ds: "xr.Dataset") -> "Optional[plt.Figure]": .values ) - fig, ax = plt.subplots(figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN)) + fig, ax = plt.subplots(figsize=(width_in, params.GRID_PANEL_ROW_IN)) grid_despine(ax) ax.plot( time_vals, temp_med, - color=params.VAR_COLORS.get("temperature", "#1a3a5c"), + color=params.var_color("temperature"), lw=1.0, ) ax.set_ylabel(params.vlabel("temperature")) diff --git a/oceanarray/plotters/primitives.py b/oceanarray/plotters/primitives.py index 580d546..24d08ae 100644 --- a/oceanarray/plotters/primitives.py +++ b/oceanarray/plotters/primitives.py @@ -25,6 +25,7 @@ from .. import parameters as params from oceanarray.config import report_tokens +from .helpers import grid_despine from ..utilities import _nice_colorbar_bounds, nice_colorbar_ticks @@ -153,6 +154,8 @@ def square_axes_grid( per_panel_colorbar: bool = False, top_pad_in: float = 0.0, bottom_pad_in: float = 0.0, + wgap_in: "Optional[float]" = None, + cbar_txt_in: "Optional[float]" = None, ) -> "tuple[plt.Figure, np.ndarray, Any]": """Lay out an ``nrows × ncols`` grid of square axes deterministically in inches. @@ -190,6 +193,15 @@ def square_axes_grid( bottom_pad_in : float Extra inches reserved below the x-tick strip, e.g. for rotated tick labels or a second x-axis label line. Default 0. + wgap_in : float, optional + Override the inter-column gap (inches). Default (``None``) uses the + standard gap, which reserves room for each column's y-labels; a caller + whose panels share a y-axis (right panel hides its y-labels) can pass a + smaller value to close the gap. + cbar_txt_in : float, optional + Override the reserved width (inches) for the shared colorbar's tick + labels and axis label. Default (``None``) uses the standard reserve; + pass a larger value for a long colorbar label so it stays on-canvas. Returns ------- @@ -199,18 +211,22 @@ def square_axes_grid( ``(nrows, ncols)`` object array when *per_panel_colorbar* is set. """ - _cbar_reserve = _SQ_CBAR_GAP_IN + _SQ_CBAR_W_IN + _SQ_CBAR_TXT_IN + # Optional overrides: a caller with a shared y-axis (right panel hides its + # y-labels) can pass a smaller *wgap_in*; a long shared-colorbar label needs a + # wider *cbar_txt_in* reserve so it stays on-canvas. + _cbar_txt = cbar_txt_in if cbar_txt_in is not None else _SQ_CBAR_TXT_IN + _cbar_reserve = _SQ_CBAR_GAP_IN + _SQ_CBAR_W_IN + _cbar_txt if per_panel_colorbar: # Each cell = y-labels + square panel + its own (tight) colorbar. per_cell_fixed = ( _SQ_LABEL_IN + _SQ_CBAR_GAP_IN + _SQ_CBAR_W_IN + _SQ_CBAR_TXT_PP_IN ) - _wgap = _SQ_WGAP_PP_IN + _wgap = wgap_in if wgap_in is not None else _SQ_WGAP_PP_IN avail_w = fig_w - ncols * per_cell_fixed - (ncols - 1) * _wgap else: - _wgap = _SQ_WGAP_IN + _wgap = wgap_in if wgap_in is not None else _SQ_WGAP_IN right_in = _cbar_reserve if colorbar else _SQ_LABEL_IN - avail_w = fig_w - _SQ_LABEL_IN - right_in - (ncols - 1) * _SQ_WGAP_IN + avail_w = fig_w - _SQ_LABEL_IN - right_in - (ncols - 1) * _wgap side = max(avail_w / ncols, 0.5) # square panel side (inches) grid_h = nrows * side + (nrows - 1) * _SQ_HGAP_IN bottom_in = _SQ_XTICK_IN + bottom_pad_in @@ -243,11 +259,11 @@ def square_axes_grid( cax = None if colorbar: - cx0 = _SQ_LABEL_IN + ncols * side + (ncols - 1) * _SQ_WGAP_IN + _SQ_CBAR_GAP_IN + cx0 = _SQ_LABEL_IN + ncols * side + (ncols - 1) * _wgap + _SQ_CBAR_GAP_IN # If the panels were floored to 0.5" (a too-small fig_w), cx0 can run past # the figure edge — clamp so the colorbar stays on-canvas rather than # rendering clipped/invisible. - cx0 = min(cx0, fig_w - _SQ_CBAR_W_IN - _SQ_CBAR_TXT_IN) + cx0 = min(cx0, fig_w - _SQ_CBAR_W_IN - _cbar_txt) cax = fig.add_axes( [cx0 / fig_w, bottom_in / fig_h, _SQ_CBAR_W_IN / fig_w, grid_h / fig_h] ) @@ -353,6 +369,8 @@ def plot_trajectory( colorbar_label: str = "", colorbar_unit: str = "", title: str = "", + *, + width_in: float = report_tokens.W_HALF, ) -> plt.Figure: """Plot a 2D trajectory, optionally coloured per-segment by a scalar field. @@ -379,15 +397,16 @@ def plot_trajectory( Unit string placed above the colorbar (units-only on top, saves width). title : str Figure title. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- matplotlib.figure.Figure """ - fig, axes, cax = square_axes_grid( - report_tokens.W_HALF, 1, 1, colorbar=color_data is not None - ) + fig, axes, cax = square_axes_grid(width_in, 1, 1, colorbar=color_data is not None) ax = axes[0, 0] if color_data is not None: @@ -421,7 +440,7 @@ def plot_trajectory( # datalim keeps the square box (from square_axes_grid) authoritative so the # colorbar's matched height is never invalidated. ax.set_aspect("equal", adjustable="datalim") - ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.4) + grid_despine(ax) return fig @@ -509,7 +528,7 @@ def hodograph_panel( ax.set_xlabel(f"East ({units})") ax.set_ylabel(f"North ({units})") ax.set_title(title) - ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.3) + grid_despine(ax) return sm @@ -537,7 +556,7 @@ def pressure_axis(ax: Any) -> None: """Configure *ax* as a standard pressure Y-axis: inverted, labelled, gridded.""" ax.invert_yaxis() ax.set_ylabel(params.vlabel("pressure")) - ax.grid(True, linestyle="--", linewidth=0.3, alpha=0.4) + grid_despine(ax) def colorbar_norm( diff --git a/oceanarray/plotters/spectrum.py b/oceanarray/plotters/spectrum.py index 15befb4..488cf6c 100644 --- a/oceanarray/plotters/spectrum.py +++ b/oceanarray/plotters/spectrum.py @@ -26,6 +26,7 @@ from oceanarray.utilities import _nice_colorbar_bounds, period_axis_ticks from ..analysis.spectral import gonella_rotary_spectrum from .primitives import square_axes_grid +from .helpers import grid_despine from oceanarray.config import report_tokens @@ -179,6 +180,8 @@ def draw_spectrum( lat: float = 0.0, hf_segment_days: float = 1.0, hf_x_max_days: float = 3.0, + *, + width_in: float = report_tokens.W_FULL, ) -> "Optional[plt.Figure]": """Two-panel Welch PSD of gridded temperature, one line per depth level. @@ -204,6 +207,9 @@ def draw_spectrum( hf_x_max_days: Upper x-axis limit (longest period shown) for the HF panel in days. When <= 3 the HF x-axis is displayed in hours; otherwise in days. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Notes ----- @@ -376,7 +382,7 @@ def draw_spectrum( # Square panels via the shared helper; bottom pad for the rotated HF ticks, # top pad for the figure suptitle above the per-panel titles. fig, _axes, _ = square_axes_grid( - report_tokens.W_FULL, 1, 2, colorbar=False, bottom_pad_in=0.5, top_pad_in=0.6 + width_in, 1, 2, colorbar=False, bottom_pad_in=0.5, top_pad_in=0.6 ) ax_lf, ax_hf = _axes[0, 0], _axes[0, 1] @@ -548,6 +554,8 @@ def draw_wavelet( da_temp: "xr.DataArray", dt_seconds: float, wavelet: str = "morlet", + *, + width_in: float = report_tokens.W_FULL, ) -> "Optional[plt.Figure]": """Continuous wavelet transform scalogram for gridded temperature; return a Figure. @@ -575,6 +583,9 @@ def draw_wavelet( Sample interval in seconds. wavelet: ``"morlet"`` (default, Morlet omega_0=6) or ``"mexican_hat"``. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -672,13 +683,14 @@ def draw_wavelet( from matplotlib.gridspec import GridSpec n_panels = len(results) - # height ratios: 1 part time series, 3 parts wavelet, per level - hr = [1, 3] * n_panels + # height ratios: 1 part time series (top), 2.75 parts wavelet (bottom), per + # level. The scalogram was too tall relative to its width, so it is trimmed + # ~17 % (3 -> 2.75 of the ratio) and the whole pair shortened (4.5 -> 3.8 in), + # which also takes the time series down ~10 %. + hr = [1, 2.75] * n_panels # constrained_layout crops the surrounding whitespace and places the spanning # colorbar cleanly (the encoder skips tight_layout for constrained figures). - fig = plt.figure( - figsize=(report_tokens.W_FULL, 4.5 * n_panels), layout="constrained" - ) + fig = plt.figure(figsize=(width_in, 3.8 * n_panels), layout="constrained") gs = GridSpec(2 * n_panels, 1, figure=fig, height_ratios=hr) tax: list = [] # time series axes (top of each pair) @@ -695,6 +707,7 @@ def draw_wavelet( tax[i].plot(times, ts, lw=0.6, color="0.3") tax[i].set_ylabel("T (°C)", fontsize="small") tax[i].tick_params(labelsize="small") + grid_despine(tax[i]) # Pressure level in the bottom-left corner (was a title, which overlapped # the scalogram of the pair above). tax[i].text( @@ -735,6 +748,8 @@ def draw_wavelet( def draw_grid_rotary_spectrum( ds: "xr.Dataset", lat: float = 0.0, + *, + width_in: float = report_tokens.W_FULL, ) -> "Optional[plt.Figure]": """Two-panel rotary velocity spectrum for the grid report; return a Figure. @@ -753,6 +768,9 @@ def draw_grid_rotary_spectrum( ``(time, pressure)`` dimensions. lat : float Mooring latitude (degrees, positive north) used for the inertial period marker. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -912,9 +930,7 @@ def draw_grid_rotary_spectrum( cmap_ccw = plt.get_cmap("Blues") # Square panels via the shared helper; bottom pad for the rotated ticks. - fig, _axes, _ = square_axes_grid( - report_tokens.W_FULL, 1, 2, colorbar=False, bottom_pad_in=0.5 - ) + fig, _axes, _ = square_axes_grid(width_in, 1, 2, colorbar=False, bottom_pad_in=0.5) ax_spec, ax_rot = _axes[0, 0], _axes[0, 1] # Panel 1: CW (solid, reds) + CCW (dashed, blues) diff --git a/oceanarray/plotters/timeseries.py b/oceanarray/plotters/timeseries.py index 000b9a6..2c4dc4b 100644 --- a/oceanarray/plotters/timeseries.py +++ b/oceanarray/plotters/timeseries.py @@ -43,6 +43,7 @@ pcolormesh_panel, ) from ..utilities import nice_colorbar_ticks +from .helpers import grid_despine from .. import parameters as params from oceanarray.config import report_tokens @@ -57,6 +58,8 @@ def draw_grid_fig( symmetric: bool = False, vmin: Optional[float] = None, vmax: Optional[float] = None, + *, + width_in: float = report_tokens.W_FULL, ) -> "plt.Figure": """Render a grid figure from *da* (dims time × pressure); return a Figure. @@ -79,6 +82,10 @@ def draw_grid_fig( vmin, vmax : float, optional Override the automatic percentile-based color limits. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. + Returns ------- plt.Figure @@ -91,7 +98,7 @@ def draw_grid_fig( data = da.transpose("pressure", "time").values fig, ax = plt.subplots( - figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN), layout="constrained" + figsize=(width_in, params.GRID_PANEL_ROW_IN), layout="constrained" ) bounds, norm = colorbar_norm(data, vmin=vmin, vmax=vmax, symmetric=symmetric) if style == "contourf": @@ -123,6 +130,8 @@ def draw_grid_fig( def draw_grid_hydro( ds: "xr.Dataset", var_bounds: "Optional[dict]" = None, + *, + width_in: float = report_tokens.W_FULL, ) -> "Optional[plt.Figure]": """Stacked temperature / salinity pcolormesh panels for the grid report; return a Figure. @@ -151,6 +160,10 @@ def draw_grid_hydro( When a key is present its limits are used instead of computing from the data. Intended for passing the T-S diagram axis limits so both figures share scales. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. + Returns ------- plt.Figure or None @@ -207,7 +220,7 @@ def draw_grid_hydro( fig, axes = plt.subplots( n, 1, - figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN * n), + figsize=(width_in, params.GRID_PANEL_ROW_IN * n), sharex=True, squeeze=False, layout="constrained", @@ -249,7 +262,9 @@ def draw_grid_hydro( return fig -def draw_grid_velocity_stacked(ds: "xr.Dataset") -> "Optional[plt.Figure]": +def draw_grid_velocity_stacked( + ds: "xr.Dataset", *, width_in: float = report_tokens.W_FULL +) -> "Optional[plt.Figure]": """Stacked east / north / up velocity pcolormesh panels for the grid report. All three panels share the time axis and show pressure (dbar) on the Y-axis @@ -263,6 +278,10 @@ def draw_grid_velocity_stacked(ds: "xr.Dataset") -> "Optional[plt.Figure]": ds : xr.Dataset Gridded dataset with dimensions ``(time, pressure)``. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. + Returns ------- plt.Figure or None @@ -315,7 +334,7 @@ def draw_grid_velocity_stacked(ds: "xr.Dataset") -> "Optional[plt.Figure]": fig, axes = plt.subplots( n, 1, - figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN * n), + figsize=(width_in, params.GRID_PANEL_ROW_IN * n), sharex=True, squeeze=False, layout="constrained", @@ -349,7 +368,9 @@ def draw_grid_velocity_stacked(ds: "xr.Dataset") -> "Optional[plt.Figure]": return fig -def draw_grid_sigma(ds: "xr.Dataset") -> "Optional[plt.Figure]": +def draw_grid_sigma( + ds: "xr.Dataset", *, width_in: float = report_tokens.W_FULL +) -> "Optional[plt.Figure]": """Stacked sigma0 pcolormesh panel(s) for the stratification section. Returns ``None`` when no sigma variables are present. @@ -370,7 +391,7 @@ def draw_grid_sigma(ds: "xr.Dataset") -> "Optional[plt.Figure]": fig, axes = plt.subplots( n, 1, - figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN * n), + figsize=(width_in, params.GRID_PANEL_ROW_IN * n), sharex=True, squeeze=False, layout="constrained", @@ -398,7 +419,9 @@ def draw_grid_sigma(ds: "xr.Dataset") -> "Optional[plt.Figure]": return fig -def draw_grid_n2(ds: "xr.Dataset", lat: float = 0.0) -> "Optional[plt.Figure]": +def draw_grid_n2( + ds: "xr.Dataset", lat: float = 0.0, *, width_in: float = report_tokens.W_FULL +) -> "Optional[plt.Figure]": """Compute and plot buoyancy frequency squared N² on the pressure-time grid. Returns ``None`` when temperature or salinity are absent. @@ -434,9 +457,14 @@ def draw_grid_n2(ds: "xr.Dataset", lat: float = 0.0) -> "Optional[plt.Figure]": N2_log = np.log10(np.maximum(N2, 1e-12)) fig, ax = plt.subplots( - figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN), layout="constrained" + figsize=(width_in, params.GRID_PANEL_ROW_IN), layout="constrained" ) - bounds, norm = colorbar_norm(N2_log[np.isfinite(N2_log)]) + # Clip the colorbar to the 2.5-97.5 percentiles of log10(N²) so a few extreme + # cells don't wash out the stratification structure. + _finite = N2_log[np.isfinite(N2_log)] + _lo = float(np.nanpercentile(_finite, 2.5)) if _finite.size else -12.0 + _hi = float(np.nanpercentile(_finite, 97.5)) if _finite.size else 0.0 + bounds, norm = colorbar_norm(vmin=_lo, vmax=_hi) pc = ax.pcolormesh( time_vals, p_mid_1d, N2_log, shading="nearest", cmap="plasma_r", norm=norm ) @@ -456,7 +484,9 @@ def draw_grid_n2(ds: "xr.Dataset", lat: float = 0.0) -> "Optional[plt.Figure]": return fig -def draw_grid_timeseries(ds: "xr.Dataset") -> "Optional[plt.Figure]": +def draw_grid_timeseries( + ds: "xr.Dataset", *, width_in: float = report_tokens.W_FULL +) -> "Optional[plt.Figure]": """Velocity time series at the depth of maximum time-mean current speed. Two stacked panels (shared time axis): @@ -477,6 +507,10 @@ def draw_grid_timeseries(ds: "xr.Dataset") -> "Optional[plt.Figure]": Gridded dataset with dimensions ``(time, pressure)`` containing at minimum ``east_velocity`` and ``north_velocity`` in m s⁻¹. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. + Returns ------- plt.Figure or None @@ -518,7 +552,7 @@ def draw_grid_timeseries(ds: "xr.Dataset") -> "Optional[plt.Figure]": east_ts = east[:, k_max] north_ts = north[:, k_max] - fig, axs = plt.subplots(2, 1, figsize=(report_tokens.W_FULL, 5), sharex=True) + fig, axs = plt.subplots(2, 1, figsize=(width_in, 5), sharex=True) _C_EAST = "#0072B2" _C_NORTH = "#E69F00" @@ -535,7 +569,7 @@ def draw_grid_timeseries(ds: "xr.Dataset") -> "Optional[plt.Figure]": axs[1].legend(loc="upper right", framealpha=0.8) for ax in axs: - ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.4) + grid_despine(ax) axs[-1].xaxis.set_major_formatter( mdates.ConciseDateFormatter(axs[-1].xaxis.get_major_locator()) ) @@ -548,7 +582,10 @@ def draw_grid_timeseries(ds: "xr.Dataset") -> "Optional[plt.Figure]": def draw_analog_timeseries( - nc_path: "Path", analog_vars: "List[str]" + nc_path: "Path", + analog_vars: "List[str]", + *, + width_in: float = report_tokens.W_FULL, ) -> "Optional[plt.Figure]": """Full-record time series for analog channel variables, one panel per variable. @@ -561,6 +598,10 @@ def draw_analog_timeseries( analog_vars : list of str Variable names to plot (caller must ensure the list is non-empty). + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. + Returns ------- plt.Figure or None @@ -579,7 +620,7 @@ def draw_analog_timeseries( fig, axes = plt.subplots( n_vars, 1, - figsize=(report_tokens.W_FULL, max(2.5, n_vars * 2.0)), + figsize=(width_in, max(2.5, n_vars * 2.0)), sharex=True, squeeze=False, ) @@ -616,7 +657,7 @@ def draw_analog_timeseries( ax.set_ylabel(ylabel, fontsize=7) ax.tick_params(axis="both", labelsize=7) - ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.5) + grid_despine(ax) fig.autofmt_xdate(rotation=30, ha="right") return fig diff --git a/oceanarray/plotters/ts.py b/oceanarray/plotters/ts.py index eb1cf3c..17d974a 100644 --- a/oceanarray/plotters/ts.py +++ b/oceanarray/plotters/ts.py @@ -114,7 +114,9 @@ def _ts_heatmap_panel( ax.set_title("T-S heat map") -def draw_ts_diagram(nc_path: Path) -> "Optional[plt.Figure]": +def draw_ts_diagram( + nc_path: Path, *, width_in: float = report_tokens.W_FULL +) -> "Optional[plt.Figure]": """T-S diagram from a NetCDF path; return a Figure. Scatter by pressure, 2-D count heatmap, and (when present) scatter by O2 saturation. @@ -123,6 +125,9 @@ def draw_ts_diagram(nc_path: Path) -> "Optional[plt.Figure]": ---------- nc_path : Path Path to a stage-3 NetCDF file. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -178,9 +183,7 @@ def draw_ts_diagram(nc_path: Path) -> "Optional[plt.Figure]": ncols = 3 if has_sat else 2 # Square panels with per-panel height-matched colorbars (shared deterministic # layout — same path as the hodographs/trajectories). - fig, _ax, _cax = square_axes_grid( - report_tokens.W_FULL, 1, ncols, per_panel_colorbar=True - ) + fig, _ax, _cax = square_axes_grid(width_in, 1, ncols, per_panel_colorbar=True) ax_l, ax_r = _ax[0, 0], _ax[0, 1] ax_sat = _ax[0, 2] if has_sat else None @@ -280,7 +283,9 @@ def draw_ts_diagram(nc_path: Path) -> "Optional[plt.Figure]": return fig -def draw_stack_ts_diagram(ds: "xr.Dataset") -> "Optional[plt.Figure]": +def draw_stack_ts_diagram( + ds: "xr.Dataset", *, width_in: float = report_tokens.W_FULL +) -> "Optional[plt.Figure]": """T-S diagram for a stacked dataset; return a Figure. Scatter-by-pressure, count heatmap, and (when present) scatter-by-AOU. @@ -295,6 +300,9 @@ def draw_stack_ts_diagram(ds: "xr.Dataset") -> "Optional[plt.Figure]": ---------- ds : xr.Dataset Stacked mooring dataset containing ``temperature`` and ``salinity``. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -339,9 +347,7 @@ def draw_stack_ts_diagram(ds: "xr.Dataset") -> "Optional[plt.Figure]": has_sat = SAT_flat is not None and np.isfinite(SAT_flat).any() ncols = 3 if has_sat else 2 - fig, _ax, _cax = square_axes_grid( - report_tokens.W_FULL, 1, ncols, per_panel_colorbar=True - ) + fig, _ax, _cax = square_axes_grid(width_in, 1, ncols, per_panel_colorbar=True) ax_scatter, ax_heat = _ax[0, 0], _ax[0, 1] ax_sat = _ax[0, 2] if has_sat else None @@ -445,7 +451,7 @@ def draw_stack_ts_diagram(ds: "xr.Dataset") -> "Optional[plt.Figure]": def draw_grid_ts_diagram( - ds: "xr.Dataset", n_bins: int = 60 + ds: "xr.Dataset", n_bins: int = 60, *, width_in: float = report_tokens.W_FULL ) -> "Optional[tuple[plt.Figure, dict]]": """T-S diagram for gridded mooring data; return a (Figure, bounds_dict) tuple. @@ -463,6 +469,9 @@ def draw_grid_ts_diagram( Gridded dataset with at least ``temperature`` and ``salinity`` variables. n_bins : int Number of bins per axis for the 2-D histogram. + width_in : float, optional + Figure width in inches -- the display-slot width the report builder + resolves; standalone callers get the full content width. Returns ------- @@ -501,7 +510,7 @@ def draw_grid_ts_diagram( ncols = 2 if has_o2 else 1 fig, _ax, _cax = square_axes_grid( - report_tokens.W_FULL if has_o2 else report_tokens.W_HALF, + width_in, 1, ncols, per_panel_colorbar=True, diff --git a/oceanarray/processors/coordinate.py b/oceanarray/processors/coordinate.py index 4e5e611..aaba8db 100644 --- a/oceanarray/processors/coordinate.py +++ b/oceanarray/processors/coordinate.py @@ -121,7 +121,10 @@ def _warn(msg: str) -> None: ) ) ds.attrs["magnetic_declination"] = declination - ds.attrs["magnetic_declination_units"] = "degrees_east" + # Declination is a plane angle in degrees (sign convention: positive + # east). NOT "degrees_east" — that is the CF unit for longitude. + ds.attrs["magnetic_declination_units"] = "degree" + ds.attrs["magnetic_declination_sign_convention"] = "positive_east" ds.attrs["magnetic_declination_method"] = ( "ppigrf IGRF at deployment midpoint" ) @@ -319,7 +322,10 @@ def _warn(msg: str) -> None: ds["north_velocity"].values[:] = -u * np.sin(D) + v * np.cos(D) ds.attrs["magnetic_declination"] = declination - ds.attrs["magnetic_declination_units"] = "degrees_east" + # Declination is a plane angle in degrees (sign convention: positive east). + # NOT "degrees_east" — that is the CF unit for longitude. + ds.attrs["magnetic_declination_units"] = "degree" + ds.attrs["magnetic_declination_sign_convention"] = "positive_east" ds.attrs["magnetic_declination_method"] = "ppigrf IGRF at deployment midpoint" ds.attrs["magnetic_declination_lat"] = lat ds.attrs["magnetic_declination_lon"] = lon diff --git a/oceanarray/reports/_array.py b/oceanarray/reports/_array.py index 73ef90b..779a913 100644 --- a/oceanarray/reports/_array.py +++ b/oceanarray/reports/_array.py @@ -27,6 +27,7 @@ _status, ) from ._plots import render_b64 +from ..plotters.helpers import grid_despine # --------------------------------------------------------------------------- @@ -141,7 +142,7 @@ def _draw() -> "plt.Figure": ax.set_xlabel("Longitude (°)") ax.set_ylabel("Latitude (°)") ax.set_title(array_name, fontsize=9) - ax.grid(True, linestyle="--", linewidth=0.3, alpha=0.5) + grid_despine(ax) plt.tight_layout() return fig diff --git a/oceanarray/reports/_env.py b/oceanarray/reports/_env.py index 992f566..751b4b1 100644 --- a/oceanarray/reports/_env.py +++ b/oceanarray/reports/_env.py @@ -18,6 +18,8 @@ from jinja2 import Environment, FileSystemLoader from . import _figdebug +from . import _slots +from ._css import emit_css from .. import parameters as params #: Directory holding the report page templates. @@ -33,6 +35,16 @@ #: Per-figure debug lookup (``func · figsize · png``) for templates' ``.debug`` #: sections; returns "" unless ``OCEANARRAY_REPORT_DEBUG`` is set. _ENV.globals["figdbg"] = _figdebug.figdbg +#: Display slot recorded for each figure (``slot_for(b64) -> slot name``), so a +#: template can pick its ``.slot-*`` width class from the same slot the figure was +#: rendered at (U0.2). +_ENV.globals["slot_for"] = _slots.slot_for +#: Generated stylesheet — the single source of truth for report CSS (tokens, +#: type/spacing scale, slot classes, shared chrome). base.html injects it via +#: ``{{ css | safe }}``; page-specific rules live in a small local block that +#: references these token variables (U0.1). The vendored ``_css.py`` is called, +#: never edited. +_ENV.globals["css"] = emit_css(params.PACKAGE_NAME) def render_template(name: str, /, **context: Any) -> str: diff --git a/oceanarray/reports/_html_helpers.py b/oceanarray/reports/_html_helpers.py index 73fc2cb..bf31e16 100644 --- a/oceanarray/reports/_html_helpers.py +++ b/oceanarray/reports/_html_helpers.py @@ -29,6 +29,38 @@ # helpers # --------------------------------------------------------------------------- +#: Full ISO timestamp with fractional seconds and an optional timezone suffix, +#: e.g. ``2026-07-11T20:30:00.000000000`` or ``2026-07-11T20:30:00.5+00:00``. The +#: date part is anchored (``^\d{4}-...``) so a non-timestamp string that merely +#: ends in ``T HH:MM:SS.digits`` is NOT matched and is left untouched. +_ISO_FRAC_TS_RE = re.compile( + r"^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)\.(\d+)(Z|[+-]\d\d:?\d\d)?$" +) + + +def _round_ts_for_display(value: str) -> str: + """Round a fractional-second ISO timestamp string to 0.1 s for the report. + + Deployment/recovery times are stored at nanosecond precision (from + ``datetime64[ns]``), which is spurious detail in the report's global-attribute + table. Only strings that are a full ISO timestamp (optionally timezone-suffixed) + are touched; everything else — and timestamps with no fractional seconds — is + returned unchanged. Fractions that round up past ``.95`` carry into the next + second (via ``numpy.datetime64``) rather than clamping. This affects the report + display only, not the saved file. + """ + m = _ISO_FRAC_TS_RE.match(value) + if not m: + return value + head, frac, tz = m.group(1), m.group(2), m.group(3) or "" + tenths = int(round(int(frac) / 10 ** len(frac) * 10)) # 0..10 + if tenths >= 10: # carry into the next second + try: + return f"{np.datetime64(head, 's') + np.timedelta64(1, 's')}{tz}" + except ValueError: + return value + return f"{head}{tz}" if tenths == 0 else f"{head}.{tenths}{tz}" + def _safe_serial(serial: Any) -> str: """Return a filename-safe serial token (see :func:`oceanarray.paths.safe_serial`).""" @@ -301,7 +333,11 @@ def _read_nc_metadata(nc_path: Path) -> Dict[str, Any]: info["is_qc"] = vname in qc_vars time_vars.append(info) - global_attrs = {k: str(val) for k, val in ds.attrs.items() if k != "history"} + global_attrs = { + k: _round_ts_for_display(str(val)) + for k, val in ds.attrs.items() + if k != "history" + } dims = {str(d): int(s) for d, s in ds.sizes.items()} ds.close() return { diff --git a/oceanarray/reports/_mooring.py b/oceanarray/reports/_mooring.py index daac1a4..beb7ff1 100644 --- a/oceanarray/reports/_mooring.py +++ b/oceanarray/reports/_mooring.py @@ -31,6 +31,7 @@ _stage_files, _status, ) +from . import _figdebug, _slots from ._env import render_template from ._grid import generate_grid_page from ._instrument import generate_instrument_pages @@ -216,6 +217,12 @@ def generate( grid: bool = False, stack: bool = False, ) -> Optional[Path]: + # Reset the per-figure debug capture and the b64->slot registry at the + # start of each report so a long-lived process (a batch of moorings) does + # not accumulate them unbounded, nor leak a slot recorded for one report + # onto a byte-identical figure in the next. + _figdebug.clear() + _slots.clear() proc_dir = self._resolve_proc_dir(mooring_name) if not proc_dir.exists(): print(f"ERROR: Processing directory not found: {proc_dir}") diff --git a/oceanarray/reports/_plots.py b/oceanarray/reports/_plots.py index 90f9cf5..c6087fd 100644 --- a/oceanarray/reports/_plots.py +++ b/oceanarray/reports/_plots.py @@ -15,6 +15,7 @@ from ._html_helpers import _QC_MARKER, _QC_LABELS from ._figdebug import render_b64 +from ._slots import render as render_slot from ..config import report_tokens from ..plotters.primitives import ( date_axis, @@ -137,7 +138,7 @@ def _plot_aquadopp_quick(ds: "xr.Dataset") -> "plt.Figure": if "velocity" in vname: ax.axhline(0, color="k", linewidth=0.5, linestyle="--") ax.set_ylabel(label) - ax.grid(True) + grid_despine(ax) if invert: vmin = float(ds[vname].min()) vmax = float(ds[vname].max()) @@ -474,7 +475,7 @@ def _make_grid_fig_b64( vmax: Optional[float] = None, ) -> Optional[str]: """Render a grid figure from *da* (dims time × pressure); return base64 PNG or None.""" - return render_b64( + return render_slot( draw_grid_fig, da, title, @@ -490,7 +491,7 @@ def _make_grid_fig_b64( def _make_grid_sigma_b64(ds: "xr.Dataset") -> Optional[str]: """Stacked sigma0 pcolormesh panel(s) for the stratification section.""" - return render_b64(draw_grid_sigma, ds, optional=True) + return render_slot(draw_grid_sigma, ds, optional=True) def _make_grid_hydro_b64( @@ -498,12 +499,12 @@ def _make_grid_hydro_b64( var_bounds: "Optional[dict]" = None, ) -> Optional[str]: """Return base64 PNG: stacked temperature / salinity pcolormesh panels.""" - return render_b64(draw_grid_hydro, ds, var_bounds, optional=True) + return render_slot(draw_grid_hydro, ds, var_bounds, optional=True) def _make_grid_velocity_stacked_b64(ds: "xr.Dataset") -> Optional[str]: """Stacked east / north / up velocity pcolormesh panels for the grid report.""" - return render_b64(draw_grid_velocity_stacked, ds, optional=True) + return render_slot(draw_grid_velocity_stacked, ds, optional=True) def _make_spectrum_fig_b64( @@ -563,15 +564,18 @@ def _make_grid_ts_diagram( """Return (b64_str_or_None, bounds_dict): T-S diagram for gridded mooring data.""" ts_bounds: dict = {} - def _draw() -> "Optional[plt.Figure]": - result = draw_grid_ts_diagram(ds, n_bins) + def _draw(*, width_in: float = report_tokens.W_FULL) -> "Optional[plt.Figure]": + result = draw_grid_ts_diagram(ds, n_bins, width_in=width_in) if result is None: return None nonlocal ts_bounds fig, ts_bounds = result return fig - return render_b64(_draw, optional=True), ts_bounds + # Displayed at half width (template slot-half); rendering at that width keeps + # the PNG px == display px. With O₂ the diagram gains a panel but the page + # still shows it at half — same as before U0.2, now without the oversample. + return render_slot(_draw, slot="half", optional=True), ts_bounds def _make_velocity_iqr_profile_b64(ds: "xr.Dataset") -> Optional[str]: @@ -581,7 +585,7 @@ def _make_velocity_iqr_profile_b64(ds: "xr.Dataset") -> Optional[str]: def _make_grid_n2_b64(ds: "xr.Dataset", lat: float = 0.0) -> Optional[str]: """Compute and plot buoyancy frequency squared N² on the pressure-time grid.""" - return render_b64(draw_grid_n2, ds, lat, optional=True) + return render_slot(draw_grid_n2, ds, lat, optional=True) def _make_rose_grid_b64( @@ -609,7 +613,7 @@ def _make_grid_rose_b64(ds: "xr.Dataset", max_roses: int = 4) -> Optional[str]: def _make_grid_trajectory_b64(ds: "xr.Dataset") -> Optional[str]: """Pseudo-Lagrangian trajectory by pressure level for the grid report.""" - return render_b64(draw_grid_trajectory, ds, optional=True) + return render_slot(draw_grid_trajectory, ds, slot="half", optional=True) def _make_grid_timeseries_b64(ds: "xr.Dataset") -> Optional[str]: @@ -697,7 +701,7 @@ def _make_aquadopp_speed_profile(ds: "xr.Dataset") -> Optional[str]: """ from oceanarray.plotters.current import plot_aquadopp_speed_profile - return render_b64(plot_aquadopp_speed_profile, ds, optional=True) + return render_slot(plot_aquadopp_speed_profile, ds, slot="half", optional=True) def _make_adcp_trajectories_b64(ds: "xr.Dataset") -> Optional[str]: diff --git a/oceanarray/reports/_slots.py b/oceanarray/reports/_slots.py new file mode 100644 index 0000000..9ea2a8b --- /dev/null +++ b/oceanarray/reports/_slots.py @@ -0,0 +1,84 @@ +"""Package-local slot layer: the display slot travels with each figure. + +A report figure is rendered at the inch-width of the display *slot* the template +will place it in, and the same slot name is read back by the template to pick the +matching ``.slot-*`` CSS class. One slot decision -- taken at the L4 figure +builder in :mod:`oceanarray.reports._plots` -- drives both the rendered PNG width +and the on-page width, so the PNG pixel width equals the display width (no +oversample-then-downscale mismatch). + +:func:`render` resolves ``report_tokens.SLOTS[slot]`` to inches, forwards that +width to the draw function as ``width_in``, delegates the actual encode to the +figure-debug wrapper (which delegates to the vendored encoder -- its signature is +untouched), and records the slot under the returned base64 string. Templates +call the :func:`slot_for` Jinja global to read the slot back. +""" + +from __future__ import annotations + +from typing import Any, Callable, Optional + +from . import _figdebug +from ..config import report_tokens + +#: Display slot chosen for each figure, keyed by its base64 PNG string. Always +#: populated (not debug-gated) because templates read it back for the CSS class. +_SLOT_BY_B64: dict[str, str] = {} + + +def render( + draw: Callable[..., Any], + /, + *args: Any, + slot: str = "full", + optional: bool = False, + **kwargs: Any, +) -> Optional[str]: + """Render *draw* at the width of *slot* and record the slot for the template. + + Resolves ``report_tokens.SLOTS[slot]`` to an inch width, forwards it to + *draw* as the ``width_in`` keyword, and records *slot* under the returned + base64 string so :func:`slot_for` can read it back. + + Parameters + ---------- + draw : callable + A ``draw_*`` function (or ``_make_*`` closure) that accepts ``width_in`` + and returns a Figure or ``None``. + *args, **kwargs + Forwarded to *draw*. + slot : str + A key of :data:`report_tokens.SLOTS` (default ``"full"``). + optional : bool + Passed through to the encoder (see + :func:`oceanarray.reports._encode.render_b64`). + + Returns + ------- + str or None + Base64-encoded PNG, or ``None`` when *draw* returned ``None`` or raised. + + """ + width_in = report_tokens.SLOTS[slot][1] + b64 = _figdebug.render_b64( + draw, *args, width_in=width_in, optional=optional, **kwargs + ) + if b64: + _SLOT_BY_B64[b64] = slot + return b64 + + +def slot_for(b64: Optional[str]) -> str: + """Return the display slot recorded for figure *b64*, or ``"full"``. + + Registered as a Jinja global so a template can pick the ``.slot-*`` class: + ``class="fig slot-{{ slot_for(fig_x_b64) }}"``. + """ + if not b64: + return "full" + return _SLOT_BY_B64.get(b64, "full") + + +def clear() -> None: + """Drop all recorded figure slots (call at the start of a page build).""" + _SLOT_BY_B64.clear() diff --git a/oceanarray/reports/_stack.py b/oceanarray/reports/_stack.py index 1af0f38..c960ca8 100644 --- a/oceanarray/reports/_stack.py +++ b/oceanarray/reports/_stack.py @@ -34,7 +34,7 @@ render_b64, ) from .. import parameters as params -from ..plotters.helpers import ordered_line_colors +from ..plotters.helpers import grid_despine, ordered_line_colors from ..plotters.primitives import date_offset_left from oceanarray.config import report_tokens @@ -213,11 +213,12 @@ def _draw() -> "plt.Figure": ax_sc.set_axis_off() if _n_dropped: + # No explicit y: constrained_layout reserves space for the suptitle + # above the panels, so it no longer overprints the top panel's title. fig.suptitle( f"Showing {_MAX_TILT_ROWS} deepest Aquadopps " f"({_n_dropped} more not shown)", fontsize=report_tokens.ANNOT_FS, - y=0.995, ) return fig @@ -330,7 +331,7 @@ def _ts_fig( arr[qc >= 3] = np.nan _serial_colors, _serial_styles = _var_line_styling(varname) with plt.style.context(str(params.MPLSTYLE)): - fig, ax = plt.subplots(figsize=(report_tokens.W_FULL, 3.2)) + fig, ax = plt.subplots(figsize=(report_tokens.W_FULL, 2.56)) plotted = False for i in range(n_instr): if exclude_types and instr_types[i].lower() in exclude_types: @@ -375,7 +376,7 @@ def _ts_fig( date_offset_left(ax) ax.set_ylabel(ylabel) ax.set_xlabel("Time") - ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.3) + grid_despine(ax) if _t_cov_start and _t_cov_end: try: ax.set_xlim( @@ -436,9 +437,6 @@ def _ts_fig( ) fig_rose_grid_b64, _n_rose = _make_rose_grid_b64(ds, _serial_list) - # Width cap: 33% for 1 panel, 50% for 2, 66% for 3, 83% for 4, 100% for 5+ - _rose_w_map = {1: "33", 2: "50", 3: "66", 4: "83"} - rose_img_width = _rose_w_map.get(_n_rose, "100") _decl_vals: list = [] _decl_missing = False @@ -590,7 +588,6 @@ def _ts_fig( fig_turbidity_b64=fig_turbidity_b64, fig_dissolved_oxygen_b64=fig_dissolved_oxygen_b64, fig_rose_grid_b64=fig_rose_grid_b64, - rose_img_width=rose_img_width, rose_declination_note=rose_declination_note, rose_declination_warn=rose_declination_warn, rose_declination_missing_serials=rose_declination_missing_serials, diff --git a/oceanarray/reports/templates/base.html b/oceanarray/reports/templates/base.html index 41fa738..288610f 100644 --- a/oceanarray/reports/templates/base.html +++ b/oceanarray/reports/templates/base.html @@ -5,69 +5,70 @@
Left: log₁₀(count+1) per T-S bin. Right (when O₂ present): median O₂ saturation per T-S bin — bins with <5 samples masked. Colour scale: BrBG (brown=low, teal=high).
Pseudo-Lagrangian trajectories at each gridded pressure level, integrated from east and north velocity (Euler forward). All start at the origin. Coloured by pressure (dbar).
+
Time–range colour plots of the four velocity components (east, north, up, error). Y-axis: range from transducer (m). Colour scale: symmetric about zero; percentile 2/98 of all components combined. @@ -144,17 +145,17 @@
+
Left panel = first 7 h (1 h lead-in + 6 h of record) |
Right panel = last 7 h (6 h of record + 1 h tail).
- ││ orange dashed = auto-detected
+ ││ orange dashed = auto-detected
(pressure-based) suggested deploy/recover time •
- ││ green dashed = YAML
+ ││ green dashed = YAML
deployment_time / recovery_time •
- ││ grey = stage 1 raw record
+ ││ grey = stage 1 raw record
(same variable, same units).
+
Reading the grey background: where the raw stage 1 record is available and uses the same physical units as the stage 2/3 data, it is shown in light grey so you can @@ -173,7 +174,7 @@
+
Coloured by pressure (or sample index). × = suspect | × = bad (QC flags).
+
Depth-average followed by bins nearest 100, 200, 300 and 400 m from the transducer. Bins with fewer than two valid samples are omitted. Direction toward which the current flows; 0° = N, clockwise. @@ -213,12 +214,12 @@
+
⚠ Magnetic declination could not be applied — latitude/longitude are missing or all-zero in the mooring YAML (check seabed_latitude, deployment_latitude, or latitude/longitude).
ENU velocities use 0° declination (magnetic north, not true north).
+
{% if rose_has_xyz %}XYZ: instrument-frame velocities (before geographic rotation). {% endif %}ENU panels split by QARTOD flag: good (flag ≤ 2, Blues), suspect (flag 3, Oranges), fail (flag 4, Reds). Direction toward which the current flows; 0° = N, clockwise.
@@ -229,7 +230,7 @@+
Pseudo-Lagrangian displacement obtained by integrating east/north velocity over time (Euler forward; NaN velocities set to zero). Coloured by temperature. Origin (0, 0) = deployment position; axes in metres. @@ -241,7 +242,7 @@
+
East vs north velocity (m s-1). Left: full record. Right: eddy component — raw minus 4-day low-pass (rolling mean). @@ -266,7 +267,7 @@
{{ ch.varname }} — from YAML:
@@ -283,7 +284,7 @@ +
Orange dashed = suspect threshold | Red dotted = fail threshold (gross-range QC). Histogram shows non-bad data only; bad-flagged count noted in red.
@@ -396,7 +397,7 @@+
Raw = file present in raw directory •
Read = format check passed •
Stage 1–3 = processed NetCDF files exist •
Stack = mooring-level _stack.nc •
Grid = pressure-gridded _grid.nc
+
Instruments marked skip: true in the YAML are excluded from all
processing and are shown with a skipped note in the Note column.
Stack and Grid pills are grey for any instrument that did not reach Stage 3.
@@ -154,7 +154,7 @@
+
Variables and record length are read from stage 3 files where available, otherwise stage 2. The P badge is shown as present whether pressure was directly measured or interpolated from neighbouring @@ -219,7 +219,7 @@
- * Δt mismatch (YAML vs observed p90): +
+ * Δt mismatch (YAML vs observed p90):
{% for instr in dt_mismatches %}
{{ instr.serial }}:
YAML gives Δt of {{ instr.yaml_interval_s }} s;
@@ -267,32 +267,32 @@
+
Recommended pressure range for oceanarray grid, derived from the
min/max pressure across all instruments (rounded outward to the nearest 20 dbar):
--pmin {{ grid_p_start }} --pmax {{ grid_p_end }}
+--pmin {{ grid_p_start }} --pmax {{ grid_p_end }}
{% endif %}
+
Stage 1 and Sugg. (raw) columns are in the raw
instrument clock (uncorrected). Sugg. UTC columns
apply the constant clock offset (and drift, for the end) and are safe to
paste into the YAML deployment_time /
recovery_time fields. Suggested UTC cells are highlighted
- amber when the
+ amber when the
pressure-derived suggested time differs from the YAML time by more than
2 Δt (indicating a sinking/rising transient was detected).
The Stage 1 last cell is highlighted
- orange when the
+ orange when the
raw record ended more than 2 Δt before the YAML recovery time
(instrument may have stopped early).
Serial numbers link to the per-instrument report; the
6 h link jumps directly to the start/end window plots.
+
Spot-check recommended: open the 6 h start/end window plots for each instrument (click 6 h) and visually confirm that the orange suggested line (or green YAML line if they match) @@ -306,8 +306,8 @@
{{ instr.serial }}
6 h
{% if tm %}
@@ -339,26 +339,26 @@ +
Deployment times — pressure-based instruments suggest times that differ from the current YAML. Copy the suggested snippet (blue border) into your YAML file. Times are given to the minute.
+
Current YAML
+
Suggested (copy → paste into YAML)
+
✓ Current YAML times match the pressure-based suggestion from the instruments.
-+
Current YAML times
{% endif %} @@ -433,7 +433,7 @@+
Positive drift/offset = instrument was slow (behind UTC); correction shifts times later. Negative = instrument was fast (ahead of UTC); correction shifts times earlier.
@@ -594,12 +594,12 @@Nominal HAB (x) vs. measured pressure (y). The dashed line shows expected pressure (water depth − HAB). Instruments below the line were knocked down. Interpolated pressure (QC flag 8) excluded.
-