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/stage1.py ADDED
@@ -0,0 +1,1959 @@
1
+ """Refactored stage1 processing for mooring data with improved readability."""
2
+
3
+ import calendar
4
+ import re
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional, Tuple
8
+
9
+ import logging
10
+
11
+ import xarray as xr
12
+ import yaml
13
+ import seasenselib
14
+ from seasenselib.writers import NetCdfWriter
15
+ from .utilities import _status, cast_output_dtypes, extract_inline_instruments
16
+ from . import parameters as P
17
+
18
+ # Suppress noisy INFO/WARNING messages from seasenselib/pycnv.
19
+ logging.getLogger("seasenselib").setLevel(logging.WARNING)
20
+ logging.getLogger("seasenselib.pipeline.derivation").setLevel(logging.ERROR)
21
+ logging.getLogger("pycnv").setLevel(logging.WARNING)
22
+
23
+
24
+ # Re-export for backward compatibility; canonical definition is in parameters.py.
25
+ ADCP_BIN_DIM: str = P.ADCP_BIN_DIM
26
+
27
+
28
+ def _dms_str_to_decimal(s: str) -> Optional[float]:
29
+ """Convert 'DD MM.mmm N/S/E/W' or a plain float string to decimal degrees."""
30
+ s = str(s).strip()
31
+ try:
32
+ return float(s)
33
+ except ValueError:
34
+ pass
35
+ parts = s.upper().split()
36
+ if not parts:
37
+ return None
38
+ hemi = parts[-1] if parts[-1] in ("N", "S", "E", "W") else None
39
+ nums = parts[:-1] if hemi else parts
40
+ try:
41
+ val = sum(float(x) / 60.0**i for i, x in enumerate(nums))
42
+ except (ValueError, ZeroDivisionError):
43
+ return None
44
+ if hemi in ("S", "W"):
45
+ val = -val
46
+ return val
47
+
48
+
49
+ def _parse_nortek_coord_system(hdr_path: Path) -> str:
50
+ """Return the coordinate system string from a Nortek .hdr file (BEAM/XYZ/ENU).
51
+
52
+ Handles two header formats:
53
+
54
+ * Old text format — looks for ``Coordinate system BEAM`` (2+ spaces separator)
55
+ in the instrument-settings block above the "Data file format" line.
56
+ * New CSV format — looks for ``CY="BEAM"`` in a ``GETAVG,`` line anywhere in
57
+ the file (e.g. ``GETAVG,...,CY="BEAM",...``).
58
+
59
+ Returns "ENU" if neither pattern is found (safe default: no rename / no
60
+ BEAM→ENU transform in stage3).
61
+ """
62
+ try:
63
+ with open(hdr_path, encoding="utf-8", errors="replace") as f:
64
+ for line in f:
65
+ # Old text-format header block (ends at "Data file format")
66
+ if "Data file format" in line:
67
+ break
68
+ m = re.match(r"^\s*Coordinate system\s{2,}(\S+)", line, re.IGNORECASE)
69
+ if m:
70
+ return m.group(1).strip().upper()
71
+ # New CSV-format: GETAVG,...,CY="BEAM",...
72
+ if line.startswith("GETAVG,"):
73
+ cy = re.search(r'\bCY="([^"]+)"', line)
74
+ if cy:
75
+ return cy.group(1).strip().upper()
76
+ except Exception: # noqa: BLE001 — intentional broad catch at I/O boundary
77
+ pass
78
+ return "ENU"
79
+
80
+
81
+ def _parse_nortek_T_matrix_hdr(hdr_path: Path) -> Optional[Dict[str, float]]:
82
+ """Parse the 3×3 Nortek transformation matrix from a .hdr file.
83
+
84
+ Reads the "Transformation matrix" block (first line has 3 values, next two
85
+ continuation lines have 3 values each). Returns a dict of M11..M33 floats,
86
+ or None if the block is not found or has fewer than 9 values.
87
+ """
88
+ try:
89
+ floats: list = []
90
+ in_matrix = False
91
+ with open(hdr_path, encoding="utf-8", errors="replace") as f:
92
+ for line in f:
93
+ if "Data file format" in line:
94
+ break
95
+ stripped = line.rstrip()
96
+ if re.search(r"Transformation matrix", stripped, re.IGNORECASE):
97
+ tail = re.split(r"matrix", stripped, flags=re.IGNORECASE)[-1]
98
+ floats.extend(
99
+ float(v)
100
+ for v in re.findall(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?", tail)
101
+ )
102
+ in_matrix = True
103
+ continue
104
+ if in_matrix:
105
+ vals = re.findall(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?", stripped)
106
+ if vals:
107
+ floats.extend(float(v) for v in vals)
108
+ elif not stripped.strip() or (
109
+ stripped and not stripped[0].isspace()
110
+ ):
111
+ break
112
+ if len(floats) >= 9:
113
+ break
114
+ if len(floats) >= 9:
115
+ keys = ["M11", "M12", "M13", "M21", "M22", "M23", "M31", "M32", "M33"]
116
+ return {k: floats[i] for i, k in enumerate(keys)}
117
+ except Exception: # noqa: BLE001 — intentional broad catch at I/O boundary
118
+ pass
119
+ return None
120
+
121
+
122
+ def _parse_nortek_T_matrix_csv(csv_path: Path) -> Optional[Dict[str, float]]:
123
+ """Parse the 3×3 Nortek transformation matrix from a String Data.csv file.
124
+
125
+ Searches for a ``GETXFAVG`` or ``GETXFBURST`` command with
126
+ ``ROWS=3,COLS=3,M11=...,M33=...`` parameters (pipe-separated field format).
127
+ Returns a dict of M11..M33 floats, or None if not found.
128
+ """
129
+ try:
130
+ with open(csv_path, encoding="utf-8", errors="replace") as f:
131
+ content = f.read()
132
+ for prefix in ("GETXFAVG", "GETXFBURST"):
133
+ m = re.search(rf"{prefix},ROWS=3,COLS=3,([^|\n]+)", content, re.IGNORECASE)
134
+ if not m:
135
+ continue
136
+ params_str = m.group(1)
137
+ result: Dict[str, float] = {}
138
+ for i in range(1, 4):
139
+ for j in range(1, 4):
140
+ key = f"M{i}{j}"
141
+ km = re.search(
142
+ key + r"=([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)",
143
+ params_str,
144
+ re.IGNORECASE,
145
+ )
146
+ if km:
147
+ result[key] = float(km.group(1))
148
+ if len(result) == 9:
149
+ return result
150
+ except Exception:
151
+ pass
152
+ return None
153
+
154
+
155
+ def _parse_nortek_pressure_cal(hdr_path: Path) -> dict:
156
+ """Return pressure sensor calibration key-value pairs from a Nortek .hdr file.
157
+
158
+ Reads the block starting at a line matching "Pressure.*calibration" (case-insensitive)
159
+ and ending at the next blank line or top-level section header (line starting without
160
+ leading whitespace and not a key-value pair). Returns an empty dict if not found.
161
+ """
162
+ cal: dict = {}
163
+ in_section = False
164
+ try:
165
+ with open(hdr_path, encoding="utf-8", errors="replace") as f:
166
+ for line in f:
167
+ if "Data file format" in line:
168
+ break
169
+ stripped = line.rstrip()
170
+ if re.search(r"pressure.{0,10}calibration", stripped, re.IGNORECASE):
171
+ in_section = True
172
+ continue
173
+ if not in_section:
174
+ continue
175
+ if stripped == "":
176
+ break # blank line ends the section
177
+ # Key-value lines have ≥2 spaces between key and value
178
+ m = re.match(r"^\s*(.+?)\s{2,}(.+?)\s*$", stripped)
179
+ if m:
180
+ key = re.sub(r"\W+", "_", m.group(1).strip().lower()).strip("_")
181
+ cal[key] = m.group(2).strip()
182
+ except Exception:
183
+ pass
184
+ return cal
185
+
186
+
187
+ class MooringProcessor:
188
+ """Handles stage1 processing of mooring data."""
189
+
190
+ # Supported format keys for seasenselib.read() or internal readers
191
+ SUPPORTED_FILE_TYPES = {
192
+ "sbe-cnv",
193
+ "sbe-ascii", # newer seasenselib ASCII reader (with date normalisation)
194
+ "nortek-aqd",
195
+ "nortek-ascii",
196
+ "nortek-csv", # seasenselib reader (future; not yet implemented)
197
+ "nortek-csv-oa", # DEPRECATED: internal oceanarray CSV reader; use nortek-csv once seasenselib supports it
198
+ "rbr-rsk",
199
+ "rbr-matlab-legacy",
200
+ "rbr-dat",
201
+ "rbr-hex",
202
+ "rbr-hex-oa", # legacy internal reader — use rbr-hex instead
203
+ "sbe-hex",
204
+ "rdi-raw", # RDI ADCP raw binary (requires mhkit[dolfyn])
205
+ }
206
+
207
+ # Variables to remove for specific file types
208
+ VARS_TO_REMOVE = {
209
+ "sbe-cnv": ["potential_temperature", "julian_days_offset", "density"],
210
+ "sbe-asc": ["potential_temperature", "julian_days_offset", "density"],
211
+ "sbe-ascii": ["potential_temperature", "julian_days_offset", "density"],
212
+ }
213
+
214
+ # Coordinates to remove for specific file types
215
+ COORDS_TO_REMOVE = {
216
+ "sbe-cnv": ["depth", "latitude", "longitude"],
217
+ "sbe-asc": ["depth", "latitude", "longitude"],
218
+ "sbe-ascii": ["depth", "latitude", "longitude"],
219
+ }
220
+
221
+ def __init__(
222
+ self,
223
+ base_dir: Optional[str] = None,
224
+ *,
225
+ raw_dir: Optional[str] = None,
226
+ proc_dir: Optional[str] = None,
227
+ ) -> None:
228
+ """Initialize processor with base directory (legacy) or raw_dir + proc_dir (new).
229
+
230
+ Parameters
231
+ ----------
232
+ base_dir : str, optional
233
+ Legacy: cruise-level base directory (must contain a ``proc/`` subdirectory).
234
+ raw_dir : str, optional
235
+ Cruise-level raw data directory. Pipeline appends ``/{mooring}/`` internally.
236
+ Required together with ``proc_dir`` when not using ``base_dir``.
237
+ proc_dir : str, optional
238
+ Cruise-level processed output directory. Pipeline appends ``/{mooring}/``.
239
+ Required together with ``raw_dir`` when not using ``base_dir``.
240
+
241
+ """
242
+ if base_dir is not None:
243
+ self.base_dir: Optional[Path] = Path(base_dir)
244
+ self._raw_dir: Optional[Path] = None
245
+ self._proc_dir: Optional[Path] = None
246
+ self._legacy = True
247
+ else:
248
+ self.base_dir = None
249
+ self._raw_dir = Path(raw_dir) if raw_dir else None
250
+ self._proc_dir = Path(proc_dir) if proc_dir else None
251
+ self._legacy = False
252
+ self.log_file = None
253
+
254
+ def _setup_logging(self, mooring_name: str, output_path: Path) -> None:
255
+ """Set up logging for the processing run using global config."""
256
+ from .logger import setup_stage_logging
257
+
258
+ self.log_file = setup_stage_logging(mooring_name, "stage1", output_path)
259
+
260
+ # Redirect noisy library loggers to the log file only (not stdout).
261
+ for lib_logger_name in ("seasenselib", "pycnv"):
262
+ lib_log = logging.getLogger(lib_logger_name)
263
+ lib_log.setLevel(logging.INFO)
264
+ lib_log.propagate = False
265
+ if not any(
266
+ isinstance(h, logging.FileHandler)
267
+ and h.baseFilename == str(self.log_file)
268
+ for h in lib_log.handlers
269
+ ):
270
+ fh = logging.FileHandler(self.log_file, mode="a", encoding="utf-8")
271
+ fh.setFormatter(logging.Formatter("%(name)s %(levelname)s %(message)s"))
272
+ lib_log.addHandler(fh)
273
+
274
+ def _log_print(self, *args: Any, **kwargs: Any) -> None:
275
+ """Print to both console and log file."""
276
+ print(*args, **kwargs)
277
+ if self.log_file:
278
+ with open(self.log_file, "a") as f:
279
+ print(*args, **kwargs, file=f)
280
+
281
+ def _rel(self, path: Path) -> str:
282
+ """Return a short display path relative to base_dir, proc_dir, or raw_dir."""
283
+ roots = [r for r in (self.base_dir, self._proc_dir, self._raw_dir) if r]
284
+ for root in roots:
285
+ try:
286
+ return str(path.relative_to(root))
287
+ except ValueError:
288
+ continue
289
+ return path.name
290
+
291
+ def _load_mooring_config(self, config_path: Path) -> Dict[str, Any]:
292
+ """Load mooring configuration from YAML file."""
293
+ with open(config_path, "r") as f:
294
+ return yaml.safe_load(f)
295
+
296
+ def _normalize_sbe_ascii(self, file_path: Path) -> Path:
297
+ """Return path to file with SBE ASCII format issues fixed.
298
+
299
+ Fixes two issues produced by some SBE software versions:
300
+ - Dates in MM-DD-YYYY format instead of DD Mon YYYY
301
+ - Missing space after comma when a value is negative (e.g. '1.23,-0.01')
302
+ """
303
+ MONTHS = {f"{i:02d}": calendar.month_abbr[i] for i in range(1, 13)}
304
+ content = file_path.read_text()
305
+
306
+ needs_fix = re.search(r"\b\d{2}-\d{2}-\d{4}\b", content) or re.search(
307
+ r",(?! )", content
308
+ )
309
+ if not needs_fix:
310
+ return file_path
311
+
312
+ def _replace_date(m: re.Match) -> str:
313
+ mm, dd, yyyy = m.group(1), m.group(2), m.group(3)
314
+ return f"{dd} {MONTHS[mm]} {yyyy}"
315
+
316
+ normalized = re.sub(r"\b(\d{2})-(\d{2})-(\d{4})\b", _replace_date, content)
317
+ normalized = re.sub(r",(?! )", ", ", normalized)
318
+
319
+ tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".asc", delete=False)
320
+ tmp.write(normalized)
321
+ tmp.close()
322
+ return Path(tmp.name)
323
+
324
+ def _read_file(
325
+ self, file_type: str, file_path: str, header_path: Optional[str] = None
326
+ ) -> xr.Dataset:
327
+ """Read a raw instrument file and return a normalised xarray Dataset.
328
+
329
+ Routes to the deprecated internal reader for ``nortek-csv-oa`` (with a
330
+ deprecation warning), or to ``seasenselib.read`` for all other types.
331
+ For Nortek formats the coordinate system is parsed from *header_path* and
332
+ ``_normalize_nortek_variables`` is called. For ``sbe-hex``, the seasenselib
333
+ output variable ``press`` is renamed to ``pressure`` for consistency.
334
+
335
+ Parameters
336
+ ----------
337
+ file_type:
338
+ One of ``SUPPORTED_FILE_TYPES`` (e.g. ``"nortek-aqd"``, ``"sbe-cnv"``).
339
+ file_path:
340
+ Path to the raw instrument file.
341
+ header_path:
342
+ Path to the companion header file (required for Nortek formats to read
343
+ the coordinate system and transformation matrix).
344
+
345
+ Returns
346
+ -------
347
+ xarray.Dataset
348
+
349
+ """
350
+ if file_type not in self.SUPPORTED_FILE_TYPES:
351
+ raise ValueError(f"Unknown file type: {file_type}")
352
+
353
+ if file_type == "nortek-csv-oa":
354
+ import warnings
355
+
356
+ warnings.warn(
357
+ "file_type 'nortek-csv-oa' is deprecated. "
358
+ "Use 'nortek-csv' once seasenselib supports Nortek CSV export files.",
359
+ DeprecationWarning,
360
+ stacklevel=4,
361
+ )
362
+ from .readers import load_nortek_csv
363
+
364
+ ds = load_nortek_csv(file_path, header_file=header_path)
365
+ coord_system = ds.attrs.get("coordinate_system")
366
+ if coord_system is None:
367
+ print(
368
+ f"WARNING: nortek-csv-oa file {file_path} has no coordinate_system "
369
+ "attribute — coordinate system unknown (UNK); velocities not renamed."
370
+ )
371
+ coord_system = "UNK"
372
+ return self._normalize_nortek_variables(ds, coord_system=coord_system)
373
+
374
+ if file_type == "rbr-hex-oa":
375
+ from .read_rbr_hex import read_rbr_hex
376
+
377
+ ds = read_rbr_hex(file_path)
378
+ # Rename column-derived physical variables to canonical oceanarray names.
379
+ # read_rbr_hex uses the column header as the variable name (e.g. "temp");
380
+ # raw ADC count variables are always named "channel_N" and kept as-is.
381
+ rename_map = {}
382
+ for v in list(ds.data_vars):
383
+ if v.startswith("channel_"):
384
+ continue
385
+ if "temp" in v.lower():
386
+ rename_map[v] = "temperature"
387
+ if rename_map:
388
+ ds = ds.rename(rename_map)
389
+ return ds
390
+
391
+ kwargs = {}
392
+ if file_type in ("nortek-aqd", "nortek-ascii") and header_path:
393
+ kwargs["header_file"] = header_path
394
+
395
+ ds = seasenselib.read(
396
+ file_path,
397
+ file_format=file_type,
398
+ pipeline_skip_stages=["derivation"],
399
+ **kwargs,
400
+ )
401
+ if file_type in ("nortek-ascii", "nortek-aqd"):
402
+ if header_path:
403
+ coord_system = _parse_nortek_coord_system(Path(header_path))
404
+ else:
405
+ print(
406
+ f"WARNING: no header file for {file_path} — "
407
+ "coordinate system unknown (UNK); velocities not renamed."
408
+ )
409
+ coord_system = "UNK"
410
+ ds = self._normalize_nortek_variables(ds, coord_system=coord_system)
411
+ if (
412
+ file_type == "sbe-hex"
413
+ and "press" in ds.data_vars
414
+ and "pressure" not in ds.data_vars
415
+ ):
416
+ ds = ds.rename_vars({"press": "pressure"})
417
+ return ds
418
+
419
+ def _normalize_nortek_variables(
420
+ self, dataset: xr.Dataset, coord_system: str = "ENU"
421
+ ) -> xr.Dataset:
422
+ """Normalise variable names produced by the seasenselib nortek readers.
423
+
424
+ The Nortek .hdr file describes columns for both the main .dat file and the
425
+ diagnostics .dia file in the same "Data file format" block. seasenselib's
426
+ header reader captures all of them, causing the pipeline's smart-numbering
427
+ logic to produce phantom _1 / _2 / … duplicates for every variable (the _1
428
+ columns are always NaN because the .dat file only has the plain columns).
429
+
430
+ This method:
431
+ 1. Rescues pressure_1 → pressure and pressure_2 → depth (Nortek has two
432
+ pressure columns: dbar and m).
433
+ 2. Drops ALL _{n} phantom duplicates of known physics variables.
434
+ 3. Renames velocity variables to reflect the instrument's coordinate system:
435
+ BEAM → velocity_beam1 / velocity_beam2 / velocity_beam3
436
+ XYZ → x_velocity / y_velocity / z_velocity
437
+ ENU → east_velocity / north_velocity / up_velocity (no rename)
438
+ 4. Stores ``coordinate_system`` as a dataset attribute.
439
+ """
440
+ import numpy as np
441
+
442
+ # ── 1. Pressure: first occurrence is dbar, second is metres ──────────
443
+ if "pressure_1" in dataset.data_vars and "pressure" not in dataset.data_vars:
444
+ dataset = dataset.rename_vars({"pressure_1": "pressure"})
445
+
446
+ if "pressure_2" in dataset.data_vars:
447
+ p2 = dataset["pressure_2"]
448
+ if np.all(np.isnan(p2.values)):
449
+ dataset = dataset.drop_vars("pressure_2")
450
+ else:
451
+ attrs = dict(p2.attrs)
452
+ attrs.update({"units": "m", "long_name": "Depth"})
453
+ dataset = dataset.drop_vars("pressure_2")
454
+ dataset["depth"] = p2.assign_attrs(attrs)
455
+
456
+ # ── 2. Drop all _{n} phantom duplicates ───────────────────────────────
457
+ # seasenselib reads both .dat and .dia column definitions from the .hdr,
458
+ # producing phantom _1 duplicates for every variable (all NaN because
459
+ # the .dat file only has the plain columns). Match on any {base}_{digits}
460
+ # suffix — drop if the plain base exists OR if the column is entirely NaN.
461
+ import re as _re
462
+
463
+ _suffix_re = _re.compile(r"^(.+)_\d+$")
464
+ _to_drop = []
465
+ for _vname in list(dataset.data_vars):
466
+ _m = _suffix_re.match(_vname)
467
+ if not _m:
468
+ continue
469
+ _base = _m.group(1)
470
+ if _base in dataset.data_vars:
471
+ _to_drop.append(_vname)
472
+ elif (
473
+ dataset[_vname].dtype.kind == "f"
474
+ and dataset[_vname].size > 0
475
+ and bool(np.all(np.isnan(dataset[_vname].values)))
476
+ ):
477
+ _to_drop.append(_vname)
478
+ if _to_drop:
479
+ dataset = dataset.drop_vars(_to_drop)
480
+
481
+ # Drop redundant datetime component columns — the time coordinate suffices.
482
+ _dt_cols = ["Year", "Month", "Day", "Hour", "Minute", "Second"]
483
+ _dt_drop = [c for c in _dt_cols if c in dataset.data_vars]
484
+ if _dt_drop:
485
+ dataset = dataset.drop_vars(_dt_drop)
486
+
487
+ # ── 3. Velocity renaming based on coordinate system ───────────────────
488
+ cs = coord_system.strip().upper()
489
+ dataset.attrs["coordinate_system"] = cs
490
+
491
+ _vel_src = ["east_velocity", "north_velocity", "up_velocity"]
492
+ if cs == "BEAM":
493
+ _vel_dst = ["velocity_beam1", "velocity_beam2", "velocity_beam3"]
494
+ elif cs == "XYZ":
495
+ _vel_dst = ["x_velocity", "y_velocity", "z_velocity"]
496
+ elif cs == "UNK":
497
+ _vel_dst = _vel_src # leave as-is; coordinate_system=UNK signals unknown
498
+ else: # ENU — names are already correct
499
+ _vel_dst = _vel_src
500
+
501
+ if _vel_src != _vel_dst:
502
+ rename_map = {
503
+ src: dst
504
+ for src, dst in zip(_vel_src, _vel_dst)
505
+ if src in dataset.data_vars
506
+ }
507
+ if rename_map:
508
+ dataset = dataset.rename_vars(rename_map)
509
+
510
+ # ── 4. Amplitude renaming — always beam-based, not ENU ───────────────
511
+ _amp_rename = {
512
+ s: d
513
+ for s, d in [
514
+ ("east_amplitude", "amplitude_beam1"),
515
+ ("north_amplitude", "amplitude_beam2"),
516
+ ("up_amplitude", "amplitude_beam3"),
517
+ ]
518
+ if s in dataset.data_vars
519
+ }
520
+ if _amp_rename:
521
+ dataset = dataset.rename_vars(_amp_rename)
522
+ for _aname in _amp_rename.values():
523
+ if _aname in dataset.data_vars:
524
+ dataset[_aname].attrs.setdefault("units", "counts")
525
+ dataset[_aname].attrs.setdefault(
526
+ "long_name", f"Acoustic signal amplitude {_aname[-1]}"
527
+ )
528
+
529
+ # ── 5. Normalize remaining Nortek column names to snake_case ─────────
530
+ # seasenselib normalises some names (battery_voltage, speed_of_sound, …)
531
+ # but leaves others verbatim from the .hdr column headers.
532
+ _NORTEK_RENAME = {
533
+ "Heading": "heading",
534
+ "Pitch": "pitch",
535
+ "Roll": "roll",
536
+ "Direction": "direction",
537
+ "Speed": "speed",
538
+ "Error code": "error_code",
539
+ "Status code": "status_code",
540
+ # "Analog input 1" and "Analog input 2" come through as analog_input_1/2
541
+ # from seasenselib already — no rename needed here.
542
+ # seasenselib maps this to speed_of_sound but also keeps the original
543
+ "Soundspeed used": "speed_of_sound",
544
+ }
545
+ _norm_rename: dict = {}
546
+ _norm_drop: list = []
547
+ for _old, _new in _NORTEK_RENAME.items():
548
+ if _old not in dataset.data_vars:
549
+ continue
550
+ if _new in dataset.data_vars:
551
+ _norm_drop.append(_old) # canonical already exists; drop the duplicate
552
+ else:
553
+ _norm_rename[_old] = _new
554
+ if _norm_rename:
555
+ dataset = dataset.rename_vars(_norm_rename)
556
+ if _norm_drop:
557
+ dataset = dataset.drop_vars(_norm_drop)
558
+
559
+ # ── 6. CSV format: demote constant time-series to attrs; drop scaffolding ──
560
+ # The nortek-csv-oa reader stores deployment-config columns as full time series
561
+ # even though they never change. Promote them to global attrs and drop.
562
+ _CSV_TO_ATTR = {
563
+ "coordinatesystem": "coordinate_system",
564
+ "blanking": "nortek_blanking_m",
565
+ "cellsize": "nortek_cellsize_m",
566
+ "nbeams": "nortek_nbeams",
567
+ "ncells": "nortek_ncells",
568
+ }
569
+ for _cv, _ca in _CSV_TO_ATTR.items():
570
+ if _cv not in dataset.data_vars:
571
+ continue
572
+ _arr = dataset[_cv].values
573
+ if _arr.dtype.kind in ("U", "S", "O"):
574
+ _unique = list({str(x) for x in _arr.flat})
575
+ if len(_unique) == 1:
576
+ dataset.attrs.setdefault(_ca, _unique[0])
577
+ dataset = dataset.drop_vars(_cv)
578
+ else:
579
+ try:
580
+ _farr = _arr.astype(float)
581
+ _uv = np.unique(_farr[np.isfinite(_farr)])
582
+ if len(_uv) == 1:
583
+ dataset.attrs.setdefault(_ca, float(_uv[0]))
584
+ dataset = dataset.drop_vars(_cv)
585
+ except Exception:
586
+ pass
587
+
588
+ # Drop pure scaffolding columns with no scientific value
589
+ _CSV_DROP = [
590
+ "serialnumber",
591
+ "statuspreviouswakeupstate",
592
+ "idx",
593
+ "ensemblecounter",
594
+ ]
595
+ _csv_drop = [v for v in _CSV_DROP if v in dataset.data_vars]
596
+ if _csv_drop:
597
+ dataset = dataset.drop_vars(_csv_drop)
598
+
599
+ # ── 7. Consolidate per-beam amplitude and correlation into (time, beam) ─
600
+ # Combine amplitude_beam1/2/3 → amplitude(time, beam) and
601
+ # correlation_beam1/2/3 → correlation(time, beam) to match the 3-D
602
+ # layout used by the RDI ADCP reader. Only applied when all three beam
603
+ # variables are present and 1-D (shape guard against future 2-D readers).
604
+ for _basename, _units, _long_name in [
605
+ ("amplitude", "counts", "Acoustic signal amplitude"),
606
+ ("correlation", "%", "Acoustic signal correlation"),
607
+ ]:
608
+ _bvars = [f"{_basename}_beam{i}" for i in range(1, 4)]
609
+ if not all(v in dataset.data_vars for v in _bvars):
610
+ continue
611
+ if any(dataset[v].ndim != 1 for v in _bvars):
612
+ continue
613
+ _tdim = dataset[_bvars[0]].dims[0]
614
+ _arr = np.stack([dataset[v].values for v in _bvars], axis=-1).astype(
615
+ np.float32
616
+ )
617
+ dataset[_basename] = xr.DataArray(
618
+ _arr,
619
+ dims=(_tdim, "beam"),
620
+ coords={"beam": np.array([1, 2, 3], dtype=np.int8)},
621
+ attrs={"units": _units, "long_name": _long_name},
622
+ )
623
+ dataset = dataset.drop_vars(_bvars)
624
+
625
+ return dataset
626
+
627
+ def _add_sbe_ascii_sensor_vars(
628
+ self, dataset: xr.Dataset, file_path: Path, instrument_config: Dict[str, Any]
629
+ ) -> xr.Dataset:
630
+ """Parse calibration coefficients from an SBE ASCII header and add SENSOR_* vars.
631
+
632
+ seasenselib's sbe-ascii reader discards the ``* TA0 = …`` header lines.
633
+ This method re-reads the raw file, extracts the same coefficients the CNV
634
+ reader would produce, and creates matching SENSOR_* scalar variables so the
635
+ report calibration table is populated for ASCII-format instruments.
636
+ """
637
+ import xarray as xr
638
+ import numpy as np
639
+
640
+ try:
641
+ text = file_path.read_text(errors="ignore")
642
+ except Exception:
643
+ return dataset
644
+
645
+ instr_serial = re.sub(r"[^\w]", "", str(instrument_config.get("serial", "")))
646
+
647
+ # Matches the start of a new calibration section or end-of-header markers
648
+ _section_start = re.compile(
649
+ r"^(temperature|conductivity|pressure|rtc|S>|\*END\*)", re.IGNORECASE
650
+ )
651
+
652
+ def _extract_block(header: str, start_pat: str) -> Dict[str, str]:
653
+ """Return coefficient dict from a labelled block of ``* KEY = VAL`` lines."""
654
+ coeffs: Dict[str, str] = {}
655
+ in_block = False
656
+ for line in header.splitlines():
657
+ stripped = line.lstrip("* ").strip()
658
+ if re.match(start_pat, stripped, re.IGNORECASE):
659
+ in_block = True
660
+ continue
661
+ if in_block:
662
+ m = re.match(r"([A-Za-z_]\w*)\s*=\s*(.+)", stripped)
663
+ if m:
664
+ coeffs[m.group(1)] = m.group(2).strip()
665
+ elif stripped and _section_start.match(stripped):
666
+ break # new section started
667
+ return coeffs
668
+
669
+ def _extract_date(header: str, pat: str) -> str:
670
+ m = re.search(pat, header, re.IGNORECASE)
671
+ if m:
672
+ raw = m.group(1).strip()
673
+ # Convert dd-Mon-yy → ISO
674
+ try:
675
+ import datetime
676
+
677
+ return datetime.datetime.strptime(raw, "%d-%b-%y").strftime(
678
+ "%Y-%m-%d"
679
+ )
680
+ except Exception:
681
+ return raw
682
+ return "—"
683
+
684
+ def _coeff_str(d: Dict[str, str]) -> str:
685
+ return ", ".join(f"{k}={v}" for k, v in d.items())
686
+
687
+ def _make_sensor_var(name: str, attrs: Dict[str, Any]) -> xr.Variable: # noqa: ARG001
688
+ return xr.Variable((), np.array(b"", dtype="|S1"), attrs=attrs)
689
+
690
+ # Temperature
691
+ t_coeffs = _extract_block(text, r"temperature:")
692
+ t_date = _extract_date(text, r"\* temperature:\s+(.+)")
693
+ if t_coeffs:
694
+ coeff_str = _coeff_str(t_coeffs)
695
+ dataset[f"SENSOR_TEMP_{instr_serial}"] = _make_sensor_var(
696
+ f"SENSOR_TEMP_{instr_serial}",
697
+ {
698
+ "long_name": "Sea-Bird SBE temperature sensor metadata",
699
+ "sensor_type": "TEMPERATURE",
700
+ "sensor_serial_number": instr_serial,
701
+ "sensor_model": "Sea-Bird SBE temperature sensor",
702
+ "sensor_calibration_date": t_date,
703
+ "TEMPERATURE_calibration_coefficients": coeff_str,
704
+ "cf_role": "sensor_id",
705
+ "coverage_content_type": "auxiliaryInformation",
706
+ },
707
+ )
708
+
709
+ # Conductivity
710
+ c_coeffs = _extract_block(text, r"conductivity:")
711
+ c_date = _extract_date(text, r"\* conductivity:\s+(.+)")
712
+ if c_coeffs:
713
+ coeff_str = _coeff_str(c_coeffs)
714
+ dataset[f"SENSOR_CNDC_{instr_serial}"] = _make_sensor_var(
715
+ f"SENSOR_CNDC_{instr_serial}",
716
+ {
717
+ "long_name": "Sea-Bird SBE conductivity sensor metadata",
718
+ "sensor_type": "CONDUCTIVITY",
719
+ "sensor_serial_number": instr_serial,
720
+ "sensor_model": "Sea-Bird SBE conductivity sensor",
721
+ "sensor_calibration_date": c_date,
722
+ "CONDUCTIVITY_calibration_coefficients": coeff_str,
723
+ "cf_role": "sensor_id",
724
+ "coverage_content_type": "auxiliaryInformation",
725
+ },
726
+ )
727
+
728
+ # Pressure (line like: * pressure S/N 2385215, range = 2900 psia: 10-nov-08)
729
+ p_coeffs = _extract_block(text, r"pressure\s+S/N")
730
+ p_date_match = re.search(r"\* pressure\s+S/N[^:]+:\s+(.+)", text, re.IGNORECASE)
731
+ p_date = "—"
732
+ p_serial = instr_serial
733
+ if p_date_match:
734
+ raw = p_date_match.group(1).strip()
735
+ try:
736
+ import datetime
737
+
738
+ p_date = datetime.datetime.strptime(raw, "%d-%b-%y").strftime(
739
+ "%Y-%m-%d"
740
+ )
741
+ except Exception:
742
+ p_date = raw
743
+ sn_match = re.search(r"pressure\s+S/N\s+(\d+)", text, re.IGNORECASE)
744
+ if sn_match:
745
+ p_serial = sn_match.group(1)
746
+ if p_coeffs:
747
+ coeff_str = _coeff_str(p_coeffs)
748
+ dataset[f"SENSOR_PRES_{instr_serial}"] = _make_sensor_var(
749
+ f"SENSOR_PRES_{instr_serial}",
750
+ {
751
+ "long_name": "Sea-Bird pressure sensor metadata",
752
+ "sensor_type": "PRESSURE",
753
+ "sensor_serial_number": p_serial,
754
+ "sensor_model": "Sea-Bird pressure sensor",
755
+ "sensor_calibration_date": p_date,
756
+ "PRESSURE_calibration_coefficients": coeff_str,
757
+ "cf_role": "sensor_id",
758
+ "coverage_content_type": "auxiliaryInformation",
759
+ },
760
+ )
761
+
762
+ return dataset
763
+
764
+ def _add_sbe_hex_sensor_vars(
765
+ self, dataset: xr.Dataset, file_path: Path, instrument_config: Dict[str, Any]
766
+ ) -> xr.Dataset:
767
+ """Parse calibration coefficients from an SBE hex header and add SENSOR_* vars.
768
+
769
+ Uses seasenselib's ``parse_hex_header_sensors`` which extracts calibration XML
770
+ embedded in the hex file header. Creates the same SENSOR_* scalar variables as
771
+ ``_add_sbe_ascii_sensor_vars`` so the report calibration table is populated.
772
+ """
773
+ import numpy as np
774
+ import xarray as xr
775
+
776
+ try:
777
+ from seasenselib.readers.sbe_hex_reader import parse_hex_header_sensors
778
+
779
+ sensor_info = parse_hex_header_sensors(file_path)
780
+ except Exception: # noqa: BLE001 — optional; don't break stage1 if unavailable
781
+ return dataset
782
+
783
+ cal_coeffs = sensor_info.get("calibration_coefficients", {})
784
+ if not cal_coeffs:
785
+ return dataset
786
+
787
+ instr_serial = re.sub(r"[^\w]", "", str(instrument_config.get("serial", "")))
788
+
789
+ def _coeff_str(coeffs: Dict[str, Any]) -> str:
790
+ return ", ".join(
791
+ f"{k}={v}"
792
+ for k, v in coeffs.items()
793
+ if k not in ("serialnum", "caldate")
794
+ )
795
+
796
+ def _make_sensor_var(attrs: Dict[str, Any]) -> "xr.Variable":
797
+ return xr.Variable((), np.array(b"", dtype="|S1"), attrs=attrs)
798
+
799
+ def _iso_date(raw: str) -> str:
800
+ import datetime
801
+
802
+ for fmt in ("%d-%b-%y", "%d-%b-%Y"):
803
+ try:
804
+ return datetime.datetime.strptime(raw, fmt).strftime("%Y-%m-%d")
805
+ except ValueError:
806
+ pass
807
+ return raw
808
+
809
+ # Temperature
810
+ if "temperature" in cal_coeffs:
811
+ tc = cal_coeffs["temperature"]["coefficients"]
812
+ sn = tc.get("serialnum", instr_serial)
813
+ cal_date = _iso_date(tc.get("caldate", "—"))
814
+ dataset[f"SENSOR_TEMP_{instr_serial}"] = _make_sensor_var(
815
+ {
816
+ "long_name": "Sea-Bird SBE temperature sensor metadata",
817
+ "sensor_type": "TEMPERATURE",
818
+ "sensor_serial_number": sn,
819
+ "sensor_model": "Sea-Bird SBE temperature sensor",
820
+ "sensor_calibration_date": cal_date,
821
+ "TEMPERATURE_calibration_coefficients": _coeff_str(tc),
822
+ "cf_role": "sensor_id",
823
+ "coverage_content_type": "auxiliaryInformation",
824
+ }
825
+ )
826
+
827
+ # Conductivity
828
+ if "conductivity" in cal_coeffs:
829
+ cc = cal_coeffs["conductivity"]["coefficients"]
830
+ sn = cc.get("serialnum", instr_serial)
831
+ cal_date = _iso_date(cc.get("caldate", "—"))
832
+ dataset[f"SENSOR_CNDC_{instr_serial}"] = _make_sensor_var(
833
+ {
834
+ "long_name": "Sea-Bird SBE conductivity sensor metadata",
835
+ "sensor_type": "CONDUCTIVITY",
836
+ "sensor_serial_number": sn,
837
+ "sensor_model": "Sea-Bird SBE conductivity sensor",
838
+ "sensor_calibration_date": cal_date,
839
+ "CONDUCTIVITY_calibration_coefficients": _coeff_str(cc),
840
+ "cf_role": "sensor_id",
841
+ "coverage_content_type": "auxiliaryInformation",
842
+ }
843
+ )
844
+
845
+ # Pressure
846
+ if "pressure" in cal_coeffs:
847
+ pc = cal_coeffs["pressure"]["coefficients"]
848
+ sn = pc.get("serialnum", instr_serial)
849
+ cal_date = _iso_date(pc.get("caldate", "—"))
850
+ dataset[f"SENSOR_PRES_{instr_serial}"] = _make_sensor_var(
851
+ {
852
+ "long_name": "Sea-Bird pressure sensor metadata",
853
+ "sensor_type": "PRESSURE",
854
+ "sensor_serial_number": sn,
855
+ "sensor_model": "Sea-Bird pressure sensor",
856
+ "sensor_calibration_date": cal_date,
857
+ "PRESSURE_calibration_coefficients": _coeff_str(pc),
858
+ "cf_role": "sensor_id",
859
+ "coverage_content_type": "auxiliaryInformation",
860
+ }
861
+ )
862
+
863
+ return dataset
864
+
865
+ def _normalize_conductivity(self, dataset: xr.Dataset) -> xr.Dataset:
866
+ """Convert conductivity to mS/cm and rename to 'conductivity' if needed."""
867
+ if "cond0S/m" in dataset:
868
+ # S/m → mS/cm: multiply by 10
869
+ data = dataset["cond0S/m"] * 10.0
870
+ data.attrs = dataset["cond0S/m"].attrs
871
+ data.attrs["units"] = "mS/cm"
872
+ dataset = dataset.drop_vars("cond0S/m")
873
+ dataset["conductivity"] = data
874
+ elif "cond0mS/cm" in dataset:
875
+ dataset = dataset.rename({"cond0mS/cm": "conductivity"})
876
+ return dataset
877
+
878
+ # Known SBE CNV variable names containing '/' and their CF-compatible replacements.
879
+ # Keys that are already handled by _normalize_conductivity are excluded.
880
+ _CNV_SLASH_RENAMES: Dict[str, str] = {
881
+ "sbeopoxMm/Kg": "dissolved_oxygen", # SBE63 optode O2 in mmol/kg
882
+ "sbeox0Mm/Kg": "dissolved_oxygen", # SBE43 electrochemical O2 mmol/kg
883
+ "sbeox0ML/L": "dissolved_oxygen", # SBE43 electrochemical O2 mL/L
884
+ "sbeox1Mm/Kg": "dissolved_oxygen_2",
885
+ "sbeox1ML/L": "dissolved_oxygen_2",
886
+ "oxsatMm/Kg": "oxygen_saturation",
887
+ "oxsatML/L": "oxygen_saturation",
888
+ }
889
+
890
+ def _sanitize_slash_vars(self, dataset: xr.Dataset) -> xr.Dataset:
891
+ """Rename known SBE slash-named variables and warn about any remaining ones."""
892
+ rename_map: Dict[str, str] = {}
893
+ for old, new in self._CNV_SLASH_RENAMES.items():
894
+ if old in dataset.data_vars:
895
+ # Avoid clobbering a variable that already exists with the target name
896
+ target = new
897
+ if target in dataset.data_vars:
898
+ target = f"{new}_raw"
899
+ rename_map[old] = target
900
+
901
+ if rename_map:
902
+ dataset = dataset.rename_vars(rename_map)
903
+
904
+ # Fallback: any remaining vars with '/' are not safe for NetCDF — replace with '_per_'
905
+ remaining = [v for v in dataset.data_vars if "/" in v]
906
+ if remaining:
907
+ fallback = {v: v.replace("/", "_per_") for v in remaining}
908
+ self._log_print(
909
+ f"WARNING: Unrecognised variable name(s) containing '/': "
910
+ f"{', '.join(remaining)} — sanitising to: "
911
+ f"{', '.join(fallback.values())}"
912
+ )
913
+ dataset = dataset.rename_vars(fallback)
914
+
915
+ return dataset
916
+
917
+ def _normalize_sensor_var_names(
918
+ self, dataset: xr.Dataset, instrument_config: Dict[str, Any]
919
+ ) -> xr.Dataset:
920
+ """Rename SENSOR_PRES_{sensor_serial} to SENSOR_PRES_{instrument_serial}.
921
+
922
+ seasenselib names the pressure sensor variable after the pressure sensor's
923
+ own serial number (e.g. SENSOR_PRES_2075203), which differs from the
924
+ instrument serial (e.g. 7507). Rename so all three SENSOR_* variables
925
+ for one instrument share the same serial suffix.
926
+ """
927
+ instr_serial = re.sub(r"[^\w]", "", str(instrument_config.get("serial", "")))
928
+ if not instr_serial:
929
+ return dataset
930
+ expected = f"SENSOR_PRES_{instr_serial}"
931
+ to_rename = {
932
+ v: expected
933
+ for v in list(dataset.data_vars)
934
+ if v.startswith("SENSOR_PRES_") and v != expected
935
+ }
936
+ if to_rename:
937
+ dataset = dataset.rename_vars(to_rename)
938
+ return dataset
939
+
940
+ def _clean_dataset_variables(
941
+ self, dataset: xr.Dataset, file_type: str
942
+ ) -> xr.Dataset:
943
+ """Remove unwanted variables and coordinates for specific file types.
944
+
945
+ Removals are driven by ``VARS_TO_REMOVE`` and ``COORDS_TO_REMOVE``, which
946
+ are only populated for SBE file types (``sbe-cnv``, ``sbe-ascii``).
947
+ For Nortek, RBR, and RDI ADCP file types this method is a no-op.
948
+ """
949
+ # Remove variables
950
+ vars_to_remove = self.VARS_TO_REMOVE.get(file_type, [])
951
+ for var in vars_to_remove:
952
+ if var in dataset.variables:
953
+ self._log_print(f"Removing variable: {var}")
954
+ dataset = dataset.drop_vars(var)
955
+
956
+ # Remove coordinates
957
+ coords_to_remove = self.COORDS_TO_REMOVE.get(file_type, [])
958
+ for coord in coords_to_remove:
959
+ if coord in dataset.coords:
960
+ self._log_print(f"Removing coordinate: {coord}")
961
+ dataset = dataset.drop_vars(coord)
962
+
963
+ return dataset
964
+
965
+ def _add_global_attributes(
966
+ self, dataset: xr.Dataset, yaml_data: Dict[str, Any]
967
+ ) -> xr.Dataset:
968
+ """Add global attributes from YAML configuration."""
969
+ global_attrs = {
970
+ "mooring_name": yaml_data["name"],
971
+ "waterdepth": yaml_data["waterdepth"],
972
+ "longitude": yaml_data.get("longitude", 0.0),
973
+ "latitude": yaml_data.get("latitude", 0.0),
974
+ "deployment_latitude": yaml_data.get("deployment_latitude", "00 00.000 N"),
975
+ "deployment_longitude": yaml_data.get(
976
+ "deployment_longitude", "000 00.000 W"
977
+ ),
978
+ "deployment_time": yaml_data.get("deployment_time", "YYYY-mm-ddTHH:MM:ss"),
979
+ "seabed_latitude": yaml_data.get("seabed_latitude", "00 00.000 N"),
980
+ "seabed_longitude": yaml_data.get("seabed_longitude", "000 00.000 W"),
981
+ "recovery_time": yaml_data.get("recovery_time", "YYYY-mm-ddTHH:MM:ss"),
982
+ }
983
+
984
+ for attr, value in global_attrs.items():
985
+ dataset.attrs[attr] = value
986
+
987
+ return dataset
988
+
989
+ def _add_instrument_metadata(
990
+ self,
991
+ dataset: xr.Dataset,
992
+ instrument_config: Dict[str, Any],
993
+ yaml_data: Dict[str, Any],
994
+ ) -> xr.Dataset:
995
+ """Add instrument-specific metadata to dataset."""
996
+ dataset["serial_number"] = instrument_config.get("serial", 0)
997
+ # Support both 'depth' (absolute) and 'hab' (height above bottom)
998
+ if "depth" in instrument_config:
999
+ depth = instrument_config["depth"]
1000
+ elif "hab" in instrument_config:
1001
+ waterdepth = yaml_data.get("waterdepth") or 0
1002
+ depth = waterdepth - instrument_config["hab"]
1003
+ else:
1004
+ depth = 0
1005
+ dataset["InstrDepth"] = depth
1006
+ dataset["instrument"] = instrument_config.get("instrument", "Unknown")
1007
+ dataset["clock_offset"] = instrument_config.get("clock_offset", 0)
1008
+ dataset["clock_offset"].attrs["units"] = "s"
1009
+ dataset["start_time"] = instrument_config.get(
1010
+ "start_time", dataset.attrs["deployment_time"]
1011
+ )
1012
+ dataset["end_time"] = instrument_config.get(
1013
+ "end_time", dataset.attrs["recovery_time"]
1014
+ )
1015
+
1016
+ return dataset
1017
+
1018
+ def _normalize_rdi_raw(
1019
+ self,
1020
+ dataset: xr.Dataset,
1021
+ instrument_config: Dict[str, Any],
1022
+ ) -> xr.Dataset:
1023
+ """Normalise the dataset produced by the ``rdi-raw`` seasenselib reader.
1024
+
1025
+ The rdi-raw reader (via mhkit/dolfyn) returns:
1026
+
1027
+ - ``vel(dir, range, time)`` with ``dir=['E','N','U','err']`` — already in
1028
+ Earth frame because ``coord_sys=earth`` was set in the RDI configuration.
1029
+ However, ``magnetic_var_deg=0.0`` is common, meaning **no magnetic
1030
+ declination was applied**; stage3 must apply the ppigrf correction.
1031
+ - ``amp``, ``corr``, ``prcnt_gd`` with dims ``(beam, range, time)``.
1032
+ - ``beam2inst_orientmat(x1, x2)`` — static beam→instrument rotation matrix.
1033
+
1034
+ Steps applied:
1035
+
1036
+ 1. Rename the ``range`` spatial dimension to ``ADCP_BIN_DIM`` (``"N_BINS"``).
1037
+ The ``range`` coordinate (metres above transducer) is preserved under the
1038
+ new dimension name.
1039
+ 2. Split ``vel`` into ``east_velocity``, ``north_velocity``, ``up_velocity``,
1040
+ and ``error_velocity``, each with dims ``(time, N_BINS)``. Drops ``vel``
1041
+ and the ``dir`` coordinate.
1042
+ 3. Transpose quality variables to ``(time, N_BINS, beam)``.
1043
+ 4. Flatten ``beam2inst_orientmat`` to global attrs ``beam2inst_M{i}{j}``
1044
+ and drop the variable and its ``x1``/``x2`` dimension coordinates.
1045
+ 5. Transpose ``orientmat`` to ``(time, earth, inst)`` (time-first).
1046
+ 6. Extract instrument metadata from the ``raw_metadata`` JSON blob:
1047
+ cell size, blanking distance, beam angle, pings per ensemble, and
1048
+ ``instrument_magnetic_variation_deg``.
1049
+ 7. Set ``coordinate_system = "ENU"``.
1050
+ 8. Store ``orientation_instrument`` from the raw file and
1051
+ ``orientation_yaml`` from the YAML ``orientation`` key (if present);
1052
+ emit a WARNING if they disagree — a correction will be needed in stage3.
1053
+
1054
+ This method is faithful to the raw data: no values are modified or removed.
1055
+ Pressure overflow values and other sensor artefacts are preserved as-is;
1056
+ flagging is deferred to stage3.
1057
+
1058
+ Args:
1059
+ dataset: Dataset as returned by ``seasenselib.read(..., file_type="rdi-raw")``.
1060
+ instrument_config: Per-instrument YAML configuration dict.
1061
+
1062
+ Returns:
1063
+ Normalised dataset ready for metadata attachment and NetCDF writing.
1064
+
1065
+ """
1066
+ import json
1067
+
1068
+ # 1. Rename the spatial bin dimension: range → N_BINS
1069
+ if "range" in dataset.dims:
1070
+ dataset = dataset.rename_dims({"range": ADCP_BIN_DIM})
1071
+
1072
+ # 2. Split vel(dir, N_BINS, time) into named components (time, N_BINS)
1073
+ _DIR_MAP = {
1074
+ "E": (
1075
+ "east_velocity",
1076
+ "East velocity (magnetic ENU; declination correction not applied)",
1077
+ ),
1078
+ "N": (
1079
+ "north_velocity",
1080
+ "North velocity (magnetic ENU; declination correction not applied)",
1081
+ ),
1082
+ "U": ("up_velocity", "Up velocity"),
1083
+ "err": ("error_velocity", "Error velocity"),
1084
+ }
1085
+ _enu_extracted: list[str] = []
1086
+ if "vel" in dataset.data_vars:
1087
+ vel = dataset["vel"]
1088
+ for dir_label, (var_name, long_name) in _DIR_MAP.items():
1089
+ if dir_label in vel.dir.values:
1090
+ component = vel.sel(dir=dir_label).transpose("time", ADCP_BIN_DIM)
1091
+ component.attrs.update({"units": "m s-1", "long_name": long_name})
1092
+ dataset[var_name] = component
1093
+ _enu_extracted.append(var_name)
1094
+ dataset = dataset.drop_vars("vel")
1095
+ if "dir" in dataset.coords:
1096
+ dataset = dataset.drop_vars("dir")
1097
+
1098
+ # 3. Rename dolfyn quality variable names to oceanarray standard names,
1099
+ # transpose to (time, N_BINS, beam), and set long_name.
1100
+ _QUAL_RENAME: Dict[str, Tuple[str, str]] = {
1101
+ "amp": ("amplitude", "Acoustic signal amplitude"),
1102
+ "corr": ("correlation", "Acoustic signal correlation"),
1103
+ "prcnt_gd": ("percent_good", "Percent good"),
1104
+ }
1105
+ for dolfyn_name, (std_name, long_name) in _QUAL_RENAME.items():
1106
+ if dolfyn_name in dataset.data_vars:
1107
+ da = dataset[dolfyn_name].transpose("time", ADCP_BIN_DIM, "beam")
1108
+ da.attrs.setdefault("long_name", long_name)
1109
+ dataset[std_name] = da
1110
+ dataset = dataset.drop_vars(dolfyn_name)
1111
+
1112
+ # Rename c_sound → speed_of_sound (matches Aquadopp convention)
1113
+ if "c_sound" in dataset.data_vars:
1114
+ dataset = dataset.rename_vars({"c_sound": "speed_of_sound"})
1115
+
1116
+ # 4. Store beam2inst rotation matrix as global attrs; drop variable + dim coords
1117
+ if "beam2inst_orientmat" in dataset.data_vars:
1118
+ mat = dataset["beam2inst_orientmat"].values
1119
+ for _i in range(mat.shape[0]):
1120
+ for _j in range(mat.shape[1]):
1121
+ dataset.attrs[f"beam2inst_M{_i + 1}{_j + 1}"] = float(mat[_i, _j])
1122
+ dataset = dataset.drop_vars("beam2inst_orientmat")
1123
+ for _dim_coord in ("x1", "x2"):
1124
+ if _dim_coord in dataset.coords:
1125
+ dataset = dataset.drop_vars(_dim_coord)
1126
+
1127
+ # 5. Transpose orientmat to (time, earth, inst)
1128
+ if "orientmat" in dataset.data_vars:
1129
+ dataset["orientmat"] = dataset["orientmat"].transpose(
1130
+ "time", "earth", "inst"
1131
+ )
1132
+
1133
+ # 6. Extract instrument metadata from the raw_metadata JSON blob
1134
+ try:
1135
+ _raw_meta = json.loads(dataset.attrs.get("raw_metadata", "{}"))
1136
+ _ga = (
1137
+ _raw_meta.get("blocks", {})
1138
+ .get("other", {})
1139
+ .get("global_attributes", {})
1140
+ )
1141
+ for _raw_key, _attr_name in [
1142
+ ("cell_size", "cell_size_m"),
1143
+ ("blank_dist", "blanking_distance_m"),
1144
+ ("beam_angle", "beam_angle_deg"),
1145
+ ("n_cells", "n_cells"),
1146
+ ("pings_per_ensemble", "n_pings_per_ensemble"),
1147
+ ("carrier_freq", "carrier_freq_khz"),
1148
+ ]:
1149
+ if _raw_key in _ga:
1150
+ dataset.attrs[_attr_name] = _ga[_raw_key]
1151
+ _mag_var = _ga.get("magnetic_var_deg")
1152
+ if _mag_var is not None:
1153
+ dataset.attrs["instrument_magnetic_variation_deg"] = float(_mag_var)
1154
+ _orient_raw = _ga.get("orientation")
1155
+ if _orient_raw:
1156
+ dataset.attrs["orientation_instrument"] = str(_orient_raw)
1157
+ except Exception: # noqa: BLE001 — raw_metadata absent or malformed; proceed without
1158
+ pass
1159
+
1160
+ # 7. Set coordinate system — only mark ENU if at least one ENU component was
1161
+ # extracted. If the RDI was configured in beam or instrument coords dolfyn
1162
+ # uses different dir labels and _DIR_MAP matches nothing; calling the result
1163
+ # "ENU" would be wrong and would confuse stage3.
1164
+ if _enu_extracted:
1165
+ dataset.attrs["coordinate_system"] = "ENU"
1166
+ else:
1167
+ dataset.attrs["coordinate_system"] = "UNK"
1168
+ self._log_print(
1169
+ f" WARNING: _normalize_rdi_raw extracted no ENU velocity components "
1170
+ f"for {instrument_config.get('serial', 'UNKNOWN')}. "
1171
+ f"The RDI file may use beam or instrument coordinates rather than "
1172
+ f"earth frame. 'coordinate_system' set to 'UNK'; stage3 will not "
1173
+ f"apply beam→ENU rotation. Re-process after converting to earth frame."
1174
+ )
1175
+
1176
+ # 8. Store YAML orientation and warn on mismatch with raw file
1177
+ orientation_yaml = instrument_config.get("orientation")
1178
+ if orientation_yaml:
1179
+ dataset.attrs["orientation_yaml"] = str(orientation_yaml)
1180
+ _orient_instrument = dataset.attrs.get("orientation_instrument", "")
1181
+ if orientation_yaml and _orient_instrument:
1182
+ if orientation_yaml.lower() != _orient_instrument.lower():
1183
+ self._log_print(
1184
+ f"WARNING: ADCP orientation mismatch — raw file says "
1185
+ f"'{_orient_instrument}' but YAML says '{orientation_yaml}'. "
1186
+ f"Velocity signs and beam geometry may be incorrect. "
1187
+ f"A correction must be applied in stage3."
1188
+ )
1189
+
1190
+ return dataset
1191
+
1192
+ def _find_file_tag(
1193
+ self, filename: str, tags: Tuple[str, ...] = ("_000", "_001", "_002")
1194
+ ) -> str:
1195
+ """Find known tag in filename."""
1196
+ filename = str(filename)
1197
+ for tag in tags:
1198
+ if tag in filename:
1199
+ return tag
1200
+ return ""
1201
+
1202
+ @staticmethod
1203
+ def _safe_serial(serial: str) -> str:
1204
+ """Strip characters that are illegal in filenames (e.g. '*' used as a YAML marker)."""
1205
+ import re
1206
+
1207
+ return re.sub(r"[^\w\-]", "", str(serial))
1208
+
1209
+ def _generate_output_filename(
1210
+ self, mooring_name: str, instrument_config: Dict[str, Any], output_dir: Path
1211
+ ) -> Path:
1212
+ """Generate output filename for processed data."""
1213
+ file_type = instrument_config.get("file_type", "")
1214
+ filename = instrument_config.get("filename", "")
1215
+ serial = self._safe_serial(instrument_config.get("serial", 0))
1216
+
1217
+ # Handle special tag for ADCP matlab files
1218
+ tag = ""
1219
+ if file_type == "adcp-matlab":
1220
+ tag = self._find_file_tag(filename)
1221
+
1222
+ output_filename = f"{mooring_name}_{serial}{tag}_stage1.nc"
1223
+ return output_dir / output_filename
1224
+
1225
+ def _get_netcdf_writer_params(self) -> Dict[str, Any]:
1226
+ """Get standard parameters for NetCDF writer."""
1227
+ return {
1228
+ "optimize": True,
1229
+ "drop_derived": False,
1230
+ "uint8_vars": [
1231
+ "correlation_magnitude",
1232
+ "echo_intensity",
1233
+ "status",
1234
+ "percent_good",
1235
+ "bt_correlation",
1236
+ "bt_amplitude",
1237
+ "bt_percent_good",
1238
+ ],
1239
+ "float32_vars": [
1240
+ "eastward_velocity",
1241
+ "northward_velocity",
1242
+ "upward_velocity",
1243
+ "temperature",
1244
+ "salinity",
1245
+ "pressure",
1246
+ "pressure_std",
1247
+ "depth",
1248
+ "bt_velocity",
1249
+ ],
1250
+ "chunk_time": 3600,
1251
+ "complevel": 5,
1252
+ "quantize": 3,
1253
+ }
1254
+
1255
+ @staticmethod
1256
+ def _guess_instrument_filename(
1257
+ instrument_config: Dict[str, Any],
1258
+ mooring_name: str,
1259
+ raw_mooring_dir: Optional[Path],
1260
+ input_dir: Optional[Path],
1261
+ log_fn: Optional[Any] = None,
1262
+ ) -> Optional[tuple]:
1263
+ """Try standard recovery-file naming conventions to locate a raw data file.
1264
+
1265
+ Used when ``filename`` is absent or ``null`` in the mooring YAML.
1266
+ Searches ``{raw_mooring_dir}/{instrument}/`` then ``{raw_mooring_dir}/``.
1267
+
1268
+ Returns ``(filename, file_type, header_filename_or_None)`` when a
1269
+ candidate is found on disk, or ``None`` if no match is found (in which
1270
+ case a diagnostic message listing the paths tried is emitted via
1271
+ ``log_fn``).
1272
+
1273
+ Naming conventions applied (all use the ``{serial}_recovery.<ext>``
1274
+ pattern except ADCP):
1275
+
1276
+ * ``microcat`` serial < 6000 → ``.asc`` / ``sbe-ascii`` (older SBE 37)
1277
+ * ``microcat`` serial ≥ 6000 → ``.hex`` / ``sbe-hex`` (newer SBE 37)
1278
+ * ``rbrsolo`` / ``rbrduet`` / ``seapoint`` → ``.rsk`` / ``rbr-rsk``
1279
+ * ``aquadopp`` / ``nortek`` serial < 400000 → ``.dat`` / ``nortek-ascii``
1280
+ + ``.hdr`` header file. ``nortek`` is accepted but deprecated; a
1281
+ warning is logged recommending ``aquadopp`` instead.
1282
+ * ``aquadopp`` / ``nortek`` serial ≥ 400000 → globs for
1283
+ ``converted_raw/A{serial}*/Average Velocity DF3.csv`` under the
1284
+ instrument directory, returns ``nortek-csv`` with the corresponding
1285
+ ``String Data.csv`` as the header path.
1286
+ * ``ADCP`` → tries ``{serial}_{mooring_name}_recovery.000`` then
1287
+ ``{serial}_{mooring_prefix}_recovery.000`` / ``rdi-raw``
1288
+
1289
+ A trailing ``*`` or other non-alphanumeric annotation in the serial
1290
+ field is stripped before building the candidate filename.
1291
+ """
1292
+ instr_type = str(instrument_config.get("instrument", "")).lower()
1293
+ raw_serial = str(instrument_config.get("serial", "")).split(",")[0].strip()
1294
+ if not raw_serial or raw_serial == "unknown":
1295
+ return None
1296
+ # Strip illegal filename chars (e.g. '*' used as a YAML annotation marker)
1297
+ import re as _re
1298
+
1299
+ raw_serial = _re.sub(r"[^\w\-]", "", raw_serial)
1300
+
1301
+ candidates: list = [] # list of (filename, file_type, header_or_None)
1302
+
1303
+ if instr_type == "microcat":
1304
+ serial_int = int(raw_serial) if raw_serial.isdigit() else None
1305
+ if serial_int is not None and serial_int < 6000:
1306
+ candidates.append((f"{raw_serial}_recovery.asc", "sbe-ascii", None))
1307
+ else:
1308
+ candidates.append((f"{raw_serial}_recovery.hex", "sbe-hex", None))
1309
+
1310
+ elif instr_type in ("rbrsolo", "rbrduet", "seapoint"):
1311
+ candidates.append((f"{raw_serial}_recovery.rsk", "rbr-rsk", None))
1312
+
1313
+ elif instr_type in ("aquadopp", "nortek"):
1314
+ if instr_type == "nortek" and log_fn:
1315
+ log_fn(
1316
+ f"AUTO-FILENAME: instrument type 'nortek' is deprecated — "
1317
+ f"use 'aquadopp' in the YAML for serial {raw_serial}"
1318
+ )
1319
+ serial_int = int(raw_serial) if raw_serial.isdigit() else None
1320
+ if serial_int is not None and serial_int >= 400000:
1321
+ # 400### series: data lives under converted_raw/A{serial}*/
1322
+ # The full internal serial suffix varies, so we use glob.
1323
+ _search_400: list = []
1324
+ if raw_mooring_dir is not None:
1325
+ _search_400 += [raw_mooring_dir / "aquadopp", raw_mooring_dir]
1326
+ if input_dir is not None:
1327
+ _search_400 += [
1328
+ input_dir / "aquadopp" / mooring_name,
1329
+ input_dir,
1330
+ ]
1331
+ _tried_400: list = []
1332
+ for _root in _search_400:
1333
+ _conv = _root / "converted_raw"
1334
+ _tried_400.append(
1335
+ str(_conv / f"A{raw_serial}*" / "Average Velocity DF3.csv")
1336
+ )
1337
+ if not _conv.is_dir():
1338
+ continue
1339
+ for _sub in sorted(_conv.glob(f"A{raw_serial}*")):
1340
+ if not _sub.is_dir():
1341
+ continue
1342
+ for _csv in ("Average Velocity DF3.csv", "String Data.csv"):
1343
+ _csv_path = _sub / _csv
1344
+ if _csv_path.exists():
1345
+ _hdr = _sub / "String Data.csv"
1346
+ _hdr_rel = (
1347
+ str(_hdr.relative_to(_root))
1348
+ if _hdr.exists()
1349
+ else None
1350
+ )
1351
+ return (
1352
+ str(_csv_path.relative_to(_root)),
1353
+ "nortek-csv",
1354
+ _hdr_rel,
1355
+ )
1356
+ # Also try the .aqd binary directly (nortek-aqd format).
1357
+ # These may live in aquadopp/ OR legacy nortek/ subdirs.
1358
+ _aqd_roots = [
1359
+ *[r / "aquadopp" for r in [raw_mooring_dir] if r is not None],
1360
+ *[r / "nortek" for r in [raw_mooring_dir] if r is not None],
1361
+ *([raw_mooring_dir] if raw_mooring_dir is not None else []),
1362
+ ]
1363
+ for _root in _aqd_roots:
1364
+ if _root is None or not _root.is_dir():
1365
+ continue
1366
+ for _aqd in sorted(_root.glob(f"A{raw_serial}*.aqd")):
1367
+ _hdr = _aqd.with_suffix(".hdr")
1368
+ return (
1369
+ _aqd.name,
1370
+ "nortek-aqd",
1371
+ _hdr.name if _hdr.exists() else None,
1372
+ )
1373
+ _tried_400.append(str(_root / f"A{raw_serial}*.aqd"))
1374
+ if log_fn:
1375
+ log_fn(
1376
+ f"AUTO-FILENAME: no converted_raw/A{raw_serial}*/ or "
1377
+ f"A{raw_serial}*.aqd found for "
1378
+ f"aquadopp {raw_serial} — tried:\n " + "\n ".join(_tried_400)
1379
+ )
1380
+ return None
1381
+ candidates.append(
1382
+ (
1383
+ f"{raw_serial}_recovery.dat",
1384
+ "nortek-ascii",
1385
+ f"{raw_serial}_recovery.hdr",
1386
+ )
1387
+ )
1388
+
1389
+ elif instr_type == "tr1050":
1390
+ candidates.append((f"{raw_serial}_recovery.hex", "rbr-hex", None))
1391
+
1392
+ elif instr_type == "adcp":
1393
+ # Try full mooring name first, then the mooring prefix (before first '_')
1394
+ # since either naming convention may be in use.
1395
+ candidates.append(
1396
+ (f"{raw_serial}_{mooring_name}_recovery.000", "rdi-raw", None)
1397
+ )
1398
+ _pfx = mooring_name.split("_")[0]
1399
+ if _pfx != mooring_name:
1400
+ candidates.append(
1401
+ (f"{raw_serial}_{_pfx}_recovery.000", "rdi-raw", None)
1402
+ )
1403
+
1404
+ if not candidates:
1405
+ if log_fn:
1406
+ log_fn(
1407
+ f"AUTO-FILENAME: instrument type '{instr_type}' not recognised "
1408
+ f"— add 'filename:' to YAML for {instr_type} {raw_serial}"
1409
+ )
1410
+ return None
1411
+
1412
+ # Search directories: new layout (raw_mooring_dir/instrument/), then
1413
+ # mooring subdir (raw_mooring_dir/), then legacy input_dir.
1414
+ search_roots: list = []
1415
+ if raw_mooring_dir is not None:
1416
+ search_roots.append(raw_mooring_dir / instr_type)
1417
+ search_roots.append(raw_mooring_dir)
1418
+ if input_dir is not None:
1419
+ search_roots.append(input_dir / instr_type / mooring_name)
1420
+ search_roots.append(input_dir)
1421
+
1422
+ tried: list = []
1423
+ for fname, ftype, hdr in candidates:
1424
+ for root in search_roots:
1425
+ candidate_path = root / fname
1426
+ tried.append(str(candidate_path))
1427
+ if candidate_path.exists():
1428
+ return fname, ftype, hdr
1429
+
1430
+ if log_fn:
1431
+ log_fn(
1432
+ f"AUTO-FILENAME: no file found for {instr_type} {raw_serial} — tried:\n "
1433
+ + "\n ".join(tried)
1434
+ )
1435
+ return None
1436
+
1437
+ def _process_instrument(
1438
+ self,
1439
+ instrument_config: Dict[str, Any],
1440
+ yaml_data: Dict[str, Any],
1441
+ input_dir: Optional[Path],
1442
+ output_path: Path,
1443
+ mooring_name: str,
1444
+ force: bool = False,
1445
+ raw_mooring_dir: Optional[Path] = None,
1446
+ ) -> bool:
1447
+ """Process one instrument entry from the mooring YAML.
1448
+
1449
+ Resolves input and output file paths, skips silently if the output
1450
+ already exists and *force* is False, then delegates to
1451
+ ``_read_and_write_file``. Catches all exceptions and logs them so
1452
+ that one failed instrument does not abort the whole mooring.
1453
+
1454
+ Returns True on success or skip, False on error or missing filename.
1455
+
1456
+ When ``raw_mooring_dir`` is provided (new-layout mode), the input file
1457
+ is resolved as ``raw_mooring_dir / instrument_name / filename`` instead
1458
+ of the legacy ``input_dir / instrument_name / mooring_name / filename``.
1459
+ """
1460
+ instrument_name = instrument_config.get("instrument", "unknown")
1461
+ serial = instrument_config.get("serial", "unknown")
1462
+
1463
+ if instrument_config.get("skip"):
1464
+ reason = instrument_config.get("skip_reason", "marked skip:true in YAML")
1465
+ self._log_print(f"SKIP {instrument_name} {serial}: {reason}")
1466
+ return True
1467
+
1468
+ if not instrument_config.get("filename"):
1469
+ guess = self._guess_instrument_filename(
1470
+ instrument_config,
1471
+ mooring_name,
1472
+ raw_mooring_dir,
1473
+ input_dir,
1474
+ log_fn=self._log_print,
1475
+ )
1476
+ if guess is None:
1477
+ self._log_print(
1478
+ f"FILENAME MISSING: Skipping {instrument_name}:{serial}. "
1479
+ f"YAML is missing 'filename' and no standard file was found."
1480
+ )
1481
+ return False
1482
+ _gf, _gt, _gh = guess
1483
+ self._log_print(
1484
+ f"AUTO-FILENAME: {instrument_name} {serial} → {_gf} ({_gt})"
1485
+ )
1486
+ instrument_config = dict(instrument_config)
1487
+ instrument_config["filename"] = _gf
1488
+ if _gt and not instrument_config.get("file_type"):
1489
+ instrument_config["file_type"] = _gt
1490
+ if _gh and not instrument_config.get("header_file"):
1491
+ instrument_config["header_file"] = _gh
1492
+
1493
+ # Set up file paths
1494
+ filename = instrument_config["filename"]
1495
+ file_type = instrument_config.get("file_type", "")
1496
+ instrument_name = instrument_config.get("instrument", "unknown")
1497
+ serial = str(instrument_config.get("serial", "unknown"))
1498
+ _status("instr", f"{instrument_name} {serial}")
1499
+
1500
+ # 'nortek' as an instrument directory name is deprecated — always use 'aquadopp'.
1501
+ dir_name = instrument_name
1502
+ if instrument_name == "nortek":
1503
+ self._log_print(
1504
+ f"WARNING: instrument='nortek' is deprecated (serial {serial}); "
1505
+ f"update YAML to instrument='aquadopp'. Using 'aquadopp/' directory."
1506
+ )
1507
+ dir_name = "aquadopp"
1508
+
1509
+ if raw_mooring_dir is not None:
1510
+ # New layout: {raw_dir}/{mooring}/{instrument}/filename
1511
+ input_file = raw_mooring_dir / dir_name / filename
1512
+ # Backward-compat: file may still live in legacy 'nortek/' subdir
1513
+ if not input_file.exists() and dir_name == "aquadopp":
1514
+ _nortek_alt = raw_mooring_dir / "nortek" / filename
1515
+ if _nortek_alt.exists():
1516
+ self._log_print(
1517
+ f"INFO: {filename} found in legacy 'nortek/' subdir "
1518
+ f"(serial {serial}); consider renaming to 'aquadopp/'."
1519
+ )
1520
+ input_file = _nortek_alt
1521
+ else:
1522
+ # Legacy layout: {base_dir}/raw/{instrument}/{mooring}/filename
1523
+ input_file = input_dir / dir_name / mooring_name / filename
1524
+
1525
+ # Create output directory
1526
+ output_inst_dir = output_path / dir_name
1527
+ output_inst_dir.mkdir(parents=True, exist_ok=True)
1528
+ if not output_inst_dir.exists():
1529
+ self._log_print(f"Created directory: {output_inst_dir}")
1530
+
1531
+ # Generate output filename
1532
+ output_file = self._generate_output_filename(
1533
+ mooring_name, instrument_config, output_inst_dir
1534
+ )
1535
+
1536
+ # Skip if output file already exists (unless forced)
1537
+ if output_file.exists() and not force:
1538
+ _status("skip", self._rel(output_file))
1539
+ return True
1540
+
1541
+ # Process the file
1542
+ try:
1543
+ return self._read_and_write_file(
1544
+ input_file,
1545
+ output_file,
1546
+ file_type,
1547
+ instrument_config,
1548
+ yaml_data,
1549
+ input_dir,
1550
+ raw_mooring_dir=raw_mooring_dir,
1551
+ )
1552
+ except Exception as e:
1553
+ self._log_print(f"ERROR: Failed to process {self._rel(input_file)}: {e}")
1554
+ return False
1555
+
1556
+ def _read_and_write_file(
1557
+ self,
1558
+ input_file: Path,
1559
+ output_file: Path,
1560
+ file_type: str,
1561
+ instrument_config: Dict[str, Any],
1562
+ yaml_data: Dict[str, Any],
1563
+ input_dir: Optional[Path],
1564
+ raw_mooring_dir: Optional[Path] = None,
1565
+ ) -> bool:
1566
+ """Read one raw instrument file, enrich the dataset, and write Stage 1 NetCDF.
1567
+
1568
+ Steps applied in order:
1569
+
1570
+ 1. Resolve Nortek header path (``header_file`` / ``header`` YAML key).
1571
+ 2. Pre-normalise ``sbe-ascii`` date format if required.
1572
+ 3. Read data via ``_read_file``.
1573
+ 4. For Nortek: store pressure-calibration coefficients and T-matrix as
1574
+ global attributes; if T-matrix is available and instrument reports in
1575
+ BEAM coordinates, apply BEAM→XYZ in stage1 (velocity_x/y/z added;
1576
+ beam velocities retained for verification).
1577
+ 5. For ``sbe-ascii``: inject SENSOR_* calibration variables from the CNV
1578
+ header (seasenselib discards the header section).
1579
+ 6. Normalise conductivity units; sanitise slash characters in variable
1580
+ names (NetCDF-illegal); fix ``"db"`` → ``"dbar"`` pressure units.
1581
+ 7. Annotate ITS-90 temperature scale for ``sbe-ascii`` and ``sbe-hex``.
1582
+ 8. Normalise SENSOR_PRES variable naming; clean dataset variables.
1583
+ 9. Add global attributes and per-instrument metadata (depth, serial, etc.).
1584
+ 10. Write to NetCDF via ``NetCdfWriter``.
1585
+
1586
+ Returns True on success, False if the file could not be read.
1587
+ """
1588
+ # Get header file for Nortek instruments ('header_file' preferred, 'header' accepted)
1589
+ header_file = None
1590
+ header_key = instrument_config.get("header_file") or instrument_config.get(
1591
+ "header"
1592
+ )
1593
+ if file_type in ("nortek-aqd", "nortek-ascii", "nortek-csv") and header_key:
1594
+ instrument_name = instrument_config.get("instrument", "unknown")
1595
+ # 'nortek' is a deprecated instrument name; files live in 'aquadopp/' dir.
1596
+ instr_dir = "aquadopp" if instrument_name == "nortek" else instrument_name
1597
+ mooring_name = yaml_data.get("name", "")
1598
+ if raw_mooring_dir is not None:
1599
+ # New layout: header sits next to the data file
1600
+ header_file = str(raw_mooring_dir / instr_dir / header_key)
1601
+ # Backward-compat: header may still live in legacy 'nortek/' subdir
1602
+ if not Path(header_file).exists():
1603
+ _nortek_hdr = raw_mooring_dir / "nortek" / header_key
1604
+ if _nortek_hdr.exists():
1605
+ header_file = str(_nortek_hdr)
1606
+ else:
1607
+ header_file = str(input_dir / instr_dir / mooring_name / header_key)
1608
+
1609
+ # Normalize date format for sbe-ascii files if needed
1610
+ read_path = input_file
1611
+ if file_type == "sbe-ascii":
1612
+ read_path = self._normalize_sbe_ascii(input_file)
1613
+
1614
+ # Read data
1615
+ try:
1616
+ dataset = self._read_file(file_type, str(read_path), header_file)
1617
+ except Exception as e:
1618
+ self._log_print(f"EXCEPT: Error reading file {self._rel(input_file)}: {e}")
1619
+ return False
1620
+
1621
+ relative_output = self._rel(output_file)
1622
+
1623
+ # For rdi-raw: reshape vel/quality arrays and extract instrument metadata
1624
+ if file_type == "rdi-raw":
1625
+ dataset = self._normalize_rdi_raw(dataset, instrument_config)
1626
+
1627
+ # Store Nortek pressure sensor calibration coefficients from .hdr as attrs
1628
+ if file_type in ("nortek-aqd", "nortek-ascii") and header_file:
1629
+ pcal = _parse_nortek_pressure_cal(Path(header_file))
1630
+ for k, v in pcal.items():
1631
+ dataset.attrs[f"nortek_pressure_cal_{k}"] = v
1632
+
1633
+ # Store Nortek transformation matrix (BEAM→XYZ) from header as attrs,
1634
+ # then apply it to produce velocity_x/y/z (keeping beam vars for verification).
1635
+ # Old-format .hdr files use a "Transformation matrix" text block;
1636
+ # new-format .hdr and String Data.csv use GETXFAVG/GETXFBURST fields.
1637
+ if file_type in ("nortek-aqd", "nortek-ascii", "nortek-csv") and header_file:
1638
+ T_mat = _parse_nortek_T_matrix_hdr(Path(header_file))
1639
+ if T_mat is None:
1640
+ T_mat = _parse_nortek_T_matrix_csv(Path(header_file))
1641
+ if T_mat:
1642
+ for k, v in T_mat.items():
1643
+ dataset.attrs[f"nortek_T_{k}"] = v
1644
+
1645
+ # Apply BEAM→XYZ in stage1 when T matrix is available and instrument
1646
+ # reports in BEAM coordinates. XYZ velocities are added as velocity_x/y/z;
1647
+ # the original beam velocities are kept for post-hoc verification.
1648
+ # stage3 will read velocity_x/y/z and skip the BEAM→XYZ step.
1649
+ pointing_down = bool(instrument_config.get("pointing_down", False))
1650
+ if (
1651
+ T_mat is not None
1652
+ and dataset.attrs.get("coordinate_system") == "BEAM"
1653
+ and "velocity_beam1" in dataset.data_vars
1654
+ ):
1655
+ import numpy as _np
1656
+ import xarray as _xr
1657
+
1658
+ _T = _np.array(
1659
+ [[T_mat[f"M{r}{c}"] for c in range(1, 4)] for r in range(1, 4)],
1660
+ dtype=float,
1661
+ )
1662
+ if pointing_down:
1663
+ _T[1, :] = -_T[1, :]
1664
+ _T[2, :] = -_T[2, :]
1665
+ _b1 = dataset["velocity_beam1"].values.astype(float)
1666
+ _b2 = dataset["velocity_beam2"].values.astype(float)
1667
+ _b3 = dataset["velocity_beam3"].values.astype(float)
1668
+ _valid = _np.isfinite(_b1) & _np.isfinite(_b2) & _np.isfinite(_b3)
1669
+ _vx = _np.full_like(_b1, _np.nan)
1670
+ _vy = _np.full_like(_b1, _np.nan)
1671
+ _vz = _np.full_like(_b1, _np.nan)
1672
+ if _valid.any():
1673
+ _xyz = _T @ _np.stack([_b1[_valid], _b2[_valid], _b3[_valid]])
1674
+ _vx[_valid], _vy[_valid], _vz[_valid] = _xyz[0], _xyz[1], _xyz[2]
1675
+ _tdim = dataset["velocity_beam1"].dims[0]
1676
+ _vel_attrs = {"units": "m s-1"}
1677
+ dataset["velocity_x"] = _xr.Variable(
1678
+ _tdim,
1679
+ _vx,
1680
+ {**_vel_attrs, "long_name": "X velocity (instrument XYZ frame)"},
1681
+ )
1682
+ dataset["velocity_y"] = _xr.Variable(
1683
+ _tdim,
1684
+ _vy,
1685
+ {**_vel_attrs, "long_name": "Y velocity (instrument XYZ frame)"},
1686
+ )
1687
+ dataset["velocity_z"] = _xr.Variable(
1688
+ _tdim,
1689
+ _vz,
1690
+ {**_vel_attrs, "long_name": "Z velocity (instrument XYZ frame)"},
1691
+ )
1692
+ dataset.attrs["coordinate_system"] = "XYZ"
1693
+
1694
+ dataset.attrs["nortek_pointing_down"] = str(pointing_down)
1695
+
1696
+ # Inject calibration metadata from file headers (seasenselib discards these)
1697
+ if file_type == "sbe-ascii":
1698
+ dataset = self._add_sbe_ascii_sensor_vars(
1699
+ dataset, input_file, instrument_config
1700
+ )
1701
+ elif file_type == "sbe-hex":
1702
+ dataset = self._add_sbe_hex_sensor_vars(
1703
+ dataset, input_file, instrument_config
1704
+ )
1705
+
1706
+ # Normalize conductivity units and name before cleaning
1707
+ dataset = self._normalize_conductivity(dataset)
1708
+
1709
+ # Rename any remaining SBE CNV variable names that contain '/' (NetCDF-illegal)
1710
+ dataset = self._sanitize_slash_vars(dataset)
1711
+
1712
+ # SBE hex reader (seasenselib) outputs ODO oxygen as 'oxygen' (µmol/L) and
1713
+ # 'oxygen_ml_l' (mL/L) — rename to canonical names so downstream stages and
1714
+ # the report use a consistent variable name regardless of file type.
1715
+ if "oxygen" in dataset.data_vars:
1716
+ dataset = dataset.rename({"oxygen": "dissolved_oxygen"})
1717
+ if "oxygen_ml_l" in dataset.data_vars:
1718
+ dataset = dataset.rename({"oxygen_ml_l": "dissolved_oxygen_ml_l"})
1719
+
1720
+ # SeaBird CNV files use "db" (decibars) instead of the CF-standard "dbar".
1721
+ for pvar in [
1722
+ v for v in dataset.data_vars if v == "pressure" or v.startswith("pressure")
1723
+ ]:
1724
+ if dataset[pvar].attrs.get("units") == "db":
1725
+ dataset[pvar].attrs["units"] = "dbar"
1726
+
1727
+ # sbe-ascii outputs ITS-90 temperature; seasenselib CNV files preserve
1728
+ # this in cnv_original_unit, but the ASCII reader doesn't — add it here.
1729
+ if file_type in ("sbe-ascii", "sbe-hex") and "temperature" in dataset.data_vars:
1730
+ dataset["temperature"].attrs.setdefault("scale", "ITS-90")
1731
+
1732
+ # Rename SENSOR_PRES_{sensor_serial} → SENSOR_PRES_{instrument_serial}
1733
+ dataset = self._normalize_sensor_var_names(dataset, instrument_config)
1734
+
1735
+ # Clean dataset
1736
+ dataset = self._clean_dataset_variables(dataset, file_type)
1737
+
1738
+ # Add metadata
1739
+ dataset = self._add_global_attributes(dataset, yaml_data)
1740
+ dataset = self._add_instrument_metadata(dataset, instrument_config, yaml_data)
1741
+
1742
+ # Enrich analog channel attributes from YAML analog_input_N / analog_input_N_units keys.
1743
+ # seasenselib outputs these as "analog_input_1" / "analog_input_2".
1744
+ for _n in (1, 2):
1745
+ _vname = f"analog_input_{_n}"
1746
+ if _vname not in dataset.data_vars:
1747
+ continue
1748
+ _long = instrument_config.get(f"analog_input_{_n}")
1749
+ _units = instrument_config.get(f"analog_input_{_n}_units")
1750
+ _sn = instrument_config.get(f"analog_input_{_n}_serial_number")
1751
+ if _long:
1752
+ dataset[_vname].attrs["long_name"] = str(_long)
1753
+ if _units:
1754
+ dataset[_vname].attrs["units"] = str(_units)
1755
+ if _sn:
1756
+ dataset[_vname].attrs["sensor_serial_number"] = str(_sn)
1757
+
1758
+ # Remove redundant seasenselib attrs that duplicate our own provenance fields
1759
+ for _redundant_attr in ("processor_level", "source_format_name"):
1760
+ dataset.attrs.pop(_redundant_attr, None)
1761
+
1762
+ # Write to NetCDF
1763
+ _status("file", str(relative_output))
1764
+ writer = NetCdfWriter(cast_output_dtypes(dataset))
1765
+ writer_params = self._get_netcdf_writer_params()
1766
+ writer.write(str(output_file), **writer_params)
1767
+
1768
+ return True
1769
+
1770
+ def process_mooring(
1771
+ self,
1772
+ mooring_name: str,
1773
+ output_path: Optional[str] = None,
1774
+ serials: Optional[List[str]] = None,
1775
+ force: bool = False,
1776
+ ) -> bool:
1777
+ """Process a single mooring's data.
1778
+
1779
+ Args:
1780
+ mooring_name: Name of the mooring to process
1781
+ output_path: Optional custom output path. If None, uses default structure.
1782
+ serials: Optional list of serial numbers to process; if None, process all.
1783
+ force: Re-process even if output already exists.
1784
+
1785
+ Returns:
1786
+ bool: True if processing completed successfully, False otherwise
1787
+
1788
+ """
1789
+ # Set up paths — legacy (base_dir) or new (raw_dir + proc_dir)
1790
+ if self._legacy:
1791
+ if output_path is None:
1792
+ proc = self.base_dir / "proc"
1793
+ if not proc.is_dir():
1794
+ legacy_proc = self.base_dir / "moor" / "proc"
1795
+ proc = legacy_proc if legacy_proc.is_dir() else proc
1796
+ output_path = proc / mooring_name
1797
+ else:
1798
+ output_path = Path(output_path) / mooring_name
1799
+ else:
1800
+ output_path = self._proc_dir / mooring_name
1801
+
1802
+ output_path.mkdir(parents=True, exist_ok=True)
1803
+
1804
+ # Set up logging
1805
+ self._setup_logging(mooring_name, output_path)
1806
+ self._log_print(f"Processing mooring: {mooring_name}")
1807
+
1808
+ # Load configuration — always from proc dir (YAML lives alongside NC outputs)
1809
+ config_file = output_path / f"{mooring_name}.mooring.yaml"
1810
+ if not config_file.exists():
1811
+ self._log_print(f"ERROR: Configuration file not found: {config_file}")
1812
+ return False
1813
+
1814
+ try:
1815
+ yaml_data = self._load_mooring_config(config_file)
1816
+ except Exception as e:
1817
+ self._log_print(f"ERROR: Failed to load configuration: {e}")
1818
+ return False
1819
+
1820
+ # Set up input directory
1821
+ if self._legacy:
1822
+ # Old layout: {base_dir}/{directory}/{instrument}/{mooring}/filename
1823
+ input_dir = self.base_dir / yaml_data.get("directory", "raw")
1824
+ raw_mooring_dir = None
1825
+ else:
1826
+ # New layout: {raw_dir}/{mooring}/{instrument}/filename
1827
+ # directory: in YAML acts as absolute-path override
1828
+ yaml_dir_override = yaml_data.get("directory")
1829
+ if yaml_dir_override and Path(yaml_dir_override).is_absolute():
1830
+ input_dir = Path(yaml_dir_override)
1831
+ self._log_print(
1832
+ f"WARNING: using 'directory: {yaml_dir_override}' from YAML "
1833
+ f"as raw root override (ignoring --raw-dir)"
1834
+ )
1835
+ raw_mooring_dir = None
1836
+ else:
1837
+ input_dir = None
1838
+ raw_mooring_dir = self._raw_dir / mooring_name
1839
+
1840
+ if input_dir is not None and not input_dir.exists():
1841
+ self._log_print(f"ERROR: Input directory not found: {input_dir}")
1842
+ return False
1843
+ if raw_mooring_dir is not None and not raw_mooring_dir.exists():
1844
+ self._log_print(
1845
+ f"ERROR: Raw mooring directory not found: {raw_mooring_dir}"
1846
+ )
1847
+ return False
1848
+
1849
+ # Process each instrument — support both 'instruments' (legacy) and 'clamp' (new format).
1850
+ # Also scan the 'inline' hardware list for entries that have an instrument field.
1851
+ instrument_list = list(yaml_data.get("clamp", yaml_data.get("instruments", [])))
1852
+ inline_instruments = extract_inline_instruments(yaml_data.get("inline", []))
1853
+ if inline_instruments:
1854
+ self._log_print(
1855
+ f"Found {len(inline_instruments)} instrument(s) in 'inline' list: "
1856
+ + ", ".join(str(ic.get("serial", "?")) for ic in inline_instruments)
1857
+ )
1858
+ instrument_list = instrument_list + inline_instruments
1859
+
1860
+ # Filter by serial if requested
1861
+ if serials:
1862
+ safe_serials = {re.sub(r"[^\w\-]", "", str(s)) for s in serials}
1863
+ instrument_list = [
1864
+ ic
1865
+ for ic in instrument_list
1866
+ if re.sub(r"[^\w\-]", "", str(ic.get("serial", ""))) in safe_serials
1867
+ ]
1868
+ self._log_print(
1869
+ f"Filtered to {len(instrument_list)} instrument(s) matching serial(s): {', '.join(serials)}"
1870
+ )
1871
+
1872
+ success_count = 0
1873
+ total_count = len(instrument_list)
1874
+
1875
+ for instrument_config in instrument_list:
1876
+ success = self._process_instrument(
1877
+ instrument_config,
1878
+ yaml_data,
1879
+ input_dir,
1880
+ output_path,
1881
+ mooring_name,
1882
+ force=force,
1883
+ raw_mooring_dir=raw_mooring_dir,
1884
+ )
1885
+ if success:
1886
+ success_count += 1
1887
+
1888
+ self._log_print(
1889
+ f"Completed processing: {success_count}/{total_count} instruments successful"
1890
+ )
1891
+ return success_count > 0
1892
+
1893
+
1894
+ def stage1_mooring(
1895
+ mooring_name: str, basedir: str, output_path: Optional[str] = None
1896
+ ) -> bool:
1897
+ """Process a single mooring's data (backwards compatibility function).
1898
+
1899
+ Args:
1900
+ mooring_name: Name of the mooring to process
1901
+ basedir: Base directory containing the data
1902
+ output_path: Optional output path override
1903
+
1904
+ Returns:
1905
+ bool: True if processing completed successfully
1906
+
1907
+ """
1908
+ processor = MooringProcessor(basedir)
1909
+ return processor.process_mooring(mooring_name, output_path)
1910
+
1911
+
1912
+ def process_multiple_moorings(mooring_list: List[str], basedir: str) -> Dict[str, bool]:
1913
+ """Process multiple moorings.
1914
+
1915
+ Args:
1916
+ mooring_list: List of mooring names to process
1917
+ basedir: Base directory containing the data
1918
+
1919
+ Returns:
1920
+ Dict mapping mooring names to success status
1921
+
1922
+ """
1923
+ processor = MooringProcessor(basedir)
1924
+ results = {}
1925
+
1926
+ for mooring_name in mooring_list:
1927
+ print(f"\n{'=' * 50}")
1928
+ print(f"Processing mooring {mooring_name}")
1929
+ print(f"{'=' * 50}")
1930
+
1931
+ results[mooring_name] = processor.process_mooring(mooring_name)
1932
+
1933
+ return results
1934
+
1935
+
1936
+ # Example usage
1937
+ if __name__ == "__main__":
1938
+ # Your mooring list
1939
+ moorlist = [
1940
+ "dsA_1_2018",
1941
+ "dsB_1_2018",
1942
+ "dsC_1_2018",
1943
+ "dsD_1_2018",
1944
+ "dsE_1_2018",
1945
+ "dsF_1_2018",
1946
+ ]
1947
+
1948
+ basedir = "/Users/eddifying/Dropbox/data/ifmro_mixsed/ds_data_eleanor/"
1949
+
1950
+ # Process all moorings
1951
+ results = process_multiple_moorings(moorlist, basedir)
1952
+
1953
+ # Print summary
1954
+ print(f"\n{'=' * 50}")
1955
+ print("PROCESSING SUMMARY")
1956
+ print(f"{'=' * 50}")
1957
+ for mooring, success in results.items():
1958
+ status = "SUCCESS" if success else "FAILED"
1959
+ print(f"{mooring}: {status}")