From 1f78d1a8b5cf51245f44025f7095df4efffd09d4 Mon Sep 17 00:00:00 2001 From: Eleanor Frajka-Williams Date: Fri, 14 Aug 2026 22:41:11 +0200 Subject: [PATCH 1/4] fix: unifying the plot encoding and visuals --- .gitattributes | 3 + oceanarray/config/parameters.py | 19 +-- oceanarray/config/report_tokens.py | 33 ++++ oceanarray/plotters/current.py | 43 +++--- oceanarray/plotters/diagnostic.py | 36 +++-- oceanarray/plotters/hydrography.py | 7 +- oceanarray/plotters/primitives.py | 14 +- oceanarray/plotters/spectrum.py | 8 +- oceanarray/plotters/ts.py | 7 +- oceanarray/reports/_encode.py | 2 + oceanarray/reports/_plots.py | 144 ++++-------------- oceanarray/reports/_stack.py | 35 ++++- oceanarray/reports/templates/base.html | 3 + oceanarray/reports/templates/grid.html | 2 +- oceanarray/reports/templates/instrument.html | 4 +- oceanarray/reports/templates/stack.html | 1 + tests/conftest.py | 9 +- .../dune2/dune2_1_2026_grid_report.html | 5 +- .../golden/dune2/dune2_1_2026_report.html | 3 + .../dune2/dune2_1_2026_stack_report.html | 48 +++--- .../instrument/dune2_1_2026_2941_report.html | 7 +- .../instrument/dune2_1_2026_9920_report.html | 7 +- tests/unit/test_plot_guard.py | 39 ++--- 23 files changed, 229 insertions(+), 250 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..66fbf06 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Generated report HTML: collapse in diffs, exclude from GitHub language stats. +tests/fixtures/golden/** linguist-generated=true +docs/source/_static/demo/** linguist-generated=true diff --git a/oceanarray/config/parameters.py b/oceanarray/config/parameters.py index ec035b7..9fe18ad 100644 --- a/oceanarray/config/parameters.py +++ b/oceanarray/config/parameters.py @@ -38,22 +38,9 @@ FIGURE_SIZE_WIDE = (14, 6) # multi-instrument overview (scatter / line) FIGURE_SIZE_TALL = (12, 8) # stacked single-instrument panels -# --------------------------------------------------------------------------- -# Report figure slot widths (inches). The HTML report body is ~1150-1200px -# wide; figures display at full/two-thirds/half/third of that slot via the -# `.fig` CSS max-width caps. Rendering a figure at the matching slot WIDTH -# makes displayed font size depend only on figsize (font_px = pt/72 x -# display_px/fig_in) — independent of dpi — so mplstyle fonts appear at a -# consistent, readable size instead of the double-shrink that oversized (12-16 -# in) figsizes caused at half/third slots. Set figure *width* to one of these; -# height is content-driven. (dpi is a separate size knob, set once in the -# mplstyle's savefig.dpi; report figure bytes are cut mainly by palette -# quantization in _fig_to_base64, not by dpi.) -# --------------------------------------------------------------------------- -W_FULL = 9.0 # full-slot figure (CSS max-width 100%) -W_TWOTHIRDS = 6.0 # two-thirds slot (~66%) -W_HALF = 4.5 # half slot (~50%) -W_THIRD = 3.0 # one-third slot (~33%) +# Report figure slot widths (W_FULL / W_TWOTHIRDS / W_HALF / W_THIRD, inches) now +# live in ``oceanarray.config.report_tokens`` as the single source of truth +# (derived from SLOTS; spec §11). Import them from there, not from here. # --------------------------------------------------------------------------- # Resampling diff --git a/oceanarray/config/report_tokens.py b/oceanarray/config/report_tokens.py index f3bfb98..8750523 100644 --- a/oceanarray/config/report_tokens.py +++ b/oceanarray/config/report_tokens.py @@ -95,6 +95,39 @@ ANNOT_FS: int = 8 # in-axes annotation / panel-label text boxes CAST_LABEL_FS: int = 6 # dense in-axes cast-number labels on maps and sections +# --------------------------------------------------------------------------- +# Figure line widths — GMT named pen-width ladder (points) +# --------------------------------------------------------------------------- +# matplotlib linewidths are in points, the same unit as GMT's pen widths, so +# plotters name line weights from GMT's ladder (``pen("thin")``) instead of +# hardcoding numbers. Values verified against the GMT line tutorial: +# https://www.generic-mapping-tools.org/gmt-examples/tutorials/basics/line.html +# The "no stray typography" test allow-lists the pen() helper. +GMT_PEN: dict[str, float] = { + "faint": 0.0, + "thinnest": 0.25, + "default": 0.25, + "thinner": 0.5, + "thin": 0.75, + "thick": 1.0, + "thicker": 1.5, + "thickest": 2.0, + "fat": 3.0, + "fatter": 6.0, + "fattest": 10.0, + "wide": 18.0, +} + + +def pen(name: str) -> float: + """Return a matplotlib linewidth in points for a GMT pen-width *name*. + + Borrows GMT's named pen ladder (``faint`` … ``wide``) so plotters name line + weights (``lw=pen("thin")``) rather than hardcoding numbers. + """ + return GMT_PEN[name] + + # --------------------------------------------------------------------------- # Spacing and radii (spec §15) [data; applied by emit_css() in rep/vis-system] # --------------------------------------------------------------------------- diff --git a/oceanarray/plotters/current.py b/oceanarray/plotters/current.py index 5c9587c..fc402c2 100644 --- a/oceanarray/plotters/current.py +++ b/oceanarray/plotters/current.py @@ -39,6 +39,7 @@ from oceanarray.plotters.helpers import _rose_ax, _velocity_panel_style from oceanarray.utilities import _nice_colorbar_bounds from oceanarray import parameters as params +from oceanarray.config import report_tokens def plot_temperature_trajectory( @@ -144,7 +145,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=(params.W_THIRD, 6)) + fig, ax = plt.subplots(figsize=(report_tokens.W_THIRD, 3.5)) bp = ax.boxplot( speed_clean, vert=True, @@ -249,7 +250,7 @@ def plot_multi_aquadopp_trajectories( _bounds = _nice_colorbar_bounds(0.0, 1.0, n=20) norm: mcolors.BoundaryNorm = mcolors.BoundaryNorm(_bounds, ncolors=256) - fig, ax = plt.subplots(figsize=(params.W_FULL, 5), constrained_layout=True) + fig, ax = plt.subplots(figsize=(report_tokens.W_FULL, 5), constrained_layout=True) for instr_i, x, y, temp in trajs: serial = str(serials[instr_i]) @@ -367,12 +368,13 @@ def plot_hodograph( import pandas as pd instr_id = ds.attrs.get("id", "") - # constrained_layout places the shared colorbar correctly against the - # equal-aspect ("box"-adjustable) panels; without it the colorbar overlaps - # the panel whitespace and appears to sit inside the axes. - fig, axes = plt.subplots( - 1, 2, figsize=(params.W_FULL, 4.5), constrained_layout=True - ) + # Hand-managed layout: each panel appends its own colorbar via + # make_axes_locatable (so the bar tracks the equal-aspect square axes), which + # does not compose with constrained_layout — so lay out manually and mark the + # figure so the encoder skips tight_layout. + fig, axes = plt.subplots(1, 2, figsize=(report_tokens.W_FULL, 4.5)) + fig._manual_layout = True # noqa: SLF001 — encoder layout opt-out (see _encode._manages_own_layout) + fig.subplots_adjust(left=0.07, right=0.97, top=0.88, bottom=0.12, wspace=0.32) if instr_id: fig.suptitle(instr_id) @@ -567,7 +569,9 @@ 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=(params.W_HALF, max(3, len(records) * 0.7 + 1))) + fig, ax = plt.subplots( + figsize=(report_tokens.W_HALF, max(3, len(records) * 0.7 + 1)) + ) for hab, serial, spd_clean in records: bp = ax.boxplot( @@ -704,7 +708,7 @@ def plot_adcp_trajectories( _bounds = _nice_colorbar_bounds(hab_min, hab_max, n=_n_hab) norm: mcolors.BoundaryNorm = mcolors.BoundaryNorm(_bounds, ncolors=256) - fig, ax = plt.subplots(figsize=(params.W_FULL, 5)) + fig, ax = plt.subplots(figsize=(report_tokens.W_FULL, 5)) for hab, x, y in trajs: points = np.array([x, y]).T.reshape(-1, 1, 2) @@ -806,13 +810,16 @@ def _masked(flag_mask: "np.ndarray") -> "tuple[np.ndarray, np.ndarray]": fig, axs = plt.subplots( 1, ncols, - figsize=(params.W_TWOTHIRDS, 3.2), + figsize=(report_tokens.W_FULL, report_tokens.W_FULL / max(ncols, 1) + 0.4), subplot_kw={"projection": "polar"}, squeeze=False, ) for ax, (east, north, title, cmap) in zip(axs[0], panels): _rose_ax(ax, east, north, title=title, cmap=cmap) + # Polar figures skip the encoder's tight_layout, so set panel spacing here; + # more wspace gives the roses room left-to-right. + fig.subplots_adjust(wspace=0.5) return fig @@ -914,7 +921,7 @@ def draw_rose_grid( fig, axs = plt.subplots( nrows, ncols, - figsize=(params.W_FULL, nrows * 3.2), + figsize=(report_tokens.W_FULL, nrows * 3.2), subplot_kw={"projection": "polar"}, squeeze=False, ) @@ -991,7 +998,7 @@ def draw_grid_rose(ds: "xr.Dataset", max_roses: int = 4) -> "Optional[plt.Figure fig, axs = plt.subplots( nrows, ncols, - figsize=(params.W_FULL, nrows * 3.2), + figsize=(report_tokens.W_FULL, nrows * 3.2), subplot_kw={"projection": "polar"}, squeeze=False, ) @@ -1058,7 +1065,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, ax = plt.subplots(figsize=(params.W_HALF, 5)) + fig, ax = plt.subplots(figsize=(report_tokens.W_HALF, 5)) for p_val, x, y in trajs: points = np.array([x, y]).T.reshape(-1, 1, 2) @@ -1226,7 +1233,7 @@ def draw_adcp_velocity(nc_path: str) -> "Optional[plt.Figure]": n = len(present) fig, axes = plt.subplots( - n, 1, figsize=(params.W_FULL, 3.5 * n), sharex=True, squeeze=False + n, 1, figsize=(report_tokens.W_FULL, 3.5 * n), sharex=True, squeeze=False ) orientation = ds.attrs.get("orientation_yaml") or ds.attrs.get( @@ -1377,7 +1384,7 @@ def draw_adcp_rose(nc_path: str) -> "Optional[plt.Figure]": fig, axs = plt.subplots( 1, ncols, - figsize=(params.W_FULL, 4.0), + figsize=(report_tokens.W_FULL, 4.0), subplot_kw={"projection": "polar"}, squeeze=False, ) @@ -1508,7 +1515,7 @@ def draw_adcp_hodograph( from oceanarray.reports._plots import _draw_hodograph_pair - fig, axes = plt.subplots(2, 2, figsize=(params.W_FULL, 9)) + fig, axes = plt.subplots(2, 2, figsize=(report_tokens.W_FULL, 9)) fig.subplots_adjust(hspace=0.55, wspace=0.45) _draw_hodograph_pair( @@ -1616,7 +1623,7 @@ def draw_grid_hodograph( smooth_n = max(3, int(round(smooth_hours * 3600.0 / dt_s))) fig, (ax_shallow, ax_deep) = plt.subplots( - 1, 2, figsize=(params.W_FULL, 4.5), constrained_layout=True + 1, 2, figsize=(report_tokens.W_FULL, 4.5), constrained_layout=True ) for ax, i_lev, label in [ diff --git a/oceanarray/plotters/diagnostic.py b/oceanarray/plotters/diagnostic.py index f906947..715df73 100644 --- a/oceanarray/plotters/diagnostic.py +++ b/oceanarray/plotters/diagnostic.py @@ -43,6 +43,7 @@ import numpy as np from .. import parameters as params +from oceanarray.config import report_tokens if TYPE_CHECKING: import matplotlib.figure @@ -138,11 +139,16 @@ def _instrument_panels( do_combo = combine_pitch_roll and "pitch" in time_vars and "roll" in time_vars out = [] + is_velocity_instrument = has_enu or bool(beam_vars & time_vars) for vname, label, color, invert in _CANONICAL_PANELS: if vname not in time_vars: continue if has_enu and vname in beam_vars: continue + # Aquadopps/ADCPs record speed_of_sound internally for the velocity + # solution, but it is not a science output — drop the panel for them. + if vname == "speed_of_sound" and is_velocity_instrument: + continue if do_combo: if vname == "pitch": out.append(("_pitch_roll_combo", "Pitch & Roll (°)", None, False)) @@ -776,7 +782,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=(params.W_FULL, 3.5), sharey=False + 1, n_panels, figsize=(report_tokens.W_FULL, 3.5), sharey=False ) if n_panels == 1: axes = [axes] @@ -799,9 +805,8 @@ def plot_clock_offset_check( plotted_serials.add(serial) ax.set_title(title) - ax.set_xlabel("Time (UTC)") ax.set_ylabel("Normalised temperature (std)") - ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.3) + ax.grid(True) locator = mdates.AutoDateLocator() ax.xaxis.set_major_locator(locator) ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator)) @@ -818,12 +823,15 @@ def plot_clock_offset_check( handles=handles, loc="lower center", ncol=min(len(handles), 6), - bbox_to_anchor=(0.5, -0.05), + bbox_to_anchor=(0.5, 0.01), frameon=True, ) - plt.tight_layout() - fig.subplots_adjust(bottom=0.18) + # Reserve space for the below-axes legend and keep it: mark the figure + # manual so the encoder does not re-run tight_layout and undo the reserve + # (which clipped the legend and overspilled the slot at full-canvas save). + fig.subplots_adjust(bottom=0.24, top=0.9, left=0.08, right=0.97, wspace=0.22) + fig._manual_layout = True # noqa: SLF001 — encoder layout opt-out return fig @@ -919,7 +927,7 @@ def draw_windows( for vname, *_ in panels ] nrows = len(panels) - fig = plt.figure(figsize=(params.W_FULL, sum(height_ratios))) + fig = plt.figure(figsize=(report_tokens.W_FULL, sum(height_ratios))) gs = GridSpec( nrows, 2, @@ -1198,8 +1206,10 @@ def draw_data_histogram(nc_path: Path) -> "Optional[plt.Figure]": fig, axs_grid = plt.subplots( nrows, ncols, - figsize=(ncols * 4.5, 2.5 * nrows), + figsize=(report_tokens.W_FULL, 2.5 * nrows), squeeze=False, + sharey=True, + layout="constrained", ) axs = axs_grid.ravel() for k in range(len(plot_panels), len(axs)): @@ -1284,7 +1294,9 @@ def draw_data_histogram(nc_path: Path) -> "Optional[plt.Figure]": label="kept", ) ax.set_yscale("log") - ax.set_ylabel(f"{ylabel}\n(log count)") + ax.set_xlabel(ylabel) + if ax.get_subplotspec().is_first_col(): + ax.set_ylabel("log₁₀(count)") s_min = s_max = f_min = f_max = None if has_qc: @@ -1353,10 +1365,6 @@ def draw_data_histogram(nc_path: Path) -> "Optional[plt.Figure]": color="#e74c3c", ) - for ax in axs_grid[-1]: - if ax.get_visible(): - ax.set_xlabel("Value") - fig.suptitle("Data value distributions (grey = all, blue = kept)", y=1.01) return fig @@ -1443,7 +1451,7 @@ def draw_velocity_iqr_profile(ds: "xr.Dataset") -> "Optional[plt.Figure]": fig, axs = plt.subplots( 1, n_panels, - figsize=(params.W_FULL, 4.5), + figsize=(report_tokens.W_FULL, 4.5), sharey=True, gridspec_kw={"width_ratios": [2] * (n_panels - 1) + [1]}, ) diff --git a/oceanarray/plotters/hydrography.py b/oceanarray/plotters/hydrography.py index feaa287..f50cc2f 100644 --- a/oceanarray/plotters/hydrography.py +++ b/oceanarray/plotters/hydrography.py @@ -20,6 +20,7 @@ from .primitives import colorbar_norm, date_axis from .. import parameters as params +from oceanarray.config import report_tokens def draw_isopycnal_ts_fig(ds_iso: "xr.Dataset") -> "Optional[plt.Figure]": @@ -68,7 +69,7 @@ def draw_isopycnal_ts_fig(ds_iso: "xr.Dataset") -> "Optional[plt.Figure]": color_norms = np.linspace(0.25, 0.95, max(n_levels, 1)) colors = [cmap(v) for v in color_norms] - fig, ax = plt.subplots(figsize=(params.W_FULL, 4)) + fig, ax = plt.subplots(figsize=(report_tokens.W_FULL, 4)) for i, (sval, col) in enumerate(zip(sigma_vals, colors)): h = height[i, :] @@ -239,7 +240,7 @@ def _bar_color(p: float) -> str: fig, (ax0, ax1, ax2) = plt.subplots( 1, 3, - figsize=(params.W_FULL, fig_h), + figsize=(report_tokens.W_FULL, fig_h), sharey=True, gridspec_kw={"width_ratios": [0.8, 1.0, 1.2]}, ) @@ -380,7 +381,7 @@ def draw_overflow_temperature_fig(ds: "xr.Dataset") -> "Optional[plt.Figure]": .values ) - fig, ax = plt.subplots(figsize=(params.W_FULL, 3)) + fig, ax = plt.subplots(figsize=(report_tokens.W_FULL, 3)) ax.plot(time_vals, temp_med, color="#1a3a5c", lw=1.0) ax.set_ylabel(params.vlabel("temperature")) hab = waterdepth - actual_p diff --git a/oceanarray/plotters/primitives.py b/oceanarray/plotters/primitives.py index 8af48fe..680f0e4 100644 --- a/oceanarray/plotters/primitives.py +++ b/oceanarray/plotters/primitives.py @@ -23,6 +23,7 @@ from matplotlib.collections import LineCollection from .. import parameters as params +from oceanarray.config import report_tokens from ..utilities import _nice_colorbar_bounds @@ -142,7 +143,7 @@ def plot_trajectory( matplotlib.figure.Figure """ - fig, ax = plt.subplots(figsize=(params.W_HALF, 6), constrained_layout=True) + fig, ax = plt.subplots(figsize=(report_tokens.W_HALF, 6), constrained_layout=True) if color_data is not None: points = np.array([x, y]).T.reshape(-1, 1, 2) @@ -221,12 +222,15 @@ def hodograph_panel( lc.set_array(t_frac[::step][:-1]) ax.add_collection(lc) + from mpl_toolkits.axes_grid1 import make_axes_locatable + sm = plt.cm.ScalarMappable(cmap="plasma", norm=norm_lc) sm.set_array([]) - cb = ax.figure.colorbar( - sm, ax=ax, shrink=0.75, pad=0.03, aspect=20, ticks=bounds_lc - ) - cb.set_label("Time →") + # Tie the colorbar height to the (equal-aspect, square) axes via an appended + # cax, so it matches the plotted square rather than the taller panel cell. + cax = make_axes_locatable(ax).append_axes("right", size="4%", pad=0.05) + cb = ax.figure.colorbar(sm, cax=cax, ticks=bounds_lc) + cb.set_label(r"Time $\rightarrow$") cb.ax.set_yticks([0.0, 1.0]) cb.ax.set_yticklabels(["start", "end"]) diff --git a/oceanarray/plotters/spectrum.py b/oceanarray/plotters/spectrum.py index dee5b32..dffaa0c 100644 --- a/oceanarray/plotters/spectrum.py +++ b/oceanarray/plotters/spectrum.py @@ -27,7 +27,7 @@ from oceanarray.utilities import _nice_colorbar_bounds, period_axis_ticks from ..analysis.spectral import gonella_rotary_spectrum -from .. import parameters as params +from oceanarray.config import report_tokens if TYPE_CHECKING: import matplotlib.axes @@ -353,7 +353,7 @@ def draw_spectrum( from matplotlib.ticker import NullLocator - fig, (ax_lf, ax_hf) = plt.subplots(1, 2, figsize=(params.W_FULL, 5)) + fig, (ax_lf, ax_hf) = plt.subplots(1, 2, figsize=(report_tokens.W_FULL, 5)) x_min_lf = max(nyq_period, 10.0 / 1440.0) # right edge: Nyquist or 10 min # Left edge = longest period Welch can estimate = 1/min_freq = window length @@ -647,7 +647,7 @@ def draw_wavelet( n_panels = len(results) # height ratios: 1 part time series, 3 parts wavelet, per level hr = [1, 3] * n_panels - fig = plt.figure(figsize=(params.W_FULL, 4.5 * n_panels)) + fig = plt.figure(figsize=(report_tokens.W_FULL, 4.5 * n_panels)) gs = GridSpec(2 * n_panels, 1, figure=fig, height_ratios=hr, hspace=0.08) tax: list = [] # time series axes (top of each pair) @@ -869,7 +869,7 @@ def draw_grid_rotary_spectrum( cmap_cw = plt.get_cmap("Reds") cmap_ccw = plt.get_cmap("Blues") - fig, (ax_spec, ax_rot) = plt.subplots(1, 2, figsize=(params.W_FULL, 5)) + fig, (ax_spec, ax_rot) = plt.subplots(1, 2, figsize=(report_tokens.W_FULL, 5)) # Panel 1: CW (solid, reds) + CCW (dashed, blues) for s_cw, s_ccw, p in zip(s_cw_list, s_ccw_list, press_plotted): diff --git a/oceanarray/plotters/ts.py b/oceanarray/plotters/ts.py index b566e14..10e11bb 100644 --- a/oceanarray/plotters/ts.py +++ b/oceanarray/plotters/ts.py @@ -10,6 +10,7 @@ from .primitives import colorbar_norm from .helpers import QC_MARKER as _QC_MARKER from .. import parameters as params +from oceanarray.config import report_tokens if TYPE_CHECKING: import matplotlib.pyplot as plt @@ -153,7 +154,7 @@ def draw_ts_diagram(nc_path: Path) -> "Optional[plt.Figure]": ncols = 3 if has_sat else 2 fig, axes = plt.subplots( - 1, ncols, figsize=(params.W_FULL, 4.5), constrained_layout=True + 1, ncols, figsize=(report_tokens.W_FULL, 4.5), constrained_layout=True ) ax_l, ax_r = axes[0], axes[1] ax_sat = axes[2] if has_sat else None @@ -293,7 +294,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, axes = plt.subplots( - 1, ncols, figsize=(params.W_FULL, 4.5), constrained_layout=True + 1, ncols, figsize=(report_tokens.W_FULL, 4.5), constrained_layout=True ) ax_scatter, ax_heat = axes[0], axes[1] @@ -424,7 +425,7 @@ def draw_grid_ts_diagram( has_o2 = has_o2 and o2_valid.any() ncols = 2 if has_o2 else 1 - fig, axes = plt.subplots(1, ncols, figsize=(params.W_HALF, 5)) + fig, axes = plt.subplots(1, ncols, figsize=(report_tokens.W_HALF, 5)) if ncols == 1: axes = [axes] diff --git a/oceanarray/reports/_encode.py b/oceanarray/reports/_encode.py index cd86664..2fdbabd 100644 --- a/oceanarray/reports/_encode.py +++ b/oceanarray/reports/_encode.py @@ -42,6 +42,8 @@ def _manages_own_layout(fig: Any) -> bool: instead — or, for an aspect-locked figure that is exempt from the exact-width invariant, an explicit ``bbox_inches="tight"`` in :func:`_fig_to_base64`. """ + if getattr(fig, "_manual_layout", False): + return True engine = getattr(fig.get_layout_engine(), "__class__", None) if engine is not None and "Constrained" in engine.__name__: return True diff --git a/oceanarray/reports/_plots.py b/oceanarray/reports/_plots.py index 27a62c8..6d8112c 100644 --- a/oceanarray/reports/_plots.py +++ b/oceanarray/reports/_plots.py @@ -5,7 +5,7 @@ import logging import os from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple if TYPE_CHECKING: import matplotlib.pyplot as plt @@ -13,7 +13,9 @@ import numpy as np -from ._html_helpers import _QC_MARKER, _QC_LABELS, _fig_to_base64 +from ._html_helpers import _QC_MARKER, _QC_LABELS +from ._encode import render_b64 +from ..config import report_tokens from ..plotters.primitives import ( date_axis, hodograph_panel, @@ -67,120 +69,17 @@ # --------------------------------------------------------------------------- -# Silent-failure guard +# Figure encoder + silent-failure guard # --------------------------------------------------------------------------- -#: When ``True``, figure-generation failures re-raise instead of returning -#: ``None``. Off in production, where a single missing panel must not abort a -#: whole report; the test suite turns it on via ``tests/conftest.py`` so a -#: broken figure fails loudly instead of silently vanishing. Overridable at -#: import time through the ``OCEANARRAY_RAISE_ON_PLOT_ERROR`` environment -#: variable for debugging on real data, e.g. -#: ``OCEANARRAY_RAISE_ON_PLOT_ERROR=1 oceanarray report ...``. -RAISE_ON_PLOT_ERROR = os.environ.get("OCEANARRAY_RAISE_ON_PLOT_ERROR", "").lower() in ( - "1", - "true", - "yes", -) - - -def _plot_failed(exc: Exception) -> None: - """Handle a figure-generation failure. - - Re-raises ``exc`` when :data:`RAISE_ON_PLOT_ERROR` is enabled (tests, - debugging) and otherwise returns ``None`` so report generation degrades - gracefully instead of aborting on one broken panel. - - Parameters - ---------- - exc : Exception - The exception caught while building a figure. - - Returns - ------- - None - Always ``None`` when the guard is disabled. - - Raises - ------ - Exception - The original ``exc``, when :data:`RAISE_ON_PLOT_ERROR` is enabled. - - """ - if RAISE_ON_PLOT_ERROR: - raise exc - return None - - -def render_b64( - draw: Callable[..., Any], - /, - *args: Any, - optional: bool = False, - **kwargs: Any, -) -> Optional[str]: - """Run *draw* and return its Figure as a base64 PNG. - - *draw* must return a :class:`matplotlib.figure.Figure`, or ``None`` if the - dataset lacks the required variables. ``render_b64`` applies the project - style sheet, calls ``tight_layout``, encodes the figure, closes it, and - routes any exception through :data:`RAISE_ON_PLOT_ERROR` so tests see the - real error instead of a silent ``None``. - - Parameters - ---------- - draw : callable - Figure-building function. Called as ``draw(*args, **kwargs)``. - *args - Positional arguments forwarded to *draw*. - optional : bool, default False - When ``True``, a ``None`` return from *draw* is expected (e.g. the - panel requires data not present for all mooring types — ADCP file, - oxygen sensor, wave data). When ``False`` (the default) a ``None`` - return is treated as a defect and raises under - :data:`RAISE_ON_PLOT_ERROR`. - **kwargs - Keyword arguments forwarded to *draw*. - - Returns - ------- - str or None - Base-64-encoded PNG, or ``None`` if *draw* returned ``None`` or raised - with the guard off. - - """ - import matplotlib.pyplot as plt - from .. import parameters as params - - fig = None - try: - with plt.style.context(str(params.MPLSTYLE)): - fig = draw(*args, **kwargs) - if fig is None: - if RAISE_ON_PLOT_ERROR and not optional: - raise ValueError( - f"{getattr(draw, '__name__', draw)} returned None" - " — panel dropped silently (pass optional=True if this" - " panel is legitimately absent for some mooring types)" - ) - return None - import matplotlib as _mpl - - from matplotlib.projections.polar import PolarAxes as _PolarAxes - - if not isinstance( - fig.get_layout_engine(), _mpl.layout_engine.ConstrainedLayoutEngine - ) and not any(isinstance(ax, _PolarAxes) for ax in fig.get_axes()): - fig.tight_layout() - return _fig_to_base64(fig) - except Exception: # noqa: BLE001 intentional broad catch — this is the project-wide figure-failure envelope - if RAISE_ON_PLOT_ERROR: - raise - log.warning("%s failed; panel omitted", getattr(draw, "__name__", draw)) - return None - finally: - if fig is not None: - plt.close(fig) +#: ``render_b64`` / ``_fig_to_base64`` live in the shared encoder +#: (:mod:`._encode`) — the single choke point applying the report mplstyle, +#: dpi, palette quantization and error policy. Error policy is +#: :data:`report_tokens.RAISE_ON_PLOT_ERROR`; honour the legacy +#: ``OCEANARRAY_RAISE_ON_PLOT_ERROR`` env var by setting that flag here at import +#: (``OCEANARRAY_RAISE_ON_PLOT_ERROR=1 oceanarray report ...``). +if os.environ.get("OCEANARRAY_RAISE_ON_PLOT_ERROR", "").lower() in ("1", "true", "yes"): + report_tokens.RAISE_ON_PLOT_ERROR = True # --------------------------------------------------------------------------- @@ -222,7 +121,9 @@ def _plot_aquadopp_quick(ds: "xr.Dataset") -> "plt.Figure": nrows = max(len(panels), 1) with plt.style.context(str(params.MPLSTYLE)): - fig, axs = plt.subplots(nrows, 1, figsize=(12, 3 * nrows), sharex=True) + fig, axs = plt.subplots( + nrows, 1, figsize=(report_tokens.W_FULL, 3 * nrows), sharex=True + ) if nrows == 1: axs = [axs] @@ -231,6 +132,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) if invert: vmin = float(ds[vname].min()) vmax = float(ds[vname].max()) @@ -244,7 +146,7 @@ def _plot_aquadopp_quick(ds: "xr.Dataset") -> "plt.Figure": ) depth = f"{ds['InstrDepth'].item():.0f} m" if "InstrDepth" in ds else "?" axs[0].set_title(f"Aquadopp s/n: {serial} | Target depth: {depth}") - axs[-1].set_xlabel("Time") + axs[-1].set_xlabel("Date") date_axis(axs[-1]) plt.tight_layout() return fig @@ -270,11 +172,16 @@ def _instrument_panels( do_combo = combine_pitch_roll and "pitch" in time_vars and "roll" in time_vars out = [] + is_velocity_instrument = has_enu or bool(beam_vars & time_vars) for vname, label, color, invert in _CANONICAL_PANELS: if vname not in time_vars: continue if has_enu and vname in beam_vars: continue + # Velocity instruments (aquadopp/ADCP) record speed_of_sound internally for + # the velocity solution — not a science output, so drop that panel. + if vname == "speed_of_sound" and is_velocity_instrument: + continue if do_combo: if vname == "pitch": out.append(("_pitch_roll_combo", "Pitch & Roll (°)", None, False)) @@ -363,7 +270,7 @@ def _build_fig_from_ds( fig, axs = plt.subplots( nrows, 1, - figsize=(params.W_FULL, sum(height_ratios)), + figsize=(report_tokens.W_FULL, sum(height_ratios)), gridspec_kw={"height_ratios": height_ratios}, sharex=True, ) @@ -373,6 +280,7 @@ def _build_fig_from_ds( time = ds["time"].values for ax, (vname, label, color, invert) in zip(axs, panels): + ax.grid(True) if vname == "_pitch_roll_combo": _suspect_t = float(ds.attrs.get("tilt_suspect_threshold", 20.0)) _fail_t = float(ds.attrs.get("tilt_fail_threshold", 30.0)) @@ -489,7 +397,7 @@ def _build_fig_from_ds( title += f" [{title_suffix}]" axs[0].set_title(title) - axs[-1].set_xlabel("Time") + axs[-1].set_xlabel("Date") date_axis(axs[-1]) plt.tight_layout() return fig diff --git a/oceanarray/reports/_stack.py b/oceanarray/reports/_stack.py index 526ffba..98c4df1 100644 --- a/oceanarray/reports/_stack.py +++ b/oceanarray/reports/_stack.py @@ -33,6 +33,7 @@ render_b64, ) from .. import parameters as params +from oceanarray.config import report_tokens # --------------------------------------------------------------------------- @@ -84,7 +85,7 @@ def _make_aquadopp_tilt_panels(ds: Any, step: int = 1) -> Optional[str]: def _draw() -> "plt.Figure": fig = plt.figure( - figsize=(params.W_FULL, 2.8 * n_panels), constrained_layout=True + figsize=(report_tokens.W_FULL, 2.8 * n_panels), constrained_layout=True ) gs = fig.add_gridspec(n_panels, 3, width_ratios=[2, 2, 1]) @@ -254,7 +255,7 @@ def generate_stack_page( { "serial": _ser, "instr_type": instr_types[i], - "hab": f"{habs[i]:.1f}", + "hab": f"{habs[i]:.0f}", "depth": depth, "stage": "", "report_href": _instrument_report_href(mooring_name, _ser), @@ -265,8 +266,21 @@ def generate_stack_page( ) _serial_list = list(serials) - _tab20 = plt.get_cmap("tab20") - _serial_colors = {s: _tab20(i % 20) for i, s in enumerate(_serial_list)} + # Colour instruments in (deep-first) order from a colourblind-friendly + # sequential map; beyond _n_line_colors, keep the colour order and cycle + # the line style (solid, dashed, …) so many series stay distinguishable + # and ordered rather than an arbitrary 20-colour wheel. + _cmap = plt.get_cmap("viridis") + _line_styles = ["-", "--", ":", "-."] + _n_line_colors = min(len(_serial_list), 10) + _serial_colors = {} + _serial_styles = {} + for _i, _s in enumerate(_serial_list): + _ci = _i % _n_line_colors + _serial_colors[_s] = _cmap(_ci / max(_n_line_colors - 1, 1)) + _serial_styles[_s] = _line_styles[ + (_i // _n_line_colors) % len(_line_styles) + ] def _ts_fig( varname: str, @@ -284,19 +298,26 @@ def _ts_fig( qc = ds[qc_varname].values arr[qc >= 3] = np.nan with plt.style.context(str(params.MPLSTYLE)): - fig, ax = plt.subplots(figsize=(params.W_FULL, 3.2)) + fig, ax = plt.subplots(figsize=(report_tokens.W_FULL, 3.2)) plotted = False for i in range(n_instr): if exclude_types and instr_types[i].lower() in exclude_types: continue serial = _serial_list[i] color = _serial_colors[serial] + style = _serial_styles[serial] y = arr[::step, i] if not np.any(np.isfinite(y)): continue plotted = True ax.plot( - time_ds, y, color=color, lw=0.7, alpha=0.85, label=f"{serial}" + time_ds, + y, + color=color, + ls=style, + lw=0.7, + alpha=0.85, + label=f"{serial}", ) if dot_overlay: ax.plot( @@ -465,7 +486,7 @@ def _ts_fig( all_spacings.extend(valid.tolist()) if all_spacings: with plt.style.context(str(params.MPLSTYLE)): - fig_sp, ax_sp = plt.subplots(figsize=(params.W_THIRD, 3)) + fig_sp, ax_sp = plt.subplots(figsize=(report_tokens.W_THIRD, 3)) ax_sp.hist( all_spacings, bins=60, color="steelblue", edgecolor="white" ) diff --git a/oceanarray/reports/templates/base.html b/oceanarray/reports/templates/base.html index b76b4ae..3057ebf 100644 --- a/oceanarray/reports/templates/base.html +++ b/oceanarray/reports/templates/base.html @@ -50,6 +50,9 @@ .history-list li:last-child { border-bottom:none; } .history-ts { color:var(--muted); white-space:nowrap; font-size:0.76rem; min-width:11rem; } .history-text { flex:1; } + /* Figures never exceed the content column; the oversampled PNG downscales + crisply (spec §11 OVERSAMPLE). Pages may override (e.g. array uses 60%). */ + .fig { max-width:100%; height:auto; display:block; } td.num { text-align:right; font-variant-numeric:tabular-nums; } td.mono { font-family:monospace; font-size:0.8rem; } .none-note { color:var(--muted); font-style:italic; } diff --git a/oceanarray/reports/templates/grid.html b/oceanarray/reports/templates/grid.html index 259d4b7..b82a51f 100644 --- a/oceanarray/reports/templates/grid.html +++ b/oceanarray/reports/templates/grid.html @@ -159,7 +159,7 @@

