oceanarray 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. oceanarray/__init__.py +20 -0
  2. oceanarray/_version.py +1 -0
  3. oceanarray/cli.py +1343 -0
  4. oceanarray/clock_offset.py +675 -0
  5. oceanarray/config/OS1_sensor_attrs.yaml +49 -0
  6. oceanarray/config/OS1_var_names.yaml +5 -0
  7. oceanarray/config/OS1_vocab_attrs.yaml +84 -0
  8. oceanarray/config/legacy/project_RAPID.yaml +69 -0
  9. oceanarray/config/legacy/rodb_keys.txt +206 -0
  10. oceanarray/config/legacy/rodb_keys.yaml +1012 -0
  11. oceanarray/config/logging.yaml +28 -0
  12. oceanarray/legacy/__init__.py +47 -0
  13. oceanarray/legacy/caldip.py +753 -0
  14. oceanarray/legacy/convertOS.py +527 -0
  15. oceanarray/legacy/find_deployment.py +489 -0
  16. oceanarray/legacy/mooring_rodb.py +429 -0
  17. oceanarray/legacy/process_rodb.py +369 -0
  18. oceanarray/legacy/rodb.py +330 -0
  19. oceanarray/logger.py +192 -0
  20. oceanarray/logsheet/__init__.py +60 -0
  21. oceanarray/logsheet/_columns.py +97 -0
  22. oceanarray/logsheet/_config.py +245 -0
  23. oceanarray/logsheet/_firmware.py +43 -0
  24. oceanarray/logsheet/_latex.py +128 -0
  25. oceanarray/logsheet/_render.py +126 -0
  26. oceanarray/logsheet/builders/__init__.py +13 -0
  27. oceanarray/logsheet/builders/caldip_download.py +195 -0
  28. oceanarray/logsheet/builders/caldip_setup.py +188 -0
  29. oceanarray/logsheet/builders/deployment_setup.py +218 -0
  30. oceanarray/logsheet/builders/mooring_download.py +243 -0
  31. oceanarray/logsheet/builders/recovery.py +157 -0
  32. oceanarray/logsheet/data/column_defs.yaml +686 -0
  33. oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
  34. oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
  35. oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
  36. oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
  37. oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
  38. oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
  39. oceanarray/mooring_level.py +1368 -0
  40. oceanarray/oceanarray.mplstyle +18 -0
  41. oceanarray/parameters.py +270 -0
  42. oceanarray/plotter.py +942 -0
  43. oceanarray/plotters/__init__.py +97 -0
  44. oceanarray/plotters/_animation.py +261 -0
  45. oceanarray/plotters/_current.py +705 -0
  46. oceanarray/plotters/_diagnostic.py +667 -0
  47. oceanarray/plotters/_helpers.py +57 -0
  48. oceanarray/plotters/_primitives.py +101 -0
  49. oceanarray/plotters/_section.py +16 -0
  50. oceanarray/plotters/_timeseries.py +11 -0
  51. oceanarray/rapid_interp.py +739 -0
  52. oceanarray/read_rbr_hex.py +541 -0
  53. oceanarray/readers.py +303 -0
  54. oceanarray/report/__init__.py +5 -0
  55. oceanarray/report/_array.py +386 -0
  56. oceanarray/report/_grid.py +539 -0
  57. oceanarray/report/_html_helpers.py +1004 -0
  58. oceanarray/report/_instrument.py +853 -0
  59. oceanarray/report/_mooring.py +1385 -0
  60. oceanarray/report/_plots.py +4284 -0
  61. oceanarray/report/_stack.py +941 -0
  62. oceanarray/stage1.py +1959 -0
  63. oceanarray/stage2.py +1160 -0
  64. oceanarray/stage3.py +2494 -0
  65. oceanarray/time_gridding.py +958 -0
  66. oceanarray/tools.py +400 -0
  67. oceanarray/transports.py +1 -0
  68. oceanarray/utilities.py +538 -0
  69. oceanarray/validation.py +389 -0
  70. oceanarray/writers.py +99 -0
  71. oceanarray-0.1.0.dist-info/METADATA +503 -0
  72. oceanarray-0.1.0.dist-info/RECORD +76 -0
  73. oceanarray-0.1.0.dist-info/WHEEL +5 -0
  74. oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
  75. oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
  76. oceanarray-0.1.0.dist-info/top_level.txt +1 -0
oceanarray/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}")