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/chi/l4_chi.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""L4 chi: chi estimation from L3 temperature gradient spectra.
|
|
2
|
+
|
|
3
|
+
Two methods:
|
|
4
|
+
1. Chi from known epsilon (L4Data from shear probes) — Method 1
|
|
5
|
+
2. Chi from spectral fitting (MLE or iterative) — Method 2
|
|
6
|
+
|
|
7
|
+
Both call existing, tested chi fitting functions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from odas_tpw.chi.chi import (
|
|
18
|
+
_chi_from_epsilon,
|
|
19
|
+
_iterative_fit,
|
|
20
|
+
_mle_fit_kB,
|
|
21
|
+
)
|
|
22
|
+
from odas_tpw.chi.fp07 import fp07_double_pole, fp07_transfer
|
|
23
|
+
from odas_tpw.chi.l3_chi import L3ChiData
|
|
24
|
+
from odas_tpw.scor160.io import L4Data
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _to_float64_seconds(arr: np.ndarray) -> np.ndarray:
|
|
30
|
+
"""Convert time array to float64, handling datetime64 from xarray."""
|
|
31
|
+
if np.issubdtype(arr.dtype, np.datetime64):
|
|
32
|
+
# Convert datetime64 → float64 seconds since epoch
|
|
33
|
+
return arr.astype("datetime64[ns]").astype("int64") / 1e9
|
|
34
|
+
return np.asarray(arr, dtype=np.float64)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class L4ChiData:
|
|
39
|
+
"""Level-4 chi (thermal variance dissipation) estimates."""
|
|
40
|
+
|
|
41
|
+
time: np.ndarray # (N_SPECTRA,)
|
|
42
|
+
pres: np.ndarray # (N_SPECTRA,)
|
|
43
|
+
pspd_rel: np.ndarray # (N_SPECTRA,)
|
|
44
|
+
section_number: np.ndarray # (N_SPECTRA,)
|
|
45
|
+
chi: np.ndarray # (N_GRADT, N_SPECTRA)
|
|
46
|
+
chi_final: np.ndarray # (N_SPECTRA,)
|
|
47
|
+
epsilon_T: np.ndarray # (N_GRADT, N_SPECTRA)
|
|
48
|
+
kB: np.ndarray # (N_GRADT, N_SPECTRA)
|
|
49
|
+
K_max: np.ndarray # (N_GRADT, N_SPECTRA)
|
|
50
|
+
fom: np.ndarray # (N_GRADT, N_SPECTRA)
|
|
51
|
+
K_max_ratio: np.ndarray # (N_GRADT, N_SPECTRA)
|
|
52
|
+
var_resolved: np.ndarray # (N_GRADT, N_SPECTRA) Batchelor V_f (#104 U4-F1)
|
|
53
|
+
method: str # "epsilon" or "fit"
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def n_spectra(self) -> int:
|
|
57
|
+
"""Number of spectral windows."""
|
|
58
|
+
return int(self.time.shape[0])
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def n_gradt(self) -> int:
|
|
62
|
+
"""Number of temperature gradient channels."""
|
|
63
|
+
return int(self.chi.shape[0])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _process_l4_chi(
|
|
67
|
+
l3_chi: L3ChiData,
|
|
68
|
+
chi_func,
|
|
69
|
+
method_name: str,
|
|
70
|
+
f_AA: float,
|
|
71
|
+
) -> L4ChiData:
|
|
72
|
+
"""Shared L4 chi processing loop.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
l3_chi : L3ChiData
|
|
77
|
+
Level-3 temperature gradient spectra.
|
|
78
|
+
chi_func : callable
|
|
79
|
+
``chi_func(j, ci, spec_obs, noise_K, K, W, nu, kappa_T, tau0, H2, _h2,
|
|
80
|
+
f_AA_eff)`` returns ``(chi_val, eps_val, kB_val, K_max_val, fom_val,
|
|
81
|
+
K_max_ratio_val, var_resolved_val)`` or None to skip.
|
|
82
|
+
method_name : str
|
|
83
|
+
"epsilon" or "fit".
|
|
84
|
+
f_AA : float
|
|
85
|
+
Anti-aliasing filter cutoff [Hz].
|
|
86
|
+
"""
|
|
87
|
+
n_spec = l3_chi.n_spectra
|
|
88
|
+
n_gradt = l3_chi.n_gradt
|
|
89
|
+
f_AA_eff = 0.9 * f_AA
|
|
90
|
+
|
|
91
|
+
_h2 = fp07_transfer if l3_chi.fp07_model == "single_pole" else fp07_double_pole
|
|
92
|
+
|
|
93
|
+
chi_out = np.full((n_gradt, n_spec), np.nan)
|
|
94
|
+
eps_out = np.full((n_gradt, n_spec), np.nan)
|
|
95
|
+
kB_out = np.full((n_gradt, n_spec), np.nan)
|
|
96
|
+
K_max_out = np.full((n_gradt, n_spec), np.nan)
|
|
97
|
+
fom_out = np.full((n_gradt, n_spec), np.nan)
|
|
98
|
+
K_max_ratio_out = np.full((n_gradt, n_spec), np.nan)
|
|
99
|
+
var_resolved_out = np.full((n_gradt, n_spec), np.nan)
|
|
100
|
+
|
|
101
|
+
for j in range(n_spec):
|
|
102
|
+
K = l3_chi.kcyc[:, j]
|
|
103
|
+
W = l3_chi.pspd_rel[j]
|
|
104
|
+
nu = l3_chi.nu[j]
|
|
105
|
+
kappa_T = float(l3_chi.kappa_T[j])
|
|
106
|
+
tau0 = l3_chi.tau0[j]
|
|
107
|
+
H2 = l3_chi.H2[j]
|
|
108
|
+
|
|
109
|
+
for ci in range(n_gradt):
|
|
110
|
+
spec_obs = l3_chi.gradt_spec[ci, :, j]
|
|
111
|
+
noise_K = l3_chi.noise_spec[ci, :, j]
|
|
112
|
+
|
|
113
|
+
result = chi_func(
|
|
114
|
+
j, ci, spec_obs, noise_K, K, W, nu, kappa_T, tau0, H2, _h2, f_AA_eff
|
|
115
|
+
)
|
|
116
|
+
if result is None:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
chi_val, eps_val, kB_val, K_max_val, fom_val, K_max_ratio_val, var_res_val = result
|
|
120
|
+
chi_out[ci, j] = chi_val
|
|
121
|
+
eps_out[ci, j] = eps_val
|
|
122
|
+
kB_out[ci, j] = kB_val
|
|
123
|
+
K_max_out[ci, j] = K_max_val
|
|
124
|
+
fom_out[ci, j] = fom_val
|
|
125
|
+
K_max_ratio_out[ci, j] = K_max_ratio_val
|
|
126
|
+
var_resolved_out[ci, j] = var_res_val
|
|
127
|
+
|
|
128
|
+
# U3-C3: drop Method-2 windows whose fitted kB reaches past the anti-alias
|
|
129
|
+
# band (kB > _CHI_KB_KAA_MAX * K_AA). Applied BEFORE _compute_chi_final so
|
|
130
|
+
# the dropped windows also leave chi_final. Method 1 ("epsilon") fixes kB
|
|
131
|
+
# from the shear epsilon rather than fitting it, so this can never fire
|
|
132
|
+
# there and is skipped by construction. K_max_ratio (= k_u/kB) divides by
|
|
133
|
+
# the biased-low kB and so cannot catch these on its own — see U3-C3 grounding.
|
|
134
|
+
if method_name == "fit":
|
|
135
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
136
|
+
K_AA_w = f_AA_eff / l3_chi.pspd_rel # (n_spec,) anti-alias wavenumber
|
|
137
|
+
under_resolved = np.isfinite(kB_out) & (kB_out > _CHI_KB_KAA_MAX * K_AA_w[None, :])
|
|
138
|
+
n_dropped = int(under_resolved.sum())
|
|
139
|
+
if n_dropped:
|
|
140
|
+
logger.warning(
|
|
141
|
+
"%d Method-2 chi window(s) dropped: fitted kB > %.1f*K_AA "
|
|
142
|
+
"(Batchelor rolloff beyond the anti-alias band; chi and "
|
|
143
|
+
"epsilon_T under-resolved, biased low)",
|
|
144
|
+
n_dropped,
|
|
145
|
+
_CHI_KB_KAA_MAX,
|
|
146
|
+
)
|
|
147
|
+
chi_out[under_resolved] = np.nan
|
|
148
|
+
eps_out[under_resolved] = np.nan
|
|
149
|
+
# Keep var_resolved consistent with the NaN'd chi (dropped window carries
|
|
150
|
+
# no valid uncertainty input either).
|
|
151
|
+
var_resolved_out[under_resolved] = np.nan
|
|
152
|
+
|
|
153
|
+
chi_final = _compute_chi_final(chi_out, fom_out, K_max_ratio_out)
|
|
154
|
+
|
|
155
|
+
return L4ChiData(
|
|
156
|
+
time=l3_chi.time.copy(),
|
|
157
|
+
pres=l3_chi.pres.copy(),
|
|
158
|
+
pspd_rel=l3_chi.pspd_rel.copy(),
|
|
159
|
+
section_number=l3_chi.section_number.copy(),
|
|
160
|
+
chi=chi_out,
|
|
161
|
+
chi_final=chi_final,
|
|
162
|
+
epsilon_T=eps_out,
|
|
163
|
+
kB=kB_out,
|
|
164
|
+
K_max=K_max_out,
|
|
165
|
+
fom=fom_out,
|
|
166
|
+
K_max_ratio=K_max_ratio_out,
|
|
167
|
+
var_resolved=var_resolved_out,
|
|
168
|
+
method=method_name,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def process_l4_chi_epsilon(
|
|
173
|
+
l3_chi: L3ChiData,
|
|
174
|
+
l4_diss: L4Data,
|
|
175
|
+
*,
|
|
176
|
+
spectrum_model: str = "kraichnan",
|
|
177
|
+
f_AA: float = 98.0,
|
|
178
|
+
) -> L4ChiData:
|
|
179
|
+
"""Compute chi from known epsilon (Method 1).
|
|
180
|
+
|
|
181
|
+
Parameters
|
|
182
|
+
----------
|
|
183
|
+
l3_chi : L3ChiData
|
|
184
|
+
Level-3 temperature gradient spectra.
|
|
185
|
+
l4_diss : L4Data
|
|
186
|
+
Level-4 dissipation estimates (epsilon from shear probes).
|
|
187
|
+
spectrum_model : str
|
|
188
|
+
'batchelor' or 'kraichnan'.
|
|
189
|
+
f_AA : float
|
|
190
|
+
Anti-aliasing filter cutoff [Hz].
|
|
191
|
+
|
|
192
|
+
Returns
|
|
193
|
+
-------
|
|
194
|
+
L4ChiData
|
|
195
|
+
"""
|
|
196
|
+
# Pre-compute time matching
|
|
197
|
+
epsi_final = l4_diss.epsi_final
|
|
198
|
+
epsi_times = _to_float64_seconds(l4_diss.time)
|
|
199
|
+
chi_times = _to_float64_seconds(l3_chi.time)
|
|
200
|
+
# Maximum |Δt| for pairing a chi window with an epsilon estimate: one
|
|
201
|
+
# epsilon-window spacing. Without this, a chi window with no nearby
|
|
202
|
+
# epsilon (failed estimates, different window grids) silently pairs
|
|
203
|
+
# with an estimate arbitrarily far away.
|
|
204
|
+
max_dt = float(np.median(np.diff(np.sort(epsi_times)))) if len(epsi_times) > 1 else 30.0
|
|
205
|
+
|
|
206
|
+
def _chi_eps_func(j, _ci, spec_obs, noise_K, K, W, nu, kappa_T, tau0, H2, _h2, f_AA_eff):
|
|
207
|
+
if len(epsi_times) > 0:
|
|
208
|
+
idx_eps = int(np.argmin(np.abs(epsi_times - chi_times[j])))
|
|
209
|
+
if abs(epsi_times[idx_eps] - chi_times[j]) > max_dt:
|
|
210
|
+
return None
|
|
211
|
+
epsilon_val = float(epsi_final[idx_eps])
|
|
212
|
+
else:
|
|
213
|
+
epsilon_val = np.nan
|
|
214
|
+
if not np.isfinite(epsilon_val) or epsilon_val <= 0:
|
|
215
|
+
return None
|
|
216
|
+
chi_val, kB_val, K_max_val, _, fom_val, K_max_ratio_val, var_res_val = _chi_from_epsilon(
|
|
217
|
+
spec_obs,
|
|
218
|
+
K,
|
|
219
|
+
epsilon_val,
|
|
220
|
+
nu,
|
|
221
|
+
noise_K,
|
|
222
|
+
H2,
|
|
223
|
+
tau0,
|
|
224
|
+
_h2,
|
|
225
|
+
f_AA_eff,
|
|
226
|
+
W,
|
|
227
|
+
spectrum_model,
|
|
228
|
+
kappa_T,
|
|
229
|
+
)
|
|
230
|
+
return chi_val, epsilon_val, kB_val, K_max_val, fom_val, K_max_ratio_val, var_res_val
|
|
231
|
+
|
|
232
|
+
return _process_l4_chi(l3_chi, _chi_eps_func, "epsilon", f_AA)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def process_l4_chi_fit(
|
|
236
|
+
l3_chi: L3ChiData,
|
|
237
|
+
*,
|
|
238
|
+
spectrum_model: str = "kraichnan",
|
|
239
|
+
fit_method: str = "iterative",
|
|
240
|
+
f_AA: float = 98.0,
|
|
241
|
+
) -> L4ChiData:
|
|
242
|
+
"""Compute chi by spectral fitting (Method 2).
|
|
243
|
+
|
|
244
|
+
Parameters
|
|
245
|
+
----------
|
|
246
|
+
l3_chi : L3ChiData
|
|
247
|
+
Level-3 temperature gradient spectra.
|
|
248
|
+
spectrum_model : str
|
|
249
|
+
'batchelor' or 'kraichnan'.
|
|
250
|
+
fit_method : str
|
|
251
|
+
'iterative' (Peterson & Fer 2014) or 'mle' (Ruddick et al. 2000).
|
|
252
|
+
f_AA : float
|
|
253
|
+
Anti-aliasing filter cutoff [Hz].
|
|
254
|
+
|
|
255
|
+
Returns
|
|
256
|
+
-------
|
|
257
|
+
L4ChiData
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
def _chi_fit_func(_j, _ci, spec_obs, noise_K, K, W, nu, kappa_T, tau0, H2, _h2, f_AA_eff):
|
|
261
|
+
if fit_method == "iterative":
|
|
262
|
+
(
|
|
263
|
+
kB_val, chi_val, eps_val, K_max_val, _, fom_val, K_max_ratio_val, var_res_val
|
|
264
|
+
) = _iterative_fit(
|
|
265
|
+
spec_obs,
|
|
266
|
+
K,
|
|
267
|
+
nu,
|
|
268
|
+
noise_K,
|
|
269
|
+
H2,
|
|
270
|
+
tau0,
|
|
271
|
+
_h2,
|
|
272
|
+
f_AA_eff,
|
|
273
|
+
W,
|
|
274
|
+
spectrum_model,
|
|
275
|
+
kappa_T,
|
|
276
|
+
)
|
|
277
|
+
else:
|
|
278
|
+
mask = (K > 0) & (f_AA_eff / W >= K)
|
|
279
|
+
if np.sum(mask) < 3:
|
|
280
|
+
return None
|
|
281
|
+
chi_obs = (
|
|
282
|
+
6
|
|
283
|
+
* kappa_T
|
|
284
|
+
* np.trapezoid(
|
|
285
|
+
np.maximum(spec_obs[mask] - noise_K[mask], 0),
|
|
286
|
+
K[mask],
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
if chi_obs <= 0:
|
|
290
|
+
chi_obs = 1e-14
|
|
291
|
+
# Iterate the MLE: chi_obs starts as the attenuation-uncorrected,
|
|
292
|
+
# band-limited variance (several times too small). _mle_fit_kB
|
|
293
|
+
# recomputes a |H|^2- and unresolved-variance-corrected chi after
|
|
294
|
+
# each kB fit; feed that back as the NLL's fixed chi so kB isn't
|
|
295
|
+
# inflated to compensate (a ~22% high kB -> ~2.2x high epsilon).
|
|
296
|
+
# Mirrors _iterative_fit's convergence loop. (M-6)
|
|
297
|
+
result = _mle_fit_kB(
|
|
298
|
+
spec_obs, K, chi_obs, nu, noise_K, H2, tau0, _h2,
|
|
299
|
+
f_AA_eff, W, spectrum_model, kappa_T,
|
|
300
|
+
)
|
|
301
|
+
kB_prev = result.kB
|
|
302
|
+
for _ in range(2):
|
|
303
|
+
if not (np.isfinite(result.kB) and np.isfinite(result.chi) and result.chi > 0):
|
|
304
|
+
break
|
|
305
|
+
result = _mle_fit_kB(
|
|
306
|
+
spec_obs, K, result.chi, nu, noise_K, H2, tau0, _h2,
|
|
307
|
+
f_AA_eff, W, spectrum_model, kappa_T,
|
|
308
|
+
)
|
|
309
|
+
if (
|
|
310
|
+
np.isfinite(result.kB) and np.isfinite(kB_prev) and kB_prev > 0
|
|
311
|
+
and abs(result.kB - kB_prev) / kB_prev < 0.01
|
|
312
|
+
):
|
|
313
|
+
break
|
|
314
|
+
kB_prev = result.kB
|
|
315
|
+
kB_val, chi_val, eps_val, K_max_val = (
|
|
316
|
+
result.kB, result.chi, result.epsilon, result.K_max
|
|
317
|
+
)
|
|
318
|
+
fom_val, K_max_ratio_val = result.fom, result.K_max_ratio
|
|
319
|
+
var_res_val = result.var_resolved
|
|
320
|
+
return chi_val, eps_val, kB_val, K_max_val, fom_val, K_max_ratio_val, var_res_val
|
|
321
|
+
|
|
322
|
+
return _process_l4_chi(l3_chi, _chi_fit_func, "fit", f_AA)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# Chi spectral-QC thresholds shared by chi_final and the mixing products, so the
|
|
326
|
+
# reported chi and the chi that drives K_T/Gamma are filtered identically.
|
|
327
|
+
_CHI_FOM_LIMIT = 1.15 # two-sided obs/model variance-ratio band [1/x, x]
|
|
328
|
+
_CHI_K_MAX_RATIO_MIN = 0.5 # reject windows whose chi is mostly extrapolated
|
|
329
|
+
|
|
330
|
+
# Method-2 (spectral-fit) applicability limit. The Batchelor wavenumber kB is
|
|
331
|
+
# recovered from in-band spectrum only (K <= K_AA = f_AA_eff / speed); when the
|
|
332
|
+
# true rolloff lies beyond K_AA the fit is under-constrained and kB is pulled
|
|
333
|
+
# LOW, so chi and (∝kB⁴) epsilon_T are underestimated. A fitted kB that itself
|
|
334
|
+
# exceeds this multiple of K_AA proves the rolloff is unresolved -> drop the
|
|
335
|
+
# window. (issue #104 U3-C3; kB/K_AA ≈ 1.8 -> ~13% low eps, ≈ 2.1 -> ~59% low.)
|
|
336
|
+
_CHI_KB_KAA_MAX = 1.5
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _compute_chi_final(
|
|
340
|
+
chi: np.ndarray,
|
|
341
|
+
fom: np.ndarray | None = None,
|
|
342
|
+
k_max_ratio: np.ndarray | None = None,
|
|
343
|
+
fom_limit: float = _CHI_FOM_LIMIT,
|
|
344
|
+
k_max_ratio_min: float = _CHI_K_MAX_RATIO_MIN,
|
|
345
|
+
) -> np.ndarray:
|
|
346
|
+
"""Per-window geometric mean of chi over probes, with optional spectral QC.
|
|
347
|
+
|
|
348
|
+
Averages the probes with finite ``chi > 0``. When per-probe ``fom`` and
|
|
349
|
+
``k_max_ratio`` are supplied (both shape ``(n_probe, n_window)``), the
|
|
350
|
+
average is restricted to probes inside the two-sided fom band
|
|
351
|
+
``[1/fom_limit, fom_limit]`` with ``k_max_ratio >= k_max_ratio_min`` — the
|
|
352
|
+
same QC that gates the mixing products — falling back to all finite probes
|
|
353
|
+
for a window where none pass, so a window is never silently lost.
|
|
354
|
+
|
|
355
|
+
Without ``fom``/``k_max_ratio`` it is the plain finite-chi geometric mean
|
|
356
|
+
(backward compatible). ``L4ChiData.chi_final`` is what the **rsi** pipeline
|
|
357
|
+
reports and what gates its K_T / Gamma, so there the reported chi is filtered
|
|
358
|
+
consistently with the mixing products. The **perturb** pipeline does NOT
|
|
359
|
+
report ``chi_final``: it reports ``chiMean`` from
|
|
360
|
+
:func:`odas_tpw.processing.chi_combine.mk_chi_mean`, which applies the SAME
|
|
361
|
+
``_CHI_FOM_LIMIT`` / ``_CHI_K_MAX_RATIO_MIN`` soft QC (issue #104 U3-C2) so
|
|
362
|
+
its chiMean/K_T/Gamma are filtered identically — via a different code path,
|
|
363
|
+
not via ``chi_final``. Both read the shared ``_CHI_FOM_LIMIT`` /
|
|
364
|
+
``_CHI_K_MAX_RATIO_MIN`` constants below, so one definition governs every
|
|
365
|
+
path. CAUTION: those constants are NOT part of the perturb config signature
|
|
366
|
+
(only the ``chi.spectral_qc`` toggle is), so editing them changes chi numbers
|
|
367
|
+
WITHOUT re-versioning cached ``chi_NN`` directories — bump a config value or
|
|
368
|
+
clear the cache if you change them. The raw per-probe ``chi`` array is
|
|
369
|
+
retained separately for traceability. (2026-07-03 review; 2026-07 #104.)
|
|
370
|
+
"""
|
|
371
|
+
_n_gradt, n_spec = chi.shape
|
|
372
|
+
chi_final = np.full(n_spec, np.nan)
|
|
373
|
+
valid = np.isfinite(chi) & (chi > 0)
|
|
374
|
+
if fom is not None and k_max_ratio is not None:
|
|
375
|
+
passes = (
|
|
376
|
+
valid
|
|
377
|
+
& np.isfinite(fom)
|
|
378
|
+
& (fom <= fom_limit)
|
|
379
|
+
& (fom >= 1.0 / fom_limit)
|
|
380
|
+
& np.isfinite(k_max_ratio)
|
|
381
|
+
& (k_max_ratio >= k_max_ratio_min)
|
|
382
|
+
)
|
|
383
|
+
else:
|
|
384
|
+
passes = valid
|
|
385
|
+
for j in range(n_spec):
|
|
386
|
+
good = passes[:, j]
|
|
387
|
+
if not good.any():
|
|
388
|
+
good = valid[:, j] # fall back to all finite probes; never lose a window
|
|
389
|
+
if good.any():
|
|
390
|
+
chi_final[j] = np.exp(np.mean(np.log(chi[good, j])))
|
|
391
|
+
return chi_final
|