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,667 @@
|
|
|
1
|
+
"""Tier-2 domain wrappers for diagnostic plots (T-S, histograms, spectra, QC).
|
|
2
|
+
|
|
3
|
+
Post-OdB: migrate the following from plotter.py and report/_plots.py:
|
|
4
|
+
plot_qartod_summary, plot_climatology, scatter_profile_vs_PRES,
|
|
5
|
+
plot_ts_diagram (was _make_ts_diagram), plot_stack_ts_diagram,
|
|
6
|
+
plot_grid_ts_diagram, plot_data_histogram (was _make_data_histogram),
|
|
7
|
+
plot_spectrum (was _make_spectrum_fig_b64).
|
|
8
|
+
|
|
9
|
+
Tier-1 primitives: plot_vector_heatmap (for T-S, U-V, any pair),
|
|
10
|
+
plot_spectrum (any 1D time series), plot_polar_histogram (current rose).
|
|
11
|
+
|
|
12
|
+
See .claude/plotters_update-20260718.md for migration checklist.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TYPE_CHECKING, Dict, Optional
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
import matplotlib.figure
|
|
24
|
+
import xarray as xr
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Shared helper for stack knockdown plots
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
_KD_COLORS = [
|
|
33
|
+
(100.0, "mediumseagreen"),
|
|
34
|
+
(200.0, "gold"),
|
|
35
|
+
(300.0, "darkorange"),
|
|
36
|
+
(np.inf, "firebrick"),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _kd_color(magnitude_dbar: float) -> str:
|
|
41
|
+
"""Return a colour string for the given knockdown magnitude in dbar."""
|
|
42
|
+
return next(c for thresh, c in _KD_COLORS if magnitude_dbar < thresh)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _collect_knockdown_records(
|
|
46
|
+
ds: "xr.Dataset",
|
|
47
|
+
waterdepth: float,
|
|
48
|
+
) -> list:
|
|
49
|
+
"""Extract per-instrument knockdown arrays from a stack dataset.
|
|
50
|
+
|
|
51
|
+
Returns a list of ``(p_nom, serial, actual_pressure_array)`` tuples,
|
|
52
|
+
one per non-ADCP instrument with at least 10 valid pressure samples.
|
|
53
|
+
Interpolated pressure (QC flag 8) is excluded.
|
|
54
|
+
"""
|
|
55
|
+
serials = ds["serial"].values
|
|
56
|
+
habs = ds["hab"].values
|
|
57
|
+
instr_types = ds["instrument_type"].values
|
|
58
|
+
n_instr = ds.sizes["N_LEVELS"]
|
|
59
|
+
pressure_arr = ds["pressure"].values # (time, N_LEVELS)
|
|
60
|
+
pqc_arr = ds["pressure_qc"].values if "pressure_qc" in ds.data_vars else None
|
|
61
|
+
|
|
62
|
+
records = []
|
|
63
|
+
for i in range(n_instr):
|
|
64
|
+
if str(instr_types[i]).lower() == "adcp":
|
|
65
|
+
continue
|
|
66
|
+
p_nom = waterdepth - float(habs[i])
|
|
67
|
+
p_vals = pressure_arr[:, i].copy()
|
|
68
|
+
if pqc_arr is not None:
|
|
69
|
+
_qc = pqc_arr[:, i] if pqc_arr.ndim == 2 else pqc_arr
|
|
70
|
+
p_vals[_qc == 8] = np.nan
|
|
71
|
+
valid = p_vals[np.isfinite(p_vals)]
|
|
72
|
+
if len(valid) < 10:
|
|
73
|
+
continue
|
|
74
|
+
records.append((p_nom, str(serials[i]), valid))
|
|
75
|
+
return records
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Knockdown figure 1: measured pressure vs. nominal (equal aspect, 1:1 line)
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def plot_knockdown_pressure(
|
|
84
|
+
ds: "xr.Dataset",
|
|
85
|
+
) -> "Optional[matplotlib.figure.Figure]":
|
|
86
|
+
"""IQR of measured pressure vs. nominal design depth, equal aspect ratio.
|
|
87
|
+
|
|
88
|
+
Each non-ADCP instrument is shown as a vertical box-and-whisker positioned
|
|
89
|
+
at its nominal pressure on the x-axis; the box spans the IQR of the actual
|
|
90
|
+
measured pressure distribution on the y-axis (increasing downward).
|
|
91
|
+
|
|
92
|
+
The axes have equal aspect (``ax.set_aspect("equal")``) and both run from
|
|
93
|
+
0 to the maximum value, so the 1:1 reference line appears at 45°.
|
|
94
|
+
Instruments on the diagonal are at their design depth; boxes that drop
|
|
95
|
+
below the diagonal experienced knockdown (measured deeper than nominal).
|
|
96
|
+
|
|
97
|
+
Boxes are coloured blue; interpolated pressure (QC flag 8) is excluded.
|
|
98
|
+
Rendered at half-width in the stack report.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
ds : xr.Dataset
|
|
103
|
+
Stack dataset with dimensions ``(time, N_LEVELS)`` containing
|
|
104
|
+
``pressure``, ``hab``, ``serial``, ``instrument_type``, and
|
|
105
|
+
optionally ``pressure_qc``. The ``waterdepth`` global attribute
|
|
106
|
+
must be present and non-zero.
|
|
107
|
+
|
|
108
|
+
Returns
|
|
109
|
+
-------
|
|
110
|
+
matplotlib.figure.Figure or None
|
|
111
|
+
|
|
112
|
+
"""
|
|
113
|
+
if "pressure" not in ds.data_vars or "hab" not in ds:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
import matplotlib.pyplot as plt
|
|
117
|
+
|
|
118
|
+
from oceanarray import parameters as P
|
|
119
|
+
|
|
120
|
+
plt.style.use(str(P.MPLSTYLE))
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
waterdepth = float(ds.attrs.get("waterdepth", 0) or 0)
|
|
124
|
+
except (ValueError, TypeError):
|
|
125
|
+
return None
|
|
126
|
+
if waterdepth <= 0:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
records = _collect_knockdown_records(ds, waterdepth)
|
|
130
|
+
if not records:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
p_nom_vals = [r[0] for r in records]
|
|
134
|
+
p_range = max(p_nom_vals) - min(p_nom_vals) if len(p_nom_vals) > 1 else 50.0
|
|
135
|
+
box_width = max(5.0, p_range / (len(p_nom_vals) * 2))
|
|
136
|
+
|
|
137
|
+
fig, ax = plt.subplots(figsize=(5, 5))
|
|
138
|
+
|
|
139
|
+
p_max_all = 0.0
|
|
140
|
+
for p_nom, _serial, actual_p in records:
|
|
141
|
+
bp = ax.boxplot(
|
|
142
|
+
actual_p,
|
|
143
|
+
vert=True,
|
|
144
|
+
positions=[p_nom],
|
|
145
|
+
widths=box_width,
|
|
146
|
+
patch_artist=True,
|
|
147
|
+
flierprops=dict(marker=".", markersize=2, alpha=0.25, color="grey"),
|
|
148
|
+
medianprops=dict(color="black", linewidth=1.5),
|
|
149
|
+
manage_ticks=False,
|
|
150
|
+
)
|
|
151
|
+
bp["boxes"][0].set_facecolor("steelblue")
|
|
152
|
+
bp["boxes"][0].set_alpha(0.6)
|
|
153
|
+
p_max_all = max(p_max_all, float(np.nanmax(actual_p)))
|
|
154
|
+
|
|
155
|
+
ax_max = max(p_max_all, max(p_nom_vals)) * 1.05
|
|
156
|
+
ax.set_xlim(ax_max, 0) # deeper nominal pressure on the left
|
|
157
|
+
ax.set_ylim(0, ax_max)
|
|
158
|
+
ax.set_aspect("equal")
|
|
159
|
+
ax.invert_yaxis() # deeper measured pressure at bottom
|
|
160
|
+
|
|
161
|
+
ax.plot(
|
|
162
|
+
[0, ax_max],
|
|
163
|
+
[0, ax_max],
|
|
164
|
+
color="dimgrey",
|
|
165
|
+
lw=0.8,
|
|
166
|
+
ls="--",
|
|
167
|
+
zorder=0,
|
|
168
|
+
label="actual = nominal",
|
|
169
|
+
)
|
|
170
|
+
ax.legend(fontsize=9, loc="upper left")
|
|
171
|
+
ax.set_xlabel("Nominal pressure (dbar)")
|
|
172
|
+
ax.set_ylabel("Measured pressure (dbar)")
|
|
173
|
+
ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.5)
|
|
174
|
+
|
|
175
|
+
plt.tight_layout()
|
|
176
|
+
return fig
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# Knockdown figure 1b: measured HAB vs. nominal HAB (horizontal displacement)
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def plot_knockdown_hab(
|
|
185
|
+
ds: "xr.Dataset",
|
|
186
|
+
) -> "Optional[matplotlib.figure.Figure]":
|
|
187
|
+
"""IQR of measured pressure vs. nominal HAB, equal aspect ratio.
|
|
188
|
+
|
|
189
|
+
Companion to :func:`plot_knockdown_pressure`. The y-axis is identical
|
|
190
|
+
(measured pressure, dbar, increasing downward); only the x-axis changes
|
|
191
|
+
from nominal pressure to nominal height-above-bottom (m).
|
|
192
|
+
|
|
193
|
+
Each non-ADCP instrument is shown as a vertical box-and-whisker positioned
|
|
194
|
+
at its nominal HAB on the x-axis; the box spans the IQR of the measured
|
|
195
|
+
pressure distribution. The dashed reference line shows the expected
|
|
196
|
+
pressure for each HAB (``pressure = waterdepth − hab``). Boxes below
|
|
197
|
+
the line were pulled deeper than their design height by current drag.
|
|
198
|
+
|
|
199
|
+
With 1 dbar ≈ 1 m, equal aspect keeps the reference line near 45° and
|
|
200
|
+
the departure from it is directly related to horizontal displacement.
|
|
201
|
+
|
|
202
|
+
Parameters
|
|
203
|
+
----------
|
|
204
|
+
ds : xr.Dataset
|
|
205
|
+
Stack dataset with dimensions ``(time, N_LEVELS)`` containing
|
|
206
|
+
``pressure``, ``hab``, ``serial``, ``instrument_type``, and
|
|
207
|
+
optionally ``pressure_qc``. The ``waterdepth`` global attribute
|
|
208
|
+
must be present and non-zero.
|
|
209
|
+
|
|
210
|
+
Returns
|
|
211
|
+
-------
|
|
212
|
+
matplotlib.figure.Figure or None
|
|
213
|
+
|
|
214
|
+
"""
|
|
215
|
+
if "pressure" not in ds.data_vars or "hab" not in ds:
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
import matplotlib.pyplot as plt
|
|
219
|
+
|
|
220
|
+
from oceanarray import parameters as P
|
|
221
|
+
|
|
222
|
+
plt.style.use(str(P.MPLSTYLE))
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
waterdepth = float(ds.attrs.get("waterdepth", 0) or 0)
|
|
226
|
+
except (ValueError, TypeError):
|
|
227
|
+
return None
|
|
228
|
+
if waterdepth <= 0:
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
records = _collect_knockdown_records(ds, waterdepth)
|
|
232
|
+
if not records:
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
# hab_nom is stored as p_nom = waterdepth - hab, so recover hab
|
|
236
|
+
hab_records = [
|
|
237
|
+
(waterdepth - p_nom, serial, actual_p) for p_nom, serial, actual_p in records
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
hab_nom_vals = [r[0] for r in hab_records]
|
|
241
|
+
hab_range = max(hab_nom_vals) - min(hab_nom_vals) if len(hab_nom_vals) > 1 else 10.0
|
|
242
|
+
box_width = max(2.0, hab_range / (len(hab_nom_vals) * 2))
|
|
243
|
+
|
|
244
|
+
fig, ax = plt.subplots(figsize=(5, 5))
|
|
245
|
+
|
|
246
|
+
p_max_all = 0.0
|
|
247
|
+
for hab_nom, _serial, actual_p in hab_records:
|
|
248
|
+
bp = ax.boxplot(
|
|
249
|
+
actual_p,
|
|
250
|
+
vert=True,
|
|
251
|
+
positions=[hab_nom],
|
|
252
|
+
widths=box_width,
|
|
253
|
+
patch_artist=True,
|
|
254
|
+
flierprops=dict(marker=".", markersize=2, alpha=0.25, color="grey"),
|
|
255
|
+
medianprops=dict(color="black", linewidth=1.5),
|
|
256
|
+
manage_ticks=False,
|
|
257
|
+
)
|
|
258
|
+
bp["boxes"][0].set_facecolor("steelblue")
|
|
259
|
+
bp["boxes"][0].set_alpha(0.6)
|
|
260
|
+
p_max_all = max(p_max_all, float(np.nanmax(actual_p)))
|
|
261
|
+
|
|
262
|
+
ax_max = max(p_max_all, waterdepth) * 1.05
|
|
263
|
+
ax.set_xlim(0, ax_max) # low HAB (deep) on the left, surface on the right
|
|
264
|
+
ax.set_ylim(0, ax_max)
|
|
265
|
+
ax.set_aspect("equal")
|
|
266
|
+
ax.invert_yaxis() # deeper (larger pressure) at bottom
|
|
267
|
+
|
|
268
|
+
# Reference line: expected pressure = waterdepth - hab
|
|
269
|
+
ax.plot(
|
|
270
|
+
[0, ax_max],
|
|
271
|
+
[waterdepth, waterdepth - ax_max],
|
|
272
|
+
color="dimgrey",
|
|
273
|
+
lw=0.8,
|
|
274
|
+
ls="--",
|
|
275
|
+
zorder=0,
|
|
276
|
+
label="expected pressure",
|
|
277
|
+
)
|
|
278
|
+
ax.legend(fontsize=9, loc="upper right")
|
|
279
|
+
ax.set_xlabel("Nominal HAB (m)")
|
|
280
|
+
ax.set_ylabel("Measured pressure (dbar)")
|
|
281
|
+
|
|
282
|
+
plt.tight_layout()
|
|
283
|
+
return fig
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
# Knockdown figure 2: pressure anomaly IQR
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def plot_knockdown_anomaly(
|
|
292
|
+
ds: "xr.Dataset",
|
|
293
|
+
) -> "Optional[matplotlib.figure.Figure]":
|
|
294
|
+
"""IQR of pressure anomaly (measured − nominal) per instrument.
|
|
295
|
+
|
|
296
|
+
Each non-ADCP instrument is shown as a horizontal box-and-whisker at its
|
|
297
|
+
nominal pressure on the y-axis; the box spans the IQR of the pressure
|
|
298
|
+
anomaly distribution (actual − nominal, positive = knocked down deeper).
|
|
299
|
+
|
|
300
|
+
A vertical reference line at x = 0 marks zero knockdown. Box colour
|
|
301
|
+
indicates the magnitude of the median knockdown:
|
|
302
|
+
|
|
303
|
+
========= ========================
|
|
304
|
+
Colour Knockdown magnitude
|
|
305
|
+
========= ========================
|
|
306
|
+
green < 100 dbar
|
|
307
|
+
gold 100–200 dbar
|
|
308
|
+
darkorange 200–300 dbar
|
|
309
|
+
firebrick > 300 dbar
|
|
310
|
+
========= ========================
|
|
311
|
+
|
|
312
|
+
Interpolated pressure (QC flag 8) is excluded. Rendered at half-width
|
|
313
|
+
in the stack report.
|
|
314
|
+
|
|
315
|
+
Parameters
|
|
316
|
+
----------
|
|
317
|
+
ds : xr.Dataset
|
|
318
|
+
Stack dataset; same requirements as :func:`plot_knockdown_pressure`.
|
|
319
|
+
|
|
320
|
+
Returns
|
|
321
|
+
-------
|
|
322
|
+
matplotlib.figure.Figure or None
|
|
323
|
+
|
|
324
|
+
"""
|
|
325
|
+
if "pressure" not in ds.data_vars or "hab" not in ds:
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
import matplotlib.pyplot as plt
|
|
329
|
+
|
|
330
|
+
from oceanarray import parameters as P
|
|
331
|
+
|
|
332
|
+
plt.style.use(str(P.MPLSTYLE))
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
waterdepth = float(ds.attrs.get("waterdepth", 0) or 0)
|
|
336
|
+
except (ValueError, TypeError):
|
|
337
|
+
return None
|
|
338
|
+
if waterdepth <= 0:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
records = _collect_knockdown_records(ds, waterdepth)
|
|
342
|
+
if not records:
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
p_nom_vals = [r[0] for r in records]
|
|
346
|
+
p_range = max(p_nom_vals) - min(p_nom_vals) if len(p_nom_vals) > 1 else 50.0
|
|
347
|
+
box_width = max(5.0, p_range / (len(p_nom_vals) * 2))
|
|
348
|
+
|
|
349
|
+
fig, ax = plt.subplots(figsize=(5, max(3, len(records) * 0.4 + 1)))
|
|
350
|
+
|
|
351
|
+
for p_nom, _serial, actual_p in records:
|
|
352
|
+
anomaly = actual_p - p_nom # positive = knocked down deeper
|
|
353
|
+
median_anom = float(np.median(anomaly))
|
|
354
|
+
color = _kd_color(abs(median_anom))
|
|
355
|
+
|
|
356
|
+
bp = ax.boxplot(
|
|
357
|
+
anomaly,
|
|
358
|
+
vert=False,
|
|
359
|
+
positions=[p_nom],
|
|
360
|
+
widths=box_width,
|
|
361
|
+
patch_artist=True,
|
|
362
|
+
flierprops=dict(marker=".", markersize=2, alpha=0.25, color="grey"),
|
|
363
|
+
medianprops=dict(color="black", linewidth=1.5),
|
|
364
|
+
manage_ticks=False,
|
|
365
|
+
)
|
|
366
|
+
bp["boxes"][0].set_facecolor(color)
|
|
367
|
+
bp["boxes"][0].set_alpha(0.7)
|
|
368
|
+
|
|
369
|
+
ax.invert_yaxis()
|
|
370
|
+
ax.axvline(0, color="k", lw=0.8, zorder=3)
|
|
371
|
+
ax.set_xlabel("Pressure anomaly (dbar) — positive = deeper than nominal")
|
|
372
|
+
ax.set_ylabel("Nominal pressure (dbar)")
|
|
373
|
+
|
|
374
|
+
plt.tight_layout()
|
|
375
|
+
return fig
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# Knockdown figure 3: horizontal displacement scatter
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def plot_knockdown_displacement(
|
|
384
|
+
ds: "xr.Dataset",
|
|
385
|
+
) -> "Optional[matplotlib.figure.Figure]":
|
|
386
|
+
"""Scatter and heatmap of estimated horizontal displacement vs. measured pressure.
|
|
387
|
+
|
|
388
|
+
For each non-ADCP instrument the horizontal displacement is estimated at
|
|
389
|
+
every time step as:
|
|
390
|
+
|
|
391
|
+
x_horiz = sqrt(max(0, hab_nom² − hab_meas²))
|
|
392
|
+
|
|
393
|
+
where ``hab_nom`` is the nominal height-above-bottom (m) and
|
|
394
|
+
``hab_meas = waterdepth − pressure`` is the measured HAB.
|
|
395
|
+
|
|
396
|
+
**Left panel** — scatter of ``(x_horiz, measured_pressure)`` per
|
|
397
|
+
instrument, coloured by serial number. y-axis is measured pressure
|
|
398
|
+
(dbar, increasing downward); x-axis is horizontal displacement (m).
|
|
399
|
+
Square axes (equal aspect, adjustable data limits).
|
|
400
|
+
|
|
401
|
+
**Right panel** — 2-D count heatmap of the same points across all
|
|
402
|
+
instruments combined. Discrete colorbar (up to 15 levels).
|
|
403
|
+
|
|
404
|
+
Parameters
|
|
405
|
+
----------
|
|
406
|
+
ds : xr.Dataset
|
|
407
|
+
Stack dataset; same requirements as :func:`plot_knockdown_pressure`.
|
|
408
|
+
|
|
409
|
+
Returns
|
|
410
|
+
-------
|
|
411
|
+
matplotlib.figure.Figure or None
|
|
412
|
+
|
|
413
|
+
"""
|
|
414
|
+
if "pressure" not in ds.data_vars or "hab" not in ds:
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
import matplotlib.colors as mcolors
|
|
418
|
+
import matplotlib.pyplot as plt
|
|
419
|
+
|
|
420
|
+
from oceanarray import parameters as P
|
|
421
|
+
from oceanarray.utilities import _nice_colorbar_bounds
|
|
422
|
+
|
|
423
|
+
plt.style.use(str(P.MPLSTYLE))
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
waterdepth = float(ds.attrs.get("waterdepth", 0) or 0)
|
|
427
|
+
except (ValueError, TypeError):
|
|
428
|
+
return None
|
|
429
|
+
if waterdepth <= 0:
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
records = _collect_knockdown_records(ds, waterdepth)
|
|
433
|
+
if not records:
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
# --- collect data for both panels ---
|
|
437
|
+
_tab20 = plt.get_cmap("tab20")
|
|
438
|
+
scatter_data = [] # (serial, x_horiz_thinned, p_thinned, color)
|
|
439
|
+
all_x_list = []
|
|
440
|
+
all_p_list = []
|
|
441
|
+
|
|
442
|
+
for idx, (p_nom, serial, actual_p) in enumerate(records):
|
|
443
|
+
hab_nom = waterdepth - p_nom
|
|
444
|
+
hab_meas = waterdepth - actual_p
|
|
445
|
+
x_horiz = np.sqrt(np.maximum(0.0, hab_nom**2 - hab_meas**2))
|
|
446
|
+
all_x_list.append(x_horiz)
|
|
447
|
+
all_p_list.append(actual_p)
|
|
448
|
+
step = max(1, len(actual_p) // 2000)
|
|
449
|
+
scatter_data.append(
|
|
450
|
+
(serial, x_horiz[::step], actual_p[::step], _tab20(idx % 20))
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
all_x = np.concatenate(all_x_list)
|
|
454
|
+
all_p = np.concatenate(all_p_list)
|
|
455
|
+
valid = np.isfinite(all_x) & np.isfinite(all_p)
|
|
456
|
+
all_x, all_p = all_x[valid], all_p[valid]
|
|
457
|
+
|
|
458
|
+
x_max = float(np.nanmax(all_x)) if len(all_x) else 1.0
|
|
459
|
+
p_max = float(np.nanmax(all_p)) * 1.05 if len(all_p) else 1.0
|
|
460
|
+
|
|
461
|
+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 5), sharey=True, sharex=True)
|
|
462
|
+
|
|
463
|
+
# --- left panel: scatter ---
|
|
464
|
+
for serial, x_thin, p_thin, color in scatter_data:
|
|
465
|
+
ax1.scatter(
|
|
466
|
+
x_thin,
|
|
467
|
+
p_thin,
|
|
468
|
+
s=4,
|
|
469
|
+
alpha=0.3,
|
|
470
|
+
color=color,
|
|
471
|
+
linewidths=0,
|
|
472
|
+
label=str(serial),
|
|
473
|
+
rasterized=True,
|
|
474
|
+
)
|
|
475
|
+
ax1.set_xlim(0, x_max) # sharex propagates to ax2
|
|
476
|
+
ax1.set_xlabel("Horizontal displacement (m)")
|
|
477
|
+
ax1.set_ylabel("Measured pressure (dbar)")
|
|
478
|
+
ax1.legend(fontsize=9, loc="lower right", markerscale=3)
|
|
479
|
+
ax1.set_aspect("equal", adjustable="box") # 100 m on x = 100 m on y
|
|
480
|
+
ax1.grid(True, linestyle="--", linewidth=0.4, alpha=0.5)
|
|
481
|
+
|
|
482
|
+
# Shared tick step so x and y gridlines fall at the same intervals
|
|
483
|
+
import matplotlib.ticker as mticker
|
|
484
|
+
|
|
485
|
+
_ax_range = max(x_max, p_max)
|
|
486
|
+
_step = next(
|
|
487
|
+
s for s in [10, 20, 25, 50, 100, 200, 250, 500, 1000] if _ax_range / s <= 6
|
|
488
|
+
)
|
|
489
|
+
ax1.xaxis.set_major_locator(mticker.MultipleLocator(_step))
|
|
490
|
+
ax1.yaxis.set_major_locator(mticker.MultipleLocator(_step))
|
|
491
|
+
|
|
492
|
+
# --- right panel: per-instrument normalised heatmap ---
|
|
493
|
+
# Each instrument's 2-D histogram is divided by its own total before
|
|
494
|
+
# summing, so all instruments contribute equally regardless of record length.
|
|
495
|
+
n_bins = 40
|
|
496
|
+
x_edges = np.linspace(0, x_max, n_bins + 1)
|
|
497
|
+
p_edges = np.linspace(0, p_max, n_bins + 1)
|
|
498
|
+
|
|
499
|
+
H_norm = np.zeros((n_bins, n_bins))
|
|
500
|
+
for x_arr, p_arr in zip(all_x_list, all_p_list):
|
|
501
|
+
valid_i = np.isfinite(x_arr) & np.isfinite(p_arr)
|
|
502
|
+
if not valid_i.sum():
|
|
503
|
+
continue
|
|
504
|
+
H_i, _, _ = np.histogram2d(
|
|
505
|
+
x_arr[valid_i], p_arr[valid_i], bins=[x_edges, p_edges]
|
|
506
|
+
)
|
|
507
|
+
total = H_i.sum()
|
|
508
|
+
if total > 0:
|
|
509
|
+
H_norm += H_i / total
|
|
510
|
+
H_norm[H_norm == 0] = np.nan
|
|
511
|
+
|
|
512
|
+
h_vals = H_norm[np.isfinite(H_norm)]
|
|
513
|
+
if len(h_vals):
|
|
514
|
+
bounds = _nice_colorbar_bounds(
|
|
515
|
+
float(np.nanmin(h_vals)), float(np.nanpercentile(h_vals, 98)), n=15
|
|
516
|
+
)
|
|
517
|
+
norm = mcolors.BoundaryNorm(bounds, ncolors=256)
|
|
518
|
+
mesh = ax2.pcolormesh(x_edges, p_edges, H_norm.T, norm=norm, cmap="YlOrRd")
|
|
519
|
+
fig.colorbar(
|
|
520
|
+
mesh,
|
|
521
|
+
ax=ax2,
|
|
522
|
+
ticks=bounds,
|
|
523
|
+
label="Normalised density (sum = 1 per instrument)",
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
ax2.set_ylim(0, p_max) # sharey propagates this to ax1
|
|
527
|
+
ax2.set_aspect("equal", adjustable="box")
|
|
528
|
+
ax2.invert_yaxis()
|
|
529
|
+
ax2.set_xlabel("Horizontal displacement (m)")
|
|
530
|
+
ax2.grid(True, linestyle="--", linewidth=0.4, alpha=0.5, zorder=3)
|
|
531
|
+
|
|
532
|
+
plt.tight_layout()
|
|
533
|
+
return fig
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# ---------------------------------------------------------------------------
|
|
537
|
+
# Clock-offset comparison
|
|
538
|
+
# ---------------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def plot_clock_offset_check(
|
|
542
|
+
nc_paths: "Dict[str, Path]",
|
|
543
|
+
deploy_dt: "Optional[datetime]",
|
|
544
|
+
recover_dt: "Optional[datetime]",
|
|
545
|
+
window_minutes: int = 30,
|
|
546
|
+
) -> "Optional[matplotlib.figure.Figure]":
|
|
547
|
+
"""Overlaid temperature time series zoomed to deployment start and end.
|
|
548
|
+
|
|
549
|
+
Plots the first and last *window_minutes* of the deployment for every
|
|
550
|
+
instrument that has a temperature variable, so that clock alignment
|
|
551
|
+
between instruments can be assessed visually. If an instrument's clock
|
|
552
|
+
is offset the temperature signal will appear shifted in time relative to
|
|
553
|
+
the other instruments.
|
|
554
|
+
|
|
555
|
+
Two sub-panels are produced side by side:
|
|
556
|
+
|
|
557
|
+
- **Left**: first ``window_minutes`` minutes after ``deploy_dt``
|
|
558
|
+
- **Right**: last ``window_minutes`` minutes before ``recover_dt``
|
|
559
|
+
|
|
560
|
+
When ``deploy_dt`` or ``recover_dt`` is ``None``, only the available
|
|
561
|
+
window is produced.
|
|
562
|
+
|
|
563
|
+
Parameters
|
|
564
|
+
----------
|
|
565
|
+
nc_paths : dict of {serial: Path}
|
|
566
|
+
Paths to stage-2 or stage-3 NetCDF files keyed by serial number.
|
|
567
|
+
Only instruments with temperature data are included in the plot.
|
|
568
|
+
deploy_dt : datetime or None
|
|
569
|
+
Deployment time (UTC).
|
|
570
|
+
recover_dt : datetime or None
|
|
571
|
+
Recovery time (UTC).
|
|
572
|
+
window_minutes : int
|
|
573
|
+
Duration of each zoom window in minutes.
|
|
574
|
+
|
|
575
|
+
Returns
|
|
576
|
+
-------
|
|
577
|
+
matplotlib.figure.Figure or None
|
|
578
|
+
None when fewer than two instruments have temperature data, or if
|
|
579
|
+
any other error prevents plotting.
|
|
580
|
+
|
|
581
|
+
"""
|
|
582
|
+
if not nc_paths:
|
|
583
|
+
return None
|
|
584
|
+
|
|
585
|
+
import matplotlib.pyplot as plt
|
|
586
|
+
import matplotlib.dates as mdates
|
|
587
|
+
import xarray as xr
|
|
588
|
+
|
|
589
|
+
from oceanarray import parameters as P
|
|
590
|
+
|
|
591
|
+
plt.style.use(str(P.MPLSTYLE))
|
|
592
|
+
|
|
593
|
+
_TEMP_VARS = ("temperature", "temp", "TEMP", "sea_water_temperature")
|
|
594
|
+
|
|
595
|
+
# Load temperature time series for each instrument
|
|
596
|
+
series: dict = {} # {serial: (time_array, temp_array)}
|
|
597
|
+
for serial, path in nc_paths.items():
|
|
598
|
+
if not path.exists():
|
|
599
|
+
continue
|
|
600
|
+
try:
|
|
601
|
+
ds = xr.open_dataset(path, decode_timedelta=False).load()
|
|
602
|
+
temp_var = next((v for v in _TEMP_VARS if v in ds.data_vars), None)
|
|
603
|
+
if temp_var is None or "time" not in ds.dims:
|
|
604
|
+
ds.close()
|
|
605
|
+
continue
|
|
606
|
+
t = ds["time"].values
|
|
607
|
+
temp = ds[temp_var].values.astype(float)
|
|
608
|
+
# Apply QC mask if available
|
|
609
|
+
qc_var = f"{temp_var}_qc"
|
|
610
|
+
if qc_var in ds.data_vars:
|
|
611
|
+
temp[ds[qc_var].values >= 4] = np.nan
|
|
612
|
+
ds.close()
|
|
613
|
+
series[serial] = (t, temp)
|
|
614
|
+
except Exception: # noqa: BLE001
|
|
615
|
+
continue
|
|
616
|
+
|
|
617
|
+
if len(series) < 2:
|
|
618
|
+
return None
|
|
619
|
+
|
|
620
|
+
# Determine zoom windows
|
|
621
|
+
_td = np.timedelta64(window_minutes * 60, "s")
|
|
622
|
+
windows: list = []
|
|
623
|
+
if deploy_dt is not None:
|
|
624
|
+
t0 = np.datetime64(deploy_dt.replace(tzinfo=None).isoformat())
|
|
625
|
+
windows.append((t0, t0 + _td, f"Start +{window_minutes} min"))
|
|
626
|
+
if recover_dt is not None:
|
|
627
|
+
t1 = np.datetime64(recover_dt.replace(tzinfo=None).isoformat())
|
|
628
|
+
windows.append((t1 - _td, t1, f"End −{window_minutes} min"))
|
|
629
|
+
if not windows:
|
|
630
|
+
return None
|
|
631
|
+
|
|
632
|
+
n_panels = len(windows)
|
|
633
|
+
fig, axes = plt.subplots(1, n_panels, figsize=(5 * n_panels, 3.5), sharey=False)
|
|
634
|
+
if n_panels == 1:
|
|
635
|
+
axes = [axes]
|
|
636
|
+
|
|
637
|
+
_tab20 = plt.get_cmap("tab20")
|
|
638
|
+
colors = {s: _tab20(i % 20) for i, s in enumerate(series)}
|
|
639
|
+
|
|
640
|
+
for ax, (t_lo, t_hi, title) in zip(axes, windows):
|
|
641
|
+
plotted = False
|
|
642
|
+
for serial, (t, temp) in series.items():
|
|
643
|
+
mask = (t >= t_lo) & (t <= t_hi) & np.isfinite(temp)
|
|
644
|
+
if not np.any(mask):
|
|
645
|
+
continue
|
|
646
|
+
ax.plot(
|
|
647
|
+
t[mask],
|
|
648
|
+
temp[mask],
|
|
649
|
+
color=colors[serial],
|
|
650
|
+
lw=1.0,
|
|
651
|
+
label=str(serial),
|
|
652
|
+
)
|
|
653
|
+
plotted = True
|
|
654
|
+
|
|
655
|
+
ax.set_title(title, fontsize=8)
|
|
656
|
+
ax.set_xlabel("Time (UTC)")
|
|
657
|
+
ax.set_ylabel("Temperature (°C)")
|
|
658
|
+
locator = mdates.AutoDateLocator()
|
|
659
|
+
ax.xaxis.set_major_locator(locator)
|
|
660
|
+
ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
|
|
661
|
+
ax.tick_params(axis="x", labelsize=7)
|
|
662
|
+
|
|
663
|
+
if plotted:
|
|
664
|
+
ax.legend(fontsize=6, loc="upper left")
|
|
665
|
+
|
|
666
|
+
plt.tight_layout()
|
|
667
|
+
return fig
|