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/utilities.py
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""Shared utility helpers used across the oceanarray processing pipeline."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import xarray as xr
|
|
10
|
+
|
|
11
|
+
from oceanarray import logger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def extract_inline_instruments(
|
|
15
|
+
inline_list: List[Dict[str, Any]],
|
|
16
|
+
) -> List[Dict[str, Any]]:
|
|
17
|
+
"""Extract processable instrument entries from the mooring YAML ``inline`` list.
|
|
18
|
+
|
|
19
|
+
Most ``inline`` entries describe passive hardware (ropes, shackles, floats).
|
|
20
|
+
This function filters for entries that have an ``instrument`` field and either
|
|
21
|
+
a ``filename`` (to be processed) or ``skip: true`` (to be reported as skipped).
|
|
22
|
+
|
|
23
|
+
Normalisation applied to each matching entry:
|
|
24
|
+
|
|
25
|
+
- ``hab`` is set from ``hab_bottom`` if ``hab`` is not already present.
|
|
26
|
+
For a downward-looking instrument the transducer is at the bottom of the
|
|
27
|
+
housing (``hab_bottom``); for an upward-looking one use ``hab_top``.
|
|
28
|
+
- ``serial`` is split on the first comma and the first token is used as the
|
|
29
|
+
primary serial number. Any remaining tokens are joined and stored under
|
|
30
|
+
``beacon_id`` in the returned dict.
|
|
31
|
+
- ``source: "inline"`` is added to distinguish these entries from ``clamp``
|
|
32
|
+
entries in downstream logging.
|
|
33
|
+
|
|
34
|
+
**Serial parsing is fragile**: the convention ``serial: 16430, R01-024``
|
|
35
|
+
assumes the *first* comma-separated token is the instrument serial and the
|
|
36
|
+
rest is a transponder/beacon ID. If a YAML author places the beacon serial
|
|
37
|
+
first, the wrong value will be used as the output filename stem. The
|
|
38
|
+
validator emits a WARNING for any inline entry whose serial contains a comma
|
|
39
|
+
so operators can confirm the ordering is correct.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
inline_list: The raw list parsed from the ``inline`` YAML key.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
List of normalised instrument-config dicts ready for stage processing.
|
|
46
|
+
|
|
47
|
+
"""
|
|
48
|
+
result: List[Dict[str, Any]] = []
|
|
49
|
+
for entry in inline_list:
|
|
50
|
+
if not isinstance(entry, dict):
|
|
51
|
+
continue
|
|
52
|
+
if "instrument" not in entry:
|
|
53
|
+
continue
|
|
54
|
+
if not entry.get("filename") and not entry.get("skip"):
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
normalized: Dict[str, Any] = dict(entry)
|
|
58
|
+
|
|
59
|
+
if "hab" not in normalized and "hab_bottom" in normalized:
|
|
60
|
+
normalized["hab"] = normalized["hab_bottom"]
|
|
61
|
+
|
|
62
|
+
serial_raw = str(entry.get("serial", ""))
|
|
63
|
+
if "," in serial_raw:
|
|
64
|
+
parts = [p.strip() for p in serial_raw.split(",")]
|
|
65
|
+
normalized["serial"] = parts[0]
|
|
66
|
+
normalized["beacon_id"] = ", ".join(parts[1:])
|
|
67
|
+
|
|
68
|
+
normalized["source"] = "inline"
|
|
69
|
+
result.append(normalized)
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def concat_with_scalar_vars(
|
|
74
|
+
datasets: List[xr.Dataset],
|
|
75
|
+
dim: str,
|
|
76
|
+
scalar_vars: Optional[List[str]] = None,
|
|
77
|
+
) -> xr.Dataset:
|
|
78
|
+
"""Concatenate datasets along a dimension, preserving scalar variables.
|
|
79
|
+
|
|
80
|
+
Scalar (0-D) variables would normally be broadcast to the concatenation
|
|
81
|
+
dimension by ``xr.concat``; this helper strips them beforehand and
|
|
82
|
+
re-attaches the first occurrence after the concat so they remain 0-D.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
datasets : list of xarray.Dataset
|
|
87
|
+
Datasets to concatenate.
|
|
88
|
+
dim : str
|
|
89
|
+
Dimension along which to concatenate.
|
|
90
|
+
scalar_vars : list of str, optional
|
|
91
|
+
List of variable names to treat as scalars. If None, auto-detect
|
|
92
|
+
scalar variables (those with ndim == 0 in any dataset).
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
xarray.Dataset
|
|
97
|
+
Concatenated dataset with scalar variables re-attached as 0-D DataArrays.
|
|
98
|
+
|
|
99
|
+
"""
|
|
100
|
+
scalar_storage = {}
|
|
101
|
+
|
|
102
|
+
# Auto-detect scalar variables if not provided
|
|
103
|
+
if scalar_vars is None:
|
|
104
|
+
scalar_vars = set()
|
|
105
|
+
for ds in datasets:
|
|
106
|
+
scalar_vars.update([v for v in ds.data_vars if ds[v].ndim == 0])
|
|
107
|
+
scalar_vars = list(scalar_vars)
|
|
108
|
+
|
|
109
|
+
# Strip scalar variables and store them
|
|
110
|
+
cleaned = []
|
|
111
|
+
for _i, ds in enumerate(datasets):
|
|
112
|
+
ds_copy = ds.copy()
|
|
113
|
+
for var in scalar_vars:
|
|
114
|
+
if var in ds_copy and ds_copy[var].ndim == 0:
|
|
115
|
+
scalar_storage[(_i, var)] = ds_copy[var]
|
|
116
|
+
del ds_copy[var]
|
|
117
|
+
cleaned.append(ds_copy)
|
|
118
|
+
|
|
119
|
+
# Concatenate non-scalar parts
|
|
120
|
+
combined = xr.concat(cleaned, dim=dim)
|
|
121
|
+
|
|
122
|
+
# Re-attach scalar variables as 0-D DataArrays
|
|
123
|
+
for (_i, var), da in scalar_storage.items():
|
|
124
|
+
combined[var] = da # safe: 0-D, no coords
|
|
125
|
+
|
|
126
|
+
return combined
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def check_necessary_variables(ds: xr.Dataset, vars: list) -> None:
|
|
130
|
+
"""Check that all required variables are present in a dataset.
|
|
131
|
+
|
|
132
|
+
Parameters
|
|
133
|
+
----------
|
|
134
|
+
ds: xarray.Dataset
|
|
135
|
+
Dataset that should be checked
|
|
136
|
+
vars: list
|
|
137
|
+
List of variables
|
|
138
|
+
|
|
139
|
+
Raises
|
|
140
|
+
------
|
|
141
|
+
KeyError:
|
|
142
|
+
Raises an error if all vars not present in ds
|
|
143
|
+
|
|
144
|
+
Notes
|
|
145
|
+
-----
|
|
146
|
+
Original Author: Callum Rollo
|
|
147
|
+
|
|
148
|
+
"""
|
|
149
|
+
missing_vars = set(vars).difference(set(ds.variables))
|
|
150
|
+
if missing_vars:
|
|
151
|
+
msg = f"Required variables {list(missing_vars)} do not exist in the supplied dataset."
|
|
152
|
+
raise KeyError(msg)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_time_key(ds: xr.Dataset) -> str:
|
|
156
|
+
"""Return the name of the time coordinate or variable in an xarray.Dataset.
|
|
157
|
+
|
|
158
|
+
Parameters
|
|
159
|
+
----------
|
|
160
|
+
ds : xarray.Dataset
|
|
161
|
+
The dataset to inspect.
|
|
162
|
+
|
|
163
|
+
Returns
|
|
164
|
+
-------
|
|
165
|
+
str
|
|
166
|
+
The name of the time coordinate or variable.
|
|
167
|
+
|
|
168
|
+
Raises
|
|
169
|
+
------
|
|
170
|
+
ValueError
|
|
171
|
+
If no time dimension or coordinate is found.
|
|
172
|
+
|
|
173
|
+
"""
|
|
174
|
+
# Common time names to check
|
|
175
|
+
time_candidates = ["TIME", "time", "Time", "DATETIME", "datetime", "date"]
|
|
176
|
+
for name in time_candidates:
|
|
177
|
+
if name in ds.coords or name in ds.variables:
|
|
178
|
+
return name
|
|
179
|
+
|
|
180
|
+
# Fallback: look for any variable or coordinate with datetime dtype
|
|
181
|
+
for name in ds.coords:
|
|
182
|
+
if np.issubdtype(ds.coords[name].dtype, np.datetime64):
|
|
183
|
+
logger.log_warning(
|
|
184
|
+
"No standard time coordinate found. Using first datetime coordinate: %s",
|
|
185
|
+
name,
|
|
186
|
+
)
|
|
187
|
+
return name
|
|
188
|
+
|
|
189
|
+
for name in ds.variables:
|
|
190
|
+
if np.issubdtype(ds.variables[name].dtype, np.datetime64):
|
|
191
|
+
logger.log_warning(
|
|
192
|
+
"No standard time coordinate found. Using first datetime coordinate: %s",
|
|
193
|
+
name,
|
|
194
|
+
)
|
|
195
|
+
return name
|
|
196
|
+
|
|
197
|
+
raise ValueError("No valid time coordinate found in dataset.") # noqa: TRY003
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_dims(
|
|
201
|
+
ds_gridded: xr.Dataset,
|
|
202
|
+
) -> tuple:
|
|
203
|
+
"""Extract pressure key, time key, and their dimensions from a dataset.
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
ds_gridded : xarray.Dataset
|
|
208
|
+
Dataset containing the variables and dimensions.
|
|
209
|
+
|
|
210
|
+
Returns
|
|
211
|
+
-------
|
|
212
|
+
pres_key : str or None
|
|
213
|
+
Key for the pressure (or depth) variable; None if not found.
|
|
214
|
+
time_key : str
|
|
215
|
+
Key for the time variable.
|
|
216
|
+
pres_dim : str or None
|
|
217
|
+
Dimension associated with the pressure variable; None if not found.
|
|
218
|
+
time_dim : str or None
|
|
219
|
+
Dimension associated with the time variable.
|
|
220
|
+
|
|
221
|
+
"""
|
|
222
|
+
if isinstance(ds_gridded, xr.DataArray):
|
|
223
|
+
ds_gridded = ds_gridded.to_dataset()
|
|
224
|
+
|
|
225
|
+
# Determine pressure key
|
|
226
|
+
pressure_candidates = [
|
|
227
|
+
"PRES_ADJUSTED",
|
|
228
|
+
"PRESSURE",
|
|
229
|
+
"pressure",
|
|
230
|
+
"PRES",
|
|
231
|
+
"PRES_INTERPOLATED",
|
|
232
|
+
]
|
|
233
|
+
pres_key = None
|
|
234
|
+
for name in pressure_candidates:
|
|
235
|
+
if name in ds_gridded.coords or name in ds_gridded.variables:
|
|
236
|
+
pres_key = name
|
|
237
|
+
break
|
|
238
|
+
|
|
239
|
+
# Fall back to depth if no pressure variable found
|
|
240
|
+
dpth_key = None
|
|
241
|
+
if pres_key is None:
|
|
242
|
+
depth_candidates = [
|
|
243
|
+
"DEPTH_ADJUSTED",
|
|
244
|
+
"DEPTH",
|
|
245
|
+
"depth",
|
|
246
|
+
"Depth",
|
|
247
|
+
"DEPTH_INTERPOLATED",
|
|
248
|
+
]
|
|
249
|
+
for name in depth_candidates:
|
|
250
|
+
if name in ds_gridded.coords or name in ds_gridded.variables:
|
|
251
|
+
dpth_key = name
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
# Get time key using helper
|
|
255
|
+
time_key = get_time_key(ds_gridded)
|
|
256
|
+
|
|
257
|
+
# Determine dimensions
|
|
258
|
+
time_dim = ds_gridded[time_key].dims[0] if ds_gridded[time_key].dims else None
|
|
259
|
+
pres_dim = None
|
|
260
|
+
if pres_key is None and dpth_key is not None:
|
|
261
|
+
pres_dims = ds_gridded[dpth_key].dims
|
|
262
|
+
if len(pres_dims) > 1:
|
|
263
|
+
pres_dim = next(dim for dim in pres_dims if dim != time_dim)
|
|
264
|
+
else:
|
|
265
|
+
pres_dim = pres_dims[0]
|
|
266
|
+
|
|
267
|
+
elif pres_key is not None:
|
|
268
|
+
pres_dims = ds_gridded[pres_key].dims
|
|
269
|
+
if len(pres_dims) > 1:
|
|
270
|
+
pres_dim = next(dim for dim in pres_dims if dim != time_dim)
|
|
271
|
+
else:
|
|
272
|
+
pres_dim = pres_dims[0]
|
|
273
|
+
|
|
274
|
+
return pres_key or dpth_key, time_key, pres_dim, time_dim
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def iso8601_duration_from_seconds(seconds: float) -> str:
|
|
278
|
+
"""Convert a duration in seconds to an ISO 8601 duration string.
|
|
279
|
+
|
|
280
|
+
Parameters
|
|
281
|
+
----------
|
|
282
|
+
seconds : float
|
|
283
|
+
Duration in seconds.
|
|
284
|
+
|
|
285
|
+
Returns
|
|
286
|
+
-------
|
|
287
|
+
str
|
|
288
|
+
ISO 8601 duration string, e.g., 'PT1H', 'PT30M', 'PT15S'.
|
|
289
|
+
|
|
290
|
+
"""
|
|
291
|
+
seconds = int(round(seconds))
|
|
292
|
+
if seconds >= 86400:
|
|
293
|
+
return f"P{seconds // 86400}D"
|
|
294
|
+
elif seconds >= 3600:
|
|
295
|
+
return f"PT{seconds // 3600}H"
|
|
296
|
+
elif seconds >= 60:
|
|
297
|
+
return f"PT{seconds // 60}M"
|
|
298
|
+
else:
|
|
299
|
+
return f"PT{seconds}S"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def is_iso8601_utc(timestr: str) -> bool:
|
|
303
|
+
"""Validate whether a string is in ISO8601 UTC format: YYYY-MM-DDTHH:MM:SSZ.
|
|
304
|
+
|
|
305
|
+
Parameters
|
|
306
|
+
----------
|
|
307
|
+
timestr : str
|
|
308
|
+
Input time string.
|
|
309
|
+
|
|
310
|
+
Returns
|
|
311
|
+
-------
|
|
312
|
+
bool
|
|
313
|
+
True if valid ISO8601 UTC format, False otherwise.
|
|
314
|
+
|
|
315
|
+
"""
|
|
316
|
+
try:
|
|
317
|
+
datetime.strptime(timestr, "%Y/%m/%dT%H:%M:%SZ") # RODB-style
|
|
318
|
+
except ValueError:
|
|
319
|
+
try:
|
|
320
|
+
datetime.strptime(timestr, "%Y-%m-%dT%H:%M:%SZ") # ISO8601 style
|
|
321
|
+
except ValueError:
|
|
322
|
+
return False
|
|
323
|
+
return True
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def apply_defaults(default_source: str, default_files: List[str]) -> Callable:
|
|
327
|
+
"""Decorate a function to apply default values for source and file_list parameters.
|
|
328
|
+
|
|
329
|
+
Parameters
|
|
330
|
+
----------
|
|
331
|
+
default_source : str
|
|
332
|
+
Default source URL or path.
|
|
333
|
+
default_files : list of str
|
|
334
|
+
Default list of filenames.
|
|
335
|
+
|
|
336
|
+
Returns
|
|
337
|
+
-------
|
|
338
|
+
Callable
|
|
339
|
+
A wrapped function with defaults applied.
|
|
340
|
+
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
def decorator(func: Callable) -> Callable:
|
|
344
|
+
@wraps(func)
|
|
345
|
+
def wrapper(
|
|
346
|
+
source: Optional[str] = None,
|
|
347
|
+
file_list: Optional[List[str]] = None,
|
|
348
|
+
*args: Any,
|
|
349
|
+
**kwargs: Any,
|
|
350
|
+
) -> Any:
|
|
351
|
+
if source is None:
|
|
352
|
+
source = default_source
|
|
353
|
+
if file_list is None:
|
|
354
|
+
file_list = default_files
|
|
355
|
+
return func(source=source, file_list=file_list, *args, **kwargs) # noqa: B026
|
|
356
|
+
|
|
357
|
+
return wrapper
|
|
358
|
+
|
|
359
|
+
return decorator
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _status(kind: str, msg: str, *, char: str = "=") -> None:
|
|
363
|
+
"""Print a formatted status line to stdout.
|
|
364
|
+
|
|
365
|
+
kind msg char (optional)
|
|
366
|
+
------- ------------------------- ---------------
|
|
367
|
+
section "Stage 1: dsG3_1_2026" border char, default "=" → "=== Stage 1: dsG3_1_2026 ==="
|
|
368
|
+
instr "<type> <serial>" ignored → "--- microcat 7507 ---"
|
|
369
|
+
file relative path ignored → "Creating output file: proc/..."
|
|
370
|
+
skip relative path ignored → " OUTFILE EXISTS: proc/... (use --force to overwrite)"
|
|
371
|
+
error message ignored → "ERROR: ..."
|
|
372
|
+
|
|
373
|
+
Pass ``char`` to ``section`` calls to distinguish mooring runs in a multi-mooring
|
|
374
|
+
script, e.g. ``char="*"`` gives ``"*** Stage 1: dsG3_2_2026 ***"``.
|
|
375
|
+
"""
|
|
376
|
+
if kind == "section":
|
|
377
|
+
border = char * 3
|
|
378
|
+
print(f"\n{border} {msg} {border}")
|
|
379
|
+
elif kind == "instr":
|
|
380
|
+
print(f"\n--- {msg} ---")
|
|
381
|
+
elif kind == "file":
|
|
382
|
+
print(f"Creating output file: {msg}")
|
|
383
|
+
elif kind == "skip":
|
|
384
|
+
print(f" OUTFILE EXISTS: {msg} (use --force to overwrite)")
|
|
385
|
+
elif kind == "error":
|
|
386
|
+
print(f"ERROR: {msg}")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _nice_colorbar_bounds(vmin: float, vmax: float, n: int = 20) -> np.ndarray:
|
|
390
|
+
"""Return a boundary array for a discrete colorbar with approximately *n* levels.
|
|
391
|
+
|
|
392
|
+
The step is rounded to 1 significant figure so tick labels land on clean values.
|
|
393
|
+
The range is centered on the midpoint of [vmin, vmax].
|
|
394
|
+
|
|
395
|
+
Examples
|
|
396
|
+
--------
|
|
397
|
+
Temperature vmin=0.87, vmax=7.23 → step=0.3, bounds 1.2–7.2 (20 levels)
|
|
398
|
+
Salinity vmin=34.782, vmax=35.139 → step=0.02, bounds 34.76–35.16 (20 levels)
|
|
399
|
+
|
|
400
|
+
"""
|
|
401
|
+
span = vmax - vmin
|
|
402
|
+
if span <= 0:
|
|
403
|
+
return np.linspace(vmin - 1, vmin + 1, n + 1)
|
|
404
|
+
raw_step = span / n
|
|
405
|
+
mag = 10.0 ** math.floor(math.log10(raw_step))
|
|
406
|
+
rounded = round(raw_step / mag)
|
|
407
|
+
if rounded == 0:
|
|
408
|
+
rounded = 1
|
|
409
|
+
elif rounded >= 10:
|
|
410
|
+
mag *= 10
|
|
411
|
+
rounded = 1
|
|
412
|
+
nice_step = rounded * mag
|
|
413
|
+
mid = (vmin + vmax) / 2
|
|
414
|
+
mid_aligned = round(mid / nice_step) * nice_step
|
|
415
|
+
lo = mid_aligned - (n / 2) * nice_step
|
|
416
|
+
return np.array([lo + i * nice_step for i in range(n + 1)])
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# ---------------------------------------------------------------------------
|
|
420
|
+
# Output dtype helpers
|
|
421
|
+
# ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def find_best_dtype(var_name: str, da: xr.DataArray) -> type:
|
|
425
|
+
"""Determine the optimal storage dtype for a variable.
|
|
426
|
+
|
|
427
|
+
Parameters
|
|
428
|
+
----------
|
|
429
|
+
var_name : str
|
|
430
|
+
Variable name.
|
|
431
|
+
da : xr.DataArray
|
|
432
|
+
Data array to inspect.
|
|
433
|
+
|
|
434
|
+
Returns
|
|
435
|
+
-------
|
|
436
|
+
type
|
|
437
|
+
Recommended numpy dtype.
|
|
438
|
+
|
|
439
|
+
Notes
|
|
440
|
+
-----
|
|
441
|
+
Rules applied in order:
|
|
442
|
+
|
|
443
|
+
- String / datetime / object variables: unchanged.
|
|
444
|
+
- ``time`` in name: unchanged (preserve datetime64 / float encoding).
|
|
445
|
+
- ``*_qc`` suffix or ``flag`` in name: ``int8``.
|
|
446
|
+
- ``serial_number`` or ``serial``: ``int32``.
|
|
447
|
+
- ``latitude`` / ``longitude`` in name: ``float64``.
|
|
448
|
+
- Integer input: downsize to ``int32`` if stored as ``int64``, else unchanged.
|
|
449
|
+
- ``float64`` input: ``float32``.
|
|
450
|
+
- Anything else: unchanged.
|
|
451
|
+
|
|
452
|
+
"""
|
|
453
|
+
input_dtype = da.dtype.type
|
|
454
|
+
if da.dtype.kind in ("U", "S", "O", "M"):
|
|
455
|
+
return input_dtype
|
|
456
|
+
if "time" in var_name.lower():
|
|
457
|
+
return input_dtype
|
|
458
|
+
if var_name.endswith("_qc") or "flag" in var_name:
|
|
459
|
+
return np.int8
|
|
460
|
+
if var_name in ("serial_number", "serial"):
|
|
461
|
+
return np.int32
|
|
462
|
+
if "latitude" in var_name.lower() or "longitude" in var_name.lower():
|
|
463
|
+
return np.float64
|
|
464
|
+
if da.dtype.kind in ("i", "u") and da.dtype.itemsize == 8:
|
|
465
|
+
return np.int32
|
|
466
|
+
if input_dtype == np.float64:
|
|
467
|
+
return np.float32
|
|
468
|
+
return input_dtype
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def drop_all_zero_vars(ds: xr.Dataset, prefixes: list[str]) -> xr.Dataset:
|
|
472
|
+
"""Drop variables whose finite values are all zero.
|
|
473
|
+
|
|
474
|
+
Parameters
|
|
475
|
+
----------
|
|
476
|
+
ds : xr.Dataset
|
|
477
|
+
Dataset to filter.
|
|
478
|
+
prefixes : list of str
|
|
479
|
+
Variable name prefixes to check (e.g. ``["amplitude_beam", "analog_input_"]``).
|
|
480
|
+
Any variable whose name starts with one of these prefixes and whose
|
|
481
|
+
finite values are all zero (or has no finite values) is dropped.
|
|
482
|
+
|
|
483
|
+
Returns
|
|
484
|
+
-------
|
|
485
|
+
xr.Dataset
|
|
486
|
+
Dataset with all-zero prefix-matched variables removed.
|
|
487
|
+
|
|
488
|
+
"""
|
|
489
|
+
to_drop = []
|
|
490
|
+
for vname in ds.data_vars:
|
|
491
|
+
if not any(vname.startswith(p) for p in prefixes):
|
|
492
|
+
continue
|
|
493
|
+
arr = ds[vname].values
|
|
494
|
+
finite = arr[np.isfinite(arr)] if np.issubdtype(arr.dtype, np.floating) else arr
|
|
495
|
+
if finite.size == 0 or np.all(finite == 0):
|
|
496
|
+
to_drop.append(vname)
|
|
497
|
+
return ds.drop_vars(to_drop) if to_drop else ds
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def cast_output_dtypes(ds: xr.Dataset) -> xr.Dataset:
|
|
501
|
+
"""Cast every variable in *ds* to its optimal storage dtype.
|
|
502
|
+
|
|
503
|
+
Calls :func:`find_best_dtype` per variable and rebuilds only those that
|
|
504
|
+
change. Attributes are preserved. The input dataset is not modified.
|
|
505
|
+
|
|
506
|
+
Parameters
|
|
507
|
+
----------
|
|
508
|
+
ds : xr.Dataset
|
|
509
|
+
Dataset to cast.
|
|
510
|
+
|
|
511
|
+
Returns
|
|
512
|
+
-------
|
|
513
|
+
xr.Dataset
|
|
514
|
+
New dataset with optimised dtypes, ready for NetCDF output.
|
|
515
|
+
|
|
516
|
+
"""
|
|
517
|
+
updates: dict[str, xr.Variable] = {}
|
|
518
|
+
for vname in ds.data_vars:
|
|
519
|
+
var = ds[vname]
|
|
520
|
+
target = find_best_dtype(vname, var)
|
|
521
|
+
if np.dtype(target) != var.dtype:
|
|
522
|
+
if np.issubdtype(np.dtype(target), np.integer) and np.issubdtype(
|
|
523
|
+
var.dtype, np.floating
|
|
524
|
+
):
|
|
525
|
+
# NaN cannot be represented as an integer; replace before cast.
|
|
526
|
+
# QC variables use 9 (CF "missing value"); other integer vars use 0.
|
|
527
|
+
fill_val = 9 if (vname.endswith("_qc") or "flag" in vname) else 0
|
|
528
|
+
safe_vals = np.where(np.isfinite(var.values), var.values, fill_val)
|
|
529
|
+
updates[vname] = xr.Variable(
|
|
530
|
+
var.dims, safe_vals.astype(target), attrs=var.attrs
|
|
531
|
+
)
|
|
532
|
+
else:
|
|
533
|
+
updates[vname] = xr.Variable(
|
|
534
|
+
var.dims, var.values.astype(target), attrs=var.attrs
|
|
535
|
+
)
|
|
536
|
+
if not updates:
|
|
537
|
+
return ds
|
|
538
|
+
return ds.assign(updates)
|