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 -Velocity IQR profiles +Velocity IQR profiles
{% 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 # ---------------------------------------------------------------------------