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
|
@@ -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
|