Buoyancy {% endif %} {% if sigma_sections or fig_overflow_temp_b64 or fig_isopycnal_coverage_b64 %} -

Overflow ↑ top

+

Overflow

{% endif %} {% if fig_isopycnal_coverage_b64 %} diff --git a/oceanarray/reports/templates/instrument.html b/oceanarray/reports/templates/instrument.html index 3ff25f6..4be4bb0 100644 --- a/oceanarray/reports/templates/instrument.html +++ b/oceanarray/reports/templates/instrument.html @@ -3,8 +3,8 @@ {% block page_styles %} :root { --accent:#1e8449; --accent-link:#d5f5e3; } .file-table { width:100%; border-collapse:collapse; font-size:0.81rem; margin-bottom:1.2rem; } - .file-table th { background:var(--seafoam); text-align:left; padding:0.35rem 0.65rem; - border-bottom:2px solid #cde; font-weight:600; } + .file-table th { background:var(--seafoam); color:var(--text); text-align:left; + padding:0.35rem 0.65rem; border-bottom:2px solid #cde; font-weight:700; } .file-table td { padding:0.3rem 0.65rem; border-bottom:1px solid #eef; vertical-align:middle; } .file-table tr:nth-child(even) td { background:#f4f9fc; } .file-table .ok { color:var(--good); font-weight:700; } diff --git a/oceanarray/reports/templates/stack.html b/oceanarray/reports/templates/stack.html index 0a0ee73..a91c0b2 100644 --- a/oceanarray/reports/templates/stack.html +++ b/oceanarray/reports/templates/stack.html @@ -62,6 +62,7 @@

Processing history

Instruments (deep-first)

+

HAB = height above bottom (m).

diff --git a/tests/conftest.py b/tests/conftest.py index 8173029..c66ff36 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,13 +28,14 @@ def _raise_on_plot_error(monkeypatch): """Make figure-generation failures raise during tests instead of vanishing. - Flips :data:`oceanarray.reports._plots.RAISE_ON_PLOT_ERROR` on for the + Flips :data:`oceanarray.config.report_tokens.RAISE_ON_PLOT_ERROR` on for the duration of every test so a broken figure surfaces as a test failure rather - than a silently-absent panel. Reverted automatically by ``monkeypatch``. + than a silently-absent panel. The encoder (``_encode.render_b64``) reads this + token flag. Reverted automatically by ``monkeypatch``. """ - from oceanarray.reports import _plots + from oceanarray.config import report_tokens - monkeypatch.setattr(_plots, "RAISE_ON_PLOT_ERROR", True, raising=False) + monkeypatch.setattr(report_tokens, "RAISE_ON_PLOT_ERROR", True) @pytest.fixture diff --git a/tests/fixtures/golden/dune2/dune2_1_2026_grid_report.html b/tests/fixtures/golden/dune2/dune2_1_2026_grid_report.html index 2eb41e2..f9ff888 100644 --- a/tests/fixtures/golden/dune2/dune2_1_2026_grid_report.html +++ b/tests/fixtures/golden/dune2/dune2_1_2026_grid_report.html @@ -50,6 +50,9 @@ .history-list li:last-child { border-bottom:none; } .history-ts { color:var(--muted); white-space:nowrap; font-size:0.76rem; min-width:11rem; } .history-text { flex:1; } + /* Figures never exceed the content column; the oversampled PNG downscales + crisply (spec §11 OVERSAMPLE). Pages may override (e.g. array uses 60%). */ + .fig { max-width:100%; height:auto; display:block; } td.num { text-align:right; font-variant-numeric:tabular-nums; } td.mono { font-family:monospace; font-size:0.8rem; } .none-note { color:var(--muted); font-style:italic; } @@ -247,7 +250,7 @@

Buoyancy -

Overflow ↑ top

+

Overflow

diff --git a/tests/fixtures/golden/dune2/dune2_1_2026_report.html b/tests/fixtures/golden/dune2/dune2_1_2026_report.html index 7a553ac..19d891d 100644 --- a/tests/fixtures/golden/dune2/dune2_1_2026_report.html +++ b/tests/fixtures/golden/dune2/dune2_1_2026_report.html @@ -50,6 +50,9 @@ .history-list li:last-child { border-bottom:none; } .history-ts { color:var(--muted); white-space:nowrap; font-size:0.76rem; min-width:11rem; } .history-text { flex:1; } + /* Figures never exceed the content column; the oversampled PNG downscales + crisply (spec §11 OVERSAMPLE). Pages may override (e.g. array uses 60%). */ + .fig { max-width:100%; height:auto; display:block; } td.num { text-align:right; font-variant-numeric:tabular-nums; } td.mono { font-family:monospace; font-size:0.8rem; } .none-note { color:var(--muted); font-style:italic; } diff --git a/tests/fixtures/golden/dune2/dune2_1_2026_stack_report.html b/tests/fixtures/golden/dune2/dune2_1_2026_stack_report.html index 776e09b..96aa10c 100644 --- a/tests/fixtures/golden/dune2/dune2_1_2026_stack_report.html +++ b/tests/fixtures/golden/dune2/dune2_1_2026_stack_report.html @@ -50,6 +50,9 @@ .history-list li:last-child { border-bottom:none; } .history-ts { color:var(--muted); white-space:nowrap; font-size:0.76rem; min-width:11rem; } .history-text { flex:1; } + /* Figures never exceed the content column; the oversampled PNG downscales + crisply (spec §11 OVERSAMPLE). Pages may override (e.g. array uses 60%). */ + .fig { max-width:100%; height:auto; display:block; } td.num { text-align:right; font-variant-numeric:tabular-nums; } td.mono { font-family:monospace; font-size:0.8rem; } .none-note { color:var(--muted); font-style:italic; } @@ -140,6 +143,7 @@

Processing history

Instruments (deep-first)

+

HAB = height above bottom (m).

#TypeSerialHAB (m)~Depth (m)
@@ -148,7 +152,7 @@

Instruments (deep-first)

- + @@ -156,7 +160,7 @@

Instruments (deep-first)

- + @@ -164,7 +168,7 @@

Instruments (deep-first)

- + @@ -172,7 +176,7 @@

Instruments (deep-first)

- + @@ -180,7 +184,7 @@

Instruments (deep-first)

- + @@ -188,7 +192,7 @@

Instruments (deep-first)

- + @@ -196,7 +200,7 @@

Instruments (deep-first)

- + @@ -204,7 +208,7 @@

Instruments (deep-first)

- + @@ -212,7 +216,7 @@

Instruments (deep-first)

- + @@ -220,7 +224,7 @@

Instruments (deep-first)

- + @@ -228,7 +232,7 @@

Instruments (deep-first)

- + @@ -236,7 +240,7 @@

Instruments (deep-first)

- + @@ -244,7 +248,7 @@

Instruments (deep-first)

- + @@ -252,7 +256,7 @@

Instruments (deep-first)

- + @@ -260,7 +264,7 @@

Instruments (deep-first)

- + @@ -268,7 +272,7 @@

Instruments (deep-first)

- + @@ -276,7 +280,7 @@

Instruments (deep-first)

- + @@ -284,7 +288,7 @@

Instruments (deep-first)

- + @@ -292,7 +296,7 @@

Instruments (deep-first)

- + @@ -300,7 +304,7 @@

Instruments (deep-first)

- + @@ -308,7 +312,7 @@

Instruments (deep-first)

- + @@ -316,7 +320,7 @@

Instruments (deep-first)

- + diff --git a/tests/fixtures/golden/dune2/instrument/dune2_1_2026_2941_report.html b/tests/fixtures/golden/dune2/instrument/dune2_1_2026_2941_report.html index a914efe..e916258 100644 --- a/tests/fixtures/golden/dune2/instrument/dune2_1_2026_2941_report.html +++ b/tests/fixtures/golden/dune2/instrument/dune2_1_2026_2941_report.html @@ -50,6 +50,9 @@ .history-list li:last-child { border-bottom:none; } .history-ts { color:var(--muted); white-space:nowrap; font-size:0.76rem; min-width:11rem; } .history-text { flex:1; } + /* Figures never exceed the content column; the oversampled PNG downscales + crisply (spec §11 OVERSAMPLE). Pages may override (e.g. array uses 60%). */ + .fig { max-width:100%; height:auto; display:block; } td.num { text-align:right; font-variant-numeric:tabular-nums; } td.mono { font-family:monospace; font-size:0.8rem; } .none-note { color:var(--muted); font-style:italic; } @@ -63,8 +66,8 @@ :root { --accent:#1e8449; --accent-link:#d5f5e3; } .file-table { width:100%; border-collapse:collapse; font-size:0.81rem; margin-bottom:1.2rem; } - .file-table th { background:var(--seafoam); text-align:left; padding:0.35rem 0.65rem; - border-bottom:2px solid #cde; font-weight:600; } + .file-table th { background:var(--seafoam); color:var(--text); text-align:left; + padding:0.35rem 0.65rem; border-bottom:2px solid #cde; font-weight:700; } .file-table td { padding:0.3rem 0.65rem; border-bottom:1px solid #eef; vertical-align:middle; } .file-table tr:nth-child(even) td { background:#f4f9fc; } .file-table .ok { color:var(--good); font-weight:700; } diff --git a/tests/fixtures/golden/dune2/instrument/dune2_1_2026_9920_report.html b/tests/fixtures/golden/dune2/instrument/dune2_1_2026_9920_report.html index dbeb2fe..57fd702 100644 --- a/tests/fixtures/golden/dune2/instrument/dune2_1_2026_9920_report.html +++ b/tests/fixtures/golden/dune2/instrument/dune2_1_2026_9920_report.html @@ -50,6 +50,9 @@ .history-list li:last-child { border-bottom:none; } .history-ts { color:var(--muted); white-space:nowrap; font-size:0.76rem; min-width:11rem; } .history-text { flex:1; } + /* Figures never exceed the content column; the oversampled PNG downscales + crisply (spec §11 OVERSAMPLE). Pages may override (e.g. array uses 60%). */ + .fig { max-width:100%; height:auto; display:block; } td.num { text-align:right; font-variant-numeric:tabular-nums; } td.mono { font-family:monospace; font-size:0.8rem; } .none-note { color:var(--muted); font-style:italic; } @@ -63,8 +66,8 @@ :root { --accent:#1e8449; --accent-link:#d5f5e3; } .file-table { width:100%; border-collapse:collapse; font-size:0.81rem; margin-bottom:1.2rem; } - .file-table th { background:var(--seafoam); text-align:left; padding:0.35rem 0.65rem; - border-bottom:2px solid #cde; font-weight:600; } + .file-table th { background:var(--seafoam); color:var(--text); text-align:left; + padding:0.35rem 0.65rem; border-bottom:2px solid #cde; font-weight:700; } .file-table td { padding:0.3rem 0.65rem; border-bottom:1px solid #eef; vertical-align:middle; } .file-table tr:nth-child(even) td { background:#f4f9fc; } .file-table .ok { color:var(--good); font-weight:700; } diff --git a/tests/unit/test_plot_guard.py b/tests/unit/test_plot_guard.py index f892b76..2f50299 100644 --- a/tests/unit/test_plot_guard.py +++ b/tests/unit/test_plot_guard.py @@ -11,6 +11,7 @@ import matplotlib.pyplot as plt import pytest +from oceanarray.config import report_tokens from oceanarray.reports import _plots @@ -28,20 +29,20 @@ def _make_trivial_fig() -> plt.Figure: def test_render_b64_returns_string_for_good_draw(monkeypatch): """``render_b64`` returns a non-empty base64 string when *draw* succeeds.""" - monkeypatch.setattr(_plots, "RAISE_ON_PLOT_ERROR", False) + monkeypatch.setattr(report_tokens, "RAISE_ON_PLOT_ERROR", False) result = _plots.render_b64(_make_trivial_fig) assert isinstance(result, str) and len(result) > 0 def test_render_b64_returns_none_when_draw_returns_none(monkeypatch): """``render_b64`` propagates ``None`` from *draw* without error.""" - monkeypatch.setattr(_plots, "RAISE_ON_PLOT_ERROR", False) + monkeypatch.setattr(report_tokens, "RAISE_ON_PLOT_ERROR", False) assert _plots.render_b64(lambda: None) is None def test_render_b64_swallows_exception_when_guard_off(monkeypatch): """``render_b64`` swallows errors and returns ``None`` when guard is off.""" - monkeypatch.setattr(_plots, "RAISE_ON_PLOT_ERROR", False) + monkeypatch.setattr(report_tokens, "RAISE_ON_PLOT_ERROR", False) def bad_draw(): raise RuntimeError("broken") @@ -51,7 +52,7 @@ def bad_draw(): def test_render_b64_reraises_when_guard_on(monkeypatch): """``render_b64`` re-raises when guard is on (test mode).""" - monkeypatch.setattr(_plots, "RAISE_ON_PLOT_ERROR", True) + monkeypatch.setattr(report_tokens, "RAISE_ON_PLOT_ERROR", True) def bad_draw(): raise RuntimeError("broken") @@ -62,7 +63,7 @@ def bad_draw(): def test_render_b64_closes_figure_on_success(monkeypatch): """``render_b64`` closes the Figure after encoding so it does not leak.""" - monkeypatch.setattr(_plots, "RAISE_ON_PLOT_ERROR", False) + monkeypatch.setattr(report_tokens, "RAISE_ON_PLOT_ERROR", False) fig = _make_trivial_fig() fig_num = fig.number _plots.render_b64(lambda: fig) @@ -71,41 +72,23 @@ def test_render_b64_closes_figure_on_success(monkeypatch): def test_render_b64_none_optional_false_guard_on_raises(monkeypatch): """``render_b64`` raises ``ValueError`` when *draw* returns ``None``, guard on, optional=False.""" - monkeypatch.setattr(_plots, "RAISE_ON_PLOT_ERROR", True) - with pytest.raises(ValueError, match="returned None"): + monkeypatch.setattr(report_tokens, "RAISE_ON_PLOT_ERROR", True) + with pytest.raises(RuntimeError, match="returned None"): _plots.render_b64(lambda: None, optional=False) def test_render_b64_none_optional_true_guard_on_returns_none(monkeypatch): """``render_b64`` silently returns ``None`` when *draw* returns ``None`` and optional=True.""" - monkeypatch.setattr(_plots, "RAISE_ON_PLOT_ERROR", True) + monkeypatch.setattr(report_tokens, "RAISE_ON_PLOT_ERROR", True) assert _plots.render_b64(lambda: None, optional=True) is None def test_render_b64_none_optional_false_guard_off_returns_none(monkeypatch): """``render_b64`` returns ``None`` (no raise) when guard is off regardless of optional.""" - monkeypatch.setattr(_plots, "RAISE_ON_PLOT_ERROR", False) + monkeypatch.setattr(report_tokens, "RAISE_ON_PLOT_ERROR", False) assert _plots.render_b64(lambda: None, optional=False) is None -# --------------------------------------------------------------------------- -# The guard mechanism itself -# --------------------------------------------------------------------------- - - -def test_plot_failed_raises_when_enabled(monkeypatch): - """``_plot_failed`` re-raises the original exception when the guard is on.""" - monkeypatch.setattr(_plots, "RAISE_ON_PLOT_ERROR", True) - with pytest.raises(ValueError, match="boom"): - _plots._plot_failed(ValueError("boom")) - - -def test_plot_failed_returns_none_when_disabled(monkeypatch): - """``_plot_failed`` swallows and returns ``None`` when the guard is off.""" - monkeypatch.setattr(_plots, "RAISE_ON_PLOT_ERROR", False) - assert _plots._plot_failed(ValueError("boom")) is None - - # --------------------------------------------------------------------------- # Instrument figures on real stage-3 data (guard on via conftest) # --------------------------------------------------------------------------- @@ -181,7 +164,7 @@ def test_render_b64_skips_tight_layout_for_constrained_layout_fig(monkeypatch): raises RuntimeError. ``render_b64`` must detect the layout engine and skip the call to avoid this. """ - monkeypatch.setattr(_plots, "RAISE_ON_PLOT_ERROR", True) + monkeypatch.setattr(report_tokens, "RAISE_ON_PLOT_ERROR", True) def _make_constrained_with_colorbar(): import numpy as np From 95051e58ff6d6c1c52a7c29901433cc9655ee430 Mon Sep 17 00:00:00 2001 From: Eleanor Frajka-Williams Date: Sat, 15 Aug 2026 09:34:29 +0200 Subject: [PATCH 2/4] feat(reports): figure-debug infrastructure (OCEANARRAY_REPORT_DEBUG) --- oceanarray/reports/_env.py | 5 + oceanarray/reports/_figdebug.py | 139 ++++++++++++++++++++++ oceanarray/reports/templates/_macros.html | 7 ++ 3 files changed, 151 insertions(+) create mode 100644 oceanarray/reports/_figdebug.py create mode 100644 oceanarray/reports/templates/_macros.html diff --git a/oceanarray/reports/_env.py b/oceanarray/reports/_env.py index 049685a..992f566 100644 --- a/oceanarray/reports/_env.py +++ b/oceanarray/reports/_env.py @@ -17,6 +17,7 @@ from jinja2 import Environment, FileSystemLoader +from . import _figdebug from .. import parameters as params #: Directory holding the report page templates. @@ -29,6 +30,9 @@ ) #: Package identity available to every template (masthead wordmark). _ENV.globals["package_name"] = params.PACKAGE_NAME +#: Per-figure debug lookup (``func · figsize · png``) for templates' ``.debug`` +#: sections; returns "" unless ``OCEANARRAY_REPORT_DEBUG`` is set. +_ENV.globals["figdbg"] = _figdebug.figdbg def render_template(name: str, /, **context: Any) -> str: @@ -48,4 +52,5 @@ def render_template(name: str, /, **context: Any) -> str: The rendered HTML. """ + context.setdefault("debug", _figdebug.enabled()) return _ENV.get_template(name).render(**context) diff --git a/oceanarray/reports/_figdebug.py b/oceanarray/reports/_figdebug.py new file mode 100644 index 0000000..08c5dd7 --- /dev/null +++ b/oceanarray/reports/_figdebug.py @@ -0,0 +1,139 @@ +"""Per-figure debug capture for report pages (opt-in via an env var). + +When ``OCEANARRAY_REPORT_DEBUG`` is set (``1``/``true``/``yes``), every figure +serialised for an HTML report records the draw function that produced it, its +matplotlib ``figsize`` (inches), and the resulting PNG pixel size. Templates +read this back through the Jinja global :func:`figdbg` and print a ``.debug`` +section next to each figure — the template supplies the display *slot* it chose, +so the two can be eyeballed against each other (the figsize width should equal +the slot width; a mismatch is the bug this view exists to surface). + +The capture is keyed by the base64 PNG string (unique per figure), so a template +looks up exactly the figure it is placing. The heavy lifting — style, dpi, +canvas — stays in the vendored encoder (:mod:`oceanarray.reports._encode`); this +module only wraps it to record metadata and is a no-op when debug is off. +""" + +from __future__ import annotations + +import os +from typing import Any, Optional + +from . import _encode +from ..config import report_tokens + +#: Captured metadata, keyed by the figure's base64 PNG string. +_COLLECTED: dict[str, dict[str, Any]] = {} + + +def enabled() -> bool: + """Return whether report figure-debug capture is switched on. + + Reads ``OCEANARRAY_REPORT_DEBUG``; true for ``"1"``, ``"true"``, ``"yes"`` + (case-insensitive). + """ + return os.environ.get("OCEANARRAY_REPORT_DEBUG", "").lower() in ( + "1", + "true", + "yes", + ) + + +def record(b64: Optional[str], func: str, fig: Any) -> None: + """Record ``(func, figsize, png_px)`` for figure *b64* when debug is on. + + Parameters + ---------- + b64 : str or None + The figure's base64 PNG string (the template's lookup key). Ignored + when falsy. + func : str + A label for the code that drew the figure (draw function name, or a + ``"_ts_fig[var]"``-style tag for the stack panels). + fig : matplotlib.figure.Figure + The figure, read for its size in inches. + + """ + if not enabled() or not b64 or fig is None: + return + try: + w_in, h_in = (float(v) for v in fig.get_size_inches()) + except Exception: # noqa: BLE001 - figure size is best-effort debug metadata + return + _COLLECTED[b64] = { + "func": func, + "figsize_in": (round(w_in, 2), round(h_in, 2)), + "png_px": (round(w_in * report_tokens.FIG_DPI), round(h_in * report_tokens.FIG_DPI)), + } + + +def _draw_name(draw: Any) -> str: + """Return a readable name for a draw callable, resolving closures. + + Many report figures are drawn by a nested ``_draw`` closure inside a + ``_make_*`` wrapper; ``__name__`` is just ``"_draw"`` for all of them, so use + ``__qualname__`` (``"_make_tilt_panels.._draw"``) and keep the + enclosing wrapper name, which identifies the figure. + """ + name = getattr(draw, "__qualname__", None) or getattr(draw, "__name__", None) + if not name: + return repr(draw) + if ".." in name: + name = name.split("..")[0] + return name + + +def lookup(b64: Optional[str]) -> Optional[dict[str, Any]]: + """Return the recorded metadata for figure *b64*, or ``None``.""" + if not b64: + return None + return _COLLECTED.get(b64) + + +def figdbg(b64: Optional[str]) -> str: + """Return a one-line ``func · figsize · png`` string for figure *b64*. + + Registered as a Jinja global so a template's ``.debug`` section can print + it beside the slot it chose. Returns ``""`` when there is nothing recorded + (e.g. debug off, or the figure failed to render). + """ + info = lookup(b64) + if info is None: + return "" + w_in, h_in = info["figsize_in"] + w_px, h_px = info["png_px"] + return f"{info['func']} · figsize {w_in}×{h_in} in · png {w_px}×{h_px} px" + + +def clear() -> None: + """Drop all recorded figure metadata (call at the start of a page build).""" + _COLLECTED.clear() + + +def render_b64(draw: Any, /, *args: Any, optional: bool = False, **kwargs: Any) -> Any: + """Encoder ``render_b64`` wrapper that records per-figure debug metadata. + + Delegates to :func:`oceanarray.reports._encode.render_b64` unchanged; when + debug is enabled it wraps *draw* to capture the figure's size and name and + stores them under the returned base64 string. Behaviour and return value + are identical to the vendored encoder in every other respect. + """ + if not enabled(): + return _encode.render_b64(draw, *args, optional=optional, **kwargs) + + import functools + + captured: dict[str, Any] = {} + + @functools.wraps(draw) # preserve draw.__name__ so encoder error/warns name it + def _wrapped(*a: Any, **k: Any) -> Any: + fig = draw(*a, **k) + if fig is not None: + captured["fig"] = fig + captured["func"] = _draw_name(draw) + return fig + + b64 = _encode.render_b64(_wrapped, *args, optional=optional, **kwargs) + if b64 and "fig" in captured: + record(b64, captured["func"], captured["fig"]) + return b64 diff --git a/oceanarray/reports/templates/_macros.html b/oceanarray/reports/templates/_macros.html new file mode 100644 index 0000000..14a6636 --- /dev/null +++ b/oceanarray/reports/templates/_macros.html @@ -0,0 +1,7 @@ +{# Shared template macros. Import with: {% import "_macros.html" as m %} #} + +{# Figure-debug line (OCEANARRAY_REPORT_DEBUG=1): the display slot this template + chose vs the render-side figsize/png, so a figsize≠slot mismatch is visible. + Renders nothing unless debug capture recorded this figure (figdbg is a Jinja + global returning "" when off), so it is a no-op in normal builds. #} +{% macro dbg(b64, slot) %}{% set d = figdbg(b64) %}{% if d %}
slot: {{ slot }} | {{ d }}
{% endif %}{% endmacro %} From 14b1864d222d0fe5d9bb7d17f6d3b27379ec082b Mon Sep 17 00:00:00 2001 From: Eleanor Frajka-Williams Date: Sat, 15 Aug 2026 09:34:55 +0200 Subject: [PATCH 3/4] feat(reports): deterministic square/matched-colorbar layout, colour registries, units --- oceanarray/config/parameters.py | 78 ++++ oceanarray/plotters/__init__.py | 3 +- oceanarray/plotters/current.py | 213 ++++++----- oceanarray/plotters/diagnostic.py | 68 ++-- oceanarray/plotters/helpers.py | 73 +++- oceanarray/plotters/hydrography.py | 26 +- oceanarray/plotters/primitives.py | 346 ++++++++++++++++-- oceanarray/plotters/spectrum.py | 108 ++++-- oceanarray/plotters/timeseries.py | 81 ++-- oceanarray/plotters/ts.py | 175 ++++++--- oceanarray/processors/pressure.py | 2 +- oceanarray/reports/_plots.py | 74 ++-- oceanarray/reports/_stack.py | 98 +++-- oceanarray/reports/templates/array.html | 2 + oceanarray/reports/templates/base.html | 5 + oceanarray/reports/templates/grid.html | 16 + oceanarray/reports/templates/instrument.html | 14 +- oceanarray/reports/templates/mooring.html | 5 + oceanarray/reports/templates/stack.html | 20 +- oceanarray/utilities.py | 30 ++ .../dune2/dune2_1_2026_grid_report.html | 5 + .../golden/dune2/dune2_1_2026_report.html | 5 + .../dune2/dune2_1_2026_stack_report.html | 7 +- .../instrument/dune2_1_2026_2941_report.html | 5 + .../instrument/dune2_1_2026_9920_report.html | 7 +- 25 files changed, 1093 insertions(+), 373 deletions(-) diff --git a/oceanarray/config/parameters.py b/oceanarray/config/parameters.py index 9fe18ad..27c68c3 100644 --- a/oceanarray/config/parameters.py +++ b/oceanarray/config/parameters.py @@ -27,6 +27,12 @@ # --------------------------------------------------------------------------- PACKAGE_NAME = "oceanarray" +#: Height (inches) of one gridded-section / time-series panel row at full width. +#: Matches the per-row height of the two-row "velocity at depth" figure (5" / 2), +#: so stacked grid panels and single-row section figures share one scale and do +#: not render over-tall. +GRID_PANEL_ROW_IN: float = 2.5 + # --------------------------------------------------------------------------- # Matplotlib style path (used by plotters via plt.style.use) # --------------------------------------------------------------------------- @@ -527,6 +533,57 @@ k: v["cmap"] for k, v in VARIABLES.items() if v.get("cmap") is not None } +#: Colormaps for colouring *lines* (one per instrument, deep-first) — distinct +#: from the pcolormesh field maps in :data:`CMAPS_BY_VARIABLE`, because a field +#: map that is fine for a filled panel can be wrong for overlaid lines (e.g. a +#: diverging map's pale midpoint washes lines out). Sampled deep→shallow with +#: washed-out colours skipped by luminance (see +#: :func:`oceanarray.plotters.helpers.ordered_line_colors`). Directions are +#: chosen so the deepest instrument gets the darkest/most-saturated colour: +#: temperature blue(cold/deep)→red(warm/shallow); pressure dark→lighter blue +#: (bathymetry convention, deep = dark); salinity starts at the blue end. +LINE_CMAPS_BY_VARIABLE: dict[str, str] = { + "temperature": "RdBu_r", + "pressure": "Blues_r", + "salinity": "YlGnBu_r", + # Velocity lines are ordered by depth, so shade them like pressure (dark = + # deep) — the field map's red/blue diverging scale means north/south, which is + # meaningless for a per-instrument line ordering. + "east_velocity": "Blues_r", + "north_velocity": "Blues_r", + "up_velocity": "Blues_r", +} + +#: One colourblind-safe colour per variable, for **single-instrument** panels +#: 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``. +VAR_COLORS: dict[str, str] = { + # Physics — Okabe-Ito + "temperature": "#56B4E9", # sky blue + "conservative_temperature": "#56B4E9", + "salinity": "#E69F00", # orange + "absolute_salinity": "#E69F00", + "conductivity": "#44AA99", # teal (Paul Tol) — readable stand-in + "potential_density": "#009E73", # bluish green + "pressure": "#000000", # black + "depth": "#000000", + "n2": "#000000", + "east_velocity": "#D55E00", # vermillion (U) + "u": "#D55E00", + "north_velocity": "#0072B2", # blue (V) + "v": "#0072B2", + "up_velocity": "#CC79A7", # reddish purple (W) + "w": "#CC79A7", + "speed": "#D55E00", + # Biogeochemistry — Paul Tol + "dissolved_oxygen": "#332288", # indigo + "dissolved_oxygen_ml_l": "#332288", + "oxygen_saturation_pct": "#332288", + "turbidity": "#661100", # dark red +} + def vlabel(var: str, prefix: str = "") -> str: """Return a matplotlib axis label for *var* from the :data:`VARIABLES` registry. @@ -554,3 +611,24 @@ def vlabel(var: str, prefix: str = "") -> str: lbl = f"{prefix}{entry.get('label', var)}" lu = entry.get("label_units", "") return f"{lbl} ({lu})" if lu else lbl + + +def vunit(var: str) -> str: + """Return the axis-label units string for *var* from :data:`VARIABLES`. + + This is the ``label_units`` component alone (e.g. ``"°C"``, ``"dbar"``), + suitable for a units-only colorbar title. Returns an empty string for a + dimensionless quantity or an unknown variable. + + Parameters + ---------- + var : str + Variable name (key in :data:`VARIABLES`). + + Returns + ------- + str + The unit string, or ``""`` when none is registered. + + """ + return VARIABLES.get(var, {}).get("label_units", "") diff --git a/oceanarray/plotters/__init__.py b/oceanarray/plotters/__init__.py index a5fe180..79d30a1 100644 --- a/oceanarray/plotters/__init__.py +++ b/oceanarray/plotters/__init__.py @@ -52,8 +52,7 @@ These three are the only remaining plotter.py entries — kept alive for the CLI (``oceanarray plot`` / ``process --plot``) pending their redesign as a -file-oriented ``oceanarray plot `` (§11). See the migration checklist -at .claude/plotters_update-20260718.md. +file-oriented ``oceanarray plot `` (§11). Future ------ diff --git a/oceanarray/plotters/current.py b/oceanarray/plotters/current.py index fc402c2..b3b8ca6 100644 --- a/oceanarray/plotters/current.py +++ b/oceanarray/plotters/current.py @@ -20,7 +20,7 @@ from __future__ import annotations from pathlib import Path -from typing import Optional +from typing import Any, Optional import matplotlib.colors as mcolors import matplotlib.pyplot as plt @@ -35,6 +35,9 @@ date_axis, hodograph_panel, plot_trajectory, + square_axes_grid, + square_limits, + unit_colorbar, ) from oceanarray.plotters.helpers import _rose_ax, _velocity_panel_style from oceanarray.utilities import _nice_colorbar_bounds @@ -92,9 +95,9 @@ def plot_temperature_trajectory( y = np.concatenate([[0.0], np.cumsum(v[:-1] * dt)]) / 1000.0 temp = ds[temp_var].values - units = ds[temp_var].attrs.get("units", "") long_name = ds[temp_var].attrs.get("long_name", temp_var) - colorbar_label = f"{long_name} ({units})" if units else long_name + # Pretty units from the registry (°C), not the file's CF attr (degC/degree_Celsius). + units = params.vunit("temperature") or ds[temp_var].attrs.get("units", "") if not title: title = ds.attrs.get("id", "") @@ -106,7 +109,8 @@ def plot_temperature_trajectory( cmap="coolwarm", xlabel="East displacement (km)", ylabel="North displacement (km)", - colorbar_label=colorbar_label, + colorbar_label=long_name, + colorbar_unit=units, title=title, ) @@ -137,7 +141,7 @@ def plot_speed_boxplot( speed = ds[speed_var].values.ravel() speed_clean = speed[~np.isnan(speed)] - units = ds[speed_var].attrs.get("units", "") + units = params.vunit("speed") or ds[speed_var].attrs.get("units", "") long_name = ds[speed_var].attrs.get("long_name", speed_var) ylabel = f"{long_name} ({units})" if units else long_name @@ -250,7 +254,8 @@ def plot_multi_aquadopp_trajectories( _bounds = _nice_colorbar_bounds(0.0, 1.0, n=20) norm: mcolors.BoundaryNorm = mcolors.BoundaryNorm(_bounds, ncolors=256) - fig, ax = plt.subplots(figsize=(report_tokens.W_FULL, 5), constrained_layout=True) + fig, axes, cax = square_axes_grid(report_tokens.W_HALF, 1, 1, colorbar=has_temp) + ax = axes[0, 0] for instr_i, x, y, temp in trajs: serial = str(serials[instr_i]) @@ -303,21 +308,25 @@ def plot_multi_aquadopp_trajectories( ax.plot(0, 0, "o", color="black", markersize=7, zorder=6, label="Start (all)") ax.legend(fontsize=8, loc="upper left") - if has_temp: + if has_temp and cax is not None: sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm) sm.set_array([]) - units = ds[temp_var].attrs.get("units", "°C") - long_name = ds[temp_var].attrs.get("long_name", "Temperature") - fig.colorbar( - sm, ax=ax, label=f"{long_name} ({units})", shrink=0.75, ticks=_bounds - ) - - ax.autoscale_view() + # Pretty units from the registry (°C), not the file's CF attr. + units = params.vunit("temperature") or ds[temp_var].attrs.get("units", "") + unit_colorbar(cax, sm, unit=units, ticks=_bounds[::2]) + + # Square the axes to the union of all trajectories so equal aspect fills the + # panel and the shared colorbar height stays matched. + _all_x = np.concatenate([x for _, x, _, _ in trajs]) + _all_y = np.concatenate([y for _, _, y, _ in trajs]) + xlim, ylim = square_limits(_all_x, _all_y) + ax.set_xlim(*xlim) + ax.set_ylim(*ylim) ax.set_xlabel("East displacement (km)") ax.set_ylabel("North displacement (km)") 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="box") + ax.set_aspect("equal", adjustable="datalim") ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.4) if not title: title = ds.attrs.get("id", "") @@ -368,19 +377,21 @@ def plot_hodograph( import pandas as pd instr_id = ds.attrs.get("id", "") - # Hand-managed layout: each panel appends its own colorbar via - # make_axes_locatable (so the bar tracks the equal-aspect square axes), which - # does not compose with constrained_layout — so lay out manually and mark the - # figure so the encoder skips tight_layout. - fig, axes = plt.subplots(1, 2, figsize=(report_tokens.W_FULL, 4.5)) - fig._manual_layout = True # noqa: SLF001 — encoder layout opt-out (see _encode._manages_own_layout) - fig.subplots_adjust(left=0.07, right=0.97, top=0.88, bottom=0.12, wspace=0.32) + # Deterministic square-panel layout + one shared time colorbar — the same + # 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 + ) + ax_raw, ax_eddy = axes[0, 0], axes[0, 1] if instr_id: fig.suptitle(instr_id) if u_var not in ds.data_vars or v_var not in ds.data_vars: - for ax in axes: + for ax in axes.ravel(): ax.set_visible(False) + if cax is not None: + cax.set_visible(False) fig.text( 0.5, 0.5, @@ -395,7 +406,7 @@ def plot_hodograph( east_raw = ds[u_var].values.astype(float).ravel() north_raw = ds[v_var].values.astype(float).ravel() t = ds["time"].values - units = ds[u_var].attrs.get("units", "m s⁻¹") + units = params.vunit("east_velocity") or ds[u_var].attrs.get("units", "m s⁻¹") dt_s = ( float(np.median(np.diff(t) / np.timedelta64(1, "s"))) if len(t) > 1 else 3600.0 @@ -425,78 +436,30 @@ def plot_hodograph( # Time fraction 0→1 for colour encoding t_frac = np.linspace(0.0, 1.0, len(east_raw)) - bounds = _nice_colorbar_bounds(0.0, 1.0, n=10) - norm = mcolors.BoundaryNorm(bounds, ncolors=256) - cmap = plt.get_cmap("viridis") - def _panel(ax: plt.Axes, e: np.ndarray, n: np.ndarray, title: str) -> None: + def _panel(ax: plt.Axes, e: np.ndarray, n: np.ndarray, title: str) -> Any: mask = np.isfinite(e) & np.isfinite(n) - if mask.sum() == 0: + if mask.sum() < 2: ax.text( 0.5, 0.5, "No data", transform=ax.transAxes, ha="center", va="center" ) - else: - ax.scatter( - e[mask], - n[mask], - c=t_frac[mask], - cmap=cmap, - norm=norm, - s=4, - alpha=0.8, - linewidths=0, - marker="o", - rasterized=True, - ) - idx = np.where(mask)[0] - ax.scatter( - e[idx[0]], - n[idx[0]], - s=55, - c="lime", - zorder=5, - marker="o", - edgecolors="black", - linewidths=0.5, - label="Start", - ) - ax.scatter( - e[idx[-1]], - n[idx[-1]], - s=65, - c="red", - zorder=5, - marker="s", - edgecolors="black", - linewidths=0.5, - label="End", - ) - ax.legend(loc="upper right", framealpha=0.7) - ax.axhline(0, color="#bbb", lw=0.7, zorder=0) - ax.axvline(0, color="#bbb", lw=0.7, zorder=0) - ax.set_aspect("equal", adjustable="box") - 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.4) + ax.set_title(title) + return None + return hodograph_panel(ax, e[mask], n[mask], t_frac[mask], title, units) - _panel(axes[0], east, north, f"Raw ({smooth_hours:.0f}-h smoothed)") - _panel( - axes[1], + sm = _panel(ax_raw, east, north, f"Raw ({smooth_hours:.0f}-h smoothed)") + sm_eddy = _panel( + ax_eddy, e_eddy, n_eddy, f"Eddy ({lp_days:.0f}-day LP removed, {smooth_hours:.0f}-h smoothed)", ) + sm = sm or sm_eddy - sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm) - sm.set_array([]) - fig.colorbar( - sm, - ax=axes, - label="Time (0 = start, 1 = end of record)", - shrink=0.8, - ticks=bounds, - ) + if sm is not None: + unit_colorbar(cax, sm, ticks=np.array([0.0, 1.0]), ticklabels=["start", "end"]) + elif cax is not None: + cax.set_visible(False) return fig @@ -598,7 +561,7 @@ def plot_aquadopp_speed_profile( color="#333", ) - units = ( + units = params.vunit("speed") or ( ds[speed_var].attrs.get("units", "m/s") if speed_var in ds.data_vars else "m/s" ) ax.set_xlabel(f"Current speed ({units})") @@ -708,7 +671,10 @@ def plot_adcp_trajectories( _bounds = _nice_colorbar_bounds(hab_min, hab_max, n=_n_hab) norm: mcolors.BoundaryNorm = mcolors.BoundaryNorm(_bounds, ncolors=256) - fig, ax = plt.subplots(figsize=(report_tokens.W_FULL, 5)) + # 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) + ax = axes[0, 0] for hab, x, y in trajs: points = np.array([x, y]).T.reshape(-1, 1, 2) @@ -719,19 +685,22 @@ def plot_adcp_trajectories( sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm) sm.set_array([]) - fig.colorbar(sm, ax=ax, label="HAB (m)", shrink=0.75, ticks=_bounds) + unit_colorbar(cax, sm, unit="m", ticks=_bounds[::2]) ax.plot(0, 0, "o", color="black", markersize=7, zorder=6, label="Start") ax.legend(fontsize=8, loc="upper left") - ax.autoscale_view() + _all_x = np.concatenate([x for _, x, _ in trajs]) + _all_y = np.concatenate([y for _, _, y in trajs]) + xlim, ylim = square_limits(_all_x, _all_y) + ax.set_xlim(*xlim) + ax.set_ylim(*ylim) ax.set_xlabel("East displacement (km)") ax.set_ylabel("North displacement (km)") 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="box") + ax.set_aspect("equal", adjustable="datalim") ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.4) ax.set_title("ADCP bins coloured by HAB") - fig.tight_layout() return fig @@ -810,6 +779,9 @@ def _masked(flag_mask: "np.ndarray") -> "tuple[np.ndarray, np.ndarray]": fig, axs = plt.subplots( 1, ncols, + # 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), subplot_kw={"projection": "polar"}, squeeze=False, @@ -925,6 +897,9 @@ def draw_rose_grid( 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) axs_flat = axs.flatten() for plot_i, instr_i in enumerate(aqd_idx): @@ -1002,6 +977,8 @@ def draw_grid_rose(ds: "xr.Dataset", max_roses: int = 4) -> "Optional[plt.Figure subplot_kw={"projection": "polar"}, squeeze=False, ) + # Match the instrument-page rose left-right spacing (polar → no tight_layout). + fig.subplots_adjust(wspace=0.5) axs_flat = axs.flatten() for plot_i, k in enumerate(valid_idx): @@ -1065,7 +1042,8 @@ 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, ax = plt.subplots(figsize=(report_tokens.W_HALF, 5)) + fig, axes, cax = square_axes_grid(report_tokens.W_HALF, 1, 1) + ax = axes[0, 0] for p_val, x, y in trajs: points = np.array([x, y]).T.reshape(-1, 1, 2) @@ -1076,16 +1054,20 @@ def draw_grid_trajectory(ds: "xr.Dataset") -> "Optional[plt.Figure]": sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm) sm.set_array([]) - fig.colorbar(sm, ax=ax, label=params.vlabel("pressure"), shrink=0.75, ticks=_bounds) + unit_colorbar(cax, sm, unit=params.vunit("pressure"), ticks=_bounds[::2]) ax.plot(0, 0, "o", color="black", markersize=6, zorder=6, label="Start") ax.legend(fontsize=8, loc="upper left") - ax.autoscale_view() + _all_x = np.concatenate([x for _, x, _ in trajs]) + _all_y = np.concatenate([y for _, _, y in trajs]) + xlim, ylim = square_limits(_all_x, _all_y) + ax.set_xlim(*xlim) + ax.set_ylim(*ylim) ax.set_xlabel("East displacement (km)") ax.set_ylabel("North displacement (km)") 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="box") + ax.set_aspect("equal", adjustable="datalim") ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.4) return fig @@ -1450,7 +1432,6 @@ def draw_adcp_hodograph( Figure, or None if velocity data are absent or insufficient. """ - import matplotlib.pyplot as plt import xarray as xr with xr.open_dataset(nc_path, decode_timedelta=False) as ds: @@ -1486,7 +1467,7 @@ def draw_adcp_hodograph( if range_coord is not None else np.arange(n_bins, dtype=float) ) - units = ds[u_var].attrs.get("units", "m s⁻¹") + units = params.vunit("east_velocity") or ds[u_var].attrs.get("units", "m s⁻¹") if "time" in ds.coords and ds["time"].size >= 2: t_ns = ds["time"].values.astype("datetime64[ns]").astype(float) @@ -1515,10 +1496,11 @@ def draw_adcp_hodograph( from oceanarray.reports._plots import _draw_hodograph_pair - fig, axes = plt.subplots(2, 2, figsize=(report_tokens.W_FULL, 9)) - fig.subplots_adjust(hspace=0.55, wspace=0.45) + # 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) - _draw_hodograph_pair( + sm_far = _draw_hodograph_pair( axes[0, 0], axes[0, 1], east_2d[:, i_far], @@ -1529,7 +1511,7 @@ def draw_adcp_hodograph( units, dt_s, ) - _draw_hodograph_pair( + sm_near = _draw_hodograph_pair( axes[1, 0], axes[1, 1], east_2d[:, i_near], @@ -1541,6 +1523,14 @@ def draw_adcp_hodograph( dt_s, ) + sm = sm_far or sm_near + if sm is not None: + unit_colorbar( + cax, sm, ticks=np.array([0.0, 1.0]), ticklabels=["start", "end"] + ) + elif cax is not None: + cax.set_visible(False) + return fig @@ -1568,8 +1558,6 @@ def draw_grid_hodograph( Figure, or None if velocity data are absent or insufficient. """ - import matplotlib.pyplot as plt - if "east_velocity" not in ds or "north_velocity" not in ds: return None east_2d = ds["east_velocity"].values.astype(float) # (time, pressure) @@ -1594,7 +1582,7 @@ def draw_grid_hodograph( else: pressure = np.arange(n_levels, dtype=float) - units = ds["east_velocity"].attrs.get("units", "m s⁻¹") + units = params.vunit("east_velocity") or ds["east_velocity"].attrs.get("units", "m s⁻¹") if "time" in ds.coords and ds["time"].size >= 2: t_ns = ds["time"].values.astype("datetime64[ns]").astype(float) @@ -1622,10 +1610,12 @@ def draw_grid_hodograph( smooth_n = max(3, int(round(smooth_hours * 3600.0 / dt_s))) - fig, (ax_shallow, ax_deep) = plt.subplots( - 1, 2, figsize=(report_tokens.W_FULL, 4.5), constrained_layout=True - ) + # 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) + ax_shallow, ax_deep = axes[0, 0], axes[0, 1] + sm = None for ax, i_lev, label in [ (ax_shallow, i_shallow, f"Shallow ({label_shallow})"), (ax_deep, i_deep, f"Deep ({label_deep})"), @@ -1640,7 +1630,7 @@ def draw_grid_hodograph( ax.set_title(label) continue t_frac = np.linspace(0.0, 1.0, len(east_2d[:, i_lev]))[mask] - hodograph_panel( + sm = hodograph_panel( ax, e_sm[mask], n_sm[mask], @@ -1649,4 +1639,11 @@ def draw_grid_hodograph( units, ) + if sm is not None: + unit_colorbar( + cax, sm, ticks=np.array([0.0, 1.0]), ticklabels=["start", "end"] + ) + elif cax is not None: + cax.set_visible(False) + return fig diff --git a/oceanarray/plotters/diagnostic.py b/oceanarray/plotters/diagnostic.py index 715df73..7e47476 100644 --- a/oceanarray/plotters/diagnostic.py +++ b/oceanarray/plotters/diagnostic.py @@ -30,8 +30,6 @@ Tier-1 primitives: plot_vector_heatmap (for T-S, U-V, any pair), plot_spectrum (any 1D time series), plot_polar_histogram (current rose). - -See .claude/plotters_update-20260718.md for migration checklist. """ from __future__ import annotations @@ -42,6 +40,8 @@ import numpy as np +from .helpers import grid_despine +from .primitives import date_offset_left from .. import parameters as params from oceanarray.config import report_tokens @@ -158,7 +158,7 @@ def _instrument_panels( actual_units = ds[vname].attrs.get("units", "") if actual_units: label = _re.sub(r"\[.*?\]", f"[{actual_units}]", label) - out.append((vname, label, color, invert)) + out.append((vname, label, params.VAR_COLORS.get(vname, color), invert)) return out @@ -388,7 +388,9 @@ def plot_knockdown_hab( box_width = max(2.0, hab_range / (len(hab_nom_vals) * 2)) with plt.style.context(str(params.MPLSTYLE)): - fig, ax = plt.subplots(figsize=(5, 5)) + # 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)) p_max_all = 0.0 for hab_nom, _serial, actual_p in hab_records: @@ -492,7 +494,10 @@ def plot_knockdown_anomaly( box_width = max(5.0, p_range / (len(p_nom_vals) * 2)) with plt.style.context(str(params.MPLSTYLE)): - fig, ax = plt.subplots(figsize=(5, max(3, len(records) * 0.4 + 1))) + # 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)) + ) for p_nom, _serial, actual_p in records: anomaly = actual_p - p_nom # positive = knocked down deeper @@ -514,6 +519,10 @@ def plot_knockdown_anomaly( ax.invert_yaxis() ax.axvline(0, color="k", lw=0.8, zorder=3) + # Anchor the left edge at exactly 0 when everything is knocked down + # (positive); keep any genuine negative (shallower-than-nominal) anomalies + # visible rather than clipping them. + ax.set_xlim(left=min(0.0, ax.get_xlim()[0])) ax.set_xlabel("Pressure anomaly (dbar) — positive = deeper than nominal") ax.set_ylabel("Nominal pressure (dbar)") @@ -603,7 +612,9 @@ def plot_knockdown_displacement( 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=(10, 5), sharey=True, sharex=True) + fig, (ax1, ax2) = plt.subplots( + 1, 2, figsize=(report_tokens.W_FULL, 4.5), sharey=True, sharex=True + ) # --- left panel: scatter --- for serial, x_thin, p_thin, color in scatter_data: @@ -656,15 +667,17 @@ def plot_knockdown_displacement( h_vals = H_norm[np.isfinite(H_norm)] if len(h_vals): + # Start the scale at 0 and thin the tick labels to ~6 nice values + # (was ~15 ticks like 0.015 / 0.045 / 0.075). bounds = _nice_colorbar_bounds( - float(np.nanmin(h_vals)), float(np.nanpercentile(h_vals, 98)), n=15 + 0.0, float(np.nanpercentile(h_vals, 98)), n=15 ) norm = mcolors.BoundaryNorm(bounds, ncolors=256) mesh = ax2.pcolormesh(x_edges, p_edges, H_norm.T, norm=norm, cmap="YlOrRd") fig.colorbar( mesh, ax=ax2, - ticks=bounds, + ticks=bounds[:: max(1, len(bounds) // 6)], label="Normalised density (sum = 1 per instrument)", ) @@ -810,22 +823,29 @@ def plot_clock_offset_check( locator = mdates.AutoDateLocator() ax.xaxis.set_major_locator(locator) ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator)) + date_offset_left(ax) + + # No instrument fell inside any window (e.g. deploy/recover times outside + # the data range) — skip the figure rather than emit an empty axis, which + # matplotlib would date-label at the 1970 epoch. + if not plotted_serials: + plt.close(fig) + return None - if plotted_serials: - from matplotlib.lines import Line2D + from matplotlib.lines import Line2D - handles = [ - Line2D([0], [0], color=colors[s], lw=1.0, label=str(s)) - for s in series - if s in plotted_serials - ] - fig.legend( - handles=handles, - loc="lower center", - ncol=min(len(handles), 6), - bbox_to_anchor=(0.5, 0.01), - frameon=True, - ) + handles = [ + Line2D([0], [0], color=colors[s], lw=1.0, label=str(s)) + for s in series + if s in plotted_serials + ] + fig.legend( + handles=handles, + loc="lower center", + ncol=min(len(handles), 6), + bbox_to_anchor=(0.5, 0.01), + frameon=True, + ) # Reserve space for the below-axes legend and keep it: mark the figure # manual so the encoder does not re-run tight_layout and undo the reserve @@ -1296,7 +1316,7 @@ def draw_data_histogram(nc_path: Path) -> "Optional[plt.Figure]": ax.set_yscale("log") ax.set_xlabel(ylabel) if ax.get_subplotspec().is_first_col(): - ax.set_ylabel("log₁₀(count)") + ax.set_ylabel(r"$\log_{10}$(count)") s_min = s_max = f_min = f_max = None if has_qc: @@ -1457,6 +1477,8 @@ def draw_velocity_iqr_profile(ds: "xr.Dataset") -> "Optional[plt.Figure]": ) if n_panels == 1: axs = [axs] + for _ax in axs: + grid_despine(_ax) ax_iter = iter(axs[:-1]) # last panel reserved for count diff --git a/oceanarray/plotters/helpers.py b/oceanarray/plotters/helpers.py index ca2ea93..e597345 100644 --- a/oceanarray/plotters/helpers.py +++ b/oceanarray/plotters/helpers.py @@ -11,8 +11,6 @@ Note: _fig_to_base64 stays in report/_html_helpers.py (called only by Tier-3 wrappers in report/_plots.py; plotters/ never serialises to base64). - -See .claude/plotters_update-20260718.md for migration checklist. """ from __future__ import annotations @@ -25,6 +23,77 @@ import matplotlib.pyplot as plt +def grid_despine(ax: "plt.Axes") -> 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. + + Parameters + ---------- + ax : matplotlib.axes.Axes + Axes to style. + + """ + ax.grid(True) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + + +def ordered_line_colors( + cmap_name: str, n: int, *, max_luminance: float = 0.72 +) -> "list": + """Return *n* colours from *cmap_name*, in colormap order, skipping pale ones. + + Samples the colormap on a fine grid, keeps only positions whose relative + luminance is ``<= max_luminance`` (so no line washes out against white), then + returns *n* colours evenly spaced across the usable positions. For a + diverging colormap this drops the pale midpoint, leaving two saturated arcs; + for a sequential one it drops the pale end. Callers assign the colours in a + fixed order (e.g. deep-first) so the darkest end maps to the intended extreme. + + Parameters + ---------- + cmap_name : str + Matplotlib colormap name. + n : int + Number of colours to return (>= 1). + max_luminance : float + Rec. 709 relative-luminance ceiling in ``[0, 1]``; positions lighter than + this are excluded. Default 0.72 — low enough that the least-saturated + remaining colour on a diverging map (the arc boundary either side of the + excluded pale centre) is still legible on white. + + Returns + ------- + list of RGBA tuples + *n* colours (length exactly ``max(n, 1)``). + + """ + import matplotlib.pyplot as plt + + cmap = plt.get_cmap(cmap_name) + grid = np.linspace(0.0, 1.0, 256) + cols = cmap(grid) + lum = 0.2126 * cols[:, 0] + 0.7152 * cols[:, 1] + 0.0722 * cols[:, 2] + mask = lum <= max_luminance + usable = grid[mask] + usable_lum = lum[mask] + if usable.size == 0: + usable, usable_lum = grid, lum + n = max(n, 1) + if n == 1: + # A single line: order carries no meaning, so pick the darkest (most + # saturated) usable colour rather than the midpoint, which on a diverging + # map lands in the pale gap between the two arcs. + picks = [usable[int(np.argmin(usable_lum))]] + else: + idx = np.linspace(0, usable.size - 1, n).round().astype(int) + picks = usable[idx] + return [cmap(float(f)) for f in picks] + + # --------------------------------------------------------------------------- # QC colours and marker styles (OceanSITES Reference Table 2) # Defined here (Tier 2) so both plotters/ and report/ can import without diff --git a/oceanarray/plotters/hydrography.py b/oceanarray/plotters/hydrography.py index f50cc2f..18a6c4b 100644 --- a/oceanarray/plotters/hydrography.py +++ b/oceanarray/plotters/hydrography.py @@ -19,6 +19,7 @@ import xarray as xr from .primitives import colorbar_norm, date_axis +from .helpers import grid_despine, ordered_line_colors from .. import parameters as params from oceanarray.config import report_tokens @@ -64,12 +65,13 @@ def draw_isopycnal_ts_fig(ds_iso: "xr.Dataset") -> "Optional[plt.Figure]": window = max(1, int(round(3600.0 / dt_s))) n_levels = len(sigma_vals) - cmap = plt.get_cmap("Blues") - # offset from 0.25 to avoid near-white; upper end capped at 0.95 - color_norms = np.linspace(0.25, 0.95, max(n_levels, 1)) - colors = [cmap(v) for v in color_norms] + # Density colormap, densest (darkest) → lightest, with too-pale colours + # dropped by luminance so every isopycnal line stays readable. + _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, 4)) + fig, ax = plt.subplots(figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN)) + grid_despine(ax) for i, (sval, col) in enumerate(zip(sigma_vals, colors)): h = height[i, :] @@ -89,7 +91,7 @@ def draw_isopycnal_ts_fig(ds_iso: "xr.Dataset") -> "Optional[plt.Figure]": vmax=float(sigma_vals.max()), n=min(n_levels, 20), ) - sm = plt.cm.ScalarMappable(cmap="Blues", norm=norm) + sm = plt.cm.ScalarMappable(cmap=_dens_cmap, norm=norm) sm.set_array([]) cb = fig.colorbar(sm, ax=ax, ticks=bounds, shrink=0.85, pad=0.02) cb.set_label(params.vlabel("potential_density")) @@ -244,6 +246,8 @@ def _bar_color(p: float) -> str: sharey=True, gridspec_kw={"width_ratios": [0.8, 1.0, 1.2]}, ) + for _ax in (ax0, ax1, ax2): + grid_despine(_ax) # ---- Panel 0: sigma0 histogram ---- ax0.barh(hist_centers, hist_pct, height=0.09, color="#7fb3d3", edgecolor="none") @@ -381,8 +385,14 @@ def draw_overflow_temperature_fig(ds: "xr.Dataset") -> "Optional[plt.Figure]": .values ) - fig, ax = plt.subplots(figsize=(report_tokens.W_FULL, 3)) - ax.plot(time_vals, temp_med, color="#1a3a5c", lw=1.0) + fig, ax = plt.subplots(figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN)) + grid_despine(ax) + ax.plot( + time_vals, + temp_med, + color=params.VAR_COLORS.get("temperature", "#1a3a5c"), + lw=1.0, + ) ax.set_ylabel(params.vlabel("temperature")) hab = waterdepth - actual_p ax.set_title( diff --git a/oceanarray/plotters/primitives.py b/oceanarray/plotters/primitives.py index 680f0e4..0671077 100644 --- a/oceanarray/plotters/primitives.py +++ b/oceanarray/plotters/primitives.py @@ -19,12 +19,13 @@ import matplotlib.colors as mcolors import matplotlib.dates as mdates import matplotlib.pyplot as plt +import matplotlib.ticker as mticker import numpy as np from matplotlib.collections import LineCollection from .. import parameters as params from oceanarray.config import report_tokens -from ..utilities import _nice_colorbar_bounds +from ..utilities import _nice_colorbar_bounds, nice_colorbar_ticks def pcolormesh_panel( @@ -95,11 +96,21 @@ def pcolormesh_panel( pc = ax.pcolormesh( time, pressure, data, shading="nearest", cmap=cmap, norm=norm ) - label = ( - cb_label if cb_label is not None else (f"{title} ({units})" if units else title) + cb = fig.colorbar( + pc, + ax=ax, + pad=0.02, + ticks=nice_colorbar_ticks(float(bounds[0]), float(bounds[-1])), ) - cb = fig.colorbar(pc, ax=ax, pad=0.02, ticks=bounds[::2]) - cb.set_label(label) + if cb_label is not None: + # Explicit full label requested — keep it as a side label. + cb.set_label(cb_label) + elif units: + # Units-only, above the bar (saves width, reads cleanly); the panel title + # already carries the variable name. + cb.ax.set_title(units, fontsize=report_tokens.ANNOT_FS) + else: + cb.set_label(title) pressure_axis(ax) if date_fmt: date_axis(ax) @@ -107,6 +118,236 @@ def pcolormesh_panel( return pc +# --------------------------------------------------------------------------- +# Deterministic square-axes-plus-colorbar layout (spec §11; mirrors the +# inch-based cruise-map layout in ctdcast plots.py::_map_layout). +# --------------------------------------------------------------------------- +# Fixed inch reservations, independent of figure width. The lesson from the +# maps work: matplotlib's auto-layout tools (`set_aspect`, `make_axes_locatable`, +# `tight_layout`) fight each other and strand the colorbar (the recurring +# "colorbar too tall" bug) — instead, size every square panel in inches and place +# the axes and its colorbar by hand so the colorbar shares the panel's exact +# pixel height by construction. +_SQ_LABEL_IN: float = 0.62 # y-tick labels + rotated y-axis label +_SQ_XTICK_IN: float = 0.52 # x-tick labels + x-axis label +_SQ_TITLE_IN: float = 0.30 # per-panel title row +_SQ_WGAP_IN: float = 0.78 # horizontal gap between columns (room for y-labels) +_SQ_HGAP_IN: float = 0.72 # vertical gap between rows (title + x-labels) +_SQ_CBAR_GAP_IN: float = 0.14 # gap between grid and shared colorbar +_SQ_CBAR_W_IN: float = 0.16 # colorbar bar width +_SQ_CBAR_TXT_IN: float = 0.52 # colorbar tick text + unit title width +# Per-panel colorbars carry only short tick numbers (the unit is a title on top), +# so they need far less text width than the shared bar — this keeps the squares +# big. The inter-cell gap is also tighter (each cell already reserves its own +# left y-label + right colorbar). +_SQ_CBAR_TXT_PP_IN: float = 0.30 +_SQ_WGAP_PP_IN: float = 0.34 + + +def square_axes_grid( + fig_w: float, + nrows: int, + ncols: int, + *, + colorbar: bool = True, + per_panel_colorbar: bool = False, + top_pad_in: float = 0.0, + bottom_pad_in: float = 0.0, +) -> "tuple[plt.Figure, np.ndarray, Any]": + """Lay out an ``nrows × ncols`` grid of square axes deterministically in inches. + + Every panel is an exact square whose side is computed from the usable width, + so an equal-aspect plot fills the panel without ``set_aspect`` having to + shrink the axes box — and a colorbar placed here shares each panel's exact + pixel height rather than the taller subplot cell. A single shared colorbar + axes (spanning the full height of the panel stack) is reserved on the right + when *colorbar* is true. ``fig._manual_layout`` is set so the base64 encoder + skips ``tight_layout`` (which would re-flow these hand-placed axes). + + Callers must NOT call ``set_aspect('equal', adjustable='box')`` on the + returned axes: the box is already square and authoritative, so use symmetric + limits (or ``adjustable='datalim'``) instead, or the box would resize and + strand the shared colorbar again. + + Parameters + ---------- + fig_w : float + Figure width in inches — must equal the display slot so the PNG is not + rescaled by the browser. + nrows, ncols : int + Grid shape (both >= 1). + colorbar : bool + Reserve and return a single shared colorbar axes on the right. Default + True. Ignored when *per_panel_colorbar* is set. + per_panel_colorbar : bool + Give **each** panel its own height-matched colorbar axes to its right + (for figures where every panel encodes a different field, e.g. a T-S dot + plot + count heatmap + O₂ panel). The third return value is then an + ``(nrows, ncols)`` object array of colorbar axes instead of a single one. + top_pad_in : float + Extra inches reserved above the panel-title strip, e.g. for a figure + ``suptitle``. Default 0. + 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. + + Returns + ------- + tuple of (matplotlib.figure.Figure, numpy.ndarray, object) + The figure; an ``(nrows, ncols)`` object array of panel axes; and the + colorbar axes — a single shared ``Axes`` (or None) normally, or an + ``(nrows, ncols)`` object array when *per_panel_colorbar* is set. + + """ + _cbar_reserve = _SQ_CBAR_GAP_IN + _SQ_CBAR_W_IN + _SQ_CBAR_TXT_IN + 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 + avail_w = fig_w - ncols * per_cell_fixed - (ncols - 1) * _wgap + else: + _wgap = _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 + 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 + fig_h = _SQ_TITLE_IN + grid_h + bottom_in + top_pad_in + + fig = plt.figure(figsize=(fig_w, fig_h)) + fig._manual_layout = True # noqa: SLF001 — encoder tight_layout opt-out + axes = np.empty((nrows, ncols), dtype=object) + caxes = np.empty((nrows, ncols), dtype=object) if per_panel_colorbar else None + for r in range(nrows): + for c in range(ncols): + if per_panel_colorbar: + x0 = c * (side + per_cell_fixed + _wgap) + _SQ_LABEL_IN + else: + x0 = _SQ_LABEL_IN + c * (side + _wgap) + # Row 0 at the top; y measured from the figure bottom. + y0 = bottom_in + (nrows - 1 - r) * (side + _SQ_HGAP_IN) + axes[r, c] = fig.add_axes( + [x0 / fig_w, y0 / fig_h, side / fig_w, side / fig_h] + ) + if per_panel_colorbar: + # Colorbar height == this panel's side (matched by construction). + _pcx0 = x0 + side + _SQ_CBAR_GAP_IN + caxes[r, c] = fig.add_axes( + [_pcx0 / fig_w, y0 / fig_h, _SQ_CBAR_W_IN / fig_w, side / fig_h] + ) + + if per_panel_colorbar: + return fig, axes, caxes + + cax = None + if colorbar: + cx0 = ( + _SQ_LABEL_IN + + ncols * side + + (ncols - 1) * _SQ_WGAP_IN + + _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) + cax = fig.add_axes( + [cx0 / fig_w, bottom_in / fig_h, _SQ_CBAR_W_IN / fig_w, grid_h / fig_h] + ) + return fig, axes, cax + + +def square_limits( + x: np.ndarray, + y: np.ndarray, + *, + pad_frac: float = 0.05, +) -> "tuple[tuple[float, float], tuple[float, float]]": + """Return ``(xlim, ylim)`` framing *x*, *y* as an equal-extent square. + + The larger of the x and y data ranges is applied to both axes (each centred + on its own data midpoint), so an equal-aspect plot of the data is square and + neither axis is a thin strip. Non-finite values are ignored; a degenerate + (zero-extent) input falls back to a unit square. Choose the limits with this + helper *before* placing a square axes so the colorbar sizing stays exact. + + Parameters + ---------- + x, y : numpy.ndarray + Data coordinates (any shape; flattened, non-finite dropped). + pad_frac : float + Fractional padding added to the half-extent on all sides. Default 0.05. + + Returns + ------- + tuple of (tuple of float, tuple of float) + ``((x0, x1), (y0, y1))``. + + """ + xf = np.asarray(x)[np.isfinite(x)] + yf = np.asarray(y)[np.isfinite(y)] + if xf.size == 0 or yf.size == 0: + return (-1.0, 1.0), (-1.0, 1.0) + xmid = 0.5 * (float(xf.min()) + float(xf.max())) + ymid = 0.5 * (float(yf.min()) + float(yf.max())) + half = 0.5 * max(float(xf.max() - xf.min()), float(yf.max() - yf.min())) + half = half * (1.0 + pad_frac) + if half <= 0: + half = 1.0 + return (xmid - half, xmid + half), (ymid - half, ymid + half) + + +def unit_colorbar( + cax: Any, + mappable: Any, + *, + unit: str = "", + ticks: Optional[np.ndarray] = None, + ticklabels: Optional[list[str]] = None, +) -> Any: + """Draw *mappable*'s colorbar into the pre-placed *cax* with the unit on top. + + The unit is rendered as a title above the bar (``cax.set_title``) rather than + a rotated side label, which saves horizontal width and reads cleanly — the + same convention as the cruise-map depth colorbar. *cax* is expected to come + from :func:`square_axes_grid` so its height already matches the plotted + square. + + Parameters + ---------- + cax : matplotlib Axes + Pre-placed colorbar axes. + mappable : matplotlib ScalarMappable + The artist (LineCollection, pcolormesh, ScalarMappable, ...) to map. + unit : str + Unit string placed above the bar (e.g. ``"m s⁻¹"``). Empty renders no + title. + ticks : numpy.ndarray, optional + Explicit colorbar tick positions. + ticklabels : list of str, optional + Explicit tick labels (same length as *ticks*), e.g. + ``["start", "end"]`` for a fractional-time bar. + + Returns + ------- + matplotlib.colorbar.Colorbar + + """ + cb = cax.figure.colorbar(mappable, cax=cax, ticks=ticks) + if ticklabels is not None: + if ticks is None or len(ticklabels) != len(ticks): + raise ValueError("ticklabels must match ticks in length") # noqa: TRY003 + # Pin the locator to the given ticks so the labels can't drift onto + # auto-placed positions (FixedLocator/FixedFormatter must agree). + cax.yaxis.set_major_locator(mticker.FixedLocator(list(ticks))) + cax.set_yticklabels(ticklabels) + if unit: + cax.set_title(unit, fontsize=report_tokens.ANNOT_FS) + return cb + + def plot_trajectory( x: np.ndarray, y: np.ndarray, @@ -115,13 +356,17 @@ def plot_trajectory( xlabel: str = "East displacement (m)", ylabel: str = "North displacement (m)", colorbar_label: str = "", + colorbar_unit: str = "", title: str = "", ) -> plt.Figure: """Plot a 2D trajectory, optionally coloured per-segment by a scalar field. When *color_data* is provided, segments are drawn as a LineCollection with - colours mapped through *cmap*. When omitted, a plain line is drawn with - green and red markers at the start and end respectively. + colours mapped through *cmap* and a height-matched colorbar (unit as a title + on top). When omitted, a plain line is drawn. Start and end are marked with + green and red markers. The axes are laid out as an exact square via + :func:`square_axes_grid` with :func:`square_limits`, so the colorbar height + always matches the plotted square. Parameters ---------- @@ -134,7 +379,9 @@ def plot_trajectory( xlabel, ylabel : str Axis labels. colorbar_label : str - Label for the colorbar (only shown when color_data is provided). + Fallback colorbar label placed on top when *colorbar_unit* is empty. + colorbar_unit : str + Unit string placed above the colorbar (units-only on top, saves width). title : str Figure title. @@ -143,7 +390,10 @@ def plot_trajectory( matplotlib.figure.Figure """ - fig, ax = plt.subplots(figsize=(report_tokens.W_HALF, 6), constrained_layout=True) + fig, axes, cax = square_axes_grid( + report_tokens.W_HALF, 1, 1, colorbar=color_data is not None + ) + ax = axes[0, 0] if color_data is not None: points = np.array([x, y]).T.reshape(-1, 1, 2) @@ -154,18 +404,16 @@ def plot_trajectory( lc = LineCollection(segments, cmap=cmap, norm=norm, linewidth=1.5) lc.set_array(color_data[:-1]) ax.add_collection(lc) - fig.colorbar(lc, ax=ax, label=colorbar_label, shrink=0.8, ticks=bounds[::2]) - ax.set_xlim( - np.nanmin(x) - 0.05 * (np.nanmax(x) - np.nanmin(x) + 1), - np.nanmax(x) + 0.05 * (np.nanmax(x) - np.nanmin(x) + 1), - ) - ax.set_ylim( - np.nanmin(y) - 0.05 * (np.nanmax(y) - np.nanmin(y) + 1), - np.nanmax(y) + 0.05 * (np.nanmax(y) - np.nanmin(y) + 1), + unit_colorbar( + cax, lc, unit=colorbar_unit or colorbar_label, ticks=bounds[::2] ) else: ax.plot(x, y, color="steelblue", linewidth=1.5) + xlim, ylim = square_limits(x, y) + ax.set_xlim(*xlim) + ax.set_ylim(*ylim) + # Start/end markers ax.plot(x[0], y[0], "o", color="green", markersize=8, label="Start", zorder=5) ax.plot(x[-1], y[-1], "s", color="red", markersize=8, label="End", zorder=5) @@ -177,7 +425,9 @@ def plot_trajectory( ax.set_title(title) 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="box") + # 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) return fig @@ -189,50 +439,52 @@ def hodograph_panel( t_frac: np.ndarray, title: str, units: str, -) -> None: - """Draw a single velocity hodograph panel on *ax*. +) -> Any: + """Draw a single velocity hodograph on a pre-squared *ax*; return its mappable. Renders a time-coloured ``LineCollection`` trajectory (downsampled to - <= 2 000 segments for performance) with start/end markers and a compact - per-panel colorbar. *e_v*, *n_v*, and *t_frac* must already be filtered - to the same finite-valid indices (no NaN, same length). + <= 2 000 segments for performance) with start/end markers, symmetric + ``-lim..lim`` limits, and a subtle grid. The panel does NOT draw its own + colorbar — the caller owns one shared colorbar (all panels use the same 0->1 + plasma time mapping); pass the returned ``ScalarMappable`` to + :func:`unit_colorbar` on a shared ``cax`` from :func:`square_axes_grid`. + + *e_v*, *n_v*, and *t_frac* must already be filtered to the same finite-valid + indices (no NaN, same length). *ax* is expected to be an exact square from + :func:`square_axes_grid`; equal aspect is enforced with + ``adjustable='datalim'`` so the box is never resized (which would strand the + shared colorbar). Parameters ---------- ax : matplotlib Axes - Target axes to draw on. + Pre-squared target axes to draw on. e_v, n_v : np.ndarray East and north velocity (finite values only, same length). t_frac : np.ndarray Fractional deployment time 0 -> 1, same length as *e_v*. title : str - Axes title (rendered at fontsize 9). + Axes title. units : str Velocity unit string appended to axis labels, e.g. ``"m s^-1"``. - """ - from matplotlib.collections import LineCollection + Returns + ------- + matplotlib.cm.ScalarMappable + The 0->1 plasma time mapping, for a shared colorbar. + """ lim = max(float(np.nanmax(np.abs(e_v))), float(np.nanmax(np.abs(n_v))), 1e-9) * 1.1 step = max(1, len(e_v) // 2000) pts = np.array([e_v[::step], n_v[::step]]).T.reshape(-1, 1, 2) segs = np.concatenate([pts[:-1], pts[1:]], axis=1) - bounds_lc, norm_lc = colorbar_norm(vmin=0.0, vmax=1.0, n=10) + _bounds_lc, norm_lc = colorbar_norm(vmin=0.0, vmax=1.0, n=10) lc = LineCollection(segs, cmap="plasma", norm=norm_lc, lw=0.9, alpha=0.85) lc.set_array(t_frac[::step][:-1]) ax.add_collection(lc) - from mpl_toolkits.axes_grid1 import make_axes_locatable - sm = plt.cm.ScalarMappable(cmap="plasma", norm=norm_lc) sm.set_array([]) - # Tie the colorbar height to the (equal-aspect, square) axes via an appended - # cax, so it matches the plotted square rather than the taller panel cell. - cax = make_axes_locatable(ax).append_axes("right", size="4%", pad=0.05) - cb = ax.figure.colorbar(sm, cax=cax, ticks=bounds_lc) - cb.set_label(r"Time $\rightarrow$") - cb.ax.set_yticks([0.0, 1.0]) - cb.ax.set_yticklabels(["start", "end"]) ax.scatter( e_v[0], @@ -256,20 +508,36 @@ def hodograph_panel( ) ax.set_xlim(-lim, lim) ax.set_ylim(-lim, lim) - ax.set_aspect("equal") + # datalim (not the default 'box'): keep the square box authoritative so the + # shared colorbar's matched height is never invalidated by an aspect resize. + ax.set_aspect("equal", adjustable="datalim") ax.axhline(0, color="#888", lw=0.7) ax.axvline(0, color="#888", lw=0.7) 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) + return sm + + +def date_offset_left(ax: Any) -> None: + """Move the x-axis date offset label (e.g. ``2026-Jul``) to the bottom-left. + + matplotlib's ``ConciseDateFormatter`` draws the year/month offset at the + bottom-right. Only the offset's vertical position is updated on each draw, so + the left x-position set here persists. Call after setting the date formatter. + """ + offset = ax.xaxis.get_offset_text() + offset.set_horizontalalignment("left") + offset.set_position((0.0, 0.0)) def date_axis(ax: Any) -> None: - """Apply a concise auto-scaled date formatter to *ax*'s x-axis.""" + """Apply a concise auto-scaled date formatter to *ax*'s x-axis (offset left).""" locator = mdates.AutoDateLocator() ax.xaxis.set_major_locator(locator) ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator)) + date_offset_left(ax) def pressure_axis(ax: Any) -> None: diff --git a/oceanarray/plotters/spectrum.py b/oceanarray/plotters/spectrum.py index dffaa0c..df44657 100644 --- a/oceanarray/plotters/spectrum.py +++ b/oceanarray/plotters/spectrum.py @@ -13,8 +13,6 @@ Post-OdB remaining migrations from report/_plots.py: plot_grid_fig (was _make_grid_fig_b64), plot_grid_n2 (was _make_grid_n2_b64). - -See .claude/plotters_update-20260718.md for migration checklist. """ from __future__ import annotations @@ -27,8 +25,31 @@ from oceanarray.utilities import _nice_colorbar_bounds, period_axis_ticks from ..analysis.spectral import gonella_rotary_spectrum +from .primitives import square_axes_grid from oceanarray.config import report_tokens + +def _mark_frequency_line(ax: "plt.Axes", period: float, color: str) -> None: + """Draw a marked-frequency reference line (tidal/inertial) on a spectrum. + + Uniform style across the temperature and rotary spectra: a thin dotted + vertical line (``pen("thinner")``), so both figures mark frequencies the + same way. + + Parameters + ---------- + ax : matplotlib.axes.Axes + Spectrum axes (period on the x-axis). + period : float + Period at which to draw the line (same x-units as the axes). + color : str + Line colour. + + """ + ax.axvline( + period, color=color, lw=report_tokens.pen("thin"), ls=":", alpha=0.65 + ) + if TYPE_CHECKING: import matplotlib.axes import xr @@ -353,7 +374,12 @@ def draw_spectrum( from matplotlib.ticker import NullLocator - fig, (ax_lf, ax_hf) = plt.subplots(1, 2, figsize=(report_tokens.W_FULL, 5)) + # 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 + ) + ax_lf, ax_hf = _axes[0, 0], _axes[0, 1] x_min_lf = max(nyq_period, 10.0 / 1440.0) # right edge: Nyquist or 10 min # Left edge = longest period Welch can estimate = 1/min_freq = window length @@ -399,7 +425,7 @@ def draw_spectrum( trans_lf = blended_transform_factory(ax_lf.transData, ax_lf.transAxes) for lbl, pd_d, clr in lf_markers: if x_min_lf <= pd_d <= x_max_lf: - ax_lf.axvline(pd_d, color=clr, lw=1.0, ls="--", alpha=0.65) + _mark_frequency_line(ax_lf, pd_d, clr) ax_lf.text( pd_d, 0.03, @@ -414,7 +440,7 @@ def draw_spectrum( ax_lf.set_xlabel("Period") ax_lf.set_ylabel("PSD (°C² cpd⁻¹)") - ax_lf.set_title(f"Low-frequency -- 14-day windows (~{n_win_lf} windows)") + ax_lf.set_title(f"Low frequency\n14-day windows ({n_win_lf})") # Single shared legend -- depth labels from LF lines serve both panels ax_lf.legend( loc="upper right", title="Depth", fontsize="small", title_fontsize="small" @@ -456,7 +482,7 @@ def draw_spectrum( pd_scaled = pd_d * hf_scale lo, hi = x_min_hf * hf_scale, x_max_hf * hf_scale if lo <= pd_scaled <= hi: - ax_hf.axvline(pd_scaled, color=clr, lw=1.0, ls="--", alpha=0.65) + _mark_frequency_line(ax_hf, pd_scaled, clr) ax_hf.text( pd_scaled, y_lbl, @@ -508,11 +534,11 @@ def draw_spectrum( ax_hf.set_xlabel(f"Period ({hf_unit})") ax_hf.set_ylabel("PSD (°C² cpd⁻¹)") n_win_hf_label = str(n_win_hf) if psds_hf else "0" - ax_hf.set_title( - f"High-frequency -- {hf_seg_label} windows ({n_win_hf_label} windows, gap-aware)" - ) + # Two-line panel titles: heading + window detail, so the detail fits the + # narrow square panels without overflowing. + ax_hf.set_title(f"High frequency\n{hf_seg_label} windows ({n_win_hf_label}, gap-aware)") - fig.suptitle("Temperature power spectrum (Welch PSD per depth level)") + fig.suptitle("Temperature power spectrum — Welch PSD per depth") return fig @@ -647,8 +673,12 @@ def draw_wavelet( n_panels = len(results) # height ratios: 1 part time series, 3 parts wavelet, per level hr = [1, 3] * n_panels - fig = plt.figure(figsize=(report_tokens.W_FULL, 4.5 * n_panels)) - gs = GridSpec(2 * n_panels, 1, figure=fig, height_ratios=hr, hspace=0.08) + # 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" + ) + gs = GridSpec(2 * n_panels, 1, figure=fig, height_ratios=hr) tax: list = [] # time series axes (top of each pair) wax: list = [] # wavelet axes (bottom of each pair) @@ -664,7 +694,18 @@ 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") - tax[i].set_title(f"{pv:.0f} dbar", fontsize="small") + # Pressure level in the bottom-left corner (was a title, which overlapped + # the scalogram of the pair above). + tax[i].text( + 0.01, + 0.08, + f"{pv:.0f} dbar", + transform=tax[i].transAxes, + ha="left", + va="bottom", + fontsize="small", + bbox=dict(boxstyle="round,pad=0.15", fc="white", ec="none", alpha=0.7), + ) plt.setp(tax[i].get_xticklabels(), visible=False) # Wavelet scalogram (bottom) @@ -685,7 +726,7 @@ def draw_wavelet( # Pass all axes (wavelet + time series) so matplotlib shrinks them # all equally, keeping each wavelet panel aligned with its T panel. cbar = fig.colorbar(mappable, ax=wax + tax, fraction=0.02, pad=0.04) - cbar.set_label("log10(power) (°C² d)") + cbar.set_label(r"$\log_{10}$(power) (°C² d)") return fig @@ -869,7 +910,11 @@ def draw_grid_rotary_spectrum( cmap_cw = plt.get_cmap("Reds") cmap_ccw = plt.get_cmap("Blues") - fig, (ax_spec, ax_rot) = plt.subplots(1, 2, figsize=(report_tokens.W_FULL, 5)) + # 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 + ) + ax_spec, ax_rot = _axes[0, 0], _axes[0, 1] # Panel 1: CW (solid, reds) + CCW (dashed, blues) for s_cw, s_ccw, p in zip(s_cw_list, s_ccw_list, press_plotted): @@ -883,7 +928,7 @@ def draw_grid_rotary_spectrum( trans1 = blended_transform_factory(ax_spec.transData, ax_spec.transAxes) for label, period_d, color in markers: if x_min <= period_d <= x_max: - ax_spec.axvline(period_d, color=color, lw=1.0, ls=":", alpha=0.65) + _mark_frequency_line(ax_spec, period_d, color) ax_spec.text( period_d, 0.03, @@ -905,17 +950,24 @@ def draw_grid_rotary_spectrum( ax_spec.set_xlabel("Period") ax_spec.set_ylabel("PSD (m² s⁻² cpd⁻¹)") ax_spec.set_title("Rotary spectra") + # Depth legend replaces the pressure colorbar (frees horizontal space so the + # panels can be square): one blue (CCW-shade) swatch per level, plus the + # CW/CCW line-style key. Colour intensity = depth; red family = CW, blue = CCW. + _style_handles = [ + Line2D([0], [0], color="#c0392b", lw=1.5, label="CW (solid, red)"), + Line2D([0], [0], color="#2c7fb8", lw=1.5, ls="--", label="CCW (dashed, blue)"), + ] + _depth_handles = [ + Line2D([0], [0], color=cmap_ccw(norm_p(p)), lw=3.0, label=f"{p:.0f} dbar") + for p in press_plotted + ] ax_spec.legend( - handles=[ - Line2D([0], [0], color="red", lw=1.2, label="CW"), - Line2D([0], [0], color="blue", lw=1.2, ls="--", label="CCW"), - ], + handles=_style_handles + _depth_handles, loc="lower left", + fontsize=8, + title="Direction / depth", + framealpha=0.85, ) - sm_cw = plt.cm.ScalarMappable(cmap=cmap_cw, norm=norm_p) - sm_cw.set_array([]) - cbar_cw = fig.colorbar(sm_cw, ax=ax_spec, pad=0.03, shrink=0.85) - cbar_cw.set_label("Pressure (dbar) -- CW") # Panel 2: Rotary coefficient r -- raw (thin) + band-averaged (thick) + significance for r, r_banded, p in zip(r_list, r_banded_list, press_plotted): @@ -943,7 +995,7 @@ def draw_grid_rotary_spectrum( trans2 = blended_transform_factory(ax_rot.transData, ax_rot.transAxes) for label, period_d, color in markers: if x_min <= period_d <= x_max: - ax_rot.axvline(period_d, color=color, lw=1.0, ls=":", alpha=0.65) + _mark_frequency_line(ax_rot, period_d, color) ax_rot.text( period_d, 0.03, @@ -997,9 +1049,7 @@ def draw_grid_rotary_spectrum( fontsize=9, framealpha=0.7, ) - sm_ccw = plt.cm.ScalarMappable(cmap=cmap_ccw, norm=norm_p) - sm_ccw.set_array([]) - cbar_ccw = fig.colorbar(sm_ccw, ax=ax_rot, pad=0.03, shrink=0.85) - cbar_ccw.set_label("Pressure (dbar) -- CCW") + # Depth is conveyed by the panel-1 legend; no pressure colorbar here (keeps + # the panel square and matches the "legend not colorbar" convention). return fig diff --git a/oceanarray/plotters/timeseries.py b/oceanarray/plotters/timeseries.py index 2d0d852..8938e9f 100644 --- a/oceanarray/plotters/timeseries.py +++ b/oceanarray/plotters/timeseries.py @@ -22,8 +22,6 @@ plot_microcat_raw, plot_aquadopp_raw, plot_mooring_timeseries (the three kept plotter.py functions; §11), plot_aquadopp_quick, build_instrument_fig (was _build_fig_from_ds), plot_instrument_windows. - -See .claude/plotters_update-20260718.md for migration checklist. """ from __future__ import annotations @@ -37,8 +35,16 @@ import matplotlib.pyplot as plt import xarray as xr -from .primitives import colorbar_norm, date_axis, pressure_axis, pcolormesh_panel +from .primitives import ( + colorbar_norm, + date_axis, + date_offset_left, + pressure_axis, + pcolormesh_panel, +) +from ..utilities import nice_colorbar_ticks from .. import parameters as params +from oceanarray.config import report_tokens def draw_grid_fig( @@ -84,7 +90,9 @@ def draw_grid_fig( pressure = da.coords["pressure"].values data = da.transpose("pressure", "time").values - fig, ax = plt.subplots(figsize=(13, 4)) + fig, ax = plt.subplots( + figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN), layout="constrained" + ) bounds, norm = colorbar_norm(data, vmin=vmin, vmax=vmax, symmetric=symmetric) if style == "contourf": pc = ax.contourf(time, pressure, data, levels=bounds, cmap=cmap, extend="both") @@ -196,16 +204,20 @@ def draw_grid_hydro( pressure = ds["pressure"].values time = ds["time"].values n = len(panels) - fig, axes = plt.subplots(n, 1, figsize=(13, 3.2 * n), sharex=True, squeeze=False) + fig, axes = plt.subplots( + n, 1, figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN * n), + sharex=True, + squeeze=False, + layout="constrained", + ) for ax, (var, cmap) in zip(axes[:, 0], panels): da = ds[var] data = da.transpose("pressure", "time").values - units = da.attrs.get("units", "") - long_name = da.attrs.get("long_name", var) + units = params.vunit(var) or da.attrs.get("units", "") # Use the variable name (tidied) as the panel title — long_name often # carries the full CF phrase "sea water temperature" which is verbose - # for a plot heading. The colorbar label keeps the full long_name. + # for a plot heading. The colorbar shows units only, on top of the bar. title = var.replace("_", " ").capitalize() _lim_key = { "temperature": "t_lim", @@ -226,7 +238,7 @@ def draw_grid_hydro( vmin=_vmin, vmax=_vmax, n=_n, - cb_label=f"{long_name} ({units})" if units else long_name, + units=units, title_loc="left", date_fmt=False, ) @@ -298,7 +310,12 @@ def draw_grid_velocity_stacked(ds: "xr.Dataset") -> "Optional[plt.Figure]": div_abs_max = 1.0 n = len(present) - fig, axes = plt.subplots(n, 1, figsize=(13, 3.2 * n), sharex=True, squeeze=False) + fig, axes = plt.subplots( + n, 1, figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN * n), + sharex=True, + squeeze=False, + layout="constrained", + ) for ax, var in zip(axes[:, 0], present): data = ds[var].transpose("pressure", "time").values @@ -310,12 +327,19 @@ def draw_grid_velocity_stacked(ds: "xr.Dataset") -> "Optional[plt.Figure]": break fv = data.ravel() fv = fv[np.isfinite(fv)] - bounds, norm, cmap, cb_label = _velocity_panel_style(var, fv, div_abs_max) + bounds, norm, cmap, _cb_label = _velocity_panel_style(var, fv, div_abs_max) pc = ax.pcolormesh( time, pressure, data, shading="nearest", cmap=cmap, norm=norm ) - cb = fig.colorbar(pc, ax=ax, pad=0.02, ticks=bounds[::2]) - cb.set_label(cb_label) + cb = fig.colorbar( + pc, + ax=ax, + pad=0.02, + ticks=nice_colorbar_ticks(float(bounds[0]), float(bounds[-1])), + ) + cb.ax.set_title( + params.vunit("east_velocity"), fontsize=report_tokens.ANNOT_FS + ) pressure_axis(ax) ax.set_title(_LABELS[var], loc="left") @@ -341,12 +365,17 @@ def draw_grid_sigma(ds: "xr.Dataset") -> "Optional[plt.Figure]": pressure = ds["pressure"].values time = ds["time"].values n = len(sigma_vars) - fig, axes = plt.subplots(n, 1, figsize=(13, 3.2 * n), sharex=True, squeeze=False) + fig, axes = plt.subplots( + n, 1, figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN * n), + sharex=True, + squeeze=False, + layout="constrained", + ) for ax, sv in zip(axes[:, 0], sigma_vars): da = ds[sv] data = da.transpose("pressure", "time").values - units = da.attrs.get("units", "kg m⁻³") + units = params.vunit("potential_density") or da.attrs.get("units", "kg m⁻³") label = da.attrs.get("long_name", sv) pcolormesh_panel( fig, @@ -356,7 +385,7 @@ def draw_grid_sigma(ds: "xr.Dataset") -> "Optional[plt.Figure]": pressure, title=label, cmap=params.DENSITY_COLORMAP, - cb_label=f"{label} ({units})" if units else label, + units=units, title_loc="left", date_fmt=False, ) @@ -400,17 +429,24 @@ def draw_grid_n2(ds: "xr.Dataset", lat: float = 0.0) -> "Optional[plt.Figure]": p_mid_1d = np.nanmean(p_mid, axis=1) N2_log = np.log10(np.maximum(N2, 1e-12)) - fig, ax = plt.subplots(figsize=(13, 4)) + fig, ax = plt.subplots( + figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN), layout="constrained" + ) bounds, norm = colorbar_norm(N2_log[np.isfinite(N2_log)]) pc = ax.pcolormesh( time_vals, p_mid_1d, N2_log, shading="nearest", cmap="plasma_r", norm=norm ) - cb = fig.colorbar(pc, ax=ax, pad=0.02, ticks=bounds) - cb.set_label("log₁₀(N²) (s⁻²)") + cb = fig.colorbar( + pc, + ax=ax, + pad=0.02, + ticks=nice_colorbar_ticks(float(bounds[0]), float(bounds[-1])), + ) + cb.ax.set_title("log(s⁻²)", fontsize=report_tokens.ANNOT_FS) pressure_axis(ax) date_axis(ax) ax.set_xlabel("Time") - ax.set_title("Buoyancy frequency squared N² (log₁₀ scale; purple = stratified)") + ax.set_title(r"Buoyancy frequency squared N² ($\log_{10}$ scale; purple = stratified)") return fig @@ -476,7 +512,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=(10, 5), sharex=True) + fig, axs = plt.subplots(2, 1, figsize=(report_tokens.W_FULL, 5), sharex=True) _C_EAST = "#0072B2" _C_NORTH = "#E69F00" @@ -497,6 +533,7 @@ def draw_grid_timeseries(ds: "xr.Dataset") -> "Optional[plt.Figure]": axs[-1].xaxis.set_major_formatter( mdates.ConciseDateFormatter(axs[-1].xaxis.get_major_locator()) ) + date_offset_left(axs[-1]) fig.suptitle( f"Velocity time series at {p_target:.0f} dbar (depth of maximum mean speed)", y=1.01, @@ -536,7 +573,7 @@ def draw_analog_timeseries( fig, axes = plt.subplots( n_vars, 1, - figsize=(10, max(2.5, n_vars * 2.0)), + figsize=(report_tokens.W_FULL, max(2.5, n_vars * 2.0)), sharex=True, squeeze=False, ) diff --git a/oceanarray/plotters/ts.py b/oceanarray/plotters/ts.py index 10e11bb..71f3e73 100644 --- a/oceanarray/plotters/ts.py +++ b/oceanarray/plotters/ts.py @@ -7,8 +7,9 @@ import numpy as np -from .primitives import colorbar_norm +from .primitives import colorbar_norm, square_axes_grid, unit_colorbar from .helpers import QC_MARKER as _QC_MARKER +from ..utilities import nice_colorbar_ticks from .. import parameters as params from oceanarray.config import report_tokens @@ -57,12 +58,29 @@ def _ts_heatmap_panel( n_bins: int = 80, plo: float = 0.01, phi: float = 99.99, + s_lim: "Optional[tuple]" = None, + t_lim: "Optional[tuple]" = None, + cax: "Optional[plt.Axes]" = None, ) -> None: - """Render a T-S 2-D count heatmap on *ax*.""" + """Render a T-S 2-D count heatmap on *ax*. + + When *s_lim* / *t_lim* are given they set the bin range and axis limits so the + heatmap shares the dot-plot's axes exactly; otherwise a percentile range is + used. The colorbar uses ~6 nice ticks. When *cax* is given (a height-matched + colorbar axes from :func:`square_axes_grid`) the colorbar is drawn there via + :func:`unit_colorbar`; otherwise a normal colorbar is added and the box is + squared with ``set_box_aspect``. + """ import matplotlib.pyplot as plt + from ..utilities import nice_colorbar_ticks + from .primitives import unit_colorbar - s_lo, s_hi = float(np.nanpercentile(S, plo)), float(np.nanpercentile(S, phi)) - t_lo, t_hi = float(np.nanpercentile(T, plo)), float(np.nanpercentile(T, phi)) + s_lo, s_hi = s_lim if s_lim is not None else ( + float(np.nanpercentile(S, plo)), float(np.nanpercentile(S, phi)) + ) + t_lo, t_hi = t_lim if t_lim is not None else ( + float(np.nanpercentile(T, plo)), float(np.nanpercentile(T, phi)) + ) s_edges = np.linspace(s_lo, s_hi, n_bins + 1) t_edges = np.linspace(t_lo, t_hi, n_bins + 1) counts, _, _ = np.histogram2d(S, T, bins=[s_edges, t_edges]) @@ -77,12 +95,17 @@ def _ts_heatmap_panel( pc = ax.pcolormesh( s_edges, t_edges, log_counts, cmap=cmap, norm=norm, shading="flat" ) - cb = fig.colorbar(pc, ax=ax, pad=0.02, ticks=bounds) - cb.set_label("log₁₀(count + 1)") + _ticks = nice_colorbar_ticks(float(bounds[0]), float(bounds[-1])) + if cax is not None: + unit_colorbar(cax, pc, unit=r"$\log_{10}$(count)", ticks=_ticks) + else: + cb = fig.colorbar(pc, ax=ax, pad=0.02, ticks=_ticks) + cb.ax.set_title(r"$\log_{10}$(count)", fontsize=report_tokens.ANNOT_FS) + ax.set_box_aspect(1) _add_sigma0_contours(ax, S, T) ax.set_xlim(s_lo, s_hi) ax.set_ylim(t_lo, t_hi) - ax.set_xlabel("Practical salinity") + ax.set_xlabel(params.vlabel("salinity")) ax.set_ylabel(params.vlabel("temperature")) ax.set_title("T-S heat map") @@ -103,7 +126,6 @@ def draw_ts_diagram(nc_path: Path) -> "Optional[plt.Figure]": Figure, or None if temperature or salinity are absent. """ - import matplotlib.pyplot as plt import xarray as xr with xr.open_dataset(nc_path, decode_timedelta=False) as ds: @@ -120,11 +142,11 @@ def draw_ts_diagram(nc_path: Path) -> "Optional[plt.Figure]": if "pressure" in ds.data_vars: C = ds["pressure"].values.astype(float) - cbar_label = params.vlabel("pressure") + _cbar_unit = params.vunit("pressure") cmap_sc = "viridis_r" else: C = np.arange(len(T), dtype=float) - cbar_label = "Sample index" + _cbar_unit = "" # sample index — no unit cmap_sc = "plasma" t_flags = ( @@ -146,18 +168,17 @@ def draw_ts_diagram(nc_path: Path) -> "Optional[plt.Figure]": suspect_mask = finite & (combined_flags == 3) bad_mask = finite & (combined_flags == 4) - sal_units = ds["salinity"].attrs.get("units", "PSU") - tmp_units = ds["temperature"].attrs.get("units", "°C") - has_sat = "oxygen_saturation_pct" in ds.data_vars sat_data = ds["oxygen_saturation_pct"].values.astype(float) if has_sat else None ncols = 3 if has_sat else 2 - fig, axes = plt.subplots( - 1, ncols, figsize=(report_tokens.W_FULL, 4.5), constrained_layout=True + # 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 ) - ax_l, ax_r = axes[0], axes[1] - ax_sat = axes[2] if has_sat else None + ax_l, ax_r = _ax[0, 0], _ax[0, 1] + ax_sat = _ax[0, 2] if has_sat else None # Panel 1: T-S scatter coloured by pressure vmin = np.nanpercentile(C[finite], 5) @@ -175,7 +196,12 @@ def draw_ts_diagram(nc_path: Path) -> "Optional[plt.Figure]": zorder=2, rasterized=True, ) - fig.colorbar(sc, ax=ax_l, label=cbar_label, fraction=0.046, pad=0.04) + unit_colorbar( + _cax[0, 0], + sc, + unit=_cbar_unit, + ticks=nice_colorbar_ticks(float(vmin), float(vmax)), + ) if suspect_mask.any(): ax_l.scatter( S[suspect_mask], @@ -193,12 +219,24 @@ def draw_ts_diagram(nc_path: Path) -> "Optional[plt.Figure]": if suspect_mask.any() or bad_mask.any(): ax_l.legend(loc="best", framealpha=0.8) _add_sigma0_contours(ax_l, S[finite], T[finite]) - ax_l.set_xlabel(f"Salinity ({sal_units})") - ax_l.set_ylabel(f"Temperature ({tmp_units})") + ax_l.set_xlabel(params.vlabel("salinity")) + ax_l.set_ylabel(params.vlabel("temperature")) ax_l.set_title("T-S (colour = pressure)") - # Panel 2: count heatmap - _ts_heatmap_panel(ax_r, fig, S[finite], T[finite]) + # Shared T-S axis limits (the dot plot's data range) so the heatmap and O₂ + # panels use identical axes; square every panel box. + _sf, _tf = S[finite], T[finite] + _sp = 0.02 * (float(np.nanmax(_sf) - np.nanmin(_sf)) + 1e-9) + _tp = 0.02 * (float(np.nanmax(_tf) - np.nanmin(_tf)) + 1e-9) + _s_lim = (float(np.nanmin(_sf)) - _sp, float(np.nanmax(_sf)) + _sp) + _t_lim = (float(np.nanmin(_tf)) - _tp, float(np.nanmax(_tf)) + _tp) + ax_l.set_xlim(*_s_lim) + ax_l.set_ylim(*_t_lim) + + # Panel 2: count heatmap (shares the dot plot's limits + matched colorbar) + _ts_heatmap_panel( + ax_r, fig, S[finite], T[finite], s_lim=_s_lim, t_lim=_t_lim, cax=_cax[0, 1] + ) # Panel 3: T-S scatter coloured by O2 saturation (when available) if ax_sat is not None and sat_data is not None: @@ -222,12 +260,18 @@ def draw_ts_diagram(nc_path: Path) -> "Optional[plt.Figure]": zorder=2, rasterized=True, ) - cb_s = fig.colorbar(sc_s, ax=ax_sat, ticks=bounds_s, pad=0.02) - cb_s.set_label("O₂ saturation (%)") + unit_colorbar( + _cax[0, 2], + sc_s, + unit="%", + ticks=nice_colorbar_ticks(float(bounds_s[0]), float(bounds_s[-1])), + ) _add_sigma0_contours(ax_sat, S[sat_finite], T[sat_finite]) - ax_sat.set_xlabel(f"Salinity ({sal_units})") - ax_sat.set_ylabel(f"Temperature ({tmp_units})") + ax_sat.set_xlabel(params.vlabel("salinity")) + ax_sat.set_ylabel(params.vlabel("temperature")) ax_sat.set_title("T-S (colour = O₂ sat.)") + ax_sat.set_xlim(*_s_lim) + ax_sat.set_ylim(*_t_lim) return fig @@ -254,8 +298,6 @@ def draw_stack_ts_diagram(ds: "xr.Dataset") -> "Optional[plt.Figure]": Figure, or None if temperature or salinity are absent. """ - import matplotlib.pyplot as plt - if "temperature" not in ds.data_vars or "salinity" not in ds.data_vars: return None @@ -293,12 +335,12 @@ 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, axes = plt.subplots( - 1, ncols, figsize=(report_tokens.W_FULL, 4.5), constrained_layout=True + fig, _ax, _cax = square_axes_grid( + report_tokens.W_FULL, 1, ncols, per_panel_colorbar=True ) - ax_scatter, ax_heat = axes[0], axes[1] - ax_sat = axes[2] if has_sat else None + ax_scatter, ax_heat = _ax[0, 0], _ax[0, 1] + ax_sat = _ax[0, 2] if has_sat else None # --- Left panel: T-S scatter coloured by pressure --- if P_flat is not None: @@ -319,9 +361,14 @@ def draw_stack_ts_diagram(ds: "xr.Dataset") -> "Optional[plt.Figure]": zorder=2, rasterized=True, ) - cb_p = fig.colorbar(sc_p, ax=ax_scatter, ticks=bounds_p, pad=0.02) - cb_p.set_label(params.vlabel("pressure")) + unit_colorbar( + _cax[0, 0], + sc_p, + unit=params.vunit("pressure"), + ticks=nice_colorbar_ticks(float(bounds_p[0]), float(bounds_p[-1])), + ) else: + _cax[0, 0].set_visible(False) # no pressure → no colorbar ax_scatter.scatter( S_flat[finite], T_flat[finite], @@ -331,12 +378,25 @@ def draw_stack_ts_diagram(ds: "xr.Dataset") -> "Optional[plt.Figure]": rasterized=True, ) _add_sigma0_contours(ax_scatter, S_flat[finite], T_flat[finite]) - ax_scatter.set_xlabel("Practical salinity") + ax_scatter.set_xlabel(params.vlabel("salinity")) ax_scatter.set_ylabel(params.vlabel("temperature")) ax_scatter.set_title("T-S (colour = pressure)") - # --- Middle panel: count heatmap --- - _ts_heatmap_panel(ax_heat, fig, S_flat[finite], T_flat[finite]) + # Shared limits (scatter's data range) so the heatmap matches (boxes are + # already square from square_axes_grid). + _sf, _tf = S_flat[finite], T_flat[finite] + _sp = 0.02 * (float(np.nanmax(_sf) - np.nanmin(_sf)) + 1e-9) + _tp = 0.02 * (float(np.nanmax(_tf) - np.nanmin(_tf)) + 1e-9) + _s_lim = (float(np.nanmin(_sf)) - _sp, float(np.nanmax(_sf)) + _sp) + _t_lim = (float(np.nanmin(_tf)) - _tp, float(np.nanmax(_tf)) + _tp) + ax_scatter.set_xlim(*_s_lim) + ax_scatter.set_ylim(*_t_lim) + + # --- Middle panel: count heatmap (shares the scatter's limits) --- + _ts_heatmap_panel( + ax_heat, fig, S_flat[finite], T_flat[finite], s_lim=_s_lim, t_lim=_t_lim, + cax=_cax[0, 1], + ) # --- Right panel: T-S scatter coloured by O2 saturation --- if ax_sat is not None and SAT_flat is not None: @@ -359,12 +419,18 @@ def draw_stack_ts_diagram(ds: "xr.Dataset") -> "Optional[plt.Figure]": zorder=2, rasterized=True, ) - cb_s = fig.colorbar(sc_s, ax=ax_sat, ticks=bounds_s, pad=0.02) - cb_s.set_label("O₂ saturation (%)") + unit_colorbar( + _cax[0, 2], + sc_s, + unit="%", + ticks=nice_colorbar_ticks(float(bounds_s[0]), float(bounds_s[-1])), + ) _add_sigma0_contours(ax_sat, S_flat[sat_finite], T_flat[sat_finite]) - ax_sat.set_xlabel("Practical salinity") + ax_sat.set_xlabel(params.vlabel("salinity")) ax_sat.set_ylabel(params.vlabel("temperature")) ax_sat.set_title("T-S (colour = O₂ sat.)") + ax_sat.set_xlim(*_s_lim) + ax_sat.set_ylim(*_t_lim) return fig @@ -425,13 +491,20 @@ def draw_grid_ts_diagram( has_o2 = has_o2 and o2_valid.any() ncols = 2 if has_o2 else 1 - fig, axes = plt.subplots(1, ncols, figsize=(report_tokens.W_HALF, 5)) - if ncols == 1: - axes = [axes] + fig, _ax, _cax = square_axes_grid( + report_tokens.W_FULL if has_o2 else report_tokens.W_HALF, + 1, + ncols, + per_panel_colorbar=True, + ) + axes = list(_ax[0]) - # Panel 1: count heatmap - _ts_heatmap_panel(axes[0], fig, S[finite], T[finite], n_bins=n_bins) - axes[0].set_title("T-S count (log₁₀ samples per bin)") + # Panel 1: count heatmap (shares the data limits + matched colorbar) + _ts_heatmap_panel( + axes[0], fig, S[finite], T[finite], n_bins=n_bins, + s_lim=(s_lo, s_hi), t_lim=(t_lo, t_hi), cax=_cax[0, 0], + ) + axes[0].set_title(r"T-S count ($\log_{10}$ samples per bin)") # Panel 2: median O2 saturation per T-S bin if has_o2 and O2 is not None: @@ -472,12 +545,16 @@ def draw_grid_ts_diagram( norm=norm, shading="flat", ) - cb = fig.colorbar(pc, ax=axes[1], ticks=bounds, pad=0.02) - cb.set_label("Median O₂ saturation (%)") + unit_colorbar( + _cax[0, 1], + pc, + unit="%", + ticks=nice_colorbar_ticks(float(bounds[0]), float(bounds[-1])), + ) _add_sigma0_contours(axes[1], S[o2_valid], T[o2_valid]) axes[1].set_xlim(s_lo, s_hi) axes[1].set_ylim(t_lo, t_hi) - axes[1].set_xlabel("Practical salinity") + axes[1].set_xlabel(params.vlabel("salinity")) axes[1].set_ylabel(params.vlabel("temperature")) axes[1].set_title("Median O₂ saturation per T-S bin") diff --git a/oceanarray/processors/pressure.py b/oceanarray/processors/pressure.py index 2aa4101..85a68d3 100644 --- a/oceanarray/processors/pressure.py +++ b/oceanarray/processors/pressure.py @@ -315,7 +315,7 @@ def compute_adcp_bin_pressure( vertical distances directly, so ``range`` is already in metres of vertical depth — no beam-angle correction is needed. - **Approximation** (fixable post-OdB; see ``.claude/refactor-plan-postOdB-20260721.md``): + **Approximation** (fixable post-OdB): ``gsw.p_from_z(-range_m, lat)`` computes the pressure of a water column of depth *range_m* measured from the *sea surface*, not the true pressure increment at the ADCP's actual depth. Error at 300 m range from a 500 m transducer is ~2–3 dbar — diff --git a/oceanarray/reports/_plots.py b/oceanarray/reports/_plots.py index 6d8112c..90f9cf5 100644 --- a/oceanarray/reports/_plots.py +++ b/oceanarray/reports/_plots.py @@ -14,13 +14,17 @@ import numpy as np from ._html_helpers import _QC_MARKER, _QC_LABELS -from ._encode import render_b64 +from ._figdebug import render_b64 from ..config import report_tokens from ..plotters.primitives import ( date_axis, hodograph_panel, ) -from ..plotters.helpers import _rose_ax, _velocity_panel_style # noqa: F401 +from ..plotters.helpers import ( # noqa: F401 + _rose_ax, + _velocity_panel_style, + grid_despine, +) from ..plotters.timeseries import ( draw_grid_fig, draw_grid_hydro, @@ -46,10 +50,11 @@ draw_grid_hodograph, ) from ..plotters.diagnostic import ( - _CANONICAL_PANELS, + _CANONICAL_PANELS, # noqa: F401 (re-exported for callers/tests) _COMPACT_PANEL_VARS, _COMPACT_PANEL_HEIGHT, _PANEL_HEIGHT, + _instrument_panels, draw_windows, draw_data_histogram, draw_velocity_iqr_profile, @@ -157,42 +162,8 @@ def _plot_aquadopp_quick(ds: "xr.Dataset") -> "plt.Figure": _DOT_LINE_VARS: frozenset = frozenset({"turbidity"}) -def _instrument_panels( - ds: "xr.Dataset", combine_pitch_roll: bool = False -) -> List[Tuple]: - """Return panel list (varname, ylabel, line_color, invert_y) in canonical order.""" - import re as _re - - time_vars = {v for v in ds.data_vars if ds[v].dims == ("time",)} - - has_enu = any( - v in time_vars for v in ("east_velocity", "north_velocity", "up_velocity") - ) - beam_vars = {"velocity_beam1", "velocity_beam2", "velocity_beam3"} - do_combo = combine_pitch_roll and "pitch" in time_vars and "roll" in time_vars - - out = [] - is_velocity_instrument = has_enu or bool(beam_vars & time_vars) - for vname, label, color, invert in _CANONICAL_PANELS: - if vname not in time_vars: - continue - if has_enu and vname in beam_vars: - continue - # Velocity instruments (aquadopp/ADCP) record speed_of_sound internally for - # the velocity solution — not a science output, so drop that panel. - if vname == "speed_of_sound" and is_velocity_instrument: - continue - if do_combo: - if vname == "pitch": - out.append(("_pitch_roll_combo", "Pitch & Roll (°)", None, False)) - continue - if vname == "roll": - continue - actual_units = ds[vname].attrs.get("units", "") - if actual_units: - label = _re.sub(r"\[.*?\]", f"[{actual_units}]", label) - out.append((vname, label, color, invert)) - return out +# _instrument_panels is imported from plotters.diagnostic (single definition) — +# it was previously duplicated here (U8). # --------------------------------------------------------------------------- @@ -280,7 +251,7 @@ def _build_fig_from_ds( time = ds["time"].values for ax, (vname, label, color, invert) in zip(axs, panels): - ax.grid(True) + grid_despine(ax) if vname == "_pitch_roll_combo": _suspect_t = float(ds.attrs.get("tilt_suspect_threshold", 20.0)) _fail_t = float(ds.attrs.get("tilt_fail_threshold", 30.0)) @@ -754,7 +725,7 @@ def _draw_hodograph_pair( lp_days: float, units: str, dt_s: float, -) -> None: +) -> Any: """Draw one row (raw + eddy panels) of a hodograph figure onto two Axes. Computes a 2-D density heatmap (hexbin) of east vs north velocity with a @@ -782,6 +753,12 @@ def _draw_hodograph_pair( dt_s : float Sample interval in seconds. + Returns + ------- + matplotlib.cm.ScalarMappable or None + The shared 0->1 plasma time mapping (for one shared colorbar), or None if + neither panel had enough finite data to draw. + """ import pandas as pd from oceanarray.plotters.helpers import tukey_smooth @@ -810,18 +787,21 @@ def _draw_hodograph_pair( # Fractional time 0→1 for colour mapping; matches east_1d length t_frac = np.linspace(0.0, 1.0, len(east_1d)) - def _draw(ax: Any, e: np.ndarray, n: np.ndarray, title: str) -> None: + def _draw(ax: Any, e: np.ndarray, n: np.ndarray, title: str) -> Any: mask = np.isfinite(e) & np.isfinite(n) if mask.sum() < 2: ax.text( 0.5, 0.5, "No data", transform=ax.transAxes, ha="center", va="center" ) - ax.set_title(title, fontsize=9) - return - hodograph_panel(ax, e[mask], n[mask], t_frac[mask], title, units) + ax.set_title(title) + return None + return hodograph_panel(ax, e[mask], n[mask], t_frac[mask], title, units) - _draw(ax_raw, e_sm, n_sm, f"{label} — raw ({smooth_hours:.0f}-h smoothed)") - _draw(ax_eddy, e_eddy, n_eddy, f"{label} — eddy ({lp_days:.0f}-day LP removed)") + sm_raw = _draw(ax_raw, e_sm, n_sm, f"{label} — raw ({smooth_hours:.0f}-h smoothed)") + sm_eddy = _draw( + ax_eddy, e_eddy, n_eddy, f"{label} — eddy ({lp_days:.0f}-day LP removed)" + ) + return sm_raw or sm_eddy def _make_adcp_rose_b64(nc_path: str) -> Optional[str]: diff --git a/oceanarray/reports/_stack.py b/oceanarray/reports/_stack.py index 98c4df1..dd15cf1 100644 --- a/oceanarray/reports/_stack.py +++ b/oceanarray/reports/_stack.py @@ -8,6 +8,7 @@ import numpy as np +from . import _figdebug from ._env import render_template from ._html_helpers import ( _fig_to_base64, @@ -33,6 +34,8 @@ render_b64, ) from .. import parameters as params +from ..plotters.helpers import ordered_line_colors +from ..plotters.primitives import date_offset_left from oceanarray.config import report_tokens @@ -74,6 +77,13 @@ def _make_aquadopp_tilt_panels(ds: Any, step: int = 1) -> Optional[str]: if not aq_indices: return None + # Cap the number of stacked panels so the figure stays a sane height for PDF + # pagination (one panel is ~2.8 in; 5 → ~14 in). Deep-first order is kept, so + # the deepest Aquadopps are shown; a note flags any that were dropped. + _MAX_TILT_ROWS = 5 + _n_dropped = max(0, len(aq_indices) - _MAX_TILT_ROWS) + aq_indices = aq_indices[:_MAX_TILT_ROWS] + has_pitch = "pitch" in ds.data_vars has_roll = "roll" in ds.data_vars has_tilt_p = "tilt_from_pressure" in ds.data_vars @@ -143,6 +153,7 @@ def _draw() -> "plt.Figure": loc = mdates.AutoDateLocator() ax_ts.xaxis.set_major_locator(loc) ax_ts.xaxis.set_major_formatter(mdates.ConciseDateFormatter(loc)) + date_offset_left(ax_ts) ax_ts.tick_params(axis="x") if tp_data is not None and np.any(np.isfinite(tp_data)): @@ -201,6 +212,13 @@ def _draw() -> "plt.Figure": ) ax_sc.set_axis_off() + if _n_dropped: + 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 return render_b64(_draw, optional=True) @@ -266,21 +284,34 @@ def generate_stack_page( ) _serial_list = list(serials) - # Colour instruments in (deep-first) order from a colourblind-friendly - # sequential map; beyond _n_line_colors, keep the colour order and cycle - # the line style (solid, dashed, …) so many series stay distinguishable - # and ordered rather than an arbitrary 20-colour wheel. - _cmap = plt.get_cmap("viridis") - _line_styles = ["-", "--", ":", "-."] - _n_line_colors = min(len(_serial_list), 10) - _serial_colors = {} - _serial_styles = {} - for _i, _s in enumerate(_serial_list): - _ci = _i % _n_line_colors - _serial_colors[_s] = _cmap(_ci / max(_n_line_colors - 1, 1)) - _serial_styles[_s] = _line_styles[ - (_i // _n_line_colors) % len(_line_styles) - ] + + def _var_line_styling(varname: str) -> "tuple[dict, dict]": + """Per-serial (colour, linestyle) for one variable, deep-first ordered. + + Instruments are ordered deep-first and mapped onto the variable's + **line** colormap (:data:`params.LINE_CMAPS_BY_VARIABLE`, falling back + to the field map then viridis) so, e.g., temperature runs from the + cold/deep (blue) end to the warm/shallow (red) end and pressure from + dark to lighter blue. Each colour is shared by a consecutive **pair** + of instruments distinguished by linestyle (solid then dashed), which + halves the number of distinct colours so the ramp stays legible for + many instruments. Washed-out colours are skipped by luminance (see + :func:`ordered_line_colors`) rather than a fixed trim, so a diverging + map's pale midpoint does not swallow the mid-depth lines. + """ + _cmap_name = ( + params.LINE_CMAPS_BY_VARIABLE.get(varname) + or params.CMAPS_BY_VARIABLE.get(varname) + or "viridis" + ) + _styles_ladder = ["-", "--"] + _n_pairs = max(1, (len(_serial_list) + 1) // 2) # 2 linestyles per colour + _pair_colors = ordered_line_colors(_cmap_name, _n_pairs) + colors, styles = {}, {} + for _i, _s in enumerate(_serial_list): + colors[_s] = _pair_colors[_i // len(_styles_ladder)] + styles[_s] = _styles_ladder[_i % len(_styles_ladder)] + return colors, styles def _ts_fig( varname: str, @@ -297,6 +328,7 @@ def _ts_fig( if qc_varname in ds.data_vars: qc = ds[qc_varname].values 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)) plotted = False @@ -340,6 +372,7 @@ def _ts_fig( locator = mdates.AutoDateLocator() ax.xaxis.set_major_locator(locator) ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator)) + date_offset_left(ax) ax.set_ylabel(ylabel) ax.set_xlabel("Time") ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.3) @@ -365,57 +398,41 @@ def _ts_fig( # bbox_inches="tight", so the PNG expands to include the legend # while the plot keeps its full height. b64 = _fig_to_base64(fig) + _figdebug.record(b64, f"_ts_fig[{varname}]", fig) plt.close(fig) return b64 fig_pressure_b64 = _ts_fig( "pressure", params.vlabel("pressure"), invert=True, exclude_types={"adcp"} ) - fig_temp_b64 = _ts_fig( - "temperature", - f"Temperature ({ds['temperature'].attrs.get('units', '°C')})" - if "temperature" in ds - else "Temperature", - ) + fig_temp_b64 = _ts_fig("temperature", params.vlabel("temperature")) fig_sal_b64 = ( - _ts_fig( - "salinity", - f"Salinity ({ds['salinity'].attrs.get('units', '')})" - if "salinity" in ds - else None, - ) + _ts_fig("salinity", params.vlabel("salinity")) if "salinity" in ds else None ) fig_dissolved_oxygen_b64 = ( - _ts_fig( - "dissolved_oxygen", - f"Dissolved oxygen ({ds['dissolved_oxygen'].attrs.get('units', 'µmol/L')})", - ) + _ts_fig("dissolved_oxygen", params.vlabel("dissolved_oxygen")) if "dissolved_oxygen" in ds else None ) fig_east_vel_b64 = ( - _ts_fig("east_velocity", "U — East velocity (m/s)") + _ts_fig("east_velocity", params.vlabel("east_velocity", prefix="U — ")) if "east_velocity" in ds else None ) fig_north_vel_b64 = ( - _ts_fig("north_velocity", "V — North velocity (m/s)") + _ts_fig("north_velocity", params.vlabel("north_velocity", prefix="V — ")) if "north_velocity" in ds else None ) fig_up_vel_b64 = ( - _ts_fig("up_velocity", "W — Up velocity (m/s)") + _ts_fig("up_velocity", params.vlabel("up_velocity", prefix="W — ")) if "up_velocity" in ds else None ) fig_turbidity_b64 = ( - _ts_fig( - "turbidity", - f"Turbidity ({ds['turbidity'].attrs.get('units', 'NTU')})", - dot_overlay=True, - ) + _ts_fig("turbidity", params.vlabel("turbidity"), dot_overlay=True) if "turbidity" in ds else None ) @@ -495,6 +512,9 @@ def _ts_fig( ax_sp.set_title("Adjacent instrument spacing distribution") plt.tight_layout() fig_spacing_b64 = _fig_to_base64(fig_sp) + _figdebug.record( + fig_spacing_b64, "_make_pressure_spacing", fig_sp + ) plt.close(fig_sp) except Exception as _exc_sp: warnings.warn( diff --git a/oceanarray/reports/templates/array.html b/oceanarray/reports/templates/array.html index e425c00..a8ed13f 100644 --- a/oceanarray/reports/templates/array.html +++ b/oceanarray/reports/templates/array.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% import "_macros.html" as m %} {% block title %}{{ array_name }} – array summary{% endblock %} {% block page_styles %} .meta-grid div { font-size:0.82rem; } @@ -40,6 +41,7 @@ {% if fig_map_b64 %}

Mooring positions

Array map +{{- m.dbg(fig_map_b64, "max-width 60%") }} {% endif %}

Moorings

diff --git a/oceanarray/reports/templates/base.html b/oceanarray/reports/templates/base.html index 3057ebf..41fa738 100644 --- a/oceanarray/reports/templates/base.html +++ b/oceanarray/reports/templates/base.html @@ -57,6 +57,11 @@ td.mono { font-family:monospace; font-size:0.8rem; } .none-note { color:var(--muted); font-style:italic; } .var-qc { color:var(--good); font-size:0.78rem; } + /* Figure-debug line under a plot (OCEANARRAY_REPORT_DEBUG=1): slot the + template chose vs the render-side figsize/png, for eyeballing mismatches. */ + .debug { font-family:monospace; font-size:0.72rem; color:var(--muted); + background:var(--seafoam); border-left:3px solid var(--muted); + padding:0.2rem 0.5rem; margin:0 0 1rem; white-space:pre-wrap; } @media print { body { padding:0; max-width:100%; } h2 { page-break-after:avoid; } diff --git a/oceanarray/reports/templates/grid.html b/oceanarray/reports/templates/grid.html index b82a51f..aecaa37 100644 --- a/oceanarray/reports/templates/grid.html +++ b/oceanarray/reports/templates/grid.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% import "_macros.html" as m %} {% block title %}Grid report – {{ mooring_name }}{% endblock %} {% block page_styles %} :root { --accent:#8e44ad; --accent-link:#e8d5ff; } @@ -72,6 +73,7 @@

Hydrography

Temperature, salinity, dissolved oxygen, and O₂ saturation (when present). Vertically interpolated to regular pressure grid • 20 discrete colour levels.

show / hide Temperature and salinity +{{- m.dbg(fig_hydro_b64, "full (100%)") }}
{% endif %} @@ -81,6 +83,7 @@

Velocity

East, north, and up velocity. QC-flagged samples excluded before interpolation. Shared symmetric Spectral_r colormap for east/north; separate bounds for up. No temporal gap fill — NaN where no data.

show / hide Velocity pcolormesh +{{- m.dbg(fig_vel_stacked_b64, "full (100%)") }}
{% endif %} @@ -90,6 +93,7 @@

T-S diagram

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).

show / hide T-S diagram +{{- m.dbg(fig_ts_grid_b64, "max-width 50%") }}
{% endif %} @@ -99,6 +103,7 @@

Velocity IQR profiles

Median (solid line) and interquartile range (shaded, 25–75 %) across the full deployment at each pressure level. 2.5–97.5 % outer envelope for speed. Right panel: count of non-NaN values per depth.

show / hide Velocity IQR profiles +{{- m.dbg(fig_vel_iqr_b64, "full (100%)") }}
{% endif %} @@ -108,6 +113,7 @@

Current roses

One rose per pressure level (up to 12, evenly subsampled). Suspect/bad QC excluded. Each petal shows the fraction of time current flowed in that direction; petal length encodes speed (m s⁻¹).

show / hide Current roses by pressure level +{{- m.dbg(fig_grid_rose_b64, "full (100%)") }}
{% endif %} @@ -117,6 +123,7 @@

Hodograph

Current hodographs at two pressure levels (25th and 75th percentile of the valid range). Top row = shallower level; bottom row = deeper level. Left = Tukey-smoothed raw; right = eddy component (LP mean removed). Colour indicates fractional time through the deployment (lime circle = start, red square = end). QC-bad data excluded.

show / hide Hodograph at two pressure levels +{{- m.dbg(fig_grid_hodograph_b64, "full (100%)") }}
{% endif %} @@ -126,6 +133,7 @@

Particle trajectory

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).

show / hide Grid particle trajectory +{{- m.dbg(fig_grid_traj_b64, "max-width 55%") }}
{% endif %} @@ -135,6 +143,7 @@

Velocity time series at depth of maximum mean speed

Depth chosen as the pressure level with the highest time-mean current speed. Speed, east, and north velocity at that level.

show / hide Velocity time series at max-speed depth +{{- m.dbg(fig_grid_ts_b64, "full (100%)") }}
{% endif %} @@ -147,6 +156,7 @@

Stratification

Potential density. 20 discrete colour levels.

show / hide sigma0 Potential density pcolormesh +{{- m.dbg(fig_sigma_b64, "full (100%)") }}
{% endif %} @@ -155,6 +165,7 @@

Buoyancy

log₁₀(N²) in s⁻². Purple = strongly stratified; yellow = weakly stratified. Computed from T and S via GSW.

show / hide N² N² pcolormesh +{{- m.dbg(fig_n2_b64, "full (100%)") }}
{% endif %} @@ -167,6 +178,7 @@

Isopycna

Percentage of time steps during which each σ₀ surface (0.1 kg m⁻³ spacing) lies within the measured column range. Green ≥ 80 %; amber 50–80 %; red < 50 %. Orange diamond = current --sig-level target. Dashed line = 80 % threshold. NaN gaps in the tracking figure above correspond to times when the isopycnal is outside the measured range (pycnocline above the shallowest grid level, or below the deepest).

show / hide Isopycnal coverage diagnostic +{{- m.dbg(fig_isopycnal_coverage_b64, "full (100%)") }}
{% endif %} @@ -185,6 +197,7 @@

Temperat

1-hour running median temperature at the grid pressure level nearest to 100 m above the seabed. Level and height-above-seabed shown in figure title.

show / hide Temperature near seabed +{{- m.dbg(fig_overflow_temp_b64, "full (100%)") }}
{% endif %} @@ -193,6 +206,7 @@

Left: low-frequency overview — 14-day Hann windows, 50 % overlap. Right: high-frequency zoom — 1-day windows (~14× more windows, smoother tidal/inertial estimate); x-axis in hours. LF markers: M2, 1.8 d, 4 d, f. HF markers: M2 (12.4 h), f (inertial). Dashed black line: −2 spectral slope. Colour encodes pressure (light blue = shallow, dark blue = deep). Window length controllable via hf_segment_days in _make_spectrum_fig_b64.

show / hide Temperature power spectrum +{{- m.dbg(fig_spectrum_b64, "full (100%)") }}
{% endif %} @@ -201,6 +215,7 @@

Continuous wavelet transform (Morlet, ω0 = 6; Torrence & Compo 1998). Three depth levels: 2nd from top, middle, 2nd from bottom of the 100-dbar-multiple valid levels. Colour shows log10(power) (°C² d); hatched grey region = cone of influence (edge-affected); hatched cross region = gap-filled data; black contour = 95 % significance against red noise.

show / hide Temperature wavelet scalogram +{{- m.dbg(fig_wavelet_b64, "full (100%)") }}
{% endif %} @@ -209,6 +224,7 @@

Rotary velocity spectrum

CW (clockwise, anticyclonic, solid red lines) and CCW (counter-clockwise, cyclonic, dashed blue lines) power spectra and rotary coefficient r = (CCW−CW)/(CCW+CW). Welch PSD, Hann window, 14-day segments, 50 % overlap. Up to 4 pressure levels (at most 1/5th of valid levels); colour encodes pressure depth. Vertical lines: M2, K1, 1.8 d, 4 d, and inertial period (f). r > 0 = CCW dominant; r < 0 = CW dominant. Physical interpretation (NH): inertial oscillations are inherently CW; for internal waves (f < ω), CW dominance indicates upward energy propagation / downward phase propagation (Leaman & Sanford 1975).

show / hide Rotary velocity spectrum +{{- m.dbg(fig_rotary_b64, "full (100%)") }}
{% endif %} diff --git a/oceanarray/reports/templates/instrument.html b/oceanarray/reports/templates/instrument.html index 4be4bb0..eae7f58 100644 --- a/oceanarray/reports/templates/instrument.html +++ b/oceanarray/reports/templates/instrument.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% import "_macros.html" as m %} {% block title %}{{ instr_type | title }} – s/n {{ serial }}{% endblock %} {% block page_styles %} :root { --accent:#1e8449; --accent-link:#d5f5e3; } @@ -125,6 +126,7 @@

Velocity

{% if data_stage and data_stage != 'stage3' %}Magnetic declination has not been applied ({{ data_stage }} data).{% endif %}

+{{- m.dbg(fig_adcp_velocity_b64, "full (100%)") }} {% endif %} @@ -133,6 +135,7 @@

Time series (full deployment)

{% for _ts_img in fig_ts_b64 %} +{{- m.dbg(_ts_img, "full (100%)") }} {% endfor %} {% else %}

No plottable variables found.

@@ -177,6 +180,7 @@

Start & end windows — first / last 6 h

{% for _win_img in fig_windows_b64 %} +{{- m.dbg(_win_img, "full (100%)") }} {% endfor %} {% else %}

Insufficient data for start/end windows.

@@ -189,6 +193,7 @@

T-S diagram

Coloured by pressure (or sample index). × = suspect  |  × = bad (QC flags).

+{{- m.dbg(fig_tsd_b64, "full (100%)") }} {% endif %} @@ -201,6 +206,7 @@

Current roses

{% if data_stage and data_stage != 'stage3' %}Magnetic declination not yet applied ({{ data_stage }} data).{% endif %}

+{{- m.dbg(fig_adcp_rose_b64, "full (100%)") }} {% endif %} @@ -216,7 +222,8 @@

Current rose diagrams

{% 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.

- + +{{- m.dbg(fig_rose_b64, "full (100%)") }} {% endif %} @@ -228,6 +235,7 @@

Particle trajectory

Origin (0, 0) = deployment position; axes in metres.

+{{- m.dbg(fig_trajectory_b64, "max-width 600px") }} {% endif %} @@ -239,12 +247,14 @@

Hodograph

Right: eddy component — raw minus 4-day low-pass (rolling mean).

+{{- m.dbg(fig_hodograph_b64, "full (100%)") }} {% endif %} {% if fig_speed_boxplot_b64 %}

Current speed distribution

+{{- m.dbg(fig_speed_boxplot_b64, "max-width 300px") }} {% endif %} @@ -268,6 +278,7 @@

Analog channels

{% endif %} +{{- m.dbg(fig_analog_b64, "full (100%)") }} {% endif %} @@ -278,6 +289,7 @@

Data value distributions

{% if fig_dt_b64 %} +{{- m.dbg(fig_dt_b64, "full (100%)") }} {% else %}

Not enough samples to compute.

{% endif %} diff --git a/oceanarray/reports/templates/mooring.html b/oceanarray/reports/templates/mooring.html index b9f09ed..ae7a6fa 100644 --- a/oceanarray/reports/templates/mooring.html +++ b/oceanarray/reports/templates/mooring.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% import "_macros.html" as m %} {% block title %}{{ mooring_name }} – recovery report{% endblock %} {% block page_styles %} table { width: 100%; border-collapse: collapse; font-size: 0.83rem; } @@ -498,6 +499,7 @@

Clock alignment check

Instruments with no temperature data are omitted.

Clock alignment check +{{- m.dbg(fig_clock_check_b64, "full (100%)") }} {% endif %} @@ -671,6 +673,7 @@

Mooring knockdown

(water depth − HAB). Instruments below the line were knocked down. Interpolated pressure (QC flag 8) excluded.

Knockdown HAB vs pressure +{{- m.dbg(fig_knockdown_hab_b64, "flex ~half column") }} {% endif %} {% if fig_knockdown_anomaly_b64 %} @@ -682,6 +685,7 @@

Mooring knockdown

amber 200–300, red > 300 dbar.

Knockdown pressure anomaly +{{- m.dbg(fig_knockdown_anomaly_b64, "flex ~half column") }} {% endif %} @@ -691,6 +695,7 @@

Mooring knockdown

Displacement derived from the rigid-pendulum approximation: x = √(habnom² − habmeas²).

Knockdown horizontal displacement +{{- m.dbg(fig_knockdown_displacement_b64, "width 100%") }} {% endif %} {% endif %} diff --git a/oceanarray/reports/templates/stack.html b/oceanarray/reports/templates/stack.html index a91c0b2..a0c7e32 100644 --- a/oceanarray/reports/templates/stack.html +++ b/oceanarray/reports/templates/stack.html @@ -25,6 +25,7 @@
Instruments
{{ n_instr }}
Source file
{{ nc_file }}
{% endblock %} +{% import "_macros.html" as m %} {% block content %}
#TypeSerialHAB (m)~Depth (m)
0 rbrsolo 2402318.08 737
1 microcat 2626925.025 720
2 aquadopp 1432126.026 719
3 rbrsolo 24023075.075 670
4 microcat 5367125.0125 620
5 aquadopp 14284126.0126 619
6 rbrsolo 240234175.0175 570
7 microcat 2942225.0225 520
8 aquadopp 9920226.0226 519
9 rbrsolo 240236275.0275 470
10 microcat 3026325.0325 420
11 aquadopp 400125326.0326 419
12 rbrsolo 240235375.0375 370
13 microcat 2941425.0425 320
14 aquadopp 400118426.0426 319
15 rbrsolo 240233475.0475 270
16 microcat 7507525.0525 220
17 aquadopp 400123526.0526 219
18 rbrsolo 240232575.0575 170
19 microcat 25586625.0625 120
20 aquadopp 400115626.0626 119
21 rbrsolo 240250675.0675 70