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.
- oceanarray/__init__.py +20 -0
- oceanarray/_version.py +1 -0
- oceanarray/cli.py +1343 -0
- oceanarray/clock_offset.py +675 -0
- oceanarray/config/OS1_sensor_attrs.yaml +49 -0
- oceanarray/config/OS1_var_names.yaml +5 -0
- oceanarray/config/OS1_vocab_attrs.yaml +84 -0
- oceanarray/config/legacy/project_RAPID.yaml +69 -0
- oceanarray/config/legacy/rodb_keys.txt +206 -0
- oceanarray/config/legacy/rodb_keys.yaml +1012 -0
- oceanarray/config/logging.yaml +28 -0
- oceanarray/legacy/__init__.py +47 -0
- oceanarray/legacy/caldip.py +753 -0
- oceanarray/legacy/convertOS.py +527 -0
- oceanarray/legacy/find_deployment.py +489 -0
- oceanarray/legacy/mooring_rodb.py +429 -0
- oceanarray/legacy/process_rodb.py +369 -0
- oceanarray/legacy/rodb.py +330 -0
- oceanarray/logger.py +192 -0
- oceanarray/logsheet/__init__.py +60 -0
- oceanarray/logsheet/_columns.py +97 -0
- oceanarray/logsheet/_config.py +245 -0
- oceanarray/logsheet/_firmware.py +43 -0
- oceanarray/logsheet/_latex.py +128 -0
- oceanarray/logsheet/_render.py +126 -0
- oceanarray/logsheet/builders/__init__.py +13 -0
- oceanarray/logsheet/builders/caldip_download.py +195 -0
- oceanarray/logsheet/builders/caldip_setup.py +188 -0
- oceanarray/logsheet/builders/deployment_setup.py +218 -0
- oceanarray/logsheet/builders/mooring_download.py +243 -0
- oceanarray/logsheet/builders/recovery.py +157 -0
- oceanarray/logsheet/data/column_defs.yaml +686 -0
- oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
- oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
- oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
- oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
- oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
- oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
- oceanarray/mooring_level.py +1368 -0
- oceanarray/oceanarray.mplstyle +18 -0
- oceanarray/parameters.py +270 -0
- oceanarray/plotter.py +942 -0
- oceanarray/plotters/__init__.py +97 -0
- oceanarray/plotters/_animation.py +261 -0
- oceanarray/plotters/_current.py +705 -0
- oceanarray/plotters/_diagnostic.py +667 -0
- oceanarray/plotters/_helpers.py +57 -0
- oceanarray/plotters/_primitives.py +101 -0
- oceanarray/plotters/_section.py +16 -0
- oceanarray/plotters/_timeseries.py +11 -0
- oceanarray/rapid_interp.py +739 -0
- oceanarray/read_rbr_hex.py +541 -0
- oceanarray/readers.py +303 -0
- oceanarray/report/__init__.py +5 -0
- oceanarray/report/_array.py +386 -0
- oceanarray/report/_grid.py +539 -0
- oceanarray/report/_html_helpers.py +1004 -0
- oceanarray/report/_instrument.py +853 -0
- oceanarray/report/_mooring.py +1385 -0
- oceanarray/report/_plots.py +4284 -0
- oceanarray/report/_stack.py +941 -0
- oceanarray/stage1.py +1959 -0
- oceanarray/stage2.py +1160 -0
- oceanarray/stage3.py +2494 -0
- oceanarray/time_gridding.py +958 -0
- oceanarray/tools.py +400 -0
- oceanarray/transports.py +1 -0
- oceanarray/utilities.py +538 -0
- oceanarray/validation.py +389 -0
- oceanarray/writers.py +99 -0
- oceanarray-0.1.0.dist-info/METADATA +503 -0
- oceanarray-0.1.0.dist-info/RECORD +76 -0
- oceanarray-0.1.0.dist-info/WHEEL +5 -0
- oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
- oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|