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
oceanarray/plotter.py ADDED
@@ -0,0 +1,942 @@
1
+ from pathlib import Path
2
+
3
+ import matplotlib.colors as mcolors
4
+ import matplotlib.dates as mdates
5
+ import matplotlib.pyplot as plt
6
+ import numpy as np
7
+ import xarray as xr
8
+ from matplotlib.colors import LinearSegmentedColormap
9
+ from pandas import DataFrame
10
+
11
+ from .utilities import _nice_colorbar_bounds
12
+
13
+
14
+ def plot_qartod_summary(ds, var="TEMP", qc_var="QC_ROLLUP"):
15
+ """Plot QARTOD rollup flags and flagged data points for a given variable.
16
+
17
+ Parameters
18
+ ----------
19
+ ds : xarray.Dataset
20
+ Dataset containing the variable and the QC flag.
21
+ var : str, optional
22
+ Name of the variable to plot (default is "TEMP").
23
+ qc_var : str, optional
24
+ Name of the QC rollup flag variable (default is "QC_ROLLUP").
25
+
26
+ """
27
+ style_path = Path(__file__).parent.parent / "oceanarray" / "oceanarray.mplstyle"
28
+ plt.style.use(str(style_path))
29
+ flag = ds[qc_var]
30
+ time = ds["TIME"]
31
+ data = ds[var]
32
+
33
+ fig, axs = plt.subplots(nrows=2, ncols=1, figsize=(12, 6), sharex=True)
34
+
35
+ # Subplot 1: QC flag over time
36
+ axs[0].plot(time, flag, drawstyle="steps-post", color="black")
37
+ axs[0].set_yticks([1, 2, 3, 4])
38
+ axs[0].set_yticklabels(["Good", "Not Eval", "Suspect", "Fail"])
39
+ axs[0].set_title("QARTOD Rollup Flag Over Time")
40
+ axs[0].grid(True)
41
+
42
+ # Subplot 2: Variable with flags
43
+ axs[1].plot(time, data, label=var, color="gray", linewidth=0.8)
44
+ axs[1].scatter(
45
+ time.where(flag == 1), data.where(flag == 1), color="green", s=10, label="Good"
46
+ )
47
+ axs[1].scatter(
48
+ time.where(flag == 3),
49
+ data.where(flag == 3),
50
+ color="orange",
51
+ s=15,
52
+ label="Suspect",
53
+ )
54
+ axs[1].scatter(
55
+ time.where(flag == 4), data.where(flag == 4), color="red", s=15, label="Fail"
56
+ )
57
+
58
+ axs[1].legend()
59
+ axs[1].set_title(f"{var} with QARTOD Flags")
60
+ axs[1].set_xlabel("Time")
61
+ axs[1].set_ylabel(var)
62
+ axs[1].grid(True)
63
+
64
+ plt.tight_layout()
65
+ plt.show()
66
+
67
+
68
+ def plot_climatology(
69
+ clim_ds: xr.Dataset,
70
+ var: str = "dTdp",
71
+ clim_ds_smoothed: xr.Dataset | None = None,
72
+ fig=None,
73
+ ax=None,
74
+ ):
75
+ """Plot seasonal climatology of dT/dP or dS/dP, optionally with smoothed version overlaid.
76
+
77
+ Parameters
78
+ ----------
79
+ clim_ds : xr.Dataset
80
+ Raw climatology dataset with 'dTdp' and/or 'dSdp'.
81
+ var : str, optional
82
+ Variable to plot ('dTdp' or 'dSdp'), by default 'dTdp'.
83
+ clim_ds_smoothed : xr.Dataset, optional
84
+ Smoothed climatology dataset to overlay, by default None.
85
+ fig : matplotlib.figure.Figure, optional
86
+ Existing figure to plot on. If None, a new figure is created.
87
+ ax : matplotlib.axes.Axes, optional
88
+ Existing axes to plot on. If None, new axes are created.
89
+
90
+ Notes
91
+ -----
92
+ If smoothed climatology is provided, the raw climatology is shown in grey.
93
+ Otherwise, only the provided climatology is shown in color.
94
+
95
+ """
96
+ style_path = Path(__file__).parent.parent / "oceanarray" / "oceanarray.mplstyle"
97
+ plt.style.use(str(style_path))
98
+ if var not in clim_ds:
99
+ raise ValueError(f"{var} not found in climatology dataset.") # noqa: TRY003
100
+
101
+ if fig is None or ax is None:
102
+ fig, ax = plt.subplots(figsize=(10, 6))
103
+
104
+ months = np.arange(1, 13)
105
+ # Create a colormap for the seasonal cycle
106
+
107
+ # Define colors for the seasonal cycle (cold to warm to cold)
108
+ colors = [
109
+ (0, 0, 1), # Blue (January, cold)
110
+ (0, 0.5, 1), # Light blue (Spring)
111
+ (0, 1, 0), # Green (Early summer)
112
+ (1, 1, 0), # Yellow (Mid-summer, warm)
113
+ (1, 0.5, 0), # Orange (Late summer)
114
+ (1, 0, 0), # Red (July, peak warmth)
115
+ (0.5, 0, 0.5), # Purple (Autumn)
116
+ (0, 0, 1), # Blue (December, cold again)
117
+ ]
118
+
119
+ # Create a colormap with 12 discrete colors for each month
120
+ seasonal_colormap = LinearSegmentedColormap.from_list(
121
+ "seasonal_cycle", colors, N=12
122
+ )
123
+
124
+ for month in months:
125
+ TEMP = clim_ds["TEMP"]
126
+
127
+ if clim_ds_smoothed is not None:
128
+ # Plot raw (grey)
129
+ ax.plot(
130
+ TEMP,
131
+ clim_ds[var].sel(month=month),
132
+ color="grey",
133
+ alpha=0.4,
134
+ linewidth=1,
135
+ )
136
+
137
+ # Plot smoothed (color)
138
+ ax.plot(
139
+ TEMP,
140
+ clim_ds_smoothed[var].sel(month=month),
141
+ linewidth=1.5,
142
+ label=f"Month {month}",
143
+ color=seasonal_colormap(month - 1),
144
+ )
145
+ else:
146
+ # Only plot raw (color)
147
+ ax.plot(
148
+ TEMP,
149
+ clim_ds[var].sel(month=month),
150
+ label=f"Month {month}",
151
+ color=seasonal_colormap(month - 1),
152
+ linewidth=1.5,
153
+ )
154
+
155
+ ax.set_xlabel("Temperature (°C)")
156
+ ax.set_ylabel(f"{var} (per dbar)")
157
+ ax.set_title(f"Monthly {var} Climatology")
158
+ ax.legend(ncol=3, fontsize=8)
159
+ ax.grid(True)
160
+ plt.tight_layout()
161
+
162
+ return fig, ax
163
+
164
+
165
+ def scatter_profile_vs_PRES(ds_interp, ds_12h, var="CT", downsample: int = 10):
166
+ """Scatter plot of *var* vs pressure for two datasets.
167
+
168
+ Parameters
169
+ ----------
170
+ ds_interp : xarray.Dataset
171
+ Dataset with a PRES coordinate and a TIME dimension.
172
+ ds_12h : xarray.Dataset
173
+ Second dataset (e.g. 12-hourly gridded) with 1-D PRES and TIME.
174
+ var : str
175
+ Variable to plot on the x-axis.
176
+ downsample : int
177
+ Keep every Nth point before plotting (default 10). With O(100k) points
178
+ the plot is saturated at any marker size; downsampling is needed to see
179
+ individual dots. Set to 1 to plot all points.
180
+
181
+ """
182
+ from . import parameters as P
183
+
184
+ plt.style.use(str(P.MPLSTYLE))
185
+ t1 = ds_interp[var].values.flatten()[::downsample]
186
+ # For ds_interp, PRES is a 1D array (DEPTH), so tile it for each TIME
187
+ p1 = np.tile(ds_interp["PRES"].values, ds_interp["TIME"].size)[::downsample]
188
+
189
+ t2 = ds_12h[var].values.flatten()[::downsample]
190
+ p2 = ds_12h["PRES"].values.flatten()[::downsample]
191
+
192
+ print(f"Plotting {t1.size:,} points for ds_interp (1-in-{downsample})")
193
+ print(f"Plotting {t2.size:,} points for ds_12h (1-in-{downsample})")
194
+
195
+ plt.figure(figsize=(8, 5))
196
+ plt.scatter(t1, p1, s=20, alpha=0.3, label="ds_interp")
197
+ plt.scatter(t2, p2, s=20, alpha=0.3, color="red", label="ds_12h")
198
+ plt.gca().invert_yaxis()
199
+ plt.xlabel(f"{var} ({ds_interp[var].attrs.get('units', '')})")
200
+ plt.ylabel("Pressure (dbar)")
201
+ plt.title(f"Scatter plot of {var} vs PRES")
202
+ plt.legend()
203
+ plt.tight_layout()
204
+ plt.show()
205
+
206
+
207
+ def pcolor_timeseries_by_depth(ds_interp, var="SA"):
208
+ style_path = Path(__file__).parent.parent / "oceanarray" / "oceanarray.mplstyle"
209
+ plt.style.use(str(style_path))
210
+ plt.figure(figsize=(12, 6))
211
+ pc = plt.pcolormesh(
212
+ ds_interp["TIME"], ds_interp["PRES"], ds_interp[var].T, shading="auto"
213
+ )
214
+ plt.gca().invert_yaxis()
215
+ # Get variable attributes for labeling
216
+ var_attrs = ds_interp[var].attrs
217
+ long_name = var_attrs.get("long_name", var)
218
+ units = var_attrs.get("units", "")
219
+ plt.colorbar(pc, label=f"{long_name} ({units})" if units else long_name)
220
+ plt.xlabel("Time")
221
+ plt.ylabel(
222
+ ds_interp["PRES"].attrs.get("long_name", "Pressure")
223
+ + f" ({ds_interp['PRES'].attrs.get('units', 'dbar')})"
224
+ )
225
+ plt.title(f"{long_name} from ds_interp")
226
+ plt.tight_layout()
227
+ plt.show()
228
+
229
+
230
+ def plot_timeseries_by_depth(ds, var="TEMP"):
231
+ """Plot individual time series for each depth level.
232
+
233
+ Parameters
234
+ ----------
235
+ ds : xarray.Dataset
236
+ Dataset containing the variable to plot.
237
+ var : str
238
+ Variable name (default is "TEMP").
239
+
240
+ """
241
+ style_path = Path(__file__).parent.parent / "oceanarray" / "oceanarray.mplstyle"
242
+ plt.style.use(str(style_path))
243
+ ds = ds.sortby("DEPTH")
244
+ da = ds[var].squeeze() # remove singleton lat/lon if present
245
+ time = ds["TIME"].values
246
+ depths = ds["DEPTH"].values
247
+
248
+ plt.figure(figsize=(12, 6))
249
+
250
+ for i, depth in enumerate(depths):
251
+ # da[:, i] assumes dimensions are (TIME, DEPTH)
252
+ series = da.isel(DEPTH=i)
253
+ plt.plot(time, series, label=f"{depth:.1f} m")
254
+
255
+ plt.xlabel("Time")
256
+ plt.ylabel(f"{var} [{da.attrs.get('units', 'unknown')}]")
257
+ plt.title(f"{var} time series by depth")
258
+ plt.legend(title="Depth", bbox_to_anchor=(1.05, 1), loc="upper left")
259
+ plt.tight_layout()
260
+ plt.show()
261
+
262
+
263
+ _DEFAULT_NN = np.timedelta64(12, "h")
264
+
265
+
266
+ def plot_trim_windows(ds, dstart, dend, NN=_DEFAULT_NN):
267
+ """Plot start and end windows for variables T, C, P in the dataset,
268
+ highlighting data before/after dstart/dend.
269
+
270
+ Parameters
271
+ ----------
272
+ ds : xarray.Dataset
273
+ Dataset containing variables 'T', 'C', 'P' and 'TIME'.
274
+ dstart : np.datetime64
275
+ Deployment start time.
276
+ dend : np.datetime64
277
+ Deployment end time.
278
+ NN : np.timedelta64, optional
279
+ Window size (default: 12 hours).
280
+
281
+ """
282
+ style_path = Path(__file__).parent.parent / "oceanarray" / "oceanarray.mplstyle"
283
+ plt.style.use(str(style_path))
284
+ fig, axes = plt.subplots(3, 2, figsize=(9, 8), sharex="col")
285
+ variables = ["T", "C", "P"]
286
+
287
+ for i, var in enumerate(variables):
288
+ data = ds[var].values
289
+ time = ds["TIME"].values
290
+
291
+ valid_idx = np.where(~np.isnan(data))[0]
292
+ first_idx = valid_idx[0]
293
+ last_idx = valid_idx[-1]
294
+
295
+ # Left column: start of record
296
+ start_window_end = dstart + NN
297
+ left_mask = (time >= time[first_idx]) & (time <= start_window_end)
298
+ axes[i, 0].plot(
299
+ time[left_mask],
300
+ data[left_mask],
301
+ marker="o",
302
+ linestyle="-",
303
+ markerfacecolor="none",
304
+ label=f"{var} start",
305
+ markersize=5,
306
+ )
307
+ after_dstart_mask = left_mask & (time >= dstart)
308
+ axes[i, 0].plot(
309
+ time[after_dstart_mask],
310
+ data[after_dstart_mask],
311
+ marker="o",
312
+ color="red",
313
+ label=f"{var} after dstart",
314
+ markersize=5,
315
+ )
316
+ axes[i, 0].axvline(dstart, color="k", linestyle="--", label="dstart")
317
+ axes[i, 0].set_ylabel(var)
318
+ if i == 0:
319
+ axes[i, 0].set_title("Start of Record")
320
+ axes[i, 0].legend()
321
+
322
+ # Right column: end of record
323
+ end_window_start = dend - NN
324
+ right_mask = (time >= end_window_start) & (time <= time[last_idx])
325
+ axes[i, 1].plot(
326
+ time[right_mask],
327
+ data[right_mask],
328
+ marker="o",
329
+ linestyle="-",
330
+ markerfacecolor="none",
331
+ label=f"{var} end",
332
+ markersize=5,
333
+ )
334
+ before_dend_mask = right_mask & (time <= dend)
335
+ axes[i, 1].plot(
336
+ time[before_dend_mask],
337
+ data[before_dend_mask],
338
+ marker="o",
339
+ color="red",
340
+ label=f"{var} before dend",
341
+ markersize=5,
342
+ )
343
+ axes[i, 1].axvline(dend, color="k", linestyle="--", label="dend")
344
+ if i == 0:
345
+ axes[i, 1].set_title("End of Record")
346
+ axes[i, 1].legend()
347
+ for ax in axes[i, :]:
348
+ plt.setp(ax.get_xticklabels(), rotation=30, ha="right")
349
+
350
+ plt.tight_layout()
351
+ return fig, axes
352
+
353
+
354
+ def plot_microcat_raw(ds, save_path=None):
355
+ """Plot temperature, conductivity, and pressure from a raw/stage2 microcat NetCDF.
356
+
357
+ Works with seasenselib variable names (temperature, conductivity, pressure).
358
+ """
359
+ style_path = Path(__file__).parent / "oceanarray.mplstyle"
360
+ plt.style.use(str(style_path))
361
+
362
+ panels = [("temperature", "Temperature [°C]", "tab:red")]
363
+ if "conductivity" in ds.data_vars:
364
+ panels.append(("conductivity", "Conductivity [mS/cm]", "tab:blue"))
365
+ if "pressure" in ds.data_vars:
366
+ panels.append(("pressure", "Pressure [dbar]", "tab:green"))
367
+
368
+ nrows = len(panels)
369
+ fig, axs = plt.subplots(nrows, 1, figsize=(12, 3 * nrows), sharex=True)
370
+ if nrows == 1:
371
+ axs = [axs]
372
+
373
+ for ax, (var, label, color) in zip(axs, panels):
374
+ ax.plot(ds["time"], ds[var], color=color, linewidth=0.5)
375
+ ax.set_ylabel(label)
376
+ axs[-1].set_xlabel("Time")
377
+
378
+ serial = (
379
+ ds["serial_number"].item()
380
+ if "serial_number" in ds
381
+ else ds.attrs.get("serial_number", "?")
382
+ )
383
+ depth = ds["InstrDepth"].item() if "InstrDepth" in ds else "?"
384
+ axs[0].set_title(f"MicroCAT s/n: {serial} | Target depth: {depth} m")
385
+
386
+ locator = mdates.AutoDateLocator()
387
+ formatter = mdates.ConciseDateFormatter(locator)
388
+ axs[-1].xaxis.set_major_locator(locator)
389
+ axs[-1].xaxis.set_major_formatter(formatter)
390
+ plt.tight_layout()
391
+
392
+ if save_path:
393
+ fig.savefig(save_path, dpi=150, bbox_inches="tight")
394
+
395
+ return fig
396
+
397
+
398
+ def plot_aquadopp_raw(ds, save_path=None):
399
+ """Plot east velocity, north velocity, and pressure from a raw/stage2 Aquadopp NetCDF."""
400
+ style_path = Path(__file__).parent / "oceanarray.mplstyle"
401
+ plt.style.use(str(style_path))
402
+
403
+ # Pick pressure variable — prefer 'pressure', fall back to 'pressure_1'
404
+ pvar = next((v for v in ("pressure", "pressure_1") if v in ds.data_vars), None)
405
+
406
+ panels = [
407
+ ("east_velocity", "East velocity [m/s]", "tab:blue", False),
408
+ ("north_velocity", "North velocity [m/s]", "tab:orange", False),
409
+ ]
410
+ if pvar:
411
+ panels.append((pvar, "Pressure [dbar]", "tab:green", True))
412
+ for tvar, tlabel in (("Pitch", "Pitch [°]"), ("Roll", "Roll [°]")):
413
+ if tvar in ds.data_vars:
414
+ panels.append((tvar, tlabel, "tab:purple", False))
415
+
416
+ nrows = len(panels)
417
+ fig, axs = plt.subplots(nrows, 1, figsize=(12, 3 * nrows), sharex=True)
418
+ if nrows == 1:
419
+ axs = [axs]
420
+
421
+ for ax, (var, label, color, invert) in zip(axs, panels):
422
+ ax.plot(ds["time"], ds[var], color=color, linewidth=0.5)
423
+ if var in ("east_velocity", "north_velocity"):
424
+ ax.axhline(0, color="k", linewidth=0.5, linestyle="--")
425
+ ax.set_ylabel(label)
426
+ if invert:
427
+ vmin = float(ds[var].min())
428
+ vmax = float(ds[var].max())
429
+ pad = max((vmax - vmin) * 0.1, 0.5)
430
+ ax.set_ylim(vmax + pad, vmin - pad)
431
+ axs[-1].set_xlabel("Time")
432
+
433
+ serial = (
434
+ ds["serial_number"].item()
435
+ if "serial_number" in ds
436
+ else ds.attrs.get("serial_number", "?")
437
+ )
438
+ depth = f"{ds['InstrDepth'].item():.0f} m" if "InstrDepth" in ds else "?"
439
+ axs[0].set_title(f"Aquadopp s/n: {serial} | Target depth: {depth}")
440
+
441
+ locator = mdates.AutoDateLocator()
442
+ axs[-1].xaxis.set_major_locator(locator)
443
+ axs[-1].xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
444
+ plt.tight_layout()
445
+
446
+ if save_path:
447
+ fig.savefig(save_path, dpi=150, bbox_inches="tight")
448
+
449
+ return fig
450
+
451
+
452
+ def plot_microcat(ds):
453
+ style_path = Path(__file__).parent.parent / "oceanarray" / "oceanarray.mplstyle"
454
+ plt.style.use(str(style_path))
455
+
456
+ fig, axs = plt.subplots(3, 1, figsize=(9, 8), sharex=True)
457
+
458
+ # Top panel: Temperature
459
+ axs[0].plot(ds["TIME"], ds["T"], color="tab:red")
460
+ axs[0].set_ylabel("Temperature [deg C]")
461
+
462
+ # Middle panel: Conductivity
463
+ axs[1].plot(ds["TIME"], ds["C"], color="tab:blue")
464
+ axs[1].set_ylabel("Conductivity [mS/cm]")
465
+
466
+ # Bottom panel: Pressure
467
+ axs[2].plot(ds["TIME"], ds["P"], color="tab:green")
468
+ axs[2].set_ylabel("Pressure [dbar]")
469
+ axs[2].set_xlabel("Time")
470
+
471
+ # Title
472
+ serial_number = ds.attrs.get("serial_number", "Unknown")
473
+ instr_depth = ds["InstrDepth"].item() if "InstrDepth" in ds else "Unknown"
474
+ axs[0].set_title(f"MicroCAT s/n: {serial_number}; Target Depth: {instr_depth}")
475
+
476
+ axs[2].xaxis.set_major_formatter(mdates.DateFormatter("%Y.%b"))
477
+ plt.setp(axs[2].get_xticklabels(), rotation=30, ha="right")
478
+
479
+ plt.tight_layout(rect=[0, 0, 1, 0.96])
480
+ return fig
481
+
482
+
483
+ def show_variables(data):
484
+ """Processes an xarray Dataset or a netCDF file, extracts variable information,
485
+ and returns a styled DataFrame with details about the variables.
486
+
487
+ Parameters
488
+ ----------
489
+ data (str or xr.Dataset): The input data, either a file path to a netCDF file or an xarray Dataset.
490
+
491
+ Returns
492
+ -------
493
+ pandas.io.formats.style.Styler: A styled DataFrame containing the following columns:
494
+ - dims: The dimension of the variable (or "string" if it is a string type).
495
+ - name: The name of the variable.
496
+ - units: The units of the variable (if available).
497
+ - comment: Any additional comments about the variable (if available).
498
+
499
+ """
500
+ if isinstance(data, str):
501
+ print("information is based on file: {}".format(data))
502
+ dataset = xr.Dataset(data)
503
+ variables = dataset.variables
504
+ elif isinstance(data, xr.Dataset):
505
+ print("information is based on xarray Dataset")
506
+ variables = data.variables
507
+ else:
508
+ raise TypeError("Input data must be a file path (str) or an xarray Dataset") # noqa: TRY003
509
+
510
+ info = {}
511
+ for i, key in enumerate(variables):
512
+ var = variables[key]
513
+ if isinstance(data, str):
514
+ dims = var.dimensions[0] if len(var.dimensions) == 1 else "string"
515
+ units = "" if not hasattr(var, "units") else var.units
516
+ comment = "" if not hasattr(var, "comment") else var.comment
517
+ else:
518
+ dims = var.dims[0] if len(var.dims) == 1 else "string"
519
+ units = var.attrs.get("units", "")
520
+ comment = var.attrs.get("comment", "")
521
+
522
+ info[i] = {
523
+ "name": key,
524
+ "dims": dims,
525
+ "units": units,
526
+ "comment": comment,
527
+ "standard_name": var.attrs.get("standard_name", ""),
528
+ "dtype": str(var.dtype) if isinstance(data, str) else str(var.data.dtype),
529
+ }
530
+
531
+ vars = DataFrame(info).T
532
+
533
+ dim = vars.dims
534
+ dim[dim.str.startswith("str")] = "string"
535
+ vars["dims"] = dim
536
+
537
+ vars = (
538
+ vars.sort_values(["dims", "name"])
539
+ .reset_index(drop=True)
540
+ .loc[:, ["dims", "name", "units", "comment", "standard_name", "dtype"]]
541
+ .set_index("name")
542
+ .style
543
+ )
544
+
545
+ return vars
546
+
547
+
548
+ def show_attributes(data):
549
+ """Processes an xarray Dataset or a netCDF file, extracts attribute information,
550
+ and returns a DataFrame with details about the attributes.
551
+
552
+ Parameters
553
+ ----------
554
+ data (str or xr.Dataset): The input data, either a file path to a netCDF file or an xarray Dataset.
555
+
556
+ Returns
557
+ -------
558
+ pandas.DataFrame: A DataFrame containing the following columns:
559
+ - Attribute: The name of the attribute.
560
+ - Value: The value of the attribute.
561
+
562
+ """
563
+ from netCDF4 import Dataset
564
+
565
+ if isinstance(data, str):
566
+ print("information is based on file: {}".format(data))
567
+ rootgrp = Dataset(data, "r", format="NETCDF4")
568
+ attributes = rootgrp.ncattrs()
569
+
570
+ def get_attr(key):
571
+ return getattr(rootgrp, key)
572
+
573
+ elif isinstance(data, xr.Dataset):
574
+ print("information is based on xarray Dataset")
575
+ attributes = data.attrs.keys()
576
+
577
+ def get_attr(key): # type: ignore[no-redef]
578
+ return data.attrs[key]
579
+
580
+ else:
581
+ raise TypeError("Input data must be a file path (str) or an xarray Dataset") # noqa: TRY003
582
+
583
+ info = {}
584
+ for i, key in enumerate(attributes):
585
+ dtype = type(get_attr(key)).__name__
586
+ info[i] = {"Attribute": key, "Value": get_attr(key), "DType": dtype}
587
+
588
+ attrs = DataFrame(info).T
589
+
590
+ return attrs
591
+
592
+
593
+ # ---------------------------------------------------------------------------
594
+ # Multi-instrument mooring overview plot
595
+ # ---------------------------------------------------------------------------
596
+
597
+
598
+ def _instrument_label(nc_path: Path, ds: xr.Dataset) -> str:
599
+ """Build a short instrument label, e.g. 'MC2942 868m'."""
600
+ from . import parameters as P
601
+
602
+ instrument_dir = nc_path.parent.name
603
+ prefix = P.INSTRUMENT_ABBREV.get(instrument_dir, instrument_dir[:2].upper())
604
+
605
+ serial = ds["serial_number"].item() if "serial_number" in ds else "?"
606
+ # Strip trailing non-alphanumeric chars (e.g. * markers in serial strings)
607
+ import re
608
+
609
+ serial = re.sub(r"[^\w]", "", str(serial))
610
+
611
+ depth = f"{ds['InstrDepth'].item():.0f}m" if "InstrDepth" in ds else ""
612
+ return f"{prefix}{serial} {depth}".strip()
613
+
614
+
615
+ def plot_mooring_timeseries(
616
+ proc_dir: Path,
617
+ mooring: str,
618
+ var_y: str = "temperature",
619
+ var_color: str = None,
620
+ colormap: str = None,
621
+ downsample_seconds: int = None,
622
+ markersize: float = None,
623
+ save_path: Path = None,
624
+ show: bool = True,
625
+ ) -> plt.Figure:
626
+ """Plot all instruments in a mooring on shared axes.
627
+
628
+ Two modes depending on whether *var_color* is supplied:
629
+
630
+ **Scatter mode** (``var_color`` given)
631
+ x = time, y = ``var_y`` (typically ``pressure``), colour = ``var_color``
632
+ (typically ``temperature``). Instruments without both variables are
633
+ skipped. The y-axis is inverted so depth increases downward.
634
+
635
+ **Line mode** (``var_color`` is None)
636
+ x = time, y = ``var_y``. Each instrument that has the variable is
637
+ plotted as a separate line and included in the legend.
638
+
639
+ Parameters
640
+ ----------
641
+ proc_dir : Path
642
+ Path to the mooring's proc directory (e.g. ``<basedir>/proc``).
643
+ mooring : str
644
+ Mooring name, e.g. ``dsG3_1_2026``.
645
+ var_y : str
646
+ Variable to place on the y-axis (default: ``temperature``).
647
+ var_color : str, optional
648
+ Variable to use for scatter-plot colour. If None, a line plot is made.
649
+ colormap : str, optional
650
+ Matplotlib colormap name. Defaults to ``parameters.DEFAULT_COLORMAP``.
651
+ downsample_seconds : int, optional
652
+ Resample interval in seconds. Defaults to ``parameters.DOWNSAMPLE_SECONDS``.
653
+ save_path : Path, optional
654
+ If given, save the figure here instead of displaying it.
655
+
656
+ Returns
657
+ -------
658
+ matplotlib.figure.Figure
659
+
660
+ """
661
+ from . import parameters as P
662
+
663
+ plt.style.use(str(P.MPLSTYLE))
664
+ cmap_name = colormap or P.DEFAULT_COLORMAP
665
+ dt_s = downsample_seconds or P.DOWNSAMPLE_SECONDS
666
+
667
+ mooring_proc = Path(proc_dir) / mooring
668
+ use_files = sorted(mooring_proc.rglob("*_stage2.nc"))
669
+
670
+ if not use_files:
671
+ raise FileNotFoundError(f"No _stage2.nc files found under {mooring_proc}") # noqa: TRY003
672
+
673
+ scatter_mode = var_color is not None
674
+
675
+ # ------------------------------------------------------------------
676
+ # Load and resample all instruments that have the required variables
677
+ # ------------------------------------------------------------------
678
+ segments = [] # list of (label, time_array, y_array, color_array_or_None)
679
+
680
+ for nc in use_files:
681
+ try:
682
+ ds = xr.open_dataset(nc, decode_timedelta=False)
683
+ except Exception: # noqa: BLE001 — skip unreadable NC; one bad file must not abort plot
684
+ continue
685
+
686
+ has_y = var_y in ds.data_vars
687
+ has_c = (not scatter_mode) or (var_color in ds.data_vars)
688
+ if not (has_y and has_c):
689
+ ds.close()
690
+ continue
691
+
692
+ try:
693
+ keep = [
694
+ v
695
+ for v in ([var_y] + ([var_color] if scatter_mode else []))
696
+ if v in ds.data_vars
697
+ ]
698
+ if len(ds["time"]) > 1:
699
+ median_dt = float(
700
+ np.median(
701
+ np.diff(ds["time"].values)
702
+ .astype("timedelta64[s]")
703
+ .astype(float)
704
+ )
705
+ )
706
+ step = max(1, round(dt_s / median_dt))
707
+ else:
708
+ step = 1
709
+ ds_small = ds[keep].isel(time=slice(None, None, step)).load()
710
+ except Exception: # noqa: BLE001 — skip instruments that fail to subsample
711
+ ds.close()
712
+ continue
713
+
714
+ label = _instrument_label(nc, ds)
715
+ ds.close()
716
+
717
+ t = ds_small["time"].values
718
+ y = ds_small[var_y].values
719
+ c = ds_small[var_color].values if scatter_mode else None
720
+ segments.append((label, t, y, c))
721
+
722
+ if not segments:
723
+ raise ValueError(
724
+ f"No instruments have variable '{var_y}'"
725
+ + (f" and '{var_color}'" if scatter_mode else "")
726
+ )
727
+
728
+ # ------------------------------------------------------------------
729
+ # Compute colour limits from combined 5th–95th percentile
730
+ # ------------------------------------------------------------------
731
+ if scatter_mode:
732
+ all_c = np.concatenate([c for _, _, _, c in segments if c is not None])
733
+ all_c = all_c[np.isfinite(all_c)]
734
+ vmin = float(np.percentile(all_c, P.COLORBAR_PLOW))
735
+ vmax = float(np.percentile(all_c, P.COLORBAR_PHIGH))
736
+
737
+ # ------------------------------------------------------------------
738
+ # Draw
739
+ # ------------------------------------------------------------------
740
+ fig, ax = plt.subplots(figsize=P.FIGURE_SIZE_WIDE)
741
+
742
+ if scatter_mode:
743
+ cmap = plt.get_cmap(cmap_name)
744
+ sc = None
745
+ for label, t, y, c in segments:
746
+ sc = ax.scatter(
747
+ t,
748
+ y,
749
+ c=c,
750
+ cmap=cmap,
751
+ vmin=vmin,
752
+ vmax=vmax,
753
+ s=markersize if markersize is not None else 4,
754
+ alpha=0.6,
755
+ rasterized=True,
756
+ label=label,
757
+ )
758
+ if sc is not None:
759
+ cbar = fig.colorbar(sc, ax=ax, pad=0.02)
760
+ cbar.set_label(var_color.replace("_", " ").title())
761
+ ax.invert_yaxis()
762
+ ax.set_ylabel(var_y.replace("_", " ").title())
763
+ else:
764
+ for label, t, y, _ in segments:
765
+ ax.plot(t, y, linewidth=0.8, label=label)
766
+ ax.set_ylabel(var_y.replace("_", " ").title())
767
+ ax.legend(fontsize=9, loc="best", ncol=2)
768
+
769
+ locator = mdates.AutoDateLocator()
770
+ ax.xaxis.set_major_locator(locator)
771
+ ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
772
+ ax.set_xlabel("Time")
773
+
774
+ title_parts = [mooring, f"dt={dt_s}s"]
775
+ if scatter_mode:
776
+ title_parts.append(
777
+ f"colour: {var_color} [{P.COLORBAR_PLOW}–{P.COLORBAR_PHIGH}th pct]"
778
+ )
779
+ ax.set_title(" | ".join(title_parts))
780
+
781
+ plt.tight_layout()
782
+
783
+ if save_path:
784
+ fig.savefig(save_path, bbox_inches="tight")
785
+ if show:
786
+ plt.show()
787
+ if not show or save_path:
788
+ plt.close(fig)
789
+
790
+ return fig
791
+
792
+
793
+ def plot_grid(
794
+ grid_path: Path,
795
+ save_dir: Path = None,
796
+ show: bool = False,
797
+ ) -> list:
798
+ """Plot temperature and salinity from a mooring grid file as pcolormesh.
799
+
800
+ Salinity is computed from conductivity and temperature using GSW
801
+ (``gsw.SP_from_C``) if a ``salinity`` variable is not already present.
802
+
803
+ Parameters
804
+ ----------
805
+ grid_path : Path
806
+ Path to a ``*_grid.nc`` file produced by ``oceanarray grid``.
807
+ save_dir : Path, optional
808
+ Directory in which to save the figure. Defaults to the directory
809
+ containing *grid_path*.
810
+ show : bool
811
+ If True, call ``plt.show()`` interactively.
812
+
813
+ Returns
814
+ -------
815
+ list of Path
816
+ Paths of the saved PNG files (one per variable plotted).
817
+
818
+ """
819
+ from . import parameters as P
820
+
821
+ grid_path = Path(grid_path)
822
+ save_dir = Path(save_dir) if save_dir else grid_path.parent
823
+ stem = grid_path.stem # e.g. "dsG3_1_2026_grid"
824
+
825
+ plt.style.use(str(P.MPLSTYLE))
826
+
827
+ ds = xr.open_dataset(grid_path).load()
828
+ time = ds["time"].values
829
+ pressure = ds["pressure"].values
830
+
831
+ def _panel(fig, ax, da, title, units, cmap, style="pcolormesh"):
832
+ """Draw one T/S panel; style is 'pcolormesh' or 'contourf'."""
833
+ data = da.transpose("pressure", "time").values
834
+ vmin = float(np.nanpercentile(data, P.COLORBAR_PLOW))
835
+ vmax = float(np.nanpercentile(data, P.COLORBAR_PHIGH))
836
+ bounds = _nice_colorbar_bounds(vmin, vmax, n=20)
837
+ norm = mcolors.BoundaryNorm(bounds, ncolors=256)
838
+ if style == "contourf":
839
+ pc = ax.contourf(
840
+ time, pressure, data, levels=bounds, cmap=cmap, extend="both"
841
+ )
842
+ else:
843
+ pc = ax.pcolormesh(
844
+ time, pressure, data, shading="nearest", cmap=cmap, norm=norm
845
+ )
846
+ cb = fig.colorbar(pc, ax=ax, pad=0.02)
847
+ cb.set_label(f"{title} ({units})" if units else title)
848
+ ax.invert_yaxis()
849
+ ax.set_ylabel("Pressure (dbar)")
850
+ locator = mdates.AutoDateLocator()
851
+ ax.xaxis.set_major_locator(locator)
852
+ ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
853
+ ax.set_title(f"{title} [{style}]")
854
+
855
+ # ------------------------------------------------------------------ #
856
+ # Derive salinity DataArray if not stored #
857
+ # ------------------------------------------------------------------ #
858
+ sal_da = sal_label = sal_units = None
859
+ if "salinity" in ds:
860
+ sal_da = ds["salinity"]
861
+ sal_label = sal_da.attrs.get("long_name", "Salinity")
862
+ sal_units = sal_da.attrs.get("units", "1")
863
+ elif "conductivity" in ds and "temperature" in ds:
864
+ import gsw
865
+
866
+ T_tp = ds["temperature"].transpose("time", "pressure").values
867
+ C_tp = ds["conductivity"].transpose("time", "pressure").values
868
+ SP_vals = gsw.SP_from_C(C_tp, T_tp, pressure[np.newaxis, :])
869
+ sal_da = xr.DataArray(
870
+ SP_vals,
871
+ dims=("time", "pressure"),
872
+ coords={"time": ds["time"], "pressure": ds["pressure"]},
873
+ )
874
+ sal_label = "Practical Salinity"
875
+ sal_units = "1"
876
+
877
+ saved: list = []
878
+
879
+ # ------------------------------------------------------------------ #
880
+ # Temperature #
881
+ # ------------------------------------------------------------------ #
882
+ if "temperature" in ds:
883
+ T_units = ds["temperature"].attrs.get("units", "degC")
884
+ for style in ("pcolormesh", "contourf"):
885
+ fig, ax = plt.subplots(figsize=(12, 4))
886
+ _panel(
887
+ fig, ax, ds["temperature"], "Temperature", T_units, "RdYlBu_r", style
888
+ )
889
+ ax.set_xlabel("Time")
890
+ fig.suptitle(stem, fontsize=10, y=1.01)
891
+ plt.tight_layout()
892
+ out = save_dir / f"{stem}_temperature_{style}.png"
893
+ fig.savefig(out, bbox_inches="tight", dpi=150)
894
+ saved.append(out)
895
+ if show:
896
+ plt.show()
897
+ plt.close(fig)
898
+
899
+ # ------------------------------------------------------------------ #
900
+ # Salinity #
901
+ # ------------------------------------------------------------------ #
902
+ if sal_da is not None:
903
+ for style in ("pcolormesh", "contourf"):
904
+ fig, ax = plt.subplots(figsize=(12, 4))
905
+ _panel(fig, ax, sal_da, sal_label, sal_units, "YlGnBu_r", style)
906
+ ax.set_xlabel("Time")
907
+ fig.suptitle(stem, fontsize=10, y=1.01)
908
+ plt.tight_layout()
909
+ out = save_dir / f"{stem}_salinity_{style}.png"
910
+ fig.savefig(out, bbox_inches="tight", dpi=150)
911
+ saved.append(out)
912
+ if show:
913
+ plt.show()
914
+ plt.close(fig)
915
+
916
+ # ------------------------------------------------------------------ #
917
+ # Potential density (sigma0, sigma2, …) #
918
+ # ------------------------------------------------------------------ #
919
+ sigma_vars = [
920
+ v
921
+ for v in ds.data_vars
922
+ if v.startswith("sigma") and ds[v].dims == ("time", "pressure")
923
+ ]
924
+ for sv in sigma_vars:
925
+ da = ds[sv]
926
+ label = da.attrs.get("long_name", sv)
927
+ units = da.attrs.get("units", "kg m-3")
928
+ for style in ("pcolormesh", "contourf"):
929
+ fig, ax = plt.subplots(figsize=(12, 4))
930
+ _panel(fig, ax, da, label, units, P.DENSITY_COLORMAP, style)
931
+ ax.set_xlabel("Time")
932
+ fig.suptitle(stem, fontsize=10, y=1.01)
933
+ plt.tight_layout()
934
+ out = save_dir / f"{stem}_{sv}_{style}.png"
935
+ fig.savefig(out, bbox_inches="tight", dpi=150)
936
+ saved.append(out)
937
+ if show:
938
+ plt.show()
939
+ plt.close(fig)
940
+
941
+ ds.close()
942
+ return saved