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,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
|