oceanarray 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oceanarray/__init__.py +20 -0
- oceanarray/_version.py +1 -0
- oceanarray/cli.py +1343 -0
- oceanarray/clock_offset.py +675 -0
- oceanarray/config/OS1_sensor_attrs.yaml +49 -0
- oceanarray/config/OS1_var_names.yaml +5 -0
- oceanarray/config/OS1_vocab_attrs.yaml +84 -0
- oceanarray/config/legacy/project_RAPID.yaml +69 -0
- oceanarray/config/legacy/rodb_keys.txt +206 -0
- oceanarray/config/legacy/rodb_keys.yaml +1012 -0
- oceanarray/config/logging.yaml +28 -0
- oceanarray/legacy/__init__.py +47 -0
- oceanarray/legacy/caldip.py +753 -0
- oceanarray/legacy/convertOS.py +527 -0
- oceanarray/legacy/find_deployment.py +489 -0
- oceanarray/legacy/mooring_rodb.py +429 -0
- oceanarray/legacy/process_rodb.py +369 -0
- oceanarray/legacy/rodb.py +330 -0
- oceanarray/logger.py +192 -0
- oceanarray/logsheet/__init__.py +60 -0
- oceanarray/logsheet/_columns.py +97 -0
- oceanarray/logsheet/_config.py +245 -0
- oceanarray/logsheet/_firmware.py +43 -0
- oceanarray/logsheet/_latex.py +128 -0
- oceanarray/logsheet/_render.py +126 -0
- oceanarray/logsheet/builders/__init__.py +13 -0
- oceanarray/logsheet/builders/caldip_download.py +195 -0
- oceanarray/logsheet/builders/caldip_setup.py +188 -0
- oceanarray/logsheet/builders/deployment_setup.py +218 -0
- oceanarray/logsheet/builders/mooring_download.py +243 -0
- oceanarray/logsheet/builders/recovery.py +157 -0
- oceanarray/logsheet/data/column_defs.yaml +686 -0
- oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
- oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
- oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
- oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
- oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
- oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
- oceanarray/mooring_level.py +1368 -0
- oceanarray/oceanarray.mplstyle +18 -0
- oceanarray/parameters.py +270 -0
- oceanarray/plotter.py +942 -0
- oceanarray/plotters/__init__.py +97 -0
- oceanarray/plotters/_animation.py +261 -0
- oceanarray/plotters/_current.py +705 -0
- oceanarray/plotters/_diagnostic.py +667 -0
- oceanarray/plotters/_helpers.py +57 -0
- oceanarray/plotters/_primitives.py +101 -0
- oceanarray/plotters/_section.py +16 -0
- oceanarray/plotters/_timeseries.py +11 -0
- oceanarray/rapid_interp.py +739 -0
- oceanarray/read_rbr_hex.py +541 -0
- oceanarray/readers.py +303 -0
- oceanarray/report/__init__.py +5 -0
- oceanarray/report/_array.py +386 -0
- oceanarray/report/_grid.py +539 -0
- oceanarray/report/_html_helpers.py +1004 -0
- oceanarray/report/_instrument.py +853 -0
- oceanarray/report/_mooring.py +1385 -0
- oceanarray/report/_plots.py +4284 -0
- oceanarray/report/_stack.py +941 -0
- oceanarray/stage1.py +1959 -0
- oceanarray/stage2.py +1160 -0
- oceanarray/stage3.py +2494 -0
- oceanarray/time_gridding.py +958 -0
- oceanarray/tools.py +400 -0
- oceanarray/transports.py +1 -0
- oceanarray/utilities.py +538 -0
- oceanarray/validation.py +389 -0
- oceanarray/writers.py +99 -0
- oceanarray-0.1.0.dist-info/METADATA +503 -0
- oceanarray-0.1.0.dist-info/RECORD +76 -0
- oceanarray-0.1.0.dist-info/WHEEL +5 -0
- oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
- oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
- oceanarray-0.1.0.dist-info/top_level.txt +1 -0
oceanarray/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}")
|