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