From 3290b15435fec958b367e3b847bffdb0718682f2 Mon Sep 17 00:00:00 2001
From: Eleanor Frajka-Williams
Date: Wed, 12 Aug 2026 22:41:43 +0200
Subject: [PATCH] fix: update fonts, reduce file size, paginate multi-panels
---
oceanarray/config/parameters.py | 17 +++
oceanarray/oceanarray.mplstyle | 18 ++-
oceanarray/plotters/__init__.py | 4 +-
oceanarray/plotters/current.py | 44 ++++---
oceanarray/plotters/diagnostic.py | 62 ++++++----
oceanarray/plotters/hydrography.py | 78 +-----------
oceanarray/plotters/primitives.py | 11 +-
oceanarray/plotters/spectrum.py | 14 +--
oceanarray/plotters/ts.py | 9 +-
oceanarray/report/_grid.py | 2 +-
oceanarray/report/_html_helpers.py | 26 +++-
oceanarray/report/_instrument.py | 8 +-
oceanarray/report/_mooring.py | 17 ++-
oceanarray/report/_pdf.py | 8 ++
oceanarray/report/_plots.py | 192 +++++++++++++++++------------
oceanarray/report/_stack.py | 35 +++---
requirements.txt | 1 +
tests/unit/test_plot_guard.py | 22 ++--
18 files changed, 309 insertions(+), 259 deletions(-)
diff --git a/oceanarray/config/parameters.py b/oceanarray/config/parameters.py
index 1641d09..179bec0 100644
--- a/oceanarray/config/parameters.py
+++ b/oceanarray/config/parameters.py
@@ -33,6 +33,23 @@
FIGURE_SIZE_WIDE = (14, 6) # multi-instrument overview (scatter / line)
FIGURE_SIZE_TALL = (12, 8) # stacked single-instrument panels
+# ---------------------------------------------------------------------------
+# Report figure slot widths (inches). The HTML report body is ~1150-1200px
+# wide; figures display at full/two-thirds/half/third of that slot via the
+# `.fig` CSS max-width caps. Rendering a figure at the matching slot WIDTH
+# makes displayed font size depend only on figsize (font_px = pt/72 x
+# display_px/fig_in) — independent of dpi — so mplstyle fonts appear at a
+# consistent, readable size instead of the double-shrink that oversized (12-16
+# in) figsizes caused at half/third slots. Set figure *width* to one of these;
+# height is content-driven. (dpi is a separate size knob, set once in the
+# mplstyle's savefig.dpi; report figure bytes are cut mainly by palette
+# quantization in _fig_to_base64, not by dpi.)
+# ---------------------------------------------------------------------------
+W_FULL = 9.0 # full-slot figure (CSS max-width 100%)
+W_TWOTHIRDS = 6.0 # two-thirds slot (~66%)
+W_HALF = 4.5 # half slot (~50%)
+W_THIRD = 3.0 # one-third slot (~33%)
+
# ---------------------------------------------------------------------------
# Resampling
# ---------------------------------------------------------------------------
diff --git a/oceanarray/oceanarray.mplstyle b/oceanarray/oceanarray.mplstyle
index 68cdfda..3c8530d 100644
--- a/oceanarray/oceanarray.mplstyle
+++ b/oceanarray/oceanarray.mplstyle
@@ -1,5 +1,5 @@
-axes.titlesize : 15
-axes.labelsize : 12
+axes.titlesize : 12
+axes.labelsize : 10
date.autoformatter.day: %Y-%m-%d
date.converter: auto
figure.figsize: 8, 4
@@ -7,12 +7,18 @@ figure.dpi: 100
savefig.dpi: 150
font.family: sans-serif
font.style: normal
-font.size: 12
-legend.fontsize: 12
+font.size: 10
+legend.fontsize: 9
lines.linewidth : 1
lines.linestyle: -
lines.markersize : 10
-xtick.labelsize : 12
+xtick.labelsize : 10
xtick.alignment: center
-ytick.labelsize : 12
+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/__init__.py b/oceanarray/plotters/__init__.py
index f47f874..a5fe180 100644
--- a/oceanarray/plotters/__init__.py
+++ b/oceanarray/plotters/__init__.py
@@ -43,7 +43,7 @@
draw_ts_diagram, draw_stack_ts_diagram, draw_grid_ts_diagram
Hydrography (hydrography.py):
- draw_isopycnal_fig, draw_isopycnal_ts_fig, draw_isopycnal_coverage,
+ draw_isopycnal_ts_fig, draw_isopycnal_coverage,
draw_overflow_temperature_fig
Legacy functions (from plotter.py — available via backward-compat shim):
@@ -131,7 +131,6 @@
# draw_* functions — isopycnal
# ---------------------------------------------------------------------------
from oceanarray.plotters.hydrography import ( # noqa: F401
- draw_isopycnal_fig,
draw_isopycnal_ts_fig,
draw_isopycnal_coverage,
draw_overflow_temperature_fig,
@@ -190,7 +189,6 @@
"draw_stack_ts_diagram",
"draw_grid_ts_diagram",
# draw_* — isopycnal
- "draw_isopycnal_fig",
"draw_isopycnal_ts_fig",
"draw_isopycnal_coverage",
"draw_overflow_temperature_fig",
diff --git a/oceanarray/plotters/current.py b/oceanarray/plotters/current.py
index 015e2e0..3f1ab9e 100644
--- a/oceanarray/plotters/current.py
+++ b/oceanarray/plotters/current.py
@@ -144,7 +144,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=(4, 6))
+ fig, ax = plt.subplots(figsize=(params.W_THIRD, 6))
bp = ax.boxplot(
speed_clean,
vert=True,
@@ -249,7 +249,7 @@ def plot_multi_aquadopp_trajectories(
_bounds = _nice_colorbar_bounds(0.0, 1.0, n=20)
norm: mcolors.BoundaryNorm = mcolors.BoundaryNorm(_bounds, ncolors=256)
- fig, ax = plt.subplots(figsize=(6, 5))
+ fig, ax = plt.subplots(figsize=(params.W_FULL, 5), constrained_layout=True)
for instr_i, x, y, temp in trajs:
serial = str(serials[instr_i])
@@ -322,7 +322,6 @@ def plot_multi_aquadopp_trajectories(
title = ds.attrs.get("id", "")
if title:
ax.set_title(title)
- fig.tight_layout()
return fig
@@ -368,10 +367,14 @@ def plot_hodograph(
import pandas as pd
instr_id = ds.attrs.get("id", "")
- fig, axes = plt.subplots(1, 2, figsize=(12, 5))
- fig.subplots_adjust(top=0.90)
+ # constrained_layout places the shared colorbar correctly against the
+ # equal-aspect ("box"-adjustable) panels; without it the colorbar overlaps
+ # the panel whitespace and appears to sit inside the axes.
+ fig, axes = plt.subplots(
+ 1, 2, figsize=(params.W_FULL, 4.5), constrained_layout=True
+ )
if instr_id:
- fig.suptitle(instr_id, fontsize=10, y=0.995)
+ fig.suptitle(instr_id)
if u_var not in ds.data_vars or v_var not in ds.data_vars:
for ax in axes:
@@ -466,13 +469,13 @@ def _panel(ax: plt.Axes, e: np.ndarray, n: np.ndarray, title: str) -> None:
linewidths=0.5,
label="End",
)
- ax.legend(fontsize=7, loc="upper right", framealpha=0.7)
+ ax.legend(loc="upper right", framealpha=0.7)
ax.axhline(0, color="#bbb", lw=0.7, zorder=0)
ax.axvline(0, color="#bbb", lw=0.7, zorder=0)
ax.set_aspect("equal", adjustable="box")
ax.set_xlabel(f"East ({units})")
ax.set_ylabel(f"North ({units})")
- ax.set_title(title, fontsize=10)
+ ax.set_title(title)
ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.4)
_panel(axes[0], east, north, f"Raw ({smooth_hours:.0f}-h smoothed)")
@@ -564,7 +567,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=(5, max(3, len(records) * 0.7 + 1)))
+ fig, ax = plt.subplots(figsize=(params.W_HALF, max(3, len(records) * 0.7 + 1)))
for hab, serial, spd_clean in records:
bp = ax.boxplot(
@@ -701,7 +704,7 @@ def plot_adcp_trajectories(
_bounds = _nice_colorbar_bounds(hab_min, hab_max, n=_n_hab)
norm: mcolors.BoundaryNorm = mcolors.BoundaryNorm(_bounds, ncolors=256)
- fig, ax = plt.subplots(figsize=(6, 5))
+ fig, ax = plt.subplots(figsize=(params.W_FULL, 5))
for hab, x, y in trajs:
points = np.array([x, y]).T.reshape(-1, 1, 2)
@@ -803,7 +806,7 @@ def _masked(flag_mask: "np.ndarray") -> "tuple[np.ndarray, np.ndarray]":
fig, axs = plt.subplots(
1,
ncols,
- figsize=(ncols * 3.0, 3.2),
+ figsize=(params.W_TWOTHIRDS, 3.2),
subplot_kw={"projection": "polar"},
squeeze=False,
)
@@ -911,7 +914,7 @@ def draw_rose_grid(
fig, axs = plt.subplots(
nrows,
ncols,
- figsize=(ncols * 3.0, nrows * 3.2),
+ figsize=(params.W_FULL, nrows * 3.2),
subplot_kw={"projection": "polar"},
squeeze=False,
)
@@ -988,7 +991,7 @@ def draw_grid_rose(ds: "xr.Dataset", max_roses: int = 4) -> "Optional[plt.Figure
fig, axs = plt.subplots(
nrows,
ncols,
- figsize=(ncols * 3.0, nrows * 3.2),
+ figsize=(params.W_FULL, nrows * 3.2),
subplot_kw={"projection": "polar"},
squeeze=False,
)
@@ -1055,7 +1058,7 @@ def draw_grid_trajectory(ds: "xr.Dataset") -> "Optional[plt.Figure]":
_bounds, norm = colorbar_norm(vmin=min(p_vals), vmax=max(p_vals))
cmap = plt.get_cmap("viridis_r") # shallow (low p) → light; deep → dark
- fig, ax = plt.subplots(figsize=(6, 5))
+ fig, ax = plt.subplots(figsize=(params.W_HALF, 5))
for p_val, x, y in trajs:
points = np.array([x, y]).T.reshape(-1, 1, 2)
@@ -1223,7 +1226,7 @@ def draw_adcp_velocity(nc_path: str) -> "Optional[plt.Figure]":
n = len(present)
fig, axes = plt.subplots(
- n, 1, figsize=(13, 3.5 * n), sharex=True, squeeze=False
+ n, 1, figsize=(params.W_FULL, 3.5 * n), sharex=True, squeeze=False
)
orientation = ds.attrs.get("orientation_yaml") or ds.attrs.get(
@@ -1374,7 +1377,7 @@ def draw_adcp_rose(nc_path: str) -> "Optional[plt.Figure]":
fig, axs = plt.subplots(
1,
ncols,
- figsize=(ncols * 3.2, 4.0),
+ figsize=(params.W_FULL, 4.0),
subplot_kw={"projection": "polar"},
squeeze=False,
)
@@ -1505,7 +1508,7 @@ def draw_adcp_hodograph(
from oceanarray.report._plots import _draw_hodograph_pair
- fig, axes = plt.subplots(2, 2, figsize=(13, 9))
+ fig, axes = plt.subplots(2, 2, figsize=(params.W_FULL, 9))
fig.subplots_adjust(hspace=0.55, wspace=0.45)
_draw_hodograph_pair(
@@ -1612,8 +1615,9 @@ def draw_grid_hodograph(
smooth_n = max(3, int(round(smooth_hours * 3600.0 / dt_s)))
- fig, (ax_shallow, ax_deep) = plt.subplots(1, 2, figsize=(13, 6))
- fig.subplots_adjust(wspace=0.45)
+ fig, (ax_shallow, ax_deep) = plt.subplots(
+ 1, 2, figsize=(params.W_FULL, 4.5), constrained_layout=True
+ )
for ax, i_lev, label in [
(ax_shallow, i_shallow, f"Shallow ({label_shallow})"),
@@ -1626,7 +1630,7 @@ def draw_grid_hodograph(
ax.text(
0.5, 0.5, "No data", transform=ax.transAxes, ha="center", va="center"
)
- ax.set_title(label, fontsize=9)
+ ax.set_title(label)
continue
t_frac = np.linspace(0.0, 1.0, len(east_2d[:, i_lev]))[mask]
hodograph_panel(
diff --git a/oceanarray/plotters/diagnostic.py b/oceanarray/plotters/diagnostic.py
index 121fe0b..66b6e9b 100644
--- a/oceanarray/plotters/diagnostic.py
+++ b/oceanarray/plotters/diagnostic.py
@@ -677,24 +677,30 @@ def plot_clock_offset_check(
nc_paths: "Dict[str, Path]",
deploy_dt: "Optional[datetime]",
recover_dt: "Optional[datetime]",
- window_minutes: int = 10,
+ window_minutes: int = 30,
) -> "Optional[matplotlib.figure.Figure]":
- """Overlaid temperature time series zoomed to deployment start and end.
+ """Overlaid, per-instrument normalised temperature around deploy and recover.
- Plots the first and last *window_minutes* of the deployment for every
- instrument that has a temperature variable, so that clock alignment
- between instruments can be assessed visually. If an instrument's clock
- is offset the temperature signal will appear shifted in time relative to
- the other instruments.
+ Plots a ``±window_minutes`` window centred on deployment and on recovery for
+ every instrument with a temperature variable, so clock alignment between
+ instruments can be assessed visually. If an instrument's clock is offset the
+ temperature signal appears shifted in time relative to the others.
+
+ Each instrument's trace is **standardised over the plotted window**
+ (subtract the window mean, divide by the window standard deviation) so
+ instruments with different absolute temperatures and amplitudes overlay on a
+ common ``std`` y-axis and their *timing* can be compared directly.
Two sub-panels are produced side by side:
- - **Left**: first ``window_minutes`` minutes after ``deploy_dt``
- - **Right**: last ``window_minutes`` minutes before ``recover_dt``
+ - **Left**: ``deploy_dt ± window_minutes``
+ - **Right**: ``recover_dt ± window_minutes``
When ``deploy_dt`` or ``recover_dt`` is ``None``, only the available
window is produced. A shared legend below both panels lists all
- instruments.
+ instruments. An instrument with zero variance in a window (flat/constant)
+ is skipped for that window (no timing information, and normalisation is
+ undefined).
Parameters
----------
@@ -756,16 +762,18 @@ def plot_clock_offset_check(
windows: list = []
if deploy_dt is not None:
t0 = np.datetime64(deploy_dt.replace(tzinfo=None).isoformat())
- windows.append((t0, t0 + _td, f"Start +{window_minutes} min"))
+ windows.append((t0 - _td, t0 + _td, f"Deployment ±{window_minutes} min"))
if recover_dt is not None:
t1 = np.datetime64(recover_dt.replace(tzinfo=None).isoformat())
- windows.append((t1 - _td, t1, f"End −{window_minutes} min"))
+ windows.append((t1 - _td, t1 + _td, f"Recovery ±{window_minutes} min"))
if not windows:
return None
n_panels = len(windows)
with plt.style.context(str(params.MPLSTYLE)):
- fig, axes = plt.subplots(1, n_panels, figsize=(5 * n_panels, 3.5), sharey=False)
+ fig, axes = plt.subplots(
+ 1, n_panels, figsize=(params.W_FULL, 3.5), sharey=False
+ )
if n_panels == 1:
axes = [axes]
@@ -778,16 +786,21 @@ def plot_clock_offset_check(
mask = (t >= t_lo) & (t <= t_hi) & np.isfinite(temp)
if not np.any(mask):
continue
- ax.plot(t[mask], temp[mask], color=colors[serial], lw=1.0)
+ tw = temp[mask]
+ sd = np.nanstd(tw)
+ 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)
plotted_serials.add(serial)
- ax.set_title(title, fontsize=8)
+ ax.set_title(title)
ax.set_xlabel("Time (UTC)")
- ax.set_ylabel("Temperature (°C)")
+ ax.set_ylabel("Normalised temperature (std)")
+ ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.3)
locator = mdates.AutoDateLocator()
ax.xaxis.set_major_locator(locator)
ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
- ax.tick_params(axis="x", labelsize=7)
if plotted_serials:
from matplotlib.lines import Line2D
@@ -802,7 +815,6 @@ def plot_clock_offset_check(
loc="lower center",
ncol=min(len(handles), 6),
bbox_to_anchor=(0.5, -0.05),
- fontsize=7,
frameon=True,
)
@@ -823,6 +835,7 @@ def draw_windows(
show_qc: bool = True,
vlines: Optional[list] = None,
stage1_nc: Optional[Path] = None,
+ panels: Optional[list] = None,
) -> "Optional[plt.Figure]":
"""Combined start + end window figure: (nrows × 2) — left = first N h, right = last N h.
@@ -836,6 +849,10 @@ def draw_windows(
Width of each window in hours (default 6).
show_qc : bool
Overlay QC flag markers on the data.
+ panels : list, optional
+ 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.
vlines : list of (time_val, color, label), optional
Vertical marker lines to draw on both panels. *time_val* may be a
``numpy.datetime64``, an ISO-8601 string, or a ``pandas.Timestamp``.
@@ -890,16 +907,17 @@ def draw_windows(
# One sample interval used to expand x-axis limits (stage2/3 fallback).
_dt_one = (time[1] - time[0]) if len(time) > 1 else np.timedelta64(300, "s") # noqa: F841
- panels = _instrument_panels(ds, combine_pitch_roll=True)
+ if panels is None:
+ panels = _instrument_panels(ds, combine_pitch_roll=True)
if not panels:
return None
height_ratios = [
- _COMPACT_PANEL_HEIGHT if vname in _COMPACT_PANEL_VARS else 3.0
+ _COMPACT_PANEL_HEIGHT if vname in _COMPACT_PANEL_VARS else 2.0
for vname, *_ in panels
]
nrows = len(panels)
- fig = plt.figure(figsize=(13, sum(height_ratios)))
+ fig = plt.figure(figsize=(params.W_FULL, sum(height_ratios)))
gs = GridSpec(
nrows,
2,
@@ -1423,7 +1441,7 @@ def draw_velocity_iqr_profile(ds: "xr.Dataset") -> "Optional[plt.Figure]":
fig, axs = plt.subplots(
1,
n_panels,
- figsize=(n_panels * 3.5, 6),
+ figsize=(params.W_FULL, 4.5),
sharey=True,
gridspec_kw={"width_ratios": [2] * (n_panels - 1) + [1]},
)
diff --git a/oceanarray/plotters/hydrography.py b/oceanarray/plotters/hydrography.py
index a3ecd18..feaa287 100644
--- a/oceanarray/plotters/hydrography.py
+++ b/oceanarray/plotters/hydrography.py
@@ -1,7 +1,6 @@
"""Tier-2 domain wrappers for hydrographic section and isopycnal plots.
hydrography.py contains:
- - ``draw_isopycnal_fig``: time × pressure with iso-sigma contour lines.
- ``draw_isopycnal_ts_fig``: isopycnal height-above-seabed time series.
- ``draw_isopycnal_coverage``: three-panel isopycnal diagnostic.
- ``draw_overflow_temperature_fig``: temperature time series at ~100 m above seabed.
@@ -19,79 +18,10 @@
import matplotlib.pyplot as plt
import xarray as xr
-from .primitives import colorbar_norm, date_axis, pressure_axis
-from ..analysis.temporal import filter_sigma_tukey
+from .primitives import colorbar_norm, date_axis
from .. import parameters as params
-def draw_isopycnal_fig(
- da: "xr.DataArray",
- levels: list,
- filter_samples: int = 0,
- zoom_center_idx: Optional[int] = None,
- zoom_n: int = 0,
-) -> "plt.Figure":
- """Render time × pressure with iso-sigma contour lines; return a Figure.
-
- Parameters
- ----------
- da : xr.DataArray
- DataArray with ``pressure`` and ``time`` dimensions.
- levels : list
- Sigma-0 contour levels (kg m⁻³).
- filter_samples : int
- If > 1, apply a Tukey moving-average filter over this many samples.
- zoom_center_idx : int, optional
- Centre index for a time-axis zoom window.
- zoom_n : int
- Half-width (in samples) of the zoom window.
-
- Returns
- -------
- plt.Figure
-
- """
- import matplotlib.pyplot as plt
-
- da_tp = da.transpose("pressure", "time")
- time_vals = da_tp["time"].values
- pressure_vals = da_tp["pressure"].values
- data = da_tp.values
-
- if zoom_center_idx is not None and zoom_n > 0:
- t0 = max(0, zoom_center_idx - zoom_n // 2)
- t1 = min(data.shape[1], t0 + zoom_n)
- time_vals = time_vals[t0:t1]
- data = data[:, t0:t1]
-
- if filter_samples > 1 and data.shape[1] > filter_samples:
- data = filter_sigma_tukey(data, filter_samples)
-
- level_colors = ["#808080"] + ["black"] * (len(levels) - 1)
-
- fig, ax = plt.subplots(figsize=(13, 4))
- for lev, col in zip(levels, level_colors):
- try:
- ax.contour(
- time_vals,
- pressure_vals,
- data,
- levels=[lev],
- colors=[col],
- linewidths=1.2,
- )
- except Exception: # noqa: BLE001 — individual contour level may fail; skip and continue
- pass
- ax.plot([], [], color=col, lw=1.2, label=f"σ₀ = {lev} kg m⁻³")
-
- pressure_axis(ax)
- date_axis(ax)
- ax.set_xlabel("Time")
- if levels:
- ax.legend(loc="upper right", framealpha=0.8)
- return fig
-
-
def draw_isopycnal_ts_fig(ds_iso: "xr.Dataset") -> "Optional[plt.Figure]":
"""Isopycnal height-above-seabed time series; return a Figure.
@@ -138,7 +68,7 @@ def draw_isopycnal_ts_fig(ds_iso: "xr.Dataset") -> "Optional[plt.Figure]":
color_norms = np.linspace(0.25, 0.95, max(n_levels, 1))
colors = [cmap(v) for v in color_norms]
- fig, ax = plt.subplots(figsize=(13, 4))
+ fig, ax = plt.subplots(figsize=(params.W_FULL, 4))
for i, (sval, col) in enumerate(zip(sigma_vals, colors)):
h = height[i, :]
@@ -309,7 +239,7 @@ def _bar_color(p: float) -> str:
fig, (ax0, ax1, ax2) = plt.subplots(
1,
3,
- figsize=(14, fig_h),
+ figsize=(params.W_FULL, fig_h),
sharey=True,
gridspec_kw={"width_ratios": [0.8, 1.0, 1.2]},
)
@@ -450,7 +380,7 @@ def draw_overflow_temperature_fig(ds: "xr.Dataset") -> "Optional[plt.Figure]":
.values
)
- fig, ax = plt.subplots(figsize=(13, 3))
+ fig, ax = plt.subplots(figsize=(params.W_FULL, 3))
ax.plot(time_vals, temp_med, color="#1a3a5c", lw=1.0)
ax.set_ylabel(params.vlabel("temperature"))
hab = waterdepth - actual_p
diff --git a/oceanarray/plotters/primitives.py b/oceanarray/plotters/primitives.py
index 24e3cb4..8af48fe 100644
--- a/oceanarray/plotters/primitives.py
+++ b/oceanarray/plotters/primitives.py
@@ -142,7 +142,7 @@ def plot_trajectory(
matplotlib.figure.Figure
"""
- fig, ax = plt.subplots(figsize=(7, 6))
+ fig, ax = plt.subplots(figsize=(params.W_HALF, 6), constrained_layout=True)
if color_data is not None:
points = np.array([x, y]).T.reshape(-1, 1, 2)
@@ -168,7 +168,7 @@ def plot_trajectory(
# Start/end markers
ax.plot(x[0], y[0], "o", color="green", markersize=8, label="Start", zorder=5)
ax.plot(x[-1], y[-1], "s", color="red", markersize=8, label="End", zorder=5)
- ax.legend(fontsize=8)
+ ax.legend()
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
@@ -178,7 +178,6 @@ def plot_trajectory(
ax.axvline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4)
ax.set_aspect("equal", adjustable="box")
ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.4)
- fig.tight_layout()
return fig
@@ -227,9 +226,9 @@ def hodograph_panel(
cb = ax.figure.colorbar(
sm, ax=ax, shrink=0.75, pad=0.03, aspect=20, ticks=bounds_lc
)
- cb.set_label("Time ->", size=8)
+ cb.set_label("Time →")
cb.ax.set_yticks([0.0, 1.0])
- cb.ax.set_yticklabels(["start", "end"], size=7)
+ cb.ax.set_yticklabels(["start", "end"])
ax.scatter(
e_v[0],
@@ -258,7 +257,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, fontsize=9)
+ ax.set_title(title)
ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.3)
diff --git a/oceanarray/plotters/spectrum.py b/oceanarray/plotters/spectrum.py
index 146a0cb..dee5b32 100644
--- a/oceanarray/plotters/spectrum.py
+++ b/oceanarray/plotters/spectrum.py
@@ -12,12 +12,7 @@
Pairs with :mod:`oceanarray.analysis.spectral` for spectral computations.
Post-OdB remaining migrations from report/_plots.py:
- plot_grid_fig (was _make_grid_fig_b64),
- plot_isopycnal (was _make_isopycnal_fig_b64), plot_grid_n2 (was _make_grid_n2_b64).
-
-Note: _filter_sigma_tukey belongs in tools/ (data pre-treatment), not here.
-Callers pass pre-filtered data; high-level wrappers like plot_isopycnal may
-apply the filter internally but expose it as a parameter.
+ plot_grid_fig (was _make_grid_fig_b64), plot_grid_n2 (was _make_grid_n2_b64).
See .claude/plotters_update-20260718.md for migration checklist.
"""
@@ -32,6 +27,7 @@
from oceanarray.utilities import _nice_colorbar_bounds, period_axis_ticks
from ..analysis.spectral import gonella_rotary_spectrum
+from .. import parameters as params
if TYPE_CHECKING:
import matplotlib.axes
@@ -357,7 +353,7 @@ def draw_spectrum(
from matplotlib.ticker import NullLocator
- fig, (ax_lf, ax_hf) = plt.subplots(1, 2, figsize=(14, 5))
+ fig, (ax_lf, ax_hf) = plt.subplots(1, 2, figsize=(params.W_FULL, 5))
x_min_lf = max(nyq_period, 10.0 / 1440.0) # right edge: Nyquist or 10 min
# Left edge = longest period Welch can estimate = 1/min_freq = window length
@@ -651,7 +647,7 @@ def draw_wavelet(
n_panels = len(results)
# height ratios: 1 part time series, 3 parts wavelet, per level
hr = [1, 3] * n_panels
- fig = plt.figure(figsize=(14, 4.5 * n_panels))
+ fig = plt.figure(figsize=(params.W_FULL, 4.5 * n_panels))
gs = GridSpec(2 * n_panels, 1, figure=fig, height_ratios=hr, hspace=0.08)
tax: list = [] # time series axes (top of each pair)
@@ -873,7 +869,7 @@ def draw_grid_rotary_spectrum(
cmap_cw = plt.get_cmap("Reds")
cmap_ccw = plt.get_cmap("Blues")
- fig, (ax_spec, ax_rot) = plt.subplots(1, 2, figsize=(13, 5))
+ fig, (ax_spec, ax_rot) = plt.subplots(1, 2, figsize=(params.W_FULL, 5))
# Panel 1: CW (solid, reds) + CCW (dashed, blues)
for s_cw, s_ccw, p in zip(s_cw_list, s_ccw_list, press_plotted):
diff --git a/oceanarray/plotters/ts.py b/oceanarray/plotters/ts.py
index bbe3c71..b566e14 100644
--- a/oceanarray/plotters/ts.py
+++ b/oceanarray/plotters/ts.py
@@ -153,7 +153,7 @@ def draw_ts_diagram(nc_path: Path) -> "Optional[plt.Figure]":
ncols = 3 if has_sat else 2
fig, axes = plt.subplots(
- 1, ncols, figsize=(5.5 * ncols, 4.5), constrained_layout=True
+ 1, ncols, figsize=(params.W_FULL, 4.5), constrained_layout=True
)
ax_l, ax_r = axes[0], axes[1]
ax_sat = axes[2] if has_sat else None
@@ -292,8 +292,9 @@ 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_w = 5.5 * ncols # ~5.5 in per panel keeps them compact in a row
- fig, axes = plt.subplots(1, ncols, figsize=(fig_w, 4.5), constrained_layout=True)
+ fig, axes = plt.subplots(
+ 1, ncols, figsize=(params.W_FULL, 4.5), constrained_layout=True
+ )
ax_scatter, ax_heat = axes[0], axes[1]
ax_sat = axes[2] if has_sat else None
@@ -423,7 +424,7 @@ def draw_grid_ts_diagram(
has_o2 = has_o2 and o2_valid.any()
ncols = 2 if has_o2 else 1
- fig, axes = plt.subplots(1, ncols, figsize=(6 * ncols, 5))
+ fig, axes = plt.subplots(1, ncols, figsize=(params.W_HALF, 5))
if ncols == 1:
axes = [axes]
diff --git a/oceanarray/report/_grid.py b/oceanarray/report/_grid.py
index ea4d620..d2128f1 100644
--- a/oceanarray/report/_grid.py
+++ b/oceanarray/report/_grid.py
@@ -191,7 +191,7 @@
Velocity IQR profiles
Median (solid line) and interquartile range (shaded, 25–75 %) across the full deployment at each pressure level. 2.5–97.5 % outer envelope for speed. Right panel: count of non-NaN values per depth.
show / hide
-
+
{% endif %}
diff --git a/oceanarray/report/_html_helpers.py b/oceanarray/report/_html_helpers.py
index e5a8b62..25dfc87 100644
--- a/oceanarray/report/_html_helpers.py
+++ b/oceanarray/report/_html_helpers.py
@@ -14,6 +14,7 @@
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
+from PIL import Image
from ..paths import safe_serial
from ..utilities import ( # noqa: F401 (re-exported)
@@ -186,10 +187,31 @@ def _should_skip(
def _fig_to_base64(fig: Any) -> str:
+ """Return *fig* as a base64-encoded, palette-quantized PNG.
+
+ dpi is taken from the active mplstyle's ``savefig.dpi`` — there is no local
+ override, so the style file is the single source of truth for figure dpi.
+ (Displayed font size is independent of dpi anyway; it is fixed by the
+ figsize<->display-slot ratio, see ``parameters.W_FULL`` etc. dpi is only a
+ size/crispness knob.)
+
+ The PNG is composited onto white (report backgrounds are white, so the alpha
+ channel carries nothing) and quantized to a 256-colour palette. Report
+ figures are few-colour by construction — line art plus *discrete* colorbars
+ (``_nice_colorbar_bounds``, max 20 levels) — so indexed PNG is ~visually
+ lossless here and cuts figure bytes ~3x, the dominant lever on report/PDF
+ size. A continuous colorbar would band; report style forbids those.
+ """
buf = io.BytesIO()
- fig.savefig(buf, format="png", dpi=110, bbox_inches="tight")
+ fig.savefig(buf, format="png", bbox_inches="tight")
buf.seek(0)
- return base64.b64encode(buf.read()).decode("ascii")
+ im = Image.open(buf).convert("RGBA")
+ background = Image.new("RGBA", im.size, (255, 255, 255, 255))
+ rgb = Image.alpha_composite(background, im).convert("RGB")
+ quantized = rgb.quantize(colors=256, method=Image.FASTOCTREE)
+ out = io.BytesIO()
+ quantized.save(out, "PNG", optimize=True)
+ return base64.b64encode(out.getvalue()).decode("ascii")
def _load_pdf_b64(path: Path) -> Optional[str]:
diff --git a/oceanarray/report/_instrument.py b/oceanarray/report/_instrument.py
index bfde55e..be7c0ec 100644
--- a/oceanarray/report/_instrument.py
+++ b/oceanarray/report/_instrument.py
@@ -233,8 +233,10 @@
Time series (full deployment)
{% if fig_ts_b64 %}
-
+{% endfor %}
{% else %}
No plottable variables found.
{% endif %}
@@ -276,7 +278,9 @@
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.
-
+{% for _win_img in fig_windows_b64 %}
+
+{% endfor %}
{% else %}
Insufficient data for start/end windows.
{% endif %}
diff --git a/oceanarray/report/_mooring.py b/oceanarray/report/_mooring.py
index f6195c7..edacb51 100644
--- a/oceanarray/report/_mooring.py
+++ b/oceanarray/report/_mooring.py
@@ -1405,19 +1405,28 @@ def _build_context(
any_clock = any(i["clock"]["has_correction"] for i in instruments)
# Build {serial: nc_path} for the clock-offset comparison figure.
- # Prefer stage3 over stage2 (clock correction was applied in stage2 and
- # is preserved in stage3, so either works for comparing alignment).
+ # Use stage1 (raw, UNtrimmed): stage2/3 are trimmed to the deployment
+ # window, which removes exactly the pre-deploy / post-recover data the
+ # ±window check needs to show the deployment/recovery temperature
+ # transient (the shared timing feature). Raw clocks also expose the real
+ # inter-instrument offsets before correction. (Note: stage1 times are in
+ # the raw instrument clock, so a very large offset could shift the
+ # transient out of the ±window; offsets are normally seconds-to-minutes.)
+ # Fall back to stage2/3 only if stage1 is absent.
_clock_nc_paths: Dict[str, Path] = {}
for _instr in instruments:
_s = _instr["serial"]
_itype = _instr["instr_type"]
_base = proc_dir / _itype / f"{mooring_name}_{_s}"
+ _s1 = Path(str(_base) + "_stage1.nc")
_s3 = Path(str(_base) + "_stage3.nc")
_s2 = Path(str(_base) + "_stage2.nc")
- if _s3.exists():
- _clock_nc_paths[_s] = _s3
+ if _s1.exists():
+ _clock_nc_paths[_s] = _s1
elif _s2.exists():
_clock_nc_paths[_s] = _s2
+ elif _s3.exists():
+ _clock_nc_paths[_s] = _s3
fig_clock_check_b64 = _make_clock_check_b64(
_clock_nc_paths, deploy_dt, recover_dt
)
diff --git a/oceanarray/report/_pdf.py b/oceanarray/report/_pdf.py
index 2910eb3..5cbec30 100644
--- a/oceanarray/report/_pdf.py
+++ b/oceanarray/report/_pdf.py
@@ -42,6 +42,14 @@
height: auto;
break-inside: avoid;
}
+/* A single
cannot split across PDF pages, so a tall multipanel figure
+ would overflow the page bottom. Cap figure height to (about) the A4 content
+ height so such figures scale down to fit one page. (Proper fix is to
+ paginate multipanel figures into <=5-panel images — tracked as report-figures
+ #10.) A4 usable height ~26cm minus room for a heading/note above the figure. */
+img.fig {
+ max-height: 22cm;
+}
/* WeasyPrint cannot resolve ``repeat(auto-fill, minmax(...))`` and collapses
such grids to a single column (the summary header's .meta-grid balloons as a
result). Force an explicit column count in print instead. */
diff --git a/oceanarray/report/_plots.py b/oceanarray/report/_plots.py
index b4e9482..3ba81bd 100644
--- a/oceanarray/report/_plots.py
+++ b/oceanarray/report/_plots.py
@@ -57,7 +57,6 @@
draw_grid_ts_diagram,
)
from ..plotters.hydrography import (
- draw_isopycnal_fig,
draw_isopycnal_ts_fig,
draw_isopycnal_coverage,
draw_overflow_temperature_fig,
@@ -293,56 +292,77 @@ def _instrument_panels(
# ---------------------------------------------------------------------------
+# Max panels per instrument time-series figure. A tall figure cannot split
+# across PDF pages, so we paginate into several figures of at most this many
+# panels each (see _build_figs_from_ds).
+_MAX_TS_PANELS = 5
+
+
+def _augment_tilt(ds: "xr.Dataset") -> "xr.Dataset":
+ """Return *ds* with a derived ``tilt`` variable added from pitch/roll.
+
+ ``tilt = arccos(cos(pitch)·cos(roll))`` in degrees from vertical. A no-op
+ when neither pitch nor roll is present. Idempotent (re-running overwrites
+ the derived variable).
+ """
+ _has_pitch = "pitch" in ds.data_vars
+ _has_roll = "roll" in ds.data_vars
+ if not (_has_pitch or _has_roll):
+ return ds
+ _n = ds.sizes["time"]
+ _pitch_r = (
+ np.radians(ds["pitch"].values.astype(float)) if _has_pitch else np.zeros(_n)
+ )
+ _roll_r = np.radians(ds["roll"].values.astype(float)) if _has_roll else np.zeros(_n)
+ _cos_t = np.cos(_pitch_r) * np.cos(_roll_r)
+ _tilt = np.degrees(np.arccos(np.clip(_cos_t, -1.0, 1.0)))
+ if _has_pitch:
+ _tilt[~np.isfinite(ds["pitch"].values.astype(float))] = np.nan
+ if _has_roll:
+ _tilt[~np.isfinite(ds["roll"].values.astype(float))] = np.nan
+ import xarray as _xr
+
+ return ds.assign(
+ tilt=_xr.Variable(
+ "time",
+ _tilt,
+ {"units": "degrees", "long_name": "Instrument tilt from vertical"},
+ )
+ )
+
+
def _build_fig_from_ds(
ds: "xr.Dataset",
instr_type: str,
show_qc: bool = True,
title_suffix: str = "",
+ panels: "Optional[list]" = None,
) -> "Optional[plt.Figure]":
- """Render instrument panels from an already-loaded xarray Dataset."""
+ """Render instrument panels from an already-loaded xarray Dataset.
+
+ When *panels* is given, only those panels are drawn (used to paginate a tall
+ instrument figure via :func:`_build_figs_from_ds`); otherwise every panel for
+ the instrument is drawn on one figure.
+ """
import matplotlib.pyplot as plt
from .. import parameters as params
- _has_pitch = "pitch" in ds.data_vars
- _has_roll = "roll" in ds.data_vars
- if _has_pitch or _has_roll:
- _n = ds.sizes["time"]
- _pitch_r = (
- np.radians(ds["pitch"].values.astype(float)) if _has_pitch else np.zeros(_n)
- )
- _roll_r = (
- np.radians(ds["roll"].values.astype(float)) if _has_roll else np.zeros(_n)
- )
- _cos_t = np.cos(_pitch_r) * np.cos(_roll_r)
- _tilt = np.degrees(np.arccos(np.clip(_cos_t, -1.0, 1.0)))
- if _has_pitch:
- _tilt[~np.isfinite(ds["pitch"].values.astype(float))] = np.nan
- if _has_roll:
- _tilt[~np.isfinite(ds["roll"].values.astype(float))] = np.nan
- import xarray as _xr
-
- ds = ds.assign(
- tilt=_xr.Variable(
- "time",
- _tilt,
- {"units": "degrees", "long_name": "Instrument tilt from vertical"},
- )
- )
-
- panels = _instrument_panels(ds, combine_pitch_roll=True)
+ ds = _augment_tilt(ds)
+ if panels is None:
+ panels = _instrument_panels(ds, combine_pitch_roll=True)
if not panels:
return None
with plt.style.context(str(params.MPLSTYLE)):
nrows = len(panels)
height_ratios = [
- _COMPACT_PANEL_HEIGHT if vname in _COMPACT_PANEL_VARS else 3.0
+ _COMPACT_PANEL_HEIGHT if vname in _COMPACT_PANEL_VARS else 2.0
for vname, *_ in panels
]
fig, axs = plt.subplots(
nrows,
1,
- figsize=(12, sum(height_ratios)),
+ figsize=(params.W_FULL, sum(height_ratios)),
gridspec_kw={"height_ratios": height_ratios},
sharex=True,
)
@@ -476,18 +496,36 @@ def _build_fig_from_ds(
def _make_instrument_fig(
nc_path: Path, instr_type: str, show_qc: bool = True
-) -> Optional[str]:
- """Data time series with optional QC markers. Returns base64 PNG or None."""
- import xarray as xr
+) -> List[str]:
+ """Instrument data time series with optional QC markers, paginated.
- def _draw() -> "Optional[plt.Figure]":
- ds = xr.open_dataset(nc_path, decode_timedelta=False).load()
- try:
- return _build_fig_from_ds(ds, instr_type, show_qc=show_qc)
- finally:
- ds.close()
+ Returns a *list* of base64 PNGs: the instrument's panels are split into
+ figures of at most ``_MAX_TS_PANELS`` panels each, so a tall instrument time
+ series paginates into successive images instead of overflowing one PDF page.
+ Empty list if the instrument has no plottable panels.
+ """
+ import xarray as xr
- return render_b64(_draw, optional=True)
+ ds = xr.open_dataset(nc_path, decode_timedelta=False).load()
+ try:
+ ds = _augment_tilt(ds)
+ panels = _instrument_panels(ds, combine_pitch_roll=True)
+ if not panels:
+ return []
+ images: List[str] = []
+ for i in range(0, len(panels), _MAX_TS_PANELS):
+ chunk = panels[i : i + _MAX_TS_PANELS]
+ b64 = render_b64(
+ lambda c=chunk: _build_fig_from_ds(
+ ds, instr_type, show_qc=show_qc, panels=c
+ ),
+ optional=True,
+ )
+ if b64:
+ images.append(b64)
+ return images
+ finally:
+ ds.close()
def _make_windows_fig(
@@ -497,18 +535,41 @@ def _make_windows_fig(
show_qc: bool = True,
vlines: Optional[list] = None,
stage1_nc: Optional[Path] = None,
-) -> Optional[str]:
- """Return base64 PNG: combined start + end window figure."""
- return render_b64(
- draw_windows,
- nc_path,
- instr_type,
- hours,
- show_qc,
- vlines,
- stage1_nc,
- optional=True,
- )
+) -> List[str]:
+ """Return base64 PNGs: combined start + end window figure, paginated.
+
+ Returns a *list* of base64 PNGs: the instrument's panels are split into
+ figures of at most ``_MAX_TS_PANELS`` rows each, so a tall start/end window
+ figure paginates into successive images instead of overflowing one PDF page.
+ Each row is a half-width start panel beside a half-width end panel. Empty
+ list if the instrument has no plottable panels.
+ """
+ import xarray as xr
+
+ ds = xr.open_dataset(nc_path, decode_timedelta=False).load()
+ try:
+ panels = _instrument_panels(ds, combine_pitch_roll=True)
+ finally:
+ ds.close()
+ if not panels:
+ return []
+ images: List[str] = []
+ for i in range(0, len(panels), _MAX_TS_PANELS):
+ chunk = panels[i : i + _MAX_TS_PANELS]
+ b64 = render_b64(
+ draw_windows,
+ nc_path,
+ instr_type,
+ hours,
+ show_qc,
+ vlines,
+ stage1_nc,
+ chunk,
+ optional=True,
+ )
+ if b64:
+ images.append(b64)
+ return images
def _make_data_histogram(nc_path: Path) -> Optional[str]:
@@ -676,27 +737,6 @@ def _make_grid_timeseries_b64(ds: "xr.Dataset") -> Optional[str]:
return render_b64(draw_grid_timeseries, ds, optional=True)
-def _make_isopycnal_fig_b64(
- da: "xr.DataArray",
- levels: list,
- filter_samples: int = 0,
- zoom_center_idx: Optional[int] = None,
- zoom_n: int = 0,
-) -> Optional[str]:
- """Return base64 PNG: time × pressure with iso-sigma contour lines."""
- if not levels:
- return None
- return render_b64(
- draw_isopycnal_fig,
- da,
- levels,
- filter_samples,
- zoom_center_idx,
- zoom_n,
- optional=True,
- )
-
-
def _make_isopycnal_ts_fig_b64(ds_iso: "xr.Dataset") -> Optional[str]:
"""Return base64 PNG: isopycnal height-above-seabed time series."""
return render_b64(draw_isopycnal_ts_fig, ds_iso, optional=True)
@@ -967,9 +1007,9 @@ def _make_clock_check_b64(
nc_paths: "Dict[str, Any]",
deploy_dt: "Any",
recover_dt: "Any",
- window_minutes: int = 10,
+ window_minutes: int = 30,
) -> Optional[str]:
- """Overlaid temperature comparison at deployment start/end, for the mooring summary.
+ """Overlaid normalised-temperature comparison ±window around deploy/recover.
Thin Tier-3 wrapper around
``plotters.diagnostic.plot_clock_offset_check``.
diff --git a/oceanarray/report/_stack.py b/oceanarray/report/_stack.py
index 8f25780..8e0616a 100644
--- a/oceanarray/report/_stack.py
+++ b/oceanarray/report/_stack.py
@@ -456,7 +456,9 @@ def _make_aquadopp_tilt_panels(ds: Any, step: int = 1) -> Optional[str]:
n_panels = len(aq_indices)
def _draw() -> "plt.Figure":
- fig = plt.figure(figsize=(16, 2.8 * n_panels), constrained_layout=True)
+ fig = plt.figure(
+ figsize=(params.W_FULL, 2.8 * n_panels), constrained_layout=True
+ )
gs = fig.add_gridspec(n_panels, 3, width_ratios=[2, 2, 1])
ax_ts_first = None
@@ -503,12 +505,9 @@ def _draw() -> "plt.Figure":
_ref_note = f" [ref: s/n {_ref_s} @ {ref_habs[i]:.0f} m]"
ax_ts.set_title(f"s/n {serial} ({hab:.0f} m hab){_ref_note}")
if ax_ts.get_legend_handles_labels()[0]:
- ax_ts.legend(
- loc="upper left",
- bbox_to_anchor=(1.01, 1.0),
- borderaxespad=0,
- framealpha=0.8,
- )
+ # Legend inside the time-series panel (was anchored outside at
+ # 1.01, which landed over the neighbouring scatter panel).
+ ax_ts.legend(loc="best", framealpha=0.8)
if row < n_panels - 1:
ax_ts.tick_params(labelbottom=False)
@@ -560,13 +559,8 @@ def _draw() -> "plt.Figure":
ax_sc.set_ylim(bottom=0.0)
ax_sc.set_xlabel("tilt (pressure) [°]")
ax_sc.set_ylabel("|pitch|, |roll| [°]")
- ax_sc.legend(
- loc="upper left",
- bbox_to_anchor=(1.01, 1.0),
- borderaxespad=0,
- framealpha=0.8,
- markerscale=3,
- )
+ # No scatter legend — the time-series panel legend already names
+ # pitch/roll; a second legend here is a redundant repeat.
else:
ax_sc.text(
0.5,
@@ -663,7 +657,7 @@ def _ts_fig(
qc = ds[qc_varname].values
arr[qc >= 3] = np.nan
with plt.style.context(str(params.MPLSTYLE)):
- fig, ax = plt.subplots(figsize=(13, 4))
+ fig, ax = plt.subplots(figsize=(params.W_FULL, 3.2))
plotted = False
for i in range(n_instr):
if exclude_types and instr_types[i].lower() in exclude_types:
@@ -700,6 +694,7 @@ def _ts_fig(
ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
ax.set_ylabel(ylabel)
ax.set_xlabel("Time")
+ ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.3)
if _t_cov_start and _t_cov_end:
try:
ax.set_xlim(
@@ -715,10 +710,12 @@ def _ts_fig(
bbox_to_anchor=(1.01, 1.0),
borderaxespad=0,
framealpha=0.8,
- fontsize=6,
- ncol=2,
+ ncol=1,
)
- plt.tight_layout()
+ # No tight_layout: it shrinks the axes to fit a tall outside
+ # legend within the figsize. _fig_to_base64 saves with
+ # bbox_inches="tight", so the PNG expands to include the legend
+ # while the plot keeps its full height.
b64 = _fig_to_base64(fig)
plt.close(fig)
return b64
@@ -841,7 +838,7 @@ def _ts_fig(
all_spacings.extend(valid.tolist())
if all_spacings:
with plt.style.context(str(params.MPLSTYLE)):
- fig_sp, ax_sp = plt.subplots(figsize=(4, 3))
+ fig_sp, ax_sp = plt.subplots(figsize=(params.W_THIRD, 3))
ax_sp.hist(
all_spacings, bins=60, color="steelblue", edgecolor="white"
)
diff --git a/requirements.txt b/requirements.txt
index b1a4f9f..4d31a11 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,6 +6,7 @@ scipy>=1.10
# Plotting
matplotlib>=3.7
+pillow>=9.0 # report figure PNG palette-quantization in _fig_to_base64 (matplotlib pulls it in too)
# Oceanographic tools
gsw>=3.6.16
diff --git a/tests/unit/test_plot_guard.py b/tests/unit/test_plot_guard.py
index c1b3d92..4fee934 100644
--- a/tests/unit/test_plot_guard.py
+++ b/tests/unit/test_plot_guard.py
@@ -143,19 +143,19 @@ def test_aquadopp_figure_functions(aquadopp_stage3_path, fn_name):
def test_make_instrument_fig_microcat(microcat_stage3_path):
- """The combined instrument figure builds for a microcat."""
- assert (
- _plots._make_instrument_fig(str(microcat_stage3_path), "microcat", show_qc=True)
- is not None
+ """The (paginated) instrument figure list builds for a microcat."""
+ imgs = _plots._make_instrument_fig(
+ str(microcat_stage3_path), "microcat", show_qc=True
)
+ assert isinstance(imgs, list) and len(imgs) >= 1
def test_make_instrument_fig_aquadopp(aquadopp_stage3_path):
- """The combined instrument figure builds for an aquadopp."""
- assert (
- _plots._make_instrument_fig(str(aquadopp_stage3_path), "aquadopp", show_qc=True)
- is not None
+ """The (paginated) instrument figure list builds for an aquadopp."""
+ imgs = _plots._make_instrument_fig(
+ str(aquadopp_stage3_path), "aquadopp", show_qc=True
)
+ assert isinstance(imgs, list) and len(imgs) >= 1
def test_build_fig_from_ds_microcat(microcat_stage3):
@@ -164,9 +164,9 @@ def test_build_fig_from_ds_microcat(microcat_stage3):
def test_windows_fig_microcat(microcat_stage3_path):
- """``_make_windows_fig`` builds a deployment-window figure for a microcat."""
- result = _plots._make_windows_fig(microcat_stage3_path, "microcat")
- assert result is not None
+ """``_make_windows_fig`` builds a (paginated) deployment-window figure list."""
+ imgs = _plots._make_windows_fig(microcat_stage3_path, "microcat")
+ assert isinstance(imgs, list) and len(imgs) >= 1
# ---------------------------------------------------------------------------