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/chi.py
ADDED
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
# Mar-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Chi (thermal variance dissipation rate) calculation.
|
|
3
|
+
|
|
4
|
+
Two methods:
|
|
5
|
+
1. Chi from known epsilon (shear probes) — Dillon & Caldwell 1980
|
|
6
|
+
2. Chi without epsilon (Batchelor spectrum fitting) — Ruddick et al. 2000,
|
|
7
|
+
Peterson & Fer 2014
|
|
8
|
+
|
|
9
|
+
References
|
|
10
|
+
----------
|
|
11
|
+
Dillon, T.M. and D.R. Caldwell, 1980: The Batchelor spectrum and dissipation
|
|
12
|
+
in the upper ocean. J. Geophys. Res., 85, 1910-1916.
|
|
13
|
+
Ruddick, B., A. Anis, and K. Thompson, 2000: Maximum likelihood spectral
|
|
14
|
+
fitting: The Batchelor spectrum. J. Atmos. Oceanic Technol., 17, 1541-1555.
|
|
15
|
+
Peterson, A.K. and I. Fer, 2014: Dissipation measurements using temperature
|
|
16
|
+
microstructure from an underwater glider. Methods in Oceanography, 10, 44-69.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import functools
|
|
20
|
+
import warnings
|
|
21
|
+
from typing import NamedTuple
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
from scipy.signal import butter, freqz
|
|
25
|
+
|
|
26
|
+
from odas_tpw.chi.batchelor import (
|
|
27
|
+
KAPPA_T,
|
|
28
|
+
Q_BATCHELOR,
|
|
29
|
+
Q_KRAICHNAN,
|
|
30
|
+
batchelor_grad,
|
|
31
|
+
batchelor_kB,
|
|
32
|
+
kraichnan_grad,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ChiEpsilonResult(NamedTuple):
|
|
37
|
+
"""Result from Method 1: chi from known epsilon."""
|
|
38
|
+
|
|
39
|
+
chi: float
|
|
40
|
+
kB: float
|
|
41
|
+
K_max: float
|
|
42
|
+
spec_batch: np.ndarray
|
|
43
|
+
fom: float
|
|
44
|
+
K_max_ratio: float
|
|
45
|
+
# Fraction of the model temperature-gradient variance resolved in
|
|
46
|
+
# [K_min, K_max] (Batchelor V_f; see _batchelor_resolved_fraction). Feeds
|
|
47
|
+
# the Lueck (2022) eq (18) L_hat*V_f^0.75 truncation in chiLnSigma. Trailing
|
|
48
|
+
# default so failure-path constructions need not supply it. (#104 U4-F1.)
|
|
49
|
+
var_resolved: float = np.nan
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ChiFitResult(NamedTuple):
|
|
53
|
+
"""Result from Methods 2 & 3: Batchelor spectrum fitting."""
|
|
54
|
+
|
|
55
|
+
kB: float
|
|
56
|
+
chi: float
|
|
57
|
+
epsilon: float
|
|
58
|
+
K_max: float
|
|
59
|
+
spec_batch: np.ndarray
|
|
60
|
+
fom: float
|
|
61
|
+
K_max_ratio: float
|
|
62
|
+
var_resolved: float = np.nan # Batchelor V_f; see ChiEpsilonResult.
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Spectrum model dispatcher
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
# The variance-correction grid resolves the model spectrum by its OWN scale kB,
|
|
70
|
+
# not by K_max: a fixed point count spread over ~5*K_max leaves the steep
|
|
71
|
+
# Batchelor/Kraichnan peak near kB unresolved when kB << K_max (low-epsilon
|
|
72
|
+
# windows where K_max is pushed out near K_AA), biasing the correction by up to
|
|
73
|
+
# ~15%. Span 40*kB (the spectrum is negligible beyond that, so V_total is fully
|
|
74
|
+
# captured and the V_resolved mask can be clamped there) at ~2000 points per kB.
|
|
75
|
+
_K_SPAN_KB = 40 # grid extends to _K_SPAN_KB * kB
|
|
76
|
+
_PTS_PER_KB = 2000 # points per kB of span -> dK = kB / _PTS_PER_KB
|
|
77
|
+
_KB_COARSE = np.logspace(0, 4.5, 100) # Coarse MLE grid: 1 to ~31623 cpm
|
|
78
|
+
_KB_COARSE_2D = _KB_COARSE[:, np.newaxis] # (100, 1) for broadcasting
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _spectrum_func(model: str) -> tuple:
|
|
82
|
+
"""Return (grad_func, q_constant) for the named spectrum model."""
|
|
83
|
+
if model == "batchelor":
|
|
84
|
+
return batchelor_grad, Q_BATCHELOR
|
|
85
|
+
elif model == "kraichnan":
|
|
86
|
+
return kraichnan_grad, Q_KRAICHNAN
|
|
87
|
+
else:
|
|
88
|
+
raise ValueError(f"Unknown spectrum model: {model!r}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _variance_correction(
|
|
92
|
+
kB: float,
|
|
93
|
+
K_max: float,
|
|
94
|
+
speed: float,
|
|
95
|
+
tau0: float,
|
|
96
|
+
_h2,
|
|
97
|
+
grad_func,
|
|
98
|
+
K_min: float = 0.0,
|
|
99
|
+
) -> float:
|
|
100
|
+
"""Compute V_total / V_resolved for the unresolved-variance correction.
|
|
101
|
+
|
|
102
|
+
V_total is the model variance over all wavenumbers; V_resolved is the
|
|
103
|
+
model variance within [K_min, K_max] *after* FP07 attenuation (|H|²),
|
|
104
|
+
so the ratio simultaneously corrects for the unresolved band edges and
|
|
105
|
+
for in-band sensor response. The correction factor is independent of
|
|
106
|
+
chi (linear scaling cancels in the ratio). The grid resolution is set by
|
|
107
|
+
kB (dK ~ kB/_PTS_PER_KB over [0, 40*kB]), giving <0.1% accuracy independent
|
|
108
|
+
of how far K_max is pushed out — verified against a high-n reference for kB
|
|
109
|
+
in [2, 400] including the K_min-masked iterative path.
|
|
110
|
+
|
|
111
|
+
Returns correction factor, or NaN if integration fails.
|
|
112
|
+
"""
|
|
113
|
+
# Tightened guard: kB=+inf passes ``kB > 0`` but makes K_fine all-inf, and
|
|
114
|
+
# the inf-inf subtract in np.trapezoid leaks a RuntimeWarning (audit nit).
|
|
115
|
+
if not (0 < kB < np.inf):
|
|
116
|
+
return np.nan
|
|
117
|
+
# Fixed dK ~ kB/_PTS_PER_KB so the peak near kB is always resolved; span
|
|
118
|
+
# 40*kB so the model variance is fully captured.
|
|
119
|
+
K_upper = _K_SPAN_KB * kB
|
|
120
|
+
n = _K_SPAN_KB * _PTS_PER_KB
|
|
121
|
+
K_fine = np.arange(1, n + 1, dtype=np.float64) * (K_upper / n)
|
|
122
|
+
spec_fine = grad_func(K_fine, kB, 1.0) # chi=1.0 (cancels in ratio)
|
|
123
|
+
|
|
124
|
+
V_total = np.trapezoid(spec_fine, K_fine)
|
|
125
|
+
if V_total <= 0:
|
|
126
|
+
return np.nan
|
|
127
|
+
|
|
128
|
+
F_fine = K_fine * speed
|
|
129
|
+
H2_fine = _h2(F_fine, tau0)
|
|
130
|
+
# Clamp the mask to the grid: past 40*kB the model spectrum is ~0, so a
|
|
131
|
+
# larger K_max contributes nothing to V_resolved.
|
|
132
|
+
mask = (K_fine >= K_min) & (K_fine <= min(K_max, K_upper))
|
|
133
|
+
if not np.any(mask):
|
|
134
|
+
return np.nan
|
|
135
|
+
V_resolved = np.trapezoid(spec_fine[mask] * H2_fine[mask], K_fine[mask])
|
|
136
|
+
|
|
137
|
+
if V_resolved <= 0:
|
|
138
|
+
return np.nan
|
|
139
|
+
return float(V_total / V_resolved)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _batchelor_resolved_fraction(
|
|
143
|
+
kB: float,
|
|
144
|
+
K_max: float,
|
|
145
|
+
grad_func,
|
|
146
|
+
K_min: float = 0.0,
|
|
147
|
+
) -> float:
|
|
148
|
+
"""Fraction of the model temperature-gradient variance resolved in [K_min, K_max].
|
|
149
|
+
|
|
150
|
+
The chi-side analog of the shear/Nasmyth ``_variance_resolved_fraction``
|
|
151
|
+
(``scor160.l4``, Lueck 2022 Part 1 eq (17)):
|
|
152
|
+
``V_f = integral_{K_min}^{K_max} S(k) dk / integral_0^inf S(k) dk`` for the
|
|
153
|
+
model (Batchelor/Kraichnan) temperature-gradient spectrum ``S``.
|
|
154
|
+
|
|
155
|
+
Unlike :func:`_variance_correction` this does **not** apply the FP07 ``|H|²``
|
|
156
|
+
attenuation: it is the pure spectral band-truncation fraction, analogous to
|
|
157
|
+
the shear V_f (which is likewise a band fraction of the universal Nasmyth
|
|
158
|
+
spectrum, with no sensor response). It feeds the Lueck 2022 Part 1 eq (18)
|
|
159
|
+
degrees-of-freedom reduction ``L_hat_f = L_hat * V_f**0.75`` used to widen
|
|
160
|
+
``chiLnSigma`` when the Batchelor spectrum is truncated at ``K_max``.
|
|
161
|
+
Including ``|H|²`` here would double-count the sensor response that the chi
|
|
162
|
+
amplitude correction (``_variance_correction``'s ``V_total/V_resolved``)
|
|
163
|
+
already undoes. (#104 U4-F1 chi-side follow-up.)
|
|
164
|
+
|
|
165
|
+
One difference from the shear V_f: this uses the *same* ``[K_min, K_max]``
|
|
166
|
+
band as the chi amplitude correction (band-consistent, so the two are not
|
|
167
|
+
double-counted), whereas the shear ``_variance_resolved_fraction`` integrates
|
|
168
|
+
``[0, K_upper]`` with no low-k bound. Because the chi fit band starts at
|
|
169
|
+
``K_min ~ 0.044*kB``, a few percent of the gradient variance sits below
|
|
170
|
+
``K_min``, so this V_f is ~0.97 (not 1.0) even for a window fully resolved at
|
|
171
|
+
the top — chiLnSigma therefore gets a small non-zero widening the epsilon
|
|
172
|
+
path never applies.
|
|
173
|
+
|
|
174
|
+
Shares the kB-scaled integration grid of :func:`_variance_correction`
|
|
175
|
+
(span ``_K_SPAN_KB*kB`` at ``_PTS_PER_KB`` points per kB) so ``V_total`` is
|
|
176
|
+
fully captured regardless of how far ``K_max`` is pushed out. Returns the
|
|
177
|
+
fraction clipped to ``[0, 1]``, or ``NaN`` if the model variance cannot be
|
|
178
|
+
integrated (non-finite ``kB`` etc.).
|
|
179
|
+
"""
|
|
180
|
+
if not (0 < kB < np.inf):
|
|
181
|
+
return np.nan
|
|
182
|
+
K_upper = _K_SPAN_KB * kB
|
|
183
|
+
n = _K_SPAN_KB * _PTS_PER_KB
|
|
184
|
+
K_fine = np.arange(1, n + 1, dtype=np.float64) * (K_upper / n)
|
|
185
|
+
spec_fine = grad_func(K_fine, kB, 1.0) # chi=1.0 cancels in the ratio
|
|
186
|
+
V_total = np.trapezoid(spec_fine, K_fine)
|
|
187
|
+
if not (V_total > 0):
|
|
188
|
+
return np.nan
|
|
189
|
+
# Same band the correction uses: [K_min, min(K_max, K_upper)], but without |H|².
|
|
190
|
+
mask = (K_fine >= K_min) & (K_fine <= min(K_max, K_upper))
|
|
191
|
+
if not np.any(mask):
|
|
192
|
+
return np.nan
|
|
193
|
+
V_resolved = np.trapezoid(spec_fine[mask], K_fine[mask])
|
|
194
|
+
return float(np.clip(V_resolved / V_total, 0.0, 1.0))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# Shared wavenumber masking helper
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _valid_wavenumber_mask(
|
|
203
|
+
spec_obs: np.ndarray,
|
|
204
|
+
noise: np.ndarray,
|
|
205
|
+
K: np.ndarray,
|
|
206
|
+
K_AA: float,
|
|
207
|
+
min_points: int = 3,
|
|
208
|
+
) -> np.ndarray:
|
|
209
|
+
"""Build a boolean mask for valid wavenumber points.
|
|
210
|
+
|
|
211
|
+
Selects points where the observed spectrum exceeds 2x noise, K > 0,
|
|
212
|
+
and K <= K_AA. Falls back to K > 0 & K <= K_AA if too few points
|
|
213
|
+
pass the noise criterion.
|
|
214
|
+
|
|
215
|
+
Parameters
|
|
216
|
+
----------
|
|
217
|
+
spec_obs : ndarray
|
|
218
|
+
Observed spectrum.
|
|
219
|
+
noise : ndarray
|
|
220
|
+
Noise floor spectrum (same units as spec_obs).
|
|
221
|
+
K : ndarray
|
|
222
|
+
Wavenumber vector [cpm].
|
|
223
|
+
K_AA : float
|
|
224
|
+
Anti-aliasing wavenumber limit [cpm].
|
|
225
|
+
min_points : int
|
|
226
|
+
Minimum valid points required.
|
|
227
|
+
|
|
228
|
+
Returns
|
|
229
|
+
-------
|
|
230
|
+
mask : ndarray of bool
|
|
231
|
+
"""
|
|
232
|
+
above_noise = spec_obs > 2 * noise
|
|
233
|
+
mask = above_noise & (K > 0) & (K <= K_AA)
|
|
234
|
+
if np.sum(mask) < min_points:
|
|
235
|
+
mask = (K > 0) & (K <= K_AA)
|
|
236
|
+
return mask
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _below_detection(
|
|
240
|
+
spec_obs: np.ndarray,
|
|
241
|
+
noise: np.ndarray,
|
|
242
|
+
K: np.ndarray,
|
|
243
|
+
K_AA: float,
|
|
244
|
+
min_points: int = 3,
|
|
245
|
+
) -> bool:
|
|
246
|
+
"""True when a window is a non-detection.
|
|
247
|
+
|
|
248
|
+
Fewer than ``min_points`` bins clear 2x the noise floor within the resolved
|
|
249
|
+
band ``(0, K_AA]`` — the strict ``above_noise`` criterion of
|
|
250
|
+
:func:`_valid_wavenumber_mask` *without* its whole-band fallback. Used to
|
|
251
|
+
reject noise-only windows before any chi path (the known-epsilon integral or
|
|
252
|
+
an MLE Batchelor fit) can shape a curve to the noise floor and emit a
|
|
253
|
+
spurious finite chi whose figure of merit (model fitted to obs) sits near 1.
|
|
254
|
+
Defined once so all three chi paths share the identical detection limit.
|
|
255
|
+
"""
|
|
256
|
+
above_noise = (spec_obs > 2.0 * noise) & (K > 0) & (K <= K_AA)
|
|
257
|
+
return int(np.sum(above_noise)) < min_points
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# Method 1: Chi from known epsilon
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _chi_from_epsilon(
|
|
266
|
+
spec_obs: np.ndarray,
|
|
267
|
+
K: np.ndarray,
|
|
268
|
+
epsilon: float,
|
|
269
|
+
nu: float,
|
|
270
|
+
noise_K: np.ndarray,
|
|
271
|
+
H2: np.ndarray,
|
|
272
|
+
tau0: float,
|
|
273
|
+
_h2,
|
|
274
|
+
f_AA: float,
|
|
275
|
+
speed: float,
|
|
276
|
+
spectrum_model: str,
|
|
277
|
+
kappa_T: float = KAPPA_T,
|
|
278
|
+
) -> ChiEpsilonResult:
|
|
279
|
+
"""Compute chi for one probe in one window, given epsilon (Method 1).
|
|
280
|
+
|
|
281
|
+
Parameters
|
|
282
|
+
----------
|
|
283
|
+
spec_obs : ndarray
|
|
284
|
+
Observed temperature gradient spectrum [(K/m)²/cpm].
|
|
285
|
+
K : ndarray
|
|
286
|
+
Wavenumber vector [cpm].
|
|
287
|
+
epsilon : float
|
|
288
|
+
TKE dissipation rate [W/kg] from shear probes.
|
|
289
|
+
nu : float
|
|
290
|
+
Kinematic viscosity [m²/s].
|
|
291
|
+
noise_K : ndarray
|
|
292
|
+
Pre-computed noise spectrum [(K/m)²/cpm].
|
|
293
|
+
H2 : ndarray
|
|
294
|
+
Pre-computed FP07 transfer function |H(f)|².
|
|
295
|
+
tau0 : float
|
|
296
|
+
Pre-computed FP07 time constant [s].
|
|
297
|
+
_h2 : callable
|
|
298
|
+
FP07 transfer function (fp07_transfer or fp07_double_pole).
|
|
299
|
+
f_AA : float
|
|
300
|
+
Anti-aliasing cutoff [Hz].
|
|
301
|
+
speed : float
|
|
302
|
+
Profiling speed [m/s].
|
|
303
|
+
spectrum_model : str
|
|
304
|
+
Theoretical spectrum model ('batchelor' or 'kraichnan').
|
|
305
|
+
kappa_T : float
|
|
306
|
+
Molecular thermal diffusivity [m²/s]. Per-window T/S/P value from
|
|
307
|
+
:func:`odas_tpw.scor160.ocean.kappa_T`; defaults to the fixed
|
|
308
|
+
``batchelor.KAPPA_T`` for backward compatibility.
|
|
309
|
+
|
|
310
|
+
Returns
|
|
311
|
+
-------
|
|
312
|
+
ChiEpsilonResult
|
|
313
|
+
Named tuple: (chi, kB, K_max, spec_batch, fom, K_max_ratio, var_resolved).
|
|
314
|
+
"""
|
|
315
|
+
grad_func, _q = _spectrum_func(spectrum_model)
|
|
316
|
+
# Bind kappa_T into the spectrum shape so every grad_func(...) call here and
|
|
317
|
+
# in _variance_correction uses the per-window value (its amplitude carries
|
|
318
|
+
# chi/(kB*kappa_T)).
|
|
319
|
+
grad_func = functools.partial(grad_func, kappa_T=kappa_T)
|
|
320
|
+
|
|
321
|
+
kB = float(batchelor_kB(epsilon, nu, kappa_T))
|
|
322
|
+
if kB < 1:
|
|
323
|
+
warnings.warn(f"kB={kB:.1f} < 1 cpm; epsilon too low for chi estimation", stacklevel=2)
|
|
324
|
+
return ChiEpsilonResult(np.nan, kB, np.nan, np.zeros_like(K), np.nan, np.nan)
|
|
325
|
+
|
|
326
|
+
# Find integration limit: where observed meets noise
|
|
327
|
+
K_AA = f_AA / speed
|
|
328
|
+
|
|
329
|
+
# Signal-presence gate. A window whose gradient spectrum never rises above
|
|
330
|
+
# the FP07 electronics noise floor carries no resolvable thermal signal.
|
|
331
|
+
# Without this gate the variance integral below still returns a small positive
|
|
332
|
+
# chi from residual above-noise scatter, and the figure of merit cannot catch
|
|
333
|
+
# it (fom = obs / model with the model amplitude derived from obs, so
|
|
334
|
+
# fom ~= 1 for the noise floor itself). Reject such windows as non-detections
|
|
335
|
+
# here, at the source, so they become NaN and drop out of chiMean rather than
|
|
336
|
+
# biasing its low tail and the derived mixing products. (2026-07-03 review.)
|
|
337
|
+
if _below_detection(spec_obs, noise_K, K, K_AA):
|
|
338
|
+
warnings.warn(
|
|
339
|
+
"No thermal signal above the FP07 noise floor; chi is a non-detection",
|
|
340
|
+
stacklevel=2,
|
|
341
|
+
)
|
|
342
|
+
return ChiEpsilonResult(np.nan, kB, np.nan, np.zeros_like(K), np.nan, np.nan)
|
|
343
|
+
|
|
344
|
+
valid = _valid_wavenumber_mask(spec_obs, noise_K, K, K_AA, min_points=3)
|
|
345
|
+
if np.sum(valid) < 3:
|
|
346
|
+
warnings.warn("Too few valid wavenumber points for chi integration", stacklevel=2)
|
|
347
|
+
return ChiEpsilonResult(np.nan, kB, np.nan, np.zeros_like(K), np.nan, np.nan)
|
|
348
|
+
|
|
349
|
+
valid_idx = np.where(valid)[0]
|
|
350
|
+
K_max = K[valid_idx[-1]]
|
|
351
|
+
|
|
352
|
+
# Chi amplitude from the noise-subtracted resolved temperature-gradient
|
|
353
|
+
# variance, with kB fixed from epsilon. This is the linear-space
|
|
354
|
+
# (method-of-moments) estimator: E[obs] = signal + noise, so subtracting the
|
|
355
|
+
# noise floor and integrating recovers the in-band gradient variance, and the
|
|
356
|
+
# analytic V_total / V_resolved correction (which also undoes the in-band FP07
|
|
357
|
+
# |H|^2 attenuation) scales it to chi = 6 * kappa_T * <(dT/dz)^2>.
|
|
358
|
+
#
|
|
359
|
+
# It replaces the former log-space least-squares grid fit, which is biased low
|
|
360
|
+
# by exactly exp(psi(d/2) - ln(d/2)) (~ -6% at the production dof ~13) because
|
|
361
|
+
# for chi^2-distributed Welch spectra E[ln(obs)] = ln(signal) + (psi(d/2) -
|
|
362
|
+
# ln(d/2)) < ln E[obs] (Jensen). The variance integral is unbiased in linear
|
|
363
|
+
# space, and it is the SAME estimator Method 2 already uses (_mle_fit_kB /
|
|
364
|
+
# _iterative_fit), so the two methods now share one amplitude form. A
|
|
365
|
+
# Monte-Carlo bias test (test_chi) pins the ~1% high/moderate-SNR bias; the
|
|
366
|
+
# residual near-detection-floor overshoot is a pre-existing property of the
|
|
367
|
+
# above-2x-noise band selection, not of this estimator. (issue #104 U3-C1.)
|
|
368
|
+
s = spec_obs[valid]
|
|
369
|
+
nv = noise_K[valid]
|
|
370
|
+
|
|
371
|
+
# Raw in-band variance (NO noise subtraction) — retained only for the figure
|
|
372
|
+
# of merit below (matching the historical fom numerator). The chi estimate
|
|
373
|
+
# uses the noise-subtracted variance instead; do not conflate the two.
|
|
374
|
+
obs_var = np.trapezoid(s, K[valid])
|
|
375
|
+
if obs_var <= 0:
|
|
376
|
+
warnings.warn("Observed variance <= 0; chi is a non-detection", stacklevel=2)
|
|
377
|
+
return ChiEpsilonResult(np.nan, kB, K_max, np.zeros_like(K), np.nan, np.nan)
|
|
378
|
+
|
|
379
|
+
# V_resolved spans the SAME measured band [K[valid][0], K_max] that the
|
|
380
|
+
# observed integral covers (K_min set accordingly), mirroring the Method-2
|
|
381
|
+
# path so the unresolved-variance correction is not double-counted.
|
|
382
|
+
K_min = float(K[valid][0])
|
|
383
|
+
correction = _variance_correction(kB, K_max, speed, tau0, _h2, grad_func, K_min=K_min)
|
|
384
|
+
chi_var = np.trapezoid(np.maximum(s - nv, 0.0), K[valid])
|
|
385
|
+
chi = 6.0 * kappa_T * chi_var * (correction if np.isfinite(correction) else 1.0)
|
|
386
|
+
|
|
387
|
+
# Compute fitted Batchelor spectrum for output
|
|
388
|
+
spec_batch = grad_func(K, kB, chi)
|
|
389
|
+
|
|
390
|
+
# Figure of merit: observed vs attenuated model (Batchelor * H2 + noise)
|
|
391
|
+
if np.sum(valid) >= 3 and np.isfinite(chi):
|
|
392
|
+
mod_v = np.trapezoid(spec_batch[valid] * H2[valid] + noise_K[valid], K[valid])
|
|
393
|
+
fom = obs_var / mod_v if mod_v > 0 else np.nan
|
|
394
|
+
else:
|
|
395
|
+
fom = np.nan
|
|
396
|
+
|
|
397
|
+
K_max_ratio_val = K_max / kB if kB > 0 else np.nan
|
|
398
|
+
|
|
399
|
+
# Batchelor variance-resolved fraction over the SAME [K_min, K_max] band the
|
|
400
|
+
# correction/integral use — feeds the eq (18) chiLnSigma truncation (U4-F1).
|
|
401
|
+
var_resolved_val = _batchelor_resolved_fraction(kB, K_max, grad_func, K_min=K_min)
|
|
402
|
+
|
|
403
|
+
return ChiEpsilonResult(chi, kB, K_max, spec_batch, fom, K_max_ratio_val, var_resolved_val)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
# Method 2: MLE spectral fitting (Ruddick et al. 2000)
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _mle_find_kB(
|
|
412
|
+
spec_obs: np.ndarray,
|
|
413
|
+
K: np.ndarray,
|
|
414
|
+
chi_obs: float,
|
|
415
|
+
noise_K: np.ndarray,
|
|
416
|
+
H2: np.ndarray,
|
|
417
|
+
f_AA: float,
|
|
418
|
+
speed: float,
|
|
419
|
+
grad_func,
|
|
420
|
+
) -> tuple[float, np.ndarray, np.ndarray]:
|
|
421
|
+
"""Core MLE grid search for kB (no chi correction or output spectra).
|
|
422
|
+
|
|
423
|
+
Returns
|
|
424
|
+
-------
|
|
425
|
+
kB_best : float
|
|
426
|
+
Best-fit Batchelor wavenumber [cpm], or NaN if fit fails.
|
|
427
|
+
fit_mask : ndarray of bool
|
|
428
|
+
Valid wavenumber mask used for fitting.
|
|
429
|
+
K_fit : ndarray
|
|
430
|
+
Wavenumber vector at fit points.
|
|
431
|
+
"""
|
|
432
|
+
K_AA = f_AA / speed
|
|
433
|
+
fit_mask = _valid_wavenumber_mask(spec_obs, noise_K, K, K_AA, min_points=6)
|
|
434
|
+
if np.sum(fit_mask) < 6:
|
|
435
|
+
return np.nan, fit_mask, np.empty(0)
|
|
436
|
+
|
|
437
|
+
fit_idx = np.where(fit_mask)[0]
|
|
438
|
+
K_fit = K[fit_idx]
|
|
439
|
+
spec_fit = spec_obs[fit_idx]
|
|
440
|
+
H2_fit = H2[fit_idx]
|
|
441
|
+
noise_fit = noise_K[fit_idx]
|
|
442
|
+
|
|
443
|
+
# --- Coarse grid search (vectorized) ---
|
|
444
|
+
spec_models = grad_func(K_fit, _KB_COARSE_2D, chi_obs) * H2_fit + noise_fit
|
|
445
|
+
spec_models = np.maximum(spec_models, 1e-30)
|
|
446
|
+
nll_coarse = np.sum(np.log(spec_models) + spec_fit / spec_models, axis=1)
|
|
447
|
+
nll_coarse = np.where(np.isfinite(nll_coarse), nll_coarse, np.inf)
|
|
448
|
+
|
|
449
|
+
if np.all(np.isinf(nll_coarse)):
|
|
450
|
+
return np.nan, fit_mask, K_fit
|
|
451
|
+
|
|
452
|
+
best_coarse = _KB_COARSE[np.argmin(nll_coarse)]
|
|
453
|
+
|
|
454
|
+
# --- Fine grid search (vectorized) ---
|
|
455
|
+
kB_lo = max(best_coarse * 0.5, 1.0)
|
|
456
|
+
kB_hi = best_coarse * 2.0
|
|
457
|
+
kB_fine = np.linspace(kB_lo, kB_hi, 100)
|
|
458
|
+
kB_fine_2d = kB_fine[:, np.newaxis]
|
|
459
|
+
spec_models = grad_func(K_fit, kB_fine_2d, chi_obs) * H2_fit + noise_fit
|
|
460
|
+
spec_models = np.maximum(spec_models, 1e-30)
|
|
461
|
+
nll_fine = np.sum(np.log(spec_models) + spec_fit / spec_models, axis=1)
|
|
462
|
+
nll_fine = np.where(np.isfinite(nll_fine), nll_fine, np.inf)
|
|
463
|
+
|
|
464
|
+
kB_best = best_coarse if np.all(np.isinf(nll_fine)) else kB_fine[np.argmin(nll_fine)]
|
|
465
|
+
return kB_best, fit_mask, K_fit
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _mle_fit_kB(
|
|
469
|
+
spec_obs: np.ndarray,
|
|
470
|
+
K: np.ndarray,
|
|
471
|
+
chi_obs: float,
|
|
472
|
+
nu: float,
|
|
473
|
+
noise_K: np.ndarray,
|
|
474
|
+
H2: np.ndarray,
|
|
475
|
+
tau0: float,
|
|
476
|
+
_h2,
|
|
477
|
+
f_AA: float,
|
|
478
|
+
speed: float,
|
|
479
|
+
spectrum_model: str,
|
|
480
|
+
kappa_T: float = KAPPA_T,
|
|
481
|
+
) -> ChiFitResult:
|
|
482
|
+
"""Maximum-likelihood fit for Batchelor wavenumber kB (Ruddick et al. 2000).
|
|
483
|
+
|
|
484
|
+
Performs a coarse+fine grid search over kB to minimize the MLE
|
|
485
|
+
negative log-likelihood for chi-squared spectral estimates.
|
|
486
|
+
|
|
487
|
+
Parameters
|
|
488
|
+
----------
|
|
489
|
+
spec_obs : ndarray
|
|
490
|
+
Observed temperature gradient spectrum [(K/m)²/cpm].
|
|
491
|
+
K : ndarray
|
|
492
|
+
Wavenumber vector [cpm].
|
|
493
|
+
chi_obs : float
|
|
494
|
+
Initial chi estimate [K²/s] from integration.
|
|
495
|
+
nu : float
|
|
496
|
+
Kinematic viscosity [m²/s].
|
|
497
|
+
noise_K, H2, tau0, _h2, f_AA, speed, spectrum_model
|
|
498
|
+
Same as :func:`_chi_from_epsilon`.
|
|
499
|
+
|
|
500
|
+
Returns
|
|
501
|
+
-------
|
|
502
|
+
ChiFitResult
|
|
503
|
+
Named tuple: (kB, chi, epsilon, K_max, spec_batch, fom, K_max_ratio, var_resolved).
|
|
504
|
+
"""
|
|
505
|
+
grad_func, _q = _spectrum_func(spectrum_model)
|
|
506
|
+
grad_func = functools.partial(grad_func, kappa_T=kappa_T)
|
|
507
|
+
|
|
508
|
+
# Signal-presence gate (mirrors Method 1): reject a noise-only window before
|
|
509
|
+
# the MLE fits a Batchelor curve to noise and returns a spurious finite
|
|
510
|
+
# chi/kB. (2026-07-03 adversarial review of the Method-1 gate.)
|
|
511
|
+
if _below_detection(spec_obs, noise_K, K, f_AA / speed):
|
|
512
|
+
warnings.warn(
|
|
513
|
+
"No thermal signal above the FP07 noise floor; chi is a non-detection",
|
|
514
|
+
stacklevel=2,
|
|
515
|
+
)
|
|
516
|
+
return ChiFitResult(np.nan, np.nan, np.nan, np.nan, np.zeros_like(K), np.nan, np.nan)
|
|
517
|
+
|
|
518
|
+
kB_best, fit_mask, K_fit = _mle_find_kB(
|
|
519
|
+
spec_obs,
|
|
520
|
+
K,
|
|
521
|
+
chi_obs,
|
|
522
|
+
noise_K,
|
|
523
|
+
H2,
|
|
524
|
+
f_AA,
|
|
525
|
+
speed,
|
|
526
|
+
grad_func,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
if not np.isfinite(kB_best):
|
|
530
|
+
if len(K_fit) == 0:
|
|
531
|
+
warnings.warn("Too few valid points for MLE fit", stacklevel=2)
|
|
532
|
+
else:
|
|
533
|
+
warnings.warn("All NLL values infinite; MLE fit failed", stacklevel=2)
|
|
534
|
+
return ChiFitResult(np.nan, np.nan, np.nan, np.nan, np.zeros_like(K), np.nan, np.nan)
|
|
535
|
+
|
|
536
|
+
K_max_fit = K_fit[-1]
|
|
537
|
+
|
|
538
|
+
# Recover epsilon from kB
|
|
539
|
+
epsilon = (2 * np.pi * kB_best) ** 4 * nu * kappa_T**2
|
|
540
|
+
|
|
541
|
+
# Re-estimate chi with unresolved-variance correction (like Method 1).
|
|
542
|
+
# Pass the same lower band edge that obs_var uses (K[fit_mask][0]) so
|
|
543
|
+
# V_resolved spans the identical [K_fit_low, K_max] band: with the prior
|
|
544
|
+
# default K_min=0 the correction included model variance in [0, K_fit_low]
|
|
545
|
+
# that obs_var excludes, inflating V_resolved and biasing chi low for
|
|
546
|
+
# low-epsilon windows (audit finding) — matching the _iterative_fit path.
|
|
547
|
+
K_fit_low = float(K[fit_mask][0])
|
|
548
|
+
correction = _variance_correction(
|
|
549
|
+
kB_best,
|
|
550
|
+
K_max_fit,
|
|
551
|
+
speed,
|
|
552
|
+
tau0,
|
|
553
|
+
_h2,
|
|
554
|
+
grad_func,
|
|
555
|
+
K_min=K_fit_low,
|
|
556
|
+
)
|
|
557
|
+
if np.isfinite(correction):
|
|
558
|
+
obs_var = np.trapezoid(np.maximum(spec_obs[fit_mask] - noise_K[fit_mask], 0), K[fit_mask])
|
|
559
|
+
chi = 6 * kappa_T * obs_var * correction
|
|
560
|
+
else:
|
|
561
|
+
chi = chi_obs
|
|
562
|
+
|
|
563
|
+
# Fitted spectrum for output
|
|
564
|
+
spec_batch = grad_func(K, kB_best, chi)
|
|
565
|
+
|
|
566
|
+
# Figure of merit: observed vs attenuated model (Batchelor * H2 + noise)
|
|
567
|
+
fit_idx = np.where(fit_mask)[0]
|
|
568
|
+
if np.isfinite(chi) and len(fit_idx) >= 3:
|
|
569
|
+
mod_v = np.trapezoid(spec_batch[fit_idx] * H2[fit_idx] + noise_K[fit_idx], K_fit)
|
|
570
|
+
obs_v = np.trapezoid(spec_obs[fit_idx], K_fit)
|
|
571
|
+
fom = obs_v / mod_v if mod_v > 0 else np.nan
|
|
572
|
+
else:
|
|
573
|
+
fom = np.nan
|
|
574
|
+
|
|
575
|
+
K_max_ratio_val = K_max_fit / kB_best if kB_best > 0 else np.nan
|
|
576
|
+
var_resolved_val = _batchelor_resolved_fraction(
|
|
577
|
+
kB_best, K_max_fit, grad_func, K_min=K_fit_low
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
return ChiFitResult(
|
|
581
|
+
kB_best, chi, epsilon, K_max_fit, spec_batch, fom, K_max_ratio_val, var_resolved_val
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _iterative_fit(
|
|
586
|
+
spec_obs: np.ndarray,
|
|
587
|
+
K: np.ndarray,
|
|
588
|
+
nu: float,
|
|
589
|
+
noise_K: np.ndarray,
|
|
590
|
+
H2: np.ndarray,
|
|
591
|
+
tau0: float,
|
|
592
|
+
_h2,
|
|
593
|
+
f_AA: float,
|
|
594
|
+
speed: float,
|
|
595
|
+
spectrum_model: str,
|
|
596
|
+
kappa_T: float = KAPPA_T,
|
|
597
|
+
) -> ChiFitResult:
|
|
598
|
+
"""Iterative MLE fitting (Peterson & Fer 2014, Method 2).
|
|
599
|
+
|
|
600
|
+
Three iterations refining the LOWER integration limit
|
|
601
|
+
``k_l = max(K[1], 3*k_star)`` (with ``k_star = 0.04*kB*sqrt(kappa/nu)``,
|
|
602
|
+
a viscous-convective onset scale; the 0.04 coefficient follows the
|
|
603
|
+
original implementation and has not been verified against Peterson &
|
|
604
|
+
Fer's text) and applying a model-based correction for the variance
|
|
605
|
+
outside [``k_l``, ``k_u``] and for in-band FP07 attenuation (see
|
|
606
|
+
:func:`_variance_correction`). The UPPER limit ``k_u`` is fixed at
|
|
607
|
+
the last valid wavenumber (noise/anti-aliasing criterion) and is not
|
|
608
|
+
refined between iterations.
|
|
609
|
+
|
|
610
|
+
Note: ``k_l`` is only the lower edge of the *integration* band, not a hard
|
|
611
|
+
clip — :func:`_variance_correction` adds back the modeled variance below
|
|
612
|
+
``k_l``, so the recovered chi is insensitive to the exact 0.04 value when
|
|
613
|
+
the model matches the observed spectrum. A 2026-07-03 review confirmed
|
|
614
|
+
numerically that ``chi`` is invariant (to <0.001%) across coefficients
|
|
615
|
+
0.02-0.16 for a model-consistent spectrum; only a model-vs-observed shape
|
|
616
|
+
mismatch in the low-k sliver below ``k_l`` makes it matter, a second-order
|
|
617
|
+
effect. So this is a documented-provenance note, not a correctness risk.
|
|
618
|
+
|
|
619
|
+
Parameters
|
|
620
|
+
----------
|
|
621
|
+
spec_obs : ndarray
|
|
622
|
+
Observed temperature gradient spectrum [(K/m)²/cpm].
|
|
623
|
+
K : ndarray
|
|
624
|
+
Wavenumber vector [cpm].
|
|
625
|
+
nu : float
|
|
626
|
+
Kinematic viscosity [m²/s].
|
|
627
|
+
noise_K, H2, tau0, _h2, f_AA, speed, spectrum_model
|
|
628
|
+
Same as :func:`_chi_from_epsilon`.
|
|
629
|
+
|
|
630
|
+
Returns
|
|
631
|
+
-------
|
|
632
|
+
ChiFitResult
|
|
633
|
+
Named tuple: (kB, chi, epsilon, K_max, spec_batch, fom, K_max_ratio, var_resolved).
|
|
634
|
+
"""
|
|
635
|
+
grad_func, _q = _spectrum_func(spectrum_model)
|
|
636
|
+
grad_func = functools.partial(grad_func, kappa_T=kappa_T)
|
|
637
|
+
|
|
638
|
+
# Initial integration limits
|
|
639
|
+
K_AA = f_AA / speed
|
|
640
|
+
|
|
641
|
+
# Signal-presence gate (mirrors Method 1's _chi_from_epsilon): a window with
|
|
642
|
+
# fewer than 3 bins above 2x the noise floor is a non-detection. Without it
|
|
643
|
+
# the MLE below fits a Batchelor curve to noise and, together with the weak
|
|
644
|
+
# band_has_signal check (spec_obs > 1x noise), emits a spurious finite
|
|
645
|
+
# chi/kB. Reject at the source -> NaN, dropping it from chiMean rather than
|
|
646
|
+
# biasing the low tail. (2026-07-03 adversarial review of the Method-1 gate.)
|
|
647
|
+
if _below_detection(spec_obs, noise_K, K, K_AA):
|
|
648
|
+
warnings.warn(
|
|
649
|
+
"No thermal signal above the FP07 noise floor; chi is a non-detection",
|
|
650
|
+
stacklevel=2,
|
|
651
|
+
)
|
|
652
|
+
return ChiFitResult(np.nan, np.nan, np.nan, np.nan, np.zeros_like(K), np.nan, np.nan)
|
|
653
|
+
|
|
654
|
+
valid = _valid_wavenumber_mask(spec_obs, noise_K, K, K_AA, min_points=6)
|
|
655
|
+
|
|
656
|
+
valid_idx = np.where(valid)[0]
|
|
657
|
+
if len(valid_idx) < 6:
|
|
658
|
+
warnings.warn("Too few valid points for iterative fit", stacklevel=2)
|
|
659
|
+
return ChiFitResult(np.nan, np.nan, np.nan, np.nan, np.zeros_like(K), np.nan, np.nan)
|
|
660
|
+
|
|
661
|
+
k_u = K[valid_idx[-1]]
|
|
662
|
+
|
|
663
|
+
# Initial chi estimate
|
|
664
|
+
mask_init = valid & (spec_obs > noise_K)
|
|
665
|
+
if np.sum(mask_init) < 3:
|
|
666
|
+
mask_init = valid
|
|
667
|
+
chi_obs = (
|
|
668
|
+
6
|
|
669
|
+
* kappa_T
|
|
670
|
+
* np.trapezoid(np.maximum(spec_obs[mask_init] - noise_K[mask_init], 0), K[mask_init])
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
if chi_obs <= 0:
|
|
674
|
+
chi_obs = 1e-14
|
|
675
|
+
|
|
676
|
+
# Iterative refinement (up to 3 iterations, with convergence check)
|
|
677
|
+
kB_best = np.nan
|
|
678
|
+
kB_prev = np.nan
|
|
679
|
+
epsilon = np.nan
|
|
680
|
+
# True once the refined integration band carries above-noise variance.
|
|
681
|
+
# If it never does (every in-band bin has Phi_obs <= Phi_noise), the
|
|
682
|
+
# window is below the detection limit and chi must be NaN, NOT the
|
|
683
|
+
# 1e-14 sentinel that chi_obs falls back to for model shaping.
|
|
684
|
+
band_has_signal = False
|
|
685
|
+
|
|
686
|
+
for iteration in range(3):
|
|
687
|
+
# MLE fit for kB (core search only — no variance correction)
|
|
688
|
+
kB_best, _, _ = _mle_find_kB(
|
|
689
|
+
spec_obs,
|
|
690
|
+
K,
|
|
691
|
+
chi_obs,
|
|
692
|
+
noise_K,
|
|
693
|
+
H2,
|
|
694
|
+
f_AA,
|
|
695
|
+
speed,
|
|
696
|
+
grad_func,
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
if not np.isfinite(kB_best) or kB_best < 1:
|
|
700
|
+
break
|
|
701
|
+
|
|
702
|
+
# Check convergence
|
|
703
|
+
if iteration > 0 and abs(kB_best - kB_prev) / kB_prev < 0.01:
|
|
704
|
+
break
|
|
705
|
+
kB_prev = kB_best
|
|
706
|
+
|
|
707
|
+
# Refine integration limits
|
|
708
|
+
k_star = 0.04 * kB_best * np.sqrt(kappa_T / nu)
|
|
709
|
+
k_l_new = max(K[1], 3 * k_star)
|
|
710
|
+
k_u_new = k_u
|
|
711
|
+
|
|
712
|
+
# Recompute chi_obs with refined limits
|
|
713
|
+
mask_refined = (k_l_new <= K) & (k_u_new >= K)
|
|
714
|
+
if np.sum(mask_refined) < 3:
|
|
715
|
+
break
|
|
716
|
+
|
|
717
|
+
chi_band = (
|
|
718
|
+
6
|
|
719
|
+
* kappa_T
|
|
720
|
+
* np.trapezoid(
|
|
721
|
+
np.maximum(spec_obs[mask_refined] - noise_K[mask_refined], 0), K[mask_refined]
|
|
722
|
+
)
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
# Model-based correction for variance outside [k_l, k_u] AND for
|
|
726
|
+
# in-band FP07 attenuation (the observed spectrum is never divided
|
|
727
|
+
# by |H|², so V_resolved in the ratio includes |H|² instead).
|
|
728
|
+
# Peterson & Fer (2014) equivalently boost the measured spectrum
|
|
729
|
+
# by the response function before integrating.
|
|
730
|
+
correction = _variance_correction(
|
|
731
|
+
kB_best, k_u_new, speed, tau0, _h2, grad_func, K_min=k_l_new
|
|
732
|
+
)
|
|
733
|
+
if chi_band > 0 and np.isfinite(correction):
|
|
734
|
+
chi_obs = chi_band * correction
|
|
735
|
+
band_has_signal = True
|
|
736
|
+
elif chi_band > 0:
|
|
737
|
+
chi_obs = chi_band
|
|
738
|
+
band_has_signal = True
|
|
739
|
+
else:
|
|
740
|
+
chi_obs = 1e-14
|
|
741
|
+
|
|
742
|
+
# Recompute chi_obs once from the *final* kB_best so the returned kB,
|
|
743
|
+
# epsilon, chi and spec_batch are mutually consistent. On the convergence
|
|
744
|
+
# break the loop exits before re-deriving chi_obs for the newly-converged
|
|
745
|
+
# kB_best, leaving chi tied to the prior iteration's kB (audit finding:
|
|
746
|
+
# k_l_new and the variance correction both depend on kB).
|
|
747
|
+
var_resolved_val = np.nan
|
|
748
|
+
if np.isfinite(kB_best) and kB_best >= 1:
|
|
749
|
+
k_star = 0.04 * kB_best * np.sqrt(kappa_T / nu)
|
|
750
|
+
k_l_final = max(K[1], 3 * k_star)
|
|
751
|
+
# Batchelor V_f over the final [k_l_final, k_u] band (eq (18); U4-F1).
|
|
752
|
+
var_resolved_val = _batchelor_resolved_fraction(
|
|
753
|
+
kB_best, k_u, grad_func, K_min=k_l_final
|
|
754
|
+
)
|
|
755
|
+
mask_final = (k_l_final <= K) & (k_u >= K)
|
|
756
|
+
if np.sum(mask_final) >= 3:
|
|
757
|
+
chi_band = (
|
|
758
|
+
6
|
|
759
|
+
* kappa_T
|
|
760
|
+
* np.trapezoid(
|
|
761
|
+
np.maximum(spec_obs[mask_final] - noise_K[mask_final], 0),
|
|
762
|
+
K[mask_final],
|
|
763
|
+
)
|
|
764
|
+
)
|
|
765
|
+
correction = _variance_correction(
|
|
766
|
+
kB_best, k_u, speed, tau0, _h2, grad_func, K_min=k_l_final
|
|
767
|
+
)
|
|
768
|
+
if chi_band > 0 and np.isfinite(correction):
|
|
769
|
+
chi_obs = chi_band * correction
|
|
770
|
+
band_has_signal = True
|
|
771
|
+
elif chi_band > 0:
|
|
772
|
+
chi_obs = chi_band
|
|
773
|
+
band_has_signal = True
|
|
774
|
+
|
|
775
|
+
# Final values
|
|
776
|
+
if np.isfinite(kB_best):
|
|
777
|
+
epsilon = (2 * np.pi * kB_best) ** 4 * nu * kappa_T**2
|
|
778
|
+
# A finite chi requires BOTH a converged Batchelor/kB fit AND above-noise
|
|
779
|
+
# variance in the integration band. A failed fit (kB NaN) leaks the initial
|
|
780
|
+
# noise-dominated band integral; a below-detection window (band_has_signal
|
|
781
|
+
# False) leaks the 1e-14 model-shaping sentinel. Either way fom=NaN and no
|
|
782
|
+
# downstream QC cut can reach it, so return NaN — excluding it from
|
|
783
|
+
# chi_final/chiMean (and thus K_T/Gamma), mirroring the epsilon side where a
|
|
784
|
+
# failed shear fit yields NaN epsilon.
|
|
785
|
+
chi = chi_obs if (np.isfinite(kB_best) and band_has_signal) else np.nan
|
|
786
|
+
spec_batch = grad_func(K, kB_best, chi) if np.isfinite(kB_best) else np.zeros_like(K)
|
|
787
|
+
|
|
788
|
+
# Figure of merit: observed vs attenuated model (Batchelor * H2 + noise).
|
|
789
|
+
# Restrict to above-noise bins (spec_obs > 2*noise) bounded by the
|
|
790
|
+
# integration limit k_u, so the FOM is computed over the same band the chi
|
|
791
|
+
# was integrated over; otherwise noise-dominated bins dilute the ratio
|
|
792
|
+
# toward 1.0. This band is intentionally NOT identical to Method 1 / the MLE
|
|
793
|
+
# path (which use _valid_wavenumber_mask with a K_AA bound and a min-points
|
|
794
|
+
# fallback): those affect only this diagnostic FOM, never chi/kB (#48).
|
|
795
|
+
if np.isfinite(kB_best) and np.isfinite(chi):
|
|
796
|
+
valid_fom = (spec_obs > 2 * noise_K) & (K > 0) & (k_u >= K)
|
|
797
|
+
if np.sum(valid_fom) >= 3:
|
|
798
|
+
obs_v = np.trapezoid(spec_obs[valid_fom], K[valid_fom])
|
|
799
|
+
mod_v = np.trapezoid(
|
|
800
|
+
spec_batch[valid_fom] * H2[valid_fom] + noise_K[valid_fom],
|
|
801
|
+
K[valid_fom],
|
|
802
|
+
)
|
|
803
|
+
fom = obs_v / mod_v if mod_v > 0 else np.nan
|
|
804
|
+
else:
|
|
805
|
+
fom = np.nan
|
|
806
|
+
else:
|
|
807
|
+
fom = np.nan
|
|
808
|
+
|
|
809
|
+
K_max_ratio_val = k_u / kB_best if np.isfinite(kB_best) and kB_best > 0 else np.nan
|
|
810
|
+
|
|
811
|
+
return ChiFitResult(
|
|
812
|
+
kB_best, chi, epsilon, k_u, spec_batch, fom, K_max_ratio_val, var_resolved_val
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _bilinear_correction(F: np.ndarray, diff_gain: float, fs: float) -> np.ndarray:
|
|
817
|
+
"""Bilinear transform correction for deconvolution filter.
|
|
818
|
+
|
|
819
|
+
Matches ODAS get_scalar_spectra_odas.m lines 264-270:
|
|
820
|
+
corrects for the difference between the ideal analog LP filter
|
|
821
|
+
H = 1/(1+(2*pi*f*diff_gain)^2) and the actual digital Butterworth
|
|
822
|
+
used in deconvolution.
|
|
823
|
+
"""
|
|
824
|
+
# Evaluate at the frequency points
|
|
825
|
+
n = len(F)
|
|
826
|
+
bl = np.ones(n)
|
|
827
|
+
if n < 3:
|
|
828
|
+
return bl
|
|
829
|
+
# Normalized cutoff for the digital Butterworth. butter() requires
|
|
830
|
+
# 0 < Wn < 1; a tiny diff_gain (e.g. a corrupt/patched .p config value)
|
|
831
|
+
# pushes Wn >= 1 and scipy raises, aborting chi for the whole cast (audit
|
|
832
|
+
# finding). Degrade to no bilinear correction (all-ones) instead.
|
|
833
|
+
wn = 1 / (2 * np.pi * diff_gain * fs / 2)
|
|
834
|
+
if not (0 < wn < 1):
|
|
835
|
+
warnings.warn(
|
|
836
|
+
f"bilinear cutoff Wn={wn:.3g} out of (0,1) for diff_gain={diff_gain:.3g}; "
|
|
837
|
+
"skipping bilinear correction",
|
|
838
|
+
stacklevel=2,
|
|
839
|
+
)
|
|
840
|
+
return bl
|
|
841
|
+
# Digital Butterworth used in deconvolution
|
|
842
|
+
b, a = butter(1, wn)
|
|
843
|
+
# Compute for indices 1..N-2 (exclude DC and Nyquist), matching ODAS
|
|
844
|
+
idx = np.arange(1, n - 1)
|
|
845
|
+
F_eval = F[idx]
|
|
846
|
+
# freqz expects normalized frequency (0 to pi)
|
|
847
|
+
w_norm = 2 * np.pi * F_eval / fs
|
|
848
|
+
_, h = freqz(b, a, worN=w_norm)
|
|
849
|
+
H_digital = np.abs(h) ** 2
|
|
850
|
+
# Ideal analog response
|
|
851
|
+
H_analog = 1.0 / (1 + (2 * np.pi * F_eval * diff_gain) ** 2)
|
|
852
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
853
|
+
bl[idx] = H_analog / H_digital
|
|
854
|
+
bl = np.where(np.isfinite(bl), bl, 1.0)
|
|
855
|
+
# Copy second-to-last value to Nyquist bin (ODAS convention)
|
|
856
|
+
bl[-1] = bl[-2]
|
|
857
|
+
return bl
|