oceanarray 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. oceanarray/__init__.py +20 -0
  2. oceanarray/_version.py +1 -0
  3. oceanarray/cli.py +1343 -0
  4. oceanarray/clock_offset.py +675 -0
  5. oceanarray/config/OS1_sensor_attrs.yaml +49 -0
  6. oceanarray/config/OS1_var_names.yaml +5 -0
  7. oceanarray/config/OS1_vocab_attrs.yaml +84 -0
  8. oceanarray/config/legacy/project_RAPID.yaml +69 -0
  9. oceanarray/config/legacy/rodb_keys.txt +206 -0
  10. oceanarray/config/legacy/rodb_keys.yaml +1012 -0
  11. oceanarray/config/logging.yaml +28 -0
  12. oceanarray/legacy/__init__.py +47 -0
  13. oceanarray/legacy/caldip.py +753 -0
  14. oceanarray/legacy/convertOS.py +527 -0
  15. oceanarray/legacy/find_deployment.py +489 -0
  16. oceanarray/legacy/mooring_rodb.py +429 -0
  17. oceanarray/legacy/process_rodb.py +369 -0
  18. oceanarray/legacy/rodb.py +330 -0
  19. oceanarray/logger.py +192 -0
  20. oceanarray/logsheet/__init__.py +60 -0
  21. oceanarray/logsheet/_columns.py +97 -0
  22. oceanarray/logsheet/_config.py +245 -0
  23. oceanarray/logsheet/_firmware.py +43 -0
  24. oceanarray/logsheet/_latex.py +128 -0
  25. oceanarray/logsheet/_render.py +126 -0
  26. oceanarray/logsheet/builders/__init__.py +13 -0
  27. oceanarray/logsheet/builders/caldip_download.py +195 -0
  28. oceanarray/logsheet/builders/caldip_setup.py +188 -0
  29. oceanarray/logsheet/builders/deployment_setup.py +218 -0
  30. oceanarray/logsheet/builders/mooring_download.py +243 -0
  31. oceanarray/logsheet/builders/recovery.py +157 -0
  32. oceanarray/logsheet/data/column_defs.yaml +686 -0
  33. oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
  34. oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
  35. oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
  36. oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
  37. oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
  38. oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
  39. oceanarray/mooring_level.py +1368 -0
  40. oceanarray/oceanarray.mplstyle +18 -0
  41. oceanarray/parameters.py +270 -0
  42. oceanarray/plotter.py +942 -0
  43. oceanarray/plotters/__init__.py +97 -0
  44. oceanarray/plotters/_animation.py +261 -0
  45. oceanarray/plotters/_current.py +705 -0
  46. oceanarray/plotters/_diagnostic.py +667 -0
  47. oceanarray/plotters/_helpers.py +57 -0
  48. oceanarray/plotters/_primitives.py +101 -0
  49. oceanarray/plotters/_section.py +16 -0
  50. oceanarray/plotters/_timeseries.py +11 -0
  51. oceanarray/rapid_interp.py +739 -0
  52. oceanarray/read_rbr_hex.py +541 -0
  53. oceanarray/readers.py +303 -0
  54. oceanarray/report/__init__.py +5 -0
  55. oceanarray/report/_array.py +386 -0
  56. oceanarray/report/_grid.py +539 -0
  57. oceanarray/report/_html_helpers.py +1004 -0
  58. oceanarray/report/_instrument.py +853 -0
  59. oceanarray/report/_mooring.py +1385 -0
  60. oceanarray/report/_plots.py +4284 -0
  61. oceanarray/report/_stack.py +941 -0
  62. oceanarray/stage1.py +1959 -0
  63. oceanarray/stage2.py +1160 -0
  64. oceanarray/stage3.py +2494 -0
  65. oceanarray/time_gridding.py +958 -0
  66. oceanarray/tools.py +400 -0
  67. oceanarray/transports.py +1 -0
  68. oceanarray/utilities.py +538 -0
  69. oceanarray/validation.py +389 -0
  70. oceanarray/writers.py +99 -0
  71. oceanarray-0.1.0.dist-info/METADATA +503 -0
  72. oceanarray-0.1.0.dist-info/RECORD +76 -0
  73. oceanarray-0.1.0.dist-info/WHEEL +5 -0
  74. oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
  75. oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
  76. oceanarray-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,4284 @@
