diff --git a/oceanarray/config/parameters.py b/oceanarray/config/parameters.py index ae73120..6c55e5b 100644 --- a/oceanarray/config/parameters.py +++ b/oceanarray/config/parameters.py @@ -1,8 +1,10 @@ """Package-level defaults for oceanarray. Matplotlib appearance (font sizes, figure size, DPI, grid style) belongs in -``oceanarray.mplstyle`` — that is the right place for anything that maps to a -matplotlib rcParam. +``config/report.mplstyle`` — that is the right place for anything that maps to a +matplotlib rcParam. There is a single style file: it is applied both by the +report encoder and by plotters that set their own style context (via +:data:`MPLSTYLE` below), so the two can never diverge. This file holds the things the mplstyle *cannot* express: instrument abbreviations, colorbar percentile clipping, downsample interval, and @@ -34,9 +36,10 @@ GRID_PANEL_ROW_IN: float = 2.5 # --------------------------------------------------------------------------- -# Matplotlib style path (used by plotters via plt.style.use) +# Matplotlib style path (used by plotters via plt.style.use). The single source +# of truth — the same file the report encoder applies (report_tokens.MPLSTYLE_PATH). # --------------------------------------------------------------------------- -MPLSTYLE = Path(__file__).parent.parent / "oceanarray.mplstyle" +MPLSTYLE = Path(__file__).with_name("report.mplstyle") # --------------------------------------------------------------------------- # Figure sizes for plot types that differ from the mplstyle default (8×4) diff --git a/oceanarray/config/report.mplstyle b/oceanarray/config/report.mplstyle index 0bb8983..0bdc837 100644 --- a/oceanarray/config/report.mplstyle +++ b/oceanarray/config/report.mplstyle @@ -1,9 +1,11 @@ -axes.titlesize : 12 +axes.titlesize : 10 axes.labelsize : 10 date.autoformatter.day: %Y-%m-%d date.converter: auto figure.figsize: 8, 4 figure.dpi: 100 +figure.titlesize: 10 +savefig.dpi: 150 font.family: sans-serif font.sans-serif: Helvetica Neue, Helvetica, Arial, Liberation Sans, DejaVu Sans font.style: normal diff --git a/oceanarray/config/report_tokens.py b/oceanarray/config/report_tokens.py index 6e4b95c..0ebe04e 100644 --- a/oceanarray/config/report_tokens.py +++ b/oceanarray/config/report_tokens.py @@ -100,7 +100,7 @@ # not be set per call. The "no stray typography" test allow-lists exactly these # three names. CLABEL_FS: int = 8 # ax.clabel() contour labels -ANNOT_FS: int = 8 # in-axes annotation / panel-label text boxes +ANNOT_FS: int = 9 # colorbar unit titles + in-axes annotation / panel-label text CAST_LABEL_FS: int = 6 # dense in-axes cast-number labels on maps and sections # --------------------------------------------------------------------------- diff --git a/oceanarray/oceanarray.mplstyle b/oceanarray/oceanarray.mplstyle deleted file mode 100644 index 3c8530d..0000000 --- a/oceanarray/oceanarray.mplstyle +++ /dev/null @@ -1,24 +0,0 @@ -axes.titlesize : 12 -axes.labelsize : 10 -date.autoformatter.day: %Y-%m-%d -date.converter: auto -figure.figsize: 8, 4 -figure.dpi: 100 -savefig.dpi: 150 -font.family: sans-serif -font.style: normal -font.size: 10 -legend.fontsize: 9 -lines.linewidth : 1 -lines.linestyle: - -lines.markersize : 10 -xtick.labelsize : 10 -xtick.alignment: center -ytick.labelsize : 10 -axes.grid : False -axes.linewidth : 0.6 -grid.alpha : 0.5 -grid.color : 0.5 -grid.linestyle : : -grid.linewidth : 0.6 -contour.linewidth : 0.8 diff --git a/oceanarray/plotters/_cli_legacy.py b/oceanarray/plotters/_cli_legacy.py index 82f377a..a5fbd9c 100644 --- a/oceanarray/plotters/_cli_legacy.py +++ b/oceanarray/plotters/_cli_legacy.py @@ -23,7 +23,7 @@ def plot_microcat_raw(ds, save_path=None): Works with seasenselib variable names (temperature, conductivity, pressure). """ - style_path = Path(__file__).parent / "oceanarray.mplstyle" + style_path = Path(__file__).parent.parent / "config" / "report.mplstyle" plt.style.use(str(style_path)) panels = [("temperature", "Temperature [°C]", "tab:red")] @@ -64,7 +64,7 @@ def plot_microcat_raw(ds, save_path=None): def plot_aquadopp_raw(ds, save_path=None): """Plot east velocity, north velocity, and pressure from a raw/stage2 Aquadopp NetCDF.""" - style_path = Path(__file__).parent / "oceanarray.mplstyle" + style_path = Path(__file__).parent.parent / "config" / "report.mplstyle" plt.style.use(str(style_path)) # Pick pressure variable — prefer 'pressure', fall back to 'pressure_1' diff --git a/oceanarray/plotters/animation.py b/oceanarray/plotters/animation.py index f38a2e4..e540194 100644 --- a/oceanarray/plotters/animation.py +++ b/oceanarray/plotters/animation.py @@ -184,7 +184,7 @@ def _setup_ax(ax: plt.Axes, lim: float, title: str) -> None: ax.axvline(0, color="#bbb", lw=0.7, zorder=0) ax.set_xlabel(f"East ({units})") ax.set_ylabel(f"North ({units})") - ax.set_title(title, fontsize=10) + ax.set_title(title) ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.4) def _draw_frame(frame_idx: int) -> None: diff --git a/oceanarray/plotters/current.py b/oceanarray/plotters/current.py index 0dbe71c..a91397f 100644 --- a/oceanarray/plotters/current.py +++ b/oceanarray/plotters/current.py @@ -33,10 +33,13 @@ from oceanarray.plotters.primitives import ( colorbar_norm, date_axis, + figure_title, hodograph_panel, + plot_title, plot_trajectory, square_axes_grid, square_limits, + ytick_reserve_in, unit_colorbar, ) from oceanarray.plotters.helpers import _rose_ax, _velocity_panel_style @@ -168,7 +171,7 @@ def plot_speed_boxplot( ax.set_xticks([]) instr_id = ds.attrs.get("id", "") if instr_id: - ax.set_title(instr_id, fontsize=9) + plot_title(ax, instr_id) grid_despine(ax, axis="y") fig.tight_layout() return fig @@ -262,7 +265,11 @@ def plot_multi_aquadopp_trajectories( _bounds = _nice_colorbar_bounds(0.0, 1.0, n=20) norm: mcolors.BoundaryNorm = mcolors.BoundaryNorm(_bounds, ncolors=256) - fig, axes, cax = square_axes_grid(width_in, 1, 1, colorbar=has_temp) + _all_x = np.concatenate([x for _, x, _, _ in trajs]) + _all_y = np.concatenate([y for _, _, y, _ in trajs]) + fig, axes, cax = square_axes_grid( + width_in, 1, 1, colorbar=has_temp, left_in=ytick_reserve_in(_all_y) + ) ax = axes[0, 0] for instr_i, x, y, temp in trajs: @@ -305,7 +312,6 @@ def plot_multi_aquadopp_trajectories( xy=(x[-1], y[-1]), xytext=(6, 3), textcoords="offset points", - fontsize=7, color="black", ha="left", va="bottom", @@ -313,7 +319,7 @@ def plot_multi_aquadopp_trajectories( # Origin marker (all trajectories share the same start) ax.plot(0, 0, "o", color="black", markersize=7, zorder=6, label="Start (all)") - ax.legend(fontsize=8, loc="upper left") + ax.legend(loc="upper left") if has_temp and cax is not None: sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm) @@ -324,8 +330,6 @@ def plot_multi_aquadopp_trajectories( # 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) @@ -338,7 +342,7 @@ def plot_multi_aquadopp_trajectories( if not title: title = ds.attrs.get("id", "") if title: - ax.set_title(title) + plot_title(ax, title) return fig @@ -397,7 +401,7 @@ def plot_hodograph( ) ax_raw, ax_eddy = axes[0, 0], axes[0, 1] if instr_id: - fig.suptitle(instr_id) + figure_title(fig, instr_id) if u_var not in ds.data_vars or v_var not in ds.data_vars: for ax in axes.ravel(): @@ -410,7 +414,7 @@ def plot_hodograph( "No east/north velocities", ha="center", va="center", - fontsize=12, + fontsize=report_tokens.ANNOT_FS, color="#95a5a6", ) return fig @@ -455,7 +459,7 @@ def _panel(ax: plt.Axes, e: np.ndarray, n: np.ndarray, title: str) -> Any: ax.text( 0.5, 0.5, "No data", transform=ax.transAxes, ha="center", va="center" ) - ax.set_title(title) + plot_title(ax, title) return None return hodograph_panel(ax, e[mask], n[mask], t_frac[mask], title, units) @@ -572,7 +576,6 @@ def plot_aquadopp_speed_profile( hab, f"s/n {serial}", va="center", - fontsize=7, color="#333", ) @@ -693,7 +696,9 @@ def plot_adcp_trajectories( # Half width — shown in a 50% flex column beside the Aquadopp trajectory # (see stack.html), matching plot_multi_aquadopp_trajectories. - fig, axes, cax = square_axes_grid(width_in, 1, 1) + _all_x = np.concatenate([x for _, x, _ in trajs]) + _all_y = np.concatenate([y for _, _, y in trajs]) + fig, axes, cax = square_axes_grid(width_in, 1, 1, left_in=ytick_reserve_in(_all_y)) ax = axes[0, 0] for hab, x, y in trajs: @@ -708,9 +713,7 @@ def plot_adcp_trajectories( 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") - _all_x = np.concatenate([x for _, x, _ in trajs]) - _all_y = np.concatenate([y for _, _, y in trajs]) + ax.legend(loc="upper left") xlim, ylim = square_limits(_all_x, _all_y) ax.set_xlim(*xlim) ax.set_ylim(*ylim) @@ -720,7 +723,7 @@ def plot_adcp_trajectories( ax.axvline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4) ax.set_aspect("equal", adjustable="datalim") grid_despine(ax) - ax.set_title("ADCP bins coloured by HAB") + plot_title(ax, "ADCP bins coloured by HAB") return fig @@ -926,17 +929,28 @@ def draw_rose_grid( ncols = 4 nrows = math.ceil(n / ncols) + _fig_h = nrows * (width_in / ncols + 0.65) fig, axs = plt.subplots( nrows, ncols, - figsize=(width_in, nrows * (width_in / ncols + 0.65)), + figsize=(width_in, _fig_h), subplot_kw={"projection": "polar"}, squeeze=False, ) # Trim the outer left/right margins and the inter-panel gap (the tucked-in - # N/E/S/W labels no longer need the wide gap). Encoder skips tight_layout for - # polar figures, so set the margins explicitly. - fig.subplots_adjust(left=0.05, right=0.95, wspace=0.4) + # N/E/S/W labels no longer need the wide gap). Also trim the top/bottom + # margins to a small fixed inch reserve: the matplotlib defaults (0.88/0.11) + # left ~1-2 rows of whitespace above and below on a tall grid (Eleanor + # 2026-08-18). Encoder skips tight_layout for polar figures, so set the + # margins explicitly. + fig.subplots_adjust( + left=0.05, + right=0.95, + wspace=0.4, + hspace=0.45, + top=1 - 0.4 / _fig_h, + bottom=0.2 / _fig_h, + ) axs_flat = axs.flatten() for plot_i, instr_i in enumerate(aqd_idx): @@ -1094,7 +1108,9 @@ def draw_grid_trajectory( _bounds, norm = colorbar_norm(vmin=min(p_vals), vmax=max(p_vals)) cmap = plt.get_cmap("viridis_r") # shallow (low p) → light; deep → dark - fig, axes, cax = square_axes_grid(width_in, 1, 1) + _all_x = np.concatenate([x for _, x, _ in trajs]) + _all_y = np.concatenate([y for _, _, y in trajs]) + fig, axes, cax = square_axes_grid(width_in, 1, 1, left_in=ytick_reserve_in(_all_y)) ax = axes[0, 0] for p_val, x, y in trajs: @@ -1109,9 +1125,7 @@ def draw_grid_trajectory( 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") - _all_x = np.concatenate([x for _, x, _ in trajs]) - _all_y = np.concatenate([y for _, _, y in trajs]) + ax.legend(loc="upper left") xlim, ylim = square_limits(_all_x, _all_y) ax.set_xlim(*xlim) ax.set_ylim(*ylim) @@ -1327,7 +1341,7 @@ def draw_adcp_velocity( cb = fig.colorbar(pc, ax=ax, pad=0.02, ticks=bounds[::2]) cb.set_label(cb_label) ax.set_ylabel(ylabel) - ax.set_title(label, loc="left") + plot_title(ax, label) grid_despine(ax) # Show from 0 (includes blanking zone) to deepest valid bin. # set_ylim with reversed args inverts for downward-looking. @@ -1706,7 +1720,7 @@ def draw_grid_hodograph( ax.text( 0.5, 0.5, "No data", transform=ax.transAxes, ha="center", va="center" ) - ax.set_title(label) + plot_title(ax, label) continue t_frac = np.linspace(0.0, 1.0, len(east_2d[:, i_lev]))[mask] sm = hodograph_panel( diff --git a/oceanarray/plotters/diagnostic.py b/oceanarray/plotters/diagnostic.py index cab0a77..da07b55 100644 --- a/oceanarray/plotters/diagnostic.py +++ b/oceanarray/plotters/diagnostic.py @@ -40,8 +40,8 @@ import numpy as np -from .helpers import grid_despine -from .primitives import date_offset_left, square_axes_grid +from .helpers import distinct_line_styles, grid_despine +from .primitives import date_offset_left, figure_title, plot_title, square_axes_grid from .. import parameters as params from oceanarray.config import report_tokens @@ -315,7 +315,7 @@ def plot_knockdown_pressure( zorder=0, label="actual = nominal", ) - ax.legend(fontsize=9, loc="upper left") + ax.legend(loc="upper left") ax.set_xlabel("Nominal pressure (dbar)") ax.set_ylabel("Measured pressure (dbar)") grid_despine(ax) @@ -429,7 +429,7 @@ def plot_knockdown_hab( zorder=0, label="expected pressure", ) - ax.legend(fontsize=9, loc="upper right") + ax.legend(loc="upper right") ax.set_xlabel("Nominal HAB (m)") ax.set_ylabel("Measured pressure (dbar)") @@ -661,7 +661,7 @@ def plot_knockdown_displacement( rasterized=True, ) ax1.set_ylabel("Measured pressure (dbar)") - ax1.legend(fontsize=9, loc="lower right", markerscale=3) + ax1.legend(loc="lower right", markerscale=3) # --- right panel: per-instrument normalised heatmap --- # Each instrument's 2-D histogram is divided by its own total before @@ -816,8 +816,10 @@ def plot_clock_offset_check( if n_panels == 1: axes = [axes] - _tab20 = plt.get_cmap("tab20") - colors = {s: _tab20(i % 20) for i, s in enumerate(series)} + # Colourblind-safe styles: colour alone (tab20 = 20 hues) collided once + # a mooring had >20 instruments, and tab20 is not CVD-safe. Vary colour + # (Okabe-Ito) and linestyle so up to 32 lines are each distinct. + styles = {s: st for s, st in zip(series, distinct_line_styles(len(series)))} plotted_serials: set = set() for ax, (t_lo, t_hi, title) in zip(axes, windows): @@ -830,10 +832,11 @@ def plot_clock_offset_check( if not np.isfinite(sd) or sd == 0: continue # flat window: no timing info, normalisation undefined tw_norm = (tw - np.nanmean(tw)) / sd - ax.plot(t[mask], tw_norm, color=colors[serial], lw=1.0) + _c, _ls, _lw = styles[serial] + ax.plot(t[mask], tw_norm, color=_c, linestyle=_ls, lw=_lw) plotted_serials.add(serial) - ax.set_title(title) + plot_title(ax, title) ax.set_ylabel("Normalised temperature (std)") grid_despine(ax) locator = mdates.AutoDateLocator() @@ -851,22 +854,44 @@ def plot_clock_offset_check( from matplotlib.lines import Line2D handles = [ - Line2D([0], [0], color=colors[s], lw=1.0, label=str(s)) + Line2D( + [0], + [0], + color=styles[s][0], + linestyle=styles[s][1], + lw=styles[s][2], + label=str(s), + ) for s in series if s in plotted_serials ] + # Shared legend below the axes. A fixed bottom reserve overlapped the + # panels once the instrument count was large (~24 → 6 cols × 4 rows), so + # size a dedicated legend band from the row count and grow the figure + # height by it — the panels keep their height and the legend's top edge + # stays below the date labels (Eleanor 2026-08-18). + ncol = min(len(handles), 6) + n_rows = int(np.ceil(len(handles) / ncol)) + panel_in = 3.5 + # Padding includes a gap above the legend for the date-offset labels, so + # the legend's top edge clears the dates (Eleanor 2026-08-18). + legend_in = 0.55 + n_rows * 0.19 # padding + per-row height (inches) + fig.set_size_inches(width_in, panel_in + legend_in) fig.legend( handles=handles, loc="lower center", - ncol=min(len(handles), 6), - bbox_to_anchor=(0.5, 0.01), + ncol=ncol, + bbox_to_anchor=(0.5, 0.005), frameon=True, ) - # Reserve space for the below-axes legend and keep it: mark the figure + # Reserve the legend band at the bottom 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) + _bottom = legend_in / (panel_in + legend_in) + 0.03 + fig.subplots_adjust( + bottom=_bottom, top=0.92, left=0.08, right=0.97, wspace=0.22 + ) fig._manual_layout = True # noqa: SLF001 — encoder layout opt-out return fig @@ -1081,7 +1106,6 @@ def _draw_vlines( rotation=90, va="bottom", ha="left", - fontsize=7, color=_vc, zorder=5, clip_on=False, @@ -1177,15 +1201,16 @@ def _plot_grey( # noqa: ANN202 ax_r.yaxis.tick_right() ax_r.yaxis.set_label_position("right") if row_i == 0: - ax_l.set_title(f"First {hours} h") - ax_r.set_title(f"Last {hours} h") + plot_title(ax_l, f"First {hours} h") + plot_title(ax_r, f"Last {hours} h") serial = ( ds["serial_number"].item() if "serial_number" in ds else ds.attrs.get("serial_number", "?") ) - fig.suptitle( + figure_title( + fig, f"{instr_type.title()} s/n {serial} — deployment start / end", ) return fig diff --git a/oceanarray/plotters/helpers.py b/oceanarray/plotters/helpers.py index 92e8b47..beb9b8a 100644 --- a/oceanarray/plotters/helpers.py +++ b/oceanarray/plotters/helpers.py @@ -17,6 +17,54 @@ import matplotlib.pyplot as plt +#: Okabe-Ito 8-colour qualitative palette — the accessibility-standard +#: colourblind-safe set (distinguishable under protan/deutan/tritan CVD). Note +#: the yellow (#F0E442) is pale on a white background; the linewidth tiers in +#: :func:`distinct_line_styles` and the linestyle help keep it legible. +OKABE_ITO: "list[str]" = [ + "#000000", # black + "#E69F00", # orange + "#56B4E9", # sky blue + "#009E73", # bluish green + "#F0E442", # yellow + "#0072B2", # blue + "#D55E00", # vermillion + "#CC79A7", # reddish purple +] + + +def distinct_line_styles(n: int) -> "list[tuple[str, str, float]]": + """Return *n* colourblind-safe ``(color, linestyle, linewidth)`` line styles. + + Cycles the 8-colour :data:`OKABE_ITO` palette and advances the linestyle + every 8 lines (solid → dashed → dash-dot → dotted), so up to 32 lines each + get a unique ``(color, linestyle)`` pair — enough to tell apart a mooring's + worth of instruments where colour alone (max 8–20 hues) collides. Linewidth + increases with the linestyle group so the sparser styles (dash-dot, dotted) + stay as visible as the solid ones: solid thinnest, dashed thin, dash-dot and + dotted thicker. Beyond 32 lines the linestyle group is clamped (styles + repeat) rather than raising. + + Parameters + ---------- + n : int + Number of line styles to return (>= 0). + + Returns + ------- + list of (str, str, float) + ``(hex_color, linestyle, linewidth)`` per line, in order. + + """ + linestyles = ["-", "--", "-.", ":"] + linewidths = [0.8, 1.1, 1.5, 1.5] # solid thinnest; dash-dot/dotted thicker + out: "list[tuple[str, str, float]]" = [] + for i in range(max(n, 0)): + grp = min(i // len(OKABE_ITO), len(linestyles) - 1) + out.append((OKABE_ITO[i % len(OKABE_ITO)], linestyles[grp], linewidths[grp])) + return out + + def grid_despine(ax: "plt.Axes", *, axis: str = "both") -> None: """Turn the grid on and hide the top and right spines (report convention). diff --git a/oceanarray/plotters/hydrography.py b/oceanarray/plotters/hydrography.py index eb801c6..c9ea5a7 100644 --- a/oceanarray/plotters/hydrography.py +++ b/oceanarray/plotters/hydrography.py @@ -18,7 +18,7 @@ import matplotlib.pyplot as plt import xarray as xr -from .primitives import colorbar_norm, date_axis +from .primitives import colorbar_norm, date_axis, plot_title from .helpers import grid_despine, ordered_line_colors from .. import parameters as params from oceanarray.config import report_tokens @@ -89,7 +89,7 @@ def draw_isopycnal_ts_fig( ax.plot(time_vals, h_med, color=col, lw=1.0, label=f"σ₀ = {sval:.2f}") if n_levels <= 8: - ax.legend(loc="upper right", framealpha=0.8, fontsize=9) + ax.legend(loc="upper right", framealpha=0.8) else: bounds, norm = colorbar_norm( vmin=float(sigma_vals.min()), @@ -266,7 +266,7 @@ def _bar_color(p: float) -> str: ax0.axhline(tgt, color="#e67e22", lw=0.6, ls=":", zorder=3) ax0.set_xlabel("Occurrence (%)") ax0.set_ylabel(f"σ₀ (kg m⁻³) [{sv}]") - ax0.set_title("Distribution") + plot_title(ax0, "Distribution") ax0.set_ylim(y_lo, y_hi) ax0.invert_yaxis() @@ -288,8 +288,8 @@ def _bar_color(p: float) -> str: ax1.set_xlabel("Time present (%)") ax1.set_xlim(0, 105) ax1.tick_params(axis="y", which="both", left=False) - ax1.legend(loc="lower right", fontsize=9, framealpha=0.7) - ax1.set_title("Coverage") + ax1.legend(loc="lower right", framealpha=0.7) + plot_title(ax1, "Coverage") # ---- Panel 2: depth distribution ---- valid_med = np.isfinite(med) @@ -323,7 +323,7 @@ def _bar_color(p: float) -> str: if abs(targets[nearest_idx] - tgt) < 0.06 and np.isfinite(med[nearest_idx]): ax2.axhline(tgt, color="#e67e22", lw=0.6, ls=":", zorder=1) ax2.set_xlabel(xlbl2) - ax2.set_title("Depth distribution") + plot_title(ax2, "Depth distribution") if not use_hab: ax2.invert_xaxis() ax2.tick_params(axis="y", which="both", left=False) @@ -332,7 +332,7 @@ def _bar_color(p: float) -> str: Line2D([0], [0], color="#2980b9", lw=3.5, label="IQR (25–75 %)"), Line2D([0], [0], color="#95a5a6", lw=1, label="5–95 %"), ] - ax2.legend(handles=legend_elems, loc="lower right", fontsize=9, framealpha=0.7) + ax2.legend(handles=legend_elems, loc="lower right", framealpha=0.7) return fig @@ -410,10 +410,9 @@ def draw_overflow_temperature_fig( ) ax.set_ylabel(params.vlabel("temperature")) hab = waterdepth - actual_p - ax.set_title( + plot_title( + ax, f"{actual_p:.0f} dbar ({hab:.0f} m above seabed)", - fontsize=10, - loc="left", pad=4, ) date_axis(ax) diff --git a/oceanarray/plotters/primitives.py b/oceanarray/plotters/primitives.py index 24d08ae..17087e7 100644 --- a/oceanarray/plotters/primitives.py +++ b/oceanarray/plotters/primitives.py @@ -29,6 +29,61 @@ from ..utilities import _nice_colorbar_bounds, nice_colorbar_ticks +def plot_title(ax: Any, text: str, *, loc: str = "left", **kwargs: Any) -> Any: + """Set an axes (panel) title, left-aligned by default. + + The single place panel-title alignment is decided for report figures, so the + left-vs-centre choice for every panel title can be switched here in one line. + Figure-level titles that span several panels use :func:`figure_title` + (centred) instead — do not route those through this helper. Colorbar unit + labels (``cb.ax.set_title``) are not panel titles and must not use this + helper either. + + Parameters + ---------- + ax : matplotlib.axes.Axes + Axes whose title is set. + text : str + Title text. + loc : str, optional + Horizontal alignment passed to ``set_title`` (default ``"left"``). + **kwargs : Any + Forwarded to ``matplotlib.axes.Axes.set_title`` (e.g. ``fontsize``). + + Returns + ------- + matplotlib.text.Text + The created title artist. + + """ + return ax.set_title(text, loc=loc, **kwargs) + + +def figure_title(fig: Any, text: str, **kwargs: Any) -> Any: + """Set a figure-level title (``suptitle``), centred over all panels. + + Use for a title that spans a multi-panel figure; per-panel titles use + :func:`plot_title` (left-aligned). Keeping both in one module makes the + left-vs-centre policy a single switch point. + + Parameters + ---------- + fig : matplotlib.figure.Figure + Figure whose suptitle is set. + text : str + Title text. + **kwargs : Any + Forwarded to ``matplotlib.figure.Figure.suptitle`` (e.g. ``y``, ``fontsize``). + + Returns + ------- + matplotlib.text.Text + The created suptitle artist. + + """ + return fig.suptitle(text, **kwargs) + + def pcolormesh_panel( fig: Any, ax: Any, @@ -43,7 +98,7 @@ def pcolormesh_panel( vmax: Optional[float] = None, n: int = 20, cb_label: Optional[str] = None, - title_loc: str = "center", + title_loc: str = "left", date_fmt: bool = True, ) -> Any: """Draw one (pressure × time) field as a discrete-colorbar panel on *ax*. @@ -78,7 +133,8 @@ def pcolormesh_panel( cb_label : str, optional Colorbar label. Defaults to ``"{title} ({units})"`` or ``"{title}"``. title_loc : str, optional - Horizontal alignment of the axes title. Default ``"center"``. + Horizontal alignment of the axes title. Default ``"left"`` (the report + panel-title convention; pass ``"center"`` for a centred title). date_fmt : bool, optional When ``True`` (default), apply :func:`date_axis` to *ax*. Pass ``False`` for stacked panels where only the last axis needs the @@ -115,7 +171,7 @@ def pcolormesh_panel( pressure_axis(ax) if date_fmt: date_axis(ax) - ax.set_title(title, loc=title_loc) + plot_title(ax, title, loc=title_loc) return pc @@ -143,6 +199,42 @@ def pcolormesh_panel( # left y-label + right colorbar). _SQ_CBAR_TXT_PP_IN: float = 0.30 _SQ_WGAP_PP_IN: float = 0.34 +# Extra left-margin inches per y-tick-label character beyond the ~4 that the base +# _SQ_LABEL_IN reserve already fits. Sizes the adaptive left reserve for wide +# labels (large trajectory displacements) so the rotated axis label stays +# on-canvas without shrinking the square when labels are short. +_SQ_PER_CHAR_IN: float = 0.10 + + +def ytick_reserve_in(y: "np.ndarray | float") -> float: + """Left-margin inches to fit the widest y-tick label plus rotated y-axis label. + + Sizes the ``left_in`` reserve for :func:`square_axes_grid` from the y-data + range instead of a fixed shrink: the base reserve (``_SQ_LABEL_IN``) already + fits up to ~4-character tick labels, and each additional character (a larger + magnitude, or a leading minus) adds ``_SQ_PER_CHAR_IN``. Returns the base + reserve unchanged for <=4-character labels, so a small-displacement + trajectory keeps the full square (no 9% shrink) while a thousands-of-km one + reserves just enough. + + Parameters + ---------- + y : numpy.ndarray or float + The y values (or the maximum absolute y-limit) about to be plotted. + + Returns + ------- + float + Left-margin reserve in inches, always >= ``_SQ_LABEL_IN``. + + """ + arr = np.asarray(y, dtype=float).ravel() + finite = arr[np.isfinite(arr)] + if finite.size == 0: + return _SQ_LABEL_IN + n_digits = len(str(int(round(float(np.max(np.abs(finite))))))) + n_chars = n_digits + (1 if np.any(finite < 0) else 0) + return _SQ_LABEL_IN + max(0, n_chars - 4) * _SQ_PER_CHAR_IN def square_axes_grid( @@ -156,6 +248,7 @@ def square_axes_grid( bottom_pad_in: float = 0.0, wgap_in: "Optional[float]" = None, cbar_txt_in: "Optional[float]" = None, + left_in: "Optional[float]" = None, ) -> "tuple[plt.Figure, np.ndarray, Any]": """Lay out an ``nrows × ncols`` grid of square axes deterministically in inches. @@ -202,6 +295,14 @@ def square_axes_grid( Override the reserved width (inches) for the shared colorbar's tick labels and axis label. Default (``None``) uses the standard reserve; pass a larger value for a long colorbar label so it stays on-canvas. + left_in : float, optional + Override the reserved left-margin width (inches) for the y-tick labels + plus rotated y-axis label. Default (``None``) uses the standard reserve + (``_SQ_LABEL_IN``), which fits up to ~4-character tick labels; pass a + larger value when the y-tick labels are wide (e.g. thousands-of-km + trajectory displacements) so the axis label stays on-canvas. Applies to + the standard (non ``per_panel_colorbar``) layout. Use + :func:`ytick_reserve_in` to size it from the data range. Returns ------- @@ -216,6 +317,7 @@ def square_axes_grid( # wider *cbar_txt_in* reserve so it stays on-canvas. _cbar_txt = cbar_txt_in if cbar_txt_in is not None else _SQ_CBAR_TXT_IN _cbar_reserve = _SQ_CBAR_GAP_IN + _SQ_CBAR_W_IN + _cbar_txt + _left = left_in if left_in is not None else _SQ_LABEL_IN if per_panel_colorbar: # Each cell = y-labels + square panel + its own (tight) colorbar. per_cell_fixed = ( @@ -226,7 +328,7 @@ def square_axes_grid( else: _wgap = wgap_in if wgap_in is not None else _SQ_WGAP_IN right_in = _cbar_reserve if colorbar else _SQ_LABEL_IN - avail_w = fig_w - _SQ_LABEL_IN - right_in - (ncols - 1) * _wgap + avail_w = fig_w - _left - right_in - (ncols - 1) * _wgap side = max(avail_w / ncols, 0.5) # square panel side (inches) grid_h = nrows * side + (nrows - 1) * _SQ_HGAP_IN bottom_in = _SQ_XTICK_IN + bottom_pad_in @@ -241,7 +343,7 @@ def square_axes_grid( if per_panel_colorbar: x0 = c * (side + per_cell_fixed + _wgap) + _SQ_LABEL_IN else: - x0 = _SQ_LABEL_IN + c * (side + _wgap) + x0 = _left + 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( @@ -406,7 +508,12 @@ def plot_trajectory( matplotlib.figure.Figure """ - fig, axes, cax = square_axes_grid(width_in, 1, 1, colorbar=color_data is not None) + # Reserve left margin for wide y-tick labels (thousands-of-km displacements) + # so the rotated y-axis label is not clipped; short labels keep the full + # square (see ytick_reserve_in). + fig, axes, cax = square_axes_grid( + width_in, 1, 1, colorbar=color_data is not None, left_in=ytick_reserve_in(y) + ) ax = axes[0, 0] if color_data is not None: @@ -434,7 +541,7 @@ def plot_trajectory( ax.set_xlabel(xlabel) ax.set_ylabel(ylabel) if title: - ax.set_title(title) + plot_title(ax, 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) # datalim keeps the square box (from square_axes_grid) authoritative so the @@ -527,7 +634,7 @@ def hodograph_panel( ax.axvline(0, color="#888", lw=0.7) ax.set_xlabel(f"East ({units})") ax.set_ylabel(f"North ({units})") - ax.set_title(title) + plot_title(ax, title) grid_despine(ax) return sm diff --git a/oceanarray/plotters/spectrum.py b/oceanarray/plotters/spectrum.py index 488cf6c..ede42df 100644 --- a/oceanarray/plotters/spectrum.py +++ b/oceanarray/plotters/spectrum.py @@ -25,7 +25,7 @@ from oceanarray.utilities import _nice_colorbar_bounds, period_axis_ticks from ..analysis.spectral import gonella_rotary_spectrum -from .primitives import square_axes_grid +from .primitives import figure_title, plot_title, square_axes_grid from .helpers import grid_despine from oceanarray.config import report_tokens @@ -169,7 +169,7 @@ def wavelet_panel( ax.yaxis.set_minor_locator(NullLocator()) ax.set_ylabel("Period") if title: - ax.set_title(title, fontsize="small") + plot_title(ax, title) return cf @@ -445,11 +445,9 @@ def draw_spectrum( ax_lf.set_xlabel("Period") ax_lf.set_ylabel("PSD (°C² cpd⁻¹)") - ax_lf.set_title(f"Low frequency\n14-day windows ({n_win_lf})") + plot_title(ax_lf, 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" - ) + ax_lf.legend(loc="upper right", title="Depth") # -- HF panel -- if psds_hf and freq_hf is not None: @@ -541,11 +539,12 @@ def draw_spectrum( n_win_hf_label = str(n_win_hf) if psds_hf else "0" # 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)" + plot_title( + ax_hf, + f"High frequency\n{hf_seg_label} windows ({n_win_hf_label}, gap-aware)", ) - fig.suptitle("Temperature power spectrum — Welch PSD per depth") + figure_title(fig, "Temperature power spectrum — Welch PSD per depth") return fig @@ -705,8 +704,7 @@ def draw_wavelet( # Temperature time series (top) ts = arr[sel[i], :] 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_ylabel("T (°C)") grid_despine(tax[i]) # Pressure level in the bottom-left corner (was a title, which overlapped # the scalogram of the pair above). @@ -717,7 +715,7 @@ def draw_wavelet( transform=tax[i].transAxes, ha="left", va="bottom", - fontsize="small", + fontsize=report_tokens.ANNOT_FS, bbox=dict(boxstyle="round,pad=0.15", fc="white", ec="none", alpha=0.7), ) plt.setp(tax[i].get_xticklabels(), visible=False) @@ -920,12 +918,30 @@ def draw_grid_rotary_spectrum( f_inert_cpd = f_inert * 86400.0 / (2.0 * np.pi) markers.append(("f", 1.0 / f_inert_cpd, "#27ae60")) + # Show at most 3 depth levels: the shallowest maps to the palest colour and is + # hard to see against white, so drop it and evenly subsample the rest. + if len(press_plotted) > 3: + _order = list(np.argsort(press_plotted)) # shallow -> deep + _keep = _order[1:] # drop the shallowest + if len(_keep) > 3: + _pick = np.linspace(0, len(_keep) - 1, 3).round().astype(int) + _keep = [_keep[i] for i in _pick] + _keep = sorted(_keep) + s_cw_list = [s_cw_list[i] for i in _keep] + s_ccw_list = [s_ccw_list[i] for i in _keep] + r_list = [r_list[i] for i in _keep] + r_banded_list = [r_banded_list[i] for i in _keep] + press_plotted = [press_plotted[i] for i in _keep] + p_arr = np.array(press_plotted) p_min, p_max = p_arr.min(), p_arr.max() if p_min == p_max: p_min -= 1.0 p_max += 1.0 - norm_p = mcolors.Normalize(vmin=p_min, vmax=p_max) + # Floor the colour scale part-way up the colormap so even the shallowest kept + # level is a visible shade (the pure-white end is invisible on white). + _span = p_max - p_min + norm_p = mcolors.Normalize(vmin=p_min - 0.54 * _span, vmax=p_max) cmap_cw = plt.get_cmap("Reds") cmap_ccw = plt.get_cmap("Blues") @@ -948,10 +964,10 @@ def draw_grid_rotary_spectrum( _mark_frequency_line(ax_spec, period_d, color) ax_spec.text( period_d, - 0.03, + 0.97, label, rotation=90, - va="bottom", + va="top", ha="center", color=color, transform=trans1, @@ -966,7 +982,7 @@ def draw_grid_rotary_spectrum( ax_spec.xaxis.set_minor_locator(_NL()) ax_spec.set_xlabel("Period") ax_spec.set_ylabel("PSD (m² s⁻² cpd⁻¹)") - ax_spec.set_title("Rotary spectra") + plot_title(ax_spec, "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. @@ -981,8 +997,7 @@ def draw_grid_rotary_spectrum( ax_spec.legend( handles=_style_handles + _depth_handles, loc="lower left", - fontsize=8, - title="Direction / depth", + fontsize=report_tokens.ANNOT_FS, framealpha=0.85, ) @@ -1032,7 +1047,7 @@ def draw_grid_rotary_spectrum( ax_rot.set_ylim(-1.1, 1.1) ax_rot.set_xlabel("Period") ax_rot.set_ylabel("Rotary coefficient r") - ax_rot.set_title(f"r = (CCW - CW) / (CCW + CW) [DOF ~= {2 * _dof_half}]") + plot_title(ax_rot, f"r = (CCW - CW) / (CCW + CW) [DOF ~= {2 * _dof_half}]") ax_rot.text( 0.02, 0.97, @@ -1063,7 +1078,7 @@ def draw_grid_rotary_spectrum( ) ], loc="upper right", - fontsize=9, + fontsize=report_tokens.ANNOT_FS, framealpha=0.7, ) # Depth is conveyed by the panel-1 legend; no pressure colorbar here (keeps diff --git a/oceanarray/plotters/timeseries.py b/oceanarray/plotters/timeseries.py index f3004b4..d5bd9c9 100644 --- a/oceanarray/plotters/timeseries.py +++ b/oceanarray/plotters/timeseries.py @@ -39,11 +39,13 @@ colorbar_norm, date_axis, date_offset_left, + figure_title, + plot_title, pressure_axis, pcolormesh_panel, ) from ..utilities import nice_colorbar_ticks -from .helpers import grid_despine +from .helpers import OKABE_ITO, grid_despine from .. import parameters as params from oceanarray.config import report_tokens @@ -117,13 +119,13 @@ def draw_grid_fig( linewidths=0.8, alpha=0.75, ) - ax.clabel(ct, fmt="%.1f", fontsize=7, inline=True) + ax.clabel(ct, fmt="%.1f", fontsize=report_tokens.CLABEL_FS, inline=True) cb = fig.colorbar(pc, ax=ax, pad=0.02, ticks=bounds) cb.set_label(f"{title} ({units})" if units else title) pressure_axis(ax) date_axis(ax) ax.set_xlabel("Time") - ax.set_title(f"{title} [{style}]") + plot_title(ax, f"{title} [{style}]") return fig @@ -365,7 +367,7 @@ def draw_grid_velocity_stacked( # direction, m s⁻¹ for velocity/speed) — not a hardcoded east-velocity unit. cb.ax.set_title(cb_label, fontsize=report_tokens.ANNOT_FS) pressure_axis(ax) - ax.set_title(_LABELS[var], loc="left") + plot_title(ax, _LABELS[var]) date_axis(axes[-1, 0]) return fig @@ -490,8 +492,8 @@ def draw_grid_n2( pressure_axis(ax) date_axis(ax) ax.set_xlabel("Time") - ax.set_title( - r"Buoyancy frequency squared N² ($\log_{10}$ scale; purple = stratified)" + plot_title( + ax, r"Buoyancy frequency squared N² ($\log_{10}$ scale; purple = stratified)" ) return fig @@ -564,7 +566,9 @@ def draw_grid_timeseries( east_ts = east[:, k_max] north_ts = north[:, k_max] - fig, axs = plt.subplots(2, 1, figsize=(width_in, 5), sharex=True) + fig, axs = plt.subplots( + 2, 1, figsize=(width_in, 5), sharex=True, layout="constrained" + ) _C_EAST = "#0072B2" _C_NORTH = "#E69F00" @@ -586,9 +590,9 @@ def draw_grid_timeseries( mdates.ConciseDateFormatter(axs[-1].xaxis.get_major_locator()) ) date_offset_left(axs[-1]) - fig.suptitle( + figure_title( + fig, f"Velocity time series at {p_target:.0f} dbar (depth of maximum mean speed)", - y=1.01, ) return fig @@ -637,7 +641,7 @@ def draw_analog_timeseries( squeeze=False, ) - colors = ["steelblue", "darkorange", "seagreen", "crimson"] + colors = OKABE_ITO # colourblind-safe palette (cycles at 8) for row, vname in enumerate(analog_vars): ax = axes[row][0] raw = ds[vname].values @@ -663,12 +667,11 @@ def draw_analog_timeseries( ) n_plotted += 1 if n_plotted > 1 and "serial" in ds.coords: - ax.legend(fontsize=6, loc="upper right") + ax.legend(loc="upper right") else: - ax.plot(time, raw, linewidth=0.8, color="steelblue") + ax.plot(time, raw, linewidth=0.8, color=OKABE_ITO[0]) - ax.set_ylabel(ylabel, fontsize=7) - ax.tick_params(axis="both", labelsize=7) + ax.set_ylabel(ylabel) grid_despine(ax) fig.autofmt_xdate(rotation=30, ha="right") diff --git a/oceanarray/plotters/ts.py b/oceanarray/plotters/ts.py index 68a6ec0..ef7f852 100644 --- a/oceanarray/plotters/ts.py +++ b/oceanarray/plotters/ts.py @@ -7,7 +7,7 @@ import numpy as np -from .primitives import colorbar_norm, square_axes_grid, unit_colorbar +from .primitives import colorbar_norm, plot_title, square_axes_grid, unit_colorbar from .helpers import QC_MARKER as _QC_MARKER from ..utilities import nice_colorbar_ticks from .. import parameters as params @@ -45,7 +45,7 @@ def _add_sigma0_contours( linestyles="--", zorder=1, ) - ax.clabel(cs, fmt="%.1f", fontsize=7, inline=True) + ax.clabel(cs, fmt="%.1f", fontsize=report_tokens.CLABEL_FS, inline=True) except (TypeError, ValueError): pass @@ -111,7 +111,7 @@ def _ts_heatmap_panel( ax.set_ylim(t_lo, t_hi) ax.set_xlabel(params.vlabel("salinity")) ax.set_ylabel(params.vlabel("temperature")) - ax.set_title("T-S heat map") + plot_title(ax, "T-S heat map") def draw_ts_diagram( @@ -228,7 +228,7 @@ def draw_ts_diagram( _add_sigma0_contours(ax_l, S[finite], T[finite]) ax_l.set_xlabel(params.vlabel("salinity")) ax_l.set_ylabel(params.vlabel("temperature")) - ax_l.set_title("T-S (colour = pressure)") + plot_title(ax_l, "T-S (colour = pressure)") # Shared T-S axis limits (the dot plot's data range) so the heatmap and O₂ # panels use identical axes; square every panel box. @@ -276,7 +276,7 @@ def draw_ts_diagram( _add_sigma0_contours(ax_sat, S[sat_finite], T[sat_finite]) ax_sat.set_xlabel(params.vlabel("salinity")) ax_sat.set_ylabel(params.vlabel("temperature")) - ax_sat.set_title("T-S (colour = O₂ sat.)") + plot_title(ax_sat, "T-S (colour = O₂ sat.)") ax_sat.set_xlim(*_s_lim) ax_sat.set_ylim(*_t_lim) @@ -428,7 +428,7 @@ def draw_stack_ts_diagram( _add_sigma0_contours(ax_scatter, S_flat[finite], T_flat[finite]) ax_scatter.set_xlabel(params.vlabel("salinity")) ax_scatter.set_ylabel(params.vlabel("temperature")) - ax_scatter.set_title("T-S (colour = pressure)") + plot_title(ax_scatter, "T-S (colour = pressure)") # Shared limits from robust percentiles (1/99, padded 5% each side and rounded # outward) so a few outliers don't stretch the box; the heatmap matches (boxes @@ -479,7 +479,7 @@ def draw_stack_ts_diagram( _add_sigma0_contours(ax_sat, S_flat[sat_finite], T_flat[sat_finite]) ax_sat.set_xlabel(params.vlabel("salinity")) ax_sat.set_ylabel(params.vlabel("temperature")) - ax_sat.set_title("T-S (colour = O₂ sat.)") + plot_title(ax_sat, "T-S (colour = O₂ sat.)") ax_sat.set_xlim(*_s_lim) ax_sat.set_ylim(*_t_lim) @@ -564,7 +564,7 @@ def draw_grid_ts_diagram( t_lim=(t_lo, t_hi), cax=_cax[0, 0], ) - axes[0].set_title(r"T-S count ($\log_{10}$ samples per bin)") + plot_title(axes[0], 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: @@ -616,6 +616,6 @@ def draw_grid_ts_diagram( axes[1].set_ylim(t_lo, t_hi) 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") + plot_title(axes[1], "Median O₂ saturation per T-S bin") return fig, ts_bounds diff --git a/oceanarray/reports/_array.py b/oceanarray/reports/_array.py index 779a913..198761c 100644 --- a/oceanarray/reports/_array.py +++ b/oceanarray/reports/_array.py @@ -28,6 +28,7 @@ ) from ._plots import render_b64 from ..plotters.helpers import grid_despine +from ..config import report_tokens # --------------------------------------------------------------------------- @@ -136,12 +137,12 @@ def _draw() -> "plt.Figure": (r["lon"], r["lat"]), textcoords="offset points", xytext=(5, 3), - fontsize=10, + fontsize=report_tokens.ANNOT_FS, color=color, ) ax.set_xlabel("Longitude (°)") ax.set_ylabel("Latitude (°)") - ax.set_title(array_name, fontsize=9) + ax.set_title(array_name) grid_despine(ax) plt.tight_layout() return fig diff --git a/oceanarray/reports/_instrument.py b/oceanarray/reports/_instrument.py index 0d6a204..f6cb793 100644 --- a/oceanarray/reports/_instrument.py +++ b/oceanarray/reports/_instrument.py @@ -483,13 +483,13 @@ def generate_instrument_pages( _yaml_recover_str = _yaml_recover_str.isoformat() _window_vlines: List[tuple] = [] if _sugg_deploy_utc: - _window_vlines.append((_sugg_deploy_utc, "#e67e22", "Sugg. deploy")) + _window_vlines.append((_sugg_deploy_utc, "#e67e22", "Suggested")) if _sugg_recover_utc: - _window_vlines.append((_sugg_recover_utc, "#e67e22", "Sugg. recover")) + _window_vlines.append((_sugg_recover_utc, "#e67e22", "Suggested")) if _yaml_deploy_str: - _window_vlines.append((_yaml_deploy_str, "#27ae60", "YAML deploy")) + _window_vlines.append((_yaml_deploy_str, "#27ae60", "YAML")) if _yaml_recover_str: - _window_vlines.append((_yaml_recover_str, "#27ae60", "YAML recover")) + _window_vlines.append((_yaml_recover_str, "#27ae60", "YAML")) # File listing — raw source and stage1/2/3 NC files raw_filename = instr.get("filename", "") diff --git a/oceanarray/reports/_stack.py b/oceanarray/reports/_stack.py index fbc7fd6..ea37a11 100644 --- a/oceanarray/reports/_stack.py +++ b/oceanarray/reports/_stack.py @@ -44,7 +44,7 @@ ) from .. import parameters as params from ..plotters.helpers import grid_despine, ordered_line_colors -from ..plotters.primitives import date_offset_left +from ..plotters.primitives import date_offset_left, plot_title from oceanarray.config import report_tokens @@ -268,38 +268,24 @@ def _draw() -> "plt.Figure": #: Stack panel captions, keyed by panel id — plain text, Unicode notation, no #: markup (a future ``config/report.yaml`` makes these user-editable; see grid). STACK_CAPTIONS: dict[str, str] = { - "pressure": ( - "Values with QC flag ≥ 3 (suspect/bad) masked to NaN before plotting. " - "All data values are in the source file without masking. ADCP " - "instruments excluded." - ), - "temperature": ( - "Values with QC flag ≥ 3 (suspect/bad) masked to NaN before plotting. " - "All data values are in the source file without masking." - ), - "salinity": ( - "Values with QC flag ≥ 3 (suspect/bad) masked to NaN before plotting. " - "All data values are in the source file without masking." + "hydro_pts": ( + "Pressure, temperature and salinity for the full deployment — one row " + "each on a shared time axis. One colour per instrument (shared across " + "rows, ordered by height) so a line can be followed through all three " + "panels; the single legend spans the union of instruments. Values with " + "QC flag ≥ 3 (suspect/bad) masked to NaN; ADCP excluded from pressure." ), "dissolved_oxygen": ( "One line per instrument with dissolved oxygen data (SBE ODO sensor); " "QC flags ≥ 3 masked. Units: µmol L⁻¹. % saturation available in " "per-instrument reports." ), - "east_velocity": ( - "ENU frame. Values with velocity_flag ≥ 3 masked to NaN before " - "plotting. All data values are in the source file without masking. " - "Instruments without velocity data omitted." - ), - "north_velocity": ( - "ENU frame. Values with velocity_flag ≥ 3 masked to NaN before " - "plotting. All data values are in the source file without masking. " - "Instruments without velocity data omitted." - ), - "up_velocity": ( - "ENU frame. Values with velocity_flag ≥ 3 masked to NaN before " - "plotting. All data values are in the source file without masking. " - "Instruments without velocity data omitted." + "vel_uvw": ( + "Eastward (U), northward (V) and upward (W) velocity in the ENU frame — " + "one row each on a shared time axis, one colour per instrument (shared " + "across rows, ordered by height) with a single legend over the union of " + "instruments. Values with velocity_flag ≥ 3 masked to NaN; instruments " + "without velocity data omitted." ), "turbidity": ( "One line per instrument with turbidity data; QC flags ≥ 3 masked. Dots " @@ -465,13 +451,14 @@ def _figure_panel( ), kind="table", ), - "pressure": _figure_panel("pressure", _has("pressure")), - "temperature": _figure_panel("temperature", _has("temperature")), - "salinity": _figure_panel("salinity", _has("salinity")), + "hydro_pts": _figure_panel( + "hydro_pts", + lambda c: any( + v in c.present_vars for v in ("pressure", "temperature", "salinity") + ), + ), "dissolved_oxygen": _figure_panel("dissolved_oxygen", _has("dissolved_oxygen")), - "east_velocity": _figure_panel("east_velocity", _has("east_velocity")), - "north_velocity": _figure_panel("north_velocity", _has("north_velocity")), - "up_velocity": _figure_panel("up_velocity", _has("up_velocity")), + "vel_uvw": _figure_panel("vel_uvw", _has_stack_velocity), "turbidity": _figure_panel("turbidity", _has("turbidity")), "trajectories_aquadopp": _figure_panel( "trajectories_aquadopp", _has_aquadopp, slot="half" @@ -551,11 +538,9 @@ def _figure_panel( "hydrography": Section( "hydrography", "Hydrography", - ("pressure", "temperature", "salinity", "dissolved_oxygen"), - ), - "velocity": Section( - "velocity", "Velocity", ("east_velocity", "north_velocity", "up_velocity") + ("hydro_pts", "dissolved_oxygen"), ), + "velocity": Section("velocity", "Velocity", ("vel_uvw",)), "turbidity": Section("turbidity", "Turbidity", ("turbidity",)), "trajectories": Section( "trajectories", @@ -784,32 +769,134 @@ def _ts_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", params.vlabel("temperature")) - fig_sal_b64 = ( - _ts_fig("salinity", params.vlabel("salinity")) if "salinity" in ds else None + def _combined_ts_fig(_rows: "List[tuple]", _color_var: str) -> Optional[str]: + """Stacked multi-variable time series with one shared instrument legend. + + Each entry in *_rows* is ``(varname, ylabel, invert, exclude_types)``. + The rows share one time axis and one instrument colour scheme (that of + *_color_var*, height-ordered), so a colour tracks an instrument through + every panel, and a single legend spans the union of instruments over + the full figure height. Used for the P/T/S and U/V/W groups so their + many instruments share one tall legend instead of one per variable. + """ + _rows = [r for r in _rows if r[0] in ds.data_vars] + if not _rows: + return None + # One colour/linestyle per instrument, shared across rows (colour + # tracks an instrument through every panel). + _s_colors, _s_styles = _var_line_styling(_color_var) + with plt.style.context(str(params.MPLSTYLE)): + fig, _axs = plt.subplots( + len(_rows), + 1, + figsize=(report_tokens.W_FULL, 2.56 * len(_rows)), + sharex=True, + squeeze=False, + ) + _handles: dict = {} # serial -> Line2D (union, height order) + for _ax, (_var, _ylabel, _invert, _excl) in zip(_axs[:, 0], _rows): + _a = ds[_var].values.copy() + _qcv = f"{_var}_qc" + if _qcv in ds.data_vars: + _a[ds[_qcv].values >= 3] = np.nan + for _i in range(n_instr): + if _excl and instr_types[_i].lower() in _excl: + continue + _serial = _serial_list[_i] + _y = _a[::step, _i] + if not np.any(np.isfinite(_y)): + continue + (_ln,) = _ax.plot( + time_ds, + _y, + color=_s_colors[_serial], + ls=_s_styles[_serial], + lw=0.7, + alpha=0.85, + label=f"{_serial}", + ) + _handles.setdefault(_serial, _ln) + if _invert: + _ax.invert_yaxis() + _ax.set_ylabel(_ylabel) + grid_despine(_ax) + if _t_cov_start and _t_cov_end: + try: + _ax.set_xlim( + np.datetime64(_t_cov_start), + np.datetime64(_t_cov_end), + ) + except Exception: + pass + # No instrument had finite data in any row: drop the figure so the + # panel stubs as "not available" rather than publishing empty axes + # (matches the old per-variable _ts_fig behaviour). + if not _handles: + plt.close(fig) + return None + _ax_last = _axs[-1, 0] + _loc = mdates.AutoDateLocator() + _ax_last.xaxis.set_major_locator(_loc) + _ax_last.xaxis.set_major_formatter(mdates.ConciseDateFormatter(_loc)) + date_offset_left(_ax_last) + _ax_last.set_xlabel("Time") + # Single legend over the union of instruments in height order, + # spanning the full figure height on the right. + _ordered = [s for s in _serial_list if s in _handles] + if _ordered: + # Anchor just right of the panels' actual right edge (not the + # figure edge, which leaves a gap), centred vertically. + _right = max(_ax.get_position().x1 for _ax in _axs[:, 0]) + fig.legend( + handles=[_handles[s] for s in _ordered], + labels=_ordered, + loc="center left", + bbox_to_anchor=(_right + 0.01, 0.5), + bbox_transform=fig.transFigure, + borderaxespad=0, + framealpha=0.8, + ncol=1, + ) + b64 = _fig_to_base64(fig) + _figdebug.record(b64, f"_combined[{_color_var}]", fig) + plt.close(fig) + return b64 + + fig_hydro_pts_b64 = _combined_ts_fig( + [ + ("pressure", params.vlabel("pressure"), True, {"adcp"}), + ("temperature", params.vlabel("temperature"), False, None), + ("salinity", params.vlabel("salinity"), False, None), + ], + "temperature", ) fig_dissolved_oxygen_b64 = ( _ts_fig("dissolved_oxygen", params.vlabel("dissolved_oxygen")) if "dissolved_oxygen" in ds else None ) - fig_east_vel_b64 = ( - _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", params.vlabel("north_velocity", prefix="V — ")) - if "north_velocity" in ds - else None - ) - fig_up_vel_b64 = ( - _ts_fig("up_velocity", params.vlabel("up_velocity", prefix="W — ")) - if "up_velocity" in ds - else None + fig_vel_uvw_b64 = _combined_ts_fig( + [ + ( + "east_velocity", + params.vlabel("east_velocity", prefix="U — "), + False, + None, + ), + ( + "north_velocity", + params.vlabel("north_velocity", prefix="V — "), + False, + None, + ), + ( + "up_velocity", + params.vlabel("up_velocity", prefix="W — "), + False, + None, + ), + ], + "east_velocity", ) fig_turbidity_b64 = ( _ts_fig("turbidity", params.vlabel("turbidity"), dot_overlay=True) @@ -893,7 +980,7 @@ def _draw_spacing( ) ax_sp.set_xlabel("Instrument spacing (dbar)") ax_sp.set_ylabel("Count (instrument pair × time step)") - ax_sp.set_title("Adjacent instrument spacing distribution") + plot_title(ax_sp, "Instrument spacing") return fig_sp # Render at the "third" slot so the PNG width matches the @@ -948,13 +1035,9 @@ def _draw_spacing( STACK_DEFAULT, StackContext( figs={ - "pressure": fig_pressure_b64, - "temperature": fig_temp_b64, - "salinity": fig_sal_b64, + "hydro_pts": fig_hydro_pts_b64, "dissolved_oxygen": fig_dissolved_oxygen_b64, - "east_velocity": fig_east_vel_b64, - "north_velocity": fig_north_vel_b64, - "up_velocity": fig_up_vel_b64, + "vel_uvw": fig_vel_uvw_b64, "turbidity": fig_turbidity_b64, "trajectories_aquadopp": fig_trajectories_b64, "trajectories_adcp": fig_adcp_trajectories_b64, 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 039761b..27cc728 100644 --- a/tests/fixtures/golden/dune2/dune2_1_2026_stack_report.html +++ b/tests/fixtures/golden/dune2/dune2_1_2026_stack_report.html @@ -534,36 +534,16 @@
Values with QC flag ≥ 3 (suspect/bad) masked to NaN before plotting. All data values are in the source file without masking. ADCP instruments excluded.
+Pressure, temperature and salinity for the full deployment — one row each on a shared time axis. One colour per instrument (shared across rows, ordered by height) so a line can be followed through all three panels; the single legend spans the union of instruments. Values with QC flag ≥ 3 (suspect/bad) masked to NaN; ADCP excluded from pressure.
Values with QC flag ≥ 3 (suspect/bad) masked to NaN before plotting. All data values are in the source file without masking.
-Values with QC flag ≥ 3 (suspect/bad) masked to NaN before plotting. All data values are in the source file without masking.
-ENU frame. Values with velocity_flag ≥ 3 masked to NaN before plotting. All data values are in the source file without masking. Instruments without velocity data omitted.
-ENU frame. Values with velocity_flag ≥ 3 masked to NaN before plotting. All data values are in the source file without masking. Instruments without velocity data omitted.
-ENU frame. Values with velocity_flag ≥ 3 masked to NaN before plotting. All data values are in the source file without masking. Instruments without velocity data omitted.
+Eastward (U), northward (V) and upward (W) velocity in the ENU frame — one row each on a shared time axis, one colour per instrument (shared across rows, ordered by height) with a single legend over the union of instruments. Values with velocity_flag ≥ 3 masked to NaN; instruments without velocity data omitted.