From fa71bd59c34039239a0dca1d6d9b98e6ae68ebd4 Mon Sep 17 00:00:00 2001
From: Eleanor Frajka-Williams
Date: Sat, 15 Aug 2026 14:03:47 +0200
Subject: [PATCH 1/3] fixes: U2
---
oceanarray/plotters/current.py | 153 ++++++--
oceanarray/plotters/diagnostic.py | 91 +++--
oceanarray/plotters/helpers.py | 19 +-
oceanarray/plotters/hydrography.py | 27 +-
oceanarray/plotters/primitives.py | 16 +-
oceanarray/plotters/spectrum.py | 34 +-
oceanarray/plotters/timeseries.py | 71 +++-
oceanarray/plotters/ts.py | 29 +-
oceanarray/reports/_array.py | 3 +-
oceanarray/reports/_env.py | 12 +
oceanarray/reports/_plots.py | 26 +-
oceanarray/reports/_slots.py | 107 ++++++
oceanarray/reports/_stack.py | 13 +-
oceanarray/reports/templates/base.html | 125 ++++---
oceanarray/reports/templates/grid.html | 15 +-
oceanarray/reports/templates/instrument.html | 37 +-
oceanarray/reports/templates/mooring.html | 88 ++---
oceanarray/reports/templates/stack.html | 18 +-
.../dune2/dune2_1_2026_grid_report.html | 308 ++++++++++++----
.../golden/dune2/dune2_1_2026_report.html | 347 +++++++++++++-----
.../dune2/dune2_1_2026_stack_report.html | 323 +++++++++++-----
.../instrument/dune2_1_2026_2941_report.html | 300 +++++++++++----
.../instrument/dune2_1_2026_9920_report.html | 304 +++++++++++----
tests/unit/test_report_tokens.py | 36 ++
24 files changed, 1831 insertions(+), 671 deletions(-)
create mode 100644 oceanarray/reports/_slots.py
diff --git a/oceanarray/plotters/current.py b/oceanarray/plotters/current.py
index e54171b..56ba5eb 100644
--- a/oceanarray/plotters/current.py
+++ b/oceanarray/plotters/current.py
@@ -29,7 +29,7 @@
from matplotlib.collections import LineCollection
from oceanarray.analysis.vector import xyz_to_enu_2d, progressive_vector
-from oceanarray.plotters.helpers import tukey_smooth
+from oceanarray.plotters.helpers import grid_despine, tukey_smooth
from oceanarray.plotters.primitives import (
colorbar_norm,
date_axis,
@@ -118,6 +118,8 @@ def plot_temperature_trajectory(
def plot_speed_boxplot(
ds: xr.Dataset,
speed_var: str = "current_speed",
+ *,
+ width_in: float = report_tokens.W_THIRD,
) -> object:
"""Boxplot of current speed with printed percentile statistics.
@@ -130,6 +132,9 @@ def plot_speed_boxplot(
Dataset containing the speed variable.
speed_var : str
Name of the current speed variable.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -149,7 +154,7 @@ def plot_speed_boxplot(
val = np.percentile(speed_clean, p)
print(f" {p:2d}th percentile: {val:.4f} {units}")
- fig, ax = plt.subplots(figsize=(report_tokens.W_THIRD, 3.5))
+ fig, ax = plt.subplots(figsize=(width_in, 3.5))
bp = ax.boxplot(
speed_clean,
vert=True,
@@ -164,7 +169,7 @@ def plot_speed_boxplot(
instr_id = ds.attrs.get("id", "")
if instr_id:
ax.set_title(instr_id, fontsize=9)
- ax.grid(True, axis="y", linestyle="--", linewidth=0.6, alpha=0.5)
+ grid_despine(ax, axis="y")
fig.tight_layout()
return fig
@@ -176,8 +181,9 @@ def plot_multi_aquadopp_trajectories(
temp_var: str = "temperature",
instr_type_var: str = "instrument_type",
serial_var: str = "serial",
- hab_var: str = "hab",
title: str = "",
+ *,
+ width_in: float = report_tokens.W_HALF,
) -> Optional[plt.Figure]:
"""Multi-instrument Lagrangian trajectories for all Aquadopps, coloured by temperature.
@@ -199,6 +205,9 @@ def plot_multi_aquadopp_trajectories(
Dimension-coordinate variable names identifying each instrument.
title : str
Optional figure title; falls back to the dataset ``id`` attribute.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -213,7 +222,6 @@ def plot_multi_aquadopp_trajectories(
"""
instr_types = ds[instr_type_var].values
serials = ds[serial_var].values
- habs = ds[hab_var].values
aqd_idx = [i for i, t in enumerate(instr_types) if str(t).lower() == "aquadopp"]
if not aqd_idx:
@@ -254,12 +262,11 @@ def plot_multi_aquadopp_trajectories(
_bounds = _nice_colorbar_bounds(0.0, 1.0, n=20)
norm: mcolors.BoundaryNorm = mcolors.BoundaryNorm(_bounds, ncolors=256)
- fig, axes, cax = square_axes_grid(report_tokens.W_HALF, 1, 1, colorbar=has_temp)
+ fig, axes, cax = square_axes_grid(width_in, 1, 1, colorbar=has_temp)
ax = axes[0, 0]
for instr_i, x, y, temp in trajs:
serial = str(serials[instr_i])
- hab = float(habs[instr_i])
if has_temp and temp is not None:
points = np.array([x, y]).T.reshape(-1, 1, 2)
@@ -294,7 +301,7 @@ def plot_multi_aquadopp_trajectories(
markeredgewidth=0.5,
)
ax.annotate(
- f"s/n {serial} {hab:.0f} m hab",
+ f"{serial}",
xy=(x[-1], y[-1]),
xytext=(6, 3),
textcoords="offset points",
@@ -327,7 +334,7 @@ def plot_multi_aquadopp_trajectories(
ax.axhline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4)
ax.axvline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4)
ax.set_aspect("equal", adjustable="datalim")
- ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.4)
+ grid_despine(ax)
if not title:
title = ds.attrs.get("id", "")
if title:
@@ -341,6 +348,8 @@ def plot_hodograph(
v_var: str = "north_velocity",
lp_days: float = 4.0,
smooth_hours: float = 3.0,
+ *,
+ width_in: float = report_tokens.W_FULL,
) -> plt.Figure:
"""Two-panel hodograph: Tukey-smoothed raw and eddy-only, coloured by time.
@@ -368,6 +377,9 @@ def plot_hodograph(
Low-pass window length in days for the eddy-component panel.
smooth_hours : float
Tukey smoothing window in hours applied to both panels.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -381,7 +393,7 @@ def plot_hodograph(
# primitive used by the ADCP and grid hodographs, so all three render
# identically and the colorbar height always matches the plotted square.
fig, axes, cax = square_axes_grid(
- report_tokens.W_FULL, 1, 2, top_pad_in=0.3 if instr_id else 0.0
+ width_in, 1, 2, top_pad_in=0.3 if instr_id else 0.0
)
ax_raw, ax_eddy = axes[0, 0], axes[0, 1]
if instr_id:
@@ -471,6 +483,8 @@ def plot_aquadopp_speed_profile(
instr_type_var: str = "instrument_type",
serial_var: str = "serial",
hab_var: str = "hab",
+ *,
+ width_in: float = report_tokens.W_HALF,
) -> Optional[plt.Figure]:
"""Horizontal speed boxplots for all Aquadopps, one per instrument at its HAB.
@@ -491,6 +505,9 @@ def plot_aquadopp_speed_profile(
Used to compute speed when *speed_var* is not present.
instr_type_var, serial_var, hab_var : str
Dimension-coordinate variable names.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -532,9 +549,7 @@ def plot_aquadopp_speed_profile(
hab_range = max(hab_vals) - min(hab_vals) if len(hab_vals) > 1 else 10.0
box_width = max(2.0, hab_range * 0.06)
- fig, ax = plt.subplots(
- figsize=(report_tokens.W_HALF, max(3, len(records) * 0.7 + 1))
- )
+ fig, ax = plt.subplots(figsize=(width_in, max(3, len(records) * 0.7 + 1)))
for hab, serial, spd_clean in records:
bp = ax.boxplot(
@@ -568,7 +583,7 @@ def plot_aquadopp_speed_profile(
ax.set_ylabel("Height above bottom (m)")
ax.set_xlim(left=0)
ax.set_ylim(min(hab_vals) - box_width * 1.5, max(hab_vals) + box_width * 1.5)
- ax.grid(True, axis="x", linestyle="--", linewidth=0.5, alpha=0.5)
+ grid_despine(ax, axis="x")
fig.tight_layout()
return fig
@@ -581,6 +596,8 @@ def plot_adcp_trajectories(
hab_var: str = "hab",
seabed_qc_var: str = "seabed_qc",
percent_good_qc_var: str = "percent_good_qc",
+ *,
+ width_in: float = report_tokens.W_HALF,
) -> Optional[plt.Figure]:
"""Lagrangian per-bin trajectories for ADCP data, coloured by HAB.
@@ -602,6 +619,9 @@ def plot_adcp_trajectories(
QC variable for seabed proximity; bins with all values >= 3 are skipped.
percent_good_qc_var : str
Ping-quality QC; timesteps flagged >= 3 are zeroed before integration.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -673,7 +693,7 @@ def plot_adcp_trajectories(
# Half width — shown in a 50% flex column beside the Aquadopp trajectory
# (see stack.html), matching plot_multi_aquadopp_trajectories.
- fig, axes, cax = square_axes_grid(report_tokens.W_HALF, 1, 1)
+ fig, axes, cax = square_axes_grid(width_in, 1, 1)
ax = axes[0, 0]
for hab, x, y in trajs:
@@ -699,12 +719,16 @@ def plot_adcp_trajectories(
ax.axhline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4)
ax.axvline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4)
ax.set_aspect("equal", adjustable="datalim")
- ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.4)
+ grid_despine(ax)
ax.set_title("ADCP bins coloured by HAB")
return fig
-def draw_instrument_rose(nc_path: Path) -> "Optional[plt.Figure]":
+def draw_instrument_rose(
+ nc_path: Path,
+ *,
+ width_in: float = report_tokens.W_FULL,
+) -> "Optional[plt.Figure]":
"""Rose diagram grid for a single Aquadopp instrument; return Figure or None.
Loads the stage-3 NetCDF at *nc_path*, builds one polar panel per available
@@ -715,6 +739,9 @@ def draw_instrument_rose(nc_path: Path) -> "Optional[plt.Figure]":
----------
nc_path : Path
Path to a stage-3 NetCDF file for a single Aquadopp instrument.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -782,7 +809,7 @@ def _masked(flag_mask: "np.ndarray") -> "tuple[np.ndarray, np.ndarray]":
# Full width — the figure is displayed at 100% (see instrument.html), so
# figsize width == slot width and the browser does not rescale (which would
# shrink the panel fonts). Was bumped 6"→9" after "too small" feedback.
- figsize=(report_tokens.W_FULL, report_tokens.W_FULL / max(ncols, 1) + 0.4),
+ figsize=(width_in, width_in / max(ncols, 1) + 0.4),
subplot_kw={"projection": "polar"},
squeeze=False,
)
@@ -798,6 +825,8 @@ def _masked(flag_mask: "np.ndarray") -> "tuple[np.ndarray, np.ndarray]":
def draw_rose_grid(
ds: "xr.Dataset",
serial_list: list,
+ *,
+ width_in: float = report_tokens.W_FULL,
) -> "Optional[tuple[plt.Figure, int]]":
"""Grid of current roses (max 4 per row) for instruments with ENU velocity data.
@@ -807,6 +836,9 @@ def draw_rose_grid(
Stack dataset with ``east_velocity`` and ``north_velocity``.
serial_list : list
Serial numbers corresponding to the instrument axis of the velocity arrays.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -887,19 +919,24 @@ def draw_rose_grid(
if n == 0:
return None
- ncols = min(n, 4)
+ # Always a 4-wide grid; vary the number of rows and render at full width so
+ # every rose is the same size regardless of instrument count (empty trailing
+ # cells are hidden below). Row height tracks the ¼-width cell so roses stay
+ # square-ish.
+ ncols = 4
nrows = math.ceil(n / ncols)
fig, axs = plt.subplots(
nrows,
ncols,
- figsize=(report_tokens.W_FULL, nrows * 3.2),
+ figsize=(width_in, nrows * (width_in / ncols + 0.65)),
subplot_kw={"projection": "polar"},
squeeze=False,
)
- # Tighten the left-right gap between polar panels to match the instrument-page
- # rose (encoder skips tight_layout for polar figures, so set it explicitly).
- fig.subplots_adjust(wspace=0.5)
+ # Trim the outer left/right margins and the inter-panel gap (the tucked-in
+ # N/E/S/W labels no longer need the wide gap). Encoder skips tight_layout for
+ # polar figures, so set the margins explicitly.
+ fig.subplots_adjust(left=0.05, right=0.95, wspace=0.4)
axs_flat = axs.flatten()
for plot_i, instr_i in enumerate(aqd_idx):
@@ -918,7 +955,12 @@ def draw_rose_grid(
return fig, n
-def draw_grid_rose(ds: "xr.Dataset", max_roses: int = 4) -> "Optional[plt.Figure]":
+def draw_grid_rose(
+ ds: "xr.Dataset",
+ max_roses: int = 4,
+ *,
+ width_in: float = report_tokens.W_FULL,
+) -> "Optional[plt.Figure]":
"""Grid of current roses, one per pressure level, for the grid report.
Shows up to *max_roses* pressure levels (at most 1/5th of valid levels,
@@ -933,6 +975,9 @@ def draw_grid_rose(ds: "xr.Dataset", max_roses: int = 4) -> "Optional[plt.Figure
``east_velocity`` and ``north_velocity`` in m s⁻¹.
max_roses : int
Maximum number of rose panels to draw (default 4).
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -973,7 +1018,7 @@ def draw_grid_rose(ds: "xr.Dataset", max_roses: int = 4) -> "Optional[plt.Figure
fig, axs = plt.subplots(
nrows,
ncols,
- figsize=(report_tokens.W_FULL, nrows * 3.2),
+ figsize=(width_in, nrows * 3.2),
subplot_kw={"projection": "polar"},
squeeze=False,
)
@@ -991,7 +1036,11 @@ def draw_grid_rose(ds: "xr.Dataset", max_roses: int = 4) -> "Optional[plt.Figure
return fig
-def draw_grid_trajectory(ds: "xr.Dataset") -> "Optional[plt.Figure]":
+def draw_grid_trajectory(
+ ds: "xr.Dataset",
+ *,
+ width_in: float = report_tokens.W_HALF,
+) -> "Optional[plt.Figure]":
"""Pseudo-Lagrangian current-vector integral by pressure level for the grid report.
For each pressure level, integrates east and north velocity over time using
@@ -1004,6 +1053,9 @@ def draw_grid_trajectory(ds: "xr.Dataset") -> "Optional[plt.Figure]":
ds : xr.Dataset
Gridded dataset with dimensions ``(time, pressure)``, containing
``east_velocity`` and ``north_velocity`` in m s⁻¹.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -1042,7 +1094,7 @@ def draw_grid_trajectory(ds: "xr.Dataset") -> "Optional[plt.Figure]":
_bounds, norm = colorbar_norm(vmin=min(p_vals), vmax=max(p_vals))
cmap = plt.get_cmap("viridis_r") # shallow (low p) → light; deep → dark
- fig, axes, cax = square_axes_grid(report_tokens.W_HALF, 1, 1)
+ fig, axes, cax = square_axes_grid(width_in, 1, 1)
ax = axes[0, 0]
for p_val, x, y in trajs:
@@ -1068,11 +1120,15 @@ def draw_grid_trajectory(ds: "xr.Dataset") -> "Optional[plt.Figure]":
ax.axhline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4)
ax.axvline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4)
ax.set_aspect("equal", adjustable="datalim")
- ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.4)
+ grid_despine(ax)
return fig
-def draw_adcp_velocity(nc_path: str) -> "Optional[plt.Figure]":
+def draw_adcp_velocity(
+ nc_path: str,
+ *,
+ width_in: float = report_tokens.W_FULL,
+) -> "Optional[plt.Figure]":
"""Stacked colour panels for the ADCP per-instrument HTML report page; return a Figure.
Reads the stage-3 NetCDF file at *nc_path* and produces a multi-panel
@@ -1112,6 +1168,9 @@ def draw_adcp_velocity(nc_path: str) -> "Optional[plt.Figure]":
----------
nc_path : str
Path to a stage-3 NetCDF file for a single ADCP instrument.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -1215,7 +1274,7 @@ def draw_adcp_velocity(nc_path: str) -> "Optional[plt.Figure]":
n = len(present)
fig, axes = plt.subplots(
- n, 1, figsize=(report_tokens.W_FULL, 3.5 * n), sharex=True, squeeze=False
+ n, 1, figsize=(width_in, 3.5 * n), sharex=True, squeeze=False
)
orientation = ds.attrs.get("orientation_yaml") or ds.attrs.get(
@@ -1269,7 +1328,7 @@ def draw_adcp_velocity(nc_path: str) -> "Optional[plt.Figure]":
cb.set_label(cb_label)
ax.set_ylabel(ylabel)
ax.set_title(label, loc="left")
- ax.grid(True, linestyle="--", linewidth=0.3, alpha=0.4)
+ grid_despine(ax)
# Show from 0 (includes blanking zone) to deepest valid bin.
# set_ylim with reversed args inverts for downward-looking.
if looking_down:
@@ -1282,7 +1341,11 @@ def draw_adcp_velocity(nc_path: str) -> "Optional[plt.Figure]":
return fig
-def draw_adcp_rose(nc_path: str) -> "Optional[plt.Figure]":
+def draw_adcp_rose(
+ nc_path: str,
+ *,
+ width_in: float = report_tokens.W_FULL,
+) -> "Optional[plt.Figure]":
"""Current rose panels for an ADCP: depth-average plus percentile-selected bins.
Selects the depth-average and up to four individual range bins at the 10th,
@@ -1294,6 +1357,9 @@ def draw_adcp_rose(nc_path: str) -> "Optional[plt.Figure]":
----------
nc_path : str
Path to a stage-3 ADCP NetCDF file.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -1366,7 +1432,7 @@ def draw_adcp_rose(nc_path: str) -> "Optional[plt.Figure]":
fig, axs = plt.subplots(
1,
ncols,
- figsize=(report_tokens.W_FULL, 4.0),
+ figsize=(width_in, 4.0),
subplot_kw={"projection": "polar"},
squeeze=False,
)
@@ -1401,7 +1467,11 @@ def draw_adcp_rose(nc_path: str) -> "Optional[plt.Figure]":
def draw_adcp_hodograph(
- nc_path: str, lp_days: float = 4.0, smooth_hours: float = 24.0
+ nc_path: str,
+ lp_days: float = 4.0,
+ smooth_hours: float = 24.0,
+ *,
+ width_in: float = report_tokens.W_FULL,
) -> "Optional[plt.Figure]":
"""Two-depth hodograph for an ADCP per-instrument report; return a Figure.
@@ -1425,6 +1495,9 @@ def draw_adcp_hodograph(
Low-pass filter cutoff in days for eddy extraction.
smooth_hours : float
Tukey smoothing window in hours for the raw panel.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -1498,7 +1571,7 @@ def draw_adcp_hodograph(
# Deterministic square-panel grid: each hodograph is an exact square so the
# single shared time colorbar (right) matches the panel height exactly.
- fig, axes, cax = square_axes_grid(report_tokens.W_FULL, 2, 2)
+ fig, axes, cax = square_axes_grid(width_in, 2, 2)
sm_far = _draw_hodograph_pair(
axes[0, 0],
@@ -1533,7 +1606,10 @@ def draw_adcp_hodograph(
def draw_grid_hodograph(
- ds: "xr.Dataset", smooth_hours: float = 24.0
+ ds: "xr.Dataset",
+ smooth_hours: float = 24.0,
+ *,
+ width_in: float = report_tokens.W_FULL,
) -> "Optional[plt.Figure]":
"""Two-depth hodograph for the grid report; return a Figure.
@@ -1549,6 +1625,9 @@ def draw_grid_hodograph(
Gridded mooring dataset with ``east_velocity`` and ``north_velocity``.
smooth_hours : float
Tukey smoothing window in hours.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -1612,7 +1691,7 @@ def draw_grid_hodograph(
# Same deterministic square-panel layout as the ADCP hodograph, so both
# reports render hodographs and their shared time colorbar identically.
- fig, axes, cax = square_axes_grid(report_tokens.W_FULL, 1, 2)
+ fig, axes, cax = square_axes_grid(width_in, 1, 2)
ax_shallow, ax_deep = axes[0, 0], axes[0, 1]
sm = None
diff --git a/oceanarray/plotters/diagnostic.py b/oceanarray/plotters/diagnostic.py
index 7e47476..fd391f9 100644
--- a/oceanarray/plotters/diagnostic.py
+++ b/oceanarray/plotters/diagnostic.py
@@ -318,7 +318,7 @@ def plot_knockdown_pressure(
ax.legend(fontsize=9, loc="upper left")
ax.set_xlabel("Nominal pressure (dbar)")
ax.set_ylabel("Measured pressure (dbar)")
- ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.5)
+ grid_despine(ax)
plt.tight_layout()
return fig
@@ -331,6 +331,8 @@ def plot_knockdown_pressure(
def plot_knockdown_hab(
ds: "xr.Dataset",
+ *,
+ width_in: float = report_tokens.W_HALF,
) -> "Optional[matplotlib.figure.Figure]":
"""IQR of measured pressure vs. nominal HAB, equal aspect ratio.
@@ -354,6 +356,9 @@ def plot_knockdown_hab(
``pressure``, ``hab``, ``serial``, ``instrument_type``, and
optionally ``pressure_qc``. The ``waterdepth`` global attribute
must be present and non-zero.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -390,7 +395,7 @@ def plot_knockdown_hab(
with plt.style.context(str(params.MPLSTYLE)):
# Half width — displayed side-by-side with the anomaly panel in a two-column
# flex row (see mooring.html), so the slot is ~half the page, not full.
- fig, ax = plt.subplots(figsize=(report_tokens.W_HALF, report_tokens.W_HALF))
+ fig, ax = plt.subplots(figsize=(width_in, width_in))
p_max_all = 0.0
for hab_nom, _serial, actual_p in hab_records:
@@ -439,6 +444,8 @@ def plot_knockdown_hab(
def plot_knockdown_anomaly(
ds: "xr.Dataset",
+ *,
+ width_in: float = report_tokens.W_HALF,
) -> "Optional[matplotlib.figure.Figure]":
"""IQR of pressure anomaly (measured − nominal) per instrument.
@@ -465,6 +472,9 @@ def plot_knockdown_anomaly(
----------
ds : xr.Dataset
Stack dataset; same requirements as :func:`plot_knockdown_pressure`.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -495,9 +505,7 @@ def plot_knockdown_anomaly(
with plt.style.context(str(params.MPLSTYLE)):
# Half width — side-by-side with the HAB panel in the mooring.html flex row.
- fig, ax = plt.subplots(
- figsize=(report_tokens.W_HALF, max(3, len(records) * 0.4 + 1))
- )
+ fig, ax = plt.subplots(figsize=(width_in, max(3, len(records) * 0.4 + 1)))
for p_nom, _serial, actual_p in records:
anomaly = actual_p - p_nom # positive = knocked down deeper
@@ -537,6 +545,8 @@ def plot_knockdown_anomaly(
def plot_knockdown_displacement(
ds: "xr.Dataset",
+ *,
+ width_in: float = report_tokens.W_FULL,
) -> "Optional[matplotlib.figure.Figure]":
"""Scatter and heatmap of estimated horizontal displacement vs. measured pressure.
@@ -560,6 +570,9 @@ def plot_knockdown_displacement(
----------
ds : xr.Dataset
Stack dataset; same requirements as :func:`plot_knockdown_pressure`.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -610,10 +623,16 @@ def plot_knockdown_displacement(
x_max = max(float(np.nanmax(all_x)) if len(all_x) else 1.0, 1.0)
p_max = float(np.nanmax(all_p)) * 1.05 if len(all_p) else 1.0
+ # Square-axes logic: a single equal extent on both axes so that, with a
+ # square box and 1 dbar ≈ 1 m, the scatter reads at true 1:1 scale (a mooring
+ # barely tilts, so the near-vertical shape is the physical point). Anchored
+ # at 0 rather than centred (displacement and depth both start at the anchor),
+ # so this adapts square_limits() to the knockdown's 0-based geometry.
+ sq_extent = max(x_max, p_max)
with plt.style.context(str(params.MPLSTYLE)):
fig, (ax1, ax2) = plt.subplots(
- 1, 2, figsize=(report_tokens.W_FULL, 4.5), sharey=True, sharex=True
+ 1, 2, figsize=(width_in, 4.5), sharey=True, sharex=True
)
# --- left panel: scatter ---
@@ -628,17 +647,17 @@ def plot_knockdown_displacement(
label=str(serial),
rasterized=True,
)
- ax1.set_xlim(0, x_max) # sharex propagates to ax2
+ ax1.set_xlim(0, sq_extent) # sharex propagates to ax2
ax1.set_xlabel("Horizontal displacement (m)")
ax1.set_ylabel("Measured pressure (dbar)")
ax1.legend(fontsize=9, loc="lower right", markerscale=3)
- ax1.set_aspect("equal", adjustable="box") # 100 m on x = 100 m on y
- ax1.grid(True, linestyle="--", linewidth=0.4, alpha=0.5)
+ ax1.set_box_aspect(1) # square box; equal extent above keeps 100 m x = 100 m y
+ grid_despine(ax1)
# Shared tick step so x and y gridlines fall at the same intervals
import matplotlib.ticker as mticker
- _ax_range = max(x_max, p_max)
+ _ax_range = sq_extent
_step = next(
s for s in [10, 20, 25, 50, 100, 200, 250, 500, 1000] if _ax_range / s <= 6
)
@@ -681,11 +700,11 @@ def plot_knockdown_displacement(
label="Normalised density (sum = 1 per instrument)",
)
- ax2.set_ylim(0, p_max) # sharey propagates this to ax1
- ax2.set_aspect("equal", adjustable="box")
+ ax2.set_ylim(0, sq_extent) # sharey propagates this to ax1
+ ax2.set_box_aspect(1)
ax2.invert_yaxis()
ax2.set_xlabel("Horizontal displacement (m)")
- ax2.grid(True, linestyle="--", linewidth=0.4, alpha=0.5, zorder=3)
+ grid_despine(ax2)
plt.tight_layout()
return fig
@@ -701,6 +720,8 @@ def plot_clock_offset_check(
deploy_dt: "Optional[datetime]",
recover_dt: "Optional[datetime]",
window_minutes: int = 30,
+ *,
+ width_in: float = report_tokens.W_FULL,
) -> "Optional[matplotlib.figure.Figure]":
"""Overlaid, per-instrument normalised temperature around deploy and recover.
@@ -736,6 +757,9 @@ def plot_clock_offset_check(
Recovery time (UTC).
window_minutes : int
Duration of each zoom window in minutes.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -794,9 +818,7 @@ def plot_clock_offset_check(
n_panels = len(windows)
with plt.style.context(str(params.MPLSTYLE)):
- fig, axes = plt.subplots(
- 1, n_panels, figsize=(report_tokens.W_FULL, 3.5), sharey=False
- )
+ fig, axes = plt.subplots(1, n_panels, figsize=(width_in, 3.5), sharey=False)
if n_panels == 1:
axes = [axes]
@@ -819,7 +841,7 @@ def plot_clock_offset_check(
ax.set_title(title)
ax.set_ylabel("Normalised temperature (std)")
- ax.grid(True)
+ grid_despine(ax)
locator = mdates.AutoDateLocator()
ax.xaxis.set_major_locator(locator)
ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
@@ -868,6 +890,8 @@ def draw_windows(
vlines: Optional[list] = None,
stage1_nc: Optional[Path] = None,
panels: Optional[list] = None,
+ *,
+ width_in: float = report_tokens.W_FULL,
) -> "Optional[plt.Figure]":
"""Combined start + end window figure: (nrows × 2) — left = first N h, right = last N h.
@@ -900,6 +924,9 @@ def draw_windows(
Subset of ``_instrument_panels`` tuples to draw. When given, only these
rows are rendered (used to paginate a tall window figure across several
images); otherwise every panel for the instrument is drawn on one figure.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -947,7 +974,7 @@ def draw_windows(
for vname, *_ in panels
]
nrows = len(panels)
- fig = plt.figure(figsize=(report_tokens.W_FULL, sum(height_ratios)))
+ fig = plt.figure(figsize=(width_in, sum(height_ratios)))
gs = GridSpec(
nrows,
2,
@@ -1174,7 +1201,11 @@ def _plot_grey( # noqa: ANN202
ds1.close()
-def draw_data_histogram(nc_path: Path) -> "Optional[plt.Figure]":
+def draw_data_histogram(
+ nc_path: Path,
+ *,
+ width_in: float = report_tokens.W_FULL,
+) -> "Optional[plt.Figure]":
"""Histogram of data values for each main variable; return a Figure.
Each panel shows grey bars (all finite data) and blue bars (kept, not bad/missing),
@@ -1184,6 +1215,9 @@ def draw_data_histogram(nc_path: Path) -> "Optional[plt.Figure]":
----------
nc_path : Path
Path to a stage-3 NetCDF file.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -1226,7 +1260,7 @@ def draw_data_histogram(nc_path: Path) -> "Optional[plt.Figure]":
fig, axs_grid = plt.subplots(
nrows,
ncols,
- figsize=(report_tokens.W_FULL, 2.5 * nrows),
+ figsize=(width_in, 2.5 * nrows),
squeeze=False,
sharey=True,
layout="constrained",
@@ -1388,7 +1422,11 @@ def draw_data_histogram(nc_path: Path) -> "Optional[plt.Figure]":
return fig
-def draw_velocity_iqr_profile(ds: "xr.Dataset") -> "Optional[plt.Figure]":
+def draw_velocity_iqr_profile(
+ ds: "xr.Dataset",
+ *,
+ width_in: float = report_tokens.W_FULL,
+) -> "Optional[plt.Figure]":
"""Percentile-profile figure for gridded ADCP velocity data; return a Figure.
Three side-by-side panels, all with pressure (dbar) on the Y-axis (inverted,
@@ -1421,6 +1459,9 @@ def draw_velocity_iqr_profile(ds: "xr.Dataset") -> "Optional[plt.Figure]":
ds : xr.Dataset
Gridded dataset with dimensions ``(time, pressure)`` containing at minimum one
of ``current_speed``, ``east_velocity``, or ``north_velocity`` in m s⁻¹.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -1471,7 +1512,7 @@ def draw_velocity_iqr_profile(ds: "xr.Dataset") -> "Optional[plt.Figure]":
fig, axs = plt.subplots(
1,
n_panels,
- figsize=(report_tokens.W_FULL, 4.5),
+ figsize=(width_in, 4.5),
sharey=True,
gridspec_kw={"width_ratios": [2] * (n_panels - 1) + [1]},
)
@@ -1534,7 +1575,7 @@ def draw_velocity_iqr_profile(ds: "xr.Dataset") -> "Optional[plt.Figure]":
)
ax.set_xlim(left=0)
ax.set_xlabel("Current speed (m s⁻¹)")
- ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.5)
+ grid_despine(ax)
ax.legend(loc="best")
# ── Panel 2: east + north on shared axes ─────────────────────────────────
@@ -1573,7 +1614,7 @@ def draw_velocity_iqr_profile(ds: "xr.Dataset") -> "Optional[plt.Figure]":
if absmax > 0:
ax.set_xlim(-absmax * 1.15, absmax * 1.15)
ax.set_xlabel("Velocity (m s⁻¹)")
- ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.5)
+ grid_despine(ax)
ax.legend(loc="best")
# ── Count panel (rightmost) ───────────────────────────────────────────────
@@ -1586,7 +1627,7 @@ def draw_velocity_iqr_profile(ds: "xr.Dataset") -> "Optional[plt.Figure]":
alpha=0.6,
)
ax_count.set_xlabel("N good")
- ax_count.grid(True, linestyle="--", linewidth=0.4, alpha=0.5)
+ grid_despine(ax_count)
axs[0].set_ylabel("Pressure (dbar)")
axs[0].invert_yaxis() # shared y — invert once only
diff --git a/oceanarray/plotters/helpers.py b/oceanarray/plotters/helpers.py
index e597345..1361e10 100644
--- a/oceanarray/plotters/helpers.py
+++ b/oceanarray/plotters/helpers.py
@@ -23,20 +23,26 @@
import matplotlib.pyplot as plt
-def grid_despine(ax: "plt.Axes") -> None:
+def grid_despine(ax: "plt.Axes", *, axis: str = "both") -> None:
"""Turn the grid on and hide the top and right spines (report convention).
The report style keeps ``axes.grid`` off by default and figures opt in; when
they do, the top and right spines are redundant clutter. Call this instead of
- ``ax.grid(True)`` so the two always travel together.
+ ``ax.grid(True)`` so the two always travel together. Grid appearance (dotted,
+ faint) comes from the active mplstyle, not hard-coded here, so a single style
+ change restyles every grid.
Parameters
----------
ax : matplotlib.axes.Axes
Axes to style.
+ axis : {"both", "x", "y"}, optional
+ Which gridlines to draw (default ``"both"``). Bar/profile plots that
+ want one-directional gridlines pass ``"x"`` or ``"y"`` and still get the
+ top/right spines hidden.
"""
- ax.grid(True)
+ ax.grid(True, axis=axis)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
@@ -212,7 +218,9 @@ def _velocity_panel_style(
if var == "current_direction":
bounds = np.linspace(0, 360, 21)
norm = mcolors.BoundaryNorm(bounds, ncolors=256)
- return bounds, norm, "hsv", "°T"
+ # Cyclic colormap so 0° and 360° share a colour; twilight is perceptually
+ # uniform (hsv is not). Hard-coded here, not in parameters.py.
+ return bounds, norm, "twilight", "°T"
if var == "bin_pressure":
p_lo = float(np.percentile(finite_vals, 2)) if len(finite_vals) else 0.0
p_hi = float(np.percentile(finite_vals, 98)) if len(finite_vals) else 1000.0
@@ -308,6 +316,9 @@ def _rose_ax(
ax.set_theta_direction(-1)
ax.set_xticks(np.radians([0, 90, 180, 270]))
ax.set_xticklabels(["N", "E", "S", "W"])
+ # Tuck the N/E/S/W labels closer to the frame (about half the default ~3.5 pad)
+ # so panels can sit nearer each other without "W" crowding the next axis.
+ ax.tick_params(axis="x", pad=1.75)
ax.set_rticks([])
ax.set_title(title, pad=2)
return spd_edges, colors
diff --git a/oceanarray/plotters/hydrography.py b/oceanarray/plotters/hydrography.py
index 18a6c4b..4594278 100644
--- a/oceanarray/plotters/hydrography.py
+++ b/oceanarray/plotters/hydrography.py
@@ -24,7 +24,9 @@
from oceanarray.config import report_tokens
-def draw_isopycnal_ts_fig(ds_iso: "xr.Dataset") -> "Optional[plt.Figure]":
+def draw_isopycnal_ts_fig(
+ ds_iso: "xr.Dataset", *, width_in: float = report_tokens.W_FULL
+) -> "Optional[plt.Figure]":
"""Isopycnal height-above-seabed time series; return a Figure.
Plots a 1-hour running median of each σ₀ surface's height above seabed.
@@ -37,6 +39,9 @@ def draw_isopycnal_ts_fig(ds_iso: "xr.Dataset") -> "Optional[plt.Figure]":
Output of :func:`~oceanarray.tools.isopycnal_dataset` — must contain
``isopycnal_height`` ``(sigma0_level, time)`` and the ``sigma0_level``
coordinate.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -70,7 +75,7 @@ def draw_isopycnal_ts_fig(ds_iso: "xr.Dataset") -> "Optional[plt.Figure]":
_dens_cmap = params.CMAPS_BY_VARIABLE.get("potential_density", "Blues")
colors = ordered_line_colors(_dens_cmap, max(n_levels, 1))
- fig, ax = plt.subplots(figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN))
+ fig, ax = plt.subplots(figsize=(width_in, params.GRID_PANEL_ROW_IN))
grid_despine(ax)
for i, (sval, col) in enumerate(zip(sigma_vals, colors)):
@@ -102,7 +107,9 @@ def draw_isopycnal_ts_fig(ds_iso: "xr.Dataset") -> "Optional[plt.Figure]":
return fig
-def draw_isopycnal_coverage(ds: "xr.Dataset") -> "Optional[plt.Figure]":
+def draw_isopycnal_coverage(
+ ds: "xr.Dataset", *, width_in: float = report_tokens.W_FULL
+) -> "Optional[plt.Figure]":
"""Three-panel isopycnal diagnostic; return a Figure.
**Panel 0 — Distribution**: horizontal histogram of all gridded σ₀ values
@@ -128,6 +135,9 @@ def draw_isopycnal_coverage(ds: "xr.Dataset") -> "Optional[plt.Figure]":
ds:
Gridded mooring xr.Dataset containing a variable whose name starts with
``"sigma"`` and has ``pressure`` and ``time`` dimensions.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -242,7 +252,7 @@ def _bar_color(p: float) -> str:
fig, (ax0, ax1, ax2) = plt.subplots(
1,
3,
- figsize=(report_tokens.W_FULL, fig_h),
+ figsize=(width_in, fig_h),
sharey=True,
gridspec_kw={"width_ratios": [0.8, 1.0, 1.2]},
)
@@ -327,7 +337,9 @@ def _bar_color(p: float) -> str:
return fig
-def draw_overflow_temperature_fig(ds: "xr.Dataset") -> "Optional[plt.Figure]":
+def draw_overflow_temperature_fig(
+ ds: "xr.Dataset", *, width_in: float = report_tokens.W_FULL
+) -> "Optional[plt.Figure]":
"""Temperature time series at ~100 m above the seabed; return a Figure.
Selects the grid pressure level nearest to ``waterdepth - 100`` dbar and
@@ -340,6 +352,9 @@ def draw_overflow_temperature_fig(ds: "xr.Dataset") -> "Optional[plt.Figure]":
Gridded mooring xr.Dataset. Must have a ``waterdepth`` global
attribute (metres) and a ``temperature`` variable with ``pressure``
and ``time`` dimensions.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -385,7 +400,7 @@ def draw_overflow_temperature_fig(ds: "xr.Dataset") -> "Optional[plt.Figure]":
.values
)
- fig, ax = plt.subplots(figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN))
+ fig, ax = plt.subplots(figsize=(width_in, params.GRID_PANEL_ROW_IN))
grid_despine(ax)
ax.plot(
time_vals,
diff --git a/oceanarray/plotters/primitives.py b/oceanarray/plotters/primitives.py
index 580d546..f2ea10d 100644
--- a/oceanarray/plotters/primitives.py
+++ b/oceanarray/plotters/primitives.py
@@ -25,6 +25,7 @@
from .. import parameters as params
from oceanarray.config import report_tokens
+from .helpers import grid_despine
from ..utilities import _nice_colorbar_bounds, nice_colorbar_ticks
@@ -353,6 +354,8 @@ def plot_trajectory(
colorbar_label: str = "",
colorbar_unit: str = "",
title: str = "",
+ *,
+ width_in: float = report_tokens.W_HALF,
) -> plt.Figure:
"""Plot a 2D trajectory, optionally coloured per-segment by a scalar field.
@@ -379,15 +382,16 @@ def plot_trajectory(
Unit string placed above the colorbar (units-only on top, saves width).
title : str
Figure title.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
matplotlib.figure.Figure
"""
- fig, axes, cax = square_axes_grid(
- report_tokens.W_HALF, 1, 1, colorbar=color_data is not None
- )
+ fig, axes, cax = square_axes_grid(width_in, 1, 1, colorbar=color_data is not None)
ax = axes[0, 0]
if color_data is not None:
@@ -421,7 +425,7 @@ def plot_trajectory(
# datalim keeps the square box (from square_axes_grid) authoritative so the
# colorbar's matched height is never invalidated.
ax.set_aspect("equal", adjustable="datalim")
- ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.4)
+ grid_despine(ax)
return fig
@@ -509,7 +513,7 @@ def hodograph_panel(
ax.set_xlabel(f"East ({units})")
ax.set_ylabel(f"North ({units})")
ax.set_title(title)
- ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.3)
+ grid_despine(ax)
return sm
@@ -537,7 +541,7 @@ def pressure_axis(ax: Any) -> None:
"""Configure *ax* as a standard pressure Y-axis: inverted, labelled, gridded."""
ax.invert_yaxis()
ax.set_ylabel(params.vlabel("pressure"))
- ax.grid(True, linestyle="--", linewidth=0.3, alpha=0.4)
+ grid_despine(ax)
def colorbar_norm(
diff --git a/oceanarray/plotters/spectrum.py b/oceanarray/plotters/spectrum.py
index 15befb4..488cf6c 100644
--- a/oceanarray/plotters/spectrum.py
+++ b/oceanarray/plotters/spectrum.py
@@ -26,6 +26,7 @@
from oceanarray.utilities import _nice_colorbar_bounds, period_axis_ticks
from ..analysis.spectral import gonella_rotary_spectrum
from .primitives import square_axes_grid
+from .helpers import grid_despine
from oceanarray.config import report_tokens
@@ -179,6 +180,8 @@ def draw_spectrum(
lat: float = 0.0,
hf_segment_days: float = 1.0,
hf_x_max_days: float = 3.0,
+ *,
+ width_in: float = report_tokens.W_FULL,
) -> "Optional[plt.Figure]":
"""Two-panel Welch PSD of gridded temperature, one line per depth level.
@@ -204,6 +207,9 @@ def draw_spectrum(
hf_x_max_days:
Upper x-axis limit (longest period shown) for the HF panel in days.
When <= 3 the HF x-axis is displayed in hours; otherwise in days.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Notes
-----
@@ -376,7 +382,7 @@ def draw_spectrum(
# Square panels via the shared helper; bottom pad for the rotated HF ticks,
# top pad for the figure suptitle above the per-panel titles.
fig, _axes, _ = square_axes_grid(
- report_tokens.W_FULL, 1, 2, colorbar=False, bottom_pad_in=0.5, top_pad_in=0.6
+ width_in, 1, 2, colorbar=False, bottom_pad_in=0.5, top_pad_in=0.6
)
ax_lf, ax_hf = _axes[0, 0], _axes[0, 1]
@@ -548,6 +554,8 @@ def draw_wavelet(
da_temp: "xr.DataArray",
dt_seconds: float,
wavelet: str = "morlet",
+ *,
+ width_in: float = report_tokens.W_FULL,
) -> "Optional[plt.Figure]":
"""Continuous wavelet transform scalogram for gridded temperature; return a Figure.
@@ -575,6 +583,9 @@ def draw_wavelet(
Sample interval in seconds.
wavelet:
``"morlet"`` (default, Morlet omega_0=6) or ``"mexican_hat"``.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -672,13 +683,14 @@ def draw_wavelet(
from matplotlib.gridspec import GridSpec
n_panels = len(results)
- # height ratios: 1 part time series, 3 parts wavelet, per level
- hr = [1, 3] * n_panels
+ # height ratios: 1 part time series (top), 2.75 parts wavelet (bottom), per
+ # level. The scalogram was too tall relative to its width, so it is trimmed
+ # ~17 % (3 -> 2.75 of the ratio) and the whole pair shortened (4.5 -> 3.8 in),
+ # which also takes the time series down ~10 %.
+ hr = [1, 2.75] * n_panels
# constrained_layout crops the surrounding whitespace and places the spanning
# colorbar cleanly (the encoder skips tight_layout for constrained figures).
- fig = plt.figure(
- figsize=(report_tokens.W_FULL, 4.5 * n_panels), layout="constrained"
- )
+ fig = plt.figure(figsize=(width_in, 3.8 * n_panels), layout="constrained")
gs = GridSpec(2 * n_panels, 1, figure=fig, height_ratios=hr)
tax: list = [] # time series axes (top of each pair)
@@ -695,6 +707,7 @@ def draw_wavelet(
tax[i].plot(times, ts, lw=0.6, color="0.3")
tax[i].set_ylabel("T (°C)", fontsize="small")
tax[i].tick_params(labelsize="small")
+ grid_despine(tax[i])
# Pressure level in the bottom-left corner (was a title, which overlapped
# the scalogram of the pair above).
tax[i].text(
@@ -735,6 +748,8 @@ def draw_wavelet(
def draw_grid_rotary_spectrum(
ds: "xr.Dataset",
lat: float = 0.0,
+ *,
+ width_in: float = report_tokens.W_FULL,
) -> "Optional[plt.Figure]":
"""Two-panel rotary velocity spectrum for the grid report; return a Figure.
@@ -753,6 +768,9 @@ def draw_grid_rotary_spectrum(
``(time, pressure)`` dimensions.
lat : float
Mooring latitude (degrees, positive north) used for the inertial period marker.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -912,9 +930,7 @@ def draw_grid_rotary_spectrum(
cmap_ccw = plt.get_cmap("Blues")
# Square panels via the shared helper; bottom pad for the rotated ticks.
- fig, _axes, _ = square_axes_grid(
- report_tokens.W_FULL, 1, 2, colorbar=False, bottom_pad_in=0.5
- )
+ fig, _axes, _ = square_axes_grid(width_in, 1, 2, colorbar=False, bottom_pad_in=0.5)
ax_spec, ax_rot = _axes[0, 0], _axes[0, 1]
# Panel 1: CW (solid, reds) + CCW (dashed, blues)
diff --git a/oceanarray/plotters/timeseries.py b/oceanarray/plotters/timeseries.py
index 000b9a6..2c4dc4b 100644
--- a/oceanarray/plotters/timeseries.py
+++ b/oceanarray/plotters/timeseries.py
@@ -43,6 +43,7 @@
pcolormesh_panel,
)
from ..utilities import nice_colorbar_ticks
+from .helpers import grid_despine
from .. import parameters as params
from oceanarray.config import report_tokens
@@ -57,6 +58,8 @@ def draw_grid_fig(
symmetric: bool = False,
vmin: Optional[float] = None,
vmax: Optional[float] = None,
+ *,
+ width_in: float = report_tokens.W_FULL,
) -> "plt.Figure":
"""Render a grid figure from *da* (dims time × pressure); return a Figure.
@@ -79,6 +82,10 @@ def draw_grid_fig(
vmin, vmax : float, optional
Override the automatic percentile-based color limits.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
+
Returns
-------
plt.Figure
@@ -91,7 +98,7 @@ def draw_grid_fig(
data = da.transpose("pressure", "time").values
fig, ax = plt.subplots(
- figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN), layout="constrained"
+ figsize=(width_in, params.GRID_PANEL_ROW_IN), layout="constrained"
)
bounds, norm = colorbar_norm(data, vmin=vmin, vmax=vmax, symmetric=symmetric)
if style == "contourf":
@@ -123,6 +130,8 @@ def draw_grid_fig(
def draw_grid_hydro(
ds: "xr.Dataset",
var_bounds: "Optional[dict]" = None,
+ *,
+ width_in: float = report_tokens.W_FULL,
) -> "Optional[plt.Figure]":
"""Stacked temperature / salinity pcolormesh panels for the grid report; return a Figure.
@@ -151,6 +160,10 @@ def draw_grid_hydro(
When a key is present its limits are used instead of computing from the data.
Intended for passing the T-S diagram axis limits so both figures share scales.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
+
Returns
-------
plt.Figure or None
@@ -207,7 +220,7 @@ def draw_grid_hydro(
fig, axes = plt.subplots(
n,
1,
- figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN * n),
+ figsize=(width_in, params.GRID_PANEL_ROW_IN * n),
sharex=True,
squeeze=False,
layout="constrained",
@@ -249,7 +262,9 @@ def draw_grid_hydro(
return fig
-def draw_grid_velocity_stacked(ds: "xr.Dataset") -> "Optional[plt.Figure]":
+def draw_grid_velocity_stacked(
+ ds: "xr.Dataset", *, width_in: float = report_tokens.W_FULL
+) -> "Optional[plt.Figure]":
"""Stacked east / north / up velocity pcolormesh panels for the grid report.
All three panels share the time axis and show pressure (dbar) on the Y-axis
@@ -263,6 +278,10 @@ def draw_grid_velocity_stacked(ds: "xr.Dataset") -> "Optional[plt.Figure]":
ds : xr.Dataset
Gridded dataset with dimensions ``(time, pressure)``.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
+
Returns
-------
plt.Figure or None
@@ -315,7 +334,7 @@ def draw_grid_velocity_stacked(ds: "xr.Dataset") -> "Optional[plt.Figure]":
fig, axes = plt.subplots(
n,
1,
- figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN * n),
+ figsize=(width_in, params.GRID_PANEL_ROW_IN * n),
sharex=True,
squeeze=False,
layout="constrained",
@@ -349,7 +368,9 @@ def draw_grid_velocity_stacked(ds: "xr.Dataset") -> "Optional[plt.Figure]":
return fig
-def draw_grid_sigma(ds: "xr.Dataset") -> "Optional[plt.Figure]":
+def draw_grid_sigma(
+ ds: "xr.Dataset", *, width_in: float = report_tokens.W_FULL
+) -> "Optional[plt.Figure]":
"""Stacked sigma0 pcolormesh panel(s) for the stratification section.
Returns ``None`` when no sigma variables are present.
@@ -370,7 +391,7 @@ def draw_grid_sigma(ds: "xr.Dataset") -> "Optional[plt.Figure]":
fig, axes = plt.subplots(
n,
1,
- figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN * n),
+ figsize=(width_in, params.GRID_PANEL_ROW_IN * n),
sharex=True,
squeeze=False,
layout="constrained",
@@ -398,7 +419,9 @@ def draw_grid_sigma(ds: "xr.Dataset") -> "Optional[plt.Figure]":
return fig
-def draw_grid_n2(ds: "xr.Dataset", lat: float = 0.0) -> "Optional[plt.Figure]":
+def draw_grid_n2(
+ ds: "xr.Dataset", lat: float = 0.0, *, width_in: float = report_tokens.W_FULL
+) -> "Optional[plt.Figure]":
"""Compute and plot buoyancy frequency squared N² on the pressure-time grid.
Returns ``None`` when temperature or salinity are absent.
@@ -434,9 +457,14 @@ def draw_grid_n2(ds: "xr.Dataset", lat: float = 0.0) -> "Optional[plt.Figure]":
N2_log = np.log10(np.maximum(N2, 1e-12))
fig, ax = plt.subplots(
- figsize=(report_tokens.W_FULL, params.GRID_PANEL_ROW_IN), layout="constrained"
+ figsize=(width_in, params.GRID_PANEL_ROW_IN), layout="constrained"
)
- bounds, norm = colorbar_norm(N2_log[np.isfinite(N2_log)])
+ # Clip the colorbar to the 2.5-97.5 percentiles of log10(N²) so a few extreme
+ # cells don't wash out the stratification structure.
+ _finite = N2_log[np.isfinite(N2_log)]
+ _lo = float(np.nanpercentile(_finite, 2.5)) if _finite.size else -12.0
+ _hi = float(np.nanpercentile(_finite, 97.5)) if _finite.size else 0.0
+ bounds, norm = colorbar_norm(vmin=_lo, vmax=_hi)
pc = ax.pcolormesh(
time_vals, p_mid_1d, N2_log, shading="nearest", cmap="plasma_r", norm=norm
)
@@ -456,7 +484,9 @@ def draw_grid_n2(ds: "xr.Dataset", lat: float = 0.0) -> "Optional[plt.Figure]":
return fig
-def draw_grid_timeseries(ds: "xr.Dataset") -> "Optional[plt.Figure]":
+def draw_grid_timeseries(
+ ds: "xr.Dataset", *, width_in: float = report_tokens.W_FULL
+) -> "Optional[plt.Figure]":
"""Velocity time series at the depth of maximum time-mean current speed.
Two stacked panels (shared time axis):
@@ -477,6 +507,10 @@ def draw_grid_timeseries(ds: "xr.Dataset") -> "Optional[plt.Figure]":
Gridded dataset with dimensions ``(time, pressure)`` containing at
minimum ``east_velocity`` and ``north_velocity`` in m s⁻¹.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
+
Returns
-------
plt.Figure or None
@@ -518,7 +552,7 @@ def draw_grid_timeseries(ds: "xr.Dataset") -> "Optional[plt.Figure]":
east_ts = east[:, k_max]
north_ts = north[:, k_max]
- fig, axs = plt.subplots(2, 1, figsize=(report_tokens.W_FULL, 5), sharex=True)
+ fig, axs = plt.subplots(2, 1, figsize=(width_in, 5), sharex=True)
_C_EAST = "#0072B2"
_C_NORTH = "#E69F00"
@@ -535,7 +569,7 @@ def draw_grid_timeseries(ds: "xr.Dataset") -> "Optional[plt.Figure]":
axs[1].legend(loc="upper right", framealpha=0.8)
for ax in axs:
- ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.4)
+ grid_despine(ax)
axs[-1].xaxis.set_major_formatter(
mdates.ConciseDateFormatter(axs[-1].xaxis.get_major_locator())
)
@@ -548,7 +582,10 @@ def draw_grid_timeseries(ds: "xr.Dataset") -> "Optional[plt.Figure]":
def draw_analog_timeseries(
- nc_path: "Path", analog_vars: "List[str]"
+ nc_path: "Path",
+ analog_vars: "List[str]",
+ *,
+ width_in: float = report_tokens.W_FULL,
) -> "Optional[plt.Figure]":
"""Full-record time series for analog channel variables, one panel per variable.
@@ -561,6 +598,10 @@ def draw_analog_timeseries(
analog_vars : list of str
Variable names to plot (caller must ensure the list is non-empty).
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
+
Returns
-------
plt.Figure or None
@@ -579,7 +620,7 @@ def draw_analog_timeseries(
fig, axes = plt.subplots(
n_vars,
1,
- figsize=(report_tokens.W_FULL, max(2.5, n_vars * 2.0)),
+ figsize=(width_in, max(2.5, n_vars * 2.0)),
sharex=True,
squeeze=False,
)
@@ -616,7 +657,7 @@ def draw_analog_timeseries(
ax.set_ylabel(ylabel, fontsize=7)
ax.tick_params(axis="both", labelsize=7)
- ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.5)
+ grid_despine(ax)
fig.autofmt_xdate(rotation=30, ha="right")
return fig
diff --git a/oceanarray/plotters/ts.py b/oceanarray/plotters/ts.py
index eb1cf3c..17d974a 100644
--- a/oceanarray/plotters/ts.py
+++ b/oceanarray/plotters/ts.py
@@ -114,7 +114,9 @@ def _ts_heatmap_panel(
ax.set_title("T-S heat map")
-def draw_ts_diagram(nc_path: Path) -> "Optional[plt.Figure]":
+def draw_ts_diagram(
+ nc_path: Path, *, width_in: float = report_tokens.W_FULL
+) -> "Optional[plt.Figure]":
"""T-S diagram from a NetCDF path; return a Figure.
Scatter by pressure, 2-D count heatmap, and (when present) scatter by O2 saturation.
@@ -123,6 +125,9 @@ def draw_ts_diagram(nc_path: Path) -> "Optional[plt.Figure]":
----------
nc_path : Path
Path to a stage-3 NetCDF file.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -178,9 +183,7 @@ def draw_ts_diagram(nc_path: Path) -> "Optional[plt.Figure]":
ncols = 3 if has_sat else 2
# Square panels with per-panel height-matched colorbars (shared deterministic
# layout — same path as the hodographs/trajectories).
- fig, _ax, _cax = square_axes_grid(
- report_tokens.W_FULL, 1, ncols, per_panel_colorbar=True
- )
+ fig, _ax, _cax = square_axes_grid(width_in, 1, ncols, per_panel_colorbar=True)
ax_l, ax_r = _ax[0, 0], _ax[0, 1]
ax_sat = _ax[0, 2] if has_sat else None
@@ -280,7 +283,9 @@ def draw_ts_diagram(nc_path: Path) -> "Optional[plt.Figure]":
return fig
-def draw_stack_ts_diagram(ds: "xr.Dataset") -> "Optional[plt.Figure]":
+def draw_stack_ts_diagram(
+ ds: "xr.Dataset", *, width_in: float = report_tokens.W_FULL
+) -> "Optional[plt.Figure]":
"""T-S diagram for a stacked dataset; return a Figure.
Scatter-by-pressure, count heatmap, and (when present) scatter-by-AOU.
@@ -295,6 +300,9 @@ def draw_stack_ts_diagram(ds: "xr.Dataset") -> "Optional[plt.Figure]":
----------
ds : xr.Dataset
Stacked mooring dataset containing ``temperature`` and ``salinity``.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -339,9 +347,7 @@ def draw_stack_ts_diagram(ds: "xr.Dataset") -> "Optional[plt.Figure]":
has_sat = SAT_flat is not None and np.isfinite(SAT_flat).any()
ncols = 3 if has_sat else 2
- fig, _ax, _cax = square_axes_grid(
- report_tokens.W_FULL, 1, ncols, per_panel_colorbar=True
- )
+ fig, _ax, _cax = square_axes_grid(width_in, 1, ncols, per_panel_colorbar=True)
ax_scatter, ax_heat = _ax[0, 0], _ax[0, 1]
ax_sat = _ax[0, 2] if has_sat else None
@@ -445,7 +451,7 @@ def draw_stack_ts_diagram(ds: "xr.Dataset") -> "Optional[plt.Figure]":
def draw_grid_ts_diagram(
- ds: "xr.Dataset", n_bins: int = 60
+ ds: "xr.Dataset", n_bins: int = 60, *, width_in: float = report_tokens.W_FULL
) -> "Optional[tuple[plt.Figure, dict]]":
"""T-S diagram for gridded mooring data; return a (Figure, bounds_dict) tuple.
@@ -463,6 +469,9 @@ def draw_grid_ts_diagram(
Gridded dataset with at least ``temperature`` and ``salinity`` variables.
n_bins : int
Number of bins per axis for the 2-D histogram.
+ width_in : float, optional
+ Figure width in inches -- the display-slot width the report builder
+ resolves; standalone callers get the full content width.
Returns
-------
@@ -501,7 +510,7 @@ def draw_grid_ts_diagram(
ncols = 2 if has_o2 else 1
fig, _ax, _cax = square_axes_grid(
- report_tokens.W_FULL if has_o2 else report_tokens.W_HALF,
+ width_in,
1,
ncols,
per_panel_colorbar=True,
diff --git a/oceanarray/reports/_array.py b/oceanarray/reports/_array.py
index 73ef90b..779a913 100644
--- a/oceanarray/reports/_array.py
+++ b/oceanarray/reports/_array.py
@@ -27,6 +27,7 @@
_status,
)
from ._plots import render_b64
+from ..plotters.helpers import grid_despine
# ---------------------------------------------------------------------------
@@ -141,7 +142,7 @@ def _draw() -> "plt.Figure":
ax.set_xlabel("Longitude (°)")
ax.set_ylabel("Latitude (°)")
ax.set_title(array_name, fontsize=9)
- ax.grid(True, linestyle="--", linewidth=0.3, alpha=0.5)
+ grid_despine(ax)
plt.tight_layout()
return fig
diff --git a/oceanarray/reports/_env.py b/oceanarray/reports/_env.py
index 992f566..751b4b1 100644
--- a/oceanarray/reports/_env.py
+++ b/oceanarray/reports/_env.py
@@ -18,6 +18,8 @@
from jinja2 import Environment, FileSystemLoader
from . import _figdebug
+from . import _slots
+from ._css import emit_css
from .. import parameters as params
#: Directory holding the report page templates.
@@ -33,6 +35,16 @@
#: Per-figure debug lookup (``func · figsize · png``) for templates' ``.debug``
#: sections; returns "" unless ``OCEANARRAY_REPORT_DEBUG`` is set.
_ENV.globals["figdbg"] = _figdebug.figdbg
+#: Display slot recorded for each figure (``slot_for(b64) -> slot name``), so a
+#: template can pick its ``.slot-*`` width class from the same slot the figure was
+#: rendered at (U0.2).
+_ENV.globals["slot_for"] = _slots.slot_for
+#: Generated stylesheet — the single source of truth for report CSS (tokens,
+#: type/spacing scale, slot classes, shared chrome). base.html injects it via
+#: ``{{ css | safe }}``; page-specific rules live in a small local block that
+#: references these token variables (U0.1). The vendored ``_css.py`` is called,
+#: never edited.
+_ENV.globals["css"] = emit_css(params.PACKAGE_NAME)
def render_template(name: str, /, **context: Any) -> str:
diff --git a/oceanarray/reports/_plots.py b/oceanarray/reports/_plots.py
index 90f9cf5..c6087fd 100644
--- a/oceanarray/reports/_plots.py
+++ b/oceanarray/reports/_plots.py
@@ -15,6 +15,7 @@
from ._html_helpers import _QC_MARKER, _QC_LABELS
from ._figdebug import render_b64
+from ._slots import render as render_slot
from ..config import report_tokens
from ..plotters.primitives import (
date_axis,
@@ -137,7 +138,7 @@ def _plot_aquadopp_quick(ds: "xr.Dataset") -> "plt.Figure":
if "velocity" in vname:
ax.axhline(0, color="k", linewidth=0.5, linestyle="--")
ax.set_ylabel(label)
- ax.grid(True)
+ grid_despine(ax)
if invert:
vmin = float(ds[vname].min())
vmax = float(ds[vname].max())
@@ -474,7 +475,7 @@ def _make_grid_fig_b64(
vmax: Optional[float] = None,
) -> Optional[str]:
"""Render a grid figure from *da* (dims time × pressure); return base64 PNG or None."""
- return render_b64(
+ return render_slot(
draw_grid_fig,
da,
title,
@@ -490,7 +491,7 @@ def _make_grid_fig_b64(
def _make_grid_sigma_b64(ds: "xr.Dataset") -> Optional[str]:
"""Stacked sigma0 pcolormesh panel(s) for the stratification section."""
- return render_b64(draw_grid_sigma, ds, optional=True)
+ return render_slot(draw_grid_sigma, ds, optional=True)
def _make_grid_hydro_b64(
@@ -498,12 +499,12 @@ def _make_grid_hydro_b64(
var_bounds: "Optional[dict]" = None,
) -> Optional[str]:
"""Return base64 PNG: stacked temperature / salinity pcolormesh panels."""
- return render_b64(draw_grid_hydro, ds, var_bounds, optional=True)
+ return render_slot(draw_grid_hydro, ds, var_bounds, optional=True)
def _make_grid_velocity_stacked_b64(ds: "xr.Dataset") -> Optional[str]:
"""Stacked east / north / up velocity pcolormesh panels for the grid report."""
- return render_b64(draw_grid_velocity_stacked, ds, optional=True)
+ return render_slot(draw_grid_velocity_stacked, ds, optional=True)
def _make_spectrum_fig_b64(
@@ -563,15 +564,18 @@ def _make_grid_ts_diagram(
"""Return (b64_str_or_None, bounds_dict): T-S diagram for gridded mooring data."""
ts_bounds: dict = {}
- def _draw() -> "Optional[plt.Figure]":
- result = draw_grid_ts_diagram(ds, n_bins)
+ def _draw(*, width_in: float = report_tokens.W_FULL) -> "Optional[plt.Figure]":
+ result = draw_grid_ts_diagram(ds, n_bins, width_in=width_in)
if result is None:
return None
nonlocal ts_bounds
fig, ts_bounds = result
return fig
- return render_b64(_draw, optional=True), ts_bounds
+ # Displayed at half width (template slot-half); rendering at that width keeps
+ # the PNG px == display px. With O₂ the diagram gains a panel but the page
+ # still shows it at half — same as before U0.2, now without the oversample.
+ return render_slot(_draw, slot="half", optional=True), ts_bounds
def _make_velocity_iqr_profile_b64(ds: "xr.Dataset") -> Optional[str]:
@@ -581,7 +585,7 @@ def _make_velocity_iqr_profile_b64(ds: "xr.Dataset") -> Optional[str]:
def _make_grid_n2_b64(ds: "xr.Dataset", lat: float = 0.0) -> Optional[str]:
"""Compute and plot buoyancy frequency squared N² on the pressure-time grid."""
- return render_b64(draw_grid_n2, ds, lat, optional=True)
+ return render_slot(draw_grid_n2, ds, lat, optional=True)
def _make_rose_grid_b64(
@@ -609,7 +613,7 @@ def _make_grid_rose_b64(ds: "xr.Dataset", max_roses: int = 4) -> Optional[str]:
def _make_grid_trajectory_b64(ds: "xr.Dataset") -> Optional[str]:
"""Pseudo-Lagrangian trajectory by pressure level for the grid report."""
- return render_b64(draw_grid_trajectory, ds, optional=True)
+ return render_slot(draw_grid_trajectory, ds, slot="half", optional=True)
def _make_grid_timeseries_b64(ds: "xr.Dataset") -> Optional[str]:
@@ -697,7 +701,7 @@ def _make_aquadopp_speed_profile(ds: "xr.Dataset") -> Optional[str]:
"""
from oceanarray.plotters.current import plot_aquadopp_speed_profile
- return render_b64(plot_aquadopp_speed_profile, ds, optional=True)
+ return render_slot(plot_aquadopp_speed_profile, ds, slot="half", optional=True)
def _make_adcp_trajectories_b64(ds: "xr.Dataset") -> Optional[str]:
diff --git a/oceanarray/reports/_slots.py b/oceanarray/reports/_slots.py
new file mode 100644
index 0000000..45af80d
--- /dev/null
+++ b/oceanarray/reports/_slots.py
@@ -0,0 +1,107 @@
+"""Package-local slot layer: the display slot travels with each figure.
+
+A report figure is rendered at the inch-width of the display *slot* the template
+will place it in, and the same slot name is read back by the template to pick the
+matching ``.slot-*`` CSS class. One slot decision -- taken at the L4 figure
+builder in :mod:`oceanarray.reports._plots` -- drives both the rendered PNG width
+and the on-page width, so the PNG pixel width equals the display width (no
+oversample-then-downscale mismatch).
+
+:func:`render` resolves ``report_tokens.SLOTS[slot]`` to inches, forwards that
+width to the draw function as ``width_in``, delegates the actual encode to the
+figure-debug wrapper (which delegates to the vendored encoder -- its signature is
+untouched), and records the slot under the returned base64 string. Templates
+call the :func:`slot_for` Jinja global to read the slot back.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Callable, Optional
+
+from . import _figdebug
+from ..config import report_tokens
+
+#: Display slot chosen for each figure, keyed by its base64 PNG string. Always
+#: populated (not debug-gated) because templates read it back for the CSS class.
+_SLOT_BY_B64: dict[str, str] = {}
+
+
+def nearest_slot(frac: float) -> str:
+ """Return the :data:`report_tokens.SLOTS` name whose fraction is closest to *frac*.
+
+ Used for the one legitimately dynamic-width figure (the current-rose grid,
+ whose width scales with the number of roses): its computed width fraction is
+ snapped to the nearest standard slot so it, too, renders at a slot width.
+
+ Parameters
+ ----------
+ frac : float
+ Target width as a fraction of the full content width (0-1).
+
+ Returns
+ -------
+ str
+ The closest slot name.
+
+ """
+ return min(
+ report_tokens.SLOTS, key=lambda name: abs(report_tokens.SLOTS[name][0] - frac)
+ )
+
+
+def render(
+ draw: Callable[..., Any],
+ /,
+ *args: Any,
+ slot: str = "full",
+ optional: bool = False,
+ **kwargs: Any,
+) -> Optional[str]:
+ """Render *draw* at the width of *slot* and record the slot for the template.
+
+ Resolves ``report_tokens.SLOTS[slot]`` to an inch width, forwards it to
+ *draw* as the ``width_in`` keyword, and records *slot* under the returned
+ base64 string so :func:`slot_for` can read it back.
+
+ Parameters
+ ----------
+ draw : callable
+ A ``draw_*`` function (or ``_make_*`` closure) that accepts ``width_in``
+ and returns a Figure or ``None``.
+ *args, **kwargs
+ Forwarded to *draw*.
+ slot : str
+ A key of :data:`report_tokens.SLOTS` (default ``"full"``).
+ optional : bool
+ Passed through to the encoder (see
+ :func:`oceanarray.reports._encode.render_b64`).
+
+ Returns
+ -------
+ str or None
+ Base64-encoded PNG, or ``None`` when *draw* returned ``None`` or raised.
+
+ """
+ width_in = report_tokens.SLOTS[slot][1]
+ b64 = _figdebug.render_b64(
+ draw, *args, width_in=width_in, optional=optional, **kwargs
+ )
+ if b64:
+ _SLOT_BY_B64[b64] = slot
+ return b64
+
+
+def slot_for(b64: Optional[str]) -> str:
+ """Return the display slot recorded for figure *b64*, or ``"full"``.
+
+ Registered as a Jinja global so a template can pick the ``.slot-*`` class:
+ ``class="fig slot-{{ slot_for(fig_x_b64) }}"``.
+ """
+ if not b64:
+ return "full"
+ return _SLOT_BY_B64.get(b64, "full")
+
+
+def clear() -> None:
+ """Drop all recorded figure slots (call at the start of a page build)."""
+ _SLOT_BY_B64.clear()
diff --git a/oceanarray/reports/_stack.py b/oceanarray/reports/_stack.py
index 1af0f38..c960ca8 100644
--- a/oceanarray/reports/_stack.py
+++ b/oceanarray/reports/_stack.py
@@ -34,7 +34,7 @@
render_b64,
)
from .. import parameters as params
-from ..plotters.helpers import ordered_line_colors
+from ..plotters.helpers import grid_despine, ordered_line_colors
from ..plotters.primitives import date_offset_left
from oceanarray.config import report_tokens
@@ -213,11 +213,12 @@ def _draw() -> "plt.Figure":
ax_sc.set_axis_off()
if _n_dropped:
+ # No explicit y: constrained_layout reserves space for the suptitle
+ # above the panels, so it no longer overprints the top panel's title.
fig.suptitle(
f"Showing {_MAX_TILT_ROWS} deepest Aquadopps "
f"({_n_dropped} more not shown)",
fontsize=report_tokens.ANNOT_FS,
- y=0.995,
)
return fig
@@ -330,7 +331,7 @@ def _ts_fig(
arr[qc >= 3] = np.nan
_serial_colors, _serial_styles = _var_line_styling(varname)
with plt.style.context(str(params.MPLSTYLE)):
- fig, ax = plt.subplots(figsize=(report_tokens.W_FULL, 3.2))
+ fig, ax = plt.subplots(figsize=(report_tokens.W_FULL, 2.56))
plotted = False
for i in range(n_instr):
if exclude_types and instr_types[i].lower() in exclude_types:
@@ -375,7 +376,7 @@ def _ts_fig(
date_offset_left(ax)
ax.set_ylabel(ylabel)
ax.set_xlabel("Time")
- ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.3)
+ grid_despine(ax)
if _t_cov_start and _t_cov_end:
try:
ax.set_xlim(
@@ -436,9 +437,6 @@ def _ts_fig(
)
fig_rose_grid_b64, _n_rose = _make_rose_grid_b64(ds, _serial_list)
- # Width cap: 33% for 1 panel, 50% for 2, 66% for 3, 83% for 4, 100% for 5+
- _rose_w_map = {1: "33", 2: "50", 3: "66", 4: "83"}
- rose_img_width = _rose_w_map.get(_n_rose, "100")
_decl_vals: list = []
_decl_missing = False
@@ -590,7 +588,6 @@ def _ts_fig(
fig_turbidity_b64=fig_turbidity_b64,
fig_dissolved_oxygen_b64=fig_dissolved_oxygen_b64,
fig_rose_grid_b64=fig_rose_grid_b64,
- rose_img_width=rose_img_width,
rose_declination_note=rose_declination_note,
rose_declination_warn=rose_declination_warn,
rose_declination_missing_serials=rose_declination_missing_serials,
diff --git a/oceanarray/reports/templates/base.html b/oceanarray/reports/templates/base.html
index 41fa738..524ecc9 100644
--- a/oceanarray/reports/templates/base.html
+++ b/oceanarray/reports/templates/base.html
@@ -5,69 +5,68 @@
{% block title %}{{ mooring_name }}{% endblock %}
{% block head_extra %}{% endblock %}
diff --git a/oceanarray/reports/templates/grid.html b/oceanarray/reports/templates/grid.html
index aecaa37..f93f473 100644
--- a/oceanarray/reports/templates/grid.html
+++ b/oceanarray/reports/templates/grid.html
@@ -3,7 +3,10 @@
{% block title %}Grid report – {{ mooring_name }}{% endblock %}
{% block page_styles %}
:root { --accent:#8e44ad; --accent-link:#e8d5ff; }
- .fig { width:100%; border:1px solid #dce; border-radius:4px; margin-bottom:0.5rem; }
+ /* No width here: the slot class (.slot-*) sets each figure's width; a bare
+ .fig fills via base.html's max-width:100%. Setting width:100% here would
+ override the slot and force every figure full-width (U0.2). */
+ .fig { border:1px solid var(--rule); border-radius:var(--radius-btn); margin-bottom:0.5rem; }
.note { color:var(--muted); font-size:0.82rem; margin-top:-0.5rem; }
.style-label { font-size:0.8rem; font-weight:600; color:var(--muted); margin:0.4rem 0 0.2rem; text-transform:uppercase; letter-spacing:0.05em; }
.var-table { width:100%; border-collapse:collapse; font-size:0.82rem; margin-bottom:1.5rem; }
@@ -92,8 +95,8 @@ Velocity
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
-
-{{- m.dbg(fig_ts_grid_b64, "max-width 50%") }}
+
+{{- m.dbg(fig_ts_grid_b64, "slot-" ~ slot_for(fig_ts_grid_b64)) }}
{% endif %}
@@ -132,8 +135,8 @@ Hodograph
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
-
-{{- m.dbg(fig_grid_traj_b64, "max-width 55%") }}
+
+{{- m.dbg(fig_grid_traj_b64, "slot-" ~ slot_for(fig_grid_traj_b64)) }}
{% endif %}
@@ -247,7 +250,7 @@ Variables
| {{ v.dtype }} |
{{ v.dims }} |
{{ "{:,}".format(v.n) }} |
- {{ "{:,}".format(v.n_valid) if v.n_valid is defined else "—" }} |
+ {{ "{:,}".format(v.n_valid) if v.n_valid is defined else "—" }} |
{% if v.v_min is not none %}{{ v.v_min }} / {{ v.v_max }}{% else %}—{% endif %} |
{{ v.units }} |
{{ v.long_name }} |
diff --git a/oceanarray/reports/templates/instrument.html b/oceanarray/reports/templates/instrument.html
index eae7f58..63f9de0 100644
--- a/oceanarray/reports/templates/instrument.html
+++ b/oceanarray/reports/templates/instrument.html
@@ -20,7 +20,8 @@
.b-ok { background:var(--good); color:#fff; }
.b-warn { background:var(--warn); color:#fff; }
.b-miss { background:#dfe6e9; color:#999; }
- img.fig { width:100%; max-width:100%; border-radius:4px; margin-bottom:0.5rem; }
+ /* No width here: the slot class sets each figure's width (U0.2). */
+ img.fig { max-width:100%; border-radius:var(--radius-btn); margin-bottom:0.5rem; }
.qc-bar { display:flex; width:180px; height:13px; border-radius:3px;
overflow:hidden; gap:1px; background:#ecf0f1; }
.qc-bar div { height:100%; }
@@ -36,7 +37,7 @@
Duration{{ duration }}
Samp. Δt{{ median_dt | default("—") }}
Records{{ n_records | default("—") }}
- Source file{{ nc_file }}{% if data_stage and data_stage != 'stage3' %} ({{ data_stage }} — QC not applied){% endif %}
+ Source file{{ nc_file }}{% if data_stage and data_stage != 'stage3' %} ({{ data_stage }} — QC not applied){% endif %}
{% endblock %}
{% block content %}
@@ -119,7 +120,7 @@ Processing history
{% if fig_adcp_velocity_b64 %}
Velocity
-
+
Time–range colour plots of the four velocity components (east, north, up, error).
Y-axis: range from transducer (m).
Colour scale: symmetric about zero; percentile 2/98 of all components combined.
@@ -144,17 +145,17 @@
Time series (full deployment)
Start & end windows — first / last 6 h
{% if fig_windows_b64 %}
-
+
Left panel = first 7 h (1 h lead-in + 6 h of record) |
Right panel = last 7 h (6 h of record + 1 h tail).
- ││ orange dashed = auto-detected
+ ││ orange dashed = auto-detected
(pressure-based) suggested deploy/recover time •
- ││ green dashed = YAML
+ ││ green dashed = YAML
deployment_time / recovery_time •
- ││ grey = stage 1 raw record
+ ││ grey = stage 1 raw record
(same variable, same units).
-
+
Reading the grey background:
where the raw stage 1 record is available and uses the same physical
units as the stage 2/3 data, it is shown in light grey so you can
@@ -173,7 +174,7 @@
Start & end windows — first / last 6 h
between stage 1 and stage 3 (e.g. mS/cm vs S/m) and the
grey trace is suppressed automatically to avoid a misleading scale mismatch.
- If the orange vline is absent from the right-hand panel,
+ If the orange vline is absent from the right-hand panel,
no pressure-based recovery transition was detected in the final 25 % of the
stage 1 record — the suggested recovery time equals the last raw sample.
Check the timing table in the mooring summary report for the suggested UTC time.
@@ -189,7 +190,7 @@ Start & end windows — first / last 6 h
{% if fig_tsd_b64 %}
T-S diagram
-
+
Coloured by pressure (or sample index). × = suspect | × = bad (QC flags).
@@ -199,7 +200,7 @@ T-S diagram
{% if fig_adcp_rose_b64 %}
Current roses
-
+
Depth-average followed by bins nearest 100, 200, 300 and 400 m from the transducer.
Bins with fewer than two valid samples are omitted.
Direction toward which the current flows; 0° = N, clockwise.
@@ -213,12 +214,12 @@
Current roses
{% if fig_rose_b64 %}
Current rose diagrams
{% if declination_warn %}
-
+
⚠ Magnetic declination could not be applied — latitude/longitude are missing or all-zero in the mooring YAML (check seabed_latitude, deployment_latitude, or latitude/longitude).
ENU velocities use 0° declination (magnetic north, not true north).
{% endif %}
-
+
{% if rose_has_xyz %}XYZ: instrument-frame velocities (before geographic rotation). {% endif %}ENU panels split by QARTOD flag: good (flag ≤ 2, Blues), suspect (flag 3, Oranges), fail (flag 4, Reds).
Direction toward which the current flows; 0° = N, clockwise.
@@ -229,7 +230,7 @@ Current rose diagrams
{% if fig_trajectory_b64 %}
Particle trajectory
-
+
Pseudo-Lagrangian displacement obtained by integrating east/north velocity over time
(Euler forward; NaN velocities set to zero). Coloured by temperature.
Origin (0, 0) = deployment position; axes in metres.
@@ -241,7 +242,7 @@
Particle trajectory
{% if fig_hodograph_b64 %}
Hodograph
-
+
East vs north velocity (m s-1).
Left: full record.
Right: eddy component — raw minus 4-day low-pass (rolling mean).
@@ -266,7 +267,7 @@
Analog channels
Labels and units are read from the mooring YAML — update them there to change what appears here.
{% if analog_yaml_info %}
-
+
{% for ch in analog_yaml_info %}
-
{{ ch.varname }} — from YAML:
@@ -283,7 +284,7 @@ Analog channels
Data value distributions
-
+
Orange dashed = suspect threshold | Red dotted = fail threshold (gross-range QC).
Histogram shows non-bad data only; bad-flagged count noted in red.
@@ -396,7 +397,7 @@ Time-seri
| {{ v.dtype }} |
{{ v.dims }} |
{{ "{:,}".format(v.n) }} |
- {{ "{:,}".format(v.n_valid) if v.n_valid is defined else "—" }} |
+ {{ "{:,}".format(v.n_valid) if v.n_valid is defined else "—" }} |
{% if v.v_min is not none %}{{ v.v_min }} / {{ v.v_max }}{% else %}—{% endif %} |
{{ v.units }} |
{{ v.long_name }} |
diff --git a/oceanarray/reports/templates/mooring.html b/oceanarray/reports/templates/mooring.html
index ae7a6fa..081fa3a 100644
--- a/oceanarray/reports/templates/mooring.html
+++ b/oceanarray/reports/templates/mooring.html
@@ -62,14 +62,14 @@
2 — Processing pipeline
-
+
Raw = file present in raw directory •
Read = format check passed •
Stage 1–3 = processed NetCDF files exist •
Stack = mooring-level _stack.nc •
Grid = pressure-gridded _grid.nc
-
+
Instruments marked skip: true in the YAML are excluded from all
processing and are shown with a skipped note in the Note column.
Stack and Grid pills are grey for any instrument that did not reach Stage 3.
@@ -154,7 +154,7 @@
2 — Processing pipeline
{% endif %}
-
+ |
{% if instr.skipped %}skipped{% endif %}
|
@@ -164,7 +164,7 @@ 2 — Processing pipeline
3 — Instrument summary
-
+
Variables and record length are read from stage 3 files where available,
otherwise stage 2. The P badge is shown as present
whether pressure was directly measured or interpolated from neighbouring
@@ -219,7 +219,7 @@
3 — Instrument summary
{% set dt = instr.nc.dt_s %}
{% if dt == dt %}{# NaN check: NaN != NaN #}
- {{ "%.0f"|format(dt) }}{% if instr.dt_mismatch %} *{% endif %}
+ {{ "%.0f"|format(dt) }}{% if instr.dt_mismatch %} *{% endif %}
{% else %}
—
{% endif %}
@@ -256,8 +256,8 @@ 3 — Instrument summary
{% set dt_mismatches = instruments | selectattr("dt_mismatch") | list %}
{% if dt_mismatches %}
-
- * Δt mismatch (YAML vs observed p90):
+
+ * Δt mismatch (YAML vs observed p90):
{% for instr in dt_mismatches %}
{{ instr.serial }}:
YAML gives Δt of {{ instr.yaml_interval_s }} s;
@@ -267,32 +267,32 @@ 3 — Instrument summary
{% endif %}
{% if grid_p_start is not none and grid_p_end is not none %}
-
+
Recommended pressure range for oceanarray grid, derived from the
min/max pressure across all instruments (rounded outward to the nearest 20 dbar):
---pmin {{ grid_p_start }} --pmax {{ grid_p_end }}
+--pmin {{ grid_p_start }} --pmax {{ grid_p_end }}
{% endif %}
3.5 — Deployment timing
-
+
Stage 1 and Sugg. (raw) columns are in the raw
instrument clock (uncorrected). Sugg. UTC columns
apply the constant clock offset (and drift, for the end) and are safe to
paste into the YAML deployment_time /
recovery_time fields. Suggested UTC cells are highlighted
- amber when the
+ amber when the
pressure-derived suggested time differs from the YAML time by more than
2 Δt (indicating a sinking/rising transient was detected).
The Stage 1 last cell is highlighted
- orange when the
+ orange when the
raw record ended more than 2 Δt before the YAML recovery time
(instrument may have stopped early).
Serial numbers link to the per-instrument report; the
6 h link jumps directly to the start/end window plots.
-
+
Spot-check recommended: open the 6 h start/end
window plots for each instrument (click 6 h) and visually
confirm that the orange suggested line (or green YAML line if they match)
@@ -306,8 +306,8 @@ 3.5 — Deployment timing
| # |
Type |
S/N |
- Start |
- End |
+ Start |
+ End |
| Stage1 first |
@@ -328,7 +328,7 @@ 3.5 — Deployment timing
{{ instr.serial }}
6 h
{% if tm %}
@@ -339,26 +339,26 @@ 3.5 — Deployment timing
{{ tm.get("stage1_start") or "—" }} |
{# Sugg. start (raw) — only present for pressure instruments; time only when same date #}
{% set v = tm.get("sugg_start") or "" %}
-
+ |
{{- v[11:] if (v and v[:10] == d_start) else (v or "—") -}}
|
{# Sugg. start (UTC) #}
{% set v = tm.get("sugg_start_utc") or "" %}
-
+ |
{{- v[11:] if (v and v[:10] == d_start) else (v or "—") -}}
|
{# Stage1 last — orange-amber when instrument stopped early #}
-
+ |
{{- tm.get("stage1_end") or "—" -}}
|
{# Sugg. end (raw) #}
{% set v = tm.get("sugg_end") or "" %}
-
+ |
{{- v[11:] if (v and v[:10] == d_end) else (v or "—") -}}
|
{# Sugg. end (UTC) #}
{% set v = tm.get("sugg_end_utc") or "" %}
-
+ |
{{- v[11:] if (v and v[:10] == d_end) else (v or "—") -}}
|
{% elif instr.skipped %}
@@ -371,7 +371,7 @@ 3.5 — Deployment timing
{% if rec_deploy_sec or rec_recover_sec %}
-
+
| ★ |
Mooring |
— |
@@ -389,42 +389,42 @@ 3.5 — Deployment timing
{% if yaml_deploy_time or yaml_recover_time or rec_deploy or rec_recover %}
{% if rec_differs %}
-
+
Deployment times — pressure-based instruments suggest times that differ
from the current YAML. Copy the suggested snippet (blue border) into your YAML file.
Times are given to the minute.
-
+
Suggested (copy → paste into YAML)
{% else %}
-
+
✓ Current YAML times match the pressure-based suggestion from the instruments.
-
+
Current YAML times
{% endif %}
@@ -433,7 +433,7 @@
3.5 — Deployment timing
4 — Clock corrections
-
+
Positive drift/offset = instrument was slow (behind UTC); correction shifts times later.
Negative = instrument was fast (ahead of UTC); correction shifts times earlier.
@@ -594,12 +594,12 @@
6 — QC flag summary
border-radius: 2px; vertical-align: middle; margin-right: 3px; }
- good (1)
- prob. good (2)
- suspect (3)
- bad (4)
- interp. (8)
- missing (9)
+ good (1)
+ prob. good (2)
+ suspect (3)
+ bad (4)
+ interp. (8)
+ missing (9)
{% set has_qc = instruments | selectattr("qc_summary") | list %}
{% if has_qc %}
@@ -672,7 +672,7 @@
Mooring knockdown
Nominal HAB (x) vs. measured pressure (y). The dashed line shows expected pressure
(water depth − HAB). Instruments below the line were knocked down.
Interpolated pressure (QC flag 8) excluded.
-

+

{{- m.dbg(fig_knockdown_hab_b64, "flex ~half column") }}
{% endif %}
@@ -684,7 +684,7 @@ Mooring knockdown
yellow 100–200,
amber 200–300,
red > 300 dbar.
-
+
{{- m.dbg(fig_knockdown_anomaly_b64, "flex ~half column") }}
{% endif %}
@@ -694,7 +694,7 @@ Mooring knockdown
Left: scatter per instrument; right: normalised 2-D density across all instruments.
Displacement derived from the rigid-pendulum approximation:
x = √(habnom² − habmeas²).
-
+
{{- m.dbg(fig_knockdown_displacement_b64, "width 100%") }}
{% endif %}
{% endif %}
@@ -704,7 +704,7 @@ Mooring knockdown
Mooring diagram