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,705 @@
1
+ """Tier-2 domain wrappers for current/velocity instrument plots.
2
+
3
+ These functions accept xarray Datasets and delegate rendering to Tier-1
4
+ primitives in _primitives.py. They understand oceanarray variable naming
5
+ conventions but do not serialise to base64 (that happens in report/_plots.py).
6
+
7
+ Post-OdB: migrate plot_aquadopp_quick, plot_instrument_rose, plot_rose_grid,
8
+ plot_aquadopp_tilt_panels from report/_plots.py and report/_stack.py here.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Optional
14
+
15
+ import matplotlib.colors as mcolors
16
+ import matplotlib.pyplot as plt
17
+ import numpy as np
18
+ import xarray as xr
19
+ from matplotlib.collections import LineCollection
20
+
21
+ from oceanarray.plotters._helpers import tukey_smooth
22
+ from oceanarray.plotters._primitives import plot_trajectory
23
+ from oceanarray.utilities import _nice_colorbar_bounds
24
+
25
+
26
+ def plot_temperature_trajectory(
27
+ ds: xr.Dataset,
28
+ u_var: str = "east_velocity",
29
+ v_var: str = "north_velocity",
30
+ temp_var: str = "temperature",
31
+ title: str = "",
32
+ ) -> object:
33
+ """Lagrangian particle trajectory coloured per-segment by temperature.
34
+
35
+ Integrates *u_var* and *v_var* (east/north velocity) over time using a
36
+ forward Euler scheme. NaN velocity values are treated as zero so the
37
+ trajectory is not interrupted.
38
+
39
+ The displacement is in metres; for long deployments the trajectory will
40
+ drift far from origin and should be interpreted as a pseudo-Lagrangian
41
+ tracer, not a real particle path.
42
+
43
+ Note: the velocity integration step will move to ``oceanarray.tools``
44
+ post-OdB so that it can be reused independently of plotting.
45
+
46
+ Parameters
47
+ ----------
48
+ ds : xr.Dataset
49
+ Dataset containing east/north velocity and temperature variables.
50
+ u_var : str
51
+ Name of the eastward velocity variable (m s⁻¹).
52
+ v_var : str
53
+ Name of the northward velocity variable (m s⁻¹).
54
+ temp_var : str
55
+ Name of the temperature variable used for colouring.
56
+ title : str
57
+ Optional figure title; defaults to the dataset ``id`` attribute.
58
+
59
+ Returns
60
+ -------
61
+ matplotlib.figure.Figure
62
+
63
+ """
64
+ u = np.nan_to_num(ds[u_var].values, nan=0.0)
65
+ v = np.nan_to_num(ds[v_var].values, nan=0.0)
66
+ time = ds["time"].values
67
+
68
+ dt = np.array(
69
+ [(time[i] - time[i - 1]) / np.timedelta64(1, "s") for i in range(1, len(time))],
70
+ dtype=float,
71
+ )
72
+ x = np.concatenate([[0.0], np.cumsum(u[:-1] * dt)]) / 1000.0
73
+ y = np.concatenate([[0.0], np.cumsum(v[:-1] * dt)]) / 1000.0
74
+
75
+ temp = ds[temp_var].values
76
+ units = ds[temp_var].attrs.get("units", "")
77
+ long_name = ds[temp_var].attrs.get("long_name", temp_var)
78
+ colorbar_label = f"{long_name} ({units})" if units else long_name
79
+
80
+ if not title:
81
+ title = ds.attrs.get("id", "")
82
+
83
+ return plot_trajectory(
84
+ x,
85
+ y,
86
+ color_data=temp,
87
+ cmap="coolwarm",
88
+ xlabel="East displacement (km)",
89
+ ylabel="North displacement (km)",
90
+ colorbar_label=colorbar_label,
91
+ title=title,
92
+ )
93
+
94
+
95
+ def plot_speed_boxplot(
96
+ ds: xr.Dataset,
97
+ speed_var: str = "current_speed",
98
+ ) -> object:
99
+ """Boxplot of current speed with printed percentile statistics.
100
+
101
+ Prints the 5th, 25th, 50th, 75th and 95th percentiles to stdout.
102
+ NaN values are excluded before plotting.
103
+
104
+ Parameters
105
+ ----------
106
+ ds : xr.Dataset
107
+ Dataset containing the speed variable.
108
+ speed_var : str
109
+ Name of the current speed variable.
110
+
111
+ Returns
112
+ -------
113
+ matplotlib.figure.Figure
114
+
115
+ """
116
+ import matplotlib.pyplot as plt
117
+
118
+ speed = ds[speed_var].values.ravel()
119
+ speed_clean = speed[~np.isnan(speed)]
120
+
121
+ units = ds[speed_var].attrs.get("units", "")
122
+ long_name = ds[speed_var].attrs.get("long_name", speed_var)
123
+ ylabel = f"{long_name} ({units})" if units else long_name
124
+
125
+ for p in [5, 25, 50, 75, 95]:
126
+ val = np.percentile(speed_clean, p)
127
+ print(f" {p:2d}th percentile: {val:.4f} {units}")
128
+
129
+ fig, ax = plt.subplots(figsize=(4, 6))
130
+ bp = ax.boxplot(
131
+ speed_clean,
132
+ vert=True,
133
+ patch_artist=True,
134
+ widths=0.5,
135
+ flierprops=dict(marker=".", markersize=3, alpha=0.4),
136
+ )
137
+ bp["boxes"][0].set_facecolor("steelblue")
138
+ bp["boxes"][0].set_alpha(0.7)
139
+ ax.set_ylabel(ylabel)
140
+ ax.set_xticks([])
141
+ instr_id = ds.attrs.get("id", "")
142
+ if instr_id:
143
+ ax.set_title(instr_id, fontsize=9)
144
+ ax.grid(True, axis="y", linestyle="--", linewidth=0.6, alpha=0.5)
145
+ fig.tight_layout()
146
+ return fig
147
+
148
+
149
+ def plot_multi_aquadopp_trajectories(
150
+ ds: xr.Dataset,
151
+ u_var: str = "east_velocity",
152
+ v_var: str = "north_velocity",
153
+ temp_var: str = "temperature",
154
+ instr_type_var: str = "instrument_type",
155
+ serial_var: str = "serial",
156
+ hab_var: str = "hab",
157
+ title: str = "",
158
+ ) -> Optional[plt.Figure]:
159
+ """Multi-instrument Lagrangian trajectories for all Aquadopps, coloured by temperature.
160
+
161
+ Each trajectory starts at the origin and is built by integrating the
162
+ east/north velocity over time (Euler forward; NaN velocities set to zero).
163
+ All trajectories share a single temperature colour scale so instruments can
164
+ be compared directly. End points are annotated with serial number and HAB.
165
+
166
+ Parameters
167
+ ----------
168
+ ds : xr.Dataset
169
+ Stacked mooring dataset with shape (time, N_LEVELS). Must contain
170
+ *instr_type_var*, *serial_var*, *hab_var*, *u_var*, *v_var*.
171
+ u_var, v_var : str
172
+ Eastward and northward velocity variables (m s⁻¹).
173
+ temp_var : str
174
+ Temperature variable for colouring; omitted if not present in ds.
175
+ instr_type_var, serial_var, hab_var : str
176
+ Dimension-coordinate variable names identifying each instrument.
177
+ title : str
178
+ Optional figure title; falls back to the dataset ``id`` attribute.
179
+
180
+ Returns
181
+ -------
182
+ matplotlib.figure.Figure or None
183
+ None if no Aquadopp instruments are found in the dataset.
184
+
185
+ Notes
186
+ -----
187
+ Adapted from ``02_aqdp_ploter.py`` by L. Moscatel (lmoscat),
188
+ Universitat de Barcelona.
189
+
190
+ """
191
+ instr_types = ds[instr_type_var].values
192
+ serials = ds[serial_var].values
193
+ habs = ds[hab_var].values
194
+ aqd_idx = [i for i, t in enumerate(instr_types) if str(t).lower() == "aquadopp"]
195
+
196
+ if not aqd_idx:
197
+ return None
198
+
199
+ time = ds["time"].values
200
+ dt = np.array(
201
+ [(time[j] - time[j - 1]) / np.timedelta64(1, "s") for j in range(1, len(time))],
202
+ dtype=float,
203
+ )
204
+
205
+ has_temp = temp_var in ds.data_vars
206
+
207
+ # Build trajectories and collect temperature data for shared norm
208
+ trajs = []
209
+ all_temps: list = []
210
+ for i in aqd_idx:
211
+ u = np.nan_to_num(ds[u_var].values[:, i], nan=0.0)
212
+ v = np.nan_to_num(ds[v_var].values[:, i], nan=0.0)
213
+ x = np.concatenate([[0.0], np.cumsum(u[:-1] * dt)]) / 1000.0
214
+ y = np.concatenate([[0.0], np.cumsum(v[:-1] * dt)]) / 1000.0
215
+ temp = ds[temp_var].values[:, i] if has_temp else None
216
+ trajs.append((i, x, y, temp))
217
+ if temp is not None:
218
+ all_temps.append(temp[np.isfinite(temp)])
219
+
220
+ cmap = plt.get_cmap("coolwarm")
221
+ if has_temp and all_temps:
222
+ flat = np.concatenate(all_temps)
223
+ _tmin = float(np.nanmin(flat))
224
+ _tmax = float(np.nanmax(flat))
225
+ _bounds = _nice_colorbar_bounds(_tmin, _tmax, n=20)
226
+ # Enforce minimum tick spacing of 0.5 °C so labels don't crowd
227
+ if len(_bounds) > 1 and (_bounds[1] - _bounds[0]) < 0.5:
228
+ _n_t = max(2, int((_tmax - _tmin) / 0.5))
229
+ _bounds = _nice_colorbar_bounds(_tmin, _tmax, n=_n_t)
230
+ else:
231
+ _bounds = _nice_colorbar_bounds(0.0, 1.0, n=20)
232
+ norm: mcolors.BoundaryNorm = mcolors.BoundaryNorm(_bounds, ncolors=256)
233
+
234
+ fig, ax = plt.subplots(figsize=(6, 5))
235
+
236
+ for instr_i, x, y, temp in trajs:
237
+ serial = str(serials[instr_i])
238
+ hab = float(habs[instr_i])
239
+
240
+ if has_temp and temp is not None:
241
+ points = np.array([x, y]).T.reshape(-1, 1, 2)
242
+ segments = np.concatenate([points[:-1], points[1:]], axis=1)
243
+ lc = LineCollection(
244
+ segments, cmap=cmap, norm=norm, linewidth=1.5, alpha=0.85
245
+ )
246
+ lc.set_array(temp[:-1])
247
+ ax.add_collection(lc)
248
+ end_color = cmap(
249
+ norm(float(np.nanmedian(temp[-max(1, len(temp) // 20) :])))
250
+ )
251
+ else:
252
+ ax.plot(x, y, linewidth=1.5, alpha=0.85)
253
+ end_color = "steelblue"
254
+
255
+ # End-point marker and label
256
+ ax.plot(
257
+ x[-1],
258
+ y[-1],
259
+ "s",
260
+ color=end_color,
261
+ markersize=6,
262
+ zorder=5,
263
+ markeredgecolor="white",
264
+ markeredgewidth=0.5,
265
+ )
266
+ ax.annotate(
267
+ f"s/n {serial} {hab:.0f} m hab",
268
+ xy=(x[-1], y[-1]),
269
+ xytext=(6, 3),
270
+ textcoords="offset points",
271
+ fontsize=7,
272
+ color="black",
273
+ ha="left",
274
+ va="bottom",
275
+ )
276
+
277
+ # Origin marker (all trajectories share the same start)
278
+ ax.plot(0, 0, "o", color="black", markersize=7, zorder=6, label="Start (all)")
279
+ ax.legend(fontsize=8, loc="upper left")
280
+
281
+ if has_temp:
282
+ sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
283
+ sm.set_array([])
284
+ units = ds[temp_var].attrs.get("units", "°C")
285
+ long_name = ds[temp_var].attrs.get("long_name", "Temperature")
286
+ fig.colorbar(
287
+ sm, ax=ax, label=f"{long_name} ({units})", shrink=0.75, ticks=_bounds
288
+ )
289
+
290
+ ax.autoscale_view()
291
+ ax.set_xlabel("East displacement (km)")
292
+ ax.set_ylabel("North displacement (km)")
293
+ ax.axhline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4)
294
+ ax.axvline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4)
295
+ ax.set_aspect("equal", adjustable="box")
296
+ ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.4)
297
+ if not title:
298
+ title = ds.attrs.get("id", "")
299
+ if title:
300
+ ax.set_title(title)
301
+ fig.tight_layout()
302
+ return fig
303
+
304
+
305
+ def plot_hodograph(
306
+ ds: xr.Dataset,
307
+ u_var: str = "east_velocity",
308
+ v_var: str = "north_velocity",
309
+ lp_days: float = 4.0,
310
+ smooth_hours: float = 3.0,
311
+ ) -> plt.Figure:
312
+ """Two-panel hodograph: Tukey-smoothed raw and eddy-only, coloured by time.
313
+
314
+ Panel 1: *smooth_hours*-Tukey-smoothed raw east-vs-north velocity.
315
+ Panel 2: eddy component — raw minus a *lp_days*-day rolling-mean low-pass,
316
+ then *smooth_hours*-Tukey smoothed to suppress instrument noise.
317
+
318
+ Points are coloured by fractional time through the record (0 = start,
319
+ 1 = end) using a discrete viridis colorbar so the temporal evolution of
320
+ rotary motion can be followed by eye. The figure title shows the
321
+ instrument id from ``ds.attrs["id"]`` when available.
322
+
323
+ If *u_var* or *v_var* are absent, a placeholder figure is returned with
324
+ an explanatory message — the caller always receives a renderable image.
325
+
326
+ Parameters
327
+ ----------
328
+ ds : xr.Dataset
329
+ Per-instrument dataset containing east and north velocity variables.
330
+ u_var : str
331
+ Eastward velocity variable name (m s⁻¹).
332
+ v_var : str
333
+ Northward velocity variable name (m s⁻¹).
334
+ lp_days : float
335
+ Low-pass window length in days for the eddy-component panel.
336
+ smooth_hours : float
337
+ Tukey smoothing window in hours applied to both panels.
338
+
339
+ Returns
340
+ -------
341
+ matplotlib.figure.Figure
342
+
343
+ """
344
+ import pandas as pd
345
+
346
+ instr_id = ds.attrs.get("id", "")
347
+ fig, axes = plt.subplots(1, 2, figsize=(12, 5))
348
+ fig.subplots_adjust(top=0.90)
349
+ if instr_id:
350
+ fig.suptitle(instr_id, fontsize=10, y=0.995)
351
+
352
+ if u_var not in ds.data_vars or v_var not in ds.data_vars:
353
+ for ax in axes:
354
+ ax.set_visible(False)
355
+ fig.text(
356
+ 0.5,
357
+ 0.5,
358
+ "No east/north velocities",
359
+ ha="center",
360
+ va="center",
361
+ fontsize=12,
362
+ color="#95a5a6",
363
+ )
364
+ return fig
365
+
366
+ east_raw = ds[u_var].values.astype(float).ravel()
367
+ north_raw = ds[v_var].values.astype(float).ravel()
368
+ t = ds["time"].values
369
+ units = ds[u_var].attrs.get("units", "m s⁻¹")
370
+
371
+ dt_s = (
372
+ float(np.median(np.diff(t) / np.timedelta64(1, "s"))) if len(t) > 1 else 3600.0
373
+ )
374
+ smooth_n = max(3, int(round(smooth_hours * 3600.0 / dt_s)))
375
+ lp_n = max(3, int(round(lp_days * 86400.0 / dt_s)))
376
+
377
+ # Raw panel: Tukey-smoothed raw signal
378
+ east = tukey_smooth(east_raw, smooth_n)
379
+ north = tukey_smooth(north_raw, smooth_n)
380
+
381
+ # Eddy panel: LP mean removed, then Tukey smoothed
382
+ e_lp = (
383
+ pd.Series(east_raw)
384
+ .rolling(window=lp_n, min_periods=1, center=True)
385
+ .mean()
386
+ .values
387
+ )
388
+ n_lp = (
389
+ pd.Series(north_raw)
390
+ .rolling(window=lp_n, min_periods=1, center=True)
391
+ .mean()
392
+ .values
393
+ )
394
+ e_eddy = tukey_smooth(east_raw - e_lp, smooth_n)
395
+ n_eddy = tukey_smooth(north_raw - n_lp, smooth_n)
396
+
397
+ # Time fraction 0→1 for colour encoding
398
+ t_frac = np.linspace(0.0, 1.0, len(east_raw))
399
+ bounds = _nice_colorbar_bounds(0.0, 1.0, n=10)
400
+ norm = mcolors.BoundaryNorm(bounds, ncolors=256)
401
+ cmap = plt.get_cmap("viridis")
402
+
403
+ def _panel(ax: plt.Axes, e: np.ndarray, n: np.ndarray, title: str) -> None:
404
+ mask = np.isfinite(e) & np.isfinite(n)
405
+ if mask.sum() == 0:
406
+ ax.text(
407
+ 0.5, 0.5, "No data", transform=ax.transAxes, ha="center", va="center"
408
+ )
409
+ else:
410
+ ax.scatter(
411
+ e[mask],
412
+ n[mask],
413
+ c=t_frac[mask],
414
+ cmap=cmap,
415
+ norm=norm,
416
+ s=4,
417
+ alpha=0.8,
418
+ linewidths=0,
419
+ marker="o",
420
+ rasterized=True,
421
+ )
422
+ idx = np.where(mask)[0]
423
+ ax.scatter(
424
+ e[idx[0]],
425
+ n[idx[0]],
426
+ s=55,
427
+ c="lime",
428
+ zorder=5,
429
+ marker="o",
430
+ edgecolors="black",
431
+ linewidths=0.5,
432
+ label="Start",
433
+ )
434
+ ax.scatter(
435
+ e[idx[-1]],
436
+ n[idx[-1]],
437
+ s=65,
438
+ c="red",
439
+ zorder=5,
440
+ marker="s",
441
+ edgecolors="black",
442
+ linewidths=0.5,
443
+ label="End",
444
+ )
445
+ ax.legend(fontsize=7, loc="upper right", framealpha=0.7)
446
+ ax.axhline(0, color="#bbb", lw=0.7, zorder=0)
447
+ ax.axvline(0, color="#bbb", lw=0.7, zorder=0)
448
+ ax.set_aspect("equal", adjustable="box")
449
+ ax.set_xlabel(f"East ({units})")
450
+ ax.set_ylabel(f"North ({units})")
451
+ ax.set_title(title, fontsize=10)
452
+ ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.4)
453
+
454
+ _panel(axes[0], east, north, f"Raw ({smooth_hours:.0f}-h smoothed)")
455
+ _panel(
456
+ axes[1],
457
+ e_eddy,
458
+ n_eddy,
459
+ f"Eddy ({lp_days:.0f}-day LP removed, {smooth_hours:.0f}-h smoothed)",
460
+ )
461
+
462
+ sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
463
+ sm.set_array([])
464
+ fig.colorbar(
465
+ sm,
466
+ ax=axes,
467
+ label="Time (0 = start, 1 = end of record)",
468
+ shrink=0.8,
469
+ ticks=bounds,
470
+ )
471
+ return fig
472
+
473
+
474
+ def plot_aquadopp_speed_profile(
475
+ ds: xr.Dataset,
476
+ speed_var: str = "current_speed",
477
+ u_var: str = "east_velocity",
478
+ v_var: str = "north_velocity",
479
+ instr_type_var: str = "instrument_type",
480
+ serial_var: str = "serial",
481
+ hab_var: str = "hab",
482
+ ) -> Optional[plt.Figure]:
483
+ """Horizontal speed boxplots for all Aquadopps, one per instrument at its HAB.
484
+
485
+ X-axis: current speed. Y-axis: height above bottom (m). All Aquadopps
486
+ appear on the same axes so the speed distribution can be compared across
487
+ depths.
488
+
489
+ If *speed_var* is not present in *ds*, speed is computed from *u_var* and
490
+ *v_var* as ``sqrt(u² + v²)``.
491
+
492
+ Parameters
493
+ ----------
494
+ ds : xr.Dataset
495
+ Stacked mooring dataset with shape (time, N_LEVELS).
496
+ speed_var : str
497
+ Preferred speed variable name; computed from u/v if absent.
498
+ u_var, v_var : str
499
+ Used to compute speed when *speed_var* is not present.
500
+ instr_type_var, serial_var, hab_var : str
501
+ Dimension-coordinate variable names.
502
+
503
+ Returns
504
+ -------
505
+ matplotlib.figure.Figure or None
506
+ None if no Aquadopp instruments are found.
507
+
508
+ Notes
509
+ -----
510
+ Adapted from ``02_aqdp_ploter.py`` by L. Moscatel (lmoscat),
511
+ Universitat de Barcelona.
512
+
513
+ """
514
+ instr_types = ds[instr_type_var].values
515
+ serials = ds[serial_var].values
516
+ habs = ds[hab_var].values
517
+ aqd_idx = [i for i, t in enumerate(instr_types) if str(t).lower() == "aquadopp"]
518
+
519
+ if not aqd_idx:
520
+ return None
521
+
522
+ # Collect per-instrument speed arrays
523
+ records = []
524
+ for i in aqd_idx:
525
+ if speed_var in ds.data_vars:
526
+ spd = ds[speed_var].values[:, i].ravel()
527
+ elif u_var in ds.data_vars and v_var in ds.data_vars:
528
+ u = ds[u_var].values[:, i]
529
+ v = ds[v_var].values[:, i]
530
+ spd = np.sqrt(u**2 + v**2)
531
+ else:
532
+ continue
533
+ spd_clean = spd[np.isfinite(spd)]
534
+ records.append((float(habs[i]), str(serials[i]), spd_clean))
535
+
536
+ if not records:
537
+ return None
538
+
539
+ hab_vals = [r[0] for r in records]
540
+ hab_range = max(hab_vals) - min(hab_vals) if len(hab_vals) > 1 else 10.0
541
+ box_width = max(2.0, hab_range * 0.06)
542
+
543
+ fig, ax = plt.subplots(figsize=(5, max(3, len(records) * 0.7 + 1)))
544
+
545
+ for hab, serial, spd_clean in records:
546
+ bp = ax.boxplot(
547
+ spd_clean,
548
+ vert=False,
549
+ positions=[hab],
550
+ widths=box_width,
551
+ patch_artist=True,
552
+ flierprops=dict(marker=".", markersize=2, alpha=0.3, color="steelblue"),
553
+ medianprops=dict(color="navy", linewidth=1.5),
554
+ manage_ticks=False,
555
+ )
556
+ bp["boxes"][0].set_facecolor("steelblue")
557
+ bp["boxes"][0].set_alpha(0.6)
558
+
559
+ # Serial label to the right of the whisker end
560
+ q75 = np.percentile(spd_clean, 75)
561
+ iqr = q75 - np.percentile(spd_clean, 25)
562
+ whisker_end = q75 + 1.5 * iqr
563
+ ax.text(
564
+ whisker_end * 1.02,
565
+ hab,
566
+ f"s/n {serial}",
567
+ va="center",
568
+ fontsize=7,
569
+ color="#333",
570
+ )
571
+
572
+ units = (
573
+ ds[speed_var].attrs.get("units", "m/s") if speed_var in ds.data_vars else "m/s"
574
+ )
575
+ ax.set_xlabel(f"Current speed ({units})")
576
+ ax.set_ylabel("Height above bottom (m)")
577
+ ax.set_xlim(left=0)
578
+ ax.set_ylim(min(hab_vals) - box_width * 1.5, max(hab_vals) + box_width * 1.5)
579
+ ax.grid(True, axis="x", linestyle="--", linewidth=0.5, alpha=0.5)
580
+ fig.tight_layout()
581
+ return fig
582
+
583
+
584
+ def plot_adcp_trajectories(
585
+ ds: xr.Dataset,
586
+ u_var: str = "east_velocity",
587
+ v_var: str = "north_velocity",
588
+ instr_type_var: str = "instrument_type",
589
+ hab_var: str = "hab",
590
+ seabed_qc_var: str = "seabed_qc",
591
+ percent_good_qc_var: str = "percent_good_qc",
592
+ ) -> Optional[plt.Figure]:
593
+ """Lagrangian per-bin trajectories for ADCP data, coloured by HAB.
594
+
595
+ Each depth bin integrated by Euler-forward from the origin. Bins that are
596
+ entirely below the seabed (all seabed_qc >= 3) are silently omitted. QC
597
+ masking is applied before integration so bad pings are treated as zero
598
+ velocity (do not accumulate spurious displacement).
599
+
600
+ Parameters
601
+ ----------
602
+ ds : xr.Dataset
603
+ Stacked mooring dataset. ADCP bins are identified by
604
+ ``instrument_type == "ADCP"``.
605
+ u_var, v_var : str
606
+ Eastward and northward velocity variable names (m s⁻¹).
607
+ instr_type_var, hab_var : str
608
+ Coordinate variable names.
609
+ seabed_qc_var : str
610
+ QC variable for seabed proximity; bins with all values >= 3 are skipped.
611
+ percent_good_qc_var : str
612
+ Ping-quality QC; timesteps flagged >= 3 are zeroed before integration.
613
+
614
+ Returns
615
+ -------
616
+ matplotlib.figure.Figure or None
617
+ None if no ADCP bins are found.
618
+ """
619
+ if u_var not in ds.data_vars or v_var not in ds.data_vars:
620
+ return None
621
+
622
+ instr_types = ds[instr_type_var].values
623
+ habs = ds[hab_var].values
624
+ adcp_idx = [i for i, t in enumerate(instr_types) if str(t).upper() == "ADCP"]
625
+
626
+ if not adcp_idx:
627
+ return None
628
+
629
+ time = ds["time"].values
630
+ dt = np.array(
631
+ [(time[j] - time[j - 1]) / np.timedelta64(1, "s") for j in range(1, len(time))],
632
+ dtype=float,
633
+ )
634
+
635
+ # Build per-bin trajectories with seabed + percent_good masking
636
+ trajs: list = [] # (hab, x, y)
637
+ hab_all: list = []
638
+ for i in adcp_idx:
639
+ u = ds[u_var].values[:, i].copy().astype(float)
640
+ v = ds[v_var].values[:, i].copy().astype(float)
641
+
642
+ # Mask by seabed proximity
643
+ if seabed_qc_var in ds.data_vars:
644
+ sqc = ds[seabed_qc_var].values[:, i]
645
+ # Skip bins that are always below the seabed
646
+ if np.all(sqc >= 3):
647
+ continue
648
+ u[sqc >= 3] = np.nan
649
+ v[sqc >= 3] = np.nan
650
+
651
+ # Mask by ping quality
652
+ if percent_good_qc_var in ds.data_vars:
653
+ pqc = ds[percent_good_qc_var].values[:, i]
654
+ u[pqc >= 3] = np.nan
655
+ v[pqc >= 3] = np.nan
656
+
657
+ # NaN → 0 for integration (no displacement during missing pings)
658
+ x = (
659
+ np.concatenate([[0.0], np.cumsum(np.nan_to_num(u[:-1], nan=0.0) * dt)])
660
+ / 1000.0
661
+ )
662
+ y = (
663
+ np.concatenate([[0.0], np.cumsum(np.nan_to_num(v[:-1], nan=0.0) * dt)])
664
+ / 1000.0
665
+ )
666
+ trajs.append((float(habs[i]), x, y))
667
+ hab_all.append(float(habs[i]))
668
+
669
+ if not trajs:
670
+ return None
671
+
672
+ hab_min, hab_max = min(hab_all), max(hab_all)
673
+ cmap = plt.get_cmap("viridis")
674
+ _bounds = _nice_colorbar_bounds(hab_min, hab_max, n=20)
675
+ # Enforce minimum tick spacing of 50 m so labels don't crowd
676
+ if len(_bounds) > 1 and (_bounds[1] - _bounds[0]) < 50.0:
677
+ _n_hab = max(2, int((hab_max - hab_min) / 50.0))
678
+ _bounds = _nice_colorbar_bounds(hab_min, hab_max, n=_n_hab)
679
+ norm: mcolors.BoundaryNorm = mcolors.BoundaryNorm(_bounds, ncolors=256)
680
+
681
+ fig, ax = plt.subplots(figsize=(6, 5))
682
+
683
+ for hab, x, y in trajs:
684
+ points = np.array([x, y]).T.reshape(-1, 1, 2)
685
+ segments = np.concatenate([points[:-1], points[1:]], axis=1)
686
+ lc = LineCollection(segments, cmap=cmap, norm=norm, linewidth=1.0, alpha=0.6)
687
+ lc.set_array(np.full(len(segments), hab))
688
+ ax.add_collection(lc)
689
+
690
+ sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
691
+ sm.set_array([])
692
+ fig.colorbar(sm, ax=ax, label="HAB (m)", shrink=0.75, ticks=_bounds)
693
+
694
+ ax.plot(0, 0, "o", color="black", markersize=7, zorder=6, label="Start")
695
+ ax.legend(fontsize=8, loc="upper left")
696
+ ax.autoscale_view()
697
+ ax.set_xlabel("East displacement (km)")
698
+ ax.set_ylabel("North displacement (km)")
699
+ ax.axhline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4)
700
+ ax.axvline(0, color="k", linewidth=0.5, linestyle="--", alpha=0.4)
701
+ ax.set_aspect("equal", adjustable="box")
702
+ ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.4)
703
+ ax.set_title("ADCP bins coloured by HAB")
704
+ fig.tight_layout()
705
+ return fig