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,354 @@
1
+ # Mar-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """FP07 in-situ calibration against a reference (slow) thermistor.
3
+
4
+ Steps:
5
+ 1. Convert FP07 to resistance ratio ln(R_T/R_0)
6
+ 2. Low-pass filter FP07 to match reference bandwidth
7
+ 3. Per-profile cross-correlate diff(filtered_FP07) vs diff(reference) -> lag
8
+ 4. Median lag across profiles
9
+ 5. Steinhart-Hart polynomial fit: 1/(T+273.15) = a0 + a1*ln(R) + ... + aN*ln(R)^order
10
+ 6. Apply calibration to slow and fast FP07 data
11
+
12
+ Reference: Code/fp07_calibration.m (354 lines)
13
+ """
14
+
15
+ import re
16
+ import warnings
17
+
18
+ import numpy as np
19
+ from scipy.signal import butter, correlate, lfilter
20
+
21
+ from odas_tpw.processing.ct_align import shift_edge_hold
22
+ from odas_tpw.rsi.p_file import PFile
23
+
24
+
25
+ def _find_fp07_channels(pf: PFile) -> list[str]:
26
+ """Find FP07 thermistor channel names (T1, T2, ...)."""
27
+ pattern = re.compile(r"^T\d+$")
28
+ return sorted(
29
+ name
30
+ for name in pf.channels
31
+ if pattern.match(name)
32
+ and pf.channel_info.get(name, {}).get("type") in ("therm", "thermistor")
33
+ )
34
+
35
+
36
+ def _get_channel_config(pf: PFile, ch_name: str) -> dict:
37
+ """Extract calibration parameters for a channel from PFile config."""
38
+ for ch in pf.config["channels"]:
39
+ if ch.get("name", "").strip() == ch_name:
40
+ return dict(ch)
41
+ return {}
42
+
43
+
44
+ def _compute_RT_R0(
45
+ counts: np.ndarray,
46
+ ch_config: dict,
47
+ ) -> np.ndarray:
48
+ """Compute ln(R_T/R_0) from raw counts or physical values.
49
+
50
+ For therm type: Z = factor * (counts - a) / b where factor = (adc_fs/2^adc_bits) * 2/(G*E_B)
51
+ For other types: Z = (counts * factor + adc_zero - a) / b * 2/(G*E_B)
52
+ RT_R0 = ln((1-Z)/(1+Z))
53
+ """
54
+ E_B = float(ch_config.get("e_b", 0))
55
+ a = float(ch_config.get("a", 0))
56
+ b = float(ch_config.get("b", 1))
57
+ G = float(ch_config.get("g", 1))
58
+ adc_fs = float(ch_config.get("adc_fs", 5))
59
+ adc_bits = int(float(ch_config.get("adc_bits", 16)))
60
+ adc_zero = float(ch_config.get("adc_zero", 0))
61
+ ch_type = ch_config.get("type", "therm").strip().lower()
62
+
63
+ factor = adc_fs / (2**adc_bits)
64
+
65
+ if ch_type in ("therm", "thermistor"):
66
+ factor = factor * 2.0 / (G * E_B) if (G * E_B) != 0 else factor
67
+ Z = factor * (counts - a) / b if b != 0 else np.zeros_like(counts)
68
+ else:
69
+ Z = counts * factor + adc_zero
70
+ Z = ((Z - a) / b) * 2.0 / (G * E_B) if (b * G * E_B) != 0 else np.zeros_like(counts)
71
+
72
+ # Clip Z to +/-0.6 as ODAS convert_odas.m does (and rsi/channels.py):
73
+ # |Z| near or beyond 1 indicates samples outside the bridge range, and
74
+ # clipping the *ratio* instead (previous behavior) produced ln-ratio
75
+ # outliers of ~-46 that passed the finite check and skewed the fit.
76
+ Z = np.clip(Z, -0.6, 0.6)
77
+ ratio = (1.0 - Z) / (1.0 + Z)
78
+ return np.asarray(np.log(ratio))
79
+
80
+
81
+ def _lowpass_filter(
82
+ fp07: np.ndarray,
83
+ reference: str,
84
+ fs: float,
85
+ W: np.ndarray,
86
+ profiles: list[tuple[int, int]],
87
+ ) -> np.ndarray:
88
+ """Low-pass filter FP07 to match reference sensor bandwidth.
89
+
90
+ For JAC_T: fc = 0.73 * sqrt(mean_speed / 0.62)
91
+ Otherwise: fc = fs/3
92
+ """
93
+ if reference.upper().startswith("JAC"):
94
+ # Compute mean profiling speed across all profiles
95
+ count = 0
96
+ W_sum = 0.0
97
+ for s, e in profiles:
98
+ count += e - s + 1
99
+ W_sum += np.sum(np.abs(W[s : e + 1]))
100
+ W_mean = W_sum / count if count > 0 else 0.3
101
+ fc = 0.73 * np.sqrt(W_mean / 0.62)
102
+ else:
103
+ fc = fs / 3.0
104
+
105
+ # Ensure fc is below Nyquist
106
+ fc = min(fc, fs / 2.0 * 0.99)
107
+ b, a = butter(1, fc / (fs / 2.0))
108
+ return np.asarray(lfilter(b, a, fp07))
109
+
110
+
111
+ def _calc_lag(
112
+ T_ref: np.ndarray,
113
+ T_fp07: np.ndarray,
114
+ fs: float,
115
+ max_lag_seconds: float = 10.0,
116
+ must_be_negative: bool = True,
117
+ ) -> tuple[float, float]:
118
+ """Cross-correlate diff(T_ref) vs diff(T_fp07) to find lag.
119
+
120
+ Returns (lag_seconds, max_correlation).
121
+ """
122
+ max_lag_samples = round(max_lag_seconds * fs)
123
+
124
+ # 4 Hz Butterworth to suppress high-freq noise
125
+ fc = min(4.0, fs / 2.0 * 0.99)
126
+ bb, aa = butter(2, fc / (fs / 2.0))
127
+
128
+ dx = lfilter(bb, aa, np.diff(T_fp07) - np.mean(np.diff(T_fp07)))
129
+ dy = lfilter(bb, aa, np.diff(T_ref) - np.mean(np.diff(T_ref)))
130
+
131
+ # Full cross-correlation
132
+ corr = correlate(dx, dy, mode="full")
133
+ # Normalize. A flatlined segment (norm == 0) or one carrying a non-finite
134
+ # sample (norm NaN) leaves corr un-normalized/NaN; argmax(|corr|) over the
135
+ # negative-lag window would then return index 0 -> the most-negative
136
+ # searched lag (-max_lag_seconds), silently poisoning the median lag that
137
+ # aligns the Steinhart-Hart fit. Mirror ct_align and abstain instead.
138
+ norm = np.sqrt(np.sum(dx**2) * np.sum(dy**2))
139
+ if not np.isfinite(norm) or norm <= 0:
140
+ return np.nan, np.nan
141
+ corr = corr / norm
142
+
143
+ n = len(dx)
144
+ lags = np.arange(-(n - 1), n)
145
+
146
+ # Restrict to max_lag range
147
+ mask = np.abs(lags) <= max_lag_samples
148
+ corr = corr[mask]
149
+ lags = lags[mask]
150
+
151
+ # Use |correlation| as ODAS cal_FP07_in_situ.m does: raw counts
152
+ # correlate negatively with temperature when the bridge coefficient
153
+ # b < 0, and a signed search would latch onto a wrong sidelobe.
154
+ if must_be_negative:
155
+ neg_mask = lags <= 0
156
+ corr_search = corr[neg_mask]
157
+ lags_search = lags[neg_mask]
158
+ if len(corr_search) == 0:
159
+ return 0.0, 0.0
160
+ idx = int(np.argmax(np.abs(corr_search)))
161
+ return lags_search[idx] / fs, corr_search[idx]
162
+ else:
163
+ idx = int(np.argmax(np.abs(corr)))
164
+ return lags[idx] / fs, corr[idx]
165
+
166
+
167
+ def fp07_calibrate(
168
+ pf: PFile,
169
+ profiles: list[tuple[int, int]],
170
+ reference: str = "JAC_T",
171
+ order: int = 2,
172
+ max_lag_seconds: float = 10.0,
173
+ must_be_negative: bool = True,
174
+ ) -> dict:
175
+ """Perform in-situ FP07 calibration.
176
+
177
+ Parameters
178
+ ----------
179
+ pf : PFile
180
+ Parsed .p file.
181
+ profiles : list of (start, end) tuples
182
+ Profile indices into slow-rate arrays.
183
+ reference : str
184
+ Reference temperature channel name (e.g. "JAC_T").
185
+ order : int
186
+ Steinhart-Hart polynomial order (1-3).
187
+ max_lag_seconds : float
188
+ Maximum cross-correlation lag [s].
189
+ must_be_negative : bool
190
+ If True, only search negative lags (FP07 leads reference).
191
+
192
+ Returns
193
+ -------
194
+ dict with keys:
195
+ channels : dict mapping channel name -> calibrated array at the
196
+ channel's native rate (e.g. T1)
197
+ fast_channels : dict mapping pre-emphasized channel name (e.g.
198
+ T1_dT1, holding the deconvolved fast temperature) -> array
199
+ recalibrated with the in-situ fit. These feed the chi
200
+ (temperature-gradient) pipeline; without them chi would
201
+ silently keep the factory calibration.
202
+ lags : dict mapping channel name -> lag in seconds
203
+ coefficients : dict mapping channel name -> Steinhart-Hart coefficients
204
+ info : dict of per-channel calibration stats
205
+ """
206
+ if reference not in pf.channels:
207
+ warnings.warn(f"Reference channel {reference!r} not found")
208
+ return {"channels": {}, "fast_channels": {}, "lags": {}, "coefficients": {}, "info": {}}
209
+
210
+ T_ref = pf.channels[reference]
211
+ fp07_names = _find_fp07_channels(pf)
212
+ if not fp07_names:
213
+ warnings.warn("No FP07 channels found")
214
+ return {"channels": {}, "fast_channels": {}, "lags": {}, "coefficients": {}, "info": {}}
215
+
216
+ # Get fall rate for low-pass filter cutoff
217
+ from odas_tpw.rsi.profile import _smooth_fall_rate
218
+
219
+ P = pf.channels.get("P")
220
+ W = _smooth_fall_rate(P, pf.fs_slow) if P is not None else np.full(len(T_ref), 0.5)
221
+
222
+ result: dict[str, dict] = {
223
+ "channels": {},
224
+ "fast_channels": {},
225
+ "lags": {},
226
+ "coefficients": {},
227
+ "info": {},
228
+ }
229
+
230
+ for ch_name in fp07_names:
231
+ ch_config = _get_channel_config(pf, ch_name)
232
+ if not ch_config:
233
+ continue
234
+
235
+ # Get raw counts (channels_raw has pre-conversion data)
236
+ if ch_name not in pf.channels_raw:
237
+ continue
238
+
239
+ raw_slow = pf.channels_raw[ch_name]
240
+ # If fast rate, subsample for slow
241
+ if pf.is_fast(ch_name):
242
+ ratio = round(pf.fs_fast / pf.fs_slow)
243
+ raw_slow = raw_slow[::ratio][: len(T_ref)]
244
+
245
+ # Low-pass filter to the reference bandwidth. Used both for lag
246
+ # finding and for the Steinhart-Hart regression: ODAS
247
+ # cal_FP07_in_situ.m fits on the filtered thermistor, since
248
+ # unfiltered high-frequency noise in the regressor
249
+ # (errors-in-variables) attenuates the fitted slope.
250
+ fp07_lp = _lowpass_filter(raw_slow, reference, pf.fs_slow, W, profiles)
251
+ RT_R0_fit_src = _compute_RT_R0(fp07_lp, ch_config)
252
+
253
+ # Per-profile lag computation
254
+ lags_list = []
255
+ corrs_list = []
256
+ for s, e in profiles:
257
+ if e - s < 10:
258
+ continue
259
+ lag, corr = _calc_lag(
260
+ T_ref[s : e + 1],
261
+ fp07_lp[s : e + 1],
262
+ pf.fs_slow,
263
+ max_lag_seconds=max_lag_seconds,
264
+ must_be_negative=must_be_negative,
265
+ )
266
+ # _calc_lag returns NaN for a flatlined / non-finite segment; drop
267
+ # it so it cannot poison the median.
268
+ if not np.isfinite(lag):
269
+ continue
270
+ lags_list.append(lag)
271
+ corrs_list.append(corr)
272
+
273
+ if not lags_list:
274
+ continue
275
+
276
+ median_lag = float(np.median(lags_list))
277
+ i_shift = round(median_lag * pf.fs_slow)
278
+ result["lags"][ch_name] = median_lag
279
+
280
+ # Shift reference to align with FP07. Edge-hold instead of
281
+ # np.roll: wrapping would splice the start of the record into the
282
+ # tail of the last profile (ODAS trims instead).
283
+ T_ref_shifted = shift_edge_hold(T_ref, i_shift)
284
+
285
+ # Collect profile data for Steinhart-Hart fit
286
+ all_RT_R0 = []
287
+ all_T_ref = []
288
+ for s, e in profiles:
289
+ all_RT_R0.append(RT_R0_fit_src[s : e + 1])
290
+ all_T_ref.append(T_ref_shifted[s : e + 1])
291
+
292
+ RT_R0_fit = np.concatenate(all_RT_R0)
293
+ T_ref_fit = np.concatenate(all_T_ref)
294
+
295
+ # Remove NaN/inf
296
+ valid = np.isfinite(RT_R0_fit) & np.isfinite(T_ref_fit)
297
+ RT_R0_fit = RT_R0_fit[valid]
298
+ T_ref_fit = T_ref_fit[valid]
299
+
300
+ if len(RT_R0_fit) < order + 1:
301
+ continue
302
+
303
+ # ODAS warns when the in-situ temperature range is small: a
304
+ # higher-order polynomial fit over a narrow range is poorly
305
+ # constrained and extrapolates badly.
306
+ T_range = float(np.max(T_ref_fit) - np.min(T_ref_fit))
307
+ if order > 1 and T_range < 8.0:
308
+ warnings.warn(
309
+ f"{ch_name}: in-situ temperature range {T_range:.1f} degC < 8 degC; "
310
+ f"order-{order} fit may be poorly constrained (consider order=1)",
311
+ stacklevel=2,
312
+ )
313
+
314
+ # Steinhart-Hart: 1/(T+273.15) = a0 + a1*RT_R0 + a2*RT_R0^2 + ...
315
+ target = 1.0 / (T_ref_fit + 273.15)
316
+ X = np.column_stack([RT_R0_fit**i for i in range(order + 1)])
317
+ coeffs, _, _, _ = np.linalg.lstsq(X, target, rcond=None)
318
+ result["coefficients"][ch_name] = coeffs
319
+
320
+ def _apply_cal(raw: np.ndarray) -> np.ndarray:
321
+ rt = _compute_RT_R0(raw, ch_config)
322
+ Xm = np.column_stack([rt**i for i in range(order + 1)])
323
+ return np.asarray(1.0 / (Xm @ coeffs) - 273.15)
324
+
325
+ # Apply calibration to the channel at its NATIVE rate (the
326
+ # previous code always stored the slow-subsampled array, which
327
+ # mis-sized fast channels when assigned back to pf.channels).
328
+ if pf.is_fast(ch_name):
329
+ result["channels"][ch_name] = _apply_cal(
330
+ pf.channels_raw[ch_name][: len(pf.t_fast)]
331
+ )
332
+ else:
333
+ result["channels"][ch_name] = _apply_cal(raw_slow)
334
+
335
+ # Recalibrate the deconvolved pre-emphasized variant (T1 ->
336
+ # T1_dT1). After PFile._apply_deconvolution, channels_raw holds
337
+ # the DECONVOLVED counts for this channel, converted downstream
338
+ # with the base channel's calibration — so applying the in-situ
339
+ # polynomial here propagates the calibration into the fast
340
+ # temperature used by the chi pipeline.
341
+ dx_name = f"{ch_name}_d{ch_name}"
342
+ if dx_name in pf.channels_raw:
343
+ n_dx = len(pf.t_fast) if pf.is_fast(dx_name) else len(pf.t_slow)
344
+ result["fast_channels"][dx_name] = _apply_cal(pf.channels_raw[dx_name][:n_dx])
345
+
346
+ result["info"][ch_name] = {
347
+ "median_lag": median_lag,
348
+ "lag_std": float(np.std(lags_list)),
349
+ "median_corr": float(np.median(corrs_list)),
350
+ "n_profiles": len(lags_list),
351
+ "coefficients": coeffs.tolist(),
352
+ }
353
+
354
+ return result