1
+ """All figure-generating functions for the mooring report package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
7
+
8
+ if TYPE_CHECKING:
9
+ import matplotlib.pyplot as plt
10
+ import xarray as xr
11
+
12
+ import numpy as np
13
+
14
+ from ._html_helpers import _QC_MARKER, _QC_LABELS, _fig_to_base64
15
+ from ..utilities import _nice_colorbar_bounds
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Shared velocity colormap helper
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ def _velocity_panel_style(
24
+ var: str,
25
+ finite_vals: np.ndarray,
26
+ div_abs_max: float,
27
+ ) -> tuple:
28
+ """Return (bounds, norm, cmap, cb_label) for one velocity pcolormesh panel.
29
+
30
+ Centralises all velocity colormap decisions so that the per-instrument
31
+ ADCP report and the grid/stack report always produce visually identical
32
+ panels. Call this helper from every function that renders a velocity
33
+ variable as a pcolormesh — do not duplicate the logic inline.
34
+
35
+ Panel types
36
+ -----------
37
+ div (diverging) east/north/up/error_velocity Spectral_r ± div_abs_max
38
+ seq (sequential) current_speed plasma 0 → 98th pctile
39
+ seq bin_pressure PuRd 2nd→98th pctile
40
+ cyc (cyclic) current_direction hsv 0–360°
41
+
42
+ Parameters
43
+ ----------
44
+ var : str
45
+ Variable name (e.g. ``"east_velocity"``, ``"current_direction"``).
46
+ finite_vals : np.ndarray
47
+ Finite values of *this* variable (used for seq bounds; pass ``[]``
48
+ for div/cyc panels where per-variable bounds are not needed).
49
+ div_abs_max : float
50
+ Pre-computed symmetric bound for diverging panels (ignored for seq/cyc).
51
+ Computed by the caller as the 2nd/98th percentile magnitude across all
52
+ ENU velocity components.
53
+
54
+ Returns
55
+ -------
56
+ tuple
57
+ ``(bounds, norm, cmap, cb_label)`` where *bounds* is a 1-D array of
58
+ colorbar tick/boundary values, *norm* is a ``BoundaryNorm``, *cmap*
59
+ is a colormap name string, and *cb_label* is the colorbar axis label.
60
+
61
+ """
62
+ import matplotlib.colors as mcolors
63
+
64
+ if var in ("east_velocity", "north_velocity", "up_velocity", "error_velocity"):
65
+ bounds = _nice_colorbar_bounds(-div_abs_max, div_abs_max, n=20)
66
+ norm = mcolors.BoundaryNorm(bounds, ncolors=256)
67
+ return bounds, norm, "Spectral_r", "m s⁻¹"
68
+ if var == "current_speed":
69
+ spd_max = float(np.percentile(finite_vals, 98)) if len(finite_vals) else 1.0
70
+ bounds = _nice_colorbar_bounds(0.0, max(spd_max, 1e-4), n=20)
71
+ norm = mcolors.BoundaryNorm(bounds, ncolors=256)
72
+ return bounds, norm, "plasma", "m s⁻¹"
73
+ if var == "current_direction":
74
+ bounds = np.linspace(0, 360, 21)
75
+ norm = mcolors.BoundaryNorm(bounds, ncolors=256)
76
+ return bounds, norm, "hsv", "°T"
77
+ if var == "bin_pressure":
78
+ p_lo = float(np.percentile(finite_vals, 2)) if len(finite_vals) else 0.0
79
+ p_hi = float(np.percentile(finite_vals, 98)) if len(finite_vals) else 1000.0
80
+ bounds = _nice_colorbar_bounds(p_lo, p_hi, n=20)
81
+ norm = mcolors.BoundaryNorm(bounds, ncolors=256)
82
+ return bounds, norm, "PuRd", "dbar"
83
+ # Unknown variable — fall back to diverging
84
+ bounds = _nice_colorbar_bounds(-div_abs_max, div_abs_max, n=20)
85
+ norm = mcolors.BoundaryNorm(bounds, ncolors=256)
86
+ return bounds, norm, "Spectral_r", "m s⁻¹"
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Aquadopp quick-look
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ def _plot_aquadopp_quick(ds: "xr.Dataset") -> "plt.Figure":
95
+ """Quick-look figure for Aquadopp; handles beam and ENU naming, lowercase attitude."""
96
+ import matplotlib.pyplot as plt
97
+ import matplotlib.dates as mdates
98
+ from .. import parameters as P
99
+
100
+ plt.style.use(str(P.MPLSTYLE))
101
+ panels: List[Tuple] = []
102
+
103
+ enu = [
104
+ v
105
+ for v in ("east_velocity", "north_velocity", "up_velocity")
106
+ if v in ds.data_vars
107
+ ]
108
+ if enu:
109
+ for vname, color in zip(enu, ["tab:blue", "tab:orange", "tab:cyan"]):
110
+ label = (
111
+ vname.replace("_velocity", " vel.").replace("_", " ").title() + " [m/s]"
112
+ )
113
+ panels.append((vname, label, color, False))
114
+ else:
115
+ for i, color in enumerate(["tab:blue", "tab:orange", "tab:cyan"], 1):
116
+ vname = f"velocity_beam{i}"
117
+ if vname in ds.data_vars:
118
+ panels.append((vname, f"Beam {i} vel. [m/s]", color, False))
119
+
120
+ pvar = next((v for v in ("pressure", "pressure_1") if v in ds.data_vars), None)
121
+ if pvar:
122
+ panels.append((pvar, "Pressure [dbar]", "tab:green", True))
123
+
124
+ for vname, label in (("pitch", "Pitch [°]"), ("roll", "Roll [°]")):
125
+ if vname in ds.data_vars:
126
+ panels.append((vname, label, "tab:purple", False))
127
+
128
+ nrows = max(len(panels), 1)
129
+ fig, axs = plt.subplots(nrows, 1, figsize=(12, 3 * nrows), sharex=True)
130
+ if nrows == 1:
131
+ axs = [axs]
132
+
133
+ for ax, (vname, label, color, invert) in zip(axs, panels):
134
+ ax.plot(ds["time"], ds[vname], color=color, linewidth=0.5)
135
+ if "velocity" in vname:
136
+ ax.axhline(0, color="k", linewidth=0.5, linestyle="--")
137
+ ax.set_ylabel(label)
138
+ if invert:
139
+ vmin = float(ds[vname].min())
140
+ vmax = float(ds[vname].max())
141
+ pad = max((vmax - vmin) * 0.1, 0.5)
142
+ ax.set_ylim(vmax + pad, vmin - pad)
143
+
144
+ serial = (
145
+ ds["serial_number"].item()
146
+ if "serial_number" in ds
147
+ else ds.attrs.get("serial_number", "?")
148
+ )
149
+ depth = f"{ds['InstrDepth'].item():.0f} m" if "InstrDepth" in ds else "?"
150
+ axs[0].set_title(f"Aquadopp s/n: {serial} | Target depth: {depth}")
151
+ axs[-1].set_xlabel("Time")
152
+ loc = mdates.AutoDateLocator()
153
+ axs[-1].xaxis.set_major_locator(loc)
154
+ axs[-1].xaxis.set_major_formatter(mdates.ConciseDateFormatter(loc))
155
+ plt.tight_layout()
156
+ return fig
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Canonical panel order
161
+ # ---------------------------------------------------------------------------
162
+
163
+ # Canonical variable order for all instrument plots.
164
+ _CANONICAL_PANELS: List[Tuple] = [
165
+ ("pressure", "Pressure (dbar)", "tab:green", True),
166
+ ("pressure_1", "Pressure 1 (dbar)", "tab:green", True),
167
+ ("temperature", "Temperature (°C)", "tab:red", False),
168
+ ("conductivity", "Conductivity (mS cm⁻¹)", "tab:blue", False),
169
+ ("salinity", "Salinity (PSU)", "tab:cyan", False),
170
+ ("east_velocity", "East velocity (m s⁻¹)", "tab:blue", False),
171
+ ("north_velocity", "North velocity (m s⁻¹)", "tab:orange", False),
172
+ ("up_velocity", "Up velocity (m s⁻¹)", "tab:cyan", False),
173
+ ("velocity_beam1", "Beam 1 velocity (m s⁻¹)", "tab:blue", False),
174
+ ("velocity_beam2", "Beam 2 velocity (m s⁻¹)", "tab:orange", False),
175
+ ("velocity_beam3", "Beam 3 velocity (m s⁻¹)", "tab:cyan", False),
176
+ ("tilt", "Tilt (°)", "tab:red", False),
177
+ ("pitch", "Pitch (°)", "tab:purple", False),
178
+ ("roll", "Roll (°)", "#8B4513", False),
179
+ ("heading", "Heading (°)", "tab:gray", False),
180
+ ("speed_of_sound", "Sound speed (m s⁻¹)", "tab:olive", False),
181
+ ("turbidity", "Turbidity (NTU)", "tab:brown", False),
182
+ ("dissolved_oxygen", "Dissolved oxygen (µmol L⁻¹)", "steelblue", False),
183
+ ("battery_voltage", "Battery (V)", "tab:pink", False),
184
+ ]
185
+
186
+ _COMPACT_PANEL_VARS: frozenset = frozenset({"battery_voltage", "speed_of_sound"})
187
+ _COMPACT_PANEL_HEIGHT: float = 1.5
188
+
189
+ # Variables plotted with both a line and individual dots so that sparse or
190
+ # near-zero samples (e.g. turbidity = 0 NTU between events) are visible.
191
+ _DOT_LINE_VARS: frozenset = frozenset({"turbidity"})
192
+
193
+
194
+ def _instrument_panels(
195
+ ds: "xr.Dataset", combine_pitch_roll: bool = False
196
+ ) -> List[Tuple]:
197
+ """Return panel list (varname, ylabel, line_color, invert_y) in canonical order."""
198
+ import re as _re
199
+
200
+ time_vars = {v for v in ds.data_vars if ds[v].dims == ("time",)}
201
+
202
+ has_enu = any(
203
+ v in time_vars for v in ("east_velocity", "north_velocity", "up_velocity")
204
+ )
205
+ beam_vars = {"velocity_beam1", "velocity_beam2", "velocity_beam3"}
206
+ do_combo = combine_pitch_roll and "pitch" in time_vars and "roll" in time_vars
207
+
208
+ out = []
209
+ for vname, label, color, invert in _CANONICAL_PANELS:
210
+ if vname not in time_vars:
211
+ continue
212
+ if has_enu and vname in beam_vars:
213
+ continue
214
+ if do_combo:
215
+ if vname == "pitch":
216
+ out.append(("_pitch_roll_combo", "Pitch & Roll [°]", None, False))
217
+ continue
218
+ if vname == "roll":
219
+ continue
220
+ actual_units = ds[vname].attrs.get("units", "")
221
+ if actual_units:
222
+ label = _re.sub(r"\[.*?\]", f"[{actual_units}]", label)
223
+ out.append((vname, label, color, invert))
224
+ return out
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Full time-series figure
229
+ # ---------------------------------------------------------------------------
230
+
231
+
232
+ def _build_fig_from_ds(
233
+ ds: "xr.Dataset",
234
+ instr_type: str,
235
+ show_qc: bool = True,
236
+ title_suffix: str = "",
237
+ ) -> "Optional[plt.Figure]":
238
+ """Render instrument panels from an already-loaded xarray Dataset."""
239
+ import matplotlib.pyplot as plt
240
+ import matplotlib.dates as mdates
241
+ from .. import parameters as P
242
+
243
+ plt.style.use(str(P.MPLSTYLE))
244
+
245
+ _has_pitch = "pitch" in ds.data_vars
246
+ _has_roll = "roll" in ds.data_vars
247
+ if _has_pitch or _has_roll:
248
+ _n = ds.sizes["time"]
249
+ _pitch_r = (
250
+ np.radians(ds["pitch"].values.astype(float)) if _has_pitch else np.zeros(_n)
251
+ )
252
+ _roll_r = (
253
+ np.radians(ds["roll"].values.astype(float)) if _has_roll else np.zeros(_n)
254
+ )
255
+ _cos_t = np.cos(_pitch_r) * np.cos(_roll_r)
256
+ _tilt = np.degrees(np.arccos(np.clip(_cos_t, -1.0, 1.0)))
257
+ if _has_pitch:
258
+ _tilt[~np.isfinite(ds["pitch"].values.astype(float))] = np.nan
259
+ if _has_roll:
260
+ _tilt[~np.isfinite(ds["roll"].values.astype(float))] = np.nan
261
+ import xarray as _xr
262
+
263
+ ds = ds.assign(
264
+ tilt=_xr.Variable(
265
+ "time",
266
+ _tilt,
267
+ {"units": "degrees", "long_name": "Instrument tilt from vertical"},
268
+ )
269
+ )
270
+
271
+ panels = _instrument_panels(ds, combine_pitch_roll=True)
272
+ if not panels:
273
+ return None
274
+
275
+ nrows = len(panels)
276
+ height_ratios = [
277
+ _COMPACT_PANEL_HEIGHT if vname in _COMPACT_PANEL_VARS else 3.0
278
+ for vname, *_ in panels
279
+ ]
280
+ fig, axs = plt.subplots(
281
+ nrows,
282
+ 1,
283
+ figsize=(12, sum(height_ratios)),
284
+ gridspec_kw={"height_ratios": height_ratios},
285
+ sharex=True,
286
+ )
287
+ if nrows == 1:
288
+ axs = [axs]
289
+
290
+ time = ds["time"].values
291
+
292
+ for ax, (vname, label, color, invert) in zip(axs, panels):
293
+ if vname == "_pitch_roll_combo":
294
+ _suspect_t = float(ds.attrs.get("tilt_suspect_threshold", 20.0))
295
+ _fail_t = float(ds.attrs.get("tilt_fail_threshold", 30.0))
296
+ if "pitch" in ds.data_vars:
297
+ ax.plot(
298
+ time,
299
+ ds["pitch"].values.astype(float),
300
+ color="tab:purple",
301
+ lw=0.6,
302
+ label="pitch",
303
+ zorder=1,
304
+ )
305
+ if "roll" in ds.data_vars:
306
+ ax.plot(
307
+ time,
308
+ ds["roll"].values.astype(float),
309
+ color="#8B4513",
310
+ lw=0.6,
311
+ label="roll",
312
+ zorder=1,
313
+ )
314
+ for _val, _c, _ls in [
315
+ (_suspect_t, "tab:orange", "--"),
316
+ (-_suspect_t, "tab:orange", "--"),
317
+ (_fail_t, "tab:red", ":"),
318
+ (-_fail_t, "tab:red", ":"),
319
+ ]:
320
+ ax.axhline(_val, color=_c, lw=0.8, ls=_ls, zorder=0)
321
+ ax.set_ylabel(label)
322
+ ax.legend(loc="upper right", framealpha=0.8)
323
+ continue
324
+
325
+ data = ds[vname].values.astype(float)
326
+ ax.plot(time, data, color=color, linewidth=0.6, zorder=1)
327
+ if vname in _DOT_LINE_VARS:
328
+ ax.plot(time, data, ".", color=color, markersize=2, linewidth=0, zorder=2)
329
+ if "velocity" in vname and not invert:
330
+ ax.axhline(0, color="k", linewidth=0.4, linestyle="--", zorder=0)
331
+ if vname == "tilt":
332
+ _suspect_t = float(ds.attrs.get("tilt_suspect_threshold", 20.0))
333
+ _fail_t = float(ds.attrs.get("tilt_fail_threshold", 30.0))
334
+ ax.axhline(
335
+ _suspect_t,
336
+ color="tab:orange",
337
+ lw=0.9,
338
+ ls="--",
339
+ label=f"suspect {_suspect_t:.0f}°",
340
+ zorder=2,
341
+ )
342
+ ax.axhline(
343
+ _fail_t,
344
+ color="tab:red",
345
+ lw=0.9,
346
+ ls="--",
347
+ label=f"fail {_fail_t:.0f}°",
348
+ zorder=2,
349
+ )
350
+ ax.legend(loc="upper right", framealpha=0.8)
351
+ ax.set_ylabel(label)
352
+ if invert:
353
+ vmin, vmax = float(np.nanmin(data)), float(np.nanmax(data))
354
+ pad = max((vmax - vmin) * 0.1, 0.5)
355
+ ax.set_ylim(vmax + pad, vmin - pad)
356
+ elif vname == "heading":
357
+ ax.set_ylim(0.0, 360.0)
358
+ elif "velocity" in vname:
359
+ _half = max(abs(float(np.nanmax(data))), abs(float(np.nanmin(data))), 1e-6)
360
+ ax.set_ylim(-_half, _half)
361
+
362
+ qc_var = f"{vname}_qc"
363
+ if show_qc and qc_var in ds.data_vars:
364
+ flags = ds[qc_var].values.astype(int)
365
+ for fval, mkw in _QC_MARKER.items():
366
+ mask = flags == fval
367
+ if mask.any():
368
+ ax.scatter(time[mask], data[mask], label=_QC_LABELS[fval], **mkw)
369
+ handles, labels_list = ax.get_legend_handles_labels()
370
+ if handles:
371
+ ax.legend(
372
+ handles,
373
+ labels_list,
374
+ loc="upper right",
375
+ ncol=3,
376
+ framealpha=0.8,
377
+ )
378
+
379
+ # Twin right y-axis: O2 % saturation alongside dissolved oxygen concentration.
380
+ # Uses in-situ seawater density for the µmol/L → µmol/kg conversion (see
381
+ # _derive_oxygen_saturation in stage3.py); error vs freshwater density ~2.5%.
382
+ if vname == "dissolved_oxygen" and "oxygen_saturation_pct" in ds.data_vars:
383
+ ax2 = ax.twinx()
384
+ sat = ds["oxygen_saturation_pct"].values.astype(float)
385
+ ax2.plot(time, sat, color="darkorange", linewidth=0.6, alpha=0.75, zorder=0)
386
+ ax2.set_ylabel("O₂ sat. (%)", color="darkorange")
387
+ ax2.tick_params(axis="y", labelcolor="darkorange")
388
+ ax2.axhline(100.0, color="darkorange", lw=0.5, ls="--", alpha=0.4)
389
+
390
+ serial = (
391
+ ds["serial_number"].item()
392
+ if "serial_number" in ds
393
+ else ds.attrs.get("serial_number", "?")
394
+ )
395
+ depth = f"{ds['InstrDepth'].item():.0f} m" if "InstrDepth" in ds else "?"
396
+ title = f"{instr_type.title()} s/n: {serial} | Target depth: {depth}"
397
+ if title_suffix:
398
+ title += f" [{title_suffix}]"
399
+ axs[0].set_title(title)
400
+
401
+ axs[-1].set_xlabel("Time")
402
+ loc = mdates.AutoDateLocator()
403
+ axs[-1].xaxis.set_major_locator(loc)
404
+ axs[-1].xaxis.set_major_formatter(mdates.ConciseDateFormatter(loc))
405
+ plt.tight_layout()
406
+ return fig
407
+
408
+
409
+ def _make_instrument_fig(
410
+ nc_path: Path, instr_type: str, show_qc: bool = True
411
+ ) -> Optional[str]:
412
+ """Data time series with optional QC markers. Returns base64 PNG or None."""
413
+ try:
414
+ import matplotlib.pyplot as plt
415
+ import xarray as xr
416
+
417
+ ds = xr.open_dataset(nc_path, decode_timedelta=False).load()
418
+ try:
419
+ fig = _build_fig_from_ds(ds, instr_type, show_qc=show_qc)
420
+ if fig is None:
421
+ return None
422
+ b64 = _fig_to_base64(fig)
423
+ plt.close(fig)
424
+ return b64
425
+ finally:
426
+ ds.close()
427
+ except Exception:
428
+ return None
429
+
430
+
431
+ # ---------------------------------------------------------------------------
432
+ # Start / end window figure
433
+ # ---------------------------------------------------------------------------
434
+
435
+
436
+ def _make_windows_fig(
437
+ nc_path: Path,
438
+ instr_type: str,
439
+ hours: int = 6,
440
+ show_qc: bool = True,
441
+ vlines: Optional[list] = None,
442
+ stage1_nc: Optional[Path] = None,
443
+ ) -> Optional[str]:
444
+ """Combined start + end window figure: (nrows × 2) — left = first N h, right = last N h.
445
+
446
+ Parameters
447
+ ----------
448
+ nc_path : Path
449
+ Path to the processed (stage2 or stage3) NetCDF file.
450
+ instr_type : str
451
+ Instrument type string (used in the figure title).
452
+ hours : int
453
+ Width of each window in hours (default 6).
454
+ show_qc : bool
455
+ Overlay QC flag markers on the data.
456
+ vlines : list of (time_val, color, label), optional
457
+ Vertical marker lines to draw on both panels. *time_val* may be a
458
+ ``numpy.datetime64``, an ISO-8601 string, or a ``pandas.Timestamp``.
459
+ Lines are only drawn when they fall inside the plotted window.
460
+ Labels appear as small rotated text at the top of the **first row** only
461
+ to avoid excessive clutter.
462
+ stage1_nc : Path, optional
463
+ Path to the stage1 NC file. When provided, the raw stage1 data is
464
+ plotted as a light-grey background trace in each window panel so the
465
+ full pre/post-deployment record is visible (bench → deployment in the
466
+ left panel; deployment → recovery in the right panel). The x-axis
467
+ limits are taken from the stage1 window extent so that the recovery
468
+ transition appears even if the stage2/3 YAML trim cut it off. The
469
+ y-axis limits are taken from the primary (stage2/3) data only so that
470
+ bench-pressure outliers (p ≈ 0) do not squish the deployment-depth view.
471
+
472
+ """
473
+ try:
474
+ import matplotlib.pyplot as plt
475
+ import matplotlib.dates as mdates
476
+ from matplotlib.gridspec import GridSpec
477
+ import xarray as xr
478
+ from .. import parameters as P
479
+
480
+ ds = xr.open_dataset(nc_path, decode_timedelta=False).load()
481
+ # Stage1 background data (optional). Closed in the finally block.
482
+ # Masks are computed later, after the stage2/3 x-axis bounds are known,
483
+ # so the grey trace is selected by the same window as the coloured data.
484
+ ds1 = None
485
+ time1 = None
486
+ start_mask1: Optional["np.ndarray"] = None
487
+ end_mask1: Optional["np.ndarray"] = None
488
+ if stage1_nc is not None:
489
+ try:
490
+ ds1 = xr.open_dataset(stage1_nc, decode_timedelta=False).load()
491
+ time1 = ds1["time"].values
492
+ except Exception:
493
+ ds1 = None
494
+ try:
495
+ time = ds["time"].values
496
+ if len(time) < 2:
497
+ return None
498
+
499
+ start_mask = time <= time[0] + np.timedelta64(hours * 3600, "s")
500
+ end_mask = time >= time[-1] - np.timedelta64(hours * 3600, "s")
501
+ if start_mask.sum() < 2 and end_mask.sum() < 2:
502
+ return None
503
+ # One sample interval used to expand x-axis limits (stage2/3 fallback).
504
+ _dt_one = (time[1] - time[0]) if len(time) > 1 else np.timedelta64(300, "s")
505
+
506
+ panels = _instrument_panels(ds, combine_pitch_roll=True)
507
+ if not panels:
508
+ return None
509
+
510
+ height_ratios = [
511
+ _COMPACT_PANEL_HEIGHT if vname in _COMPACT_PANEL_VARS else 3.0
512
+ for vname, *_ in panels
513
+ ]
514
+ nrows = len(panels)
515
+ plt.style.use(str(P.MPLSTYLE))
516
+ fig = plt.figure(figsize=(13, sum(height_ratios)), constrained_layout=True)
517
+ gs = GridSpec(
518
+ nrows,
519
+ 2,
520
+ figure=fig,
521
+ height_ratios=height_ratios,
522
+ wspace=0.06,
523
+ hspace=0.18,
524
+ )
525
+
526
+ def _plot_panel( # noqa: ANN202
527
+ ax: "plt.Axes",
528
+ vname: str,
529
+ label: str,
530
+ color: str,
531
+ invert: bool,
532
+ mask: "np.ndarray",
533
+ col: str,
534
+ ) -> None:
535
+ _suspect_t = float(ds.attrs.get("tilt_suspect_threshold", 20.0))
536
+ _fail_t = float(ds.attrs.get("tilt_fail_threshold", 30.0))
537
+
538
+ if vname == "_pitch_roll_combo":
539
+ t = time[mask]
540
+ if len(t) < 2:
541
+ ax.set_visible(False)
542
+ return
543
+ if "pitch" in ds.data_vars:
544
+ _p = ds["pitch"].values.astype(float)[mask]
545
+ ax.plot(
546
+ t, _p, color="tab:purple", lw=0.6, label="pitch", zorder=1
547
+ )
548
+ ax.scatter(
549
+ t, _p, s=3, color="tab:purple", zorder=2, linewidths=0
550
+ )
551
+ if "roll" in ds.data_vars:
552
+ _r = ds["roll"].values.astype(float)[mask]
553
+ ax.plot(t, _r, color="#8B4513", lw=0.6, label="roll", zorder=1)
554
+ ax.scatter(t, _r, s=3, color="#8B4513", zorder=2, linewidths=0)
555
+ for _val, _c, _ls in [
556
+ (_suspect_t, "tab:orange", "--"),
557
+ (-_suspect_t, "tab:orange", "--"),
558
+ (_fail_t, "tab:red", ":"),
559
+ (-_fail_t, "tab:red", ":"),
560
+ ]:
561
+ ax.axhline(_val, color=_c, lw=0.8, ls=_ls, zorder=0)
562
+ if col == 0:
563
+ ax.set_ylabel(label)
564
+ ax.legend(loc="upper right", framealpha=0.8)
565
+ else:
566
+ ax.tick_params(labelleft=False)
567
+ loc = mdates.AutoDateLocator()
568
+ ax.xaxis.set_major_locator(loc)
569
+ ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(loc))
570
+ ax.tick_params(axis="x")
571
+ return
572
+
573
+ data = ds[vname].values.astype(float)
574
+ t, d = time[mask], data[mask]
575
+ if len(t) < 2:
576
+ ax.set_visible(False)
577
+ return
578
+ ax.plot(t, d, color=color, linewidth=0.6, zorder=1)
579
+ ax.scatter(t, d, s=3, color=color, zorder=2, linewidths=0)
580
+ if "velocity" in vname and not invert:
581
+ ax.axhline(0, color="k", linewidth=0.4, linestyle="--", zorder=0)
582
+ if vname == "tilt":
583
+ ax.axhline(
584
+ _suspect_t, color="tab:orange", lw=0.9, ls="--", zorder=2
585
+ )
586
+ ax.axhline(_fail_t, color="tab:red", lw=0.9, ls="--", zorder=2)
587
+ # ylim for inverted variables is set from the outer loop using the
588
+ # combined range of both panels, so we do not set it here.
589
+ if show_qc and f"{vname}_qc" in ds.data_vars:
590
+ flags = ds[f"{vname}_qc"].values.astype(int)[mask]
591
+ for fval, mkw in _QC_MARKER.items():
592
+ m2 = flags == fval
593
+ if m2.any():
594
+ ax.scatter(t[m2], d[m2], **mkw)
595
+ if col == 0:
596
+ ax.set_ylabel(label)
597
+ else:
598
+ ax.tick_params(labelleft=False)
599
+ loc = mdates.AutoDateLocator()
600
+ ax.xaxis.set_major_locator(loc)
601
+ ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(loc))
602
+ ax.tick_params(axis="x")
603
+
604
+ # Normalise vlines to numpy datetime64[ns] upfront.
605
+ # Use pandas.Timestamp as an intermediate because it handles Python
606
+ # datetime objects, ISO strings, and pandas.Timestamp objects uniformly.
607
+ import pandas as _pd
608
+
609
+ _vlines_ns: list = []
610
+ for _vt, _vc, _vl in vlines or []:
611
+ try:
612
+ _ts = _pd.Timestamp(_vt)
613
+ if _ts.tzinfo is not None:
614
+ _ts = _ts.tz_convert("UTC").tz_localize(None)
615
+ _vlines_ns.append((_ts.to_datetime64(), _vc, _vl))
616
+ except Exception:
617
+ pass
618
+
619
+ def _draw_vlines(
620
+ ax: "plt.Axes",
621
+ t_lo: "np.datetime64",
622
+ t_hi: "np.datetime64",
623
+ first_row: bool,
624
+ ) -> None:
625
+ """Draw vertical marker lines that fall inside [t_lo, t_hi]."""
626
+ for _vt, _vc, _vl in _vlines_ns:
627
+ if _vt < t_lo or _vt > t_hi:
628
+ continue
629
+ ax.axvline(_vt, color=_vc, lw=1.2, ls="--", zorder=-1)
630
+ if first_row:
631
+ ax.text(
632
+ _vt,
633
+ 1.0,
634
+ f" {_vl}",
635
+ transform=ax.get_xaxis_transform(),
636
+ rotation=90,
637
+ va="bottom",
638
+ ha="left",
639
+ fontsize=7,
640
+ color=_vc,
641
+ zorder=5,
642
+ clip_on=False,
643
+ )
644
+
645
+ # X-axis bounds: always based on stage2/3 data. Add 1 h of padding
646
+ # before the stage2/3 start (left panel) and after the stage2/3 end
647
+ # (right panel) so that any stage1 grey data in those margins is visible.
648
+ # This makes each window effectively 7 h wide (1 h pad + 6 h of record).
649
+ _one_hour = np.timedelta64(3600, "s")
650
+ _t_start_lo = time[0] - _one_hour
651
+ _t_start_hi = time[start_mask][-1] if start_mask.any() else time[0]
652
+ _t_end_lo = time[end_mask][0] if end_mask.any() else time[-1]
653
+ _t_end_hi = time[-1] + _one_hour
654
+
655
+ # Stage1 masks: select stage1 samples within the expanded x windows so the
656
+ # grey trace fills the 1 h margins but never triggers axes auto-rescaling.
657
+ if time1 is not None:
658
+ start_mask1 = (time1 >= _t_start_lo) & (time1 <= _t_start_hi)
659
+ end_mask1 = (time1 >= _t_end_lo) & (time1 <= _t_end_hi)
660
+
661
+ def _plot_grey( # noqa: ANN202
662
+ ax: "plt.Axes",
663
+ vname: str,
664
+ mask: "np.ndarray",
665
+ ) -> None:
666
+ """Plot stage1 reference data as light-grey background trace.
667
+
668
+ Skipped when the physical units differ between stage1 and stage2/3
669
+ (e.g. conductivity in mS/cm in stage1 vs S/m after normalisation)
670
+ because the values would be on an incompatible scale.
671
+
672
+ ``_pitch_roll_combo`` is a synthetic panel name; pitch and roll are
673
+ read directly from ds1 by their real variable names.
674
+ """
675
+ if ds1 is None or time1 is None:
676
+ return
677
+ if vname == "_pitch_roll_combo":
678
+ for _v in ("pitch", "roll"):
679
+ if _v not in ds1.data_vars:
680
+ continue
681
+ _t1 = time1[mask]
682
+ _d1 = ds1[_v].values.astype(float)[mask]
683
+ if len(_t1) < 2:
684
+ continue
685
+ ax.plot(_t1, _d1, color="#cccccc", lw=0.5, zorder=0)
686
+ ax.scatter(
687
+ _t1, _d1, s=2, color="#cccccc", zorder=0, linewidths=0
688
+ )
689
+ return
690
+ if vname not in ds1.data_vars:
691
+ return
692
+ _u1 = ds1[vname].attrs.get("units", "").strip()
693
+ _u2 = ds[vname].attrs.get("units", "").strip()
694
+ if _u1 and _u2 and _u1 != _u2:
695
+ return
696
+ data1 = ds1[vname].values.astype(float)
697
+ t1, d1 = time1[mask], data1[mask]
698
+ if len(t1) < 2:
699
+ return
700
+ ax.plot(t1, d1, color="#cccccc", lw=0.5, zorder=0)
701
+ ax.scatter(t1, d1, s=2, color="#cccccc", zorder=0, linewidths=0)
702
+
703
+ for row_i, (vname, label, color, invert) in enumerate(panels):
704
+ ax_l = fig.add_subplot(gs[row_i, 0])
705
+ ax_r = fig.add_subplot(gs[row_i, 1], sharey=ax_l)
706
+ # Grey stage1 background drawn before the coloured primary data.
707
+ if start_mask1 is not None:
708
+ _plot_grey(ax_l, vname, start_mask1)
709
+ if end_mask1 is not None:
710
+ _plot_grey(ax_r, vname, end_mask1)
711
+ _plot_panel(ax_l, vname, label, color, invert, start_mask, 0)
712
+ _plot_panel(ax_r, vname, label, color, invert, end_mask, 1)
713
+ # For inverted variables (pressure), set the shared y-axis from the
714
+ # combined range of both panels so neither panel clips the other's data.
715
+ # Use stage2/3 data only — stage1 bench pressure (≈ 0) must not expand
716
+ # the y-axis and squish the deployment-depth view.
717
+ if invert and vname in ds.data_vars:
718
+ _d_both = np.concatenate(
719
+ [
720
+ ds[vname].values.astype(float)[start_mask],
721
+ ds[vname].values.astype(float)[end_mask],
722
+ ]
723
+ )
724
+ _d_both = _d_both[np.isfinite(_d_both)]
725
+ if _d_both.size:
726
+ _lo_c, _hi_c = float(_d_both.min()), float(_d_both.max())
727
+ _pad_c = max((_hi_c - _lo_c) * 0.1, 0.5)
728
+ ax_l.set_ylim(_hi_c + _pad_c, _lo_c - _pad_c)
729
+ # Explicit x-axis limits (from stage1 if available, else stage2/3).
730
+ ax_l.set_xlim(_t_start_lo, _t_start_hi)
731
+ ax_r.set_xlim(_t_end_lo, _t_end_hi)
732
+ _draw_vlines(ax_l, _t_start_lo, _t_start_hi, row_i == 0)
733
+ _draw_vlines(ax_r, _t_end_lo, _t_end_hi, row_i == 0)
734
+ ax_r.yaxis.tick_right()
735
+ ax_r.yaxis.set_label_position("right")
736
+ if row_i == 0:
737
+ ax_l.set_title(f"First {hours} h")
738
+ ax_r.set_title(f"Last {hours} h")
739
+
740
+ serial = (
741
+ ds["serial_number"].item()
742
+ if "serial_number" in ds
743
+ else ds.attrs.get("serial_number", "?")
744
+ )
745
+ fig.suptitle(
746
+ f"{instr_type.title()} s/n {serial} — deployment start / end",
747
+ )
748
+ b64 = _fig_to_base64(fig)
749
+ plt.close(fig)
750
+ return b64
751
+ finally:
752
+ ds.close()
753
+ if ds1 is not None:
754
+ ds1.close()
755
+ except Exception:
756
+ return None
757
+
758
+
759
+ # ---------------------------------------------------------------------------
760
+ # Data value histogram
761
+ # ---------------------------------------------------------------------------
762
+
763
+
764
+ def _make_data_histogram(nc_path: Path) -> Optional[str]:
765
+ """Histogram of data values for each main variable, with QC range threshold lines."""
766
+ try:
767
+ import matplotlib.pyplot as plt
768
+ import xarray as xr
769
+ from .. import parameters as P
770
+
771
+ plt.style.use(str(P.MPLSTYLE))
772
+ ds = xr.open_dataset(nc_path, decode_timedelta=False).load()
773
+
774
+ panels = _instrument_panels(ds)
775
+ _HIST_EXCLUDE = {"battery_voltage"}
776
+ plot_panels: list = [
777
+ (vn, lbl, False)
778
+ for vn, lbl, *_ in panels
779
+ if ds[vn].dims == ("time",) and vn not in _HIST_EXCLUDE
780
+ ]
781
+ # ADCP: append multi-dim velocity and percent_good panels (flattened).
782
+ _ADCP_FLAT = [
783
+ ("east_velocity", "East vel. (all bins, m s⁻¹)"),
784
+ ("north_velocity", "North vel. (all bins, m s⁻¹)"),
785
+ ("up_velocity", "Up vel. (all bins, m s⁻¹)"),
786
+ ("error_velocity", "Error vel. (all bins, m s⁻¹)"),
787
+ ("percent_good", "Pct good (all bins/beams, %)"),
788
+ ]
789
+ for vn, lbl in _ADCP_FLAT:
790
+ if vn in ds.data_vars and ds[vn].values.ndim > 1:
791
+ plot_panels.append((vn, lbl, True)) # True = flatten
792
+
793
+ if not plot_panels:
794
+ ds.close()
795
+ return None
796
+
797
+ import math
798
+
799
+ ncols = 3
800
+ nrows = math.ceil(len(plot_panels) / ncols)
801
+ fig, axs_grid = plt.subplots(
802
+ nrows,
803
+ ncols,
804
+ figsize=(ncols * 4.5, 2.5 * nrows),
805
+ squeeze=False,
806
+ )
807
+ axs = axs_grid.ravel()
808
+ for k in range(len(plot_panels), len(axs)):
809
+ axs[k].set_visible(False)
810
+
811
+ for ax, (vname, ylabel, flatten) in zip(axs, plot_panels):
812
+ data = (
813
+ ds[vname].values.astype(float).ravel()
814
+ if flatten
815
+ else ds[vname].values.astype(float)
816
+ )
817
+
818
+ qc_var = f"{vname}_qc"
819
+ has_qc = qc_var in ds
820
+ if has_qc:
821
+ flags = (
822
+ ds[qc_var].values.astype(int).ravel()
823
+ if flatten
824
+ else ds[qc_var].values.astype(int)
825
+ )
826
+ kept_mask = np.isfinite(data) & ~np.isin(flags, [4, 9])
827
+ else:
828
+ kept_mask = np.isfinite(data)
829
+ all_mask = np.isfinite(data)
830
+
831
+ # percent_good has no _qc variable; annotate with ADCP thresholds
832
+ pg_thresholds = None
833
+ if vname == "percent_good":
834
+ from .. import parameters as _P
835
+
836
+ pg_thresholds = (
837
+ _P.QC_ADCP["percent_good_bad"],
838
+ _P.QC_ADCP["percent_good_suspect"],
839
+ )
840
+ # Treat bins below bad threshold as "removed" for the histogram
841
+ kept_mask = all_mask & (data >= _P.QC_ADCP["percent_good_bad"])
842
+
843
+ all_data = data[all_mask]
844
+ kept_data = data[kept_mask]
845
+ if len(all_data) == 0:
846
+ ax.set_ylabel(ylabel)
847
+ ax.text(
848
+ 0.5,
849
+ 0.5,
850
+ "no data",
851
+ transform=ax.transAxes,
852
+ ha="center",
853
+ va="center",
854
+ color="#999",
855
+ )
856
+ continue
857
+
858
+ # Compute shared bin edges from ALL data so both histograms are comparable
859
+ if vname == "heading":
860
+ bin_edges = np.linspace(0.0, 360.0, 81)
861
+ elif vname == "percent_good":
862
+ bin_edges = np.linspace(0.0, 100.0, 81)
863
+ elif "velocity" in vname:
864
+ _half = max(
865
+ abs(float(all_data.min())), abs(float(all_data.max())), 1e-4
866
+ )
867
+ bin_edges = np.linspace(-_half, _half, 81)
868
+ else:
869
+ bin_edges = 80
870
+
871
+ # Grey: all finite data; blue: kept (not bad/missing)
872
+ ax.hist(
873
+ all_data,
874
+ bins=bin_edges,
875
+ color="#aaaaaa",
876
+ alpha=0.6,
877
+ edgecolor="none",
878
+ zorder=1,
879
+ label="all",
880
+ )
881
+ if len(kept_data) > 0:
882
+ ax.hist(
883
+ kept_data,
884
+ bins=bin_edges,
885
+ color="#2980b9",
886
+ alpha=0.85,
887
+ edgecolor="none",
888
+ zorder=2,
889
+ label="kept",
890
+ )
891
+ ax.set_yscale("log")
892
+ ax.set_ylabel(f"{ylabel}\n(log count)")
893
+
894
+ s_min = s_max = f_min = f_max = None
895
+ if has_qc:
896
+ qattrs = ds[qc_var].attrs
897
+ s_min = qattrs.get("qc_gross_range_suspect_min")
898
+ s_max = qattrs.get("qc_gross_range_suspect_max")
899
+ f_min = qattrs.get("qc_gross_range_fail_min")
900
+ f_max = qattrs.get("qc_gross_range_fail_max")
901
+
902
+ all_lo, all_hi = float(all_data.min()), float(all_data.max())
903
+ pad = max(0.03 * (all_hi - all_lo), 1e-6)
904
+ xlim_lo = (
905
+ max(float(f_min), all_lo - pad) if f_min is not None else all_lo - pad
906
+ )
907
+ xlim_hi = (
908
+ min(float(f_max), all_hi + pad) if f_max is not None else all_hi + pad
909
+ )
910
+ ax.set_xlim(xlim_lo, xlim_hi)
911
+
912
+ if vname == "heading":
913
+ ax.set_xlim(0.0, 360.0)
914
+ elif vname == "percent_good":
915
+ ax.set_xlim(0.0, 100.0)
916
+ elif "velocity" in vname:
917
+ _half = max(abs(all_lo), abs(all_hi), 1e-6)
918
+ ax.set_xlim(-_half, _half)
919
+
920
+ legend_handles, legend_labels = [], []
921
+ threshold_lines = [
922
+ (s_min, "#f39c12", "--", f"suspect min ({s_min})"),
923
+ (s_max, "#f39c12", "--", f"suspect max ({s_max})"),
924
+ (f_min, "#e74c3c", ":", f"fail min ({f_min})"),
925
+ (f_max, "#e74c3c", ":", f"fail max ({f_max})"),
926
+ ]
927
+ if pg_thresholds is not None:
928
+ pg_bad, pg_susp = pg_thresholds
929
+ threshold_lines += [
930
+ (pg_bad, "#e74c3c", ":", f"bad < {pg_bad}%"),
931
+ (pg_susp, "#f39c12", "--", f"suspect < {pg_susp}%"),
932
+ ]
933
+ for xv, col, ls, lbl in threshold_lines:
934
+ if xv is not None and xlim_lo <= float(xv) <= xlim_hi:
935
+ line = ax.axvline(
936
+ float(xv), color=col, linewidth=1.2, linestyle=ls, zorder=3
937
+ )
938
+ legend_handles.append(line)
939
+ legend_labels.append(lbl)
940
+ if legend_handles:
941
+ ax.legend(
942
+ legend_handles,
943
+ legend_labels,
944
+ loc="upper right",
945
+ ncol=2,
946
+ framealpha=0.8,
947
+ )
948
+
949
+ n_removed = int(np.sum(all_mask)) - int(np.sum(kept_mask))
950
+ if n_removed > 0:
951
+ ax.text(
952
+ 0.98,
953
+ 0.96,
954
+ f"{n_removed} removed (grey)",
955
+ transform=ax.transAxes,
956
+ ha="right",
957
+ va="top",
958
+ color="#e74c3c",
959
+ )
960
+
961
+ for ax in axs_grid[-1]:
962
+ if ax.get_visible():
963
+ ax.set_xlabel("Value")
964
+ fig.suptitle("Data value distributions (grey = all, blue = kept)", y=1.01)
965
+ plt.tight_layout()
966
+ b64 = _fig_to_base64(fig)
967
+ plt.close(fig)
968
+ ds.close()
969
+ return b64
970
+ except Exception:
971
+ return None
972
+
973
+
974
+ # ---------------------------------------------------------------------------
975
+ # T-S helpers
976
+ # ---------------------------------------------------------------------------
977
+
978
+
979
+ def _add_sigma0_contours(
980
+ ax: "plt.Axes", S_data: "np.ndarray", T_data: "np.ndarray", n_grid: int = 200
981
+ ) -> None:
982
+ """Overlay sigma-0 contour lines on a T-S axes."""
983
+ try:
984
+ import gsw
985
+
986
+ s_min, s_max = np.nanmin(S_data), np.nanmax(S_data)
987
+ t_min, t_max = np.nanmin(T_data), np.nanmax(T_data)
988
+ s_pad = max((s_max - s_min) * 0.05, 0.05)
989
+ t_pad = max((t_max - t_min) * 0.05, 0.05)
990
+ s_c = np.linspace(s_min - s_pad, s_max + s_pad, n_grid)
991
+ t_c = np.linspace(t_min - t_pad, t_max + t_pad, n_grid)
992
+ Sg, Tg = np.meshgrid(s_c, t_c)
993
+ SA = gsw.SA_from_SP(Sg, 0.0, 0.0, 0.0)
994
+ CT = gsw.CT_from_t(SA, Tg, 0.0)
995
+ sigma0 = gsw.sigma0(SA, CT)
996
+ cs = ax.contour(
997
+ Sg,
998
+ Tg,
999
+ sigma0,
1000
+ levels=8,
1001
+ colors="0.35",
1002
+ linewidths=0.6,
1003
+ linestyles="--",
1004
+ zorder=1,
1005
+ )
1006
+ ax.clabel(cs, fmt="%.1f", fontsize=7, inline=True)
1007
+ except Exception:
1008
+ pass
1009
+
1010
+
1011
+ def _ts_heatmap_panel(
1012
+ ax: "plt.Axes",
1013
+ fig: "plt.Figure",
1014
+ S: np.ndarray,
1015
+ T: np.ndarray,
1016
+ n_bins: int = 80,
1017
+ plo: float = 0.01,
1018
+ phi: float = 99.99,
1019
+ ) -> None:
1020
+ """Render a T-S 2-D count heatmap on *ax*."""
1021
+ import matplotlib.colors as mcolors
1022
+ import matplotlib.pyplot as plt
1023
+
1024
+ s_lo, s_hi = float(np.nanpercentile(S, plo)), float(np.nanpercentile(S, phi))
1025
+ t_lo, t_hi = float(np.nanpercentile(T, plo)), float(np.nanpercentile(T, phi))
1026
+ s_edges = np.linspace(s_lo, s_hi, n_bins + 1)
1027
+ t_edges = np.linspace(t_lo, t_hi, n_bins + 1)
1028
+ counts, _, _ = np.histogram2d(S, T, bins=[s_edges, t_edges])
1029
+ log_counts = np.log10(counts.T + 1)
1030
+ log_counts = np.ma.masked_where(counts.T == 0, log_counts)
1031
+
1032
+ vmin = float(np.nanmin(log_counts))
1033
+ vmax = float(np.nanmax(log_counts))
1034
+ bounds = _nice_colorbar_bounds(vmin, vmax, n=20)
1035
+ norm = mcolors.BoundaryNorm(bounds, ncolors=256)
1036
+ cmap = plt.cm.YlOrRd.copy()
1037
+ cmap.set_bad("white")
1038
+ pc = ax.pcolormesh(
1039
+ s_edges, t_edges, log_counts, cmap=cmap, norm=norm, shading="flat"
1040
+ )
1041
+ cb = fig.colorbar(pc, ax=ax, pad=0.02, ticks=bounds)
1042
+ cb.set_label("log₁₀(count + 1)")
1043
+ _add_sigma0_contours(ax, S, T)
1044
+ ax.set_xlim(s_lo, s_hi)
1045
+ ax.set_ylim(t_lo, t_hi)
1046
+ ax.set_xlabel("Practical salinity")
1047
+ ax.set_ylabel("Temperature (°C)")
1048
+ ax.set_title("T-S heat map")
1049
+
1050
+
1051
+ def _make_ts_diagram(nc_path: Path) -> Optional[str]:
1052
+ """T-S diagram: scatter by pressure, 2-D count heatmap, and (when present) scatter by O2 saturation."""
1053
+ try:
1054
+ import matplotlib.colors as mcolors
1055
+ import matplotlib.pyplot as plt
1056
+ import xarray as xr
1057
+ from .. import parameters as P
1058
+ from ..utilities import _nice_colorbar_bounds
1059
+
1060
+ plt.style.use(str(P.MPLSTYLE))
1061
+ ds = xr.open_dataset(nc_path, decode_timedelta=False).load()
1062
+
1063
+ if "temperature" not in ds.data_vars or "salinity" not in ds.data_vars:
1064
+ ds.close()
1065
+ return None
1066
+
1067
+ T = ds["temperature"].values.astype(float)
1068
+ S = ds["salinity"].values.astype(float)
1069
+ finite = np.isfinite(T) & np.isfinite(S)
1070
+ if finite.sum() < 5:
1071
+ ds.close()
1072
+ return None
1073
+
1074
+ if "pressure" in ds.data_vars:
1075
+ C = ds["pressure"].values.astype(float)
1076
+ cbar_label = "Pressure (dbar)"
1077
+ cmap_sc = "viridis_r"
1078
+ else:
1079
+ C = np.arange(len(T), dtype=float)
1080
+ cbar_label = "Sample index"
1081
+ cmap_sc = "plasma"
1082
+
1083
+ t_flags = (
1084
+ ds["temperature_qc"].values.astype(int)
1085
+ if "temperature_qc" in ds
1086
+ else np.ones(len(T), dtype=int)
1087
+ )
1088
+ s_flags = (
1089
+ ds["salinity_qc"].values.astype(int)
1090
+ if "salinity_qc" in ds
1091
+ else np.ones(len(T), dtype=int)
1092
+ )
1093
+ combined_flags = np.where(
1094
+ np.maximum(t_flags, s_flags) >= 4,
1095
+ 4,
1096
+ np.where(np.maximum(t_flags, s_flags) == 3, 3, 1),
1097
+ )
1098
+ good_mask = finite & (combined_flags == 1)
1099
+ suspect_mask = finite & (combined_flags == 3)
1100
+ bad_mask = finite & (combined_flags == 4)
1101
+
1102
+ sal_units = ds["salinity"].attrs.get("units", "PSU")
1103
+ tmp_units = ds["temperature"].attrs.get("units", "°C")
1104
+
1105
+ has_sat = "oxygen_saturation_pct" in ds.data_vars
1106
+ sat_data = ds["oxygen_saturation_pct"].values.astype(float) if has_sat else None
1107
+
1108
+ ncols = 3 if has_sat else 2
1109
+ fig, axes = plt.subplots(
1110
+ 1, ncols, figsize=(5.5 * ncols, 4.5), constrained_layout=True
1111
+ )
1112
+ ax_l, ax_r = axes[0], axes[1]
1113
+ ax_sat = axes[2] if has_sat else None
1114
+
1115
+ # Panel 1: T-S scatter coloured by pressure
1116
+ vmin = np.nanpercentile(C[finite], 5)
1117
+ vmax = np.nanpercentile(C[finite], 95)
1118
+ sc = ax_l.scatter(
1119
+ S[good_mask],
1120
+ T[good_mask],
1121
+ c=C[good_mask],
1122
+ cmap=cmap_sc,
1123
+ vmin=vmin,
1124
+ vmax=vmax,
1125
+ s=4,
1126
+ linewidths=0,
1127
+ alpha=0.6,
1128
+ zorder=2,
1129
+ rasterized=True,
1130
+ )
1131
+ fig.colorbar(sc, ax=ax_l, label=cbar_label, fraction=0.046, pad=0.04)
1132
+ if suspect_mask.any():
1133
+ ax_l.scatter(
1134
+ S[suspect_mask],
1135
+ T[suspect_mask],
1136
+ label=f"suspect ({suspect_mask.sum()})",
1137
+ **_QC_MARKER[3],
1138
+ )
1139
+ if bad_mask.any():
1140
+ ax_l.scatter(
1141
+ S[bad_mask],
1142
+ T[bad_mask],
1143
+ label=f"bad ({bad_mask.sum()})",
1144
+ **_QC_MARKER[4],
1145
+ )
1146
+ if suspect_mask.any() or bad_mask.any():
1147
+ ax_l.legend(loc="best", framealpha=0.8)
1148
+ _add_sigma0_contours(ax_l, S[finite], T[finite])
1149
+ ax_l.set_xlabel(f"Salinity ({sal_units})")
1150
+ ax_l.set_ylabel(f"Temperature ({tmp_units})")
1151
+ ax_l.set_title("T-S (colour = pressure)")
1152
+
1153
+ # Panel 2: count heatmap
1154
+ _ts_heatmap_panel(ax_r, fig, S[finite], T[finite])
1155
+
1156
+ # Panel 3: T-S scatter coloured by O2 saturation (when available)
1157
+ if ax_sat is not None and sat_data is not None:
1158
+ sat_finite = good_mask & np.isfinite(sat_data)
1159
+ if sat_finite.any():
1160
+ sv = sat_data[sat_finite]
1161
+ bounds_s = _nice_colorbar_bounds(
1162
+ float(np.nanpercentile(sv, 2)),
1163
+ float(np.nanpercentile(sv, 98)),
1164
+ n=11,
1165
+ )
1166
+ norm_s = mcolors.BoundaryNorm(bounds_s, ncolors=256)
1167
+ sc_s = ax_sat.scatter(
1168
+ S[sat_finite],
1169
+ T[sat_finite],
1170
+ c=sv,
1171
+ cmap="BrBG",
1172
+ norm=norm_s,
1173
+ s=4,
1174
+ linewidths=0,
1175
+ alpha=0.6,
1176
+ zorder=2,
1177
+ rasterized=True,
1178
+ )
1179
+ cb_s = fig.colorbar(sc_s, ax=ax_sat, ticks=bounds_s, pad=0.02)
1180
+ cb_s.set_label("O₂ saturation (%)")
1181
+ _add_sigma0_contours(ax_sat, S[sat_finite], T[sat_finite])
1182
+ ax_sat.set_xlabel(f"Salinity ({sal_units})")
1183
+ ax_sat.set_ylabel(f"Temperature ({tmp_units})")
1184
+ ax_sat.set_title("T-S (colour = O₂ sat.)")
1185
+
1186
+ b64 = _fig_to_base64(fig)
1187
+ plt.close(fig)
1188
+ ds.close()
1189
+ return b64
1190
+ except Exception:
1191
+ return None
1192
+
1193
+
1194
+ # ---------------------------------------------------------------------------
1195
+ # Grid and spectrum figures
1196
+ # ---------------------------------------------------------------------------
1197
+
1198
+
1199
+ def _make_spectrum_fig_b64(
1200
+ da_temp: "xr.DataArray",
1201
+ dt_seconds: float,
1202
+ lat: float = 0.0,
1203
+ ) -> Optional[str]:
1204
+ """Welch PSD of gridded temperature, one line per depth level coloured by pressure."""
1205
+ try:
1206
+ import matplotlib.pyplot as plt
1207
+ import matplotlib.colors as mcolors
1208
+ from matplotlib.transforms import blended_transform_factory
1209
+ import numpy as np
1210
+ from scipy import signal as _signal
1211
+
1212
+ def welch_psd(
1213
+ x: "np.ndarray",
1214
+ dt_days: float,
1215
+ segment_length: int,
1216
+ overlap: float = 0.5,
1217
+ window: str = "hann",
1218
+ ) -> "tuple[np.ndarray, np.ndarray]":
1219
+ fs = 1.0 / dt_days
1220
+ noverlap = int(round(overlap * segment_length))
1221
+ f, p = _signal.welch(
1222
+ x,
1223
+ fs=fs,
1224
+ window=window,
1225
+ nperseg=segment_length,
1226
+ noverlap=noverlap,
1227
+ detrend="linear",
1228
+ scaling="density",
1229
+ )
1230
+ return f, p
1231
+
1232
+ from .. import parameters as P
1233
+
1234
+ if da_temp.dims[0] != "pressure":
1235
+ da_temp = da_temp.transpose("pressure", ...)
1236
+ arr = da_temp.values
1237
+ if "pressure" in da_temp.coords:
1238
+ press_vals = da_temp.coords["pressure"].values.astype(float)
1239
+ else:
1240
+ press_vals = np.arange(arr.shape[0], dtype=float)
1241
+
1242
+ n_lev, n_time = arr.shape
1243
+ dt_days = dt_seconds / 86400.0
1244
+
1245
+ seg_14d = max(128, int(14.0 / dt_days))
1246
+ segment_length = min(seg_14d, max(n_time // 4, 128))
1247
+
1248
+ # Select at most 8 evenly-spaced valid levels
1249
+ _MAX_LEVELS = 8
1250
+ valid_lev_idx = [
1251
+ k for k in range(n_lev) if np.sum(np.isfinite(arr[k, :])) >= segment_length
1252
+ ]
1253
+ if len(valid_lev_idx) > _MAX_LEVELS:
1254
+ sel = np.linspace(0, len(valid_lev_idx) - 1, _MAX_LEVELS, dtype=int)
1255
+ valid_lev_idx = [valid_lev_idx[i] for i in sel]
1256
+
1257
+ freq_out = None
1258
+ psds, press_plotted = [], []
1259
+ for k in valid_lev_idx:
1260
+ col = arr[k, :].copy()
1261
+ good = np.isfinite(col)
1262
+ if not good.all():
1263
+ col = np.interp(np.arange(n_time), np.where(good)[0], col[good])
1264
+ freq, psd = welch_psd(col, dt_days, segment_length=segment_length)
1265
+ if freq_out is None:
1266
+ freq_out = freq
1267
+ psds.append(psd)
1268
+ press_plotted.append(press_vals[k])
1269
+
1270
+ if freq_out is None or not psds:
1271
+ return None
1272
+
1273
+ markers = [
1274
+ ("M2", 1.0 / 1.9323, "#c0392b"),
1275
+ ("K1", 23.93 / 24.0, "#e67e22"),
1276
+ ("1.8d", 1.8, "#7f8c8d"),
1277
+ ("4d", 4.0, "#95a5a6"),
1278
+ ]
1279
+ if lat != 0.0:
1280
+ import gsw as _gsw
1281
+
1282
+ f_inert = abs(_gsw.f(lat))
1283
+ f_inert_cpd = f_inert * 86400.0 / (2.0 * np.pi)
1284
+ markers.append(("f", 1.0 / f_inert_cpd, "#27ae60"))
1285
+
1286
+ p_arr = np.array(press_plotted)
1287
+ p_min, p_max = p_arr.min(), p_arr.max()
1288
+ if p_min == p_max:
1289
+ p_min -= 1.0
1290
+ p_max += 1.0
1291
+ cmap = plt.get_cmap("Blues_r")
1292
+ norm = mcolors.Normalize(vmin=p_min, vmax=p_max)
1293
+
1294
+ plt.style.use(str(P.MPLSTYLE))
1295
+ fig, ax = plt.subplots(figsize=(11, 5))
1296
+
1297
+ nyq_period = 2.0 * dt_days
1298
+ x_min = nyq_period
1299
+ x_max = min(30.0, n_time * dt_days / 2.0)
1300
+
1301
+ fmask = (freq_out > 0) & (freq_out <= 1.0 / nyq_period)
1302
+ freq_plot = freq_out[fmask]
1303
+ period_plot = 1.0 / freq_plot
1304
+
1305
+ for psd, p in zip(psds, press_plotted):
1306
+ ax.loglog(period_plot, psd[fmask], color=cmap(norm(p)), lw=0.8, alpha=0.75)
1307
+
1308
+ idx_1d = np.argmin(np.abs(freq_plot - 1.0))
1309
+ median_at_1d = float(np.median([psd[fmask][idx_1d] for psd in psds]))
1310
+ if np.isfinite(median_at_1d) and median_at_1d > 0:
1311
+ ref_periods = np.array([x_min, x_max])
1312
+ ref_psd = median_at_1d * ref_periods**2
1313
+ ax.loglog(
1314
+ ref_periods,
1315
+ ref_psd,
1316
+ color="k",
1317
+ lw=0.9,
1318
+ ls="--",
1319
+ alpha=0.35,
1320
+ label="−2 slope",
1321
+ )
1322
+
1323
+ ax.set_xlim(x_max, x_min)
1324
+
1325
+ trans = blended_transform_factory(ax.transData, ax.transAxes)
1326
+ for label, period_d, color in markers:
1327
+ if x_min <= period_d <= x_max:
1328
+ ax.axvline(period_d, color=color, lw=1.0, ls="--", alpha=0.65)
1329
+ ax.text(
1330
+ period_d,
1331
+ 0.03,
1332
+ label,
1333
+ rotation=90,
1334
+ va="bottom",
1335
+ ha="center",
1336
+ color=color,
1337
+ transform=trans,
1338
+ bbox=dict(
1339
+ boxstyle="round,pad=0.1", fc="white", ec="none", alpha=0.6
1340
+ ),
1341
+ )
1342
+
1343
+ ax.set_ylim(1e-6, 1e2)
1344
+ ax.set_xlabel("Period (days)")
1345
+ ax.set_ylabel("PSD (°C² cpd⁻¹)")
1346
+ ax.set_title("Temperature power spectrum (Welch per depth level)")
1347
+ ax.legend(loc="lower left")
1348
+
1349
+ sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
1350
+ sm.set_array([])
1351
+ cbar = fig.colorbar(sm, ax=ax, pad=0.02)
1352
+ cbar.set_label("Pressure (dbar)")
1353
+
1354
+ plt.tight_layout()
1355
+ b64 = _fig_to_base64(fig)
1356
+ plt.close(fig)
1357
+ return b64
1358
+ except Exception as exc:
1359
+ print(f" WARNING: spectrum figure failed: {exc}")
1360
+ return None
1361
+
1362
+
1363
+ def _make_grid_rotary_spectrum_b64(
1364
+ ds: "xr.Dataset",
1365
+ lat: float = 0.0,
1366
+ ) -> Optional[str]:
1367
+ """Two-panel rotary velocity spectrum for the grid report.
1368
+
1369
+ Left panel: CW (solid, reds) and CCW (dashed, blues) power spectra on the same axes,
1370
+ one line per selected pressure level. Right panel: rotary coefficient
1371
+ r = (CCW - CW) / (CCW + CW) on a linear [-1, 1] scale.
1372
+ Welch PSD with Hann window, 14-day segments, 50% overlap.
1373
+
1374
+ Level selection: min(4, max(1, n_valid_levels // 5)) evenly-spaced levels
1375
+ from those with >= 5% finite data in both east and north velocity.
1376
+
1377
+ Parameters
1378
+ ----------
1379
+ ds : xr.Dataset
1380
+ Gridded dataset with ``east_velocity`` and ``north_velocity`` on
1381
+ ``(time, pressure)`` dimensions.
1382
+ lat : float
1383
+ Mooring latitude (degrees, positive north) used for the inertial period marker.
1384
+
1385
+ Returns
1386
+ -------
1387
+ str or None
1388
+ Base64-encoded PNG or None if insufficient velocity data.
1389
+
1390
+ """
1391
+ try:
1392
+ import matplotlib.pyplot as plt
1393
+ import matplotlib.colors as mcolors
1394
+ from matplotlib.lines import Line2D
1395
+ from matplotlib.transforms import blended_transform_factory
1396
+ import numpy as np
1397
+ from scipy import signal as _signal
1398
+ from .. import parameters as P
1399
+
1400
+ if "east_velocity" not in ds.data_vars or "north_velocity" not in ds.data_vars:
1401
+ return None
1402
+
1403
+ # Grid dims are (time, pressure) — transpose to (pressure, time) for indexing
1404
+ east_da = ds["east_velocity"]
1405
+ north_da = ds["north_velocity"]
1406
+ if east_da.dims[0] != "pressure":
1407
+ east_da = east_da.transpose("pressure", "time")
1408
+ north_da = north_da.transpose("pressure", "time")
1409
+
1410
+ arr_u = east_da.values.copy() # (pressure, time)
1411
+ arr_v = north_da.values.copy()
1412
+
1413
+ # Apply QC flags (≥3 → NaN)
1414
+ for _qc_var, _arr in (
1415
+ ("east_velocity_qc", arr_u),
1416
+ ("north_velocity_qc", arr_v),
1417
+ ):
1418
+ if _qc_var in ds.data_vars:
1419
+ _qc = ds[_qc_var].values
1420
+ if _qc.ndim == arr_u.ndim:
1421
+ if ds[_qc_var].dims[0] != "pressure":
1422
+ _qc = _qc.T
1423
+ _arr[_qc >= 3] = np.nan
1424
+
1425
+ press_vals = ds.coords["pressure"].values.astype(float)
1426
+ n_lev, n_time = arr_u.shape
1427
+ dt_s = float(ds.attrs.get("dt_seconds", 3600))
1428
+ dt_days = dt_s / 86400.0
1429
+
1430
+ seg_14d = max(128, int(14.0 / dt_days))
1431
+ segment_length = min(seg_14d, max(n_time // 4, 128))
1432
+ fs = 1.0 / dt_days
1433
+ noverlap = int(0.5 * segment_length)
1434
+
1435
+ # Valid levels: both u and v have ≥ segment_length finite values
1436
+ valid_lev_idx = [
1437
+ k
1438
+ for k in range(n_lev)
1439
+ if np.sum(np.isfinite(arr_u[k, :])) >= segment_length
1440
+ and np.sum(np.isfinite(arr_v[k, :])) >= segment_length
1441
+ ]
1442
+ if len(valid_lev_idx) < 2:
1443
+ return None
1444
+
1445
+ # Subsample: min(4, max(1, n_valid // 5))
1446
+ n_select = min(4, max(1, len(valid_lev_idx) // 5))
1447
+ if len(valid_lev_idx) > n_select:
1448
+ sel = np.linspace(0, len(valid_lev_idx) - 1, n_select, dtype=int)
1449
+ valid_lev_idx = [valid_lev_idx[i] for i in sel]
1450
+
1451
+ freq_out: Optional["np.ndarray"] = None
1452
+ s_cw_list: "List[np.ndarray]" = []
1453
+ s_ccw_list: "List[np.ndarray]" = []
1454
+ r_list: "List[np.ndarray]" = []
1455
+ press_plotted: "List[float]" = []
1456
+
1457
+ for k in valid_lev_idx:
1458
+ u_col = arr_u[k, :].copy()
1459
+ v_col = arr_v[k, :].copy()
1460
+ # Gap-fill by linear interpolation before Welch
1461
+ for col in (u_col, v_col):
1462
+ good = np.isfinite(col)
1463
+ if good.sum() < 2:
1464
+ break
1465
+ col[:] = np.interp(np.arange(n_time), np.where(good)[0], col[good])
1466
+ else:
1467
+ _kw = dict(
1468
+ fs=fs,
1469
+ window="hann",
1470
+ nperseg=segment_length,
1471
+ noverlap=noverlap,
1472
+ detrend="linear",
1473
+ scaling="density",
1474
+ )
1475
+ f_uu, p_uu = _signal.welch(u_col, **_kw)
1476
+ _, p_vv = _signal.welch(v_col, **_kw)
1477
+ _, c_uv = _signal.csd(u_col, v_col, **_kw)
1478
+ # Gonella (1972) rotary decomposition
1479
+ q_uv = np.imag(c_uv)
1480
+ s_cw = np.maximum((p_uu + p_vv + 2.0 * q_uv) / 4.0, 0.0)
1481
+ s_ccw = np.maximum((p_uu + p_vv - 2.0 * q_uv) / 4.0, 0.0)
1482
+ denom = s_cw + s_ccw
1483
+ r = np.where(denom > 0, (s_ccw - s_cw) / denom, 0.0)
1484
+ if freq_out is None:
1485
+ freq_out = f_uu
1486
+ s_cw_list.append(s_cw)
1487
+ s_ccw_list.append(s_ccw)
1488
+ r_list.append(r)
1489
+ press_plotted.append(float(press_vals[k]))
1490
+
1491
+ if freq_out is None or not s_cw_list:
1492
+ return None
1493
+
1494
+ nyq_period = 2.0 * dt_days
1495
+ x_min = nyq_period
1496
+ x_max = min(30.0, n_time * dt_days / 2.0)
1497
+ fmask = (freq_out > 0) & (freq_out <= 1.0 / nyq_period)
1498
+ period_plot = 1.0 / freq_out[fmask]
1499
+
1500
+ # Tidal/inertial frequency markers
1501
+ markers = [
1502
+ ("M2", 1.0 / 1.9323, "#c0392b"),
1503
+ ("K1", 23.93 / 24.0, "#e67e22"),
1504
+ ("1.8d", 1.8, "#7f8c8d"),
1505
+ ("4d", 4.0, "#95a5a6"),
1506
+ ]
1507
+ if lat != 0.0:
1508
+ import gsw as _gsw
1509
+
1510
+ f_inert = abs(_gsw.f(lat))
1511
+ f_inert_cpd = f_inert * 86400.0 / (2.0 * np.pi)
1512
+ markers.append(("f", 1.0 / f_inert_cpd, "#27ae60"))
1513
+
1514
+ p_arr = np.array(press_plotted)
1515
+ p_min, p_max = p_arr.min(), p_arr.max()
1516
+ if p_min == p_max:
1517
+ p_min -= 1.0
1518
+ p_max += 1.0
1519
+ norm_p = mcolors.Normalize(vmin=p_min, vmax=p_max)
1520
+ cmap_cw = plt.get_cmap("Reds")
1521
+ cmap_ccw = plt.get_cmap("Blues")
1522
+
1523
+ plt.style.use(str(P.MPLSTYLE))
1524
+ fig, (ax_spec, ax_rot) = plt.subplots(1, 2, figsize=(13, 5))
1525
+
1526
+ # Panel 1: CW (solid, reds) + CCW (dashed, blues)
1527
+ for s_cw, s_ccw, p in zip(s_cw_list, s_ccw_list, press_plotted):
1528
+ cw_col = cmap_cw(norm_p(p))
1529
+ ccw_col = cmap_ccw(norm_p(p))
1530
+ ax_spec.loglog(period_plot, s_cw[fmask], color=cw_col, lw=1.0, alpha=0.85)
1531
+ ax_spec.loglog(
1532
+ period_plot, s_ccw[fmask], color=ccw_col, lw=1.0, alpha=0.85, ls="--"
1533
+ )
1534
+
1535
+ trans1 = blended_transform_factory(ax_spec.transData, ax_spec.transAxes)
1536
+ for label, period_d, color in markers:
1537
+ if x_min <= period_d <= x_max:
1538
+ ax_spec.axvline(period_d, color=color, lw=1.0, ls=":", alpha=0.65)
1539
+ ax_spec.text(
1540
+ period_d,
1541
+ 0.03,
1542
+ label,
1543
+ rotation=90,
1544
+ va="bottom",
1545
+ ha="center",
1546
+ color=color,
1547
+ transform=trans1,
1548
+ bbox=dict(
1549
+ boxstyle="round,pad=0.1", fc="white", ec="none", alpha=0.6
1550
+ ),
1551
+ )
1552
+ ax_spec.set_xlim(x_max, x_min)
1553
+ ax_spec.set_xlabel("Period (days)")
1554
+ ax_spec.set_ylabel("PSD (m² s⁻² cpd⁻¹)")
1555
+ ax_spec.set_title("Rotary spectra")
1556
+ ax_spec.legend(
1557
+ handles=[
1558
+ Line2D([0], [0], color="red", lw=1.2, label="CW"),
1559
+ Line2D([0], [0], color="blue", lw=1.2, ls="--", label="CCW"),
1560
+ ],
1561
+ loc="lower left",
1562
+ )
1563
+ sm_cw = plt.cm.ScalarMappable(cmap=cmap_cw, norm=norm_p)
1564
+ sm_cw.set_array([])
1565
+ cbar_cw = fig.colorbar(sm_cw, ax=ax_spec, pad=0.03, shrink=0.85)
1566
+ cbar_cw.set_label("Pressure (dbar) — CW")
1567
+
1568
+ # Panel 2: Rotary coefficient r
1569
+ for r, p in zip(r_list, press_plotted):
1570
+ ax_rot.semilogx(
1571
+ period_plot, r[fmask], color=cmap_ccw(norm_p(p)), lw=1.0, alpha=0.85
1572
+ )
1573
+
1574
+ trans2 = blended_transform_factory(ax_rot.transData, ax_rot.transAxes)
1575
+ for label, period_d, color in markers:
1576
+ if x_min <= period_d <= x_max:
1577
+ ax_rot.axvline(period_d, color=color, lw=1.0, ls=":", alpha=0.65)
1578
+ ax_rot.text(
1579
+ period_d,
1580
+ 0.03,
1581
+ label,
1582
+ rotation=90,
1583
+ va="bottom",
1584
+ ha="center",
1585
+ color=color,
1586
+ transform=trans2,
1587
+ bbox=dict(
1588
+ boxstyle="round,pad=0.1", fc="white", ec="none", alpha=0.6
1589
+ ),
1590
+ )
1591
+ ax_rot.axhline(0, color="k", lw=0.8, ls="--", alpha=0.5)
1592
+ ax_rot.set_xlim(x_max, x_min)
1593
+ ax_rot.set_ylim(-1.1, 1.1)
1594
+ ax_rot.set_xlabel("Period (days)")
1595
+ ax_rot.set_ylabel("Rotary coefficient r")
1596
+ ax_rot.set_title("r = (CCW − CW) / (CCW + CW)")
1597
+ ax_rot.text(
1598
+ 0.02,
1599
+ 0.97,
1600
+ "CCW dominant (r > 0)",
1601
+ transform=ax_rot.transAxes,
1602
+ va="top",
1603
+ ha="left",
1604
+ color="steelblue",
1605
+ )
1606
+ ax_rot.text(
1607
+ 0.02,
1608
+ 0.05,
1609
+ "CW dominant (r < 0)",
1610
+ transform=ax_rot.transAxes,
1611
+ va="bottom",
1612
+ ha="left",
1613
+ color="#c0392b",
1614
+ )
1615
+ sm_ccw = plt.cm.ScalarMappable(cmap=cmap_ccw, norm=norm_p)
1616
+ sm_ccw.set_array([])
1617
+ cbar_ccw = fig.colorbar(sm_ccw, ax=ax_rot, pad=0.03, shrink=0.85)
1618
+ cbar_ccw.set_label("Pressure (dbar) — CCW")
1619
+
1620
+ plt.tight_layout()
1621
+ b64 = _fig_to_base64(fig)
1622
+ plt.close(fig)
1623
+ return b64
1624
+ except Exception as exc:
1625
+ print(f" WARNING: rotary spectrum figure failed: {exc}")
1626
+ return None
1627
+
1628
+
1629
+ def _make_grid_fig_b64(
1630
+ da: "xr.DataArray",
1631
+ title: str,
1632
+ units: str,
1633
+ cmap: str,
1634
+ style: str = "pcolormesh",
1635
+ contour_levels: Optional[list] = None,
1636
+ symmetric: bool = False,
1637
+ vmin: Optional[float] = None,
1638
+ vmax: Optional[float] = None,
1639
+ ) -> Optional[str]:
1640
+ """Render a grid figure from *da* (dims time × pressure); return base64 PNG or None."""
1641
+ try:
1642
+ import matplotlib.pyplot as plt
1643
+ import matplotlib.dates as mdates
1644
+ from .. import parameters as P
1645
+
1646
+ time = da.coords["time"].values
1647
+ pressure = da.coords["pressure"].values
1648
+ data = da.transpose("pressure", "time").values
1649
+
1650
+ plt.style.use(str(P.MPLSTYLE))
1651
+ fig, ax = plt.subplots(figsize=(13, 4))
1652
+ _vmin = float(np.nanpercentile(data, P.COLORBAR_PLOW)) if vmin is None else vmin
1653
+ _vmax = (
1654
+ float(np.nanpercentile(data, P.COLORBAR_PHIGH)) if vmax is None else vmax
1655
+ )
1656
+ vmin, vmax = _vmin, _vmax
1657
+ if symmetric:
1658
+ abs_max = max(abs(vmin), abs(vmax), 1e-9)
1659
+ vmin, vmax = -abs_max, abs_max
1660
+ import matplotlib.colors as mcolors
1661
+
1662
+ bounds = _nice_colorbar_bounds(vmin, vmax, n=20)
1663
+ norm = mcolors.BoundaryNorm(bounds, ncolors=256)
1664
+ if style == "contourf":
1665
+ pc = ax.contourf(
1666
+ time, pressure, data, levels=bounds, cmap=cmap, extend="both"
1667
+ )
1668
+ else:
1669
+ pc = ax.pcolormesh(
1670
+ time, pressure, data, shading="nearest", cmap=cmap, norm=norm
1671
+ )
1672
+ if contour_levels:
1673
+ ct = ax.contour(
1674
+ time,
1675
+ pressure,
1676
+ data,
1677
+ levels=contour_levels,
1678
+ colors="k",
1679
+ linewidths=0.8,
1680
+ alpha=0.75,
1681
+ )
1682
+ ax.clabel(ct, fmt="%.1f", fontsize=7, inline=True)
1683
+ cb = fig.colorbar(pc, ax=ax, pad=0.02, ticks=bounds)
1684
+ cb.set_label(f"{title} ({units})" if units else title)
1685
+ ax.invert_yaxis()
1686
+ ax.set_ylabel("Pressure (dbar)")
1687
+ locator = mdates.AutoDateLocator()
1688
+ ax.xaxis.set_major_locator(locator)
1689
+ ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
1690
+ ax.set_xlabel("Time")
1691
+ ax.set_title(f"{title} [{style}]")
1692
+ plt.tight_layout()
1693
+ b64 = _fig_to_base64(fig)
1694
+ plt.close(fig)
1695
+ return b64
1696
+ except Exception:
1697
+ return None
1698
+
1699
+
1700
+ # ---------------------------------------------------------------------------
1701
+ # Stacked grid panels (hydrography and velocity)
1702
+ # ---------------------------------------------------------------------------
1703
+
1704
+
1705
+ def _make_grid_hydro_b64(
1706
+ ds: "xr.Dataset",
1707
+ var_bounds: "Optional[dict]" = None,
1708
+ ) -> Optional[str]:
1709
+ """Stacked temperature / salinity pcolormesh panels for the grid report.
1710
+
1711
+ Both panels have pressure (dbar) on the Y-axis (inverted, surface at top).
1712
+ Colorbar bounds are clipped to the ``COLORBAR_PLOW``–``COLORBAR_PHIGH`` percentiles
1713
+ of the data to reduce the influence of outliers on color scaling.
1714
+
1715
+ **Temperature** (°C, colormap ``RdYlBu_r``): sea water temperature on the gridded
1716
+ pressure–time grid.
1717
+
1718
+ **Salinity** (PSU, colormap ``YlGnBu_r``): taken from the ``salinity`` variable if
1719
+ present. If absent but both ``conductivity`` (mS cm⁻¹) and ``temperature`` (°C) are
1720
+ present, Practical Salinity is derived via ``gsw.SP_from_C`` (PSS-78). Note: this
1721
+ is Practical Salinity, not Absolute Salinity (g kg⁻¹); the latter would require
1722
+ pressure and longitude via ``gsw.SA_from_SP``.
1723
+
1724
+ Panels are rendered only for variables that are present in *ds* or derivable.
1725
+
1726
+ Parameters
1727
+ ----------
1728
+ ds : xr.Dataset
1729
+ Gridded mooring dataset with dimensions ``(time, pressure)``.
1730
+ var_bounds : dict, optional
1731
+ Pre-computed colorbar limits keyed by variable name, e.g.
1732
+ ``{"t_lim": (vmin, vmax), "s_lim": (vmin, vmax), "o2_lim": (vmin, vmax)}``.
1733
+ When a key is present its limits are used instead of computing from the data.
1734
+ Intended for passing the T-S diagram axis limits so both figures share scales.
1735
+
1736
+ Returns
1737
+ -------
1738
+ str or None
1739
+ Base64-encoded PNG for HTML embedding, or None if no hydrographic data
1740
+ are present.
1741
+
1742
+ """
1743
+ import matplotlib.pyplot as plt
1744
+ import matplotlib.colors as mcolors
1745
+ import matplotlib.dates as mdates
1746
+ import xarray as _xr
1747
+ from .. import parameters as P
1748
+
1749
+ if var_bounds is None:
1750
+ var_bounds = {}
1751
+
1752
+ panels = []
1753
+ for var, cmap in [
1754
+ ("temperature", "RdYlBu_r"),
1755
+ ("salinity", "YlGnBu_r"),
1756
+ ("oxygen_saturation_pct", "BrBG"),
1757
+ ]:
1758
+ if var not in ds.data_vars:
1759
+ continue
1760
+ panels.append((var, cmap))
1761
+
1762
+ # Also allow derived salinity from T/C
1763
+ if (
1764
+ "salinity" not in ds.data_vars
1765
+ and "conductivity" in ds.data_vars
1766
+ and "temperature" in ds.data_vars
1767
+ ):
1768
+ try:
1769
+ import gsw
1770
+
1771
+ p_1d = ds["pressure"].values
1772
+ T = ds["temperature"].transpose("time", "pressure").values
1773
+ C = ds["conductivity"].transpose("time", "pressure").values
1774
+ SP_vals = gsw.SP_from_C(C, T, p_1d[np.newaxis, :])
1775
+ ds = ds.assign(
1776
+ salinity=_xr.DataArray(
1777
+ SP_vals,
1778
+ dims=("time", "pressure"),
1779
+ coords={"time": ds["time"], "pressure": ds["pressure"]},
1780
+ attrs={"units": "1", "long_name": "Practical Salinity"},
1781
+ )
1782
+ )
1783
+ panels.append(("salinity", "YlGnBu_r"))
1784
+ except Exception: # noqa: BLE001
1785
+ pass
1786
+
1787
+ if not panels:
1788
+ return None
1789
+
1790
+ pressure = ds["pressure"].values
1791
+ time = ds["time"].values
1792
+ plt.style.use(str(P.MPLSTYLE))
1793
+ n = len(panels)
1794
+ fig, axes = plt.subplots(n, 1, figsize=(13, 3.2 * n), sharex=True, squeeze=False)
1795
+ locator = mdates.AutoDateLocator()
1796
+
1797
+ for ax, (var, cmap) in zip(axes[:, 0], panels):
1798
+ da = ds[var]
1799
+ data = da.transpose("pressure", "time").values
1800
+ units = da.attrs.get("units", "")
1801
+ long_name = da.attrs.get("long_name", var)
1802
+ # Use the variable name (tidied) as the panel title — long_name often
1803
+ # carries the full CF phrase "sea water temperature" which is verbose
1804
+ # for a plot heading. The colorbar label keeps the full long_name.
1805
+ title = var.replace("_", " ").capitalize()
1806
+ _lim_key = {
1807
+ "temperature": "t_lim",
1808
+ "salinity": "s_lim",
1809
+ "oxygen_saturation_pct": "o2_lim",
1810
+ }.get(var)
1811
+ _passed = var_bounds.get(_lim_key) if _lim_key else None
1812
+ if _passed is not None:
1813
+ _vmin, _vmax = _passed
1814
+ else:
1815
+ _vmin = float(np.nanpercentile(data, P.COLORBAR_PLOW))
1816
+ _vmax = float(np.nanpercentile(data, P.COLORBAR_PHIGH))
1817
+ _n = 11 if var == "oxygen_saturation_pct" else 20
1818
+ bounds = _nice_colorbar_bounds(_vmin, _vmax, n=_n)
1819
+ norm = mcolors.BoundaryNorm(bounds, ncolors=256)
1820
+ pc = ax.pcolormesh(
1821
+ time, pressure, data, shading="nearest", cmap=cmap, norm=norm
1822
+ )
1823
+ cb = fig.colorbar(pc, ax=ax, pad=0.02, ticks=bounds[::2])
1824
+ cb.set_label(f"{long_name} ({units})" if units else long_name)
1825
+ ax.invert_yaxis()
1826
+ ax.set_ylabel("Pressure (dbar)")
1827
+ ax.set_title(title, loc="left")
1828
+ ax.grid(True, linestyle="--", linewidth=0.3, alpha=0.4)
1829
+
1830
+ axes[-1, 0].xaxis.set_major_locator(locator)
1831
+ axes[-1, 0].xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
1832
+ fig.tight_layout()
1833
+ b64 = _fig_to_base64(fig)
1834
+ plt.close(fig)
1835
+ return b64
1836
+
1837
+
1838
+ def _make_grid_velocity_stacked_b64(ds: "xr.Dataset") -> Optional[str]:
1839
+ """Stacked east / north / up velocity pcolormesh panels for the grid report.
1840
+
1841
+ All three panels have pressure (dbar) on the Y-axis (inverted, surface at top) and
1842
+ share the same time axis.
1843
+
1844
+ **Coordinate convention**: velocities are in geographic ENU (East–North–Up), after
1845
+ magnetic declination correction applied in stage 3. Positive east = rightward facing
1846
+ north; positive north = toward True North; positive up = upward.
1847
+
1848
+ The horizontal panels (east, north) share a symmetric diverging colormap
1849
+ (``Spectral_r``) with bounds ±max(|2nd pctile|, |98th pctile|) of all finite
1850
+ horizontal velocity values, so east and north are directly comparable in magnitude.
1851
+
1852
+ Up velocity uses its own symmetric bounds (same percentile logic, but independent of
1853
+ horizontal) because open-ocean vertical velocities are typically 1–2 cm s⁻¹ while
1854
+ horizontal velocities can reach tens of cm s⁻¹.
1855
+
1856
+ All velocity units are m s⁻¹.
1857
+
1858
+ Parameters
1859
+ ----------
1860
+ ds : xr.Dataset
1861
+ Gridded dataset with dimensions ``(time, pressure)``.
1862
+
1863
+ Returns
1864
+ -------
1865
+ str or None
1866
+ Base64-encoded PNG for HTML embedding, or None if no velocity variables are
1867
+ present.
1868
+
1869
+ """
1870
+ import matplotlib.pyplot as plt
1871
+ import matplotlib.dates as mdates
1872
+ from .. import parameters as P
1873
+
1874
+ vel_vars = [
1875
+ "east_velocity",
1876
+ "north_velocity",
1877
+ "up_velocity",
1878
+ "current_speed",
1879
+ "current_direction",
1880
+ ]
1881
+ _LABELS = {
1882
+ "east_velocity": "East velocity",
1883
+ "north_velocity": "North velocity",
1884
+ "up_velocity": "Up velocity",
1885
+ "current_speed": "Current speed",
1886
+ "current_direction": "Current direction",
1887
+ }
1888
+ present = [v for v in vel_vars if v in ds.data_vars]
1889
+ if not present:
1890
+ return None
1891
+
1892
+ pressure = ds["pressure"].values
1893
+ time = ds["time"].values
1894
+
1895
+ # Shared diverging bound from all ENU components (2nd/98th pctile magnitude)
1896
+ div_vars = [
1897
+ v for v in present if v in ("east_velocity", "north_velocity", "up_velocity")
1898
+ ]
1899
+ if div_vars:
1900
+ div_vals = np.concatenate([ds[v].values.ravel() for v in div_vars])
1901
+ finite_div = div_vals[np.isfinite(div_vals)]
1902
+ div_abs_max = (
1903
+ max(
1904
+ abs(float(np.percentile(finite_div, 2))),
1905
+ abs(float(np.percentile(finite_div, 98))),
1906
+ 1e-4,
1907
+ )
1908
+ if len(finite_div)
1909
+ else 1.0
1910
+ )
1911
+ else:
1912
+ div_abs_max = 1.0
1913
+
1914
+ plt.style.use(str(P.MPLSTYLE))
1915
+ n = len(present)
1916
+ fig, axes = plt.subplots(n, 1, figsize=(13, 3.2 * n), sharex=True, squeeze=False)
1917
+ locator = mdates.AutoDateLocator()
1918
+
1919
+ for ax, var in zip(axes[:, 0], present):
1920
+ data = ds[var].transpose("pressure", "time").values
1921
+ # Apply QC mask via the variable's own QC flag, or fall back to east_velocity_qc
1922
+ for _qv in (f"{var}_qc", "east_velocity_qc"):
1923
+ if _qv in ds.data_vars:
1924
+ data = data.copy()
1925
+ data[ds[_qv].transpose("pressure", "time").values >= 3] = np.nan
1926
+ break
1927
+ fv = data.ravel()
1928
+ fv = fv[np.isfinite(fv)]
1929
+ bounds, norm, cmap, cb_label = _velocity_panel_style(var, fv, div_abs_max)
1930
+ pc = ax.pcolormesh(
1931
+ time, pressure, data, shading="nearest", cmap=cmap, norm=norm
1932
+ )
1933
+ cb = fig.colorbar(pc, ax=ax, pad=0.02, ticks=bounds[::2])
1934
+ cb.set_label(cb_label)
1935
+ ax.invert_yaxis()
1936
+ ax.set_ylabel("Pressure (dbar)")
1937
+ ax.set_title(_LABELS[var], loc="left")
1938
+ ax.grid(True, linestyle="--", linewidth=0.3, alpha=0.4)
1939
+
1940
+ axes[-1, 0].xaxis.set_major_locator(locator)
1941
+ axes[-1, 0].xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
1942
+ fig.tight_layout()
1943
+ b64 = _fig_to_base64(fig)
1944
+ plt.close(fig)
1945
+ return b64
1946
+
1947
+
1948
+ def _make_grid_sigma_b64(ds: "xr.Dataset") -> Optional[str]:
1949
+ """Stacked sigma0 pcolormesh panel(s) for the stratification section."""
1950
+ import matplotlib.pyplot as plt
1951
+ import matplotlib.colors as mcolors
1952
+ import matplotlib.dates as mdates
1953
+ from .. import parameters as P
1954
+
1955
+ sigma_vars = [
1956
+ v for v in ds.data_vars if v.startswith("sigma") and "pressure" in ds[v].dims
1957
+ ]
1958
+ if not sigma_vars:
1959
+ return None
1960
+
1961
+ pressure = ds["pressure"].values
1962
+ time = ds["time"].values
1963
+ plt.style.use(str(P.MPLSTYLE))
1964
+ n = len(sigma_vars)
1965
+ fig, axes = plt.subplots(n, 1, figsize=(13, 3.2 * n), sharex=True, squeeze=False)
1966
+ locator = mdates.AutoDateLocator()
1967
+
1968
+ for ax, sv in zip(axes[:, 0], sigma_vars):
1969
+ da = ds[sv]
1970
+ data = da.transpose("pressure", "time").values
1971
+ units = da.attrs.get("units", "kg m⁻³")
1972
+ label = da.attrs.get("long_name", sv)
1973
+ _vmin = float(np.nanpercentile(data, P.COLORBAR_PLOW))
1974
+ _vmax = float(np.nanpercentile(data, P.COLORBAR_PHIGH))
1975
+ bounds = _nice_colorbar_bounds(_vmin, _vmax, n=20)
1976
+ norm = mcolors.BoundaryNorm(bounds, ncolors=256)
1977
+ pc = ax.pcolormesh(
1978
+ time, pressure, data, shading="nearest", cmap=P.DENSITY_COLORMAP, norm=norm
1979
+ )
1980
+ cb = fig.colorbar(pc, ax=ax, pad=0.02, ticks=bounds[::2])
1981
+ cb.set_label(f"{label} ({units})" if units else label)
1982
+ ax.invert_yaxis()
1983
+ ax.set_ylabel("Pressure (dbar)")
1984
+ ax.set_title(label, loc="left")
1985
+ ax.grid(True, linestyle="--", linewidth=0.3, alpha=0.4)
1986
+
1987
+ axes[-1, 0].xaxis.set_major_locator(locator)
1988
+ axes[-1, 0].xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
1989
+ fig.tight_layout()
1990
+ b64 = _fig_to_base64(fig)
1991
+ plt.close(fig)
1992
+ return b64
1993
+
1994
+
1995
+ # ---------------------------------------------------------------------------
1996
+ # Rose diagrams
1997
+ # ---------------------------------------------------------------------------
1998
+
1999
+
2000
+ def _rose_ax(
2001
+ ax: "plt.Axes",
2002
+ east: np.ndarray,
2003
+ north: np.ndarray,
2004
+ title: str = "",
2005
+ n_dir: int = 16,
2006
+ cmap: str = "Blues",
2007
+ max_speed: Optional[float] = None,
2008
+ ) -> "Optional[tuple]":
2009
+ """Draw a current rose on a polar Axes (compass convention, N up, CW).
2010
+
2011
+ Returns ``(spd_edges, colors)`` so callers can add a shared colorbar, or
2012
+ ``None`` if the panel was hidden due to insufficient data.
2013
+ """
2014
+ import matplotlib.pyplot as plt
2015
+
2016
+ speed = np.sqrt(east**2 + north**2)
2017
+ direction = np.degrees(np.arctan2(east, north)) % 360
2018
+ valid = np.isfinite(speed) & np.isfinite(direction)
2019
+ speed, direction = speed[valid], direction[valid]
2020
+ if len(speed) < 2:
2021
+ ax.set_visible(False)
2022
+ return None
2023
+
2024
+ dir_edges = np.linspace(0, 360, n_dir + 1)
2025
+ dir_centers = (dir_edges[:-1] + dir_edges[1:]) / 2
2026
+ theta = np.radians(dir_centers)
2027
+ bar_width = 2 * np.pi / n_dir * 0.9
2028
+
2029
+ if max_speed is None:
2030
+ max_speed = max(float(np.nanpercentile(speed, 99)), 1e-9)
2031
+ n_spd = 5
2032
+ spd_edges = np.linspace(0, max_speed, n_spd + 1)
2033
+ colors = getattr(plt.cm, cmap)(np.linspace(0.25, 1.0, n_spd))
2034
+
2035
+ total = len(speed)
2036
+ freqs = np.zeros((n_dir, n_spd))
2037
+ for i, (d0, d1) in enumerate(zip(dir_edges[:-1], dir_edges[1:])):
2038
+ in_dir = (direction >= d0) & (direction < d1)
2039
+ for j in range(n_spd):
2040
+ in_spd = (speed >= spd_edges[j]) & (speed < spd_edges[j + 1])
2041
+ freqs[i, j] = np.sum(in_dir & in_spd) / total
2042
+
2043
+ bottom = np.zeros(n_dir)
2044
+ for j in range(n_spd):
2045
+ ax.bar(
2046
+ theta,
2047
+ freqs[:, j],
2048
+ width=bar_width,
2049
+ bottom=bottom,
2050
+ color=colors[j],
2051
+ align="center",
2052
+ linewidth=0.2,
2053
+ edgecolor="white",
2054
+ )
2055
+ bottom += freqs[:, j]
2056
+
2057
+ ax.set_theta_zero_location("N")
2058
+ ax.set_theta_direction(-1)
2059
+ ax.set_xticks(np.radians([0, 90, 180, 270]))
2060
+ ax.set_xticklabels(["N", "E", "S", "W"])
2061
+ ax.set_rticks([])
2062
+ ax.set_title(title, pad=2)
2063
+ return spd_edges, colors
2064
+
2065
+
2066
+ def _xyz_to_enu_2d(
2067
+ vx: np.ndarray,
2068
+ vy: np.ndarray,
2069
+ vz: np.ndarray,
2070
+ heading_deg: np.ndarray,
2071
+ pitch_deg: np.ndarray,
2072
+ roll_deg: np.ndarray,
2073
+ declination_deg: float = 0.0,
2074
+ ) -> "tuple[np.ndarray, np.ndarray]":
2075
+ """Rotate XYZ → ENU using the Nortek heading convention (vectorised)."""
2076
+ h = np.radians(heading_deg - 90.0 + declination_deg)
2077
+ p = np.radians(pitch_deg)
2078
+ r = np.radians(roll_deg)
2079
+ ch, sh = np.cos(h), np.sin(h)
2080
+ cp, sp = np.cos(p), np.sin(p)
2081
+ cr, sr = np.cos(r), np.sin(r)
2082
+ east = (
2083
+ ch * cp * vx + (-ch * sp * sr + sh * cr) * vy + (-ch * sp * cr - sh * sr) * vz
2084
+ )
2085
+ north = (
2086
+ -sh * cp * vx + (sh * sp * sr + ch * cr) * vy + (sh * sp * cr - ch * sr) * vz
2087
+ )
2088
+ return east, north
2089
+
2090
+
2091
+ def _make_instrument_rose_b64(nc_path: Path) -> Optional[str]:
2092
+ """Rose diagram grid for a single Aquadopp instrument."""
2093
+ try:
2094
+ import matplotlib.pyplot as plt
2095
+ import xarray as xr
2096
+ from .. import parameters as P
2097
+
2098
+ ds = xr.open_dataset(nc_path, decode_timedelta=False).load()
2099
+ ds.close()
2100
+
2101
+ panels = []
2102
+
2103
+ has_xyz = all(f"velocity_{c}" in ds.data_vars for c in ("x", "y", "z"))
2104
+ has_orientation = all(v in ds.data_vars for v in ("heading", "pitch", "roll"))
2105
+ decl = ds.attrs.get("magnetic_declination")
2106
+ if has_xyz and has_orientation and decl is not None:
2107
+ vx_r = ds["velocity_x"].values.astype(float)
2108
+ vy_r = ds["velocity_y"].values.astype(float)
2109
+ vz_r = ds["velocity_z"].values.astype(float)
2110
+ hdg = ds["heading"].values.astype(float)
2111
+ pch = ds["pitch"].values.astype(float)
2112
+ rll = ds["roll"].values.astype(float)
2113
+ e_mag, n_mag = _xyz_to_enu_2d(vx_r, vy_r, vz_r, hdg, pch, rll, 0.0)
2114
+ if np.any(np.isfinite(e_mag)):
2115
+ panels.append((e_mag, n_mag, "ENU magnetic\n(decl = 0°)", "Purples"))
2116
+
2117
+ if "east_velocity" in ds.data_vars and "north_velocity" in ds.data_vars:
2118
+ e_all = ds["east_velocity"].values.astype(float)
2119
+ n_all = ds["north_velocity"].values.astype(float)
2120
+ qc = (
2121
+ ds["east_velocity_qc"].values.astype(int)
2122
+ if "east_velocity_qc" in ds.data_vars
2123
+ else np.ones(len(e_all), dtype=int)
2124
+ )
2125
+
2126
+ def _masked(flag_mask: "np.ndarray") -> "tuple[np.ndarray, np.ndarray]":
2127
+ e = e_all.copy()
2128
+ n = n_all.copy()
2129
+ e[~flag_mask] = np.nan
2130
+ n[~flag_mask] = np.nan
2131
+ return e, n
2132
+
2133
+ good_mask = qc <= 2
2134
+ susp_mask = qc == 3
2135
+ fail_mask = qc == 4
2136
+
2137
+ decl_str = f"{float(decl):+.1f}°" if decl is not None else "?"
2138
+ enu_title = f"ENU true (decl = {decl_str})\nflag ≤ 2 (good)"
2139
+ if np.any(np.isfinite(e_all[good_mask])):
2140
+ panels.append((*_masked(good_mask), enu_title, "Blues"))
2141
+ if np.any(np.isfinite(e_all[susp_mask])):
2142
+ panels.append(
2143
+ (*_masked(susp_mask), "ENU — suspect\n(flag 3)", "Oranges")
2144
+ )
2145
+ if np.any(np.isfinite(e_all[fail_mask])):
2146
+ panels.append((*_masked(fail_mask), "ENU — fail\n(flag 4)", "Reds"))
2147
+
2148
+ if not panels:
2149
+ return None
2150
+
2151
+ plt.style.use(str(P.MPLSTYLE))
2152
+ ncols = len(panels)
2153
+ fig, axs = plt.subplots(
2154
+ 1,
2155
+ ncols,
2156
+ figsize=(ncols * 3.0, 3.2),
2157
+ subplot_kw={"projection": "polar"},
2158
+ squeeze=False,
2159
+ )
2160
+ for ax, (east, north, title, cmap) in zip(axs[0], panels):
2161
+ _rose_ax(ax, east, north, title=title, cmap=cmap)
2162
+
2163
+ plt.tight_layout()
2164
+ b64 = _fig_to_base64(fig)
2165
+ plt.close(fig)
2166
+ return b64
2167
+ except Exception:
2168
+ return None
2169
+
2170
+
2171
+ # ---------------------------------------------------------------------------
2172
+ # Stack and grid T-S diagrams
2173
+ # ---------------------------------------------------------------------------
2174
+
2175
+
2176
+ def _make_stack_ts_diagram(ds: "xr.Dataset") -> Optional[str]:
2177
+ """T-S diagram for a stacked dataset: scatter-by-pressure, count heatmap, and (when present) scatter-by-AOU.
2178
+
2179
+ Panels are arranged in a single row. The AOU panel is included when
2180
+ ``apparent_oxygen_utilization`` is present in *ds* (written by stage3 for
2181
+ instruments with dissolved oxygen data).
2182
+
2183
+ QC masking: bad (flag 4) and missing (flag 9) excluded; interpolated
2184
+ pressure (flag 8) is kept as usable colour data.
2185
+ """
2186
+ try:
2187
+ import matplotlib.colors as mcolors
2188
+ import matplotlib.pyplot as plt
2189
+ from .. import parameters as P
2190
+ from ..utilities import _nice_colorbar_bounds
2191
+
2192
+ if "temperature" not in ds.data_vars or "salinity" not in ds.data_vars:
2193
+ return None
2194
+
2195
+ T_all = ds["temperature"].values.copy().astype(float)
2196
+ S_all = ds["salinity"].values.copy().astype(float)
2197
+ if "temperature_qc" in ds.data_vars:
2198
+ _tqc = ds["temperature_qc"].values.astype(float)
2199
+ T_all[(_tqc == 4) | (_tqc == 9)] = np.nan
2200
+ if "salinity_qc" in ds.data_vars:
2201
+ _sqc = ds["salinity_qc"].values.astype(float)
2202
+ S_all[(_sqc == 4) | (_sqc == 9)] = np.nan
2203
+
2204
+ T_flat = T_all.ravel()
2205
+ S_flat = S_all.ravel()
2206
+
2207
+ # Pressure: keep flag 8 (interpolated) — only bad (4) and missing (9) excluded.
2208
+ P_flat: Optional[np.ndarray] = None
2209
+ if "pressure" in ds.data_vars:
2210
+ P_arr = ds["pressure"].values.astype(float)
2211
+ if "pressure_qc" in ds.data_vars:
2212
+ _pqc = ds["pressure_qc"].values.astype(float)
2213
+ P_arr[(_pqc == 4) | (_pqc == 9)] = np.nan
2214
+ P_flat = P_arr.ravel()
2215
+
2216
+ finite = np.isfinite(T_flat) & np.isfinite(S_flat)
2217
+ if P_flat is not None:
2218
+ finite &= np.isfinite(P_flat)
2219
+ if finite.sum() < 5:
2220
+ return None
2221
+
2222
+ # O2 saturation: optional third panel.
2223
+ SAT_flat: Optional[np.ndarray] = None
2224
+ if "oxygen_saturation_pct" in ds.data_vars:
2225
+ SAT_flat = ds["oxygen_saturation_pct"].values.astype(float).ravel()
2226
+
2227
+ has_sat = SAT_flat is not None and np.isfinite(SAT_flat).any()
2228
+ ncols = 3 if has_sat else 2
2229
+ fig_w = 5.5 * ncols # ~5.5 in per panel keeps them compact in a row
2230
+ plt.style.use(str(P.MPLSTYLE))
2231
+ fig, axes = plt.subplots(
2232
+ 1, ncols, figsize=(fig_w, 4.5), constrained_layout=True
2233
+ )
2234
+
2235
+ ax_scatter, ax_heat = axes[0], axes[1]
2236
+ ax_sat = axes[2] if has_sat else None
2237
+
2238
+ # --- Left panel: T-S scatter coloured by pressure ---
2239
+ if P_flat is not None:
2240
+ pv = P_flat[finite]
2241
+ vmin = float(np.nanpercentile(pv, 2))
2242
+ vmax = float(np.nanpercentile(pv, 98))
2243
+ bounds_p = _nice_colorbar_bounds(vmin, vmax, n=20)
2244
+ norm_p = mcolors.BoundaryNorm(bounds_p, ncolors=256)
2245
+ sc_p = ax_scatter.scatter(
2246
+ S_flat[finite],
2247
+ T_flat[finite],
2248
+ c=pv,
2249
+ cmap="viridis_r",
2250
+ norm=norm_p,
2251
+ s=2,
2252
+ linewidths=0,
2253
+ alpha=0.5,
2254
+ zorder=2,
2255
+ rasterized=True,
2256
+ )
2257
+ cb_p = fig.colorbar(sc_p, ax=ax_scatter, ticks=bounds_p, pad=0.02)
2258
+ cb_p.set_label("Pressure (dbar)")
2259
+ else:
2260
+ ax_scatter.scatter(
2261
+ S_flat[finite],
2262
+ T_flat[finite],
2263
+ s=2,
2264
+ linewidths=0,
2265
+ alpha=0.4,
2266
+ rasterized=True,
2267
+ )
2268
+ _add_sigma0_contours(ax_scatter, S_flat[finite], T_flat[finite])
2269
+ ax_scatter.set_xlabel("Practical salinity")
2270
+ ax_scatter.set_ylabel("Temperature (°C)")
2271
+ ax_scatter.set_title("T-S (colour = pressure)")
2272
+
2273
+ # --- Middle panel: count heatmap ---
2274
+ _ts_heatmap_panel(ax_heat, fig, S_flat[finite], T_flat[finite])
2275
+
2276
+ # --- Right panel: T-S scatter coloured by O2 saturation ---
2277
+ if ax_sat is not None and SAT_flat is not None:
2278
+ sat_finite = finite & np.isfinite(SAT_flat)
2279
+ sat_v = SAT_flat[sat_finite]
2280
+ bounds_s = _nice_colorbar_bounds(
2281
+ float(np.nanpercentile(sat_v, 2)),
2282
+ float(np.nanpercentile(sat_v, 98)),
2283
+ n=11,
2284
+ )
2285
+ norm_s = mcolors.BoundaryNorm(bounds_s, ncolors=256)
2286
+ sc_s = ax_sat.scatter(
2287
+ S_flat[sat_finite],
2288
+ T_flat[sat_finite],
2289
+ c=sat_v,
2290
+ cmap="YlGnBu",
2291
+ norm=norm_s,
2292
+ s=2,
2293
+ linewidths=0,
2294
+ alpha=0.5,
2295
+ zorder=2,
2296
+ rasterized=True,
2297
+ )
2298
+ cb_s = fig.colorbar(sc_s, ax=ax_sat, ticks=bounds_s, pad=0.02)
2299
+ cb_s.set_label("O₂ saturation (%)")
2300
+ _add_sigma0_contours(ax_sat, S_flat[sat_finite], T_flat[sat_finite])
2301
+ ax_sat.set_xlabel("Practical salinity")
2302
+ ax_sat.set_ylabel("Temperature (°C)")
2303
+ ax_sat.set_title("T-S (colour = O₂ sat.)")
2304
+
2305
+ b64 = _fig_to_base64(fig)
2306
+ plt.close(fig)
2307
+ return b64
2308
+ except Exception:
2309
+ return None
2310
+
2311
+
2312
+ def _make_grid_ts_diagram(
2313
+ ds: "xr.Dataset", n_bins: int = 60
2314
+ ) -> "tuple[Optional[str], dict]":
2315
+ """T-S diagram for gridded mooring data.
2316
+
2317
+ Left panel: 2-D count heatmap (log₁₀ samples per T-S bin).
2318
+ Right panel (when ``oxygen_saturation_pct`` is present): median O₂ saturation
2319
+ per T-S bin, computed with ``scipy.stats.binned_statistic_2d``. Bins with
2320
+ fewer than 5 samples are masked white.
2321
+
2322
+ This lets you see which water masses (T-S combinations) are oxygen-rich vs
2323
+ oxygen-depleted at this mooring — a compact water-mass characterisation.
2324
+
2325
+ Returns
2326
+ -------
2327
+ tuple of (b64_str or None, bounds_dict)
2328
+ ``bounds_dict`` contains the axis/colorbar limits computed from the data so
2329
+ the hydro pcolormesh panels can share the same scales:
2330
+
2331
+ - ``"t_lim"`` : (vmin, vmax) for temperature
2332
+ - ``"s_lim"`` : (vmin, vmax) for salinity
2333
+ - ``"o2_lim"`` : (vmin, vmax) for oxygen_saturation_pct, or None
2334
+
2335
+ """
2336
+ try:
2337
+ import matplotlib.colors as mcolors
2338
+ import matplotlib.pyplot as plt
2339
+ from scipy.stats import binned_statistic_2d
2340
+ from .. import parameters as P
2341
+ from ..utilities import _nice_colorbar_bounds
2342
+
2343
+ if "temperature" not in ds.data_vars or "salinity" not in ds.data_vars:
2344
+ return None, {}
2345
+
2346
+ T = ds["temperature"].values.astype(float).ravel()
2347
+ S = ds["salinity"].values.astype(float).ravel()
2348
+ finite = np.isfinite(T) & np.isfinite(S)
2349
+ if finite.sum() < 10:
2350
+ return None, {}
2351
+
2352
+ # Axis limits from the data — returned to caller so hydro panels share scales.
2353
+ s_lo = float(np.nanpercentile(S[finite], 0.01))
2354
+ s_hi = float(np.nanpercentile(S[finite], 99.99))
2355
+ t_lo = float(np.nanpercentile(T[finite], 0.01))
2356
+ t_hi = float(np.nanpercentile(T[finite], 99.99))
2357
+ ts_bounds = {"t_lim": (t_lo, t_hi), "s_lim": (s_lo, s_hi), "o2_lim": None}
2358
+
2359
+ has_o2 = "oxygen_saturation_pct" in ds.data_vars
2360
+ O2 = (
2361
+ ds["oxygen_saturation_pct"].values.astype(float).ravel() if has_o2 else None
2362
+ )
2363
+ o2_valid = (
2364
+ finite & np.isfinite(O2) if (O2 is not None) else np.zeros_like(finite)
2365
+ )
2366
+ has_o2 = has_o2 and o2_valid.any()
2367
+
2368
+ ncols = 2 if has_o2 else 1
2369
+ plt.style.use(str(P.MPLSTYLE))
2370
+ fig, axes = plt.subplots(
2371
+ 1, ncols, figsize=(6 * ncols, 5), constrained_layout=True
2372
+ )
2373
+ if ncols == 1:
2374
+ axes = [axes]
2375
+
2376
+ # Panel 1: count heatmap
2377
+ _ts_heatmap_panel(axes[0], fig, S[finite], T[finite], n_bins=n_bins)
2378
+ axes[0].set_title("T-S count (log₁₀ samples per bin)")
2379
+
2380
+ # Panel 2: median O2 saturation per T-S bin
2381
+ if has_o2 and O2 is not None:
2382
+ s_edges = np.linspace(s_lo, s_hi, n_bins + 1)
2383
+ t_edges = np.linspace(t_lo, t_hi, n_bins + 1)
2384
+
2385
+ o2_med, _, _, _ = binned_statistic_2d(
2386
+ S[o2_valid],
2387
+ T[o2_valid],
2388
+ O2[o2_valid],
2389
+ statistic="median",
2390
+ bins=[s_edges, t_edges],
2391
+ )
2392
+ cnt, _, _, _ = binned_statistic_2d(
2393
+ S[o2_valid],
2394
+ T[o2_valid],
2395
+ O2[o2_valid],
2396
+ statistic="count",
2397
+ bins=[s_edges, t_edges],
2398
+ )
2399
+ # o2_med shape is (n_s, n_t); transpose to (n_t, n_s) for pcolormesh
2400
+ populated = cnt >= 5
2401
+ o2_masked = np.ma.masked_where(~populated | ~np.isfinite(o2_med), o2_med)
2402
+
2403
+ valid_vals = o2_med[populated & np.isfinite(o2_med)]
2404
+ if valid_vals.size:
2405
+ o2_vmin = float(np.nanpercentile(valid_vals, 2))
2406
+ o2_vmax = float(np.nanpercentile(valid_vals, 98))
2407
+ ts_bounds["o2_lim"] = (o2_vmin, o2_vmax)
2408
+ bounds = _nice_colorbar_bounds(o2_vmin, o2_vmax, n=11)
2409
+ norm = mcolors.BoundaryNorm(bounds, ncolors=256)
2410
+ cmap = plt.cm.BrBG.copy()
2411
+ cmap.set_bad("white")
2412
+ pc = axes[1].pcolormesh(
2413
+ s_edges,
2414
+ t_edges,
2415
+ o2_masked.T,
2416
+ cmap=cmap,
2417
+ norm=norm,
2418
+ shading="flat",
2419
+ )
2420
+ cb = fig.colorbar(pc, ax=axes[1], ticks=bounds, pad=0.02)
2421
+ cb.set_label("Median O₂ saturation (%)")
2422
+ _add_sigma0_contours(axes[1], S[o2_valid], T[o2_valid])
2423
+ axes[1].set_xlim(s_lo, s_hi)
2424
+ axes[1].set_ylim(t_lo, t_hi)
2425
+ axes[1].set_xlabel("Practical salinity")
2426
+ axes[1].set_ylabel("Temperature (°C)")
2427
+ axes[1].set_title("Median O₂ saturation per T-S bin")
2428
+
2429
+ b64 = _fig_to_base64(fig)
2430
+ plt.close(fig)
2431
+ return b64, ts_bounds
2432
+ except Exception:
2433
+ return None, {}
2434
+
2435
+
2436
+ def _make_velocity_iqr_profile_b64(ds: "xr.Dataset") -> Optional[str]:
2437
+ """Percentile-profile figure for gridded ADCP velocity data.
2438
+
2439
+ Three side-by-side panels, all with pressure (dbar) on the Y-axis (inverted,
2440
+ surface at top). All velocity units are m s⁻¹.
2441
+
2442
+ **Left — current speed** (always ≥ 0):
2443
+ Shaded percentile profile: outer band p2.5–p97.5 (95% range of the
2444
+ distribution); inner band IQR p25–p75; median p50. Wide IQR at a given
2445
+ depth indicates high velocity variability (e.g. an eddy-active layer or a
2446
+ strong tidal signal); narrow IQR with a large median indicates a persistent
2447
+ mean flow. ``current_speed`` is computed from ``sqrt(east² + north²)`` if
2448
+ not already present in the dataset.
2449
+
2450
+ **Middle — east and north velocity** (can be negative):
2451
+ Median and IQR (p25/p50/p75) for each component. East velocity in
2452
+ Okabe-Ito blue (#0072B2); north velocity in Okabe-Ito orange (#E69F00); both
2453
+ colours are distinguishable for common colour-vision deficiencies.
2454
+ Positive east = rightward facing north (geographic ENU after declination
2455
+ correction); positive north = toward True North. A median near zero with
2456
+ large IQR suggests rotary motion (e.g. tides or near-inertial oscillations);
2457
+ a non-zero median indicates a mean current.
2458
+
2459
+ **Right — count** (dimensionless):
2460
+ Number of non-NaN time steps at each pressure level. Use this panel to assess
2461
+ data coverage before interpreting velocity statistics: pressure levels with very
2462
+ few records produce unreliable percentile estimates.
2463
+
2464
+ Parameters
2465
+ ----------
2466
+ ds : xr.Dataset
2467
+ Gridded dataset with dimensions ``(time, pressure)`` containing at minimum one
2468
+ of ``current_speed``, ``east_velocity``, or ``north_velocity`` in m s⁻¹.
2469
+
2470
+ Returns
2471
+ -------
2472
+ str or None
2473
+ Base64-encoded PNG for HTML embedding, or None if no velocity data are present.
2474
+
2475
+ """
2476
+ import warnings
2477
+ import matplotlib.pyplot as plt
2478
+ import xarray as _xr
2479
+ from .. import parameters as P
2480
+
2481
+ # Ensure current_speed exists
2482
+ if "current_speed" not in ds.data_vars and all(
2483
+ v in ds.data_vars for v in ("east_velocity", "north_velocity")
2484
+ ):
2485
+ spd = np.sqrt(
2486
+ ds["east_velocity"].values ** 2 + ds["north_velocity"].values ** 2
2487
+ )
2488
+ ds = ds.assign(
2489
+ current_speed=_xr.DataArray(
2490
+ spd,
2491
+ dims=ds["east_velocity"].dims,
2492
+ coords=ds["east_velocity"].coords,
2493
+ attrs={"units": "m s-1", "long_name": "Current speed"},
2494
+ )
2495
+ )
2496
+
2497
+ has_speed = "current_speed" in ds.data_vars
2498
+ has_horiz = all(v in ds.data_vars for v in ("east_velocity", "north_velocity"))
2499
+ if not has_speed and not has_horiz:
2500
+ return None
2501
+
2502
+ pressure = ds["pressure"].values # 1-D (n_levels,)
2503
+
2504
+ # Count non-NaN values at each pressure level (from east_velocity, or speed)
2505
+ _count_ref = ds["east_velocity"].values if has_horiz else ds["current_speed"].values
2506
+ n_good = np.sum(np.isfinite(_count_ref), axis=0) # (n_levels,)
2507
+
2508
+ # Suppress levels with < 20 % coverage relative to the best-sampled level
2509
+ _max_good = n_good.max() if n_good.max() > 0 else 1
2510
+ coverage_mask = n_good >= 0.2 * _max_good
2511
+
2512
+ # Okabe-Ito blue and orange — distinguishable for all common colour-vision deficiencies
2513
+ _C_EAST = "#0072B2"
2514
+ _C_NORTH = "#E69F00"
2515
+
2516
+ n_panels = int(has_speed) + int(has_horiz) + 1 # +1 for count panel
2517
+ plt.style.use(str(P.MPLSTYLE))
2518
+ fig, axs = plt.subplots(
2519
+ 1,
2520
+ n_panels,
2521
+ figsize=(n_panels * 3.5, 6),
2522
+ sharey=True,
2523
+ gridspec_kw={"width_ratios": [2] * (n_panels - 1) + [1]},
2524
+ )
2525
+ if n_panels == 1:
2526
+ axs = [axs]
2527
+
2528
+ ax_iter = iter(axs[:-1]) # last panel reserved for count
2529
+
2530
+ # ── Panel 1: current speed with 2.5/25/50/75/97.5 ────────────────────────
2531
+ if has_speed:
2532
+ ax = next(ax_iter)
2533
+ data = ds["current_speed"].values # (time, pressure)
2534
+ with warnings.catch_warnings():
2535
+ warnings.simplefilter("ignore", RuntimeWarning) # all-NaN pressure levels
2536
+ p025 = np.nanpercentile(data, 2.5, axis=0)
2537
+ p25 = np.nanpercentile(data, 25, axis=0)
2538
+ p50 = np.nanpercentile(data, 50, axis=0)
2539
+ p75 = np.nanpercentile(data, 75, axis=0)
2540
+ p975 = np.nanpercentile(data, 97.5, axis=0)
2541
+ valid = np.isfinite(p50) & coverage_mask
2542
+ if valid.any():
2543
+ ax.fill_betweenx(
2544
+ pressure[valid],
2545
+ p025[valid],
2546
+ p975[valid],
2547
+ alpha=0.15,
2548
+ color="steelblue",
2549
+ label="2.5–97.5 %",
2550
+ )
2551
+ ax.fill_betweenx(
2552
+ pressure[valid],
2553
+ p25[valid],
2554
+ p75[valid],
2555
+ alpha=0.35,
2556
+ color="steelblue",
2557
+ label="IQR (25–75 %)",
2558
+ )
2559
+ ax.plot(
2560
+ p50[valid],
2561
+ pressure[valid],
2562
+ color="steelblue",
2563
+ linewidth=1.5,
2564
+ label="Median",
2565
+ )
2566
+ ax.plot(
2567
+ p025[valid],
2568
+ pressure[valid],
2569
+ color="steelblue",
2570
+ linewidth=0.6,
2571
+ linestyle=":",
2572
+ )
2573
+ ax.plot(
2574
+ p975[valid],
2575
+ pressure[valid],
2576
+ color="steelblue",
2577
+ linewidth=0.6,
2578
+ linestyle=":",
2579
+ )
2580
+ ax.set_xlim(left=0)
2581
+ ax.set_xlabel("Current speed (m s⁻¹)")
2582
+ ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.5)
2583
+ ax.legend(loc="best")
2584
+
2585
+ # ── Panel 2: east + north on shared axes ─────────────────────────────────
2586
+ if has_horiz:
2587
+ ax = next(ax_iter)
2588
+ absmax = 0.0
2589
+ for varname, color, label in [
2590
+ ("east_velocity", _C_EAST, "East"),
2591
+ ("north_velocity", _C_NORTH, "North"),
2592
+ ]:
2593
+ data = ds[varname].values
2594
+ with warnings.catch_warnings():
2595
+ warnings.simplefilter("ignore", RuntimeWarning)
2596
+ p25 = np.nanpercentile(data, 25, axis=0)
2597
+ p50 = np.nanpercentile(data, 50, axis=0)
2598
+ p75 = np.nanpercentile(data, 75, axis=0)
2599
+ valid = np.isfinite(p50) & coverage_mask
2600
+ if not valid.any():
2601
+ continue
2602
+ ax.fill_betweenx(
2603
+ pressure[valid], p25[valid], p75[valid], alpha=0.25, color=color
2604
+ )
2605
+ ax.plot(
2606
+ p50[valid], pressure[valid], color=color, linewidth=1.5, label=label
2607
+ )
2608
+ ax.plot(
2609
+ p25[valid], pressure[valid], color=color, linewidth=0.6, linestyle="--"
2610
+ )
2611
+ ax.plot(
2612
+ p75[valid], pressure[valid], color=color, linewidth=0.6, linestyle="--"
2613
+ )
2614
+ _abs = np.nanmax(np.abs([p25[valid], p75[valid]]))
2615
+ if np.isfinite(_abs):
2616
+ absmax = max(absmax, _abs)
2617
+ ax.axvline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4)
2618
+ if absmax > 0:
2619
+ ax.set_xlim(-absmax * 1.15, absmax * 1.15)
2620
+ ax.set_xlabel("Velocity (m s⁻¹)")
2621
+ ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.5)
2622
+ ax.legend(loc="best")
2623
+
2624
+ # ── Count panel (rightmost) ───────────────────────────────────────────────
2625
+ ax_count = axs[-1]
2626
+ ax_count.barh(
2627
+ pressure,
2628
+ n_good,
2629
+ height=np.diff(pressure).mean() * 0.8 if len(pressure) > 1 else 5,
2630
+ color="gray",
2631
+ alpha=0.6,
2632
+ )
2633
+ ax_count.set_xlabel("N good")
2634
+ ax_count.grid(True, linestyle="--", linewidth=0.4, alpha=0.5)
2635
+
2636
+ axs[0].set_ylabel("Pressure (dbar)")
2637
+ axs[0].invert_yaxis() # shared y — invert once only
2638
+
2639
+ # Mark bottom depth if available (waterdepth global attr, dbar ≈ m seawater)
2640
+ try:
2641
+ _wd = float(ds.attrs.get("waterdepth", ""))
2642
+ if np.isfinite(_wd) and _wd > 0:
2643
+ for _ax in axs:
2644
+ _ax.axhline(_wd, color="k", linewidth=2.0, linestyle="-", zorder=5)
2645
+ except (ValueError, TypeError):
2646
+ pass
2647
+
2648
+ fig.tight_layout()
2649
+ b64 = _fig_to_base64(fig)
2650
+ plt.close(fig)
2651
+ return b64
2652
+
2653
+
2654
+ def _make_grid_n2_b64(ds: "xr.Dataset", lat: float = 0.0) -> Optional[str]:
2655
+ """Compute and plot buoyancy frequency squared N² on the pressure-time grid."""
2656
+ try:
2657
+ import gsw
2658
+ import matplotlib.pyplot as plt
2659
+ import matplotlib.dates as mdates
2660
+ import matplotlib.colors as mcolors
2661
+ from .. import parameters as P
2662
+
2663
+ if "temperature" not in ds.data_vars or "salinity" not in ds.data_vars:
2664
+ return None
2665
+
2666
+ p_1d = ds["pressure"].values.astype(float)
2667
+ T_pt = ds["temperature"].transpose("pressure", "time").values.astype(float)
2668
+ SP_pt = ds["salinity"].transpose("pressure", "time").values.astype(float)
2669
+ time_vals = ds["time"].values
2670
+ n_p, n_t = T_pt.shape
2671
+
2672
+ lon = 0.0
2673
+ for key in ("deployment_longitude", "seabed_longitude", "longitude"):
2674
+ v = ds.attrs.get(key)
2675
+ if v is not None:
2676
+ try:
2677
+ lon = float(v)
2678
+ break
2679
+ except Exception:
2680
+ pass
2681
+
2682
+ p_2d = np.broadcast_to(p_1d[:, np.newaxis], (n_p, n_t)).copy()
2683
+ SA = gsw.SA_from_SP(SP_pt, p_2d, lon, lat)
2684
+ CT = gsw.CT_from_t(SA, T_pt, p_2d)
2685
+ N2, p_mid = gsw.Nsquared(SA, CT, p_2d, lat=lat)
2686
+ p_mid_1d = np.nanmean(p_mid, axis=1)
2687
+ N2_log = np.log10(np.maximum(N2, 1e-12))
2688
+
2689
+ plt.style.use(str(P.MPLSTYLE))
2690
+ fig, ax = plt.subplots(figsize=(13, 4))
2691
+ vmin = float(np.nanpercentile(N2_log[np.isfinite(N2_log)], P.COLORBAR_PLOW))
2692
+ vmax = float(np.nanpercentile(N2_log[np.isfinite(N2_log)], P.COLORBAR_PHIGH))
2693
+ bounds = _nice_colorbar_bounds(vmin, vmax, n=20)
2694
+ norm = mcolors.BoundaryNorm(bounds, ncolors=256)
2695
+ pc = ax.pcolormesh(
2696
+ time_vals,
2697
+ p_mid_1d,
2698
+ N2_log,
2699
+ shading="nearest",
2700
+ cmap="plasma_r",
2701
+ norm=norm,
2702
+ )
2703
+ cb = fig.colorbar(pc, ax=ax, pad=0.02, ticks=bounds)
2704
+ cb.set_label("log₁₀(N²) [s⁻²]")
2705
+ ax.invert_yaxis()
2706
+ ax.set_ylabel("Pressure (dbar)")
2707
+ locator = mdates.AutoDateLocator()
2708
+ ax.xaxis.set_major_locator(locator)
2709
+ ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
2710
+ ax.set_xlabel("Time")
2711
+ ax.set_title("Buoyancy frequency squared N² [log₁₀ scale; purple = stratified]")
2712
+ plt.tight_layout()
2713
+ b64 = _fig_to_base64(fig)
2714
+ plt.close(fig)
2715
+ return b64
2716
+ except Exception:
2717
+ return None
2718
+
2719
+
2720
+ def _make_rose_grid_b64(
2721
+ ds: "xr.Dataset",
2722
+ serial_list: list,
2723
+ ) -> tuple:
2724
+ """Grid of current roses (max 4 per row) for instruments with ENU velocity data.
2725
+
2726
+ Returns
2727
+ -------
2728
+ tuple[str | None, int]
2729
+ ``(base64_png, n_panels)`` — base64 image string (or ``None`` on failure)
2730
+ and the number of rose panels rendered.
2731
+
2732
+ """
2733
+ import math
2734
+ import matplotlib.pyplot as plt
2735
+ from .. import parameters as P
2736
+
2737
+ if "east_velocity" not in ds.data_vars or "north_velocity" not in ds.data_vars:
2738
+ return None, 0
2739
+
2740
+ east_all = ds["east_velocity"].values.copy()
2741
+ north_all = ds["north_velocity"].values.copy()
2742
+
2743
+ # Mask suspect/bad data from all available QC variables before plotting roses.
2744
+ # seabed_qc handles ADCP bins below the seafloor; percent_good_qc and
2745
+ # error_velocity_qc handle low-quality ADCP pings. Any flag >= 3
2746
+ # (suspect or worse) is treated as invalid.
2747
+ for _qc_var in (
2748
+ "east_velocity_qc",
2749
+ "seabed_qc",
2750
+ "percent_good_qc",
2751
+ "error_velocity_qc",
2752
+ ):
2753
+ if _qc_var in ds.data_vars:
2754
+ _qc = ds[_qc_var].values
2755
+ if _qc.shape == east_all.shape:
2756
+ east_all[_qc >= 3] = np.nan
2757
+ north_all[_qc >= 3] = np.nan
2758
+
2759
+ has_vel = [np.any(np.isfinite(east_all[:, i])) for i in range(east_all.shape[1])]
2760
+ hab_vals = ds.coords["hab"].values if "hab" in ds.coords else None
2761
+
2762
+ # Split by instrument type: ADCP bins are capped at 4 per serial (top, 2 mid,
2763
+ # bottom by HAB), single-point instruments (Aquadopp, etc.) keep all.
2764
+ # The variable may be "instrument_type" (mooring_level) or "instrument" (time_gridding).
2765
+ _type_var = (
2766
+ "instrument_type"
2767
+ if "instrument_type" in ds
2768
+ else ("instrument" if "instrument" in ds else None)
2769
+ )
2770
+ instr_type_vals = ds[_type_var].values if _type_var is not None else None
2771
+ _all_cand = [i for i in range(len(serial_list)) if i < len(has_vel) and has_vel[i]]
2772
+
2773
+ aqd_idx: list[int] = []
2774
+ if instr_type_vals is not None:
2775
+ # Non-ADCP: all levels with velocity
2776
+ aqd_idx = [i for i in _all_cand if str(instr_type_vals[i]).upper() != "ADCP"]
2777
+
2778
+ # ADCP: group by serial, select ≤4 representative bins by HAB
2779
+ from collections import defaultdict
2780
+
2781
+ adcp_by_serial: dict[str, list[int]] = defaultdict(list)
2782
+ for i in _all_cand:
2783
+ if str(instr_type_vals[i]).upper() == "ADCP":
2784
+ ser = str(serial_list[i]) if i < len(serial_list) else "?"
2785
+ adcp_by_serial[ser].append(i)
2786
+
2787
+ _ADCP_PCTS = [0, 33, 67, 100]
2788
+ for _idxs in adcp_by_serial.values():
2789
+ # Sort by HAB so percentile picks are spatially meaningful
2790
+ if hab_vals is not None:
2791
+ _idxs = sorted(_idxs, key=lambda k: hab_vals[k])
2792
+ n_b = len(_idxs)
2793
+ selected: list[int] = []
2794
+ for pct in _ADCP_PCTS:
2795
+ k = int(round(pct / 100 * (n_b - 1)))
2796
+ k = max(0, min(n_b - 1, k))
2797
+ if _idxs[k] not in selected:
2798
+ selected.append(_idxs[k])
2799
+ aqd_idx.extend(selected)
2800
+ else:
2801
+ aqd_idx = _all_cand
2802
+
2803
+ n = len(aqd_idx)
2804
+ if n == 0:
2805
+ return None, 0
2806
+
2807
+ ncols = min(n, 4)
2808
+ nrows = math.ceil(n / ncols)
2809
+
2810
+ plt.style.use(str(P.MPLSTYLE))
2811
+ fig, axs = plt.subplots(
2812
+ nrows,
2813
+ ncols,
2814
+ figsize=(ncols * 3.0, nrows * 3.2),
2815
+ subplot_kw={"projection": "polar"},
2816
+ squeeze=False,
2817
+ )
2818
+ axs_flat = axs.flatten()
2819
+
2820
+ for plot_i, instr_i in enumerate(aqd_idx):
2821
+ serial = serial_list[instr_i] if instr_i < len(serial_list) else "?"
2822
+ if hab_vals is not None and instr_i < len(hab_vals):
2823
+ title = f"{serial} ({hab_vals[instr_i]:.0f} m)"
2824
+ else:
2825
+ title = str(serial)
2826
+ _rose_ax(
2827
+ axs_flat[plot_i], east_all[:, instr_i], north_all[:, instr_i], title=title
2828
+ )
2829
+
2830
+ for k in range(n, len(axs_flat)):
2831
+ axs_flat[k].set_visible(False)
2832
+
2833
+ plt.tight_layout()
2834
+ b64 = _fig_to_base64(fig)
2835
+ plt.close(fig)
2836
+ return b64, n
2837
+
2838
+
2839
+ def _make_grid_rose_b64(ds: "xr.Dataset", max_roses: int = 4) -> Optional[str]:
2840
+ """Grid of current roses, one per pressure level, for the grid report.
2841
+
2842
+ Shows up to *max_roses* pressure levels (at most 1/5th of valid levels,
2843
+ capped at 4), each labelled with its pressure (dbar). Levels with no finite
2844
+ ENU velocity are skipped. Returns None when east/north velocity are absent or
2845
+ all-NaN.
2846
+
2847
+ Parameters
2848
+ ----------
2849
+ ds : xr.Dataset
2850
+ Gridded dataset with dimensions ``(time, pressure)`` containing
2851
+ ``east_velocity`` and ``north_velocity`` in m s⁻¹.
2852
+ max_roses : int
2853
+ Maximum number of rose panels to draw (default 12).
2854
+
2855
+ Returns
2856
+ -------
2857
+ str or None
2858
+ Base64-encoded PNG, or None if no velocity data are present.
2859
+
2860
+ """
2861
+ import math
2862
+ import matplotlib.pyplot as plt
2863
+ from .. import parameters as P
2864
+
2865
+ if "east_velocity" not in ds.data_vars or "north_velocity" not in ds.data_vars:
2866
+ return None
2867
+
2868
+ east_all = ds["east_velocity"].values.copy() # (time, pressure)
2869
+ north_all = ds["north_velocity"].values.copy()
2870
+
2871
+ for _qc_var in ("east_velocity_qc", "north_velocity_qc"):
2872
+ if _qc_var in ds.data_vars:
2873
+ _qc = ds[_qc_var].values
2874
+ if _qc.shape == east_all.shape:
2875
+ east_all[_qc >= 3] = np.nan
2876
+ north_all[_qc >= 3] = np.nan
2877
+
2878
+ pressure = ds["pressure"].values # 1-D (n_levels,)
2879
+ valid_idx = [k for k in range(len(pressure)) if np.any(np.isfinite(east_all[:, k]))]
2880
+ if not valid_idx:
2881
+ return None
2882
+
2883
+ # Subsample: min(max_roses, max(1, n_valid // 5)) — same rule as rotary spectrum
2884
+ n_select = min(max_roses, max(1, len(valid_idx) // 5))
2885
+ if len(valid_idx) > n_select:
2886
+ step = len(valid_idx) / n_select
2887
+ valid_idx = [valid_idx[int(round(i * step))] for i in range(n_select)]
2888
+
2889
+ n = len(valid_idx)
2890
+ ncols = min(n, 4)
2891
+ nrows = math.ceil(n / ncols)
2892
+
2893
+ plt.style.use(str(P.MPLSTYLE))
2894
+ fig, axs = plt.subplots(
2895
+ nrows,
2896
+ ncols,
2897
+ figsize=(ncols * 3.0, nrows * 3.2),
2898
+ subplot_kw={"projection": "polar"},
2899
+ squeeze=False,
2900
+ )
2901
+ axs_flat = axs.flatten()
2902
+
2903
+ for plot_i, k in enumerate(valid_idx):
2904
+ title = f"{int(pressure[k])} dbar"
2905
+ _rose_ax(axs_flat[plot_i], east_all[:, k], north_all[:, k], title=title)
2906
+
2907
+ for k in range(n, len(axs_flat)):
2908
+ axs_flat[k].set_visible(False)
2909
+
2910
+ plt.tight_layout()
2911
+ b64 = _fig_to_base64(fig)
2912
+ plt.close(fig)
2913
+ return b64
2914
+
2915
+
2916
+ # ---------------------------------------------------------------------------
2917
+ # Isopycnal / sigma helpers
2918
+ # ---------------------------------------------------------------------------
2919
+
2920
+
2921
+ def _filter_sigma_tukey(
2922
+ data: np.ndarray, window_samples: int, alpha: float = 0.5
2923
+ ) -> np.ndarray:
2924
+ """Apply a Tukey moving-average filter along axis=1 (time), NaN-aware."""
2925
+ from scipy.signal import convolve
2926
+ from scipy.signal.windows import tukey
2927
+
2928
+ w = tukey(window_samples, alpha=alpha).astype(np.float64)
2929
+ w /= w.sum()
2930
+ n_p, n_t = data.shape
2931
+ result = data.copy()
2932
+ for k in range(n_p):
2933
+ col = data[k, :]
2934
+ nan_mask = ~np.isfinite(col)
2935
+ if nan_mask.all():
2936
+ continue
2937
+ if nan_mask.any():
2938
+ xi = np.where(~nan_mask)[0]
2939
+ yi = col[~nan_mask]
2940
+ if len(xi) < 2:
2941
+ continue
2942
+ filled = np.interp(np.arange(n_t), xi, yi)
2943
+ else:
2944
+ filled = col.copy()
2945
+ smoothed = convolve(filled, w, mode="same")
2946
+ smoothed[nan_mask] = np.nan
2947
+ result[k, :] = smoothed
2948
+ return result
2949
+
2950
+
2951
+ def _make_grid_trajectory_b64(ds: "xr.Dataset") -> Optional[str]:
2952
+ """Pseudo-Lagrangian current-vector integral by pressure level for the grid report.
2953
+
2954
+ For each pressure level in the gridded dataset, integrates east and north velocity
2955
+ over time using the Euler forward method to produce a cumulative displacement
2956
+ trajectory. All trajectories start at the origin (0, 0). The figure shows the net
2957
+ displacement (in **metres**) a neutrally buoyant float would experience at each depth
2958
+ if it were advected by the measured velocity field. This is a *pseudo-Lagrangian*
2959
+ representation of Eulerian mooring velocity data — the water parcel does not
2960
+ actually stay at the mooring.
2961
+
2962
+ **QC masking**: time steps flagged suspect or bad on ``east_velocity_qc`` or
2963
+ ``north_velocity_qc`` (≥ 3) are set to NaN before integration. NaN time steps
2964
+ contribute zero displacement, which biases trajectories toward the origin during
2965
+ extended data gaps.
2966
+
2967
+ Trajectory lines are coloured by pressure (dbar) using ``viridis_r``: shallow
2968
+ levels (low pressure) are light; deep levels (high pressure) are dark. Pressure
2969
+ levels with no finite velocity data at any time step are omitted.
2970
+
2971
+ Parameters
2972
+ ----------
2973
+ ds : xr.Dataset
2974
+ Gridded dataset with dimensions ``(time, pressure)``, containing
2975
+ ``east_velocity`` and ``north_velocity`` in m s⁻¹.
2976
+
2977
+ Returns
2978
+ -------
2979
+ str or None
2980
+ Base64-encoded PNG for HTML embedding, or None if east/north velocity are
2981
+ absent or all-NaN.
2982
+
2983
+ """
2984
+ import matplotlib.pyplot as plt
2985
+ import matplotlib.colors as mcolors
2986
+ from matplotlib.collections import LineCollection
2987
+ from .. import parameters as P
2988
+
2989
+ if "east_velocity" not in ds.data_vars or "north_velocity" not in ds.data_vars:
2990
+ return None
2991
+
2992
+ pressure = ds["pressure"].values # 1-D (n_levels,)
2993
+ time = ds["time"].values
2994
+ dt = np.array(
2995
+ [(time[j] - time[j - 1]) / np.timedelta64(1, "s") for j in range(1, len(time))],
2996
+ dtype=float,
2997
+ )
2998
+
2999
+ east = ds["east_velocity"].values.copy() # (time, pressure)
3000
+ north = ds["north_velocity"].values.copy()
3001
+
3002
+ # Apply QC masking before integration
3003
+ for _qv, _dv in (("east_velocity_qc", east), ("north_velocity_qc", north)):
3004
+ if _qv in ds.data_vars:
3005
+ _qc = ds[_qv].values
3006
+ _dv[_qc >= 3] = np.nan
3007
+
3008
+ # Build one trajectory per pressure level; skip levels with all-NaN velocity
3009
+ trajs = [] # (pressure_val, x_array, y_array)
3010
+ for k, p_val in enumerate(pressure):
3011
+ u = np.nan_to_num(east[:, k], nan=0.0)
3012
+ v = np.nan_to_num(north[:, k], nan=0.0)
3013
+ if not np.any(east[:, k][np.isfinite(east[:, k])]):
3014
+ continue
3015
+ x = np.concatenate([[0.0], np.cumsum(u[:-1] * dt)]) / 1000.0
3016
+ y = np.concatenate([[0.0], np.cumsum(v[:-1] * dt)]) / 1000.0
3017
+ trajs.append((float(p_val), x, y))
3018
+
3019
+ if not trajs:
3020
+ return None
3021
+
3022
+ p_vals = [t[0] for t in trajs]
3023
+ _bounds = _nice_colorbar_bounds(min(p_vals), max(p_vals), n=20)
3024
+ norm: mcolors.BoundaryNorm = mcolors.BoundaryNorm(_bounds, ncolors=256)
3025
+ cmap = plt.get_cmap("viridis_r") # shallow (low p) → light; deep → dark
3026
+
3027
+ plt.style.use(str(P.MPLSTYLE))
3028
+ fig, ax = plt.subplots(figsize=(6, 5))
3029
+
3030
+ for p_val, x, y in trajs:
3031
+ points = np.array([x, y]).T.reshape(-1, 1, 2)
3032
+ segments = np.concatenate([points[:-1], points[1:]], axis=1)
3033
+ lc = LineCollection(segments, cmap=cmap, norm=norm, linewidth=0.8, alpha=0.7)
3034
+ lc.set_array(np.full(len(segments), p_val))
3035
+ ax.add_collection(lc)
3036
+
3037
+ sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
3038
+ sm.set_array([])
3039
+ fig.colorbar(sm, ax=ax, label="Pressure (dbar)", shrink=0.75, ticks=_bounds)
3040
+
3041
+ ax.plot(0, 0, "o", color="black", markersize=6, zorder=6, label="Start")
3042
+ ax.legend(fontsize=8, loc="upper left")
3043
+ ax.autoscale_view()
3044
+ ax.set_xlabel("East displacement (km)")
3045
+ ax.set_ylabel("North displacement (km)")
3046
+ ax.axhline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4)
3047
+ ax.axvline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4)
3048
+ ax.set_aspect("equal", adjustable="box")
3049
+ ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.4)
3050
+ fig.tight_layout()
3051
+ b64 = _fig_to_base64(fig)
3052
+ plt.close(fig)
3053
+ return b64
3054
+
3055
+
3056
+ def _make_grid_timeseries_b64(ds: "xr.Dataset") -> Optional[str]:
3057
+ """Velocity time series at the depth of maximum time-mean current speed.
3058
+
3059
+ Two stacked panels (shared time axis):
3060
+
3061
+ - **Speed** (black, m s⁻¹): ``sqrt(east² + north²)``; always ≥ 0.
3062
+ - **East and North velocity** (same axes, m s⁻¹): east in Okabe-Ito blue
3063
+ (#0072B2), north in Okabe-Ito orange (#E69F00). Both signed components
3064
+ are plotted together so the relationship between along- and cross-stream
3065
+ flow is immediately visible.
3066
+
3067
+ The target pressure level is chosen automatically as the level with the
3068
+ highest time-mean current speed and at least 70 % non-NaN coverage. The
3069
+ selected pressure is annotated in the figure title.
3070
+
3071
+ Parameters
3072
+ ----------
3073
+ ds : xr.Dataset
3074
+ Gridded dataset with dimensions ``(time, pressure)`` containing at
3075
+ minimum ``east_velocity`` and ``north_velocity`` in m s⁻¹.
3076
+
3077
+ Returns
3078
+ -------
3079
+ str or None
3080
+ Base64-encoded PNG for HTML embedding, or None if horizontal velocity
3081
+ data are absent.
3082
+
3083
+ """
3084
+ import warnings
3085
+ import matplotlib.pyplot as plt
3086
+ import matplotlib.dates as mdates
3087
+ from .. import parameters as P
3088
+
3089
+ has_horiz = all(v in ds.data_vars for v in ("east_velocity", "north_velocity"))
3090
+ if not has_horiz:
3091
+ return None
3092
+
3093
+ east = ds["east_velocity"].values # (time, pressure)
3094
+ north = ds["north_velocity"].values
3095
+ pressure = ds["pressure"].values # 1-D
3096
+ time_vals = ds["time"].values
3097
+
3098
+ spd = np.sqrt(east**2 + north**2)
3099
+ with warnings.catch_warnings():
3100
+ warnings.simplefilter("ignore", RuntimeWarning)
3101
+ mean_spd = np.nanmean(spd, axis=0) # (pressure,)
3102
+ if not np.any(np.isfinite(mean_spd)):
3103
+ return None
3104
+
3105
+ # Only consider levels with at least 70 % non-NaN values so that a
3106
+ # depth covered only briefly does not win on spuriously high mean speed.
3107
+ n_time = spd.shape[0]
3108
+ coverage = np.sum(np.isfinite(spd), axis=0) / n_time # fraction per level
3109
+ eligible = coverage >= 0.70
3110
+ if not np.any(eligible):
3111
+ eligible = np.ones(len(pressure), dtype=bool) # fall back: no restriction
3112
+ candidate_spd = np.where(eligible, mean_spd, np.nan)
3113
+ k_max = int(np.nanargmax(candidate_spd))
3114
+ p_target = float(pressure[k_max])
3115
+
3116
+ spd_ts = spd[:, k_max]
3117
+ east_ts = east[:, k_max]
3118
+ north_ts = north[:, k_max]
3119
+
3120
+ plt.style.use(str(P.MPLSTYLE))
3121
+ fig, axs = plt.subplots(2, 1, figsize=(10, 5), sharex=True)
3122
+ _C_EAST = "#0072B2"
3123
+ _C_NORTH = "#E69F00"
3124
+
3125
+ # Panel 0: speed
3126
+ axs[0].plot(time_vals, spd_ts, color="k", linewidth=0.8)
3127
+ axs[0].set_ylabel("Speed (m s⁻¹)")
3128
+ axs[0].set_ylim(bottom=0)
3129
+
3130
+ # Panel 1: east and north on the same axes
3131
+ axs[1].plot(time_vals, east_ts, color=_C_EAST, linewidth=0.8, label="East")
3132
+ axs[1].plot(time_vals, north_ts, color=_C_NORTH, linewidth=0.8, label="North")
3133
+ axs[1].axhline(0, color="k", linewidth=0.4, linestyle="--", alpha=0.4, zorder=0)
3134
+ axs[1].set_ylabel("Velocity (m s⁻¹)")
3135
+ axs[1].legend(loc="upper right", framealpha=0.8)
3136
+
3137
+ for ax in axs:
3138
+ ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.4)
3139
+ axs[-1].xaxis.set_major_formatter(
3140
+ mdates.ConciseDateFormatter(axs[-1].xaxis.get_major_locator())
3141
+ )
3142
+ fig.suptitle(
3143
+ f"Velocity time series at {p_target:.0f} dbar (depth of maximum mean speed)",
3144
+ y=1.01,
3145
+ )
3146
+ fig.tight_layout()
3147
+ b64 = _fig_to_base64(fig)
3148
+ plt.close(fig)
3149
+ return b64
3150
+
3151
+
3152
+ def _make_isopycnal_fig_b64(
3153
+ da: "xr.DataArray",
3154
+ levels: list,
3155
+ filter_samples: int = 0,
3156
+ zoom_center_idx: Optional[int] = None,
3157
+ zoom_n: int = 0,
3158
+ ) -> Optional[str]:
3159
+ """Return base64 PNG: time × pressure with iso-sigma contour lines."""
3160
+ if not levels:
3161
+ return None
3162
+ try:
3163
+ import matplotlib.pyplot as plt
3164
+ import matplotlib.dates as mdates
3165
+ from .. import parameters as P
3166
+
3167
+ da_tp = da.transpose("pressure", "time")
3168
+ time_vals = da_tp["time"].values
3169
+ pressure_vals = da_tp["pressure"].values
3170
+ data = da_tp.values
3171
+
3172
+ if zoom_center_idx is not None and zoom_n > 0:
3173
+ t0 = max(0, zoom_center_idx - zoom_n // 2)
3174
+ t1 = min(data.shape[1], t0 + zoom_n)
3175
+ time_vals = time_vals[t0:t1]
3176
+ data = data[:, t0:t1]
3177
+
3178
+ if filter_samples > 1 and data.shape[1] > filter_samples:
3179
+ data = _filter_sigma_tukey(data, filter_samples)
3180
+
3181
+ level_colors = ["#808080"] + ["black"] * (len(levels) - 1)
3182
+
3183
+ plt.style.use(str(P.MPLSTYLE))
3184
+ fig, ax = plt.subplots(figsize=(13, 4))
3185
+ for lev, col in zip(levels, level_colors):
3186
+ try:
3187
+ ax.contour(
3188
+ time_vals,
3189
+ pressure_vals,
3190
+ data,
3191
+ levels=[lev],
3192
+ colors=[col],
3193
+ linewidths=1.2,
3194
+ )
3195
+ except Exception:
3196
+ pass
3197
+ ax.plot([], [], color=col, lw=1.2, label=f"σ₀ = {lev} kg m⁻³")
3198
+
3199
+ ax.invert_yaxis()
3200
+ ax.set_ylabel("Pressure (dbar)")
3201
+ locator = mdates.AutoDateLocator()
3202
+ ax.xaxis.set_major_locator(locator)
3203
+ ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
3204
+ ax.set_xlabel("Time")
3205
+ if levels:
3206
+ ax.legend(loc="upper right", framealpha=0.8)
3207
+ plt.tight_layout()
3208
+ b64 = _fig_to_base64(fig)
3209
+ plt.close(fig)
3210
+ return b64
3211
+ except Exception:
3212
+ return None
3213
+
3214
+
3215
+ # ---------------------------------------------------------------------------
3216
+ # Aquadopp trajectory and speed distribution (Tier-3 wrappers)
3217
+ # Delegates to oceanarray.plotters._current (Tier-2 domain wrappers).
3218
+ # ---------------------------------------------------------------------------
3219
+
3220
+
3221
+ def _make_temperature_trajectory(nc_path: str) -> Optional[str]:
3222
+ """Lagrangian trajectory coloured by temperature, for Aquadopp instrument page."""
3223
+ try:
3224
+ import xarray as xr
3225
+ import matplotlib.pyplot as plt
3226
+ from oceanarray.plotters._current import plot_temperature_trajectory
3227
+
3228
+ with xr.open_dataset(nc_path) as ds:
3229
+ fig = plot_temperature_trajectory(ds)
3230
+ b64 = _fig_to_base64(fig)
3231
+ plt.close(fig)
3232
+ return b64
3233
+ except Exception: # noqa: BLE001 — plot is optional; bad data or missing vars → skip
3234
+ return None
3235
+
3236
+
3237
+ def _make_speed_boxplot(nc_path: str) -> Optional[str]:
3238
+ """Speed boxplot with percentile statistics, for Aquadopp instrument page."""
3239
+ try:
3240
+ import xarray as xr
3241
+ import matplotlib.pyplot as plt
3242
+ from oceanarray.plotters._current import plot_speed_boxplot
3243
+
3244
+ with xr.open_dataset(nc_path) as ds:
3245
+ fig = plot_speed_boxplot(ds)
3246
+ b64 = _fig_to_base64(fig)
3247
+ plt.close(fig)
3248
+ return b64
3249
+ except Exception: # noqa: BLE001 — plot is optional; bad data or missing vars → skip
3250
+ return None
3251
+
3252
+
3253
+ def _make_hodograph_b64(nc_path: str) -> Optional[str]:
3254
+ """Two-panel hodograph (raw + eddy-only) for Aquadopp instrument page.
3255
+
3256
+ Always returns a base64 PNG — a placeholder image is rendered when
3257
+ east/north velocities are absent so the section is never silently omitted.
3258
+ Returns None only on unrecoverable file errors.
3259
+ """
3260
+ try:
3261
+ import xarray as xr
3262
+ import matplotlib.pyplot as plt
3263
+ from oceanarray.plotters._current import plot_hodograph
3264
+
3265
+ with xr.open_dataset(nc_path) as ds:
3266
+ fig = plot_hodograph(ds)
3267
+ b64 = _fig_to_base64(fig)
3268
+ plt.close(fig)
3269
+ return b64
3270
+ except Exception: # noqa: BLE001 — file unreadable; skip section entirely
3271
+ return None
3272
+
3273
+
3274
+ def _make_multi_aquadopp_trajectories(ds: "xr.Dataset") -> Optional[str]:
3275
+ """Multi-instrument Aquadopp trajectory plot coloured by temperature, for stack page.
3276
+
3277
+ Takes an already-loaded xarray.Dataset (not a path) since the stack report
3278
+ has the dataset in memory when it calls this function.
3279
+ """
3280
+ try:
3281
+ import matplotlib.pyplot as plt
3282
+ from oceanarray.plotters._current import plot_multi_aquadopp_trajectories
3283
+
3284
+ fig = plot_multi_aquadopp_trajectories(ds)
3285
+ if fig is None:
3286
+ return None
3287
+ b64 = _fig_to_base64(fig)
3288
+ plt.close(fig)
3289
+ return b64
3290
+ except Exception: # noqa: BLE001 — plot is optional; no aquadopps or missing vars → skip
3291
+ return None
3292
+
3293
+
3294
+ def _make_aquadopp_speed_profile(ds: "xr.Dataset") -> Optional[str]:
3295
+ """Horizontal speed boxplots per Aquadopp positioned by HAB, for stack page.
3296
+
3297
+ Takes an already-loaded xarray.Dataset (not a path).
3298
+ """
3299
+ try:
3300
+ import matplotlib.pyplot as plt
3301
+ from oceanarray.plotters._current import plot_aquadopp_speed_profile
3302
+
3303
+ fig = plot_aquadopp_speed_profile(ds)
3304
+ if fig is None:
3305
+ return None
3306
+ b64 = _fig_to_base64(fig)
3307
+ plt.close(fig)
3308
+ return b64
3309
+ except Exception: # noqa: BLE001 — plot is optional; no aquadopps or missing vars → skip
3310
+ return None
3311
+
3312
+
3313
+ def _make_adcp_trajectories_b64(ds: "xr.Dataset") -> Optional[str]:
3314
+ """Per-bin ADCP particle trajectories coloured by HAB, for stack page.
3315
+
3316
+ Takes an already-loaded xarray.Dataset (not a path).
3317
+ """
3318
+ try:
3319
+ import matplotlib.pyplot as plt
3320
+ from oceanarray.plotters._current import plot_adcp_trajectories
3321
+
3322
+ fig = plot_adcp_trajectories(ds)
3323
+ if fig is None:
3324
+ return None
3325
+ b64 = _fig_to_base64(fig)
3326
+ plt.close(fig)
3327
+ return b64
3328
+ except Exception: # noqa: BLE001 — plot is optional; no ADCP bins or missing vars → skip
3329
+ return None
3330
+
3331
+
3332
+ def _make_adcp_velocity_b64(nc_path: str) -> Optional[str]:
3333
+ """Stacked colour panels for the ADCP per-instrument HTML report page.
3334
+
3335
+ Reads the stage-3 NetCDF file at *nc_path* and produces a multi-panel
3336
+ time × range pcolormesh figure encoded as a base64 PNG string for HTML embedding.
3337
+
3338
+ **Coordinate convention**: velocities are in geographic ENU (East–North–Up), after
3339
+ magnetic declination correction applied in stage 3. Positive east = rightward facing
3340
+ north; positive north = toward True North.
3341
+
3342
+ **Direction convention** (``current_direction`` panel): direction *toward which* the
3343
+ water flows, clockwise from True North (0° = northward, 90° = eastward). This is
3344
+ the oceanographic convention, opposite to meteorological "direction from".
3345
+ Computed as ``atan2(east, north) mod 360``.
3346
+
3347
+ Panels rendered for each variable present in the file:
3348
+
3349
+ ==================== ================================ ===================
3350
+ Variable Label Colormap
3351
+ ==================== ================================ ===================
3352
+ east_velocity East velocity (m s⁻¹) Spectral_r (div)
3353
+ north_velocity North velocity (m s⁻¹) Spectral_r (div)
3354
+ up_velocity Up velocity (m s⁻¹) Spectral_r (div)
3355
+ error_velocity Error velocity (m s⁻¹) Spectral_r (div)
3356
+ current_speed Current speed (m s⁻¹) plasma (seq, 0→98th)
3357
+ current_direction Current direction (°T) hsv (cyclic, 0–360°)
3358
+ bin_pressure Bin pressure (dbar) viridis (seq)
3359
+ ==================== ================================ ===================
3360
+
3361
+ Diverging panels (east/north/up/error) share symmetric colormap bounds set to
3362
+ ±max(|2nd pctile|, |98th pctile|) of all finite ENU velocity values.
3363
+
3364
+ Bins flagged at or below the seabed (``seabed_qc >= 3``) are masked to NaN.
3365
+ Y-axis (range coordinate) is inverted for downward-looking instruments (pressure
3366
+ increases into the page); non-inverted for upward-looking.
3367
+
3368
+ Parameters
3369
+ ----------
3370
+ nc_path : str
3371
+ Path to a stage-3 NetCDF file for a single ADCP instrument.
3372
+
3373
+ Returns
3374
+ -------
3375
+ str or None
3376
+ Base64-encoded PNG string (for embedding in HTML as
3377
+ ``<img src="data:image/png;base64,…">``), or None if the file cannot be
3378
+ opened, no velocity data are present, or the range coordinate is absent.
3379
+
3380
+ """
3381
+ try:
3382
+ import matplotlib.pyplot as plt
3383
+ import matplotlib.dates as mdates
3384
+ import xarray as xr
3385
+ from .. import parameters as P
3386
+
3387
+ # (varname, label, panel_type) — panel_type: "div" | "seq" | "cyc"
3388
+ _COMPONENTS = [
3389
+ ("east_velocity", "East velocity (m s⁻¹)", "div"),
3390
+ ("north_velocity", "North velocity (m s⁻¹)", "div"),
3391
+ ("up_velocity", "Up velocity (m s⁻¹)", "div"),
3392
+ ("error_velocity", "Error velocity (m s⁻¹)", "div"),
3393
+ ("current_speed", "Current speed (m s⁻¹)", "seq"),
3394
+ ("current_direction", "Current direction (°T)", "cyc"),
3395
+ ("bin_pressure", "Bin pressure (dbar)", "seq"),
3396
+ ]
3397
+
3398
+ with xr.open_dataset(nc_path, decode_timedelta=False) as ds:
3399
+ # Compute speed and direction from east/north if both present
3400
+ arrays = {}
3401
+ for v in ds.data_vars:
3402
+ arrays[v] = ds[v]
3403
+ if "east_velocity" in ds and "north_velocity" in ds:
3404
+ e = ds["east_velocity"].values.astype(float)
3405
+ n = ds["north_velocity"].values.astype(float)
3406
+ spd = np.sqrt(e**2 + n**2)
3407
+ # Oceanographic convention: direction water flows TO, 0=N clockwise
3408
+ dirn = np.degrees(np.arctan2(e, n)) % 360.0
3409
+ arrays["current_speed"] = spd
3410
+ arrays["current_direction"] = dirn
3411
+
3412
+ # Apply seabed mask (seabed_qc ≥ 3 → at or below seabed) to all
3413
+ # (time, N_BINS) arrays before plotting. Done after speed/direction
3414
+ # are computed so they are masked consistently.
3415
+ if "seabed_qc" in ds.data_vars:
3416
+ seabed_mask = ds["seabed_qc"].values >= 3 # (time, N_BINS)
3417
+ masked_arrays = {}
3418
+ for k, v in arrays.items():
3419
+ arr = v if isinstance(v, np.ndarray) else v.values
3420
+ if arr.ndim == 2 and arr.shape == seabed_mask.shape:
3421
+ arr = arr.astype(float).copy()
3422
+ arr[seabed_mask] = np.nan
3423
+ masked_arrays[k] = arr
3424
+ arrays = masked_arrays
3425
+
3426
+ present = [(v, lbl, pt) for v, lbl, pt in _COMPONENTS if v in arrays]
3427
+ if not present:
3428
+ return None
3429
+
3430
+ time = ds["time"].values
3431
+
3432
+ if "range" in ds.coords:
3433
+ range_coord = ds["range"].values
3434
+ else:
3435
+ range_coord = None
3436
+ for v, _, _ in present:
3437
+ if v not in ds:
3438
+ continue
3439
+ for dim in ds[v].dims:
3440
+ if dim == "time":
3441
+ continue
3442
+ for _cn, cda in ds.coords.items():
3443
+ if cda.dims == (dim,):
3444
+ range_coord = cda.values
3445
+ break
3446
+ if range_coord is not None:
3447
+ break
3448
+ if range_coord is not None:
3449
+ break
3450
+ if range_coord is None:
3451
+ return None
3452
+
3453
+ # Shared diverging bound from all ENU components (2nd/98th pctile magnitude)
3454
+ # Passed to _velocity_panel_style for div panels; seq/cyc panels compute
3455
+ # their own bounds inside the helper from the per-panel finite values.
3456
+ div_vals = np.concatenate(
3457
+ [
3458
+ arrays[v].ravel()
3459
+ if isinstance(arrays[v], np.ndarray)
3460
+ else arrays[v].values.ravel()
3461
+ for v, _, pt in present
3462
+ if pt == "div"
3463
+ ]
3464
+ )
3465
+ finite_div = div_vals[np.isfinite(div_vals)]
3466
+ abs_max = (
3467
+ max(
3468
+ abs(float(np.percentile(finite_div, 2))),
3469
+ abs(float(np.percentile(finite_div, 98))),
3470
+ 1e-4,
3471
+ )
3472
+ if len(finite_div)
3473
+ else 1.0
3474
+ )
3475
+
3476
+ plt.style.use(str(P.MPLSTYLE))
3477
+ n = len(present)
3478
+ fig, axes = plt.subplots(
3479
+ n, 1, figsize=(13, 3.5 * n), sharex=True, squeeze=False
3480
+ )
3481
+
3482
+ orientation = ds.attrs.get("orientation_yaml") or ds.attrs.get(
3483
+ "orientation_instrument", "down"
3484
+ )
3485
+ looking_down = str(orientation).lower() == "down"
3486
+ ylabel = (
3487
+ "Range (m, ↓ deeper)" if looking_down else "Range (m from transducer)"
3488
+ )
3489
+
3490
+ # Determine the deepest range to show.
3491
+ # Prefer seabed_qc: deepest bin that is ever above the seabed
3492
+ # (flag 1 = good). Fall back to deepest bin with any finite
3493
+ # velocity data. Fall back further to the full range_coord span.
3494
+ if "seabed_qc" in ds.data_vars:
3495
+ seabed_qc_vals = ds["seabed_qc"].values # (time, N_BINS)
3496
+ bin_ever_good = np.any(seabed_qc_vals == 1, axis=0) # (N_BINS,)
3497
+ range_max = (
3498
+ float(range_coord[bin_ever_good].max())
3499
+ if bin_ever_good.any()
3500
+ else float(range_coord.max())
3501
+ )
3502
+ else:
3503
+ range_max = float(range_coord.max())
3504
+ for vel_var in ("east_velocity", "north_velocity", "up_velocity"):
3505
+ if vel_var not in arrays:
3506
+ continue
3507
+ vel = (
3508
+ arrays[vel_var]
3509
+ if isinstance(arrays[vel_var], np.ndarray)
3510
+ else arrays[vel_var].values
3511
+ )
3512
+ bin_has_data = np.any(np.isfinite(vel), axis=0) # (N_BINS,)
3513
+ if bin_has_data.any():
3514
+ range_max = float(range_coord[bin_has_data].max())
3515
+ break
3516
+
3517
+ for ax, (var, label, _pt) in zip(axes[:, 0], present):
3518
+ raw = arrays[var]
3519
+ data2d = raw if isinstance(raw, np.ndarray) else raw.values
3520
+ # Ensure (time, N_BINS) then transpose to (N_BINS, time) for pcolormesh
3521
+ if data2d.shape[0] != len(time):
3522
+ data2d = data2d.T
3523
+ data2d = data2d.T # now (N_BINS, time)
3524
+
3525
+ fv = data2d.ravel()
3526
+ fv = fv[np.isfinite(fv)]
3527
+ bounds, norm, cmap, cb_label = _velocity_panel_style(var, fv, abs_max)
3528
+ pc = ax.pcolormesh(
3529
+ time, range_coord, data2d, shading="nearest", cmap=cmap, norm=norm
3530
+ )
3531
+ cb = fig.colorbar(pc, ax=ax, pad=0.02, ticks=bounds[::2])
3532
+ cb.set_label(cb_label)
3533
+ ax.set_ylabel(ylabel)
3534
+ ax.set_title(label, loc="left")
3535
+ ax.grid(True, linestyle="--", linewidth=0.3, alpha=0.4)
3536
+ # Show from 0 (includes blanking zone) to deepest valid bin.
3537
+ # set_ylim with reversed args inverts for downward-looking.
3538
+ if looking_down:
3539
+ ax.set_ylim(range_max, 0.0)
3540
+ else:
3541
+ ax.set_ylim(0.0, range_max)
3542
+
3543
+ locator = mdates.AutoDateLocator()
3544
+ axes[-1, 0].xaxis.set_major_locator(locator)
3545
+ axes[-1, 0].xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
3546
+
3547
+ fig.tight_layout()
3548
+ b64 = _fig_to_base64(fig)
3549
+ plt.close(fig)
3550
+ return b64
3551
+ except Exception: # noqa: BLE001 — plot is optional; missing vars or bad data → skip
3552
+ return None
3553
+
3554
+
3555
+ def _draw_hodograph_pair(
3556
+ ax_raw: Any,
3557
+ ax_eddy: Any,
3558
+ east_1d: np.ndarray,
3559
+ north_1d: np.ndarray,
3560
+ label: str,
3561
+ smooth_hours: float,
3562
+ lp_days: float,
3563
+ units: str,
3564
+ dt_s: float,
3565
+ ) -> None:
3566
+ """Draw one row (raw + eddy panels) of a hodograph figure onto two Axes.
3567
+
3568
+ Computes a 2-D density heatmap (hexbin) of east vs north velocity with a
3569
+ thin downsampled trajectory line overlay for temporal context. Start and
3570
+ end positions are marked with distinct markers.
3571
+
3572
+ Parameters
3573
+ ----------
3574
+ ax_raw : matplotlib.axes.Axes
3575
+ Left panel — Tukey-smoothed raw east vs north velocity density.
3576
+ ax_eddy : matplotlib.axes.Axes
3577
+ Right panel — eddy component (LP mean removed, then Tukey smoothed).
3578
+ east_1d : np.ndarray
3579
+ 1-D eastward velocity timeseries (may contain NaN).
3580
+ north_1d : np.ndarray
3581
+ 1-D northward velocity timeseries (may contain NaN).
3582
+ label : str
3583
+ Short bin description used in the subplot title, e.g. ``"430 m range"``.
3584
+ smooth_hours : float
3585
+ Tukey window half-width in hours for the raw panel.
3586
+ lp_days : float
3587
+ Rolling-mean low-pass window in days for the eddy panel.
3588
+ units : str
3589
+ Velocity units string, e.g. ``"m s⁻¹"``.
3590
+ dt_s : float
3591
+ Sample interval in seconds.
3592
+
3593
+ """
3594
+ import matplotlib.pyplot as plt
3595
+ import pandas as pd
3596
+ from oceanarray.plotters._helpers import tukey_smooth
3597
+
3598
+ smooth_n = max(3, int(round(smooth_hours * 3600.0 / dt_s)))
3599
+ lp_n = max(3, int(round(lp_days * 86400.0 / dt_s)))
3600
+
3601
+ e_sm = tukey_smooth(east_1d, smooth_n)
3602
+ n_sm = tukey_smooth(north_1d, smooth_n)
3603
+
3604
+ e_lp = (
3605
+ pd.Series(east_1d)
3606
+ .rolling(window=lp_n, min_periods=1, center=True)
3607
+ .mean()
3608
+ .values
3609
+ )
3610
+ n_lp = (
3611
+ pd.Series(north_1d)
3612
+ .rolling(window=lp_n, min_periods=1, center=True)
3613
+ .mean()
3614
+ .values
3615
+ )
3616
+ e_eddy = tukey_smooth(east_1d - e_lp, smooth_n)
3617
+ n_eddy = tukey_smooth(north_1d - n_lp, smooth_n)
3618
+
3619
+ # Fractional time 0→1 for colour mapping; matches east_1d length
3620
+ t_frac = np.linspace(0.0, 1.0, len(east_1d))
3621
+
3622
+ def _draw(ax: Any, e: np.ndarray, n: np.ndarray, title: str) -> None:
3623
+ from matplotlib.collections import LineCollection
3624
+ import matplotlib.colors as mcolors
3625
+
3626
+ mask = np.isfinite(e) & np.isfinite(n)
3627
+ if mask.sum() < 2:
3628
+ ax.text(
3629
+ 0.5, 0.5, "No data", transform=ax.transAxes, ha="center", va="center"
3630
+ )
3631
+ ax.set_title(title, fontsize=9)
3632
+ return
3633
+
3634
+ e_v = e[mask]
3635
+ n_v = n[mask]
3636
+ t_v = t_frac[mask]
3637
+
3638
+ lim = max(np.nanmax(np.abs(e_v)), np.nanmax(np.abs(n_v)), 1e-9) * 1.1
3639
+
3640
+ # Time-coloured line hodograph (thin to ≤2000 segments for performance)
3641
+ step = max(1, len(e_v) // 2000)
3642
+ e_t = e_v[::step]
3643
+ n_t = n_v[::step]
3644
+ t_t = t_v[::step]
3645
+ pts = np.array([e_t, n_t]).T.reshape(-1, 1, 2)
3646
+ segs = np.concatenate([pts[:-1], pts[1:]], axis=1)
3647
+ norm_lc = mcolors.Normalize(0.0, 1.0)
3648
+ lc = LineCollection(segs, cmap="plasma", norm=norm_lc, lw=0.9, alpha=0.85)
3649
+ lc.set_array(t_t[:-1])
3650
+ ax.add_collection(lc)
3651
+
3652
+ # Compact per-panel time colorbar
3653
+ sm = plt.cm.ScalarMappable(cmap="plasma", norm=norm_lc)
3654
+ sm.set_array([])
3655
+ cb = ax.figure.colorbar(sm, ax=ax, shrink=0.75, pad=0.03, aspect=20)
3656
+ cb.set_label("Time →", size=8)
3657
+ cb.ax.set_yticks([0, 1])
3658
+ cb.ax.set_yticklabels(["start", "end"], size=7)
3659
+
3660
+ # Start (lime circle) and end (red square) markers
3661
+ ax.scatter(
3662
+ e_v[0],
3663
+ n_v[0],
3664
+ s=50,
3665
+ c="lime",
3666
+ marker="o",
3667
+ edgecolors="black",
3668
+ linewidths=0.7,
3669
+ zorder=5,
3670
+ )
3671
+ ax.scatter(
3672
+ e_v[-1],
3673
+ n_v[-1],
3674
+ s=55,
3675
+ c="red",
3676
+ marker="s",
3677
+ edgecolors="black",
3678
+ linewidths=0.7,
3679
+ zorder=5,
3680
+ )
3681
+ ax.set_xlim(-lim, lim)
3682
+ ax.set_ylim(-lim, lim)
3683
+ ax.set_aspect("equal")
3684
+ ax.axhline(0, color="#888", lw=0.7)
3685
+ ax.axvline(0, color="#888", lw=0.7)
3686
+ ax.set_xlabel(f"East ({units})")
3687
+ ax.set_ylabel(f"North ({units})")
3688
+ ax.set_title(title, fontsize=9)
3689
+ ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.3)
3690
+
3691
+ _draw(ax_raw, e_sm, n_sm, f"{label} — raw ({smooth_hours:.0f}-h smoothed)")
3692
+ _draw(ax_eddy, e_eddy, n_eddy, f"{label} — eddy ({lp_days:.0f}-day LP removed)")
3693
+
3694
+
3695
+ def _make_adcp_rose_b64(nc_path: str) -> Optional[str]:
3696
+ """Current rose panels for an ADCP: depth-average plus percentile-selected bins.
3697
+
3698
+ Selects the depth-average and up to four individual range bins at the 10th,
3699
+ 37th, 63rd, and 90th percentile positions of the valid bin indices (bins with
3700
+ at least 5 % of time steps having finite data). Returns None if east/north
3701
+ velocity are absent or too few samples exist.
3702
+ """
3703
+ try:
3704
+ import matplotlib.pyplot as plt
3705
+ import xarray as xr
3706
+ from .. import parameters as P
3707
+
3708
+ _PERCENTILES = [10, 37, 63, 90]
3709
+
3710
+ with xr.open_dataset(nc_path, decode_timedelta=False) as ds:
3711
+ if "east_velocity" not in ds or "north_velocity" not in ds:
3712
+ return None
3713
+
3714
+ east_2d = ds["east_velocity"].values.astype(float) # (time, N_BINS)
3715
+ north_2d = ds["north_velocity"].values.astype(float)
3716
+
3717
+ n_time, n_bins = east_2d.shape
3718
+
3719
+ if "range" in ds.coords:
3720
+ range_vals = ds["range"].values
3721
+ else:
3722
+ range_vals = np.arange(n_bins, dtype=float)
3723
+
3724
+ # Find valid bins: at least 5 % of time steps have finite data
3725
+ min_finite = max(2, int(0.05 * n_time))
3726
+ valid_mask = np.array(
3727
+ [
3728
+ np.sum(np.isfinite(east_2d[:, i])) >= min_finite
3729
+ for i in range(n_bins)
3730
+ ]
3731
+ )
3732
+ valid_bins = np.where(valid_mask)[0]
3733
+
3734
+ # Build panel list: depth-average first, then percentile-selected bins
3735
+ panels = []
3736
+
3737
+ e_mean = np.nanmean(east_2d, axis=1)
3738
+ n_mean = np.nanmean(north_2d, axis=1)
3739
+ if np.sum(np.isfinite(e_mean)) >= 2:
3740
+ panels.append((e_mean, n_mean, "Depth average"))
3741
+
3742
+ if len(valid_bins) >= 1:
3743
+ # Select bins at percentile positions; deduplicate
3744
+ selected_indices = []
3745
+ for pct in _PERCENTILES:
3746
+ pos = int(round(pct / 100.0 * (len(valid_bins) - 1)))
3747
+ pos = max(0, min(len(valid_bins) - 1, pos))
3748
+ idx = int(valid_bins[pos])
3749
+ if idx not in selected_indices:
3750
+ selected_indices.append(idx)
3751
+
3752
+ for idx in selected_indices:
3753
+ e_bin = east_2d[:, idx]
3754
+ n_bin = north_2d[:, idx]
3755
+ if np.sum(np.isfinite(e_bin)) < 2:
3756
+ continue
3757
+ actual = float(range_vals[idx])
3758
+ panels.append((e_bin, n_bin, f"{actual:.0f} m"))
3759
+
3760
+ if not panels:
3761
+ return None
3762
+
3763
+ # Shared speed scale across all panels (99th percentile of all data)
3764
+ all_speeds = np.concatenate([np.sqrt(e**2 + n**2) for e, n, _ in panels])
3765
+ shared_max = max(
3766
+ float(np.nanpercentile(all_speeds[np.isfinite(all_speeds)], 99)), 1e-9
3767
+ )
3768
+
3769
+ plt.style.use(str(P.MPLSTYLE))
3770
+ ncols = len(panels)
3771
+ fig, axs = plt.subplots(
3772
+ 1,
3773
+ ncols,
3774
+ figsize=(ncols * 3.2, 4.0),
3775
+ subplot_kw={"projection": "polar"},
3776
+ squeeze=False,
3777
+ )
3778
+ spd_edges = colors = None
3779
+ for ax, (east, north, title) in zip(axs[0], panels):
3780
+ result = _rose_ax(ax, east, north, title=title, max_speed=shared_max)
3781
+ if result is not None:
3782
+ spd_edges, colors = result
3783
+
3784
+ # Add shared colorbar below the rose panels
3785
+ if spd_edges is not None and colors is not None:
3786
+ import matplotlib.colors as mcolors
3787
+ import matplotlib.cm as mcm
3788
+
3789
+ cmap_obj = mcolors.ListedColormap(colors)
3790
+ norm = mcolors.BoundaryNorm(spd_edges, len(colors))
3791
+ sm = mcm.ScalarMappable(cmap=cmap_obj, norm=norm)
3792
+ sm.set_array([])
3793
+ cbar = fig.colorbar(
3794
+ sm,
3795
+ ax=axs[0].tolist(),
3796
+ orientation="horizontal",
3797
+ fraction=0.04,
3798
+ pad=0.08,
3799
+ aspect=40,
3800
+ )
3801
+ cbar.set_label("Speed (m s⁻¹)")
3802
+ cbar.set_ticks(spd_edges)
3803
+ cbar.set_ticklabels([f"{v:.2f}" for v in spd_edges])
3804
+
3805
+ b64 = _fig_to_base64(fig)
3806
+ plt.close(fig)
3807
+ return b64
3808
+ except Exception: # noqa: BLE001
3809
+ return None
3810
+
3811
+
3812
+ def _make_adcp_hodograph_b64(
3813
+ nc_path: str, lp_days: float = 4.0, smooth_hours: float = 24.0
3814
+ ) -> Optional[str]:
3815
+ """Two-depth hodograph for an ADCP per-instrument report.
3816
+
3817
+ Picks the bins nearest the 25th and 75th percentile of the valid range and
3818
+ renders a 2×2 figure: top row = far bin (75th pctile — typically shallower
3819
+ for upward-looking), bottom row = near bin (25th pctile). Left column =
3820
+ Tukey-smoothed raw; right column = eddy (LP mean removed). Returns None if
3821
+ east/north velocity are absent, 1-D, or too few valid bins.
3822
+
3823
+ Accepts both the current naming convention (``east_velocity`` /
3824
+ ``north_velocity``) and the legacy dolfyn names (``u`` / ``v``) so that
3825
+ files produced before the stage1 renaming was introduced can still be
3826
+ visualised. Files need to be regenerated with the current stage1 to get
3827
+ the canonical names.
3828
+ """
3829
+ try:
3830
+ import matplotlib.pyplot as plt
3831
+ import xarray as xr
3832
+ from .. import parameters as P
3833
+
3834
+ with xr.open_dataset(nc_path, decode_timedelta=False) as ds:
3835
+ # Support both canonical names and legacy dolfyn names
3836
+ u_var = next((v for v in ("east_velocity", "u") if v in ds), None)
3837
+ v_var = next((v for v in ("north_velocity", "v") if v in ds), None)
3838
+ if u_var is None or v_var is None:
3839
+ return None
3840
+
3841
+ # Normalise dimension order to (time, bins)
3842
+ east_da = ds[u_var]
3843
+ north_da = ds[v_var]
3844
+ if east_da.dims[0] != "time":
3845
+ east_da = east_da.transpose("time", ...)
3846
+ north_da = north_da.transpose("time", ...)
3847
+ east_2d = east_da.values.astype(float)
3848
+ north_2d = north_da.values.astype(float)
3849
+ if east_2d.ndim != 2:
3850
+ return None
3851
+
3852
+ n_time, n_bins = east_2d.shape
3853
+ if n_time < 4:
3854
+ return None
3855
+
3856
+ # Range coordinate — try both the current dim name and "range"
3857
+ bin_dim = east_da.dims[1] if east_da.ndim == 2 else None
3858
+ range_coord = next(
3859
+ (c for c in ("range", bin_dim) if c and c in ds.coords),
3860
+ None,
3861
+ )
3862
+ range_vals = (
3863
+ ds.coords[range_coord].values
3864
+ if range_coord is not None
3865
+ else np.arange(n_bins, dtype=float)
3866
+ )
3867
+ units = ds[u_var].attrs.get("units", "m s⁻¹")
3868
+
3869
+ if "time" in ds.coords and ds["time"].size >= 2:
3870
+ t_ns = ds["time"].values.astype("datetime64[ns]").astype(float)
3871
+ dt_s = float(np.nanmedian(np.diff(t_ns))) / 1e9
3872
+ else:
3873
+ dt_s = 3600.0
3874
+ dt_s = max(dt_s, 1.0)
3875
+
3876
+ min_finite = max(2, int(0.05 * n_time))
3877
+ valid_mask = np.array(
3878
+ [np.sum(np.isfinite(east_2d[:, i])) >= min_finite for i in range(n_bins)]
3879
+ )
3880
+ valid_bins = np.where(valid_mask)[0]
3881
+ if len(valid_bins) < 2:
3882
+ return None
3883
+
3884
+ i_near = valid_bins[max(0, int(round(0.25 * (len(valid_bins) - 1))))]
3885
+ i_far = valid_bins[
3886
+ min(len(valid_bins) - 1, int(round(0.75 * (len(valid_bins) - 1))))
3887
+ ]
3888
+ if i_near == i_far:
3889
+ return None
3890
+
3891
+ label_far = f"{float(range_vals[i_far]):.0f} m range"
3892
+ label_near = f"{float(range_vals[i_near]):.0f} m range"
3893
+
3894
+ plt.style.use(str(P.MPLSTYLE))
3895
+ fig, axes = plt.subplots(2, 2, figsize=(13, 9))
3896
+ fig.subplots_adjust(hspace=0.55, wspace=0.45)
3897
+
3898
+ _draw_hodograph_pair(
3899
+ axes[0, 0],
3900
+ axes[0, 1],
3901
+ east_2d[:, i_far],
3902
+ north_2d[:, i_far],
3903
+ f"Far bin ({label_far})",
3904
+ smooth_hours,
3905
+ lp_days,
3906
+ units,
3907
+ dt_s,
3908
+ )
3909
+ _draw_hodograph_pair(
3910
+ axes[1, 0],
3911
+ axes[1, 1],
3912
+ east_2d[:, i_near],
3913
+ north_2d[:, i_near],
3914
+ f"Near bin ({label_near})",
3915
+ smooth_hours,
3916
+ lp_days,
3917
+ units,
3918
+ dt_s,
3919
+ )
3920
+
3921
+ b64 = _fig_to_base64(fig)
3922
+ plt.close(fig)
3923
+ return b64
3924
+ except Exception: # noqa: BLE001 — plot is optional; missing vars or bad data → skip
3925
+ return None
3926
+
3927
+
3928
+ def _make_grid_hodograph_b64(
3929
+ ds: "xr.Dataset", smooth_hours: float = 24.0
3930
+ ) -> Optional[str]:
3931
+ """Two-depth hodograph for the grid report.
3932
+
3933
+ Takes an already-loaded xr.Dataset (not a path). Picks the pressure levels
3934
+ nearest the 25th and 75th percentile of the valid gridded pressure range and
3935
+ renders a 1×2 figure (shallow / deep, each showing the ``smooth_hours``-smoothed
3936
+ hodograph). Returns None if east/north velocity are absent or fewer than two
3937
+ valid levels exist.
3938
+ """
3939
+ try:
3940
+ import matplotlib.pyplot as plt
3941
+ from .. import parameters as P
3942
+
3943
+ if "east_velocity" not in ds or "north_velocity" not in ds:
3944
+ return None
3945
+ east_2d = ds["east_velocity"].values.astype(float) # (time, pressure)
3946
+ north_2d = ds["north_velocity"].values.astype(float)
3947
+ if east_2d.ndim != 2:
3948
+ return None
3949
+
3950
+ n_time, n_levels = east_2d.shape
3951
+ if n_time < 4:
3952
+ return None
3953
+
3954
+ # Apply QC masking where companion QC variables are present
3955
+ if "east_velocity_qc" in ds:
3956
+ east_2d = np.where(ds["east_velocity_qc"].values >= 3, np.nan, east_2d)
3957
+ if "north_velocity_qc" in ds:
3958
+ north_2d = np.where(ds["north_velocity_qc"].values >= 3, np.nan, north_2d)
3959
+
3960
+ if "pressure" in ds.coords:
3961
+ pressure = ds.coords["pressure"].values
3962
+ elif "pressure" in ds:
3963
+ pressure = ds["pressure"].values
3964
+ else:
3965
+ pressure = np.arange(n_levels, dtype=float)
3966
+
3967
+ units = ds["east_velocity"].attrs.get("units", "m s⁻¹")
3968
+
3969
+ if "time" in ds.coords and ds["time"].size >= 2:
3970
+ t_ns = ds["time"].values.astype("datetime64[ns]").astype(float)
3971
+ dt_s = float(np.nanmedian(np.diff(t_ns))) / 1e9
3972
+ else:
3973
+ dt_s = 3600.0
3974
+ dt_s = max(dt_s, 1.0)
3975
+
3976
+ min_finite = max(2, int(0.05 * n_time))
3977
+ valid_plevs = np.array(
3978
+ [
3979
+ k
3980
+ for k in range(n_levels)
3981
+ if np.sum(np.isfinite(east_2d[:, k])) >= min_finite
3982
+ ]
3983
+ )
3984
+ if len(valid_plevs) < 2:
3985
+ return None
3986
+
3987
+ i_shallow = valid_plevs[max(0, int(round(0.25 * (len(valid_plevs) - 1))))]
3988
+ i_deep = valid_plevs[
3989
+ min(len(valid_plevs) - 1, int(round(0.75 * (len(valid_plevs) - 1))))
3990
+ ]
3991
+ if i_shallow == i_deep:
3992
+ return None
3993
+
3994
+ label_shallow = f"{int(pressure[i_shallow])} dbar"
3995
+ label_deep = f"{int(pressure[i_deep])} dbar"
3996
+
3997
+ from oceanarray.plotters._helpers import tukey_smooth
3998
+
3999
+ smooth_n = max(3, int(round(smooth_hours * 3600.0 / dt_s)))
4000
+
4001
+ plt.style.use(str(P.MPLSTYLE))
4002
+ fig, (ax_shallow, ax_deep) = plt.subplots(1, 2, figsize=(13, 6))
4003
+ fig.subplots_adjust(wspace=0.45)
4004
+
4005
+ for ax, i_lev, label in [
4006
+ (ax_shallow, i_shallow, f"Shallow ({label_shallow})"),
4007
+ (ax_deep, i_deep, f"Deep ({label_deep})"),
4008
+ ]:
4009
+ e_sm = tukey_smooth(east_2d[:, i_lev], smooth_n)
4010
+ n_sm = tukey_smooth(north_2d[:, i_lev], smooth_n)
4011
+ mask = np.isfinite(e_sm) & np.isfinite(n_sm)
4012
+ if mask.sum() < 2:
4013
+ ax.text(
4014
+ 0.5,
4015
+ 0.5,
4016
+ "No data",
4017
+ transform=ax.transAxes,
4018
+ ha="center",
4019
+ va="center",
4020
+ )
4021
+ ax.set_title(label, fontsize=9)
4022
+ continue
4023
+ e_v, n_v = e_sm[mask], n_sm[mask]
4024
+ t_frac = np.linspace(0.0, 1.0, len(east_2d[:, i_lev]))[mask]
4025
+ lim = max(np.nanmax(np.abs(e_v)), np.nanmax(np.abs(n_v)), 1e-9) * 1.1
4026
+ step = max(1, len(e_v) // 2000)
4027
+ pts = np.array([e_v[::step], n_v[::step]]).T.reshape(-1, 1, 2)
4028
+ segs = np.concatenate([pts[:-1], pts[1:]], axis=1)
4029
+ from matplotlib.collections import LineCollection
4030
+ import matplotlib.colors as mcolors
4031
+
4032
+ norm_lc = mcolors.Normalize(0.0, 1.0)
4033
+ lc = LineCollection(segs, cmap="plasma", norm=norm_lc, lw=0.9, alpha=0.85)
4034
+ lc.set_array(t_frac[::step][:-1])
4035
+ ax.add_collection(lc)
4036
+ sm = plt.cm.ScalarMappable(cmap="plasma", norm=norm_lc)
4037
+ sm.set_array([])
4038
+ cb = fig.colorbar(sm, ax=ax, shrink=0.75, pad=0.03, aspect=20)
4039
+ cb.set_label("Time →", size=8)
4040
+ cb.ax.set_yticks([0, 1])
4041
+ cb.ax.set_yticklabels(["start", "end"], size=7)
4042
+ ax.scatter(
4043
+ e_v[0],
4044
+ n_v[0],
4045
+ s=50,
4046
+ c="lime",
4047
+ marker="o",
4048
+ edgecolors="black",
4049
+ linewidths=0.7,
4050
+ zorder=5,
4051
+ )
4052
+ ax.scatter(
4053
+ e_v[-1],
4054
+ n_v[-1],
4055
+ s=55,
4056
+ c="red",
4057
+ marker="s",
4058
+ edgecolors="black",
4059
+ linewidths=0.7,
4060
+ zorder=5,
4061
+ )
4062
+ ax.set_xlim(-lim, lim)
4063
+ ax.set_ylim(-lim, lim)
4064
+ ax.set_aspect("equal")
4065
+ ax.axhline(0, color="#888", lw=0.7)
4066
+ ax.axvline(0, color="#888", lw=0.7)
4067
+ ax.set_xlabel(f"East ({units})")
4068
+ ax.set_ylabel(f"North ({units})")
4069
+ ax.set_title(f"{label} — {smooth_hours:.0f}-h smoothed", fontsize=9)
4070
+ ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.3)
4071
+
4072
+ b64 = _fig_to_base64(fig)
4073
+ plt.close(fig)
4074
+ return b64
4075
+ except Exception: # noqa: BLE001 — plot is optional; missing vars or bad data → skip
4076
+ return None
4077
+
4078
+
4079
+ def _make_analog_timeseries(nc_path: "Path", analog_vars: "List[str]") -> Optional[str]:
4080
+ """Full-record time series for analog channel variables, one panel per variable.
4081
+
4082
+ Only generates a plot when *analog_vars* is non-empty (caller should check
4083
+ via nc_meta['analog_vars'] before calling). Returns None on any error or if
4084
+ the dataset lacks a time dimension.
4085
+ """
4086
+ if not analog_vars:
4087
+ return None
4088
+ try:
4089
+ import matplotlib.pyplot as plt
4090
+ import xarray as xr
4091
+ from .. import parameters as P
4092
+
4093
+ plt.style.use(str(P.MPLSTYLE))
4094
+ ds = xr.open_dataset(nc_path, decode_timedelta=False).load()
4095
+
4096
+ if "time" not in ds.dims:
4097
+ ds.close()
4098
+ return None
4099
+
4100
+ time = ds["time"].values
4101
+ n_vars = len(analog_vars)
4102
+ fig, axes = plt.subplots(
4103
+ n_vars,
4104
+ 1,
4105
+ figsize=(10, max(2.5, n_vars * 2.0)),
4106
+ sharex=True,
4107
+ squeeze=False,
4108
+ )
4109
+
4110
+ colors = ["steelblue", "darkorange", "seagreen", "crimson"]
4111
+ for row, vname in enumerate(analog_vars):
4112
+ ax = axes[row][0]
4113
+ raw = ds[vname].values
4114
+ units = ds[vname].attrs.get("units", "")
4115
+ long_name = ds[vname].attrs.get("long_name", vname)
4116
+ ylabel = f"{long_name} ({units})" if units else long_name
4117
+
4118
+ # Stack NC: shape (time, N_LEVELS) — plot each level with finite data
4119
+ if raw.ndim == 2:
4120
+ n_plotted = 0
4121
+ for lvl_i in range(raw.shape[1]):
4122
+ row_data = raw[:, lvl_i]
4123
+ if np.any(np.isfinite(row_data)):
4124
+ label = None
4125
+ if "serial" in ds.coords:
4126
+ label = str(ds["serial"].values[lvl_i])
4127
+ ax.plot(
4128
+ time,
4129
+ row_data,
4130
+ linewidth=0.8,
4131
+ color=colors[n_plotted % len(colors)],
4132
+ label=label,
4133
+ )
4134
+ n_plotted += 1
4135
+ if n_plotted > 1 and "serial" in ds.coords:
4136
+ ax.legend(fontsize=6, loc="upper right")
4137
+ else:
4138
+ ax.plot(time, raw, linewidth=0.8, color="steelblue")
4139
+
4140
+ ax.set_ylabel(ylabel, fontsize=7)
4141
+ ax.tick_params(axis="both", labelsize=7)
4142
+ ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.5)
4143
+
4144
+ fig.autofmt_xdate(rotation=30, ha="right")
4145
+ fig.tight_layout()
4146
+ ds.close()
4147
+ b64 = _fig_to_base64(fig)
4148
+ plt.close(fig)
4149
+ return b64
4150
+ except Exception: # noqa: BLE001
4151
+ return None
4152
+
4153
+
4154
+ def _make_knockdown_pressure_b64(ds: "xr.Dataset") -> Optional[str]:
4155
+ """IQR of measured pressure vs. nominal pressure, for the stack report.
4156
+
4157
+ Thin Tier-3 wrapper around
4158
+ ``plotters._diagnostic.plot_knockdown_pressure``. Takes an already-loaded
4159
+ xarray.Dataset (not a path).
4160
+
4161
+ Returns a base64-encoded PNG string or None when no data are available.
4162
+ """
4163
+ try:
4164
+ import matplotlib.pyplot as plt
4165
+ from oceanarray.plotters._diagnostic import plot_knockdown_pressure
4166
+
4167
+ fig = plot_knockdown_pressure(ds)
4168
+ if fig is None:
4169
+ return None
4170
+ b64 = _fig_to_base64(fig)
4171
+ plt.close(fig)
4172
+ return b64
4173
+ except Exception: # noqa: BLE001
4174
+ return None
4175
+
4176
+
4177
+ def _make_knockdown_hab_b64(ds: "xr.Dataset") -> Optional[str]:
4178
+ """IQR of measured HAB vs. nominal HAB, for the stack report.
4179
+
4180
+ Thin Tier-3 wrapper around
4181
+ ``plotters._diagnostic.plot_knockdown_hab``. Takes an already-loaded
4182
+ xarray.Dataset (not a path).
4183
+
4184
+ Returns a base64-encoded PNG string or None when no data are available.
4185
+ """
4186
+ try:
4187
+ import matplotlib.pyplot as plt
4188
+ from oceanarray.plotters._diagnostic import plot_knockdown_hab
4189
+
4190
+ fig = plot_knockdown_hab(ds)
4191
+ if fig is None:
4192
+ return None
4193
+ b64 = _fig_to_base64(fig)
4194
+ plt.close(fig)
4195
+ return b64
4196
+ except Exception: # noqa: BLE001
4197
+ return None
4198
+
4199
+
4200
+ def _make_knockdown_displacement_b64(ds: "xr.Dataset") -> Optional[str]:
4201
+ """Scatter of estimated horizontal displacement vs. measured pressure.
4202
+
4203
+ Thin Tier-3 wrapper around
4204
+ ``plotters._diagnostic.plot_knockdown_displacement``.
4205
+
4206
+ Returns a base64-encoded PNG string or None when no data are available.
4207
+ """
4208
+ try:
4209
+ import matplotlib.pyplot as plt
4210
+ from oceanarray.plotters._diagnostic import plot_knockdown_displacement
4211
+
4212
+ fig = plot_knockdown_displacement(ds)
4213
+ if fig is None:
4214
+ return None
4215
+ b64 = _fig_to_base64(fig)
4216
+ plt.close(fig)
4217
+ return b64
4218
+ except Exception: # noqa: BLE001
4219
+ return None
4220
+
4221
+
4222
+ def _make_knockdown_anomaly_b64(ds: "xr.Dataset") -> Optional[str]:
4223
+ """IQR of pressure anomaly (measured − nominal) per instrument, for the stack report.
4224
+
4225
+ Thin Tier-3 wrapper around
4226
+ ``plotters._diagnostic.plot_knockdown_anomaly``. Takes an already-loaded
4227
+ xarray.Dataset (not a path).
4228
+
4229
+ Returns a base64-encoded PNG string or None when no data are available.
4230
+ """
4231
+ try:
4232
+ import matplotlib.pyplot as plt
4233
+ from oceanarray.plotters._diagnostic import plot_knockdown_anomaly
4234
+
4235
+ fig = plot_knockdown_anomaly(ds)
4236
+ if fig is None:
4237
+ return None
4238
+ b64 = _fig_to_base64(fig)
4239
+ plt.close(fig)
4240
+ return b64
4241
+ except Exception: # noqa: BLE001
4242
+ return None
4243
+
4244
+
4245
+ def _make_clock_check_b64(
4246
+ nc_paths: "Dict[str, Any]",
4247
+ deploy_dt: "Any",
4248
+ recover_dt: "Any",
4249
+ window_minutes: int = 30,
4250
+ ) -> Optional[str]:
4251
+ """Overlaid temperature comparison at deployment start/end, for the mooring summary.
4252
+
4253
+ Thin Tier-3 wrapper around
4254
+ ``plotters._diagnostic.plot_clock_offset_check``.
4255
+
4256
+ Parameters
4257
+ ----------
4258
+ nc_paths : dict of {serial: Path}
4259
+ Paths to stage-2 or stage-3 NetCDF files keyed by serial number.
4260
+ deploy_dt : datetime or None
4261
+ Deployment start time (UTC).
4262
+ recover_dt : datetime or None
4263
+ Recovery time (UTC).
4264
+ window_minutes : int
4265
+ Duration of each zoom window in minutes (default 30).
4266
+
4267
+ Returns
4268
+ -------
4269
+ str or None
4270
+ Base64-encoded PNG for HTML embedding, or None.
4271
+
4272
+ """
4273
+ try:
4274
+ import matplotlib.pyplot as plt
4275
+ from oceanarray.plotters._diagnostic import plot_clock_offset_check
4276
+
4277
+ fig = plot_clock_offset_check(nc_paths, deploy_dt, recover_dt, window_minutes)
4278
+ if fig is None:
4279
+ return None
4280
+ b64 = _fig_to_base64(fig)
4281
+ plt.close(fig)
4282
+ return b64
4283
+ except Exception: # noqa: BLE001
4284
+ return None