oceanarray 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. oceanarray/__init__.py +20 -0
  2. oceanarray/_version.py +1 -0
  3. oceanarray/cli.py +1343 -0
  4. oceanarray/clock_offset.py +675 -0
  5. oceanarray/config/OS1_sensor_attrs.yaml +49 -0
  6. oceanarray/config/OS1_var_names.yaml +5 -0
  7. oceanarray/config/OS1_vocab_attrs.yaml +84 -0
  8. oceanarray/config/legacy/project_RAPID.yaml +69 -0
  9. oceanarray/config/legacy/rodb_keys.txt +206 -0
  10. oceanarray/config/legacy/rodb_keys.yaml +1012 -0
  11. oceanarray/config/logging.yaml +28 -0
  12. oceanarray/legacy/__init__.py +47 -0
  13. oceanarray/legacy/caldip.py +753 -0
  14. oceanarray/legacy/convertOS.py +527 -0
  15. oceanarray/legacy/find_deployment.py +489 -0
  16. oceanarray/legacy/mooring_rodb.py +429 -0
  17. oceanarray/legacy/process_rodb.py +369 -0
  18. oceanarray/legacy/rodb.py +330 -0
  19. oceanarray/logger.py +192 -0
  20. oceanarray/logsheet/__init__.py +60 -0
  21. oceanarray/logsheet/_columns.py +97 -0
  22. oceanarray/logsheet/_config.py +245 -0
  23. oceanarray/logsheet/_firmware.py +43 -0
  24. oceanarray/logsheet/_latex.py +128 -0
  25. oceanarray/logsheet/_render.py +126 -0
  26. oceanarray/logsheet/builders/__init__.py +13 -0
  27. oceanarray/logsheet/builders/caldip_download.py +195 -0
  28. oceanarray/logsheet/builders/caldip_setup.py +188 -0
  29. oceanarray/logsheet/builders/deployment_setup.py +218 -0
  30. oceanarray/logsheet/builders/mooring_download.py +243 -0
  31. oceanarray/logsheet/builders/recovery.py +157 -0
  32. oceanarray/logsheet/data/column_defs.yaml +686 -0
  33. oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
  34. oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
  35. oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
  36. oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
  37. oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
  38. oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
  39. oceanarray/mooring_level.py +1368 -0
  40. oceanarray/oceanarray.mplstyle +18 -0
  41. oceanarray/parameters.py +270 -0
  42. oceanarray/plotter.py +942 -0
  43. oceanarray/plotters/__init__.py +97 -0
  44. oceanarray/plotters/_animation.py +261 -0
  45. oceanarray/plotters/_current.py +705 -0
  46. oceanarray/plotters/_diagnostic.py +667 -0
  47. oceanarray/plotters/_helpers.py +57 -0
  48. oceanarray/plotters/_primitives.py +101 -0
  49. oceanarray/plotters/_section.py +16 -0
  50. oceanarray/plotters/_timeseries.py +11 -0
  51. oceanarray/rapid_interp.py +739 -0
  52. oceanarray/read_rbr_hex.py +541 -0
  53. oceanarray/readers.py +303 -0
  54. oceanarray/report/__init__.py +5 -0
  55. oceanarray/report/_array.py +386 -0
  56. oceanarray/report/_grid.py +539 -0
  57. oceanarray/report/_html_helpers.py +1004 -0
  58. oceanarray/report/_instrument.py +853 -0
  59. oceanarray/report/_mooring.py +1385 -0
  60. oceanarray/report/_plots.py +4284 -0
  61. oceanarray/report/_stack.py +941 -0
  62. oceanarray/stage1.py +1959 -0
  63. oceanarray/stage2.py +1160 -0
  64. oceanarray/stage3.py +2494 -0
  65. oceanarray/time_gridding.py +958 -0
  66. oceanarray/tools.py +400 -0
  67. oceanarray/transports.py +1 -0
  68. oceanarray/utilities.py +538 -0
  69. oceanarray/validation.py +389 -0
  70. oceanarray/writers.py +99 -0
  71. oceanarray-0.1.0.dist-info/METADATA +503 -0
  72. oceanarray-0.1.0.dist-info/RECORD +76 -0
  73. oceanarray-0.1.0.dist-info/WHEEL +5 -0
  74. oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
  75. oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
  76. oceanarray-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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