microstructure-tpw 0.3.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.
- microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
- microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
- microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
- microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
- microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
- microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
- odas_tpw/__init__.py +18 -0
- odas_tpw/chi/__init__.py +48 -0
- odas_tpw/chi/batchelor.py +189 -0
- odas_tpw/chi/chi.py +857 -0
- odas_tpw/chi/fp07.py +649 -0
- odas_tpw/chi/l2_chi.py +153 -0
- odas_tpw/chi/l3_chi.py +388 -0
- odas_tpw/chi/l4_chi.py +391 -0
- odas_tpw/config_base.py +423 -0
- odas_tpw/perturb/__init__.py +18 -0
- odas_tpw/perturb/adcp.py +274 -0
- odas_tpw/perturb/atomic_io.py +47 -0
- odas_tpw/perturb/binning.py +761 -0
- odas_tpw/perturb/cli.py +591 -0
- odas_tpw/perturb/combo.py +346 -0
- odas_tpw/perturb/config.py +1046 -0
- odas_tpw/perturb/ctd.py +424 -0
- odas_tpw/perturb/diag/__init__.py +12 -0
- odas_tpw/perturb/diag/_common.py +140 -0
- odas_tpw/perturb/diag/chi.py +42 -0
- odas_tpw/perturb/diag/cli.py +67 -0
- odas_tpw/perturb/diag/data.py +662 -0
- odas_tpw/perturb/diag/epsilon.py +45 -0
- odas_tpw/perturb/diag/inspector.py +342 -0
- odas_tpw/perturb/diag/mixing.py +94 -0
- odas_tpw/perturb/diag/render.py +543 -0
- odas_tpw/perturb/discover.py +40 -0
- odas_tpw/perturb/fp07_cal.py +354 -0
- odas_tpw/perturb/gps.py +458 -0
- odas_tpw/perturb/hotel.py +572 -0
- odas_tpw/perturb/logging_setup.py +181 -0
- odas_tpw/perturb/merge.py +320 -0
- odas_tpw/perturb/mk_sections.py +310 -0
- odas_tpw/perturb/netcdf_schema.py +520 -0
- odas_tpw/perturb/pipeline.py +2654 -0
- odas_tpw/perturb/plot/__init__.py +15 -0
- odas_tpw/perturb/plot/cli.py +107 -0
- odas_tpw/perturb/plot/diagnostics.py +195 -0
- odas_tpw/perturb/plot/eps_chi.py +524 -0
- odas_tpw/perturb/plot/figure.py +679 -0
- odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
- odas_tpw/perturb/plot/grid.py +112 -0
- odas_tpw/perturb/plot/layout.py +361 -0
- odas_tpw/perturb/plot/overview.py +547 -0
- odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
- odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
- odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
- odas_tpw/perturb/plot/profiles.py +652 -0
- odas_tpw/perturb/plot/scalar.py +391 -0
- odas_tpw/perturb/plot/sections.py +506 -0
- odas_tpw/perturb/plot/xaxis.py +396 -0
- odas_tpw/perturb/qc_gate.py +177 -0
- odas_tpw/perturb/qc_rules.py +373 -0
- odas_tpw/perturb/resolve.py +306 -0
- odas_tpw/perturb/seawater.py +76 -0
- odas_tpw/perturb/speed.py +14 -0
- odas_tpw/perturb/trim.py +313 -0
- odas_tpw/processing/__init__.py +62 -0
- odas_tpw/processing/bottom.py +215 -0
- odas_tpw/processing/chi_combine.py +300 -0
- odas_tpw/processing/ct_align.py +172 -0
- odas_tpw/processing/epsilon_combine.py +210 -0
- odas_tpw/processing/mixing.py +630 -0
- odas_tpw/processing/thorpe.py +458 -0
- odas_tpw/processing/top_trim.py +274 -0
- odas_tpw/pyturb/__init__.py +3 -0
- odas_tpw/pyturb/__main__.py +5 -0
- odas_tpw/pyturb/_compat.py +204 -0
- odas_tpw/pyturb/_profind.py +124 -0
- odas_tpw/pyturb/bin.py +113 -0
- odas_tpw/pyturb/cli.py +175 -0
- odas_tpw/pyturb/eps.py +353 -0
- odas_tpw/pyturb/merge.py +101 -0
- odas_tpw/pyturb/p2nc.py +76 -0
- odas_tpw/rsi/__init__.py +31 -0
- odas_tpw/rsi/adapter.py +314 -0
- odas_tpw/rsi/bench.py +904 -0
- odas_tpw/rsi/binning.py +193 -0
- odas_tpw/rsi/channels.py +376 -0
- odas_tpw/rsi/chi_io.py +980 -0
- odas_tpw/rsi/cli.py +1411 -0
- odas_tpw/rsi/combine.py +120 -0
- odas_tpw/rsi/config.py +126 -0
- odas_tpw/rsi/config_patch.py +790 -0
- odas_tpw/rsi/convert.py +651 -0
- odas_tpw/rsi/deconvolve.py +119 -0
- odas_tpw/rsi/diss_look.py +343 -0
- odas_tpw/rsi/dissipation.py +669 -0
- odas_tpw/rsi/helpers.py +527 -0
- odas_tpw/rsi/mixing_look.py +509 -0
- odas_tpw/rsi/p_file.py +771 -0
- odas_tpw/rsi/pipeline.py +924 -0
- odas_tpw/rsi/profile.py +624 -0
- odas_tpw/rsi/quick_look.py +758 -0
- odas_tpw/rsi/sensor_inventory.py +684 -0
- odas_tpw/rsi/shear_noise.py +128 -0
- odas_tpw/rsi/speed.py +221 -0
- odas_tpw/rsi/vehicle.py +74 -0
- odas_tpw/rsi/viewer_base.py +1071 -0
- odas_tpw/rsi/window.py +459 -0
- odas_tpw/scor160/__init__.py +36 -0
- odas_tpw/scor160/cli.py +242 -0
- odas_tpw/scor160/compare.py +484 -0
- odas_tpw/scor160/despike.py +227 -0
- odas_tpw/scor160/goodman.py +288 -0
- odas_tpw/scor160/io.py +690 -0
- odas_tpw/scor160/l2.py +311 -0
- odas_tpw/scor160/l3.py +279 -0
- odas_tpw/scor160/l4.py +850 -0
- odas_tpw/scor160/nasmyth.py +208 -0
- odas_tpw/scor160/ocean.py +287 -0
- odas_tpw/scor160/profile.py +231 -0
- odas_tpw/scor160/spectral.py +482 -0
odas_tpw/rsi/binning.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Depth binning for dissipation and chi estimates (L5)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import xarray as xr
|
|
7
|
+
|
|
8
|
+
# CF attributes (units/long_name/standard_name) for the variables that flow
|
|
9
|
+
# through the rsi L5/L6 products. Defect: bin_by_depth previously attached only
|
|
10
|
+
# cell_methods, so epsilon/chi/K_T/Gamma/K_rho/P_mean/speed/N2/dTdz reached
|
|
11
|
+
# L5_binned.nc / L6_combined.nc with no units — uncatalogued, non-CF-compliant
|
|
12
|
+
# headline products. Keyed by the variable names passed from
|
|
13
|
+
# rsi/pipeline.py:bin_by_depth(...); unknown names are left metadata-free.
|
|
14
|
+
_VAR_CF_ATTRS: dict[str, dict[str, str]] = {
|
|
15
|
+
"epsilon": {
|
|
16
|
+
"units": "W kg-1",
|
|
17
|
+
"long_name": "TKE dissipation rate",
|
|
18
|
+
"standard_name": (
|
|
19
|
+
"specific_turbulent_kinetic_energy_dissipation_in_sea_water"
|
|
20
|
+
),
|
|
21
|
+
},
|
|
22
|
+
"chi": {
|
|
23
|
+
"units": "K2 s-1",
|
|
24
|
+
"long_name": "thermal variance dissipation rate",
|
|
25
|
+
},
|
|
26
|
+
"P_mean": {
|
|
27
|
+
"units": "dbar",
|
|
28
|
+
"long_name": "mean pressure within bin",
|
|
29
|
+
"standard_name": "sea_water_pressure",
|
|
30
|
+
},
|
|
31
|
+
"speed": {
|
|
32
|
+
"units": "m s-1",
|
|
33
|
+
"long_name": "profiling speed",
|
|
34
|
+
},
|
|
35
|
+
"N2": {
|
|
36
|
+
"units": "s-2",
|
|
37
|
+
"long_name": "buoyancy frequency squared",
|
|
38
|
+
"standard_name": "square_of_brunt_vaisala_frequency_in_sea_water",
|
|
39
|
+
},
|
|
40
|
+
"dTdz": {
|
|
41
|
+
"units": "K m-1",
|
|
42
|
+
"long_name": "background temperature gradient (positive down)",
|
|
43
|
+
},
|
|
44
|
+
"K_T": {
|
|
45
|
+
"units": "m2 s-1",
|
|
46
|
+
"long_name": "Osborn-Cox eddy diffusivity of heat",
|
|
47
|
+
},
|
|
48
|
+
"K_rho": {
|
|
49
|
+
"units": "m2 s-1",
|
|
50
|
+
"long_name": "Osborn diapycnal diffusivity",
|
|
51
|
+
},
|
|
52
|
+
"Gamma": {
|
|
53
|
+
"units": "1",
|
|
54
|
+
"long_name": "mixing coefficient",
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def bin_by_depth(
|
|
60
|
+
pres: np.ndarray,
|
|
61
|
+
values: dict[str, np.ndarray],
|
|
62
|
+
bin_size: float = 1.0,
|
|
63
|
+
pres_range: tuple[float, float] | None = None,
|
|
64
|
+
log_mean_vars: set[str] | None = None,
|
|
65
|
+
) -> xr.Dataset:
|
|
66
|
+
"""Bin estimates by depth (pressure).
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
pres : ndarray, shape (N,)
|
|
71
|
+
Pressure per estimate [dbar].
|
|
72
|
+
values : dict of str -> ndarray
|
|
73
|
+
Variables to bin. Each value has shape (N,) or (M, N) where M is
|
|
74
|
+
the number of probes. 2-D arrays are reduced across probes first
|
|
75
|
+
(geometric mean for log_mean_vars, arithmetic mean otherwise).
|
|
76
|
+
bin_size : float
|
|
77
|
+
Bin size [dbar]. Default: 1.0.
|
|
78
|
+
pres_range : tuple of (min, max), optional
|
|
79
|
+
Pressure range for binning. Default: data range.
|
|
80
|
+
log_mean_vars : set of str, optional
|
|
81
|
+
Variables to average using the geometric (log-space) mean. Default:
|
|
82
|
+
``None`` → empty, i.e. EVERY variable is averaged arithmetically.
|
|
83
|
+
|
|
84
|
+
This matches the perturb pipeline, which depth-bins epsilon/chi with
|
|
85
|
+
``np.nanmean`` (arithmetic). For lognormally distributed dissipation the
|
|
86
|
+
arithmetic mean is the unbiased estimator of the true mean — the
|
|
87
|
+
flux-relevant quantity, and what the diapycnal diffusivities (K_T, K_rho,
|
|
88
|
+
Gamma) computed from these estimates require. The geometric mean instead
|
|
89
|
+
estimates the MEDIAN and biases binned dissipation low. Pass an explicit
|
|
90
|
+
set here to opt specific variables back into geometric averaging.
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
xr.Dataset with dimension ``depth_bin``.
|
|
95
|
+
"""
|
|
96
|
+
if log_mean_vars is None:
|
|
97
|
+
log_mean_vars = set()
|
|
98
|
+
|
|
99
|
+
pres = np.asarray(pres, dtype=np.float64)
|
|
100
|
+
valid = np.isfinite(pres)
|
|
101
|
+
if not valid.any():
|
|
102
|
+
return xr.Dataset()
|
|
103
|
+
|
|
104
|
+
if pres_range is None:
|
|
105
|
+
p_min = np.floor(np.nanmin(pres) / bin_size) * bin_size
|
|
106
|
+
p_max = np.ceil(np.nanmax(pres) / bin_size) * bin_size
|
|
107
|
+
else:
|
|
108
|
+
p_min, p_max = pres_range
|
|
109
|
+
|
|
110
|
+
bin_edges = np.arange(p_min, p_max + bin_size, bin_size)
|
|
111
|
+
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
|
|
112
|
+
n_bins = len(bin_centers)
|
|
113
|
+
|
|
114
|
+
# Assign samples to bins. Samples with NaN pressure (np.digitize
|
|
115
|
+
# returns len(edges)) or outside [p_min, p_max] are EXCLUDED — the
|
|
116
|
+
# previous np.clip silently dumped them into the shallowest/deepest
|
|
117
|
+
# bin, contaminating the edge bins. Bins are half-open [lo, hi)
|
|
118
|
+
# except the last, which includes its upper edge.
|
|
119
|
+
with np.errstate(invalid="ignore"):
|
|
120
|
+
bin_idx = np.digitize(pres, bin_edges) - 1 # 0-indexed
|
|
121
|
+
bin_idx = np.where(pres == bin_edges[-1], n_bins - 1, bin_idx)
|
|
122
|
+
in_range = np.isfinite(pres) & (bin_idx >= 0) & (bin_idx < n_bins)
|
|
123
|
+
bin_idx = np.where(in_range, bin_idx, 0) # placeholder; masked below
|
|
124
|
+
|
|
125
|
+
data_vars = {}
|
|
126
|
+
for name, arr in values.items():
|
|
127
|
+
arr = np.asarray(arr, dtype=np.float64)
|
|
128
|
+
|
|
129
|
+
# Reduce 2-D arrays (probes x time) → 1-D
|
|
130
|
+
if arr.ndim == 2:
|
|
131
|
+
if name in log_mean_vars:
|
|
132
|
+
with np.errstate(divide="ignore"):
|
|
133
|
+
arr = np.exp(np.nanmean(np.log(np.where(arr > 0, arr, np.nan)), axis=0))
|
|
134
|
+
else:
|
|
135
|
+
arr = np.nanmean(arr, axis=0)
|
|
136
|
+
|
|
137
|
+
# Vectorize per-bin reduction with bincount. Log-mean uses
|
|
138
|
+
# log(x) accumulators with strictly-positive values; arithmetic
|
|
139
|
+
# mean uses values directly. Both paths drop NaN (and ≤0 for
|
|
140
|
+
# log-mean) up front, then accumulate sums and counts in one
|
|
141
|
+
# linear pass.
|
|
142
|
+
if name in log_mean_vars:
|
|
143
|
+
valid = in_range & np.isfinite(arr) & (arr > 0)
|
|
144
|
+
log_vals = np.log(arr, where=valid, out=np.full_like(arr, np.nan))
|
|
145
|
+
idx_v = bin_idx[valid]
|
|
146
|
+
log_v = log_vals[valid]
|
|
147
|
+
counts_v = np.bincount(idx_v, minlength=n_bins)
|
|
148
|
+
sums_v = np.bincount(idx_v, weights=log_v, minlength=n_bins)
|
|
149
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
150
|
+
binned = np.where(
|
|
151
|
+
counts_v > 0, np.exp(sums_v / np.maximum(counts_v, 1)), np.nan
|
|
152
|
+
)
|
|
153
|
+
attrs = {
|
|
154
|
+
"cell_methods": "depth_bin: geometric mean",
|
|
155
|
+
"comment": (
|
|
156
|
+
"Geometric (log-space) mean within each depth bin. For "
|
|
157
|
+
"lognormally distributed dissipation this estimates the "
|
|
158
|
+
"MEDIAN, not the arithmetic mean; use the arithmetic mean "
|
|
159
|
+
"for budget (flux) calculations."
|
|
160
|
+
),
|
|
161
|
+
}
|
|
162
|
+
else:
|
|
163
|
+
valid = in_range & np.isfinite(arr)
|
|
164
|
+
idx_v = bin_idx[valid]
|
|
165
|
+
vals_v = arr[valid]
|
|
166
|
+
counts_v = np.bincount(idx_v, minlength=n_bins)
|
|
167
|
+
sums_v = np.bincount(idx_v, weights=vals_v, minlength=n_bins)
|
|
168
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
169
|
+
binned = np.where(counts_v > 0, sums_v / np.maximum(counts_v, 1), np.nan)
|
|
170
|
+
attrs = {"cell_methods": "depth_bin: mean"}
|
|
171
|
+
|
|
172
|
+
# Defect fix: merge CF units/long_name/standard_name so the binned
|
|
173
|
+
# products are cataloged. cell_methods stays authoritative; CF attrs
|
|
174
|
+
# only fill in for known variables (unknown names stay metadata-free).
|
|
175
|
+
attrs = {**_VAR_CF_ATTRS.get(name, {}), **attrs}
|
|
176
|
+
|
|
177
|
+
data_vars[name] = (["depth_bin"], binned, attrs)
|
|
178
|
+
|
|
179
|
+
ds = xr.Dataset(
|
|
180
|
+
data_vars,
|
|
181
|
+
coords={"depth_bin": bin_centers},
|
|
182
|
+
)
|
|
183
|
+
ds.coords["depth_bin"].attrs.update(
|
|
184
|
+
{
|
|
185
|
+
"units": "dbar",
|
|
186
|
+
"long_name": "depth bin center pressure",
|
|
187
|
+
"standard_name": "sea_water_pressure",
|
|
188
|
+
"positive": "down",
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
ds.attrs["bin_size"] = bin_size
|
|
192
|
+
|
|
193
|
+
return ds
|
odas_tpw/rsi/channels.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# Mar-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""
|
|
3
|
+
Channel conversion functions: raw counts -> physical units.
|
|
4
|
+
|
|
5
|
+
Ported from the ODAS MATLAB Library convert_odas.m.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import warnings
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _safe_float(s: Any, default: float = 0.0) -> float:
|
|
15
|
+
"""Convert *s* to float, returning *default* on failure."""
|
|
16
|
+
try:
|
|
17
|
+
return float(s)
|
|
18
|
+
except (ValueError, TypeError):
|
|
19
|
+
return default
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _require_float(params: dict[str, Any], key: str, default: float, sensor: str) -> float:
|
|
23
|
+
"""Like _safe_float, but warn loudly when a sensor-specific calibration
|
|
24
|
+
coefficient is missing from the channel config.
|
|
25
|
+
|
|
26
|
+
ODAS convert_odas.m errors outright in this situation; silently
|
|
27
|
+
substituting a generic default would produce plausible-looking but
|
|
28
|
+
wrong physical units (e.g. a missing shear ``sens`` scales epsilon by
|
|
29
|
+
the square of the sensitivity error).
|
|
30
|
+
"""
|
|
31
|
+
if key not in params or params.get(key) in (None, ""):
|
|
32
|
+
warnings.warn(
|
|
33
|
+
f"{sensor}: calibration coefficient '{key}' missing from channel "
|
|
34
|
+
f"config; using default {default} — physical units are suspect",
|
|
35
|
+
stacklevel=3,
|
|
36
|
+
)
|
|
37
|
+
return default
|
|
38
|
+
return _safe_float(params[key], default)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _adis_14bit(data: np.ndarray) -> np.ndarray:
|
|
42
|
+
"""Extract 14-bit data from ADIS16209 inclinometer words.
|
|
43
|
+
|
|
44
|
+
Matches ODAS ``adis.m``: clears status bits (15 = new-data,
|
|
45
|
+
14 = error), then applies 14-bit two's complement. The
|
|
46
|
+
inclination X/Y channels use 14-bit signed data; the
|
|
47
|
+
temperature channel uses 12-bit unsigned, which passes through
|
|
48
|
+
the two's complement step unchanged.
|
|
49
|
+
|
|
50
|
+
Note: the strict ``< -2**14`` test (kept to match ``adis.m:48``)
|
|
51
|
+
mis-decodes error-flagged words 0xC000-0xDFFF (bit14+bit13 set). This is a
|
|
52
|
+
faithful port of an ODAS quirk; it touches only the auxiliary inclinometer
|
|
53
|
+
channels, never shear/temperature, and no such words occur in the ARCTERX
|
|
54
|
+
data (verified across all 29 files). Left as-is for ODAS parity (#1).
|
|
55
|
+
"""
|
|
56
|
+
val = data.copy().astype(np.float64)
|
|
57
|
+
# Bit 15 set → new-data flag. Clear it.
|
|
58
|
+
mask = val < -(2**14)
|
|
59
|
+
val[mask] += 2**15
|
|
60
|
+
# Bit 14 set → error flag. Clear it.
|
|
61
|
+
mask = val >= 2**14
|
|
62
|
+
val[mask] -= 2**14
|
|
63
|
+
# Two's complement for the upper half of the 14-bit range.
|
|
64
|
+
mask = val >= 2**13
|
|
65
|
+
val[mask] -= 2**14
|
|
66
|
+
return val
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _unsigned_16bit(data: np.ndarray) -> np.ndarray:
|
|
70
|
+
"""Convert signed int16 to unsigned by wrapping negative values.
|
|
71
|
+
|
|
72
|
+
The caller may pass already-unsigned data (the PFile loader's
|
|
73
|
+
upstream wrap step views int16 channels as uint16 in place); in that
|
|
74
|
+
case the negative-mask is empty and the array is returned as-is
|
|
75
|
+
(with a copy to keep the existing contract that callers may safely
|
|
76
|
+
mutate the result).
|
|
77
|
+
"""
|
|
78
|
+
if data.dtype.kind == "u":
|
|
79
|
+
return data.copy()
|
|
80
|
+
if data.dtype.kind == "i" and data.dtype.itemsize <= 2:
|
|
81
|
+
# Native +2**16 would overflow int16; promote first.
|
|
82
|
+
d = data.astype(np.int32)
|
|
83
|
+
else:
|
|
84
|
+
d = data.copy()
|
|
85
|
+
d[d < 0] += 2**16
|
|
86
|
+
return d
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _deglitch_rs232(d: np.ndarray) -> np.ndarray:
|
|
90
|
+
"""Forward-fill obvious RS232-receiver glitches (RINKO FT sensors).
|
|
91
|
+
|
|
92
|
+
Faithful port of the "Temporary fix of a buggy RS232 receiver" in ODAS
|
|
93
|
+
``convert_odas.m`` (odas_aroft_o2_internal / odas_aroft_t_internal): any
|
|
94
|
+
sample sitting at/near the unsigned rails (``<= 50`` or ``>= 2^16 - 50``)
|
|
95
|
+
is replaced by the preceding sample. The first sample is never replaced
|
|
96
|
+
(ODAS loops from index 2, 1-based), and a run of consecutive glitches all
|
|
97
|
+
inherit the last good value. Applied to the UNSIGNED-wrapped values, i.e.
|
|
98
|
+
after :func:`_unsigned_16bit`, matching the ODAS ordering. (#104 U1-2.)
|
|
99
|
+
"""
|
|
100
|
+
out = np.asarray(d, dtype=np.float64).copy()
|
|
101
|
+
if out.size == 0:
|
|
102
|
+
return out
|
|
103
|
+
glitch = (out <= 50) | (out >= 2**16 - 50)
|
|
104
|
+
glitch[0] = False # ODAS keeps the first sample regardless
|
|
105
|
+
# Standard forward-fill: map each glitch to the last non-glitch index.
|
|
106
|
+
idx = np.where(glitch, 0, np.arange(out.size))
|
|
107
|
+
np.maximum.accumulate(idx, out=idx)
|
|
108
|
+
return out[idx]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def convert_therm(data: np.ndarray, params: dict[str, Any]) -> tuple[np.ndarray, str]:
|
|
112
|
+
"""FP07 thermistor via Steinhart-Hart equation and half-bridge circuit.
|
|
113
|
+
|
|
114
|
+
Uses coefficients T_0, beta_1 (and optional beta_2, beta_3) from the
|
|
115
|
+
channel config. Matches ODAS ``convert_odas.m`` therm path.
|
|
116
|
+
"""
|
|
117
|
+
a = _safe_float(params.get("a", "0"))
|
|
118
|
+
b = _safe_float(params.get("b", "1"))
|
|
119
|
+
adc_fs = _safe_float(params.get("adc_fs", "4.096"))
|
|
120
|
+
adc_bits = _safe_float(params.get("adc_bits", "16"))
|
|
121
|
+
# g (gain) and e_b (bridge excitation) scale the bridge resistance and thus
|
|
122
|
+
# temperature directly — warn if the config omits them rather than silently
|
|
123
|
+
# using a default that may not match the instrument (as t_0 already does).
|
|
124
|
+
G = _require_float(params, "g", 6.0, "therm")
|
|
125
|
+
E_B = _require_float(params, "e_b", 0.68, "therm")
|
|
126
|
+
T_0 = _require_float(params, "t_0", 289.0, "therm")
|
|
127
|
+
# `beta` and `beta_1` are mutually exclusive alternatives for the linear
|
|
128
|
+
# Steinhart-Hart term (beta = legacy single-coeff form, beta_1 = newer
|
|
129
|
+
# multi-coeff form; beta_2/beta_3 are additive higher-order terms applied
|
|
130
|
+
# regardless). ODAS convert_odas.m:501-507 checks `beta` FIRST; match that
|
|
131
|
+
# precedence rather than preferring beta_1 (#4).
|
|
132
|
+
if "beta" in params:
|
|
133
|
+
beta_1 = _safe_float(params["beta"], 3000.0)
|
|
134
|
+
elif "beta_1" in params:
|
|
135
|
+
beta_1 = _safe_float(params["beta_1"], 3000.0)
|
|
136
|
+
else:
|
|
137
|
+
beta_1 = _require_float(params, "beta_1", 3000.0, "therm")
|
|
138
|
+
beta_2 = params.get("beta_2")
|
|
139
|
+
|
|
140
|
+
Z = ((data - a) / b) * (adc_fs / 2**adc_bits) * 2 / (G * E_B)
|
|
141
|
+
Z = np.clip(Z, -0.6, 0.6)
|
|
142
|
+
R_ratio = (1 - Z) / (1 + Z)
|
|
143
|
+
log_R = np.log(R_ratio)
|
|
144
|
+
|
|
145
|
+
inv_T = 1.0 / T_0 + (1.0 / beta_1) * log_R
|
|
146
|
+
if beta_2 is not None:
|
|
147
|
+
inv_T += (1.0 / _safe_float(beta_2)) * log_R**2
|
|
148
|
+
beta_3 = params.get("beta_3")
|
|
149
|
+
if beta_3 is not None:
|
|
150
|
+
inv_T += (1.0 / _safe_float(beta_3)) * log_R**3
|
|
151
|
+
return 1.0 / inv_T - 273.15, "deg_C"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def convert_shear(data: np.ndarray, params: dict[str, Any]) -> tuple[np.ndarray, str]:
|
|
155
|
+
"""Shear probe: raw counts to velocity shear [s⁻¹].
|
|
156
|
+
|
|
157
|
+
Formula: ``(adc_fs / 2^adc_bits * data + offset) / (2*sqrt(2)*diff_gain*sens)``.
|
|
158
|
+
"""
|
|
159
|
+
adc_fs = _safe_float(params.get("adc_fs", "4.096"))
|
|
160
|
+
adc_bits = _safe_float(params.get("adc_bits", "16"))
|
|
161
|
+
diff_gain = _require_float(params, "diff_gain", 1.0, "shear")
|
|
162
|
+
sens = _require_float(params, "sens", 1.0, "shear")
|
|
163
|
+
adc_zero = _safe_float(params.get("adc_zero", "0"))
|
|
164
|
+
sig_zero = _safe_float(params.get("sig_zero", "0"))
|
|
165
|
+
phys = (adc_fs / 2**adc_bits) * data + (adc_zero - sig_zero)
|
|
166
|
+
phys = phys / (2 * np.sqrt(2) * diff_gain * sens)
|
|
167
|
+
# NB: this is the ODAS intermediate — still MISSING the /speed^2 fall-rate
|
|
168
|
+
# normalization that makes it true physical shear (applied exactly once
|
|
169
|
+
# downstream in helpers.py + adapter). The units stay the UDUNITS-valid
|
|
170
|
+
# "s-1" so CF-declared per-profile files remain compliant; the
|
|
171
|
+
# pre-normalization caveat is surfaced CF-legally via the sh1/sh2 ``comment``
|
|
172
|
+
# attribute (set during channel conversion in PFile._read), not baked into
|
|
173
|
+
# ``units``. (#104 U1-1.)
|
|
174
|
+
return phys, "s-1"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def convert_poly(data: np.ndarray, params: dict[str, Any]) -> tuple[np.ndarray, str]:
|
|
178
|
+
"""Polynomial conversion (pressure, etc.): coef0 + coef1*x + coef2*x² + …"""
|
|
179
|
+
coeffs = []
|
|
180
|
+
for i in range(10):
|
|
181
|
+
key = f"coef{i}"
|
|
182
|
+
if key in params:
|
|
183
|
+
coeffs.append(_safe_float(params[key]))
|
|
184
|
+
else:
|
|
185
|
+
break
|
|
186
|
+
if not coeffs:
|
|
187
|
+
return data, "counts"
|
|
188
|
+
phys = np.polyval(coeffs[::-1], data)
|
|
189
|
+
units = params.get("units", "").strip("[]")
|
|
190
|
+
return phys, units or "unknown"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def convert_voltage(data: np.ndarray, params: dict[str, Any]) -> tuple[np.ndarray, str]:
|
|
194
|
+
"""Generic voltage channel: ``(adc_zero + data * adc_fs / 2^adc_bits) / gain`` → Volts."""
|
|
195
|
+
# adc_fs/adc_bits set the absolute voltage scale; a missing adc_bits used to
|
|
196
|
+
# default to 0 -> 2**0 = 1, silently mis-scaling the result by 2**16. Route
|
|
197
|
+
# through _require_float so omission warns loudly (default 16-bit ADC).
|
|
198
|
+
adc_fs = _require_float(params, "adc_fs", 1.0, "voltage")
|
|
199
|
+
adc_bits = _require_float(params, "adc_bits", 16.0, "voltage")
|
|
200
|
+
gain = _safe_float(params.get("g", "1"))
|
|
201
|
+
adc_zero = _safe_float(params.get("adc_zero", "0"))
|
|
202
|
+
phys = (adc_zero + data * adc_fs / 2**adc_bits) / gain
|
|
203
|
+
return phys, "V"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def convert_piezo(data: np.ndarray, params: dict[str, Any]) -> tuple[np.ndarray, str]:
|
|
207
|
+
"""Piezo accelerometer: subtract zero-offset ``a_0``."""
|
|
208
|
+
a_0 = _safe_float(params.get("a_0", "0"))
|
|
209
|
+
return data - a_0, "counts"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def convert_accel(data: np.ndarray, params: dict[str, Any]) -> tuple[np.ndarray, str]:
|
|
213
|
+
"""Linear (DC-response) accelerometer → m/s².
|
|
214
|
+
|
|
215
|
+
Matches ODAS ``convert_odas.m`` ``odas_accel_internal``:
|
|
216
|
+
``x = data*adc_fs/2^adc_bits + adc_zero - sig_zero``
|
|
217
|
+
``physical = 9.81 * (x - coef0) / coef1``
|
|
218
|
+
|
|
219
|
+
Used by older VMPs with calibrated accelerometers (in place of the
|
|
220
|
+
piezo type, whose output stays in counts).
|
|
221
|
+
"""
|
|
222
|
+
adc_zero = _safe_float(params.get("adc_zero", "0"))
|
|
223
|
+
# ODAS odas_accel_internal defaults adc_bits to 0 (2**0 = 1) and adc_fs to 1
|
|
224
|
+
# for the counts-based coef0/coef1 calibration used by legacy calibrated
|
|
225
|
+
# accelerometers that omit the ADC params. (Unlike convert_voltage, whose
|
|
226
|
+
# ODAS default IS 16 — do NOT copy that here: the 2026-06-19 audit
|
|
227
|
+
# disconfirmed a 16 default for accel, and PR #74's 16.0 mis-scaled such
|
|
228
|
+
# configs by 2**16. SN479 configs carry adc_bits=16 explicitly, so this
|
|
229
|
+
# default does not change the campaign output.)
|
|
230
|
+
adc_fs = _safe_float(params.get("adc_fs", "1"))
|
|
231
|
+
adc_bits = _safe_float(params.get("adc_bits", "0"))
|
|
232
|
+
sig_zero = _safe_float(params.get("sig_zero", "0"))
|
|
233
|
+
coef0 = _require_float(params, "coef0", 0.0, "accel")
|
|
234
|
+
coef1 = _require_float(params, "coef1", 1.0, "accel")
|
|
235
|
+
x = data * adc_fs / 2**adc_bits + adc_zero - sig_zero
|
|
236
|
+
return 9.81 * (x - coef0) / coef1, "m_s-2"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def convert_magn(data: np.ndarray, params: dict[str, Any]) -> tuple[np.ndarray, str]:
|
|
240
|
+
"""Magnetometer → µT.
|
|
241
|
+
|
|
242
|
+
Matches ODAS ``convert_odas.m`` ``odas_magn_internal``:
|
|
243
|
+
``physical = (data - coef0) / coef1``
|
|
244
|
+
"""
|
|
245
|
+
coef0 = _require_float(params, "coef0", 0.0, "magn")
|
|
246
|
+
coef1 = _require_float(params, "coef1", 1.0, "magn")
|
|
247
|
+
return (data - coef0) / coef1, "uT"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def convert_inclxy(data: np.ndarray, params: dict[str, Any]) -> tuple[np.ndarray, str]:
|
|
251
|
+
"""ADIS inclinometer X or Y: 14-bit two's complement → degrees."""
|
|
252
|
+
val = _adis_14bit(data)
|
|
253
|
+
# ODAS odas_inclxy_internal has no default for coef1 — MATLAB errors on a
|
|
254
|
+
# missing scale rather than fabricating one. Route through _require_float so
|
|
255
|
+
# a missing coefficient WARNS (house style: shear/voltage/accel/magn) instead
|
|
256
|
+
# of silently substituting the ADIS16209 nominal 0.025 deg/LSB. (#104 U1-3.)
|
|
257
|
+
coef0 = _require_float(params, "coef0", 0.0, "inclxy")
|
|
258
|
+
coef1 = _require_float(params, "coef1", 0.025, "inclxy")
|
|
259
|
+
return coef1 * val + coef0, "deg"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def convert_inclt(data: np.ndarray, params: dict[str, Any]) -> tuple[np.ndarray, str]:
|
|
263
|
+
"""ADIS inclinometer temperature: 14-bit two's complement → °C."""
|
|
264
|
+
val = _adis_14bit(data)
|
|
265
|
+
coef0 = _safe_float(params.get("coef0", "624"))
|
|
266
|
+
coef1 = _safe_float(params.get("coef1", "-0.47"))
|
|
267
|
+
return coef1 * val + coef0, "deg_C"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def convert_jac_c(data: np.ndarray, params: dict[str, Any]) -> tuple[np.ndarray, str]:
|
|
271
|
+
"""JAC conductivity: ratio of I/V parts from 32-bit combined word → mS/cm."""
|
|
272
|
+
i_part = np.floor(data / 2**16)
|
|
273
|
+
v_part = np.mod(data, 2**16)
|
|
274
|
+
# NaN (not 1) for a zero voltage part: a v_part==0 sample is corrupt, so
|
|
275
|
+
# flag it rather than fabricating a finite ratio that looks like real data.
|
|
276
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
277
|
+
ratio = np.where(v_part == 0, np.nan, i_part / v_part)
|
|
278
|
+
a = _safe_float(params.get("a"))
|
|
279
|
+
b = _safe_float(params.get("b"))
|
|
280
|
+
c = _safe_float(params.get("c"))
|
|
281
|
+
return np.polyval([c, b, a], ratio), "mS_cm-1"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def convert_jac_t(data: np.ndarray, params: dict[str, Any]) -> tuple[np.ndarray, str]:
|
|
285
|
+
"""JAC temperature: unsigned 16-bit wrapping + 5th-order polynomial → °C."""
|
|
286
|
+
d = _unsigned_16bit(data)
|
|
287
|
+
a = _safe_float(params.get("a"))
|
|
288
|
+
b = _safe_float(params.get("b"))
|
|
289
|
+
c = _safe_float(params.get("c"))
|
|
290
|
+
d_coef = _safe_float(params.get("d"))
|
|
291
|
+
e = _safe_float(params.get("e"))
|
|
292
|
+
f = _safe_float(params.get("f"))
|
|
293
|
+
return np.polyval([f, e, d_coef, c, b, a], d), "deg_C"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def convert_raw(data: np.ndarray, params: dict[str, Any]) -> tuple[np.ndarray, str]:
|
|
297
|
+
"""Passthrough: return raw counts unchanged."""
|
|
298
|
+
return data, "counts"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def convert_aroft_o2(data: np.ndarray, params: dict[str, Any]) -> tuple[np.ndarray, str]:
|
|
302
|
+
"""RINKO FT dissolved oxygen: unsigned 16-bit wrapping / 100 → µmol/L."""
|
|
303
|
+
d = _deglitch_rs232(_unsigned_16bit(data))
|
|
304
|
+
return d / 100.0, "umol_L-1"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def convert_aroft_t(data: np.ndarray, params: dict[str, Any]) -> tuple[np.ndarray, str]:
|
|
308
|
+
"""RINKO FT temperature: unsigned 16-bit wrapping / 1000 - 5 -> deg C."""
|
|
309
|
+
d = _deglitch_rs232(_unsigned_16bit(data))
|
|
310
|
+
return d / 1000.0 - 5.0, "deg_C"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def convert_aem1g_a(data: np.ndarray, params: dict[str, Any]) -> tuple[np.ndarray, str]:
|
|
314
|
+
"""AEM1-G electromagnetic current meter, analog output → m/s.
|
|
315
|
+
|
|
316
|
+
Matches ODAS ``convert_odas.m`` ``odas_aem1g_a_internal``:
|
|
317
|
+
1. Convert raw counts to voltage: ``V = adc_zero + data * (adc_fs / 2^adc_bits)``
|
|
318
|
+
2. Apply calibration: ``V = a/100 + b/100 * V`` (cm/s → m/s)
|
|
319
|
+
3. Subtract bias: ``physical = V - bias``
|
|
320
|
+
"""
|
|
321
|
+
adc_fs = _safe_float(params.get("adc_fs", "4.096"))
|
|
322
|
+
adc_bits = _safe_float(params.get("adc_bits", "16"))
|
|
323
|
+
adc_zero = _safe_float(params.get("adc_zero", str(adc_fs / 2)))
|
|
324
|
+
a = _safe_float(params.get("a", "0")) / 100.0
|
|
325
|
+
b = _safe_float(params.get("b", "1")) / 100.0
|
|
326
|
+
bias = _safe_float(params.get("bias", "0"))
|
|
327
|
+
V = adc_zero + data * (adc_fs / 2**adc_bits)
|
|
328
|
+
V = a + b * V
|
|
329
|
+
return V - bias, "m_s-1"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def convert_aem1g_d(data: np.ndarray, params: dict[str, Any]) -> tuple[np.ndarray, str]:
|
|
333
|
+
"""AEM1-G electromagnetic current meter, digital/RS232 output → m/s.
|
|
334
|
+
|
|
335
|
+
Matches ODAS ``convert_odas.m`` ``odas_aem1g_d_internal``:
|
|
336
|
+
1. Convert signed int16 to unsigned (wrap negatives)
|
|
337
|
+
2. Apply calibration: ``physical = a/100 + b/100 * data`` (cm/s → m/s)
|
|
338
|
+
"""
|
|
339
|
+
d = _unsigned_16bit(data)
|
|
340
|
+
a = _safe_float(params.get("a", "0")) / 100.0
|
|
341
|
+
b = _safe_float(params.get("b", "1")) / 100.0
|
|
342
|
+
return a + b * d, "m_s-1"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def convert_alec_emc(data: np.ndarray, params: dict[str, Any]) -> tuple[np.ndarray, str]:
|
|
346
|
+
"""Alec Electronics electromagnetic current meter → m/s.
|
|
347
|
+
|
|
348
|
+
Matches ODAS ``convert_odas.m`` ``odas_Alec_EMC_internal``:
|
|
349
|
+
``physical = coef1 * data + coef0``
|
|
350
|
+
"""
|
|
351
|
+
coef0 = _safe_float(params.get("coef0", "0"))
|
|
352
|
+
coef1 = _safe_float(params.get("coef1", "1"))
|
|
353
|
+
return np.polyval([coef1, coef0], data), "m_s-1"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
CONVERTERS = {
|
|
357
|
+
"therm": convert_therm,
|
|
358
|
+
"shear": convert_shear,
|
|
359
|
+
"poly": convert_poly,
|
|
360
|
+
"voltage": convert_voltage,
|
|
361
|
+
"piezo": convert_piezo,
|
|
362
|
+
"accel": convert_accel,
|
|
363
|
+
"magn": convert_magn,
|
|
364
|
+
"inclxy": convert_inclxy,
|
|
365
|
+
"inclt": convert_inclt,
|
|
366
|
+
"jac_c": convert_jac_c,
|
|
367
|
+
"jac_t": convert_jac_t,
|
|
368
|
+
"raw": convert_raw,
|
|
369
|
+
"aroft_o2": convert_aroft_o2,
|
|
370
|
+
"aroft_t": convert_aroft_t,
|
|
371
|
+
"gnd": convert_raw,
|
|
372
|
+
"aem1g_a": convert_aem1g_a,
|
|
373
|
+
"aem1g_d": convert_aem1g_d,
|
|
374
|
+
"alec_emc": convert_alec_emc,
|
|
375
|
+
"jac_emc": convert_aem1g_a, # deprecated alias
|
|
376
|
+
}
|