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/validation.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Validation utilities for oceanarray mooring YAML configuration files.
|
|
2
|
+
|
|
3
|
+
The ``instrument`` field in each clamp/instruments entry is used as a
|
|
4
|
+
subdirectory name when reading raw files and writing processed output::
|
|
5
|
+
|
|
6
|
+
<basedir>/raw/<instrument>/<mooring_name>/<filename>
|
|
7
|
+
<basedir>/proc/<mooring_name>/<instrument>/<output>.nc
|
|
8
|
+
|
|
9
|
+
Valid instrument names and their typical file types
|
|
10
|
+
----------------------------------------------------
|
|
11
|
+
|
|
12
|
+
+------------+------------------------------+------------------------+
|
|
13
|
+
| instrument | Description | Typical file_type |
|
|
14
|
+
+============+==============================+========================+
|
|
15
|
+
| microcat | SeaBird SBE37 CTD | sbe-cnv, sbe-ascii |
|
|
16
|
+
| sbe56 | SeaBird SBE56 temperature | sbe-cnv |
|
|
17
|
+
| sbe16 | SeaBird SBE16 CTD | sbe-cnv, sbe-hex |
|
|
18
|
+
| rbrsolo | RBR Solo temperature | rbr-rsk, rbr-dat |
|
|
19
|
+
| rbrduet | RBR Duet CT | rbr-rsk, rbr-dat |
|
|
20
|
+
| aquadopp | Nortek Aquadopp current meter| nortek-aqd, nortek-ascii, nortek-csv |
|
|
21
|
+
| adcp | Acoustic Doppler Current Prof| adcp-matlab |
|
|
22
|
+
| tr1050 | Turner TR-1050 (via RBR) | rbr-matlab |
|
|
23
|
+
+------------+------------------------------+------------------------+
|
|
24
|
+
|
|
25
|
+
Do NOT use hardware/model names as the instrument value:
|
|
26
|
+
- ``sbe37`` → use ``microcat``
|
|
27
|
+
- ``nortek`` → use ``aquadopp``
|
|
28
|
+
|
|
29
|
+
Clock correction YAML fields (per-instrument, applied in Stage 2)
|
|
30
|
+
-----------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
Stage 2 applies corrections in this order:
|
|
33
|
+
|
|
34
|
+
1. **Constant offset** — applied uniformly across the entire record::
|
|
35
|
+
|
|
36
|
+
clock_offset: 15 # seconds; positive = instrument was slow (behind)
|
|
37
|
+
|
|
38
|
+
2. **Linear drift** — grows linearly from 0 at deployment to the full drift
|
|
39
|
+
at recovery. Two equivalent ways to specify it:
|
|
40
|
+
|
|
41
|
+
**Option A** — direct::
|
|
42
|
+
|
|
43
|
+
clock_drift_seconds: 8 # positive = instrument was slow (behind at recovery)
|
|
44
|
+
|
|
45
|
+
**Option B** — two timestamps read off at recovery (preferred; no sign errors)::
|
|
46
|
+
|
|
47
|
+
clock_computer_at_recovery: '2026-07-11T10:23:30'
|
|
48
|
+
clock_instrument_at_recovery: '2026-07-11T10:23:22'
|
|
49
|
+
# drift = computer − instrument = +8 s (instrument was 8 s behind)
|
|
50
|
+
|
|
51
|
+
If both Option A and Option B are present, Option B takes priority.
|
|
52
|
+
|
|
53
|
+
3. **Trimming** — data outside deployment_time … recovery_time is discarded
|
|
54
|
+
*after* all clock corrections, so corrections are applied to the full raw record.
|
|
55
|
+
|
|
56
|
+
Sign convention — both values are amounts **added** to instrument time:
|
|
57
|
+
|
|
58
|
+
+--------------------------------+------------------+--------------------+
|
|
59
|
+
| Situation | clock_offset | clock_drift_seconds|
|
|
60
|
+
+================================+==================+====================+
|
|
61
|
+
| Instrument clock was slow | positive (+) | positive (+) |
|
|
62
|
+
| (behind real time) | | |
|
|
63
|
+
+--------------------------------+------------------+--------------------+
|
|
64
|
+
| Instrument clock was fast | negative (-) | negative (-) |
|
|
65
|
+
| (ahead of real time) | | |
|
|
66
|
+
+--------------------------------+------------------+--------------------+
|
|
67
|
+
|
|
68
|
+
The original, uncorrected time is always saved as ``time_orig`` in the output
|
|
69
|
+
NetCDF alongside the corrected ``time`` coordinate. The ``history`` attribute
|
|
70
|
+
records what was applied and when.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
import re
|
|
74
|
+
from pathlib import Path
|
|
75
|
+
from typing import Any, Dict, List, NamedTuple
|
|
76
|
+
|
|
77
|
+
import yaml
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
VALID_INSTRUMENTS: Dict[str, Dict[str, Any]] = {
|
|
81
|
+
"microcat": {
|
|
82
|
+
"description": "SeaBird SBE37 CTD",
|
|
83
|
+
"typical_file_types": ["sbe-cnv", "sbe-ascii"],
|
|
84
|
+
},
|
|
85
|
+
"sbe56": {
|
|
86
|
+
"description": "SeaBird SBE56 temperature logger",
|
|
87
|
+
"typical_file_types": ["sbe-cnv"],
|
|
88
|
+
},
|
|
89
|
+
"sbe16": {
|
|
90
|
+
"description": "SeaBird SBE16 CTD",
|
|
91
|
+
"typical_file_types": ["sbe-cnv", "sbe-hex"],
|
|
92
|
+
},
|
|
93
|
+
"rbrsolo": {
|
|
94
|
+
"description": "RBR Solo temperature logger",
|
|
95
|
+
"typical_file_types": ["rbr-rsk", "rbr-dat"],
|
|
96
|
+
},
|
|
97
|
+
"rbrduet": {
|
|
98
|
+
"description": "RBR Duet CT logger",
|
|
99
|
+
"typical_file_types": ["rbr-rsk", "rbr-dat"],
|
|
100
|
+
},
|
|
101
|
+
"aquadopp": {
|
|
102
|
+
"description": "Nortek Aquadopp current meter",
|
|
103
|
+
"typical_file_types": ["nortek-aqd", "nortek-ascii"],
|
|
104
|
+
},
|
|
105
|
+
"adcp": {
|
|
106
|
+
"description": "Acoustic Doppler Current Profiler (lowercase; use 'ADCP' in YAML)",
|
|
107
|
+
"typical_file_types": ["rdi-raw", "adcp-matlab"],
|
|
108
|
+
},
|
|
109
|
+
"ADCP": {
|
|
110
|
+
"description": "Acoustic Doppler Current Profiler (RDI or similar)",
|
|
111
|
+
"typical_file_types": ["rdi-raw", "adcp-matlab"],
|
|
112
|
+
},
|
|
113
|
+
"tr1050": {
|
|
114
|
+
"description": "Turner TR-1050 fluorometer (via RBR logger)",
|
|
115
|
+
"typical_file_types": ["rbr-matlab", "rbr-hex-oa"],
|
|
116
|
+
},
|
|
117
|
+
"seapoint": {
|
|
118
|
+
"description": "Seapoint turbidity sensor (via RBR logger)",
|
|
119
|
+
"typical_file_types": ["rbr-rsk", "rbr-dat"],
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
KNOWN_ALIASES: Dict[str, str] = {
|
|
124
|
+
"sbe37": "microcat",
|
|
125
|
+
"nortek": "aquadopp",
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
VALID_FILE_TYPES = {
|
|
129
|
+
"sbe-cnv",
|
|
130
|
+
"sbe-asc",
|
|
131
|
+
"sbe-ascii",
|
|
132
|
+
"sbe-hex",
|
|
133
|
+
"nortek-aqd",
|
|
134
|
+
"nortek-ascii",
|
|
135
|
+
"nortek-csv", # seasenselib reader (future)
|
|
136
|
+
"nortek-csv-oa", # DEPRECATED: internal oceanarray CSV reader
|
|
137
|
+
"rbr-rsk",
|
|
138
|
+
"rbr-dat",
|
|
139
|
+
"rbr-matlab",
|
|
140
|
+
"adcp-matlab",
|
|
141
|
+
"rdi-raw", # RDI ADCP raw binary; requires mhkit[dolfyn]
|
|
142
|
+
"rbr-hex-oa", # internal oceanarray hex reader for TR-1050; use rbr-hex once seasenselib supports it
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
UNSUPPORTED_FILE_TYPES: Dict[str, str] = {}
|
|
146
|
+
|
|
147
|
+
REQUIRED_MOORING_KEYS = ["name", "waterdepth", "deployment_time", "recovery_time"]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ValidationIssue(NamedTuple):
|
|
151
|
+
"""A single validation finding with a severity level and human-readable message."""
|
|
152
|
+
|
|
153
|
+
level: str # "ERROR" or "WARNING"
|
|
154
|
+
message: str
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def validate_mooring_yaml(yaml_path: str) -> List[ValidationIssue]:
|
|
158
|
+
"""Validate a mooring YAML configuration file.
|
|
159
|
+
|
|
160
|
+
Checks:
|
|
161
|
+
- Required top-level keys are present
|
|
162
|
+
- Each instrument entry uses a valid ``instrument`` name (not a model alias)
|
|
163
|
+
- Each instrument entry with a ``file_type`` uses a recognised value
|
|
164
|
+
- Instruments with ``filename`` also have ``file_type``
|
|
165
|
+
- Instruments without ``filename`` are flagged as warnings (not yet staged)
|
|
166
|
+
|
|
167
|
+
Returns a list of :class:`ValidationIssue` named-tuples. An empty list
|
|
168
|
+
means the file passed all checks.
|
|
169
|
+
"""
|
|
170
|
+
issues: List[ValidationIssue] = []
|
|
171
|
+
path = Path(yaml_path)
|
|
172
|
+
|
|
173
|
+
if not path.exists():
|
|
174
|
+
return [ValidationIssue("ERROR", f"File not found: {yaml_path}")]
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
with open(path) as f:
|
|
178
|
+
data = yaml.safe_load(f)
|
|
179
|
+
except yaml.YAMLError as e:
|
|
180
|
+
return [ValidationIssue("ERROR", f"YAML parse error: {e}")]
|
|
181
|
+
|
|
182
|
+
if not isinstance(data, dict):
|
|
183
|
+
return [ValidationIssue("ERROR", "YAML root must be a mapping")]
|
|
184
|
+
|
|
185
|
+
# Check required mooring-level keys
|
|
186
|
+
for key in REQUIRED_MOORING_KEYS:
|
|
187
|
+
if key not in data:
|
|
188
|
+
issues.append(ValidationIssue("ERROR", f"Missing required key: '{key}'"))
|
|
189
|
+
|
|
190
|
+
# Validate instrument list (supports both 'clamp' and legacy 'instruments').
|
|
191
|
+
# Also validate inline hardware entries that carry instrument data.
|
|
192
|
+
instrument_list = data.get("clamp", data.get("instruments", []))
|
|
193
|
+
inline_list = data.get("inline", [])
|
|
194
|
+
inline_instruments = [
|
|
195
|
+
e
|
|
196
|
+
for e in inline_list
|
|
197
|
+
if isinstance(e, dict)
|
|
198
|
+
and "instrument" in e
|
|
199
|
+
and (e.get("filename") or e.get("skip"))
|
|
200
|
+
]
|
|
201
|
+
combined_list = list(instrument_list) + inline_instruments
|
|
202
|
+
|
|
203
|
+
if not combined_list:
|
|
204
|
+
issues.append(
|
|
205
|
+
ValidationIssue(
|
|
206
|
+
"WARNING",
|
|
207
|
+
"No instruments found under 'clamp', 'instruments', or 'inline'",
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
return issues
|
|
211
|
+
|
|
212
|
+
for i, entry in enumerate(combined_list):
|
|
213
|
+
if not isinstance(entry, dict):
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
serial = entry.get("serial", f"index {i}")
|
|
217
|
+
prefix = f"[serial {serial}]"
|
|
218
|
+
|
|
219
|
+
instrument = entry.get("instrument")
|
|
220
|
+
file_type = entry.get("file_type")
|
|
221
|
+
filename = entry.get("filename")
|
|
222
|
+
|
|
223
|
+
serial_str = str(serial)
|
|
224
|
+
if re.search(r"[^\w\-]", serial_str):
|
|
225
|
+
issues.append(
|
|
226
|
+
ValidationIssue(
|
|
227
|
+
"WARNING",
|
|
228
|
+
f"{prefix} serial='{serial_str}' contains characters illegal in filenames "
|
|
229
|
+
f"(e.g. '*') — they will be stripped automatically when constructing output filenames",
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Fragile serial parsing for inline instruments with compound serials.
|
|
234
|
+
# extract_inline_instruments splits on the first comma and uses the first
|
|
235
|
+
# token as the instrument serial. Warn so operators can verify the ordering.
|
|
236
|
+
if entry.get("source") == "inline" or entry in inline_instruments:
|
|
237
|
+
if "," in serial_str:
|
|
238
|
+
parts = [p.strip() for p in serial_str.split(",")]
|
|
239
|
+
issues.append(
|
|
240
|
+
ValidationIssue(
|
|
241
|
+
"WARNING",
|
|
242
|
+
f"{prefix} inline serial='{serial_str}' contains a comma — "
|
|
243
|
+
f"the first token '{parts[0]}' will be used as the instrument serial "
|
|
244
|
+
f"and '{', '.join(parts[1:])}' stored as beacon_id. "
|
|
245
|
+
f"Confirm the instrument serial comes first.",
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if instrument is None:
|
|
250
|
+
issues.append(
|
|
251
|
+
ValidationIssue("WARNING", f"{prefix} Missing 'instrument' field")
|
|
252
|
+
)
|
|
253
|
+
elif instrument in KNOWN_ALIASES:
|
|
254
|
+
correct = KNOWN_ALIASES[instrument]
|
|
255
|
+
issues.append(
|
|
256
|
+
ValidationIssue(
|
|
257
|
+
"ERROR",
|
|
258
|
+
f"{prefix} instrument='{instrument}' is deprecated — use '{correct}' instead",
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
elif instrument not in VALID_INSTRUMENTS:
|
|
262
|
+
issues.append(
|
|
263
|
+
ValidationIssue(
|
|
264
|
+
"WARNING",
|
|
265
|
+
f"{prefix} instrument='{instrument}' is not in the known list: "
|
|
266
|
+
f"{', '.join(sorted(VALID_INSTRUMENTS))}",
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
if (
|
|
271
|
+
file_type is not None
|
|
272
|
+
and file_type not in VALID_FILE_TYPES
|
|
273
|
+
and file_type != "TBD"
|
|
274
|
+
):
|
|
275
|
+
if file_type in UNSUPPORTED_FILE_TYPES:
|
|
276
|
+
issues.append(
|
|
277
|
+
ValidationIssue(
|
|
278
|
+
"ERROR",
|
|
279
|
+
f"{prefix} file_type='{file_type}' is not supported: {UNSUPPORTED_FILE_TYPES[file_type]}",
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
else:
|
|
283
|
+
issues.append(
|
|
284
|
+
ValidationIssue(
|
|
285
|
+
"WARNING",
|
|
286
|
+
f"{prefix} file_type='{file_type}' is not recognised. "
|
|
287
|
+
f"Known types: {', '.join(sorted(VALID_FILE_TYPES))}",
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if filename and not file_type:
|
|
292
|
+
issues.append(
|
|
293
|
+
ValidationIssue(
|
|
294
|
+
"ERROR",
|
|
295
|
+
f"{prefix} has 'filename' but no 'file_type'",
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
if not filename and not entry.get("skip"):
|
|
300
|
+
issues.append(
|
|
301
|
+
ValidationIssue(
|
|
302
|
+
"WARNING",
|
|
303
|
+
f"{prefix} instrument='{instrument}' has no 'filename' — not yet staged for processing",
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Clock correction field checks
|
|
308
|
+
has_comp = "computer_clock_at_recovery" in entry
|
|
309
|
+
has_inst = "instrument_clock_at_recovery" in entry
|
|
310
|
+
if has_comp != has_inst:
|
|
311
|
+
issues.append(
|
|
312
|
+
ValidationIssue(
|
|
313
|
+
"ERROR",
|
|
314
|
+
f"{prefix} computer_clock_at_recovery and instrument_clock_at_recovery "
|
|
315
|
+
f"must both be present or both absent",
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
elif has_comp and has_inst:
|
|
319
|
+
for ts_key in (
|
|
320
|
+
"computer_clock_at_recovery",
|
|
321
|
+
"instrument_clock_at_recovery",
|
|
322
|
+
):
|
|
323
|
+
try:
|
|
324
|
+
import pandas as _pd
|
|
325
|
+
|
|
326
|
+
_pd.Timestamp(entry[ts_key])
|
|
327
|
+
except Exception: # noqa: BLE001 — report issue and continue; Timestamp parse errors vary
|
|
328
|
+
issues.append(
|
|
329
|
+
ValidationIssue(
|
|
330
|
+
"ERROR",
|
|
331
|
+
f"{prefix} {ts_key}='{entry[ts_key]}' is not a valid timestamp",
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
if "clock_drift_seconds" in entry:
|
|
335
|
+
try:
|
|
336
|
+
import pandas as _pd
|
|
337
|
+
|
|
338
|
+
comp_t = _pd.Timestamp(entry["computer_clock_at_recovery"])
|
|
339
|
+
inst_t = _pd.Timestamp(entry["instrument_clock_at_recovery"])
|
|
340
|
+
computed_drift = (comp_t - inst_t).total_seconds()
|
|
341
|
+
stated_drift = float(entry["clock_drift_seconds"])
|
|
342
|
+
if abs(computed_drift - stated_drift) > 1.0:
|
|
343
|
+
issues.append(
|
|
344
|
+
ValidationIssue(
|
|
345
|
+
"ERROR",
|
|
346
|
+
f"{prefix} clock_drift_seconds={stated_drift:.1f}s conflicts with "
|
|
347
|
+
f"timestamp pair (computed drift={computed_drift:+.1f}s); "
|
|
348
|
+
f"remove clock_drift_seconds or correct the timestamps",
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
else:
|
|
352
|
+
issues.append(
|
|
353
|
+
ValidationIssue(
|
|
354
|
+
"WARNING",
|
|
355
|
+
f"{prefix} both clock_drift_seconds and timestamp pair are present; "
|
|
356
|
+
f"timestamp pair will be used (they agree to <1 s)",
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
except Exception: # noqa: BLE001 — parse errors already reported above; skip silently
|
|
360
|
+
pass
|
|
361
|
+
|
|
362
|
+
return issues
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def print_validation_report(yaml_path: str) -> bool:
|
|
366
|
+
"""Print a human-readable validation report. Returns True if no errors."""
|
|
367
|
+
issues = validate_mooring_yaml(yaml_path)
|
|
368
|
+
|
|
369
|
+
errors = [i for i in issues if i.level == "ERROR"]
|
|
370
|
+
warnings = [i for i in issues if i.level == "WARNING"]
|
|
371
|
+
|
|
372
|
+
print(f"Validating: {yaml_path}")
|
|
373
|
+
|
|
374
|
+
if not issues:
|
|
375
|
+
print(" OK — no issues found")
|
|
376
|
+
return True
|
|
377
|
+
|
|
378
|
+
for issue in issues:
|
|
379
|
+
marker = " ERROR " if issue.level == "ERROR" else " warn "
|
|
380
|
+
print(f"{marker}{issue.message}")
|
|
381
|
+
|
|
382
|
+
summary = []
|
|
383
|
+
if errors:
|
|
384
|
+
summary.append(f"{len(errors)} error(s)")
|
|
385
|
+
if warnings:
|
|
386
|
+
summary.append(f"{len(warnings)} warning(s)")
|
|
387
|
+
print(f" {', '.join(summary)}")
|
|
388
|
+
|
|
389
|
+
return len(errors) == 0
|
oceanarray/writers.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from numbers import Number
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import xarray as xr
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def save_dataset(ds: xr.Dataset, output_file: str = "../test.nc") -> bool:
|
|
9
|
+
"""Attempts to save the dataset to a NetCDF file. If a TypeError occurs due to invalid attribute values,
|
|
10
|
+
it converts the invalid attributes to strings and retries the save operation.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
ds : xarray.Dataset
|
|
15
|
+
The dataset to be saved.
|
|
16
|
+
output_file : str, optional
|
|
17
|
+
The path to the output NetCDF file. Defaults to '../test.nc'.
|
|
18
|
+
|
|
19
|
+
Returns
|
|
20
|
+
-------
|
|
21
|
+
bool
|
|
22
|
+
True if the dataset was saved successfully, False otherwise.
|
|
23
|
+
|
|
24
|
+
Notes
|
|
25
|
+
-----
|
|
26
|
+
This function is based on a workaround for issues with saving datasets containing
|
|
27
|
+
attributes of unsupported types. See: https://github.com/pydata/xarray/issues/3743
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
# More general
|
|
31
|
+
valid_types = (str, Number, np.ndarray, np.number, list, tuple)
|
|
32
|
+
try:
|
|
33
|
+
ds.to_netcdf(output_file, format="NETCDF4_CLASSIC")
|
|
34
|
+
except TypeError as e:
|
|
35
|
+
print(e.__class__.__name__, e)
|
|
36
|
+
for varname, variable in ds.variables.items():
|
|
37
|
+
for k, v in variable.attrs.items():
|
|
38
|
+
if not isinstance(v, valid_types) or isinstance(v, bool):
|
|
39
|
+
print(
|
|
40
|
+
f"variable '{varname}': Converting attribute '{k}' with value '{v}' to string.",
|
|
41
|
+
)
|
|
42
|
+
variable.attrs[k] = str(v)
|
|
43
|
+
try:
|
|
44
|
+
ds.to_netcdf(output_file, format="NETCDF4_CLASSIC")
|
|
45
|
+
except Exception as e: # noqa: BLE001 — log failure details and report False; write errors vary
|
|
46
|
+
print("Failed to save dataset:", e)
|
|
47
|
+
datetime_vars = [
|
|
48
|
+
var for var in ds.variables if ds[var].dtype == "datetime64[ns]"
|
|
49
|
+
]
|
|
50
|
+
print("Variables with dtype datetime64[ns]:", datetime_vars)
|
|
51
|
+
float_attrs = [
|
|
52
|
+
attr for attr in ds.attrs if isinstance(ds.attrs[attr], float)
|
|
53
|
+
]
|
|
54
|
+
print("Attributes with dtype float64:", float_attrs)
|
|
55
|
+
return False
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save_OS_instrument(ds: xr.Dataset, data_dir: Path):
|
|
60
|
+
"""Save OceanSITES dataset to netCDF using the 'id' global attribute as filename.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
ds : xarray.Dataset
|
|
65
|
+
Dataset with OceanSITES-compliant global attributes including 'id'.
|
|
66
|
+
data_dir : pathlib.Path
|
|
67
|
+
Directory to save the netCDF file.
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
Path
|
|
72
|
+
Full path to the saved NetCDF file.
|
|
73
|
+
|
|
74
|
+
"""
|
|
75
|
+
if "id" not in ds.attrs:
|
|
76
|
+
raise ValueError( # noqa: TRY003
|
|
77
|
+
"Global attribute 'id' not found. Cannot determine output filename."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
filename = f"{ds.attrs['id']}.nc"
|
|
81
|
+
filepath = data_dir / filename
|
|
82
|
+
|
|
83
|
+
# Use netCDF4 format and encoding for compression
|
|
84
|
+
encoding = {var: {"zlib": True, "complevel": 4} for var in ds.data_vars}
|
|
85
|
+
|
|
86
|
+
# Sanitize attributes: replace None with empty string
|
|
87
|
+
for k, v in ds.attrs.items():
|
|
88
|
+
if v is None:
|
|
89
|
+
ds.attrs[k] = ""
|
|
90
|
+
|
|
91
|
+
# Remove conflicting attributes that may clash with encoding
|
|
92
|
+
conflicting_keys = ["units", "calendar"]
|
|
93
|
+
for var in ds.coords:
|
|
94
|
+
for key in conflicting_keys:
|
|
95
|
+
ds[var].attrs.pop(key, None)
|
|
96
|
+
|
|
97
|
+
ds.to_netcdf(filepath, format="NETCDF4", encoding=encoding)
|
|
98
|
+
|
|
99
|
+
return filepath
|