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,1004 @@
1
+ """Utility functions, QC constants, base64 helpers, and NC metadata readers.
2
+
3
+ Nothing here imports matplotlib — these are pure Python / numpy / xarray helpers
4
+ used by both _plots.py and _mooring.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import io
11
+ import re
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Tuple
15
+
16
+ import numpy as np
17
+
18
+ from ..utilities import _nice_colorbar_bounds, _status # noqa: F401 (re-exported)
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # helpers
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ def _safe_serial(serial: Any) -> str:
27
+ return re.sub(r"[^\w\-]", "", str(serial))
28
+
29
+
30
+ def _get_proc_dir(base_dir: Path, mooring_name: str) -> Path:
31
+ proc = base_dir / "proc"
32
+ if not proc.is_dir():
33
+ legacy = base_dir / "moor" / "proc"
34
+ proc = legacy if legacy.is_dir() else proc
35
+ return proc / mooring_name
36
+
37
+
38
+ def _parse_dt(s: Optional[str]) -> Optional[datetime]:
39
+ """Parse clock timestamp: HH:MM:SS, YYYYMMDDTHH:MM:SS, or standard ISO."""
40
+ if not s:
41
+ return None
42
+ s = str(s).strip().replace("Z", "+00:00").replace(" UTC", "")
43
+ if re.match(r"^\d{2}:\d{2}:\d{2}", s) and "T" not in s:
44
+ s = f"2000-01-01T{s}"
45
+ m = re.match(r"^(\d{4})(\d{2})(\d{2})T(\d{2}:\d{2}:\d{2}.*)$", s)
46
+ if m:
47
+ s = f"{m.group(1)}-{m.group(2)}-{m.group(3)}T{m.group(4)}"
48
+ try:
49
+ return datetime.fromisoformat(s)
50
+ except ValueError:
51
+ return None
52
+
53
+
54
+ def _fmt_dt(dt: Optional[datetime], fmt: str = "%Y-%m-%d %H:%M UTC") -> str:
55
+ return dt.strftime(fmt) if dt else "—"
56
+
57
+
58
+ def _duration_str(start: Optional[datetime], end: Optional[datetime]) -> str:
59
+ if start is None or end is None:
60
+ return "—"
61
+ delta = end - start
62
+ days = delta.days
63
+ hours, _ = divmod(delta.seconds, 3600)
64
+ return f"{days}d {hours}h"
65
+
66
+
67
+ def _fmt_clock_str(s: Optional[str]) -> Optional[str]:
68
+ """Reformat an ISO-8601 timestamp to 'YYYY-MM-DD HH:MM' (minute precision).
69
+
70
+ Handles both compact ISO (``20260710T21:03:00``) and spaced ISO
71
+ (``2026-07-10T21:03:00``) inputs. Returns *None* if *s* is falsy.
72
+ """
73
+ if not s:
74
+ return None
75
+ try:
76
+ return datetime.fromisoformat(str(s).replace("T", " ").strip()).strftime(
77
+ "%Y-%m-%d %H:%M"
78
+ )
79
+ except ValueError:
80
+ return str(s)[:16]
81
+
82
+
83
+ def _resolve_clock(entry: Dict[str, Any]) -> Dict[str, Any]:
84
+ """Extract clock correction info from one YAML instrument entry."""
85
+ offset_s = float(entry.get("clock_offset", 0) or 0)
86
+ drift_s: Optional[float] = None
87
+ method = "none"
88
+
89
+ comp_str = entry.get("computer_clock_at_recovery")
90
+ inst_str = entry.get("instrument_clock_at_recovery")
91
+ if comp_str and inst_str:
92
+ comp = _parse_dt(str(comp_str))
93
+ inst = _parse_dt(str(inst_str))
94
+ if comp and inst:
95
+ drift_s = (inst - comp).total_seconds()
96
+ method = "Option B"
97
+ elif entry.get("clock_drift_seconds") not in (None, 0, ""):
98
+ drift_s = float(entry["clock_drift_seconds"])
99
+ method = "Option A"
100
+
101
+ return {
102
+ "offset_s": offset_s,
103
+ "drift_s": drift_s,
104
+ "method": method,
105
+ "computer_time": _fmt_clock_str(str(comp_str)) if comp_str else None,
106
+ "instrument_time": _fmt_clock_str(str(inst_str)) if inst_str else None,
107
+ "has_correction": offset_s != 0 or (drift_s is not None and drift_s != 0),
108
+ }
109
+
110
+
111
+ def _raw_file_path(
112
+ base_dir: Path,
113
+ raw_subdir: str,
114
+ instr_type: str,
115
+ mooring_name: str,
116
+ filename: str,
117
+ ) -> Path:
118
+ """Reconstruct the raw input file path exactly as stage1.py does."""
119
+ return base_dir / raw_subdir / instr_type / mooring_name / filename
120
+
121
+
122
+ def _check_readable(file_path: Path, file_type: str) -> Tuple[bool, str]:
123
+ """Quick format sanity check — reads a few bytes/lines, not the full file."""
124
+ if not file_path.exists():
125
+ return False, "file missing"
126
+ try:
127
+ size = file_path.stat().st_size
128
+ if size == 0:
129
+ return False, "empty file"
130
+
131
+ if file_type in ("sbe-cnv", "sbe-ascii"):
132
+ with open(file_path, "r", errors="ignore") as f:
133
+ head = f.read(800)
134
+ if any(
135
+ line.lstrip().startswith(("*", "#")) for line in head.splitlines()[:5]
136
+ ):
137
+ return True, "ok"
138
+ return False, "no SeaBird header markers"
139
+
140
+ elif file_type in ("nortek-ascii", "nortek-aqd"):
141
+ hdr = file_path.with_suffix(".hdr")
142
+ if not hdr.exists():
143
+ candidates = list(file_path.parent.glob(file_path.stem + "*.hdr"))
144
+ if not candidates:
145
+ return True, "ok (no .hdr found; may fail)"
146
+ return True, "ok"
147
+
148
+ elif file_type == "nortek-csv-oa":
149
+ with open(file_path, "r", errors="ignore") as f:
150
+ first = f.readline()
151
+ if ";" in first:
152
+ return True, "ok"
153
+ return False, "expected semicolon delimiter"
154
+
155
+ elif file_type in ("rbr-rsk", "rbr-dat"):
156
+ with open(file_path, "rb") as f:
157
+ magic = f.read(16)
158
+ if magic[:6] == b"SQLite":
159
+ return True, "ok"
160
+ return False, "not SQLite format"
161
+
162
+ else:
163
+ return True, "ok (format not deeply checked)"
164
+
165
+ except Exception as exc:
166
+ return False, str(exc)[:80]
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # QC colours (OceanSITES Reference Table 2)
171
+ # ---------------------------------------------------------------------------
172
+
173
+ _QC_COLORS: Dict[int, str] = {
174
+ 0: "#999999",
175
+ 1: "#27ae60",
176
+ 2: "#a8e6cf",
177
+ 3: "#f39c12",
178
+ 4: "#e74c3c",
179
+ 8: "#3498db",
180
+ 9: "#bdc3c7",
181
+ }
182
+ _QC_LABELS: Dict[int, str] = {
183
+ 0: "no QC",
184
+ 1: "good",
185
+ 2: "prob. good",
186
+ 3: "suspect",
187
+ 4: "bad",
188
+ 8: "interp.",
189
+ 9: "missing",
190
+ }
191
+
192
+ # Centralised marker style for QC overlay scatter points.
193
+ _QC_MARKER: Dict[int, dict] = {
194
+ 3: dict(
195
+ marker="+", c=_QC_COLORS[3], s=15, linewidths=0.8, zorder=3, rasterized=True
196
+ ),
197
+ 4: dict(
198
+ marker="+", c=_QC_COLORS[4], s=15, linewidths=0.8, zorder=4, rasterized=True
199
+ ),
200
+ 8: dict(
201
+ marker=".", c=_QC_COLORS[8], s=8, linewidths=0.5, zorder=2, rasterized=True
202
+ ),
203
+ }
204
+
205
+
206
+ def _fig_to_base64(fig: Any) -> str:
207
+ buf = io.BytesIO()
208
+ fig.savefig(buf, format="png", dpi=110, bbox_inches="tight")
209
+ buf.seek(0)
210
+ return base64.b64encode(buf.read()).decode("ascii")
211
+
212
+
213
+ def _load_pdf_b64(path: Path) -> Optional[str]:
214
+ """Return base64-encoded PDF bytes if *path* exists, else None."""
215
+ if path.is_file():
216
+ return base64.b64encode(path.read_bytes()).decode("ascii")
217
+ return None
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # NC metadata readers
222
+ # ---------------------------------------------------------------------------
223
+
224
+
225
+ def _parse_history(history_str: str) -> List[Dict[str, str]]:
226
+ """Split a semicolon-delimited NC history attribute into timestamped entries."""
227
+ if not history_str:
228
+ return []
229
+ entries = []
230
+ for part in history_str.split("; "):
231
+ part = part.strip()
232
+ if not part:
233
+ continue
234
+ if ": " in part:
235
+ ts, _, text = part.partition(": ")
236
+ entries.append({"timestamp": ts.strip(), "text": text.strip()})
237
+ else:
238
+ entries.append({"timestamp": "", "text": part})
239
+ return entries
240
+
241
+
242
+ def _fmt_minmax(v: float) -> str:
243
+ """Format a min/max value for display in the variables table."""
244
+ if v == 0.0:
245
+ return "0"
246
+ av = abs(v)
247
+ if av < 0.001 or av >= 100000:
248
+ return f"{v:.3g}"
249
+ return f"{v:.4g}"
250
+
251
+
252
+ def _read_nc_metadata(nc_path: Path) -> Dict[str, Any]:
253
+ """Return variable lists, scalar metadata, and global attrs from a NC file."""
254
+ try:
255
+ import xarray as xr
256
+
257
+ ds = xr.open_dataset(nc_path, decode_timedelta=False)
258
+ qc_vars = {v for v in ds.data_vars if v.endswith("_qc")}
259
+ time_vars, scalar_vars = [], []
260
+ analog_vars: List[str] = []
261
+
262
+ for vname in sorted(ds.data_vars):
263
+ v = ds[vname]
264
+ info: Dict[str, Any] = {
265
+ "name": vname,
266
+ "units": v.attrs.get("units", ""),
267
+ "long_name": v.attrs.get("long_name", ""),
268
+ "standard_name": v.attrs.get("standard_name", ""),
269
+ "v_min": None,
270
+ "v_max": None,
271
+ }
272
+ if v.dims == ():
273
+ info["dtype"] = str(v.dtype)
274
+ try:
275
+ raw = v.values.item()
276
+ info["value"] = str(raw)[:120]
277
+ except Exception:
278
+ info["value"] = "?"
279
+ scalar_vars.append(info)
280
+ else:
281
+ info["dims"] = ", ".join(str(d) for d in v.dims)
282
+ info["dtype"] = str(v.dtype)
283
+ info["n"] = int(np.prod(v.shape)) if v.shape else 0
284
+ arr = v.values
285
+ if arr.dtype.kind in ("f", "c"):
286
+ finite = arr[np.isfinite(arr)]
287
+ info["n_valid"] = int(finite.size)
288
+ if finite.size > 0:
289
+ info["v_min"] = _fmt_minmax(float(np.min(finite)))
290
+ info["v_max"] = _fmt_minmax(float(np.max(finite)))
291
+ if "analog" in vname.lower() and not np.all(finite == 0.0):
292
+ analog_vars.append(vname)
293
+ else:
294
+ info["n_valid"] = int(arr.size)
295
+ info["has_qc"] = f"{vname}_qc" in qc_vars
296
+ info["is_qc"] = vname in qc_vars
297
+ time_vars.append(info)
298
+
299
+ global_attrs = {k: str(val) for k, val in ds.attrs.items() if k != "history"}
300
+ dims = {str(d): int(s) for d, s in ds.sizes.items()}
301
+ ds.close()
302
+ return {
303
+ "time_vars": time_vars,
304
+ "scalar_vars": scalar_vars,
305
+ "global_attrs": global_attrs,
306
+ "analog_vars": analog_vars,
307
+ "dims": dims,
308
+ }
309
+ except Exception as exc:
310
+ return {
311
+ "error": str(exc)[:120],
312
+ "time_vars": [],
313
+ "scalar_vars": [],
314
+ "global_attrs": {},
315
+ "analog_vars": [],
316
+ }
317
+
318
+
319
+ def _read_qc_thresholds(nc_path: Path) -> List[Dict[str, Any]]:
320
+ """Return the QC thresholds actually applied, as stored in the stage3 NC file.
321
+
322
+ Reads four types of threshold from the ``{var}_qc`` variable attrs and from
323
+ dataset-level attrs:
324
+
325
+ * ``qc_gross_range_*`` — gross-range fail/suspect min/max
326
+ * ``qc_spike_*`` — QARTOD spike-test suspect/fail thresholds
327
+ * ``qc_flat_line_*`` — QARTOD flat-line (stuck-value) suspect/fail sample counts
328
+ * ``tilt_suspect_threshold`` / ``tilt_fail_threshold`` — Aquadopp tilt QC
329
+
330
+ Returns one dict per (variable, test) combination, with the actual values
331
+ stored in the file — regardless of whether they came from package defaults,
332
+ mooring-level YAML, or per-instrument overrides. Spike and flat-line rows only
333
+ appear for stage3 files produced after 2026-07-24.
334
+ """
335
+ try:
336
+ import xarray as xr
337
+
338
+ ds = xr.open_dataset(nc_path, decode_timedelta=False)
339
+ rows = []
340
+ for v in sorted(ds.data_vars):
341
+ if not v.endswith("_qc") or v.endswith("_orig_qc"):
342
+ continue
343
+ base = v[:-3]
344
+ if base not in ds.data_vars:
345
+ continue
346
+ attrs = ds[v].attrs
347
+ fail_min = attrs.get("qc_gross_range_fail_min")
348
+ fail_max = attrs.get("qc_gross_range_fail_max")
349
+ susp_min = attrs.get("qc_gross_range_suspect_min")
350
+ susp_max = attrs.get("qc_gross_range_suspect_max")
351
+ if fail_min is not None or susp_min is not None:
352
+ rows.append(
353
+ {
354
+ "var": base,
355
+ "test": "gross-range",
356
+ "suspect": (
357
+ f"[{susp_min}, {susp_max}]" if susp_min is not None else "—"
358
+ ),
359
+ "fail": (
360
+ f"[{fail_min}, {fail_max}]" if fail_min is not None else "—"
361
+ ),
362
+ }
363
+ )
364
+ sp_susp = attrs.get("qc_spike_suspect_threshold")
365
+ sp_fail = attrs.get("qc_spike_fail_threshold")
366
+ if sp_susp is not None or sp_fail is not None:
367
+ rows.append(
368
+ {
369
+ "var": base,
370
+ "test": "spike",
371
+ "suspect": f"|Δ| > {sp_susp}" if sp_susp is not None else "—",
372
+ "fail": f"|Δ| > {sp_fail}" if sp_fail is not None else "—",
373
+ }
374
+ )
375
+ fl_susp = attrs.get("qc_flat_line_suspect_count")
376
+ fl_fail = attrs.get("qc_flat_line_fail_count")
377
+ if fl_susp is not None or fl_fail is not None:
378
+ rows.append(
379
+ {
380
+ "var": base,
381
+ "test": "flat-line",
382
+ "suspect": (
383
+ f"stuck ≥ {fl_susp} samples" if fl_susp is not None else "—"
384
+ ),
385
+ "fail": (
386
+ f"stuck ≥ {fl_fail} samples" if fl_fail is not None else "—"
387
+ ),
388
+ }
389
+ )
390
+ # Tilt QC (Aquadopp): stored at dataset level
391
+ t_susp = ds.attrs.get("tilt_suspect_threshold")
392
+ t_fail = ds.attrs.get("tilt_fail_threshold")
393
+ if t_susp is not None or t_fail is not None:
394
+ rows.append(
395
+ {
396
+ "var": "roll (tilt)",
397
+ "test": "tilt",
398
+ "suspect": f">{t_susp}°" if t_susp is not None else "—",
399
+ "fail": f">{t_fail}°" if t_fail is not None else "—",
400
+ }
401
+ )
402
+ ds.close()
403
+ return rows
404
+ except Exception:
405
+ return []
406
+
407
+
408
+ def _read_qc_summary(nc_path: Path) -> List[Dict[str, Any]]:
409
+ """Return per-variable QC flag breakdown from a stage3 NC file."""
410
+ try:
411
+ import xarray as xr
412
+
413
+ ds = xr.open_dataset(nc_path, decode_timedelta=False)
414
+ rows = []
415
+ for v in sorted(ds.data_vars):
416
+ if not v.endswith("_qc") or v.endswith("_orig_qc"):
417
+ continue
418
+ base = v[:-3]
419
+ if base not in ds.data_vars:
420
+ continue
421
+ flags = ds[v].values.astype(int).ravel()
422
+ total = len(flags)
423
+ if total == 0:
424
+ continue
425
+ flag_rows = []
426
+ for fval in (1, 2, 3, 4, 8, 9):
427
+ n = int(np.sum(flags == fval))
428
+ flag_rows.append(
429
+ {
430
+ "flag": fval,
431
+ "label": _QC_LABELS.get(fval, str(fval)),
432
+ "color": _QC_COLORS.get(fval, "#999"),
433
+ "n": n,
434
+ "pct": round(100.0 * n / total, 1),
435
+ }
436
+ )
437
+ rows.append({"var": base, "total": total, "flags": flag_rows})
438
+ ds.close()
439
+ return rows
440
+ except Exception:
441
+ return []
442
+
443
+
444
+ # Fixed 5 primary variables always shown in the instrument summary table
445
+ _PRIMARY_VARS = [
446
+ ("temperature", "T"),
447
+ ("conductivity", "C"),
448
+ ("pressure", "P"),
449
+ ("east_velocity", "U"),
450
+ ("north_velocity", "V"),
451
+ ]
452
+
453
+
454
+ def _read_instrument_info(
455
+ proc_dir: Path, instr_type: str, mooring: str, serial: str
456
+ ) -> Dict[str, Any]:
457
+ """Read record statistics from the best available processed NetCDF file.
458
+
459
+ Prefers stage-3 output (QC and coordinate-transformed); falls back to
460
+ stage-2 (deployment-trimmed, clock-corrected) if stage-3 is absent.
461
+ Stage-1 (raw) files are never read here.
462
+
463
+ Returns a dict with keys:
464
+
465
+ * ``t_start`` / ``t_end``: first and last timestamp (ISO-8601 string,
466
+ truncated to the minute).
467
+ * ``t_end_raw``: last timestamp as ``numpy.datetime64`` (for arithmetic).
468
+ * ``n_records``: total number of time steps.
469
+ * ``dt_s``: 90th-percentile inter-sample interval in seconds (robust
470
+ against occasional data gaps).
471
+ * ``shorthands``: list of ``(label, bool)`` pairs indicating which of
472
+ T / C / P / U / V variables are present.
473
+ * ``pressure_interpolated``: True if any ``pressure_qc == 8``, meaning
474
+ pressure was filled by interpolation from neighbouring instruments.
475
+ * ``p_min`` / ``p_max``: minimum and maximum pressure (dbar) over the
476
+ trimmed record, excluding non-positive values (stuck-at-zero sensors,
477
+ pre-deployment bench readings) and QC-bad/missing samples where stage-3
478
+ flags are available. ``None`` if no valid pressure data exist.
479
+
480
+ Returns ``{}`` if no processed file is found, or ``{"error": str}`` if
481
+ the file cannot be opened.
482
+ """
483
+ base = proc_dir / instr_type / f"{mooring}_{serial}"
484
+ nc_path = None
485
+ for suffix in ("_stage3.nc", "_stage2.nc"):
486
+ p = Path(str(base) + suffix)
487
+ if p.exists():
488
+ nc_path = p
489
+ break
490
+ if nc_path is None:
491
+ return {}
492
+
493
+ try:
494
+ import xarray as xr
495
+
496
+ ds = xr.open_dataset(nc_path, decode_timedelta=False)
497
+ time = ds["time"].values
498
+ n = len(time)
499
+
500
+ t_start = str(time[0])[:16].replace("T", " ") if n > 0 else "—"
501
+ t_end = str(time[-1])[:16].replace("T", " ") if n > 0 else "—"
502
+ t_end_raw = time[-1] if n > 0 else None
503
+
504
+ if n > 1:
505
+ dt_arr = np.diff(time.astype("datetime64[s]").astype(float))
506
+ dt_s = float(np.percentile(dt_arr, 90))
507
+ else:
508
+ dt_s = float("nan")
509
+
510
+ vars_present = {v for v in ds.data_vars if ds[v].dims != ()}
511
+ has_beam = any(f"velocity_beam{i}" in vars_present for i in (1, 2, 3))
512
+
513
+ # Check whether pressure was interpolated (any QC flag == 8) so the P pill
514
+ # in the instrument summary can be coloured blue (interpolated) vs green (measured).
515
+ pressure_interpolated = False
516
+ if "pressure_qc" in ds.data_vars:
517
+ try:
518
+ pressure_interpolated = bool(
519
+ np.any(ds["pressure_qc"].values.astype(int) == 8)
520
+ )
521
+ except Exception: # noqa: BLE001
522
+ pass
523
+
524
+ # Min/max pressure over the trimmed record (stage2/stage3).
525
+ # Exclude non-positive values (negative or stuck-at-zero sensors are
526
+ # non-physical for a deployed mooring) and QC-bad/missing flag values.
527
+ p_min: Optional[float] = None
528
+ p_max: Optional[float] = None
529
+ if "pressure" in ds.data_vars:
530
+ try:
531
+ pvals = ds["pressure"].values.astype(float)
532
+ # Mask QC=4 (bad) and QC=9 (missing) if stage3 QC is available
533
+ if "pressure_qc" in ds.data_vars:
534
+ qc = ds["pressure_qc"].values.astype(int)
535
+ pvals = np.where(np.isin(qc, [4, 9]), np.nan, pvals)
536
+ # Exclude non-positive values (pre-deployment bench / stuck sensor)
537
+ pvals = np.where(pvals > 0, pvals, np.nan)
538
+ _pmin = float(np.nanmin(pvals))
539
+ _pmax = float(np.nanmax(pvals))
540
+ if np.isfinite(_pmin):
541
+ p_min = _pmin
542
+ if np.isfinite(_pmax):
543
+ p_max = _pmax
544
+ except Exception: # noqa: BLE001
545
+ pass
546
+
547
+ ds.close()
548
+
549
+ shorthands = [(label, name in vars_present) for name, label in _PRIMARY_VARS]
550
+
551
+ return {
552
+ "t_start": t_start,
553
+ "t_end": t_end,
554
+ "t_end_raw": t_end_raw,
555
+ "n_records": n,
556
+ "dt_s": dt_s,
557
+ "shorthands": shorthands,
558
+ "has_beam": has_beam,
559
+ "pressure_interpolated": pressure_interpolated,
560
+ "p_min": p_min,
561
+ "p_max": p_max,
562
+ }
563
+ except Exception as exc:
564
+ return {"error": str(exc)[:80]}
565
+
566
+
567
+ def _read_timing_info(
568
+ proc_dir: Path, instr_type: str, mooring: str, serial: str
569
+ ) -> Dict[str, Any]:
570
+ """Read deployment-timing attrs from the stage 2 NC file.
571
+
572
+ Always reads from stage 2 (not stage 3) because timing metadata is written
573
+ during stage 2 processing. Returns an empty dict if no stage 2 file exists.
574
+
575
+ The dict keys are:
576
+
577
+ stage1_start / stage1_end
578
+ First and last timestamps in the stage 1 record (raw instrument clock).
579
+ stage1_nonnull_start / stage1_nonnull_end
580
+ First/last non-NaN timestamps in the priority variable (raw clock).
581
+ sugg_start / sugg_end
582
+ Auto-detected deployment window in the raw instrument clock.
583
+ sugg_start_utc / sugg_end_utc
584
+ Clock-corrected suggested times (safe to paste into the YAML).
585
+ stage2_start / stage2_end
586
+ Actual first/last record in the stage 2 file (corrected, trimmed).
587
+ sugg_start_differs / sugg_end_differs
588
+ True when the suggested time differs from the YAML time by more than
589
+ 2 × Δt (two sample intervals) — amber-highlight trigger in the timing
590
+ table.
591
+ dt_s
592
+ Median sample interval in seconds (p90 of first-differences).
593
+ """
594
+ nc_path = proc_dir / instr_type / f"{mooring}_{serial}_stage2.nc"
595
+ if not nc_path.exists():
596
+ return {}
597
+ try:
598
+ import xarray as xr
599
+
600
+ ds = xr.open_dataset(nc_path, decode_timedelta=False)
601
+ time = ds["time"].values
602
+ n = len(time)
603
+
604
+ stage2_start = str(time[0])[:16].replace("T", " ") if n > 0 else None
605
+ stage2_end = str(time[-1])[:16].replace("T", " ") if n > 0 else None
606
+
607
+ if n > 1:
608
+ dt_arr = np.diff(time.astype("datetime64[s]").astype(float))
609
+ dt_s = float(np.percentile(dt_arr, 90))
610
+ else:
611
+ dt_s = float("nan")
612
+
613
+ stage1_start = ds.attrs.get("stage1_time_start")
614
+ stage1_end = ds.attrs.get("stage1_time_end")
615
+ stage1_nonnull_start = ds.attrs.get("stage1_first_nonnull")
616
+ stage1_nonnull_end = ds.attrs.get("stage1_last_nonnull")
617
+ sugg_start = ds.attrs.get("suggested_deployment_time")
618
+ sugg_end = ds.attrs.get("suggested_recovery_time")
619
+ sugg_start_utc = ds.attrs.get("suggested_deployment_time_utc")
620
+ sugg_end_utc = ds.attrs.get("suggested_recovery_time_utc")
621
+ yaml_recovery = ds.attrs.get("yaml_recovery_time")
622
+ ds.close()
623
+
624
+ # Amber: sugg UTC differs from YAML/stage2 bound by > 2 sample intervals
625
+ sugg_start_differs = False
626
+ sugg_end_differs = False
627
+ if sugg_start_utc and stage2_start and np.isfinite(dt_s) and dt_s > 0:
628
+ try:
629
+ diff_s = abs(
630
+ (
631
+ datetime.fromisoformat(sugg_start_utc)
632
+ - datetime.fromisoformat(stage2_start)
633
+ ).total_seconds()
634
+ )
635
+ sugg_start_differs = diff_s > 2 * dt_s
636
+ except Exception: # noqa: BLE001
637
+ pass
638
+ if sugg_end_utc and stage2_end and np.isfinite(dt_s) and dt_s > 0:
639
+ try:
640
+ diff_s = abs(
641
+ (
642
+ datetime.fromisoformat(sugg_end_utc)
643
+ - datetime.fromisoformat(stage2_end)
644
+ ).total_seconds()
645
+ )
646
+ sugg_end_differs = diff_s > 2 * dt_s
647
+ except Exception: # noqa: BLE001
648
+ pass
649
+
650
+ # Orange-amber: stage1 last record ends more than 2 sample intervals before
651
+ # the YAML recovery time — indicates the instrument stopped recording early.
652
+ stage1_end_early = False
653
+ if stage1_end and yaml_recovery and np.isfinite(dt_s) and dt_s > 0:
654
+ try:
655
+ diff_s = (
656
+ datetime.fromisoformat(yaml_recovery[:16])
657
+ - datetime.fromisoformat(stage1_end[:16])
658
+ ).total_seconds()
659
+ stage1_end_early = diff_s > 2 * dt_s
660
+ except Exception: # noqa: BLE001
661
+ pass
662
+
663
+ def _fmt(s: Optional[str]) -> Optional[str]:
664
+ if not s:
665
+ return None
666
+ return str(s)[:16].replace("T", " ")
667
+
668
+ return {
669
+ "stage1_start": _fmt(stage1_start),
670
+ "stage1_end": _fmt(stage1_end),
671
+ "stage1_nonnull_start": _fmt(stage1_nonnull_start),
672
+ "stage1_nonnull_end": _fmt(stage1_nonnull_end),
673
+ "sugg_start": _fmt(sugg_start),
674
+ "sugg_end": _fmt(sugg_end),
675
+ "sugg_start_utc": _fmt(sugg_start_utc),
676
+ "sugg_end_utc": _fmt(sugg_end_utc),
677
+ "stage2_start": stage2_start,
678
+ "stage2_end": stage2_end,
679
+ "sugg_start_differs": sugg_start_differs,
680
+ "sugg_end_differs": sugg_end_differs,
681
+ "stage1_end_early": stage1_end_early,
682
+ "dt_s": dt_s,
683
+ }
684
+ except Exception: # noqa: BLE001
685
+ return {}
686
+
687
+
688
+ def _read_sensor_info(
689
+ proc_dir: Path, instr_type: str, mooring: str, serial: str
690
+ ) -> List[Dict[str, Any]]:
691
+ """Return one dict per SENSOR_* variable found in the stage2 NC file."""
692
+ nc_path = proc_dir / instr_type / f"{mooring}_{serial}_stage2.nc"
693
+ if not nc_path.exists():
694
+ return []
695
+ try:
696
+ import xarray as xr
697
+
698
+ ds = xr.open_dataset(nc_path, decode_timedelta=False)
699
+ rows = []
700
+ for vname in sorted(v for v in ds.data_vars if v.startswith("SENSOR_")):
701
+ attrs = dict(ds[vname].attrs)
702
+ stype = attrs.get("sensor_type", "")
703
+ coeff_key = f"{stype}_calibration_coefficients"
704
+ rows.append(
705
+ {
706
+ "var_name": vname,
707
+ "sensor_type": stype,
708
+ "sensor_model": attrs.get("sensor_model", "—"),
709
+ "sensor_serial": attrs.get("sensor_serial_number", "—"),
710
+ "cal_date": attrs.get("sensor_calibration_date", "—"),
711
+ "coefficients": attrs.get(coeff_key, ""),
712
+ }
713
+ )
714
+ ds.close()
715
+ return rows
716
+ except Exception:
717
+ return []
718
+
719
+
720
+ def _stage_files(
721
+ proc_dir: Path, instr_type: str, mooring: str, serial: str
722
+ ) -> Dict[str, bool]:
723
+ base = proc_dir / instr_type / f"{mooring}_{serial}"
724
+ return {
725
+ "stage1": Path(str(base) + "_stage1.nc").exists(),
726
+ "stage2": Path(str(base) + "_stage2.nc").exists(),
727
+ "stage3": Path(str(base) + "_stage3.nc").exists(),
728
+ }
729
+
730
+
731
+ def _fmt_size(n_bytes: int) -> str:
732
+ """Return a human-readable file size string."""
733
+ for unit in ("B", "KB", "MB", "GB"):
734
+ if n_bytes < 1024:
735
+ return f"{n_bytes:.0f} {unit}"
736
+ n_bytes /= 1024 # type: ignore[assignment]
737
+ return f"{n_bytes:.1f} GB"
738
+
739
+
740
+ def _file_info(path: Path) -> Dict[str, str]:
741
+ """Return size and mtime for a file path, or empty strings if it doesn't exist."""
742
+ if not path.exists():
743
+ return {"exists": False, "name": path.name, "size": "", "mtime": ""}
744
+ import datetime as _dt
745
+
746
+ st = path.stat()
747
+ mtime = _dt.datetime.fromtimestamp(st.st_mtime, tz=_dt.timezone.utc)
748
+ return {
749
+ "exists": True,
750
+ "name": path.name,
751
+ "size": _fmt_size(st.st_size),
752
+ "mtime": mtime.strftime("%Y-%m-%d %H:%M UTC"),
753
+ }
754
+
755
+
756
+ def _stage_file_details(
757
+ proc_dir: Path, instr_type: str, mooring: str, serial: str
758
+ ) -> list:
759
+ """Return per-stage file info dicts for template rendering."""
760
+ base = proc_dir / instr_type / f"{mooring}_{serial}"
761
+ rows = []
762
+ for stage in ("stage1", "stage2", "stage3"):
763
+ p = Path(str(base) + f"_{stage}.nc")
764
+ info = _file_info(p)
765
+ info["label"] = stage.capitalize()
766
+ rows.append(info)
767
+ return rows
768
+
769
+
770
+ def _instrument_report_href(
771
+ mooring_name: str,
772
+ serial: str,
773
+ in_instrument_subdir: bool = False,
774
+ ) -> str:
775
+ """Return the relative href for a per-instrument HTML report.
776
+
777
+ Instrument reports live at ``report/instrument/{mooring}_{serial}_report.html``.
778
+ The prefix differs depending on which report is linking to them:
779
+
780
+ - Summary / Stack / Grid (in ``report/``): prefix is ``instrument/``
781
+ - Another instrument report (in ``report/instrument/``): no prefix needed
782
+
783
+ Parameters
784
+ ----------
785
+ mooring_name : str
786
+ Mooring identifier used in the report filename.
787
+ serial : str
788
+ Instrument serial number.
789
+ in_instrument_subdir : bool
790
+ ``True`` when linking from a per-instrument report.
791
+ ``False`` (default) when linking from summary, stack, or grid reports.
792
+
793
+ Returns
794
+ -------
795
+ str
796
+ Relative href string, e.g. ``"instrument/dsG3_1_2026_26261_report.html"``.
797
+
798
+ """
799
+ import html as _html
800
+
801
+ prefix = "" if in_instrument_subdir else "instrument/"
802
+ return (
803
+ f"{prefix}{_html.escape(mooring_name)}_{_html.escape(str(serial))}_report.html"
804
+ )
805
+
806
+
807
+ def _instrument_report_exists(out_dir: Path, mooring_name: str, serial: str) -> bool:
808
+ """Return True if the per-instrument report file already exists on disk.
809
+
810
+ Parameters
811
+ ----------
812
+ out_dir : Path
813
+ The report output directory (i.e. ``proc_dir / "report"``).
814
+ mooring_name : str
815
+ Mooring identifier used in the report filename.
816
+ serial : str
817
+ Instrument serial number.
818
+
819
+ """
820
+ return (out_dir / "instrument" / f"{mooring_name}_{serial}_report.html").exists()
821
+
822
+
823
+ def _find_array_report_href(
824
+ out_dir: Path, in_instrument_subdir: bool = False
825
+ ) -> Optional[str]:
826
+ """Return a relative href to the array report if one exists in the top-level proc dir.
827
+
828
+ Parameters
829
+ ----------
830
+ out_dir : Path
831
+ Directory where the current report will be written. Expected to be
832
+ ``{proc_dir}/{mooring}/report/`` (default) or ``{proc_dir}/{mooring}/``.
833
+ in_instrument_subdir : bool
834
+ True when the caller is a per-instrument report one level deeper.
835
+
836
+ Returns
837
+ -------
838
+ str or None
839
+ Relative href string (e.g. ``"../../dsG3_2026_array_report.html"``),
840
+ or None if no ``*_array_report.html`` exists.
841
+
842
+ """
843
+ if out_dir is None:
844
+ return None
845
+ # Navigate up to the top-level proc dir (above the mooring dir)
846
+ candidate = out_dir
847
+ # go up past report/ (if present) and mooring dir
848
+ for _ in range(3 if in_instrument_subdir else 2):
849
+ candidate = candidate.parent
850
+ matches = sorted(
851
+ p for p in candidate.glob("*_array_report.html") if not p.name.startswith(".")
852
+ )
853
+ if not matches:
854
+ return None
855
+ depth = 3 if in_instrument_subdir else 2
856
+ prefix = "../" * depth
857
+ return f"{prefix}{matches[0].name}"
858
+
859
+
860
+ def _nav_buttons_html(
861
+ mooring_name: str,
862
+ instruments: List[Dict[str, Any]],
863
+ stack_exists: bool = True,
864
+ grid_exists: bool = True,
865
+ current_report: str = "",
866
+ in_instrument_subdir: bool = False,
867
+ array_report_href: Optional[str] = None,
868
+ ) -> str:
869
+ """Return an HTML navigation snippet for injection into report masthead cards.
870
+
871
+ Parameters
872
+ ----------
873
+ mooring_name : str
874
+ Mooring identifier used to build sibling report filenames.
875
+ instruments : list of dict
876
+ Each dict must have ``serial`` and ``instr_type`` keys (the format
877
+ produced by ``MooringReport._build_context()``).
878
+ stack_exists : bool
879
+ Whether the stack report is available.
880
+ grid_exists : bool
881
+ Whether the grid report is available.
882
+ current_report : str
883
+ Which report this snippet is rendered in — ``"summary"``, ``"stack"``,
884
+ ``"grid"``, or a serial number string for per-instrument reports.
885
+ The matching pill is shown as a non-clickable chip (greyed out).
886
+ in_instrument_subdir : bool
887
+ Set to ``True`` when this snippet is rendered inside a per-instrument
888
+ report that lives one level deeper than the main report directory
889
+ (i.e. in ``report/instrument/``). Summary/Stack/Grid links will then
890
+ be prefixed with ``../`` and instrument-to-instrument links will remain
891
+ flat. When ``False`` (default, used by Summary/Stack/Grid reports),
892
+ instrument links are prefixed with ``instrument/``.
893
+ array_report_href : str or None
894
+ Relative href to the array-level report (e.g.
895
+ ``"../../dsG3_2026_array_report.html"``). When provided an "Array"
896
+ pill is prepended to Row 1. Pass the result of
897
+ ``_find_array_report_href(out_dir)`` at each call site.
898
+
899
+ Returns
900
+ -------
901
+ str
902
+ HTML fragment safe for ``{{ nav_buttons | safe }}`` in Jinja2 templates.
903
+
904
+ """
905
+ import html as _html
906
+
907
+ mn = _html.escape(mooring_name)
908
+ _pfx = "../" if in_instrument_subdir else ""
909
+ _ipfx = "" if in_instrument_subdir else "instrument/"
910
+
911
+ _BTN = (
912
+ "display:inline-block;padding:0.2em 0.65em;border-radius:4px;"
913
+ "font-size:0.78rem;font-weight:700;text-decoration:none;"
914
+ "color:#fff;margin:0 0.2rem 0.25rem 0;white-space:nowrap;"
915
+ )
916
+ _CHIP = (
917
+ "display:inline-block;padding:0.2em 0.65em;border-radius:4px;"
918
+ "font-size:0.78rem;font-weight:700;color:#fff;"
919
+ "margin:0 0.2rem 0.25rem 0;white-space:nowrap;opacity:0.45;"
920
+ )
921
+ _LABEL = (
922
+ "font-size:0.72rem;opacity:0.75;margin-right:0.3rem;"
923
+ "white-space:nowrap;vertical-align:middle;"
924
+ )
925
+
926
+ def _pill(
927
+ label: str, href: str, bg: str, is_current: bool, is_missing: bool = False
928
+ ) -> str:
929
+ esc = _html.escape(label)
930
+ if is_current:
931
+ return f'<span style="{_CHIP}background:{bg}" title="current">{esc}</span>'
932
+ if is_missing:
933
+ return (
934
+ f'<span style="{_CHIP}background:#999" '
935
+ f'title="report not yet generated">{esc}</span>'
936
+ )
937
+ return f'<a href="{href}" style="{_BTN}background:{bg}">{esc}</a>'
938
+
939
+ parts = ['<div style="margin-top:0.65rem">']
940
+
941
+ # Row 1: report-level navigation (Array / Summary / Stack / Grid)
942
+ parts.append('<div style="margin-bottom:0.25rem">')
943
+ parts.append(f'<span style="{_LABEL}">Reports:</span>')
944
+ if array_report_href:
945
+ parts.append(
946
+ f'<a href="{_html.escape(array_report_href)}" '
947
+ f'style="{_BTN}background:#5d6d7e">Array</a>'
948
+ )
949
+ parts.append(
950
+ _pill(
951
+ "Summary", f"{_pfx}{mn}_report.html", "#1a3a5c", current_report == "summary"
952
+ )
953
+ )
954
+ parts.append(
955
+ _pill(
956
+ "Stack",
957
+ f"{_pfx}{mn}_stack_report.html",
958
+ "#2980b9",
959
+ current_report == "stack",
960
+ is_missing=not stack_exists,
961
+ )
962
+ )
963
+ parts.append(
964
+ _pill(
965
+ "Grid",
966
+ f"{_pfx}{mn}_grid_report.html",
967
+ "#8e44ad",
968
+ current_report == "grid",
969
+ is_missing=not grid_exists,
970
+ )
971
+ )
972
+ parts.append("</div>")
973
+
974
+ # Row 2: per-instrument buttons grouped by instrument type.
975
+ # Grey out instruments that have no processed stage files (no report generated).
976
+ by_type: Dict[str, List[Dict]] = {}
977
+ for instr in instruments:
978
+ itype = str(instr.get("instr_type", instr.get("instrument", "other"))).lower()
979
+ if itype not in by_type:
980
+ by_type[itype] = []
981
+ by_type[itype].append(instr)
982
+
983
+ for itype, instrs_of_type in by_type.items():
984
+ parts.append('<div style="margin-bottom:0.1rem">')
985
+ parts.append(
986
+ f'<span style="{_LABEL}">{_html.escape(itype.capitalize())}:</span>'
987
+ )
988
+ for instr in instrs_of_type:
989
+ ser = str(instr.get("serial", ""))
990
+ stages = instr.get("stages", {})
991
+ has_report = any(stages.values()) if stages else True
992
+ parts.append(
993
+ _pill(
994
+ ser,
995
+ f"{_ipfx}{mn}_{_html.escape(ser)}_report.html",
996
+ "#27ae60",
997
+ current_report == ser,
998
+ is_missing=not has_report,
999
+ )
1000
+ )
1001
+ parts.append("</div>")
1002
+
1003
+ parts.append("</div>")
1004
+ return "".join(parts)