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/chi_io.py
ADDED
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
# Mar-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Convenience wrappers for chi computation from PFile / NetCDF sources.
|
|
3
|
+
|
|
4
|
+
These functions bridge rsi instrument I/O with the pure chi
|
|
5
|
+
computation in chi.chi. They handle data loading, profile
|
|
6
|
+
detection, and file-level orchestration.
|
|
7
|
+
|
|
8
|
+
.. deprecated::
|
|
9
|
+
``get_chi`` is deprecated. Use :func:`odas_tpw.rsi.pipeline.run_pipeline`
|
|
10
|
+
or the modular ``process_l2_chi`` → ``process_l3_chi`` → ``process_l4_chi_*``
|
|
11
|
+
chain instead.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import functools
|
|
17
|
+
import re
|
|
18
|
+
import warnings
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
import numpy.typing as npt
|
|
25
|
+
import xarray as xr
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from odas_tpw.rsi.p_file import PFile
|
|
29
|
+
|
|
30
|
+
# Max acceptable epsilon FM (Lueck 2022 MAD-based reject statistic; a good fit
|
|
31
|
+
# sits near ~0.7-0.8, NOT 0, ATOMIX rejects FM > ~1.15) for including a probe in the Method-1
|
|
32
|
+
# mean. Named for FM, not the variance-ratio `fom` (centered on 1.0) it gates
|
|
33
|
+
# only as a fallback when FM is absent. (matches compute_chi_window's default.)
|
|
34
|
+
_EPS_FM_LIMIT = 1.15
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _compute_chi(
|
|
38
|
+
source: PFile | str | Path,
|
|
39
|
+
epsilon_ds: xr.Dataset | None = None,
|
|
40
|
+
fft_length: int = 1024,
|
|
41
|
+
diss_length: int | None = None,
|
|
42
|
+
overlap: int | None = None,
|
|
43
|
+
speed: float | None = None,
|
|
44
|
+
direction: str = "auto",
|
|
45
|
+
fp07_model: str = "single_pole",
|
|
46
|
+
goodman: bool = True,
|
|
47
|
+
f_AA: float = 98.0,
|
|
48
|
+
fit_method: str = "iterative",
|
|
49
|
+
spectrum_model: str = "kraichnan",
|
|
50
|
+
salinity: npt.ArrayLike | None = None,
|
|
51
|
+
vehicle: str | None = None,
|
|
52
|
+
_pre_loaded: dict[str, Any] | None = None,
|
|
53
|
+
) -> list[xr.Dataset]:
|
|
54
|
+
"""Compute chi from temperature gradient spectra (internal, no deprecation warning).
|
|
55
|
+
|
|
56
|
+
``_pre_loaded`` is a private hook accepting the dict produced by
|
|
57
|
+
:func:`_load_therm_channels` (i.e. channels + ``therm`` + ``diff_gains`` +
|
|
58
|
+
``therm_cal``) so callers like ``perturb.pipeline`` can avoid the
|
|
59
|
+
redundant NC reads when diss and chi share the same source.
|
|
60
|
+
"""
|
|
61
|
+
from odas_tpw.chi.chi import _spectrum_func
|
|
62
|
+
from odas_tpw.chi.l2_chi import process_l2_chi
|
|
63
|
+
from odas_tpw.chi.l3_chi import process_l3_chi
|
|
64
|
+
from odas_tpw.chi.l4_chi import process_l4_chi_epsilon, process_l4_chi_fit
|
|
65
|
+
from odas_tpw.rsi.helpers import _build_l1data_from_channels, prepare_profiles
|
|
66
|
+
from odas_tpw.scor160.io import L2Params, L3Params
|
|
67
|
+
from odas_tpw.scor160.l2 import process_l2
|
|
68
|
+
|
|
69
|
+
if diss_length is None:
|
|
70
|
+
diss_length = 4 * fft_length
|
|
71
|
+
if overlap is None:
|
|
72
|
+
overlap = diss_length // 2
|
|
73
|
+
|
|
74
|
+
if isinstance(salinity, str):
|
|
75
|
+
# The "measured" sentinel is resolved upstream (perturb.pipeline reads
|
|
76
|
+
# the profile's conductivity); _compute_chi only accepts a concrete
|
|
77
|
+
# value here, so fail clearly rather than crash in np.asarray(...).
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"salinity={salinity!r} is not resolved at this layer; pass a "
|
|
80
|
+
"number, an array, or None. The 'measured' option is resolved by "
|
|
81
|
+
"the perturb pipeline from the profile's C/T/P."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Load channels including thermistor data
|
|
85
|
+
data = _pre_loaded if _pre_loaded is not None else _load_therm_channels(source)
|
|
86
|
+
|
|
87
|
+
therm_names = [t[0] for t in data["therm"]]
|
|
88
|
+
n_therm = len(therm_names)
|
|
89
|
+
diff_gains = data.get("diff_gains", [0.94] * n_therm)
|
|
90
|
+
therm_cal = data.get("therm_cal", [{}] * n_therm)
|
|
91
|
+
|
|
92
|
+
if n_therm == 0:
|
|
93
|
+
raise ValueError("No thermistor gradient channels found")
|
|
94
|
+
|
|
95
|
+
fs_fast = data["fs_fast"]
|
|
96
|
+
if vehicle is None:
|
|
97
|
+
vehicle = data.get("vehicle", "")
|
|
98
|
+
|
|
99
|
+
# Resolve "auto" to a concrete up/down so it doesn't leak into
|
|
100
|
+
# L1Data.profile_dir (prepare_profiles resolves internally but doesn't
|
|
101
|
+
# return it). Mirrors adapter.py / _compute_epsilon.
|
|
102
|
+
from odas_tpw.rsi.vehicle import resolve_direction
|
|
103
|
+
|
|
104
|
+
direction = resolve_direction(direction, vehicle)
|
|
105
|
+
prepared = prepare_profiles(data, speed, direction, salinity, vehicle=vehicle)
|
|
106
|
+
if prepared is None:
|
|
107
|
+
return []
|
|
108
|
+
(profiles_slow, speed_fast, P_fast, T_fast, sal_fast, fs_fast, _fs_slow, ratio, t_fast) = (
|
|
109
|
+
prepared
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Pipeline parameters
|
|
113
|
+
l2_params = L2Params(
|
|
114
|
+
HP_cut=0.25,
|
|
115
|
+
despike_sh=np.array([np.inf, 0.5, 0.04]), # don't despike shear for chi
|
|
116
|
+
despike_A=np.array([np.inf, 0.5, 0.04]),
|
|
117
|
+
profile_min_W=0.05,
|
|
118
|
+
profile_min_P=0.0,
|
|
119
|
+
profile_min_duration=0.0,
|
|
120
|
+
speed_tau=0.0,
|
|
121
|
+
)
|
|
122
|
+
l3_params = L3Params(
|
|
123
|
+
fft_length=fft_length,
|
|
124
|
+
diss_length=diss_length,
|
|
125
|
+
overlap=overlap,
|
|
126
|
+
HP_cut=0.25,
|
|
127
|
+
fs_fast=fs_fast,
|
|
128
|
+
goodman=goodman,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
grad_func, _ = _spectrum_func(spectrum_model)
|
|
132
|
+
|
|
133
|
+
results = []
|
|
134
|
+
for s_slow, e_slow in profiles_slow:
|
|
135
|
+
s_fast = s_slow * ratio
|
|
136
|
+
e_fast = min((e_slow + 1) * ratio, len(t_fast))
|
|
137
|
+
|
|
138
|
+
sal_prof = sal_fast[s_fast:e_fast] if isinstance(sal_fast, np.ndarray) else sal_fast
|
|
139
|
+
|
|
140
|
+
# Build L1Data with temp_fast
|
|
141
|
+
l1 = _build_l1data_from_channels(
|
|
142
|
+
data,
|
|
143
|
+
s_fast,
|
|
144
|
+
e_fast,
|
|
145
|
+
speed_fast,
|
|
146
|
+
P_fast,
|
|
147
|
+
T_fast,
|
|
148
|
+
direction,
|
|
149
|
+
therm_list=data["therm"],
|
|
150
|
+
diff_gains=diff_gains,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# L2: section selection and speed
|
|
154
|
+
l2 = process_l2(l1, l2_params)
|
|
155
|
+
|
|
156
|
+
# L2_chi: despike temperature, HP-filter vib, compute gradient
|
|
157
|
+
l2_chi = process_l2_chi(l1, l2)
|
|
158
|
+
|
|
159
|
+
# L3_chi: temperature gradient spectra
|
|
160
|
+
l3_chi = process_l3_chi(
|
|
161
|
+
l2_chi,
|
|
162
|
+
l3_params,
|
|
163
|
+
fp07_model=fp07_model,
|
|
164
|
+
salinity=sal_prof,
|
|
165
|
+
therm_cal=therm_cal,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if l3_chi.n_spectra == 0:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
# L4_chi: chi estimation
|
|
172
|
+
if epsilon_ds is not None:
|
|
173
|
+
l4_diss = _epsilon_ds_to_l4data(epsilon_ds)
|
|
174
|
+
l4_chi = process_l4_chi_epsilon(
|
|
175
|
+
l3_chi,
|
|
176
|
+
l4_diss,
|
|
177
|
+
spectrum_model=spectrum_model,
|
|
178
|
+
f_AA=f_AA,
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
l4_chi = process_l4_chi_fit(
|
|
182
|
+
l3_chi,
|
|
183
|
+
spectrum_model=spectrum_model,
|
|
184
|
+
fit_method=fit_method,
|
|
185
|
+
f_AA=f_AA,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Build output dataset
|
|
189
|
+
ds = _build_chi_ds_from_pipeline(
|
|
190
|
+
l3_chi,
|
|
191
|
+
l4_chi,
|
|
192
|
+
therm_names,
|
|
193
|
+
fft_length=fft_length,
|
|
194
|
+
diss_length=diss_length,
|
|
195
|
+
overlap=overlap,
|
|
196
|
+
fs_fast=fs_fast,
|
|
197
|
+
fp07_model=fp07_model,
|
|
198
|
+
spectrum_model=spectrum_model,
|
|
199
|
+
fit_method=fit_method,
|
|
200
|
+
f_AA=f_AA,
|
|
201
|
+
grad_func=grad_func,
|
|
202
|
+
chi_method=l4_chi.method,
|
|
203
|
+
)
|
|
204
|
+
ds.attrs.update(data["metadata"])
|
|
205
|
+
ds.attrs["history"] = f"Computed with microstructure-tpw on {datetime.now(UTC).isoformat()}"
|
|
206
|
+
start_time = data["metadata"].get("start_time", "")
|
|
207
|
+
t_units = f"seconds since {start_time}" if start_time else "seconds"
|
|
208
|
+
ds.coords["t"].attrs.update(
|
|
209
|
+
{
|
|
210
|
+
"standard_name": "time",
|
|
211
|
+
"long_name": "time of chi estimate",
|
|
212
|
+
"units": t_units,
|
|
213
|
+
"calendar": "standard",
|
|
214
|
+
"axis": "T",
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
results.append(ds)
|
|
218
|
+
|
|
219
|
+
return results
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_chi(
|
|
223
|
+
source: PFile | str | Path,
|
|
224
|
+
epsilon_ds: xr.Dataset | None = None,
|
|
225
|
+
fft_length: int = 1024,
|
|
226
|
+
diss_length: int | None = None,
|
|
227
|
+
overlap: int | None = None,
|
|
228
|
+
speed: float | None = None,
|
|
229
|
+
direction: str = "down",
|
|
230
|
+
fp07_model: str = "single_pole",
|
|
231
|
+
goodman: bool = True,
|
|
232
|
+
f_AA: float = 98.0,
|
|
233
|
+
fit_method: str = "iterative",
|
|
234
|
+
spectrum_model: str = "kraichnan",
|
|
235
|
+
salinity: npt.ArrayLike | None = None,
|
|
236
|
+
) -> list[xr.Dataset]:
|
|
237
|
+
"""Compute chi from temperature gradient spectra.
|
|
238
|
+
|
|
239
|
+
.. deprecated::
|
|
240
|
+
Use :func:`odas_tpw.rsi.pipeline.run_pipeline` or the modular
|
|
241
|
+
``process_l2_chi`` → ``process_l3_chi`` → ``process_l4_chi_*``
|
|
242
|
+
chain instead.
|
|
243
|
+
|
|
244
|
+
Parameters
|
|
245
|
+
----------
|
|
246
|
+
source : PFile, str, or Path
|
|
247
|
+
Input data (same multi-source support as get_diss).
|
|
248
|
+
epsilon_ds : xarray.Dataset or None
|
|
249
|
+
If provided: Method 1 — use known epsilon from shear probes.
|
|
250
|
+
If None: Method 2 — fit Batchelor spectrum for kB.
|
|
251
|
+
fft_length : int
|
|
252
|
+
FFT segment length [samples], default 1024.
|
|
253
|
+
diss_length : int or None
|
|
254
|
+
Dissipation window length [samples], default 4 * fft_length.
|
|
255
|
+
overlap : int or None
|
|
256
|
+
Window overlap [samples], default diss_length // 2.
|
|
257
|
+
speed : float or None
|
|
258
|
+
Fixed profiling speed [m/s]. If None, from dP/dt.
|
|
259
|
+
direction : str
|
|
260
|
+
'up' or 'down' for speed sign convention.
|
|
261
|
+
fp07_model : str
|
|
262
|
+
'single_pole' or 'double_pole' for FP07 transfer function.
|
|
263
|
+
goodman : bool
|
|
264
|
+
Apply Goodman coherent noise removal using accelerometers (default True).
|
|
265
|
+
f_AA : float
|
|
266
|
+
Anti-aliasing filter cutoff [Hz].
|
|
267
|
+
fit_method : str
|
|
268
|
+
Method 2 only: 'mle' (Ruddick et al. 2000) or 'iterative'
|
|
269
|
+
(Peterson & Fer 2014).
|
|
270
|
+
spectrum_model : str
|
|
271
|
+
'batchelor' or 'kraichnan'.
|
|
272
|
+
salinity : float or array_like or None
|
|
273
|
+
Practical salinity [PSU]. If provided, uses gsw-based viscosity
|
|
274
|
+
instead of visc35. Scalar or array matching slow time series.
|
|
275
|
+
|
|
276
|
+
Returns
|
|
277
|
+
-------
|
|
278
|
+
list of xarray.Dataset, one per profile, with variables:
|
|
279
|
+
chi (probe, time) — thermal dissipation rate [K^2/s]
|
|
280
|
+
epsilon_T (probe, time) — epsilon from T (Method 2) or input epsilon
|
|
281
|
+
kB (probe, time) — Batchelor wavenumber [cpm]
|
|
282
|
+
K_max_T (probe, time) — integration limit [cpm]
|
|
283
|
+
fom (probe, time) — figure of merit (obs/model variance)
|
|
284
|
+
K_max_ratio (probe, time) — K_max / kB (spectral resolution)
|
|
285
|
+
var_resolved (probe, time) — Batchelor gradient-variance resolved
|
|
286
|
+
fraction (chi-side V_f; feeds chiLnSigma)
|
|
287
|
+
speed (time) — profiling speed [m/s]
|
|
288
|
+
nu (time) — kinematic viscosity [m^2/s]
|
|
289
|
+
T_mean (time) — mean temperature [deg C]
|
|
290
|
+
P_mean (time) — mean pressure [dbar]
|
|
291
|
+
spec_gradT (probe, freq, time) — temperature gradient spectra
|
|
292
|
+
spec_batch (probe, freq, time) — fitted Batchelor spectra
|
|
293
|
+
spec_noise (probe, freq, time) — noise spectra (per-probe)
|
|
294
|
+
K (freq, time) — wavenumber vectors
|
|
295
|
+
"""
|
|
296
|
+
warnings.warn(
|
|
297
|
+
"get_chi() is deprecated. Use run_pipeline() or the modular "
|
|
298
|
+
"process_l2_chi → process_l3_chi → process_l4_chi_* chain "
|
|
299
|
+
"instead. See odas_tpw.rsi.pipeline.run_pipeline().",
|
|
300
|
+
DeprecationWarning,
|
|
301
|
+
stacklevel=2,
|
|
302
|
+
)
|
|
303
|
+
return _compute_chi(
|
|
304
|
+
source,
|
|
305
|
+
epsilon_ds=epsilon_ds,
|
|
306
|
+
fft_length=fft_length,
|
|
307
|
+
diss_length=diss_length,
|
|
308
|
+
overlap=overlap,
|
|
309
|
+
speed=speed,
|
|
310
|
+
direction=direction,
|
|
311
|
+
fp07_model=fp07_model,
|
|
312
|
+
goodman=goodman,
|
|
313
|
+
f_AA=f_AA,
|
|
314
|
+
fit_method=fit_method,
|
|
315
|
+
spectrum_model=spectrum_model,
|
|
316
|
+
salinity=salinity,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
# Pipeline-to-Dataset helpers
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _epsilon_ds_to_l4data(epsilon_ds: xr.Dataset) -> Any:
|
|
326
|
+
"""Convert old-format epsilon xr.Dataset to L4Data for chi computation.
|
|
327
|
+
|
|
328
|
+
If the dataset already carries ``epsilonMean`` (written by
|
|
329
|
+
``processing.epsilon_combine.mk_epsilon_mean``), that QC-filtered geometric
|
|
330
|
+
mean is used as ``epsi_final``. Otherwise we fall back to a plain
|
|
331
|
+
``nanmean`` across the per-probe ``epsilon`` array — the historical
|
|
332
|
+
behavior, retained for inputs produced by the lower-level rsi pipeline
|
|
333
|
+
that does not run the multi-probe combine step.
|
|
334
|
+
|
|
335
|
+
Time reference: ``L3ChiData.time`` is in "seconds since start_time"
|
|
336
|
+
(small floats). When the diss dataset is read back from NetCDF, xarray
|
|
337
|
+
decodes its ``t`` coord to absolute ``datetime64``. We convert it back
|
|
338
|
+
to seconds-since-``start_time`` here so ``process_l4_chi_epsilon``'s
|
|
339
|
+
nearest-neighbor matching is over a common reference — otherwise every
|
|
340
|
+
chi window collapses onto ``epsilonMean[0]``.
|
|
341
|
+
"""
|
|
342
|
+
from odas_tpw.scor160.io import L4Data
|
|
343
|
+
|
|
344
|
+
eps_vals = epsilon_ds["epsilon"].values
|
|
345
|
+
if eps_vals.ndim == 2:
|
|
346
|
+
n_sh = eps_vals.shape[0]
|
|
347
|
+
else:
|
|
348
|
+
n_sh = 1
|
|
349
|
+
eps_vals = eps_vals[np.newaxis]
|
|
350
|
+
|
|
351
|
+
def _probe_var(name: str) -> np.ndarray | None:
|
|
352
|
+
"""Read a per-probe (n_sh, n) variable from the epsilon dataset."""
|
|
353
|
+
if name not in epsilon_ds:
|
|
354
|
+
return None
|
|
355
|
+
a = np.asarray(epsilon_ds[name].values, dtype=np.float64)
|
|
356
|
+
return a[np.newaxis] if a.ndim == 1 else a
|
|
357
|
+
|
|
358
|
+
# Probe-rejection filters on the Lueck (2022) MAD-based FM statistic (good
|
|
359
|
+
# a good fit sits near ~0.7-0.8 not 0; ATOMIX rejects FM > ~1.15), NOT the variance-ratio `fom`
|
|
360
|
+
# (centered on 1.0, 0.7-1.4 is a GOOD fit). Thresholding `fom` at 1.15 wrongly
|
|
361
|
+
# drops good probes. Fall back to `fom` only when FM is absent (older files),
|
|
362
|
+
# mirroring l4.py's fom_for_flags.
|
|
363
|
+
fom_arr = _probe_var("FM")
|
|
364
|
+
if fom_arr is None:
|
|
365
|
+
fom_arr = _probe_var("fom")
|
|
366
|
+
mad_arr = _probe_var("mad")
|
|
367
|
+
|
|
368
|
+
if "epsilonMean" in epsilon_ds:
|
|
369
|
+
epsi_final = np.asarray(epsilon_ds["epsilonMean"].values, dtype=np.float64)
|
|
370
|
+
elif n_sh > 1:
|
|
371
|
+
# Exclude bad-probe estimates (FM > limit) before averaging across
|
|
372
|
+
# probes, mirroring compute_chi_window's Method-1 filter: a high-FM
|
|
373
|
+
# shear probe must not bias the epsilon that drives every thermistor's
|
|
374
|
+
# chi. Where a window has no good probe, fall back to the all-probe mean.
|
|
375
|
+
eps_for_mean = eps_vals.astype(np.float64, copy=True)
|
|
376
|
+
if fom_arr is not None and fom_arr.shape == eps_for_mean.shape:
|
|
377
|
+
bad = ~np.isfinite(fom_arr) | (fom_arr > _EPS_FM_LIMIT)
|
|
378
|
+
eps_for_mean = np.where(bad, np.nan, eps_for_mean)
|
|
379
|
+
# catch_warnings (not just errstate): an all-NaN window makes nanmean
|
|
380
|
+
# emit a "Mean of empty slice" RuntimeWarning via warnings.warn, which
|
|
381
|
+
# np.errstate does not suppress. Legitimate dropout windows must not
|
|
382
|
+
# produce a warning storm (#9).
|
|
383
|
+
with np.errstate(invalid="ignore"), warnings.catch_warnings():
|
|
384
|
+
warnings.simplefilter("ignore", RuntimeWarning)
|
|
385
|
+
epsi_final = np.nanmean(eps_for_mean, axis=0)
|
|
386
|
+
allnan = ~np.isfinite(epsi_final)
|
|
387
|
+
if np.any(allnan):
|
|
388
|
+
fallback = np.nanmean(eps_vals, axis=0)
|
|
389
|
+
epsi_final = epsi_final.copy()
|
|
390
|
+
epsi_final[allnan] = fallback[allnan]
|
|
391
|
+
else:
|
|
392
|
+
epsi_final = eps_vals[0]
|
|
393
|
+
|
|
394
|
+
times = epsilon_ds.coords["t"].values
|
|
395
|
+
if np.issubdtype(times.dtype, np.datetime64):
|
|
396
|
+
start_str = epsilon_ds.attrs.get("start_time")
|
|
397
|
+
if start_str:
|
|
398
|
+
# HONOR the timezone offset (decode_cf did when it decoded `t`), then
|
|
399
|
+
# normalize to naive UTC — do NOT strip the offset and reinterpret the
|
|
400
|
+
# local wall-clock as the reference, which shifts the epsilon time base
|
|
401
|
+
# by the offset and silently NaNs all chi for a non-UTC instrument.
|
|
402
|
+
_dt = datetime.fromisoformat(start_str)
|
|
403
|
+
if _dt.tzinfo is not None:
|
|
404
|
+
_dt = _dt.astimezone(UTC).replace(tzinfo=None)
|
|
405
|
+
start = np.datetime64(_dt)
|
|
406
|
+
else:
|
|
407
|
+
start = times[0]
|
|
408
|
+
times = ((times - start) / np.timedelta64(1, "ns")).astype(np.float64) / 1e9
|
|
409
|
+
n = len(times)
|
|
410
|
+
|
|
411
|
+
return L4Data(
|
|
412
|
+
time=times,
|
|
413
|
+
pres=epsilon_ds["P_mean"].values if "P_mean" in epsilon_ds else np.zeros(n),
|
|
414
|
+
pspd_rel=epsilon_ds["speed"].values if "speed" in epsilon_ds else np.zeros(n),
|
|
415
|
+
section_number=np.ones(n),
|
|
416
|
+
epsi=eps_vals,
|
|
417
|
+
epsi_final=epsi_final,
|
|
418
|
+
epsi_flags=np.zeros((n_sh, n)),
|
|
419
|
+
fom=fom_arr if fom_arr is not None and fom_arr.shape == (n_sh, n)
|
|
420
|
+
else np.zeros((n_sh, n)),
|
|
421
|
+
mad=mad_arr if mad_arr is not None and mad_arr.shape == (n_sh, n)
|
|
422
|
+
else np.zeros((n_sh, n)),
|
|
423
|
+
kmax=np.zeros((n_sh, n)),
|
|
424
|
+
method=np.zeros((n_sh, n)),
|
|
425
|
+
var_resolved=np.zeros((n_sh, n)),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _reconstruct_spec_batch(l3_chi, l4_chi, grad_func) -> np.ndarray:
|
|
430
|
+
"""``(n_gradt, n_wavenumber, n_spec)`` fitted gradient spectra for the
|
|
431
|
+
perturb-diag overlay.
|
|
432
|
+
|
|
433
|
+
Bind the per-window molecular thermal diffusivity ``kappa_T`` (mirroring the
|
|
434
|
+
amplitude/fit at ``chi.py:258``) so the overlay matches the stored chi. The
|
|
435
|
+
bare ``grad_func`` would fall back to the module default ``KAPPA_T=1.4e-7``,
|
|
436
|
+
and since the gradient amplitude scales as ``1/kappa_T`` the overlay would
|
|
437
|
+
run ~6% high in warm water where the per-window kappa exceeds the default.
|
|
438
|
+
Falls back to the bare ``grad_func`` only when ``kappa_T`` is not per-window
|
|
439
|
+
sized (e.g. the degenerate no-spectra path). Diagnostic-only — published
|
|
440
|
+
chi/kB/K_T/Gamma come from ``l4_chi`` (already computed with per-window
|
|
441
|
+
kappa), not this reconstruction. (#104 I2a-4.)
|
|
442
|
+
"""
|
|
443
|
+
n_spec = l3_chi.n_spectra
|
|
444
|
+
n_gradt = l3_chi.n_gradt
|
|
445
|
+
n_wave = l3_chi.n_wavenumber
|
|
446
|
+
spec_batch = np.full((n_gradt, n_wave, n_spec), np.nan)
|
|
447
|
+
kappa_per_window = l3_chi.kappa_T.size == n_spec
|
|
448
|
+
for j in range(n_spec):
|
|
449
|
+
gf = (
|
|
450
|
+
functools.partial(grad_func, kappa_T=float(l3_chi.kappa_T[j]))
|
|
451
|
+
if kappa_per_window
|
|
452
|
+
else grad_func
|
|
453
|
+
)
|
|
454
|
+
for ci in range(n_gradt):
|
|
455
|
+
kB = l4_chi.kB[ci, j]
|
|
456
|
+
chi_val = l4_chi.chi[ci, j]
|
|
457
|
+
if np.isfinite(kB) and np.isfinite(chi_val):
|
|
458
|
+
spec_batch[ci, :, j] = gf(l3_chi.kcyc[:, j], kB, chi_val)
|
|
459
|
+
return spec_batch
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _build_chi_ds_from_pipeline(
|
|
463
|
+
l3_chi,
|
|
464
|
+
l4_chi,
|
|
465
|
+
therm_names: list[str],
|
|
466
|
+
*,
|
|
467
|
+
fft_length: int,
|
|
468
|
+
diss_length: int,
|
|
469
|
+
overlap: int,
|
|
470
|
+
fs_fast: float,
|
|
471
|
+
fp07_model: str,
|
|
472
|
+
spectrum_model: str,
|
|
473
|
+
fit_method: str,
|
|
474
|
+
f_AA: float,
|
|
475
|
+
grad_func,
|
|
476
|
+
chi_method: str = "epsilon",
|
|
477
|
+
) -> xr.Dataset:
|
|
478
|
+
"""Build old-format xr.Dataset from L3ChiData + L4ChiData."""
|
|
479
|
+
n_spec = l3_chi.n_spectra
|
|
480
|
+
n_freq = l3_chi.n_wavenumber
|
|
481
|
+
|
|
482
|
+
# DOF
|
|
483
|
+
num_ffts = 2 * (diss_length // fft_length) - 1
|
|
484
|
+
dof_spec = 1.9 * num_ffts
|
|
485
|
+
|
|
486
|
+
# Reconstruct fitted gradient spectra for the diagnostic overlay.
|
|
487
|
+
spec_batch = _reconstruct_spec_batch(l3_chi, l4_chi, grad_func)
|
|
488
|
+
|
|
489
|
+
# F vector (constant across windows) — broadcast view is read-only
|
|
490
|
+
# but suffices for xarray storage and NetCDF serialization.
|
|
491
|
+
F_const = l3_chi.freq
|
|
492
|
+
F_out = np.broadcast_to(F_const[:, np.newaxis], (n_freq, n_spec))
|
|
493
|
+
|
|
494
|
+
return _build_chi_dataset(
|
|
495
|
+
chi_out=l4_chi.chi,
|
|
496
|
+
eps_out=l4_chi.epsilon_T,
|
|
497
|
+
kB_out=l4_chi.kB,
|
|
498
|
+
K_max_out=l4_chi.K_max,
|
|
499
|
+
fom_out=l4_chi.fom,
|
|
500
|
+
K_max_ratio_out=l4_chi.K_max_ratio,
|
|
501
|
+
var_resolved_out=l4_chi.var_resolved,
|
|
502
|
+
speed_out=l3_chi.pspd_rel,
|
|
503
|
+
nu_out=l3_chi.nu,
|
|
504
|
+
P_out=l3_chi.pres,
|
|
505
|
+
T_out=l3_chi.temp,
|
|
506
|
+
t_out=l3_chi.time,
|
|
507
|
+
spec_gradT=l3_chi.gradt_spec,
|
|
508
|
+
spec_batch=spec_batch,
|
|
509
|
+
spec_noise_out=l3_chi.noise_spec,
|
|
510
|
+
K_out=l3_chi.kcyc,
|
|
511
|
+
F_out=F_out,
|
|
512
|
+
therm_names=therm_names,
|
|
513
|
+
fft_length=fft_length,
|
|
514
|
+
diss_length=diss_length,
|
|
515
|
+
overlap=overlap,
|
|
516
|
+
fs_fast=fs_fast,
|
|
517
|
+
dof_spec=dof_spec,
|
|
518
|
+
fp07_model=fp07_model,
|
|
519
|
+
spectrum_model=spectrum_model,
|
|
520
|
+
fit_method=fit_method,
|
|
521
|
+
f_AA=f_AA,
|
|
522
|
+
chi_method=chi_method,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _build_chi_dataset(
|
|
527
|
+
*,
|
|
528
|
+
chi_out: np.ndarray,
|
|
529
|
+
eps_out: np.ndarray,
|
|
530
|
+
kB_out: np.ndarray,
|
|
531
|
+
K_max_out: np.ndarray,
|
|
532
|
+
fom_out: np.ndarray,
|
|
533
|
+
K_max_ratio_out: np.ndarray,
|
|
534
|
+
var_resolved_out: np.ndarray,
|
|
535
|
+
speed_out: np.ndarray,
|
|
536
|
+
nu_out: np.ndarray,
|
|
537
|
+
P_out: np.ndarray,
|
|
538
|
+
T_out: np.ndarray,
|
|
539
|
+
t_out: np.ndarray,
|
|
540
|
+
spec_gradT: np.ndarray,
|
|
541
|
+
spec_batch: np.ndarray,
|
|
542
|
+
spec_noise_out: np.ndarray,
|
|
543
|
+
K_out: np.ndarray,
|
|
544
|
+
F_out: np.ndarray,
|
|
545
|
+
therm_names: list[str],
|
|
546
|
+
fft_length: int,
|
|
547
|
+
diss_length: int,
|
|
548
|
+
overlap: int,
|
|
549
|
+
fs_fast: float,
|
|
550
|
+
dof_spec: float,
|
|
551
|
+
fp07_model: str,
|
|
552
|
+
spectrum_model: str,
|
|
553
|
+
fit_method: str,
|
|
554
|
+
f_AA: float,
|
|
555
|
+
chi_method: str = "epsilon",
|
|
556
|
+
) -> xr.Dataset:
|
|
557
|
+
"""Build an xarray Dataset from chi estimation output arrays."""
|
|
558
|
+
from odas_tpw.rsi.helpers import _build_result_dataset
|
|
559
|
+
|
|
560
|
+
variables = [
|
|
561
|
+
(
|
|
562
|
+
"chi",
|
|
563
|
+
["probe", "time"],
|
|
564
|
+
chi_out,
|
|
565
|
+
{
|
|
566
|
+
"units": "K2 s-1",
|
|
567
|
+
"long_name": "thermal variance dissipation rate",
|
|
568
|
+
},
|
|
569
|
+
),
|
|
570
|
+
(
|
|
571
|
+
"epsilon_T",
|
|
572
|
+
["probe", "time"],
|
|
573
|
+
eps_out,
|
|
574
|
+
{
|
|
575
|
+
"units": "W kg-1",
|
|
576
|
+
"long_name": "TKE dissipation rate from temperature",
|
|
577
|
+
},
|
|
578
|
+
),
|
|
579
|
+
(
|
|
580
|
+
"kB",
|
|
581
|
+
["probe", "time"],
|
|
582
|
+
kB_out,
|
|
583
|
+
{
|
|
584
|
+
"units": "cpm",
|
|
585
|
+
"long_name": "Batchelor wavenumber",
|
|
586
|
+
},
|
|
587
|
+
),
|
|
588
|
+
(
|
|
589
|
+
"K_max_T",
|
|
590
|
+
["probe", "time"],
|
|
591
|
+
K_max_out,
|
|
592
|
+
{
|
|
593
|
+
"units": "cpm",
|
|
594
|
+
"long_name": "upper wavenumber integration limit for chi",
|
|
595
|
+
},
|
|
596
|
+
),
|
|
597
|
+
(
|
|
598
|
+
"fom",
|
|
599
|
+
["probe", "time"],
|
|
600
|
+
fom_out,
|
|
601
|
+
{
|
|
602
|
+
"units": "1",
|
|
603
|
+
"long_name": "figure of merit (observed/model variance ratio)",
|
|
604
|
+
"comment": (
|
|
605
|
+
"Ratio of observed to attenuated-model variance over the fit "
|
|
606
|
+
"range; values near 1.0 indicate a good fit. NOT the MAD-based "
|
|
607
|
+
"ATOMIX/Rockland FM statistic (Lueck 2022), for which a good "
|
|
608
|
+
"fit sits near ~0.7-0.8 (NOT 0) and the recommended QC limit is ~1.15."
|
|
609
|
+
),
|
|
610
|
+
},
|
|
611
|
+
),
|
|
612
|
+
(
|
|
613
|
+
"K_max_ratio",
|
|
614
|
+
["probe", "time"],
|
|
615
|
+
K_max_ratio_out,
|
|
616
|
+
{
|
|
617
|
+
"units": "1",
|
|
618
|
+
"long_name": "K_max / kB spectral resolution ratio",
|
|
619
|
+
},
|
|
620
|
+
),
|
|
621
|
+
(
|
|
622
|
+
"var_resolved",
|
|
623
|
+
["probe", "time"],
|
|
624
|
+
var_resolved_out,
|
|
625
|
+
{
|
|
626
|
+
"units": "1",
|
|
627
|
+
"long_name": "Batchelor gradient-spectrum variance-resolved fraction",
|
|
628
|
+
"comment": (
|
|
629
|
+
"Fraction of the model temperature-gradient (Batchelor/"
|
|
630
|
+
"Kraichnan) variance within the fit band [K_min, K_max], "
|
|
631
|
+
"relative to the full spectrum; NO FP07 |H|^2 attenuation "
|
|
632
|
+
"(the chi-side analog of the Nasmyth V_f, Lueck 2022 eq 17). "
|
|
633
|
+
"mk_chi_mean applies the eq (18) truncation "
|
|
634
|
+
"L_hat_f = L_hat * var_resolved**0.75 to widen chiLnSigma "
|
|
635
|
+
"where the spectrum is truncated at K_max. (issue #104 U4-F1.) "
|
|
636
|
+
"NOTE: the diss (epsilon) product also has a 'var_resolved' "
|
|
637
|
+
"but it is the Nasmyth SHEAR fraction; the two are distinct "
|
|
638
|
+
"per-product quantities and must not be cross-read (mk_chi_mean "
|
|
639
|
+
"reads only the chi product, mk_epsilon_mean only the diss product)."
|
|
640
|
+
),
|
|
641
|
+
},
|
|
642
|
+
),
|
|
643
|
+
(
|
|
644
|
+
"speed",
|
|
645
|
+
["time"],
|
|
646
|
+
speed_out,
|
|
647
|
+
{
|
|
648
|
+
"units": "m s-1",
|
|
649
|
+
"long_name": "profiling speed",
|
|
650
|
+
},
|
|
651
|
+
),
|
|
652
|
+
(
|
|
653
|
+
"nu",
|
|
654
|
+
["time"],
|
|
655
|
+
nu_out,
|
|
656
|
+
{
|
|
657
|
+
"units": "m2 s-1",
|
|
658
|
+
"long_name": "kinematic viscosity of sea water",
|
|
659
|
+
},
|
|
660
|
+
),
|
|
661
|
+
(
|
|
662
|
+
"P_mean",
|
|
663
|
+
["time"],
|
|
664
|
+
P_out,
|
|
665
|
+
{
|
|
666
|
+
"units": "dbar",
|
|
667
|
+
"long_name": "mean sea water pressure",
|
|
668
|
+
"standard_name": "sea_water_pressure",
|
|
669
|
+
"positive": "down",
|
|
670
|
+
},
|
|
671
|
+
),
|
|
672
|
+
(
|
|
673
|
+
"T_mean",
|
|
674
|
+
["time"],
|
|
675
|
+
T_out,
|
|
676
|
+
{
|
|
677
|
+
"units": "degree_Celsius",
|
|
678
|
+
"long_name": "mean sea water temperature",
|
|
679
|
+
"standard_name": "sea_water_temperature",
|
|
680
|
+
},
|
|
681
|
+
),
|
|
682
|
+
(
|
|
683
|
+
"spec_gradT",
|
|
684
|
+
["probe", "freq", "time"],
|
|
685
|
+
spec_gradT,
|
|
686
|
+
{
|
|
687
|
+
"units": "K2 m-1",
|
|
688
|
+
"long_name": "temperature gradient wavenumber spectrum (observed)",
|
|
689
|
+
},
|
|
690
|
+
),
|
|
691
|
+
(
|
|
692
|
+
"spec_batch",
|
|
693
|
+
["probe", "freq", "time"],
|
|
694
|
+
spec_batch,
|
|
695
|
+
{
|
|
696
|
+
"units": "K2 m-1",
|
|
697
|
+
"long_name": "fitted Batchelor temperature gradient spectrum",
|
|
698
|
+
},
|
|
699
|
+
),
|
|
700
|
+
(
|
|
701
|
+
"spec_noise",
|
|
702
|
+
["probe", "freq", "time"],
|
|
703
|
+
spec_noise_out,
|
|
704
|
+
{
|
|
705
|
+
"units": "K2 m-1",
|
|
706
|
+
"long_name": "FP07 electronics noise spectrum",
|
|
707
|
+
},
|
|
708
|
+
),
|
|
709
|
+
(
|
|
710
|
+
"K",
|
|
711
|
+
["freq", "time"],
|
|
712
|
+
K_out,
|
|
713
|
+
{
|
|
714
|
+
"units": "cpm",
|
|
715
|
+
"long_name": "wavenumber (cycles per meter)",
|
|
716
|
+
},
|
|
717
|
+
),
|
|
718
|
+
(
|
|
719
|
+
"F",
|
|
720
|
+
["freq", "time"],
|
|
721
|
+
F_out,
|
|
722
|
+
{
|
|
723
|
+
"units": "Hz",
|
|
724
|
+
"long_name": "frequency",
|
|
725
|
+
},
|
|
726
|
+
),
|
|
727
|
+
]
|
|
728
|
+
return _build_result_dataset(
|
|
729
|
+
variables,
|
|
730
|
+
therm_names,
|
|
731
|
+
t_out,
|
|
732
|
+
"thermistor probe name",
|
|
733
|
+
{
|
|
734
|
+
"Conventions": "CF-1.13, ACDD-1.3",
|
|
735
|
+
"fft_length": fft_length,
|
|
736
|
+
"diss_length": diss_length,
|
|
737
|
+
"overlap": overlap,
|
|
738
|
+
"fs_fast": fs_fast,
|
|
739
|
+
"dof_spec": dof_spec,
|
|
740
|
+
"fp07_model": fp07_model,
|
|
741
|
+
"spectrum_model": spectrum_model,
|
|
742
|
+
"fit_method": fit_method,
|
|
743
|
+
"f_AA": f_AA,
|
|
744
|
+
"chi_method": chi_method,
|
|
745
|
+
},
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
# ---------------------------------------------------------------------------
|
|
750
|
+
# Channel loading helpers
|
|
751
|
+
# ---------------------------------------------------------------------------
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _extract_therm_cal(ch_cfg: dict[str, Any]) -> dict[str, float]:
|
|
755
|
+
"""Extract thermistor calibration parameters from PFile channel config.
|
|
756
|
+
|
|
757
|
+
Config keys are lowercase (parse_config lowercases all keys); 'g' and
|
|
758
|
+
't_0' are renamed to the 'gain'/'T_0' parameter names expected by
|
|
759
|
+
noise_thermchannel.
|
|
760
|
+
"""
|
|
761
|
+
cal = {}
|
|
762
|
+
for key in ("e_b", "b", "g", "beta_1", "beta_2", "adc_fs", "adc_bits", "t_0"):
|
|
763
|
+
val = ch_cfg.get(key)
|
|
764
|
+
if val is not None:
|
|
765
|
+
cal[key] = float(val)
|
|
766
|
+
# Map config keys to noise_thermchannel parameter names
|
|
767
|
+
if "g" in cal:
|
|
768
|
+
cal["gain"] = cal.pop("g")
|
|
769
|
+
if "t_0" in cal:
|
|
770
|
+
cal["T_0"] = cal.pop("t_0")
|
|
771
|
+
return cal
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _load_therm_channels(source: PFile | str | Path) -> dict[str, Any]:
|
|
775
|
+
"""Load thermistor gradient channels from any source.
|
|
776
|
+
|
|
777
|
+
Looks for channels matching T*_dT* pattern (pre-emphasized temperature),
|
|
778
|
+
or falls back to T1/T2 channels for first-difference gradient.
|
|
779
|
+
"""
|
|
780
|
+
from odas_tpw.rsi.helpers import load_channels
|
|
781
|
+
from odas_tpw.rsi.p_file import PFile
|
|
782
|
+
|
|
783
|
+
# First load standard channels (convert TypedDict → plain dict for extra keys)
|
|
784
|
+
data: dict[str, Any] = dict(load_channels(source))
|
|
785
|
+
|
|
786
|
+
therm: list[tuple[str, np.ndarray]] = []
|
|
787
|
+
diff_gains: list[float] = []
|
|
788
|
+
|
|
789
|
+
if isinstance(source, PFile):
|
|
790
|
+
pf = source
|
|
791
|
+
elif Path(source).suffix.lower() == ".p":
|
|
792
|
+
pf = PFile(source)
|
|
793
|
+
else:
|
|
794
|
+
pf = None
|
|
795
|
+
|
|
796
|
+
therm_cal: list[dict[str, float]] = []
|
|
797
|
+
|
|
798
|
+
if pf is not None:
|
|
799
|
+
from odas_tpw.rsi.helpers import DT_PATTERN, T_PATTERN
|
|
800
|
+
|
|
801
|
+
for name in sorted(pf._fast_channels):
|
|
802
|
+
if DT_PATTERN.match(name):
|
|
803
|
+
therm.append((name, pf.channels[name]))
|
|
804
|
+
# Get diff_gain from config
|
|
805
|
+
ch_cfg: dict = next(
|
|
806
|
+
(ch for ch in pf.config["channels"] if ch.get("name") == name),
|
|
807
|
+
{},
|
|
808
|
+
)
|
|
809
|
+
dg = ch_cfg.get("diff_gain", "0.94")
|
|
810
|
+
diff_gains.append(float(dg))
|
|
811
|
+
# Extract calibration from base channel (T1 for T1_dT1)
|
|
812
|
+
m = re.match(r"^(\w+)_d\1$", name)
|
|
813
|
+
base_name = m.group(1) if m else name
|
|
814
|
+
base_cfg: dict = next(
|
|
815
|
+
(ch for ch in pf.config["channels"] if ch.get("name") == base_name),
|
|
816
|
+
{},
|
|
817
|
+
)
|
|
818
|
+
therm_cal.append(_extract_therm_cal(base_cfg))
|
|
819
|
+
|
|
820
|
+
# If no pre-emphasized channels, use T channels with first-difference
|
|
821
|
+
if not therm:
|
|
822
|
+
for name in sorted(pf._fast_channels):
|
|
823
|
+
if T_PATTERN.match(name):
|
|
824
|
+
# Compute gradient via first-difference
|
|
825
|
+
T_data = pf.channels[name]
|
|
826
|
+
therm.append((name, T_data))
|
|
827
|
+
diff_gains.append(0.94)
|
|
828
|
+
ch_cfg = next(
|
|
829
|
+
(ch for ch in pf.config["channels"] if ch.get("name") == name),
|
|
830
|
+
{},
|
|
831
|
+
)
|
|
832
|
+
therm_cal.append(_extract_therm_cal(ch_cfg))
|
|
833
|
+
else:
|
|
834
|
+
# NetCDF source — look for gradient channels or T channels
|
|
835
|
+
import netCDF4 as nc
|
|
836
|
+
|
|
837
|
+
from odas_tpw.rsi.helpers import DT_PATTERN, T_PATTERN
|
|
838
|
+
|
|
839
|
+
ds = nc.Dataset(str(source), "r")
|
|
840
|
+
|
|
841
|
+
for vname in sorted(ds.variables.keys()):
|
|
842
|
+
var = ds.variables[vname]
|
|
843
|
+
if var.dimensions == ("time_fast",):
|
|
844
|
+
arr = var[:].data.astype(np.float64)
|
|
845
|
+
if DT_PATTERN.match(vname):
|
|
846
|
+
therm.append((vname, arr))
|
|
847
|
+
diff_gains.append(0.94)
|
|
848
|
+
|
|
849
|
+
if not therm:
|
|
850
|
+
for vname in sorted(ds.variables.keys()):
|
|
851
|
+
var = ds.variables[vname]
|
|
852
|
+
if var.dimensions == ("time_fast",) and T_PATTERN.match(vname):
|
|
853
|
+
therm.append((vname, var[:].data.astype(np.float64)))
|
|
854
|
+
diff_gains.append(0.94)
|
|
855
|
+
|
|
856
|
+
ds.close()
|
|
857
|
+
|
|
858
|
+
data["therm"] = therm
|
|
859
|
+
data["diff_gains"] = diff_gains
|
|
860
|
+
data["therm_cal"] = therm_cal if therm_cal else [{}] * len(therm)
|
|
861
|
+
return data
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
# ---------------------------------------------------------------------------
|
|
865
|
+
# File-level processing
|
|
866
|
+
# ---------------------------------------------------------------------------
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def compute_chi_file(
|
|
870
|
+
source_path: str | Path,
|
|
871
|
+
output_dir: str | Path,
|
|
872
|
+
**chi_kwargs: Any,
|
|
873
|
+
) -> list[Path]:
|
|
874
|
+
"""Compute chi for one file and write NetCDF output(s).
|
|
875
|
+
|
|
876
|
+
Parameters
|
|
877
|
+
----------
|
|
878
|
+
source_path : str or Path
|
|
879
|
+
Input file.
|
|
880
|
+
output_dir : str or Path
|
|
881
|
+
Output directory.
|
|
882
|
+
**chi_kwargs
|
|
883
|
+
Keyword arguments forwarded to ``_compute_chi`` (the chi parameter
|
|
884
|
+
set; ``get_chi`` is the deprecated public wrapper).
|
|
885
|
+
|
|
886
|
+
Returns
|
|
887
|
+
-------
|
|
888
|
+
list of Path
|
|
889
|
+
Paths to output files written.
|
|
890
|
+
"""
|
|
891
|
+
from odas_tpw.rsi.helpers import write_profile_results
|
|
892
|
+
|
|
893
|
+
source_path = Path(source_path)
|
|
894
|
+
output_dir = Path(output_dir)
|
|
895
|
+
results = _compute_chi(source_path, **chi_kwargs)
|
|
896
|
+
return write_profile_results(results, source_path, output_dir, "chi")
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def load_epsilon_dataset(
|
|
900
|
+
source_path: str | Path, epsilon_dir: str | Path
|
|
901
|
+
) -> xr.Dataset | None:
|
|
902
|
+
"""Locate and open the epsilon dataset(s) for *source_path* (Method 1).
|
|
903
|
+
|
|
904
|
+
``rsi-tpw eps`` writes into hash-tracked subdirectories
|
|
905
|
+
(``epsilon_dir/eps_00/``...) and names files per profile
|
|
906
|
+
(``{stem}_prof001_eps.nc``). This helper searches *epsilon_dir*
|
|
907
|
+
itself first, then its ``eps_*`` subdirectories (most recently
|
|
908
|
+
modified first), and concatenates multiple per-profile files along
|
|
909
|
+
``time`` so every chi window can pair with its own profile's epsilon
|
|
910
|
+
estimates.
|
|
911
|
+
|
|
912
|
+
Returns None when no matching epsilon files exist (the caller falls
|
|
913
|
+
back to Method 2).
|
|
914
|
+
"""
|
|
915
|
+
stem = Path(source_path).stem
|
|
916
|
+
base = Path(epsilon_dir)
|
|
917
|
+
candidates = [
|
|
918
|
+
base,
|
|
919
|
+
*sorted(
|
|
920
|
+
(d for d in base.glob("eps_*") if d.is_dir()),
|
|
921
|
+
key=lambda d: d.stat().st_mtime,
|
|
922
|
+
reverse=True,
|
|
923
|
+
),
|
|
924
|
+
]
|
|
925
|
+
for d in candidates:
|
|
926
|
+
files = sorted(d.glob(f"{stem}_prof*_eps.nc"))
|
|
927
|
+
# The single-profile (`{stem}_eps.nc`) and per-profile
|
|
928
|
+
# (`{stem}_prof00N_eps.nc`) naming schemes are mutually exclusive within
|
|
929
|
+
# one run. Only fall back to the single-profile file when NO per-profile
|
|
930
|
+
# file exists: concatenating both (e.g. when a re-run with different
|
|
931
|
+
# trim/direction left stale products of both forms in the dir) would
|
|
932
|
+
# double-count epsilon times and silently bias the Method-1 match.
|
|
933
|
+
if not files:
|
|
934
|
+
single = d / f"{stem}_eps.nc"
|
|
935
|
+
if single.exists():
|
|
936
|
+
files = [single]
|
|
937
|
+
if not files:
|
|
938
|
+
continue
|
|
939
|
+
if len(files) == 1:
|
|
940
|
+
return xr.open_dataset(files[0])
|
|
941
|
+
datasets = [xr.open_dataset(p).load() for p in files]
|
|
942
|
+
try:
|
|
943
|
+
return xr.concat(datasets, dim="time", combine_attrs="override")
|
|
944
|
+
finally:
|
|
945
|
+
for ds in datasets:
|
|
946
|
+
ds.close()
|
|
947
|
+
return None
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _compute_chi_one(args: tuple) -> tuple[str, int]:
|
|
951
|
+
"""Worker for parallel chi computation.
|
|
952
|
+
|
|
953
|
+
args is (source_path, output_dir, kwargs) or
|
|
954
|
+
(source_path, output_dir, kwargs, epsilon_dir).
|
|
955
|
+
When epsilon_dir is provided, the worker opens the matching epsilon
|
|
956
|
+
file itself (xr.Datasets cannot be pickled across process boundaries).
|
|
957
|
+
"""
|
|
958
|
+
if len(args) == 4:
|
|
959
|
+
source_path, output_dir, kwargs, epsilon_dir = args
|
|
960
|
+
else:
|
|
961
|
+
source_path, output_dir, kwargs = args
|
|
962
|
+
epsilon_dir = None
|
|
963
|
+
|
|
964
|
+
eps_ds = load_epsilon_dataset(source_path, epsilon_dir) if epsilon_dir else None
|
|
965
|
+
if eps_ds is not None:
|
|
966
|
+
try:
|
|
967
|
+
kwargs = dict(kwargs)
|
|
968
|
+
kwargs["epsilon_ds"] = eps_ds
|
|
969
|
+
paths = compute_chi_file(source_path, output_dir, **kwargs)
|
|
970
|
+
finally:
|
|
971
|
+
eps_ds.close()
|
|
972
|
+
else:
|
|
973
|
+
if epsilon_dir is not None:
|
|
974
|
+
print(
|
|
975
|
+
f" Warning: no epsilon files for {Path(source_path).stem} "
|
|
976
|
+
f"under {epsilon_dir} (searched eps_* subdirectories too); "
|
|
977
|
+
"using Method 2"
|
|
978
|
+
)
|
|
979
|
+
paths = compute_chi_file(source_path, output_dir, **kwargs)
|
|
980
|
+
return str(source_path), len(paths)
|