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.
Files changed (119) hide show
  1. microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
  2. microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
  3. microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
  4. microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
  5. microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
  6. microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
  7. odas_tpw/__init__.py +18 -0
  8. odas_tpw/chi/__init__.py +48 -0
  9. odas_tpw/chi/batchelor.py +189 -0
  10. odas_tpw/chi/chi.py +857 -0
  11. odas_tpw/chi/fp07.py +649 -0
  12. odas_tpw/chi/l2_chi.py +153 -0
  13. odas_tpw/chi/l3_chi.py +388 -0
  14. odas_tpw/chi/l4_chi.py +391 -0
  15. odas_tpw/config_base.py +423 -0
  16. odas_tpw/perturb/__init__.py +18 -0
  17. odas_tpw/perturb/adcp.py +274 -0
  18. odas_tpw/perturb/atomic_io.py +47 -0
  19. odas_tpw/perturb/binning.py +761 -0
  20. odas_tpw/perturb/cli.py +591 -0
  21. odas_tpw/perturb/combo.py +346 -0
  22. odas_tpw/perturb/config.py +1046 -0
  23. odas_tpw/perturb/ctd.py +424 -0
  24. odas_tpw/perturb/diag/__init__.py +12 -0
  25. odas_tpw/perturb/diag/_common.py +140 -0
  26. odas_tpw/perturb/diag/chi.py +42 -0
  27. odas_tpw/perturb/diag/cli.py +67 -0
  28. odas_tpw/perturb/diag/data.py +662 -0
  29. odas_tpw/perturb/diag/epsilon.py +45 -0
  30. odas_tpw/perturb/diag/inspector.py +342 -0
  31. odas_tpw/perturb/diag/mixing.py +94 -0
  32. odas_tpw/perturb/diag/render.py +543 -0
  33. odas_tpw/perturb/discover.py +40 -0
  34. odas_tpw/perturb/fp07_cal.py +354 -0
  35. odas_tpw/perturb/gps.py +458 -0
  36. odas_tpw/perturb/hotel.py +572 -0
  37. odas_tpw/perturb/logging_setup.py +181 -0
  38. odas_tpw/perturb/merge.py +320 -0
  39. odas_tpw/perturb/mk_sections.py +310 -0
  40. odas_tpw/perturb/netcdf_schema.py +520 -0
  41. odas_tpw/perturb/pipeline.py +2654 -0
  42. odas_tpw/perturb/plot/__init__.py +15 -0
  43. odas_tpw/perturb/plot/cli.py +107 -0
  44. odas_tpw/perturb/plot/diagnostics.py +195 -0
  45. odas_tpw/perturb/plot/eps_chi.py +524 -0
  46. odas_tpw/perturb/plot/figure.py +679 -0
  47. odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
  48. odas_tpw/perturb/plot/grid.py +112 -0
  49. odas_tpw/perturb/plot/layout.py +361 -0
  50. odas_tpw/perturb/plot/overview.py +547 -0
  51. odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
  52. odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
  53. odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
  54. odas_tpw/perturb/plot/profiles.py +652 -0
  55. odas_tpw/perturb/plot/scalar.py +391 -0
  56. odas_tpw/perturb/plot/sections.py +506 -0
  57. odas_tpw/perturb/plot/xaxis.py +396 -0
  58. odas_tpw/perturb/qc_gate.py +177 -0
  59. odas_tpw/perturb/qc_rules.py +373 -0
  60. odas_tpw/perturb/resolve.py +306 -0
  61. odas_tpw/perturb/seawater.py +76 -0
  62. odas_tpw/perturb/speed.py +14 -0
  63. odas_tpw/perturb/trim.py +313 -0
  64. odas_tpw/processing/__init__.py +62 -0
  65. odas_tpw/processing/bottom.py +215 -0
  66. odas_tpw/processing/chi_combine.py +300 -0
  67. odas_tpw/processing/ct_align.py +172 -0
  68. odas_tpw/processing/epsilon_combine.py +210 -0
  69. odas_tpw/processing/mixing.py +630 -0
  70. odas_tpw/processing/thorpe.py +458 -0
  71. odas_tpw/processing/top_trim.py +274 -0
  72. odas_tpw/pyturb/__init__.py +3 -0
  73. odas_tpw/pyturb/__main__.py +5 -0
  74. odas_tpw/pyturb/_compat.py +204 -0
  75. odas_tpw/pyturb/_profind.py +124 -0
  76. odas_tpw/pyturb/bin.py +113 -0
  77. odas_tpw/pyturb/cli.py +175 -0
  78. odas_tpw/pyturb/eps.py +353 -0
  79. odas_tpw/pyturb/merge.py +101 -0
  80. odas_tpw/pyturb/p2nc.py +76 -0
  81. odas_tpw/rsi/__init__.py +31 -0
  82. odas_tpw/rsi/adapter.py +314 -0
  83. odas_tpw/rsi/bench.py +904 -0
  84. odas_tpw/rsi/binning.py +193 -0
  85. odas_tpw/rsi/channels.py +376 -0
  86. odas_tpw/rsi/chi_io.py +980 -0
  87. odas_tpw/rsi/cli.py +1411 -0
  88. odas_tpw/rsi/combine.py +120 -0
  89. odas_tpw/rsi/config.py +126 -0
  90. odas_tpw/rsi/config_patch.py +790 -0
  91. odas_tpw/rsi/convert.py +651 -0
  92. odas_tpw/rsi/deconvolve.py +119 -0
  93. odas_tpw/rsi/diss_look.py +343 -0
  94. odas_tpw/rsi/dissipation.py +669 -0
  95. odas_tpw/rsi/helpers.py +527 -0
  96. odas_tpw/rsi/mixing_look.py +509 -0
  97. odas_tpw/rsi/p_file.py +771 -0
  98. odas_tpw/rsi/pipeline.py +924 -0
  99. odas_tpw/rsi/profile.py +624 -0
  100. odas_tpw/rsi/quick_look.py +758 -0
  101. odas_tpw/rsi/sensor_inventory.py +684 -0
  102. odas_tpw/rsi/shear_noise.py +128 -0
  103. odas_tpw/rsi/speed.py +221 -0
  104. odas_tpw/rsi/vehicle.py +74 -0
  105. odas_tpw/rsi/viewer_base.py +1071 -0
  106. odas_tpw/rsi/window.py +459 -0
  107. odas_tpw/scor160/__init__.py +36 -0
  108. odas_tpw/scor160/cli.py +242 -0
  109. odas_tpw/scor160/compare.py +484 -0
  110. odas_tpw/scor160/despike.py +227 -0
  111. odas_tpw/scor160/goodman.py +288 -0
  112. odas_tpw/scor160/io.py +690 -0
  113. odas_tpw/scor160/l2.py +311 -0
  114. odas_tpw/scor160/l3.py +279 -0
  115. odas_tpw/scor160/l4.py +850 -0
  116. odas_tpw/scor160/nasmyth.py +208 -0
  117. odas_tpw/scor160/ocean.py +287 -0
  118. odas_tpw/scor160/profile.py +231 -0
  119. odas_tpw/scor160/spectral.py +482 -0
@@ -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
@@ -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
+ }