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
oceanarray/stage2.py
ADDED
|
@@ -0,0 +1,1160 @@
|
|
|
1
|
+
"""Stage 2 processing for mooring data: apply clock corrections and trim to deployment.
|
|
2
|
+
|
|
3
|
+
Processing order per instrument
|
|
4
|
+
--------------------------------
|
|
5
|
+
1. Load Stage 1 ``_stage1.nc`` file.
|
|
6
|
+
2. Resolve clock offset and drift from YAML.
|
|
7
|
+
3. If either is non-zero, save ``time_orig`` (original instrument time) before correcting.
|
|
8
|
+
4. Apply a linear correction that ramps from ``clock_offset`` at deployment to
|
|
9
|
+
``clock_drift_seconds`` at recovery. Both default to 0, so:
|
|
10
|
+
- Only ``clock_offset`` set: uniform constant shift (same correction throughout).
|
|
11
|
+
- Only ``clock_drift_seconds`` set: ramps from 0 at deployment to drift at recovery.
|
|
12
|
+
- Both set: ramps from ``clock_offset`` at deployment to ``clock_drift_seconds`` at recovery.
|
|
13
|
+
5. Trim record to ``deployment_time`` … ``recovery_time`` from the mooring YAML.
|
|
14
|
+
6. Write ``_stage2.nc``. ``time_orig`` is only present when a correction was applied.
|
|
15
|
+
|
|
16
|
+
Clock correction YAML keys (per instrument)
|
|
17
|
+
--------------------------------------------
|
|
18
|
+
``clock_offset`` : float, seconds
|
|
19
|
+
Total correction to apply at the start of the deployment (instrument clock error
|
|
20
|
+
at deployment time). Positive = instrument was slow (behind UTC).
|
|
21
|
+
|
|
22
|
+
``clock_drift_seconds`` : float, seconds [Option A]
|
|
23
|
+
Total correction to apply at the end of the deployment (instrument clock error
|
|
24
|
+
at recovery time). Positive = instrument was slow (behind UTC) at recovery.
|
|
25
|
+
The correction ramps linearly from ``clock_offset`` at deployment to this value at recovery.
|
|
26
|
+
|
|
27
|
+
``computer_clock_at_recovery`` / ``instrument_clock_at_recovery`` : ISO-8601 str [Option B]
|
|
28
|
+
Two timestamps read off at recovery. drift = computer − instrument = total correction
|
|
29
|
+
at recovery. Equivalent to setting ``clock_drift_seconds``.
|
|
30
|
+
Option B takes priority over Option A if both are present.
|
|
31
|
+
|
|
32
|
+
Sign convention
|
|
33
|
+
---------------
|
|
34
|
+
All clock values are the amounts **added** to instrument time to obtain corrected time.
|
|
35
|
+
|
|
36
|
+
- Positive value → instrument was *slow* (behind real time); times shifted later.
|
|
37
|
+
- Negative value → instrument was *fast* (ahead of real time); times shifted earlier.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
import re
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any, Dict, List, Optional
|
|
43
|
+
|
|
44
|
+
import numpy as np
|
|
45
|
+
import pandas as pd
|
|
46
|
+
import xarray as xr
|
|
47
|
+
import yaml
|
|
48
|
+
from seasenselib.writers import NetCdfWriter
|
|
49
|
+
|
|
50
|
+
from .utilities import (
|
|
51
|
+
_status,
|
|
52
|
+
cast_output_dtypes,
|
|
53
|
+
drop_all_zero_vars,
|
|
54
|
+
extract_inline_instruments,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _parse_clock_str(s: str) -> Optional[pd.Timestamp]:
|
|
59
|
+
"""Parse a clock timestamp in multiple formats; return None if unparseable.
|
|
60
|
+
|
|
61
|
+
Accepts:
|
|
62
|
+
- ``HH:MM:SS`` (time only — date is arbitrary; only differences matter)
|
|
63
|
+
- ``YYYYMMDDTHH:MM:SS`` (compact ISO, no dashes in date)
|
|
64
|
+
- ``YYYY-MM-DDTHH:MM:SS`` (standard ISO)
|
|
65
|
+
- ``unknown`` or any non-parseable string → None (no correction applied)
|
|
66
|
+
"""
|
|
67
|
+
s = s.strip()
|
|
68
|
+
if re.match(r"^\d{2}:\d{2}:\d{2}", s) and "T" not in s:
|
|
69
|
+
# Time-only: anchor to an arbitrary date (only the difference is used)
|
|
70
|
+
s = f"2000-01-01T{s}"
|
|
71
|
+
elif re.match(r"^\d{8}T", s):
|
|
72
|
+
# Compact YYYYMMDDTHH:MM:SS — s[8] is 'T', s[9:] is the time part
|
|
73
|
+
s = f"{s[:4]}-{s[4:6]}-{s[6:8]}T{s[9:]}"
|
|
74
|
+
try:
|
|
75
|
+
return pd.Timestamp(s)
|
|
76
|
+
except Exception: # noqa: BLE001 — returns None for any unparseable timestamp string
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _append_history(dataset: xr.Dataset, note: str) -> None:
|
|
81
|
+
"""Append a timestamped note to dataset.attrs['history'] in place."""
|
|
82
|
+
import datetime
|
|
83
|
+
|
|
84
|
+
stamp = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%MZ")
|
|
85
|
+
entry = f"{stamp}: {note}"
|
|
86
|
+
existing = dataset.attrs.get("history", "")
|
|
87
|
+
dataset.attrs["history"] = f"{existing}; {entry}" if existing else entry
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def detect_deployment_window(
|
|
91
|
+
ds: xr.Dataset,
|
|
92
|
+
) -> tuple[Optional[np.datetime64], Optional[np.datetime64], str]:
|
|
93
|
+
"""Estimate the deployed in-water window from a stage1 pressure record.
|
|
94
|
+
|
|
95
|
+
.. important:: **Returns ``(None, None, ...)`` when no pressure data are
|
|
96
|
+
available.** The caller must check for ``None`` before using the result.
|
|
97
|
+
Suggested times are **not** written to the output file when pressure is
|
|
98
|
+
absent — instruments without pressure receive no ``suggested_*`` attrs.
|
|
99
|
+
|
|
100
|
+
.. important:: **All non-None timestamps are in the raw instrument clock.**
|
|
101
|
+
|
|
102
|
+
The input *ds* is a stage 1 dataset whose ``"time"`` coordinate carries
|
|
103
|
+
the uncorrected instrument clock. Add the YAML ``clock_offset`` to
|
|
104
|
+
convert to UTC for copy-pasting into the YAML.
|
|
105
|
+
|
|
106
|
+
**Pressure-based algorithm** (middle-50 % pmin + 10 dbar):
|
|
107
|
+
|
|
108
|
+
1. **Middle-50 % reference window**: skip the first and last 25 % of the
|
|
109
|
+
record by time. This excludes any bench / surface period at either end
|
|
110
|
+
regardless of its length, while avoiding a ``pmax``-based threshold that
|
|
111
|
+
would be biased by knockdown events (which push instruments *deeper* than
|
|
112
|
+
the nominal deployment depth).
|
|
113
|
+
2. ``pmin_deployed`` = 1st-percentile of pressure within that middle window
|
|
114
|
+
(robust to brief sensor-zero artefacts that would drag the absolute
|
|
115
|
+
minimum to ~0 dbar and make the threshold negative).
|
|
116
|
+
3. ``threshold`` = ``pmin_deployed − 10`` dbar.
|
|
117
|
+
4. **Opening search window**: the first 25 % of the record by time (mirrors
|
|
118
|
+
the middle window, long enough to cover multi-day bench periods).
|
|
119
|
+
5. **Closing search window**: the last 25 % of the record by time.
|
|
120
|
+
6. **Start**: last sample at or below the threshold in the opening window
|
|
121
|
+
→ the *next* sample is the suggested deployment start.
|
|
122
|
+
7. **End**: first sample at or below the threshold in the closing window
|
|
123
|
+
→ the *preceding* sample is the suggested recovery end.
|
|
124
|
+
|
|
125
|
+
This approach avoids two known failure modes of the original "skip
|
|
126
|
+
first/last 12 h" middle window:
|
|
127
|
+
|
|
128
|
+
* **Bench data in the middle window**: if an instrument recorded for days on
|
|
129
|
+
deck before deployment, the 12 h skip was too short and ``pmin`` would
|
|
130
|
+
equal the bench pressure (≈ 0 dbar), driving ``threshold`` negative so
|
|
131
|
+
that nothing ever satisfied it.
|
|
132
|
+
* **``pmax``-based threshold biased by knockdown**: using ``0.5 × pmax``
|
|
133
|
+
as a conservative-window threshold is biased because knockdowns push
|
|
134
|
+
instruments *deeper* than their nominal position, inflating ``pmax``
|
|
135
|
+
above the nominal deployment pressure. ``0.5 × pmax`` therefore sits
|
|
136
|
+
*deeper* than intended, delaying the start of the conservative window.
|
|
137
|
+
The middle-50 % approach uses the typical deployed pressure (median
|
|
138
|
+
region of the time series), not the occasional extreme.
|
|
139
|
+
|
|
140
|
+
Returns ``(None, None, "no_pressure")`` when the dataset has no
|
|
141
|
+
``"pressure"`` variable, fewer than 10 records, all-NaN pressure, the
|
|
142
|
+
middle window is empty, or the algorithm cannot produce a valid window.
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
ds : xr.Dataset
|
|
147
|
+
Stage 1 dataset. Must contain a ``"time"`` coordinate in the raw
|
|
148
|
+
instrument clock. The ``"pressure"`` variable (dbar) is required.
|
|
149
|
+
|
|
150
|
+
Returns
|
|
151
|
+
-------
|
|
152
|
+
tuple[Optional[np.datetime64], Optional[np.datetime64], str]
|
|
153
|
+
``(sug_start, sug_end, source)`` where *source* is
|
|
154
|
+
``"pressure_pmin10dbar"`` on success or ``"no_pressure"`` when no
|
|
155
|
+
valid pressure data are available.
|
|
156
|
+
|
|
157
|
+
"""
|
|
158
|
+
time = ds["time"].values
|
|
159
|
+
n = len(time)
|
|
160
|
+
|
|
161
|
+
if "pressure" not in ds.data_vars or n < 10:
|
|
162
|
+
return None, None, "no_pressure"
|
|
163
|
+
|
|
164
|
+
p = ds["pressure"].values.astype(float)
|
|
165
|
+
|
|
166
|
+
# ── Middle-50 % reference window (skip first/last 25 % by index) ─────────
|
|
167
|
+
i_lo = int(0.25 * n)
|
|
168
|
+
i_hi = int(0.75 * n)
|
|
169
|
+
mid_mask = np.zeros(n, dtype=bool)
|
|
170
|
+
mid_mask[i_lo:i_hi] = True
|
|
171
|
+
|
|
172
|
+
p_mid_finite = p[mid_mask & np.isfinite(p)]
|
|
173
|
+
if p_mid_finite.size == 0:
|
|
174
|
+
return None, None, "no_pressure"
|
|
175
|
+
|
|
176
|
+
# 1st percentile: robust to brief sensor-zero artefacts in the deployed
|
|
177
|
+
# record that would drag the absolute minimum to ~0 dbar.
|
|
178
|
+
pmin_deployed = float(np.nanpercentile(p_mid_finite, 1))
|
|
179
|
+
threshold = pmin_deployed - 10.0
|
|
180
|
+
|
|
181
|
+
# ── Search windows: first/last 25 % of record ────────────────────────────
|
|
182
|
+
# Proportional windows cover multi-day bench periods that a fixed 12 h
|
|
183
|
+
# window would miss.
|
|
184
|
+
start_window = np.zeros(n, dtype=bool)
|
|
185
|
+
start_window[:i_lo] = True # first 25 %
|
|
186
|
+
end_window = np.zeros(n, dtype=bool)
|
|
187
|
+
end_window[i_hi:] = True # last 25 %
|
|
188
|
+
|
|
189
|
+
below = np.isfinite(p) & (p <= threshold)
|
|
190
|
+
|
|
191
|
+
# Start: last below-threshold sample in opening window → next sample
|
|
192
|
+
idx_start = np.where(below & start_window)[0]
|
|
193
|
+
start_idx = min(int(idx_start[-1]) + 1, n - 1) if idx_start.size > 0 else 0
|
|
194
|
+
|
|
195
|
+
# End: first below-threshold sample in closing window → previous sample
|
|
196
|
+
idx_end = np.where(below & end_window)[0]
|
|
197
|
+
if idx_end.size > 0:
|
|
198
|
+
end_idx = max(int(idx_end[0]) - 1, 0)
|
|
199
|
+
else:
|
|
200
|
+
import logging as _logging
|
|
201
|
+
|
|
202
|
+
_logging.getLogger(__name__).warning(
|
|
203
|
+
"detect_deployment_window: no recovery pressure transition detected "
|
|
204
|
+
"in last 25 %% of record — suggested end = last stage1 sample (%s). "
|
|
205
|
+
"If the YAML recovery_time is set earlier than this, the orange vline "
|
|
206
|
+
"will fall outside the 6 h end-window and be invisible.",
|
|
207
|
+
time[n - 1],
|
|
208
|
+
)
|
|
209
|
+
end_idx = n - 1
|
|
210
|
+
|
|
211
|
+
if start_idx >= end_idx:
|
|
212
|
+
return None, None, "no_pressure"
|
|
213
|
+
|
|
214
|
+
return time[start_idx], time[end_idx], "pressure_pmin10dbar"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _find_nonnull_bounds(
|
|
218
|
+
dataset: xr.Dataset,
|
|
219
|
+
) -> tuple[np.datetime64, np.datetime64]:
|
|
220
|
+
"""Return (first_nonnull_time, last_nonnull_time) using a variable priority hierarchy.
|
|
221
|
+
|
|
222
|
+
Priority: pressure > temperature > any variable with 'velocity' or 'turbidity' in
|
|
223
|
+
its name > any other 1-D time-dimension variable. Falls back to the first/last
|
|
224
|
+
timestamps if no finite values are found.
|
|
225
|
+
|
|
226
|
+
.. important:: Returns times in whatever clock the dataset carries — for stage 1
|
|
227
|
+
input this is the **raw instrument clock** (uncorrected).
|
|
228
|
+
|
|
229
|
+
Parameters
|
|
230
|
+
----------
|
|
231
|
+
dataset : xr.Dataset
|
|
232
|
+
Dataset whose ``"time"`` coordinate is used for indexing.
|
|
233
|
+
|
|
234
|
+
Returns
|
|
235
|
+
-------
|
|
236
|
+
tuple[np.datetime64, np.datetime64]
|
|
237
|
+
``(first_nonnull, last_nonnull)`` timestamps from the chosen variable.
|
|
238
|
+
|
|
239
|
+
"""
|
|
240
|
+
time = dataset["time"].values
|
|
241
|
+
n = len(time)
|
|
242
|
+
|
|
243
|
+
# Choose the representative variable using the priority hierarchy
|
|
244
|
+
var: Optional[str] = None
|
|
245
|
+
if "pressure" in dataset.data_vars and dataset["pressure"].dims == ("time",):
|
|
246
|
+
var = "pressure"
|
|
247
|
+
elif "temperature" in dataset.data_vars and dataset["temperature"].dims == (
|
|
248
|
+
"time",
|
|
249
|
+
):
|
|
250
|
+
var = "temperature"
|
|
251
|
+
|
|
252
|
+
if var is None:
|
|
253
|
+
for v in dataset.data_vars:
|
|
254
|
+
if dataset[v].dims == ("time",) and any(
|
|
255
|
+
k in v for k in ("velocity", "turbidity")
|
|
256
|
+
):
|
|
257
|
+
var = v
|
|
258
|
+
break
|
|
259
|
+
|
|
260
|
+
if var is None:
|
|
261
|
+
for v in dataset.data_vars:
|
|
262
|
+
if dataset[v].dims == ("time",):
|
|
263
|
+
var = v
|
|
264
|
+
break
|
|
265
|
+
|
|
266
|
+
if var is None:
|
|
267
|
+
return time[0], time[-1]
|
|
268
|
+
|
|
269
|
+
vals = np.asarray(dataset[var].values, dtype=float)
|
|
270
|
+
finite_mask = np.isfinite(vals)
|
|
271
|
+
if not finite_mask.any():
|
|
272
|
+
return time[0], time[-1]
|
|
273
|
+
|
|
274
|
+
first_idx = int(np.argmax(finite_mask))
|
|
275
|
+
last_idx = int(n - 1 - np.argmax(finite_mask[::-1]))
|
|
276
|
+
return time[first_idx], time[last_idx]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class Stage2Processor:
|
|
280
|
+
"""Handles Stage 2 processing: clock correction and temporal trimming."""
|
|
281
|
+
|
|
282
|
+
def __init__(
|
|
283
|
+
self,
|
|
284
|
+
base_dir: Optional[str] = None,
|
|
285
|
+
*,
|
|
286
|
+
proc_dir: Optional[str] = None,
|
|
287
|
+
) -> None:
|
|
288
|
+
"""Apply clock-drift correction and trim records to the deployment window.
|
|
289
|
+
|
|
290
|
+
Reads stage1 CF-NetCDF files, corrects instrument clock offsets using a
|
|
291
|
+
linear drift model (offset at deployment and recovery specified in the YAML),
|
|
292
|
+
and trims each record to the deployment start/end times. Writes
|
|
293
|
+
``{mooring}_{serial}_stage2.nc`` alongside the stage1 file.
|
|
294
|
+
|
|
295
|
+
Parameters
|
|
296
|
+
----------
|
|
297
|
+
base_dir : str, optional
|
|
298
|
+
Legacy: cruise-level base directory containing a ``proc/`` subdirectory.
|
|
299
|
+
proc_dir : str, optional
|
|
300
|
+
Cruise-level processed output directory. Pipeline appends ``/{mooring}/``.
|
|
301
|
+
|
|
302
|
+
"""
|
|
303
|
+
if base_dir is not None:
|
|
304
|
+
self.base_dir: Optional[Path] = Path(base_dir)
|
|
305
|
+
self._proc_dir: Optional[Path] = None
|
|
306
|
+
self._legacy = True
|
|
307
|
+
else:
|
|
308
|
+
self.base_dir = None
|
|
309
|
+
self._proc_dir = Path(proc_dir) if proc_dir else None
|
|
310
|
+
self._legacy = False
|
|
311
|
+
self.log_file = None
|
|
312
|
+
|
|
313
|
+
def _rel(self, path: Path) -> str:
|
|
314
|
+
"""Return a short display path relative to base_dir or proc_dir."""
|
|
315
|
+
for root in (r for r in (self.base_dir, self._proc_dir) if r):
|
|
316
|
+
try:
|
|
317
|
+
return str(path.relative_to(root))
|
|
318
|
+
except ValueError:
|
|
319
|
+
continue
|
|
320
|
+
return path.name
|
|
321
|
+
|
|
322
|
+
def _setup_logging(self, mooring_name: str, output_path: Path) -> None:
|
|
323
|
+
"""Set up logging for the processing run using global config."""
|
|
324
|
+
from .logger import setup_stage_logging
|
|
325
|
+
|
|
326
|
+
self.log_file = setup_stage_logging(mooring_name, "stage2", output_path)
|
|
327
|
+
|
|
328
|
+
def _log_print(self, *args: Any, **kwargs: Any) -> None:
|
|
329
|
+
"""Print to both console and log file."""
|
|
330
|
+
print(*args, **kwargs)
|
|
331
|
+
if self.log_file:
|
|
332
|
+
with open(self.log_file, "a") as f:
|
|
333
|
+
print(*args, **kwargs, file=f)
|
|
334
|
+
|
|
335
|
+
def _load_mooring_config(self, config_path: Path) -> Dict[str, Any]:
|
|
336
|
+
"""Load mooring configuration from YAML file."""
|
|
337
|
+
with open(config_path, "r") as f:
|
|
338
|
+
return yaml.safe_load(f)
|
|
339
|
+
|
|
340
|
+
def _read_yaml_time(self, data: Dict[str, Any], key: str) -> np.datetime64:
|
|
341
|
+
"""Return datetime64[ns] from YAML dict or NaT if missing/invalid."""
|
|
342
|
+
val = data.get(key, None)
|
|
343
|
+
if val is None or (isinstance(val, str) and not val.strip()):
|
|
344
|
+
return np.datetime64("NaT", "ns")
|
|
345
|
+
try:
|
|
346
|
+
return pd.to_datetime(val).to_datetime64()
|
|
347
|
+
except Exception: # noqa: BLE001 — returns NaT for any unparseable value
|
|
348
|
+
return np.datetime64("NaT", "ns")
|
|
349
|
+
|
|
350
|
+
def _preserve_time_orig(self, dataset: xr.Dataset) -> xr.Dataset:
|
|
351
|
+
"""Save original (uncorrected) time as time_orig before any clock corrections."""
|
|
352
|
+
if "time_orig" not in dataset.coords:
|
|
353
|
+
dataset = dataset.assign_coords(time_orig=dataset["time"])
|
|
354
|
+
dataset["time_orig"].attrs = {
|
|
355
|
+
"long_name": "original instrument time before clock correction",
|
|
356
|
+
"standard_name": "time",
|
|
357
|
+
}
|
|
358
|
+
return dataset
|
|
359
|
+
|
|
360
|
+
def _apply_clock_offset(
|
|
361
|
+
self, dataset: xr.Dataset, clock_offset: float
|
|
362
|
+
) -> xr.Dataset:
|
|
363
|
+
"""Apply constant clock offset correction.
|
|
364
|
+
|
|
365
|
+
Convention: clock_offset is the amount ADDED to instrument time to get
|
|
366
|
+
corrected time.
|
|
367
|
+
clock_offset > 0: instrument was slow (behind); times shifted later.
|
|
368
|
+
clock_offset < 0: instrument was fast (ahead); times shifted earlier.
|
|
369
|
+
"""
|
|
370
|
+
if clock_offset == 0:
|
|
371
|
+
return dataset
|
|
372
|
+
|
|
373
|
+
self._log_print(f"Applying clock offset: {clock_offset:+.1f} s")
|
|
374
|
+
result = dataset.copy()
|
|
375
|
+
result["clock_offset"] = clock_offset
|
|
376
|
+
result["clock_offset"].attrs = {
|
|
377
|
+
"units": "s",
|
|
378
|
+
"long_name": "constant clock offset added to time",
|
|
379
|
+
}
|
|
380
|
+
result = result.assign_coords(
|
|
381
|
+
time=result["time"].values + np.timedelta64(int(clock_offset * 1e9), "ns")
|
|
382
|
+
)
|
|
383
|
+
sign = "+" if clock_offset >= 0 else ""
|
|
384
|
+
_append_history(result, f"clock_offset={sign}{clock_offset:.1f}s applied")
|
|
385
|
+
return result
|
|
386
|
+
|
|
387
|
+
def _resolve_clock_drift(
|
|
388
|
+
self,
|
|
389
|
+
instrument_config: Dict[str, Any],
|
|
390
|
+
) -> tuple:
|
|
391
|
+
"""Return (drift_seconds, history_note) from YAML config.
|
|
392
|
+
|
|
393
|
+
Convention: drift is the amount ADDED to instrument time at recovery to
|
|
394
|
+
get corrected time (positive = instrument was slow/behind).
|
|
395
|
+
|
|
396
|
+
Supports two YAML approaches:
|
|
397
|
+
Option A — direct:
|
|
398
|
+
clock_drift_seconds: 8 # instrument was 8 s slow at recovery
|
|
399
|
+
Option B — two timestamps at recovery (preferred; no sign errors):
|
|
400
|
+
computer_clock_at_recovery: '2026-07-11T10:23:30'
|
|
401
|
+
instrument_clock_at_recovery: '2026-07-11T10:23:22'
|
|
402
|
+
# computer - instrument = 8 s → instrument was 8 s behind → drift = +8
|
|
403
|
+
Option B takes priority.
|
|
404
|
+
"""
|
|
405
|
+
comp_str = instrument_config.get("computer_clock_at_recovery")
|
|
406
|
+
inst_str = instrument_config.get("instrument_clock_at_recovery")
|
|
407
|
+
if comp_str and inst_str:
|
|
408
|
+
comp_t = _parse_clock_str(str(comp_str))
|
|
409
|
+
inst_t = _parse_clock_str(str(inst_str))
|
|
410
|
+
if comp_t is None or inst_t is None:
|
|
411
|
+
drift_s = float(instrument_config.get("clock_drift_seconds", 0))
|
|
412
|
+
note = f"clock_drift={drift_s:+.1f}s over deployment"
|
|
413
|
+
return drift_s, note
|
|
414
|
+
drift_s = (comp_t - inst_t).total_seconds() # +ve when instrument was slow
|
|
415
|
+
note = (
|
|
416
|
+
f"clock_drift={drift_s:+.1f}s over deployment "
|
|
417
|
+
f"(computer={comp_str}, instrument={inst_str})"
|
|
418
|
+
)
|
|
419
|
+
return drift_s, note
|
|
420
|
+
|
|
421
|
+
drift_s = float(instrument_config.get("clock_drift_seconds", 0))
|
|
422
|
+
note = f"clock_drift={drift_s:+.1f}s over deployment"
|
|
423
|
+
return drift_s, note
|
|
424
|
+
|
|
425
|
+
def _apply_clock_drift(
|
|
426
|
+
self,
|
|
427
|
+
dataset: xr.Dataset,
|
|
428
|
+
clock_drift_seconds: float,
|
|
429
|
+
deploy_time: np.datetime64,
|
|
430
|
+
recover_time: np.datetime64,
|
|
431
|
+
history_note: str = "",
|
|
432
|
+
) -> xr.Dataset:
|
|
433
|
+
"""Apply linear clock drift ramp on top of any already-applied constant offset.
|
|
434
|
+
|
|
435
|
+
``clock_drift_seconds`` here is the *additional* ramp needed above the constant
|
|
436
|
+
offset — i.e. (total_at_recovery − clock_offset). Call site computes this.
|
|
437
|
+
Ramp goes from 0 at deployment to clock_drift_seconds at recovery.
|
|
438
|
+
"""
|
|
439
|
+
if clock_drift_seconds == 0:
|
|
440
|
+
return dataset
|
|
441
|
+
|
|
442
|
+
self._log_print(
|
|
443
|
+
f"Applying clock drift: {clock_drift_seconds:+.1f} s over deployment"
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
total_duration_s = (recover_time - deploy_time) / np.timedelta64(1, "s")
|
|
447
|
+
if total_duration_s <= 0:
|
|
448
|
+
self._log_print(
|
|
449
|
+
"WARNING: deploy_time >= recover_time; skipping clock drift ramp"
|
|
450
|
+
)
|
|
451
|
+
return dataset
|
|
452
|
+
time_since_deploy_s = np.clip(
|
|
453
|
+
(dataset["time"].values - deploy_time) / np.timedelta64(1, "s"),
|
|
454
|
+
0.0,
|
|
455
|
+
total_duration_s,
|
|
456
|
+
)
|
|
457
|
+
correction_ns = (
|
|
458
|
+
clock_drift_seconds * time_since_deploy_s / total_duration_s * 1e9
|
|
459
|
+
).astype("int64")
|
|
460
|
+
|
|
461
|
+
result = dataset.copy()
|
|
462
|
+
result["clock_drift_seconds"] = clock_drift_seconds
|
|
463
|
+
result["clock_drift_seconds"].attrs = {
|
|
464
|
+
"units": "s",
|
|
465
|
+
"long_name": "total linear clock drift applied",
|
|
466
|
+
}
|
|
467
|
+
corrected_times = dataset["time"].values + correction_ns.astype(
|
|
468
|
+
"timedelta64[ns]"
|
|
469
|
+
)
|
|
470
|
+
result = result.assign_coords(time=corrected_times)
|
|
471
|
+
_append_history(
|
|
472
|
+
result, history_note or f"clock_drift={clock_drift_seconds:+.1f}s applied"
|
|
473
|
+
)
|
|
474
|
+
return result
|
|
475
|
+
|
|
476
|
+
def _warn_missing_deployment_window(
|
|
477
|
+
self,
|
|
478
|
+
serial: str,
|
|
479
|
+
instrument_type: str,
|
|
480
|
+
mooring_name: str,
|
|
481
|
+
deploy_time: np.datetime64,
|
|
482
|
+
recover_time: np.datetime64,
|
|
483
|
+
sug_start: np.datetime64,
|
|
484
|
+
sug_end: np.datetime64,
|
|
485
|
+
clock_offset: float = 0,
|
|
486
|
+
) -> None:
|
|
487
|
+
"""Print a prominent console + log warning when YAML deployment times are absent.
|
|
488
|
+
|
|
489
|
+
Suggests copy-pasteable ISO-8601 timestamps (with clock_offset applied for
|
|
490
|
+
display) so the operator can fill in the YAML and re-run. Stage 2 never
|
|
491
|
+
applies the suggested window automatically.
|
|
492
|
+
|
|
493
|
+
Parameters
|
|
494
|
+
----------
|
|
495
|
+
serial : str
|
|
496
|
+
Instrument serial number.
|
|
497
|
+
instrument_type : str
|
|
498
|
+
Instrument type string (e.g. ``"sbe"``).
|
|
499
|
+
mooring_name : str
|
|
500
|
+
Mooring name.
|
|
501
|
+
deploy_time : np.datetime64
|
|
502
|
+
Resolved YAML deployment_time (NaT if absent).
|
|
503
|
+
recover_time : np.datetime64
|
|
504
|
+
Resolved YAML recovery_time (NaT if absent).
|
|
505
|
+
sug_start : np.datetime64
|
|
506
|
+
Suggested start from ``detect_deployment_window`` (raw stage1 clock).
|
|
507
|
+
sug_end : np.datetime64
|
|
508
|
+
Suggested end from ``detect_deployment_window`` (raw stage1 clock).
|
|
509
|
+
clock_offset : float
|
|
510
|
+
Constant clock offset in seconds to apply to suggested times for display.
|
|
511
|
+
|
|
512
|
+
"""
|
|
513
|
+
missing_keys = []
|
|
514
|
+
if not np.isfinite(deploy_time):
|
|
515
|
+
missing_keys.append("deployment_time")
|
|
516
|
+
if not np.isfinite(recover_time):
|
|
517
|
+
missing_keys.append("recovery_time")
|
|
518
|
+
if not missing_keys:
|
|
519
|
+
return
|
|
520
|
+
|
|
521
|
+
border = "─" * 60
|
|
522
|
+
lines = [
|
|
523
|
+
"",
|
|
524
|
+
f" ┌─ DEPLOYMENT WINDOW WARNING {'─' * 32}",
|
|
525
|
+
f" │ Mooring : {mooring_name}",
|
|
526
|
+
f" │ Instrument: {instrument_type} {serial}",
|
|
527
|
+
f" │ Missing YAML key(s): {', '.join(missing_keys)}",
|
|
528
|
+
" │ No deployment-window trimming applied for this instrument.",
|
|
529
|
+
]
|
|
530
|
+
if sug_start is not None:
|
|
531
|
+
offset_td = (
|
|
532
|
+
np.timedelta64(int(clock_offset * 1e9), "ns")
|
|
533
|
+
if clock_offset
|
|
534
|
+
else np.timedelta64(0, "ns")
|
|
535
|
+
)
|
|
536
|
+
offset_note = (
|
|
537
|
+
f" (clock_offset={clock_offset:+.1f}s applied)" if clock_offset else ""
|
|
538
|
+
)
|
|
539
|
+
lines += [
|
|
540
|
+
" │",
|
|
541
|
+
f" │ Auto-detected window from pressure{offset_note} (copy-paste into YAML):",
|
|
542
|
+
f" │ deployment_time: {pd.Timestamp(sug_start + offset_td).isoformat()}",
|
|
543
|
+
f" │ recovery_time: {pd.Timestamp(sug_end + offset_td).isoformat()}",
|
|
544
|
+
]
|
|
545
|
+
else:
|
|
546
|
+
lines += [
|
|
547
|
+
" │",
|
|
548
|
+
" │ No pressure variable — cannot auto-detect deployment window.",
|
|
549
|
+
" │ Set deployment_time and recovery_time manually in the YAML.",
|
|
550
|
+
]
|
|
551
|
+
lines += [
|
|
552
|
+
" │",
|
|
553
|
+
f" │ Update {mooring_name}.mooring.yaml then re-run stage 2.",
|
|
554
|
+
f" └{border}",
|
|
555
|
+
"",
|
|
556
|
+
]
|
|
557
|
+
for line in lines:
|
|
558
|
+
self._log_print(line)
|
|
559
|
+
|
|
560
|
+
def _trim_to_deployment_window(
|
|
561
|
+
self,
|
|
562
|
+
dataset: xr.Dataset,
|
|
563
|
+
deploy_time: np.datetime64,
|
|
564
|
+
recover_time: np.datetime64,
|
|
565
|
+
) -> xr.Dataset:
|
|
566
|
+
"""Trim dataset to deployment time window.
|
|
567
|
+
|
|
568
|
+
A two-pass approach is used:
|
|
569
|
+
|
|
570
|
+
1. **Stray-record pre-filter** (boolean indexing, non-monotonic safe):
|
|
571
|
+
Any record whose timestamp falls more than 31 days before deployment
|
|
572
|
+
or more than 31 days after recovery is dropped with a WARNING. The
|
|
573
|
+
31-day window is hard-coded; it was chosen to handle clock-wrap
|
|
574
|
+
artefacts (e.g. SeaBird clocks jumping to 2038) without discarding
|
|
575
|
+
instruments that start logging a few weeks before entering the water.
|
|
576
|
+
TODO: consider making this window configurable via YAML or a package
|
|
577
|
+
parameter if shorter/longer windows are needed for specific deployments.
|
|
578
|
+
|
|
579
|
+
2. **Deployment-window trim** (``sel(time=slice(...))``): the remaining
|
|
580
|
+
time-series is sliced to ``[deploy_time, recover_time]``. This call
|
|
581
|
+
requires a monotonic time index; if non-monotonic timestamps remain
|
|
582
|
+
after the pre-filter, the ``sel`` call will raise — use ``skip: true``
|
|
583
|
+
in the YAML for instruments with irrecoverably corrupted clocks.
|
|
584
|
+
"""
|
|
585
|
+
original_size = len(dataset.time)
|
|
586
|
+
|
|
587
|
+
# Drop timestamps that are wildly out of range before attempting
|
|
588
|
+
# monotonic-index slicing. SeaBird (and other) instruments sometimes
|
|
589
|
+
# download stray records from older deployments or clock-wrap artifacts.
|
|
590
|
+
# Use boolean indexing (works on non-monotonic time) to cull anything
|
|
591
|
+
# more than 31 days outside the known deployment window.
|
|
592
|
+
_one_month = np.timedelta64(31, "D")
|
|
593
|
+
t_vals = dataset.time.values
|
|
594
|
+
mask = np.ones(len(t_vals), dtype=bool)
|
|
595
|
+
if np.isfinite(deploy_time):
|
|
596
|
+
early_cutoff = deploy_time - _one_month
|
|
597
|
+
n_early = int(np.sum(t_vals < early_cutoff))
|
|
598
|
+
if n_early:
|
|
599
|
+
self._log_print(
|
|
600
|
+
f"WARNING: dropping {n_early} record(s) with timestamps "
|
|
601
|
+
f"more than 31 days before deployment ({early_cutoff}): "
|
|
602
|
+
f"{t_vals[t_vals < early_cutoff]}"
|
|
603
|
+
)
|
|
604
|
+
mask &= t_vals >= early_cutoff
|
|
605
|
+
if np.isfinite(recover_time):
|
|
606
|
+
late_cutoff = recover_time + _one_month
|
|
607
|
+
n_late = int(np.sum(t_vals > late_cutoff))
|
|
608
|
+
if n_late:
|
|
609
|
+
self._log_print(
|
|
610
|
+
f"WARNING: dropping {n_late} record(s) with timestamps "
|
|
611
|
+
f"more than 31 days after recovery ({late_cutoff}): "
|
|
612
|
+
f"{t_vals[t_vals > late_cutoff]}"
|
|
613
|
+
)
|
|
614
|
+
mask &= t_vals <= late_cutoff
|
|
615
|
+
if not mask.all():
|
|
616
|
+
dataset = dataset.isel(time=np.where(mask)[0])
|
|
617
|
+
|
|
618
|
+
# Apply deployment time trimming
|
|
619
|
+
if np.isfinite(deploy_time):
|
|
620
|
+
self._log_print(f"Trimming start to deployment time: {deploy_time}")
|
|
621
|
+
dataset = dataset.sel(time=slice(deploy_time, None))
|
|
622
|
+
|
|
623
|
+
# Apply recovery time trimming
|
|
624
|
+
if np.isfinite(recover_time):
|
|
625
|
+
self._log_print(f"Trimming end to recovery time: {recover_time}")
|
|
626
|
+
dataset = dataset.sel(time=slice(None, recover_time))
|
|
627
|
+
|
|
628
|
+
final_size = len(dataset.time)
|
|
629
|
+
self._log_print(f"Trimmed from {original_size} to {final_size} records")
|
|
630
|
+
|
|
631
|
+
if final_size == 0:
|
|
632
|
+
self._log_print("WARNING: No data remains after trimming!")
|
|
633
|
+
|
|
634
|
+
return dataset
|
|
635
|
+
|
|
636
|
+
def _extract_metadata_from_filepath(
|
|
637
|
+
self, filepath: Path, mooring_name: str
|
|
638
|
+
) -> Dict[str, Any]:
|
|
639
|
+
"""Extract metadata from filepath when not available in YAML or dataset.
|
|
640
|
+
|
|
641
|
+
Expected pattern: {instrument_type}/{mooring_name}_{serial}_stage1.nc
|
|
642
|
+
"""
|
|
643
|
+
fallback_metadata = {}
|
|
644
|
+
|
|
645
|
+
# Extract instrument type from parent directory
|
|
646
|
+
instrument_type = filepath.parent.name
|
|
647
|
+
fallback_metadata["instrument"] = instrument_type
|
|
648
|
+
|
|
649
|
+
# Extract serial number from filename
|
|
650
|
+
filename = filepath.stem # Remove .nc extension
|
|
651
|
+
for suffix in ("_stage1", "_stage2", "_raw"):
|
|
652
|
+
if filename.endswith(suffix):
|
|
653
|
+
filename = filename[: -len(suffix)]
|
|
654
|
+
break
|
|
655
|
+
|
|
656
|
+
# Pattern: mooring_name_serial
|
|
657
|
+
if filename.startswith(f"{mooring_name}_"):
|
|
658
|
+
serial_str = filename[len(f"{mooring_name}_") :]
|
|
659
|
+
try:
|
|
660
|
+
serial = int(serial_str)
|
|
661
|
+
fallback_metadata["serial"] = serial
|
|
662
|
+
self._log_print(
|
|
663
|
+
f"Extracted from filename - instrument: {instrument_type}, serial: {serial}"
|
|
664
|
+
)
|
|
665
|
+
except ValueError:
|
|
666
|
+
self._log_print(
|
|
667
|
+
f"WARNING: Could not parse serial number from filename: {filename}"
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
return fallback_metadata
|
|
671
|
+
|
|
672
|
+
def _get_figure_naming_info(
|
|
673
|
+
self, dataset: xr.Dataset, mooring_name: str
|
|
674
|
+
) -> Dict[str, str]:
|
|
675
|
+
"""Get information needed for figure naming convention.
|
|
676
|
+
|
|
677
|
+
Returns dict with mooring_name, instrument, serial for creating
|
|
678
|
+
figure names like: dsE_1_2018_microcat_7518_ctd.png
|
|
679
|
+
"""
|
|
680
|
+
instrument = str(dataset.get("instrument", "unknown").values)
|
|
681
|
+
serial = str(int(dataset.get("serial_number", 0).values))
|
|
682
|
+
|
|
683
|
+
return {
|
|
684
|
+
"mooring_name": mooring_name,
|
|
685
|
+
"instrument": instrument,
|
|
686
|
+
"serial": serial,
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
def _add_missing_metadata(
|
|
690
|
+
self,
|
|
691
|
+
dataset: xr.Dataset,
|
|
692
|
+
instrument_config: Dict[str, Any],
|
|
693
|
+
filepath: Path,
|
|
694
|
+
mooring_name: str,
|
|
695
|
+
) -> xr.Dataset:
|
|
696
|
+
"""Add any missing metadata variables to dataset with fallback extraction."""
|
|
697
|
+
# Get metadata from YAML config (highest priority)
|
|
698
|
+
yaml_instrument = instrument_config.get("instrument")
|
|
699
|
+
yaml_serial = instrument_config.get("serial")
|
|
700
|
+
yaml_depth = instrument_config.get("depth", 0)
|
|
701
|
+
|
|
702
|
+
# Check if we need fallback for any missing fields
|
|
703
|
+
needs_instrument_fallback = yaml_instrument is None
|
|
704
|
+
needs_serial_fallback = yaml_serial is None
|
|
705
|
+
|
|
706
|
+
fallback_used = False
|
|
707
|
+
final_instrument = yaml_instrument
|
|
708
|
+
final_serial = yaml_serial
|
|
709
|
+
|
|
710
|
+
if needs_instrument_fallback or needs_serial_fallback:
|
|
711
|
+
self._log_print(
|
|
712
|
+
"Some metadata missing from YAML, attempting extraction from filepath..."
|
|
713
|
+
)
|
|
714
|
+
fallback_metadata = self._extract_metadata_from_filepath(
|
|
715
|
+
filepath, mooring_name
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
# Use fallback only for the missing fields
|
|
719
|
+
if needs_instrument_fallback and "instrument" in fallback_metadata:
|
|
720
|
+
final_instrument = fallback_metadata["instrument"]
|
|
721
|
+
self._log_print(f"Using fallback instrument type: {final_instrument}")
|
|
722
|
+
fallback_used = True
|
|
723
|
+
|
|
724
|
+
if needs_serial_fallback and "serial" in fallback_metadata:
|
|
725
|
+
final_serial = fallback_metadata["serial"]
|
|
726
|
+
self._log_print(f"Using fallback serial number: {final_serial}")
|
|
727
|
+
fallback_used = True
|
|
728
|
+
|
|
729
|
+
# Add metadata to dataset if missing
|
|
730
|
+
if "InstrDepth" not in dataset.variables:
|
|
731
|
+
dataset["InstrDepth"] = yaml_depth
|
|
732
|
+
|
|
733
|
+
if "instrument" not in dataset.variables and final_instrument is not None:
|
|
734
|
+
dataset["instrument"] = final_instrument
|
|
735
|
+
|
|
736
|
+
if "serial_number" not in dataset.variables and final_serial is not None:
|
|
737
|
+
dataset["serial_number"] = final_serial
|
|
738
|
+
|
|
739
|
+
# Add history note if fallback was used
|
|
740
|
+
if fallback_used:
|
|
741
|
+
history_note = "non-standard enrichment of metadata from filename patterns"
|
|
742
|
+
if "history" in dataset.attrs:
|
|
743
|
+
dataset.attrs["history"] += f"; {history_note}"
|
|
744
|
+
else:
|
|
745
|
+
dataset.attrs["history"] = history_note
|
|
746
|
+
self._log_print(f"Added history note: {history_note}")
|
|
747
|
+
|
|
748
|
+
return dataset
|
|
749
|
+
|
|
750
|
+
def _clean_unnecessary_variables(self, dataset: xr.Dataset) -> xr.Dataset:
|
|
751
|
+
"""Remove variables that are not needed in the final product.
|
|
752
|
+
|
|
753
|
+
- timeS, timeQ: SeaBird CNV elapsed-time columns; redundant with the
|
|
754
|
+
``time`` coordinate (same clock, different encoding).
|
|
755
|
+
- flag: SeaBird CNV scan flag column; dropped only when all values are
|
|
756
|
+
zero (no scans were flagged by SeaBird software).
|
|
757
|
+
"""
|
|
758
|
+
for var in ("timeS", "timeQ"):
|
|
759
|
+
if var in dataset.variables:
|
|
760
|
+
self._log_print(f"Removing redundant SeaBird time variable: {var}")
|
|
761
|
+
dataset = dataset.drop_vars(var)
|
|
762
|
+
|
|
763
|
+
if "flag" in dataset.variables:
|
|
764
|
+
flag_vals = np.asarray(dataset["flag"].values, dtype="float64")
|
|
765
|
+
if np.all((flag_vals == 0) | np.isnan(flag_vals)):
|
|
766
|
+
self._log_print(
|
|
767
|
+
"Removing 'flag': all values are zero (no SeaBird scan flags set)"
|
|
768
|
+
)
|
|
769
|
+
dataset = dataset.drop_vars("flag")
|
|
770
|
+
_append_history(
|
|
771
|
+
dataset,
|
|
772
|
+
"dropped SeaBird 'flag' column: all values were 0 (good data)",
|
|
773
|
+
)
|
|
774
|
+
else:
|
|
775
|
+
n_flagged = int(np.sum(np.isfinite(flag_vals) & (flag_vals != 0)))
|
|
776
|
+
self._log_print(
|
|
777
|
+
f"Keeping 'flag': {n_flagged} non-zero scan flag(s) from SeaBird CNV"
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
return dataset
|
|
781
|
+
|
|
782
|
+
def _get_netcdf_writer_params(self) -> Dict[str, Any]:
|
|
783
|
+
"""Get standard parameters for NetCDF writer."""
|
|
784
|
+
return {
|
|
785
|
+
"optimize": True,
|
|
786
|
+
"drop_derived": False,
|
|
787
|
+
"uint8_vars": [
|
|
788
|
+
"correlation_magnitude",
|
|
789
|
+
"echo_intensity",
|
|
790
|
+
"status",
|
|
791
|
+
"percent_good",
|
|
792
|
+
"bt_correlation",
|
|
793
|
+
"bt_amplitude",
|
|
794
|
+
"bt_percent_good",
|
|
795
|
+
],
|
|
796
|
+
"float32_vars": [
|
|
797
|
+
"eastward_velocity",
|
|
798
|
+
"northward_velocity",
|
|
799
|
+
"upward_velocity",
|
|
800
|
+
"temperature",
|
|
801
|
+
"salinity",
|
|
802
|
+
"pressure",
|
|
803
|
+
"pressure_std",
|
|
804
|
+
"depth",
|
|
805
|
+
"bt_velocity",
|
|
806
|
+
],
|
|
807
|
+
"chunk_time": 3600,
|
|
808
|
+
"complevel": 5,
|
|
809
|
+
"quantize": 3,
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
def _process_instrument(
|
|
813
|
+
self,
|
|
814
|
+
instrument_config: Dict[str, Any],
|
|
815
|
+
mooring_config: Dict[str, Any], # noqa: ARG002 — reserved for future per-mooring overrides
|
|
816
|
+
proc_dir: Path,
|
|
817
|
+
mooring_name: str,
|
|
818
|
+
deploy_time: np.datetime64,
|
|
819
|
+
recover_time: np.datetime64,
|
|
820
|
+
force: bool = False,
|
|
821
|
+
) -> bool:
|
|
822
|
+
"""Apply clock corrections and deployment trimming to one instrument.
|
|
823
|
+
|
|
824
|
+
Reads the Stage 1 NetCDF for *instrument_config*, applies (in order):
|
|
825
|
+
constant clock offset, linear clock drift, and trimming to the
|
|
826
|
+
deployment window [*deploy_time*, *recover_time*]. Writes the result
|
|
827
|
+
as ``{mooring}_{serial}_stage2.nc`` in the same directory.
|
|
828
|
+
|
|
829
|
+
Skips silently if the Stage 1 file does not exist (not yet staged) or
|
|
830
|
+
if the Stage 2 output already exists and *force* is False.
|
|
831
|
+
|
|
832
|
+
Returns True on success or skip, False if the Stage 1 file is missing
|
|
833
|
+
or an error occurs.
|
|
834
|
+
"""
|
|
835
|
+
import re
|
|
836
|
+
|
|
837
|
+
serial = re.sub(r"[^\w\-]", "", str(instrument_config.get("serial", "unknown")))
|
|
838
|
+
instrument_type = instrument_config.get("instrument", "unknown")
|
|
839
|
+
|
|
840
|
+
if instrument_config.get("skip"):
|
|
841
|
+
reason = instrument_config.get("skip_reason", "marked skip:true in YAML")
|
|
842
|
+
self._log_print(f"SKIP {instrument_type} {serial}: {reason}")
|
|
843
|
+
return True
|
|
844
|
+
|
|
845
|
+
# Construct file paths
|
|
846
|
+
raw_filename = f"{mooring_name}_{serial}_stage1.nc"
|
|
847
|
+
use_filename = f"{mooring_name}_{serial}_stage2.nc"
|
|
848
|
+
|
|
849
|
+
raw_filepath = proc_dir / instrument_type / raw_filename
|
|
850
|
+
use_filepath = proc_dir / instrument_type / use_filename
|
|
851
|
+
|
|
852
|
+
if not raw_filepath.exists():
|
|
853
|
+
if "filename" in instrument_config:
|
|
854
|
+
self._log_print(f"WARNING: Raw file not found: {raw_filepath}")
|
|
855
|
+
return False
|
|
856
|
+
|
|
857
|
+
_status("instr", f"{instrument_type} {serial}")
|
|
858
|
+
|
|
859
|
+
if use_filepath.exists() and not force:
|
|
860
|
+
_status("skip", self._rel(use_filepath))
|
|
861
|
+
return True
|
|
862
|
+
|
|
863
|
+
try:
|
|
864
|
+
# Load the raw dataset
|
|
865
|
+
with xr.open_dataset(raw_filepath, decode_timedelta=False) as ds:
|
|
866
|
+
# Create a copy to modify
|
|
867
|
+
dataset = ds.load()
|
|
868
|
+
|
|
869
|
+
# Capture the raw stage1 time bounds before any corrections are applied.
|
|
870
|
+
# Used later for the "differs by ≥ Δt" highlighting comparison in reports.
|
|
871
|
+
_stage1_t0 = dataset["time"].values[0]
|
|
872
|
+
_stage1_t1 = dataset["time"].values[-1]
|
|
873
|
+
|
|
874
|
+
# Resolve corrections early (needed for suggested-window display)
|
|
875
|
+
clock_offset = instrument_config.get("clock_offset", 0)
|
|
876
|
+
drift_s, drift_note = self._resolve_clock_drift(instrument_config)
|
|
877
|
+
# drift_s is the total correction at recovery; clock_offset is the total at
|
|
878
|
+
# deployment. The ramp applied on top of the constant offset is the difference.
|
|
879
|
+
drift_ramp = (drift_s - clock_offset) if drift_s != 0 else 0
|
|
880
|
+
|
|
881
|
+
# Always detect the suggested deployment window from the stage1 pressure record
|
|
882
|
+
sug_start, sug_end, sug_source = detect_deployment_window(dataset)
|
|
883
|
+
|
|
884
|
+
# Find first/last non-NaN timestamps (variable hierarchy: pressure > temp > …)
|
|
885
|
+
_nonnull_start, _nonnull_end = _find_nonnull_bounds(dataset)
|
|
886
|
+
|
|
887
|
+
# Warn loudly when YAML deployment times are absent
|
|
888
|
+
if not np.isfinite(deploy_time) or not np.isfinite(recover_time):
|
|
889
|
+
self._warn_missing_deployment_window(
|
|
890
|
+
serial,
|
|
891
|
+
instrument_type,
|
|
892
|
+
mooring_name,
|
|
893
|
+
deploy_time,
|
|
894
|
+
recover_time,
|
|
895
|
+
sug_start,
|
|
896
|
+
sug_end,
|
|
897
|
+
clock_offset,
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
# Add missing metadata with fallback extraction
|
|
901
|
+
dataset = self._add_missing_metadata(
|
|
902
|
+
dataset, instrument_config, raw_filepath, mooring_name
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
# Clean unnecessary variables
|
|
906
|
+
dataset = self._clean_unnecessary_variables(dataset)
|
|
907
|
+
|
|
908
|
+
# Only save time_orig when we are actually going to change time
|
|
909
|
+
if clock_offset != 0 or drift_ramp != 0:
|
|
910
|
+
dataset = self._preserve_time_orig(dataset)
|
|
911
|
+
|
|
912
|
+
dataset = self._apply_clock_offset(dataset, clock_offset)
|
|
913
|
+
dataset = self._apply_clock_drift(
|
|
914
|
+
dataset, drift_ramp, deploy_time, recover_time, history_note=drift_note
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
# Trim to deployment window
|
|
918
|
+
dataset = self._trim_to_deployment_window(
|
|
919
|
+
dataset, deploy_time, recover_time
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
if len(dataset.time) == 0:
|
|
923
|
+
self._log_print(
|
|
924
|
+
f"ERROR: No data remains after processing {instrument_type} {serial}"
|
|
925
|
+
)
|
|
926
|
+
return False
|
|
927
|
+
|
|
928
|
+
# Log time range
|
|
929
|
+
start_time = dataset["time"].values.min()
|
|
930
|
+
end_time = dataset["time"].values.max()
|
|
931
|
+
self._log_print(f"Final time range: {start_time} to {end_time}")
|
|
932
|
+
|
|
933
|
+
# Tag with QC convention so downstream tools know the flag vocabulary
|
|
934
|
+
from . import parameters as _P
|
|
935
|
+
|
|
936
|
+
dataset.attrs.setdefault("qc_convention", _P.QC_CONVENTION)
|
|
937
|
+
|
|
938
|
+
# Suggested deployment window — only written when pressure detection succeeded.
|
|
939
|
+
# All raw-clock attrs use the uncorrected instrument clock; _utc variants add
|
|
940
|
+
# the clock correction so they are safe to paste directly into the YAML.
|
|
941
|
+
if sug_start is not None:
|
|
942
|
+
dataset.attrs["suggested_deployment_time"] = pd.Timestamp(
|
|
943
|
+
sug_start
|
|
944
|
+
).isoformat()
|
|
945
|
+
dataset.attrs["suggested_recovery_time"] = pd.Timestamp(
|
|
946
|
+
sug_end
|
|
947
|
+
).isoformat()
|
|
948
|
+
dataset.attrs["suggested_window_source"] = sug_source
|
|
949
|
+
dataset.attrs["suggested_window_clock"] = "raw_instrument_clock"
|
|
950
|
+
_sugg_start_utc = pd.Timestamp(sug_start) + pd.Timedelta(
|
|
951
|
+
seconds=clock_offset
|
|
952
|
+
)
|
|
953
|
+
dataset.attrs["suggested_deployment_time_utc"] = (
|
|
954
|
+
_sugg_start_utc.isoformat()
|
|
955
|
+
)
|
|
956
|
+
_end_correction = drift_s if drift_s else clock_offset
|
|
957
|
+
_sugg_end_utc = pd.Timestamp(sug_end) + pd.Timedelta(
|
|
958
|
+
seconds=_end_correction
|
|
959
|
+
)
|
|
960
|
+
dataset.attrs["suggested_recovery_time_utc"] = _sugg_end_utc.isoformat()
|
|
961
|
+
# First/last non-NaN timestamps (raw clock) from the priority variable.
|
|
962
|
+
dataset.attrs["stage1_first_nonnull"] = pd.Timestamp(
|
|
963
|
+
_nonnull_start
|
|
964
|
+
).isoformat()
|
|
965
|
+
dataset.attrs["stage1_last_nonnull"] = pd.Timestamp(
|
|
966
|
+
_nonnull_end
|
|
967
|
+
).isoformat()
|
|
968
|
+
# Store the raw stage1 full-record bounds for report comparison (same clock).
|
|
969
|
+
dataset.attrs["stage1_time_start"] = pd.Timestamp(_stage1_t0).isoformat()
|
|
970
|
+
dataset.attrs["stage1_time_end"] = pd.Timestamp(_stage1_t1).isoformat()
|
|
971
|
+
# Store the YAML trim bounds (UTC) so the report can compare stage2_end
|
|
972
|
+
# against the expected recovery time without needing the YAML directly.
|
|
973
|
+
if np.isfinite(deploy_time):
|
|
974
|
+
dataset.attrs["yaml_deployment_time"] = pd.Timestamp(
|
|
975
|
+
deploy_time
|
|
976
|
+
).isoformat()
|
|
977
|
+
if np.isfinite(recover_time):
|
|
978
|
+
dataset.attrs["yaml_recovery_time"] = pd.Timestamp(
|
|
979
|
+
recover_time
|
|
980
|
+
).isoformat()
|
|
981
|
+
|
|
982
|
+
# Remove existing output file before writing
|
|
983
|
+
if use_filepath.exists():
|
|
984
|
+
use_filepath.unlink()
|
|
985
|
+
|
|
986
|
+
# Write the processed dataset
|
|
987
|
+
dataset = drop_all_zero_vars(dataset, ["amplitude_beam", "analog_input_"])
|
|
988
|
+
writer = NetCdfWriter(cast_output_dtypes(dataset))
|
|
989
|
+
writer_params = self._get_netcdf_writer_params()
|
|
990
|
+
writer.write(str(use_filepath), **writer_params)
|
|
991
|
+
|
|
992
|
+
_status("file", self._rel(use_filepath))
|
|
993
|
+
|
|
994
|
+
except Exception as e: # noqa: BLE001 — intentional broad catch at I/O boundary
|
|
995
|
+
self._log_print(f"ERROR processing {instrument_type} {serial}: {e}")
|
|
996
|
+
return False
|
|
997
|
+
return True
|
|
998
|
+
|
|
999
|
+
def process_mooring(
|
|
1000
|
+
self,
|
|
1001
|
+
mooring_name: str,
|
|
1002
|
+
output_path: Optional[str] = None,
|
|
1003
|
+
serials: Optional[List[str]] = None,
|
|
1004
|
+
force: bool = False,
|
|
1005
|
+
) -> bool:
|
|
1006
|
+
"""Process Stage 2 for a single mooring.
|
|
1007
|
+
|
|
1008
|
+
Args:
|
|
1009
|
+
mooring_name: Name of the mooring to process
|
|
1010
|
+
output_path: Optional custom output path
|
|
1011
|
+
serials: Optional list of serial numbers to process; if None, process all.
|
|
1012
|
+
force: Re-process even if Stage 2 output already exists.
|
|
1013
|
+
|
|
1014
|
+
Returns:
|
|
1015
|
+
bool: True if processing completed successfully
|
|
1016
|
+
|
|
1017
|
+
"""
|
|
1018
|
+
# Set up paths
|
|
1019
|
+
if self._legacy:
|
|
1020
|
+
if output_path is None:
|
|
1021
|
+
proc = self.base_dir / "proc"
|
|
1022
|
+
if not proc.is_dir():
|
|
1023
|
+
legacy_proc = self.base_dir / "moor" / "proc"
|
|
1024
|
+
proc = legacy_proc if legacy_proc.is_dir() else proc
|
|
1025
|
+
proc_dir = proc / mooring_name
|
|
1026
|
+
else:
|
|
1027
|
+
proc_dir = Path(output_path) / mooring_name
|
|
1028
|
+
else:
|
|
1029
|
+
proc_dir = self._proc_dir / mooring_name
|
|
1030
|
+
|
|
1031
|
+
if not proc_dir.exists():
|
|
1032
|
+
print(f"ERROR: Processing directory not found: {proc_dir}")
|
|
1033
|
+
return False
|
|
1034
|
+
|
|
1035
|
+
# Set up logging
|
|
1036
|
+
self._setup_logging(mooring_name, proc_dir)
|
|
1037
|
+
self._log_print(f"Starting Stage 2 processing for mooring: {mooring_name}")
|
|
1038
|
+
|
|
1039
|
+
# Load configuration
|
|
1040
|
+
config_file = proc_dir / f"{mooring_name}.mooring.yaml"
|
|
1041
|
+
if not config_file.exists():
|
|
1042
|
+
self._log_print(f"ERROR: Configuration file not found: {config_file}")
|
|
1043
|
+
return False
|
|
1044
|
+
|
|
1045
|
+
try:
|
|
1046
|
+
mooring_config = self._load_mooring_config(config_file)
|
|
1047
|
+
except Exception as e: # noqa: BLE001 — intentional broad catch at I/O boundary
|
|
1048
|
+
self._log_print(f"ERROR: Failed to load configuration: {e}")
|
|
1049
|
+
return False
|
|
1050
|
+
|
|
1051
|
+
# Extract deployment time window
|
|
1052
|
+
deploy_time = self._read_yaml_time(mooring_config, "deployment_time")
|
|
1053
|
+
recover_time = self._read_yaml_time(mooring_config, "recovery_time")
|
|
1054
|
+
|
|
1055
|
+
self._log_print(f"Deployment time: {deploy_time}")
|
|
1056
|
+
self._log_print(f"Recovery time: {recover_time}")
|
|
1057
|
+
|
|
1058
|
+
# Process each instrument — support both 'instruments' (legacy) and 'clamp' (new format)
|
|
1059
|
+
instrument_list = list(
|
|
1060
|
+
mooring_config.get("clamp", mooring_config.get("instruments", []))
|
|
1061
|
+
)
|
|
1062
|
+
instrument_list += extract_inline_instruments(mooring_config.get("inline", []))
|
|
1063
|
+
|
|
1064
|
+
# Filter by serial if requested
|
|
1065
|
+
if serials:
|
|
1066
|
+
import re
|
|
1067
|
+
|
|
1068
|
+
safe_serials = {re.sub(r"[^\w\-]", "", str(s)) for s in serials}
|
|
1069
|
+
instrument_list = [
|
|
1070
|
+
ic
|
|
1071
|
+
for ic in instrument_list
|
|
1072
|
+
if re.sub(r"[^\w\-]", "", str(ic.get("serial", ""))) in safe_serials
|
|
1073
|
+
]
|
|
1074
|
+
self._log_print(
|
|
1075
|
+
f"Filtered to {len(instrument_list)} instrument(s) matching serial(s): {', '.join(serials)}"
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
success_count = 0
|
|
1079
|
+
total_count = len(instrument_list)
|
|
1080
|
+
|
|
1081
|
+
for instrument_config in instrument_list:
|
|
1082
|
+
success = self._process_instrument(
|
|
1083
|
+
instrument_config,
|
|
1084
|
+
mooring_config,
|
|
1085
|
+
proc_dir,
|
|
1086
|
+
mooring_name,
|
|
1087
|
+
deploy_time,
|
|
1088
|
+
recover_time,
|
|
1089
|
+
force=force,
|
|
1090
|
+
)
|
|
1091
|
+
if success:
|
|
1092
|
+
success_count += 1
|
|
1093
|
+
|
|
1094
|
+
self._log_print(
|
|
1095
|
+
f"Stage 2 completed: {success_count}/{total_count} instruments successful"
|
|
1096
|
+
)
|
|
1097
|
+
return success_count > 0
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def stage2_mooring(
|
|
1101
|
+
mooring_name: str, basedir: str, output_path: Optional[str] = None
|
|
1102
|
+
) -> bool:
|
|
1103
|
+
"""Process Stage 2 for a single mooring (backwards compatibility function).
|
|
1104
|
+
|
|
1105
|
+
Args:
|
|
1106
|
+
mooring_name: Name of the mooring to process
|
|
1107
|
+
basedir: Base directory containing the data
|
|
1108
|
+
output_path: Optional output path override
|
|
1109
|
+
|
|
1110
|
+
Returns:
|
|
1111
|
+
bool: True if processing completed successfully
|
|
1112
|
+
|
|
1113
|
+
"""
|
|
1114
|
+
processor = Stage2Processor(basedir)
|
|
1115
|
+
return processor.process_mooring(mooring_name, output_path)
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def process_multiple_moorings_stage2(
|
|
1119
|
+
mooring_list: List[str], basedir: str
|
|
1120
|
+
) -> Dict[str, bool]:
|
|
1121
|
+
"""Process Stage 2 for multiple moorings.
|
|
1122
|
+
|
|
1123
|
+
Args:
|
|
1124
|
+
mooring_list: List of mooring names to process
|
|
1125
|
+
basedir: Base directory containing the data
|
|
1126
|
+
|
|
1127
|
+
Returns:
|
|
1128
|
+
Dict mapping mooring names to success status
|
|
1129
|
+
|
|
1130
|
+
"""
|
|
1131
|
+
processor = Stage2Processor(basedir)
|
|
1132
|
+
results = {}
|
|
1133
|
+
|
|
1134
|
+
for mooring_name in mooring_list:
|
|
1135
|
+
print(f"\n{'=' * 50}")
|
|
1136
|
+
print(f"Processing Stage 2 for mooring {mooring_name}")
|
|
1137
|
+
print(f"{'=' * 50}")
|
|
1138
|
+
|
|
1139
|
+
results[mooring_name] = processor.process_mooring(mooring_name)
|
|
1140
|
+
|
|
1141
|
+
return results
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
# Example usage
|
|
1145
|
+
if __name__ == "__main__":
|
|
1146
|
+
# Your mooring list
|
|
1147
|
+
moorlist = ["dsE_1_2018"]
|
|
1148
|
+
|
|
1149
|
+
basedir = "/Users/eddifying/Dropbox/data/ifmro_mixsed/ds_data_eleanor/"
|
|
1150
|
+
|
|
1151
|
+
# Process all moorings
|
|
1152
|
+
results = process_multiple_moorings_stage2(moorlist, basedir)
|
|
1153
|
+
|
|
1154
|
+
# Print summary
|
|
1155
|
+
print(f"\n{'=' * 50}")
|
|
1156
|
+
print("STAGE 2 PROCESSING SUMMARY")
|
|
1157
|
+
print(f"{'=' * 50}")
|
|
1158
|
+
for mooring, success in results.items():
|
|
1159
|
+
status = "SUCCESS" if success else "FAILED"
|
|
1160
|
+
print(f"{mooring}: {status}")
|