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/scor160/l4.py
ADDED
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
"""L3 -> L4: dissipation rate estimation from wavenumber spectra.
|
|
2
|
+
|
|
3
|
+
Implements Sec. 3.4 of Lueck et al. (2024).
|
|
4
|
+
|
|
5
|
+
Steps:
|
|
6
|
+
1. For each dissipation window, estimate epsilon from shear wavenumber spectrum.
|
|
7
|
+
2. Use variance method (low epsilon) or ISR method (high epsilon).
|
|
8
|
+
3. Iterative variance correction for unresolved spectral tail.
|
|
9
|
+
4. Compute QC metrics: FOM, MAD, VAR_RESOLVED.
|
|
10
|
+
5. Apply QC flags and compute EPSI_FINAL (geometric mean of passing probes).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import warnings
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from odas_tpw.scor160.io import L3Data, L4Data
|
|
20
|
+
from odas_tpw.scor160.nasmyth import LUECK_A, X_95, nasmyth_grid
|
|
21
|
+
from odas_tpw.scor160.ocean import visc, visc35
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Constants (matching rsi.dissipation)
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
EPSILON_FLOOR = 1e-15
|
|
28
|
+
E_ISR_THRESHOLD = 1.5e-5
|
|
29
|
+
K_LIMIT_MIN = 7
|
|
30
|
+
K_LIMIT_MAX = 150
|
|
31
|
+
K_INITIAL_CUTOFF = 10
|
|
32
|
+
ISOTROPY_FACTOR = 7.5
|
|
33
|
+
# Upper limit of the inertial-subrange fit, as a fraction of the
|
|
34
|
+
# Kolmogorov wavenumber k_s = (epsilon/nu^3)^(1/4). ODAS used 0.01 for
|
|
35
|
+
# years; v4.5.1 experimentally doubles it ("test pushing this upward",
|
|
36
|
+
# get_diss_odas.m:434-436). The ATOMIX benchmark reference values match
|
|
37
|
+
# 0.01 distinctly better (verified by rerunning the L2->L4 comparison
|
|
38
|
+
# with 0.02: per-probe mean log10 errors on MR1000 Minas Passage grow
|
|
39
|
+
# from ~+0.02 to +0.05..+0.08), so we keep 0.01.
|
|
40
|
+
# NB: tuned on the same ATOMIX benchmark later used to validate the pipeline,
|
|
41
|
+
# so its benchmark agreement is partly circular (not an independent test); see
|
|
42
|
+
# docs/atomix_benchmark.md.
|
|
43
|
+
X_ISR = 0.01
|
|
44
|
+
|
|
45
|
+
# Margin applied to the variance->ISR method-switch threshold:
|
|
46
|
+
# use_isr = e_1 >= e_isr_threshold * isr_margin. ODAS switches at
|
|
47
|
+
# e_isr_threshold (1.5e-5 W/kg) with no margin. Empirically, this
|
|
48
|
+
# pipeline's preliminary estimate e_1 runs high enough near the
|
|
49
|
+
# threshold that a 1.0 margin overshoots the benchmark ISR fraction
|
|
50
|
+
# (e.g. VMP250 tidal: 37.5% ISR vs 21.9% in the reference, dropping
|
|
51
|
+
# method agreement from 100% to 84%); 1.6 maximizes method agreement
|
|
52
|
+
# with the ATOMIX benchmark references. Both epsilon estimates are
|
|
53
|
+
# always computed, so the margin only affects which one is reported.
|
|
54
|
+
# NB: like X_ISR above, tuned on the same benchmark used to validate the
|
|
55
|
+
# pipeline — partly circular; see docs/atomix_benchmark.md.
|
|
56
|
+
DEFAULT_ISR_MARGIN = 1.6
|
|
57
|
+
|
|
58
|
+
VARIANCE_TANH_COEFF = 48
|
|
59
|
+
VARIANCE_EXP_COEFF = 2.9
|
|
60
|
+
VARIANCE_EXP_DECAY = 22.3
|
|
61
|
+
VARIANCE_CONVERGENCE = 1.02
|
|
62
|
+
LOW_K_CORRECTION_THRESHOLD = 1.1
|
|
63
|
+
LOW_K_CORRECTION_FACTOR = 0.25
|
|
64
|
+
|
|
65
|
+
ISR_FLYER_THRESHOLD = 0.5
|
|
66
|
+
ISR_FLYER_FRAC = 0.2
|
|
67
|
+
|
|
68
|
+
FM_SIGMA_COEFF = 1.25
|
|
69
|
+
FM_SIGMA_EXPONENT = -7 / 9
|
|
70
|
+
FM_TM_OFFSET = 0.8
|
|
71
|
+
FM_TM_COEFF = 1.56
|
|
72
|
+
|
|
73
|
+
# Default QC thresholds (from ATOMIX benchmark file attributes;
|
|
74
|
+
# VMP250_TidalChannel_024.nc L4_dissipation group)
|
|
75
|
+
DEFAULT_FOM_LIMIT = 1.15
|
|
76
|
+
DEFAULT_VAR_RESOLVED_LIMIT = 0.5
|
|
77
|
+
DEFAULT_DESPIKE_FRACTION_LIMIT = 0.05 # despike_shear_fraction_limit
|
|
78
|
+
DEFAULT_DESPIKE_PASSES_LIMIT = 8 # despike_shear_iterations_limit
|
|
79
|
+
# z-factor on the expected sigma_ln for the inter-probe consistency
|
|
80
|
+
# test: ln(e_i/e_min) > diss_ratio_limit * mean(sigma_ln) flags probe i.
|
|
81
|
+
# The benchmark stores exactly 1.96*sqrt(2) (95% CI for the difference
|
|
82
|
+
# of two equal-variance lognormal estimates).
|
|
83
|
+
DEFAULT_DISS_RATIO_LIMIT = 1.96 * np.sqrt(2.0)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Public API
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def process_l4(
|
|
92
|
+
l3: L3Data,
|
|
93
|
+
*,
|
|
94
|
+
temp: np.ndarray | None = None,
|
|
95
|
+
salinity: np.ndarray | float | None = None,
|
|
96
|
+
f_AA: float = 98.0,
|
|
97
|
+
fit_order: int = 3,
|
|
98
|
+
fom_limit: float = DEFAULT_FOM_LIMIT,
|
|
99
|
+
var_resolved_limit: float = DEFAULT_VAR_RESOLVED_LIMIT,
|
|
100
|
+
num_ffts: int = 0,
|
|
101
|
+
n_v: int = 0,
|
|
102
|
+
diss_length_s: float = 0.0,
|
|
103
|
+
despike_fraction_limit: float = DEFAULT_DESPIKE_FRACTION_LIMIT,
|
|
104
|
+
despike_passes_limit: int = DEFAULT_DESPIKE_PASSES_LIMIT,
|
|
105
|
+
diss_ratio_limit: float = DEFAULT_DISS_RATIO_LIMIT,
|
|
106
|
+
) -> L4Data:
|
|
107
|
+
"""Compute L4 dissipation estimates from L3 wavenumber spectra.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
l3 : L3Data
|
|
112
|
+
Level-3 wavenumber spectra (clean spectra used for epsilon).
|
|
113
|
+
temp : ndarray, optional
|
|
114
|
+
Per-window temperature for viscosity. If None, uses 10 C default.
|
|
115
|
+
salinity : ndarray or float, optional
|
|
116
|
+
Per-window practical salinity [PSU] for viscosity. When supplied,
|
|
117
|
+
viscosity is ``visc(temp, salinity, l3.pres)`` (salinity- and
|
|
118
|
+
pressure-aware), matching the deprecated ``dissipation.py`` path
|
|
119
|
+
and keeping epsilon on the same viscosity basis as chi/N2 in a
|
|
120
|
+
field run. Defect when omitted: epsilon viscosity defaults to
|
|
121
|
+
``visc35`` (S=35, P=0), which silently diverges from the measured
|
|
122
|
+
salinity used by chi/stratification in the same pipeline run.
|
|
123
|
+
``None`` keeps the S=35 default to preserve ATOMIX benchmark parity.
|
|
124
|
+
f_AA : float
|
|
125
|
+
Anti-alias filter cutoff [Hz].
|
|
126
|
+
fit_order : int
|
|
127
|
+
Polynomial order for spectral minimum detection.
|
|
128
|
+
fom_limit : float
|
|
129
|
+
QC threshold for the figure of merit. Applied to the Lueck
|
|
130
|
+
(2022a,b) MAD-based FM statistic when ``num_ffts > 0`` (the
|
|
131
|
+
ATOMIX convention; recommended limit ~1.15), otherwise to the
|
|
132
|
+
observed/Nasmyth variance ratio ``fom``.
|
|
133
|
+
var_resolved_limit : float
|
|
134
|
+
QC threshold for fraction of variance resolved.
|
|
135
|
+
num_ffts : int
|
|
136
|
+
Number of FFT segments per dissipation window,
|
|
137
|
+
``2 * (diss_length // fft_length) - 1`` for 50 % overlap.
|
|
138
|
+
Required for the Lueck FM statistic; 0 skips FM.
|
|
139
|
+
n_v : int
|
|
140
|
+
Number of coherent-noise (vibration) signals removed by Goodman
|
|
141
|
+
cleaning (reduces the effective degrees of freedom in FM).
|
|
142
|
+
Pass 0 when Goodman cleaning was not applied.
|
|
143
|
+
diss_length_s : float
|
|
144
|
+
Dissipation window duration [s] (``diss_length / fs_fast``).
|
|
145
|
+
Required for the inter-probe consistency flag (bit 4, via the
|
|
146
|
+
Lueck 2022 variance model); 0 skips that flag.
|
|
147
|
+
despike_fraction_limit : float
|
|
148
|
+
Flag bit 2 when the fraction of shear samples replaced by
|
|
149
|
+
despiking in a window exceeds this (ATOMIX
|
|
150
|
+
``despike_shear_fraction_limit``).
|
|
151
|
+
despike_passes_limit : int
|
|
152
|
+
Flag bit 8 when despiking needed more than this many passes
|
|
153
|
+
(ATOMIX ``despike_shear_iterations_limit``).
|
|
154
|
+
diss_ratio_limit : float
|
|
155
|
+
z-factor for the inter-probe consistency test (bit 4):
|
|
156
|
+
``ln(e_i/e_min) > diss_ratio_limit * mean(sigma_ln)`` flags
|
|
157
|
+
probe i. The ATOMIX benchmark value is 1.96*sqrt(2).
|
|
158
|
+
"""
|
|
159
|
+
n_spec = l3.n_spectra
|
|
160
|
+
n_shear = l3.n_shear
|
|
161
|
+
|
|
162
|
+
if n_spec == 0:
|
|
163
|
+
return _empty_l4(n_shear)
|
|
164
|
+
|
|
165
|
+
# Temperature for viscosity
|
|
166
|
+
if temp is None:
|
|
167
|
+
temp = l3.temp if l3.temp.size == n_spec else np.full(n_spec, 10.0)
|
|
168
|
+
temp = np.asarray(temp, dtype=np.float64)
|
|
169
|
+
if not np.all(np.isfinite(temp)):
|
|
170
|
+
warnings.warn(
|
|
171
|
+
"Non-finite window temperature(s); using 10 degC for viscosity in those windows",
|
|
172
|
+
stacklevel=2,
|
|
173
|
+
)
|
|
174
|
+
temp = np.where(np.isfinite(temp), temp, 10.0)
|
|
175
|
+
|
|
176
|
+
# Per-window salinity for viscosity. When provided, epsilon viscosity
|
|
177
|
+
# becomes salinity/pressure aware (visc); when None it stays at the
|
|
178
|
+
# S=35/P=0 visc35 reference (ATOMIX benchmark parity).
|
|
179
|
+
sal: np.ndarray | None = None
|
|
180
|
+
if salinity is not None:
|
|
181
|
+
if np.ndim(salinity) > 0:
|
|
182
|
+
sal = np.asarray(salinity, dtype=np.float64)
|
|
183
|
+
if sal.size != n_spec:
|
|
184
|
+
sal = np.full(n_spec, float(np.nanmean(sal)))
|
|
185
|
+
else:
|
|
186
|
+
sal = np.full(n_spec, float(salinity))
|
|
187
|
+
if not np.all(np.isfinite(sal)):
|
|
188
|
+
sal = np.where(np.isfinite(sal), sal, 35.0)
|
|
189
|
+
pres_arr = np.asarray(l3.pres, dtype=np.float64) if l3.pres.size == n_spec else np.zeros(n_spec)
|
|
190
|
+
|
|
191
|
+
# Output arrays
|
|
192
|
+
epsi = np.full((n_shear, n_spec), np.nan)
|
|
193
|
+
fom = np.full((n_shear, n_spec), np.nan)
|
|
194
|
+
mad = np.full((n_shear, n_spec), np.nan)
|
|
195
|
+
kmax = np.full((n_shear, n_spec), np.nan)
|
|
196
|
+
method_arr = np.full((n_shear, n_spec), np.nan)
|
|
197
|
+
var_resolved = np.full((n_shear, n_spec), np.nan)
|
|
198
|
+
FM = np.full((n_shear, n_spec), np.nan)
|
|
199
|
+
nu_arr = np.full(n_spec, np.nan)
|
|
200
|
+
|
|
201
|
+
for j in range(n_spec):
|
|
202
|
+
# Per-window wavenumber grid: kcyc is (N_WAVENUMBER, N_SPECTRA)
|
|
203
|
+
K = l3.kcyc[:, j] # cpm
|
|
204
|
+
W = l3.pspd_rel[j]
|
|
205
|
+
if sal is not None:
|
|
206
|
+
nu = float(visc(temp[j], sal[j], pres_arr[j]))
|
|
207
|
+
else:
|
|
208
|
+
nu = float(visc35(temp[j]))
|
|
209
|
+
nu_arr[j] = nu
|
|
210
|
+
|
|
211
|
+
# Anti-alias wavenumber limit. Defect: pspd_rel holds the
|
|
212
|
+
# un-floored per-window mean speed, which is NaN for an all-NaN
|
|
213
|
+
# window (l3.py records NaN there). max(NaN, 0.05) keeps the NaN,
|
|
214
|
+
# so K_AA -> NaN and the integration limits silently widen to the
|
|
215
|
+
# full spectrum (searchsorted(K, NaN)=len(K)), yielding a wrong-but-
|
|
216
|
+
# finite over-integrated epsilon. Mirror the L3 wavenumber-axis
|
|
217
|
+
# floor so a degenerate window gives a clean, floored estimate.
|
|
218
|
+
K_AA = 0.9 * f_AA / max(np.nan_to_num(W, nan=0.05), 0.05)
|
|
219
|
+
|
|
220
|
+
for i in range(n_shear):
|
|
221
|
+
# Use cleaned spectra
|
|
222
|
+
spec = l3.sh_spec_clean[i, :, j]
|
|
223
|
+
|
|
224
|
+
if not np.any(np.isfinite(spec) & (spec > 0)):
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
e, km, md, meth, fom_val, var_res, _nas, _kmr, fm, _eisr, _evar = _estimate_epsilon(
|
|
228
|
+
K,
|
|
229
|
+
spec,
|
|
230
|
+
nu,
|
|
231
|
+
K_AA,
|
|
232
|
+
fit_order,
|
|
233
|
+
num_ffts=num_ffts,
|
|
234
|
+
n_v=n_v,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
epsi[i, j] = e
|
|
238
|
+
fom[i, j] = fom_val
|
|
239
|
+
mad[i, j] = md
|
|
240
|
+
kmax[i, j] = km
|
|
241
|
+
method_arr[i, j] = meth
|
|
242
|
+
var_resolved[i, j] = var_res
|
|
243
|
+
FM[i, j] = fm
|
|
244
|
+
|
|
245
|
+
# Expected sigma_ln per probe/window (Lueck 2022a variance model)
|
|
246
|
+
# for the inter-probe consistency flag.
|
|
247
|
+
sigma_ln = None
|
|
248
|
+
if diss_length_s > 0:
|
|
249
|
+
sigma_ln = _sigma_ln_epsilon(epsi, nu_arr, diss_length_s * l3.pspd_rel)
|
|
250
|
+
|
|
251
|
+
# QC flags and EPSI_FINAL. Flag on the MAD-based FM statistic when
|
|
252
|
+
# available (the ATOMIX convention the fom_limit default of 1.15 was
|
|
253
|
+
# written for); fall back to the variance-ratio fom otherwise.
|
|
254
|
+
fom_for_flags = FM if num_ffts > 0 else fom
|
|
255
|
+
epsi_flags = _compute_flags(
|
|
256
|
+
epsi,
|
|
257
|
+
fom_for_flags,
|
|
258
|
+
var_resolved,
|
|
259
|
+
fom_limit,
|
|
260
|
+
var_resolved_limit,
|
|
261
|
+
despike_fraction=l3.despike_fraction if l3.despike_fraction.size > 0 else None,
|
|
262
|
+
despike_passes=l3.despike_passes if l3.despike_passes.size > 0 else None,
|
|
263
|
+
despike_fraction_limit=despike_fraction_limit,
|
|
264
|
+
despike_passes_limit=despike_passes_limit,
|
|
265
|
+
sigma_ln=sigma_ln,
|
|
266
|
+
diss_ratio_limit=diss_ratio_limit,
|
|
267
|
+
method=method_arr,
|
|
268
|
+
)
|
|
269
|
+
epsi_final = _compute_epsi_final(epsi, epsi_flags)
|
|
270
|
+
|
|
271
|
+
return L4Data(
|
|
272
|
+
time=l3.time.copy(),
|
|
273
|
+
pres=l3.pres.copy(),
|
|
274
|
+
pspd_rel=l3.pspd_rel.copy(),
|
|
275
|
+
section_number=l3.section_number.copy(),
|
|
276
|
+
epsi=epsi,
|
|
277
|
+
epsi_final=epsi_final,
|
|
278
|
+
epsi_flags=epsi_flags,
|
|
279
|
+
fom=fom,
|
|
280
|
+
mad=mad,
|
|
281
|
+
kmax=kmax,
|
|
282
|
+
method=method_arr,
|
|
283
|
+
var_resolved=var_resolved,
|
|
284
|
+
FM=FM,
|
|
285
|
+
despike_fraction=(l3.despike_fraction.copy() if l3.despike_fraction.size > 0 else None),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _empty_l4(n_shear: int) -> L4Data:
|
|
290
|
+
return L4Data(
|
|
291
|
+
time=np.array([]),
|
|
292
|
+
pres=np.array([]),
|
|
293
|
+
pspd_rel=np.array([]),
|
|
294
|
+
section_number=np.array([]),
|
|
295
|
+
epsi=np.zeros((n_shear, 0)),
|
|
296
|
+
epsi_final=np.array([]),
|
|
297
|
+
epsi_flags=np.zeros((n_shear, 0)),
|
|
298
|
+
fom=np.zeros((n_shear, 0)),
|
|
299
|
+
mad=np.zeros((n_shear, 0)),
|
|
300
|
+
kmax=np.zeros((n_shear, 0)),
|
|
301
|
+
method=np.zeros((n_shear, 0)),
|
|
302
|
+
var_resolved=np.zeros((n_shear, 0)),
|
|
303
|
+
FM=np.zeros((n_shear, 0)),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
# Core epsilon estimation (per-spectrum)
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _estimate_epsilon(
|
|
313
|
+
K: np.ndarray,
|
|
314
|
+
shear_spectrum: np.ndarray,
|
|
315
|
+
nu: float,
|
|
316
|
+
K_AA: float,
|
|
317
|
+
fit_order: int,
|
|
318
|
+
e_isr_threshold: float = E_ISR_THRESHOLD,
|
|
319
|
+
num_ffts: int = 0,
|
|
320
|
+
n_v: int = 0,
|
|
321
|
+
isr_margin: float = DEFAULT_ISR_MARGIN,
|
|
322
|
+
) -> tuple[float, float, float, int, float, float, np.ndarray, float, float, float, float]:
|
|
323
|
+
"""Estimate epsilon from a single shear wavenumber spectrum.
|
|
324
|
+
|
|
325
|
+
This is the canonical implementation used by both the scor160 pipeline
|
|
326
|
+
and the RSI ``get_diss()`` function.
|
|
327
|
+
|
|
328
|
+
Parameters
|
|
329
|
+
----------
|
|
330
|
+
K : ndarray
|
|
331
|
+
Wavenumber vector [cpm].
|
|
332
|
+
shear_spectrum : ndarray
|
|
333
|
+
Observed shear spectrum [s^-2 cpm^-1].
|
|
334
|
+
nu : float
|
|
335
|
+
Kinematic viscosity [m^2/s].
|
|
336
|
+
K_AA : float
|
|
337
|
+
Anti-aliasing wavenumber limit [cpm].
|
|
338
|
+
fit_order : int
|
|
339
|
+
Polynomial order for spectral minimum detection.
|
|
340
|
+
e_isr_threshold : float
|
|
341
|
+
Epsilon threshold for switching to ISR method [W/kg].
|
|
342
|
+
num_ffts : int
|
|
343
|
+
Number of FFT segments (for Lueck FM statistic). 0 to skip FM.
|
|
344
|
+
n_v : int
|
|
345
|
+
Number of vibration signals removed by Goodman cleaning (for the
|
|
346
|
+
Lueck FM statistic). Pass 0 when Goodman was not applied.
|
|
347
|
+
isr_margin : float
|
|
348
|
+
Multiplier on ``e_isr_threshold`` for the method switch (see
|
|
349
|
+
``DEFAULT_ISR_MARGIN``). 1.0 reproduces the ODAS convention.
|
|
350
|
+
|
|
351
|
+
Returns
|
|
352
|
+
-------
|
|
353
|
+
epsilon : float
|
|
354
|
+
Dissipation rate [W/kg].
|
|
355
|
+
K_max : float
|
|
356
|
+
Upper integration limit [cpm].
|
|
357
|
+
mad : float
|
|
358
|
+
Mean absolute deviation of spectral fit (log10).
|
|
359
|
+
method : int
|
|
360
|
+
0 = variance, 1 = inertial subrange.
|
|
361
|
+
fom : float
|
|
362
|
+
Figure of merit (observed/Nasmyth variance ratio).
|
|
363
|
+
var_resolved : float
|
|
364
|
+
Fraction of Nasmyth variance resolved.
|
|
365
|
+
nasmyth_spectrum : ndarray
|
|
366
|
+
Nasmyth spectrum at final epsilon.
|
|
367
|
+
K_max_ratio : float
|
|
368
|
+
K_max / K_95 spectral resolution ratio.
|
|
369
|
+
FM : float
|
|
370
|
+
Lueck (2022a,b) figure of merit (NaN if num_ffts == 0).
|
|
371
|
+
epsilon_isr : float
|
|
372
|
+
Inertial subrange epsilon estimate [W/kg].
|
|
373
|
+
epsilon_var : float
|
|
374
|
+
Variance method epsilon estimate [W/kg].
|
|
375
|
+
"""
|
|
376
|
+
n_freq = len(K)
|
|
377
|
+
|
|
378
|
+
# Corrupted spectral bins (ODAS get_diss_odas.m:577-587 convention):
|
|
379
|
+
# a single interior NaN/Inf bin is interpolated from its neighbors;
|
|
380
|
+
# multiple bad bins invalidate the estimate. Zeroing bad bins (the
|
|
381
|
+
# previous behavior) silently removes variance, biasing epsilon low
|
|
382
|
+
# and corrupting the log-space polynomial minimum search.
|
|
383
|
+
bad = np.where(~np.isfinite(shear_spectrum))[0]
|
|
384
|
+
spec_safe = shear_spectrum
|
|
385
|
+
if len(bad) > 0:
|
|
386
|
+
spec_safe = shear_spectrum.copy()
|
|
387
|
+
# The DC bin (index 0, K=0) is excluded from the log-spectrum polynomial
|
|
388
|
+
# fit (ODAS fits log10(shear_spectrum(2:Index_limit))) but IS included in
|
|
389
|
+
# the variance trapezoid below, so repairing it from a finite neighbor
|
|
390
|
+
# keeps that integral finite (a repaired DC shifts a variance-regime
|
|
391
|
+
# estimate by ~5%, immaterial vs the factor-2 epsilon uncertainty). A
|
|
392
|
+
# non-finite DC value is common in externally Goodman-cleaned L3 spectra
|
|
393
|
+
# (the ATOMIX Epsifish set is NaN@DC in every spectrum). NOTE: ODAS
|
|
394
|
+
# itself voids a DC-NaN spectrum; this repair deliberately IMPROVES on it
|
|
395
|
+
# so such externally-cleaned spectra remain usable. (issue #104 U8-1.)
|
|
396
|
+
if bad[0] == 0 and n_freq > 1 and np.isfinite(shear_spectrum[1]):
|
|
397
|
+
spec_safe[0] = shear_spectrum[1]
|
|
398
|
+
bad = bad[1:]
|
|
399
|
+
# A single remaining interior bad bin is interpolated from its neighbors;
|
|
400
|
+
# anything else (>=2 bad, or an un-repairable boundary bin) invalidates
|
|
401
|
+
# the estimate — but WARN rather than dropping it silently.
|
|
402
|
+
if len(bad) == 1 and 0 < bad[0] < n_freq - 1:
|
|
403
|
+
spec_safe[bad[0]] = 0.5 * (spec_safe[bad[0] - 1] + spec_safe[bad[0] + 1])
|
|
404
|
+
elif len(bad) > 0:
|
|
405
|
+
warnings.warn(
|
|
406
|
+
f"{len(bad)} non-finite shear-spectrum bin(s) at {bad.tolist()}; "
|
|
407
|
+
"epsilon set to NaN",
|
|
408
|
+
stacklevel=2,
|
|
409
|
+
)
|
|
410
|
+
nan_spec = np.full(n_freq, np.nan)
|
|
411
|
+
nan = np.nan
|
|
412
|
+
return (nan, nan, nan, 0, nan, nan, nan_spec, nan, nan, nan, nan)
|
|
413
|
+
|
|
414
|
+
# Initial estimate: integrate to K_INITIAL_CUTOFF cpm. K is monotone
|
|
415
|
+
# nondecreasing (it's an FFT wavenumber grid divided by a positive
|
|
416
|
+
# speed), so ``np.where(K <= cutoff)[0]`` is equivalent to
|
|
417
|
+
# ``np.arange(searchsorted(K, cutoff, 'right'))`` — and slicing with
|
|
418
|
+
# the count avoids building both the boolean and the index array.
|
|
419
|
+
n_init = int(np.searchsorted(K, K_INITIAL_CUTOFF, side="right"))
|
|
420
|
+
if n_init < 3:
|
|
421
|
+
n_init = min(3, n_freq)
|
|
422
|
+
|
|
423
|
+
e_10 = ISOTROPY_FACTOR * nu * np.trapezoid(spec_safe[:n_init], K[:n_init])
|
|
424
|
+
if e_10 <= 0:
|
|
425
|
+
e_10 = EPSILON_FLOOR
|
|
426
|
+
e_1 = e_10 * np.sqrt(1 + LUECK_A * e_10)
|
|
427
|
+
|
|
428
|
+
use_isr = e_1 >= e_isr_threshold * isr_margin
|
|
429
|
+
|
|
430
|
+
# Always compute both estimates so viewers can show the alternative
|
|
431
|
+
K_limit = min(K_AA, K_LIMIT_MAX)
|
|
432
|
+
isr_e, isr_km, isr_range = _inertial_subrange(K, spec_safe, e_1, nu, K_limit)
|
|
433
|
+
var_e, var_km, n_var = _variance_method(
|
|
434
|
+
K,
|
|
435
|
+
spec_safe,
|
|
436
|
+
e_1,
|
|
437
|
+
nu,
|
|
438
|
+
K_AA,
|
|
439
|
+
fit_order,
|
|
440
|
+
n_freq,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
# qc_idx is the wavenumber-bin index set the MAD/FOM/FM statistics
|
|
444
|
+
# are computed over, excluding the DC bin (K[0]=0 by construction;
|
|
445
|
+
# F starts at 0 in csd_matrix_batch and K=F/W with W>0).
|
|
446
|
+
if use_isr:
|
|
447
|
+
method = 1
|
|
448
|
+
# Use the flyer-pruned ISR fit range, as ODAS computes MAD over
|
|
449
|
+
# the flyer-reduced Range (get_diss_odas.m:660-671); a contiguous
|
|
450
|
+
# range would re-include the excluded flyer bins.
|
|
451
|
+
qc_idx = isr_range[K[isr_range] > 0]
|
|
452
|
+
e_4, k_max = isr_e, isr_km
|
|
453
|
+
else:
|
|
454
|
+
method = 0
|
|
455
|
+
e_4, k_max = var_e, var_km
|
|
456
|
+
qc_idx = np.arange(1, max(n_var, 1))
|
|
457
|
+
|
|
458
|
+
# Nasmyth spectrum at final epsilon
|
|
459
|
+
nas_spec = nasmyth_grid(max(e_4, EPSILON_FLOOR), nu, K + 1e-30)
|
|
460
|
+
|
|
461
|
+
if len(qc_idx) > 0:
|
|
462
|
+
K_pos = K[qc_idx]
|
|
463
|
+
spec_safe_pos = spec_safe[qc_idx]
|
|
464
|
+
nas_spec_pos = nas_spec[qc_idx]
|
|
465
|
+
spec_ratio = spec_safe_pos / (nas_spec_pos + 1e-30)
|
|
466
|
+
spec_ratio = spec_ratio[spec_ratio > 0]
|
|
467
|
+
mad_val = float(np.mean(np.abs(np.log10(spec_ratio)))) if len(spec_ratio) > 0 else np.nan
|
|
468
|
+
obs_var = np.trapezoid(spec_safe_pos, K_pos)
|
|
469
|
+
nas_var = np.trapezoid(nas_spec_pos, K_pos)
|
|
470
|
+
fom_val = obs_var / nas_var if nas_var > 0 else np.nan
|
|
471
|
+
else:
|
|
472
|
+
mad_val = np.nan
|
|
473
|
+
fom_val = np.nan
|
|
474
|
+
spec_ratio = np.array([])
|
|
475
|
+
|
|
476
|
+
# Variance resolved fraction
|
|
477
|
+
var_res = _variance_resolved_fraction(k_max, e_4, nu)
|
|
478
|
+
|
|
479
|
+
# K_max / K_95 spectral resolution ratio
|
|
480
|
+
K_95 = X_95 * (max(e_4, EPSILON_FLOOR) / nu**3) ** 0.25
|
|
481
|
+
K_max_ratio_val = k_max / K_95 if K_95 > 0 else np.nan
|
|
482
|
+
|
|
483
|
+
# Lueck (2022a,b) figure of merit
|
|
484
|
+
FM_val = np.nan
|
|
485
|
+
if num_ffts > 0 and len(spec_ratio) > 0:
|
|
486
|
+
N_s = len(spec_ratio)
|
|
487
|
+
mad_ln = np.mean(np.abs(np.log(spec_ratio))) # natural log
|
|
488
|
+
N_eff = max(num_ffts - n_v, 1)
|
|
489
|
+
sigma_ln = np.sqrt(FM_SIGMA_COEFF * N_eff**FM_SIGMA_EXPONENT)
|
|
490
|
+
T_M = FM_TM_OFFSET + np.sqrt(FM_TM_COEFF / N_s)
|
|
491
|
+
FM_val = mad_ln / (T_M * sigma_ln)
|
|
492
|
+
|
|
493
|
+
return (
|
|
494
|
+
e_4,
|
|
495
|
+
k_max,
|
|
496
|
+
mad_val,
|
|
497
|
+
method,
|
|
498
|
+
fom_val,
|
|
499
|
+
var_res,
|
|
500
|
+
nas_spec,
|
|
501
|
+
K_max_ratio_val,
|
|
502
|
+
FM_val,
|
|
503
|
+
isr_e,
|
|
504
|
+
var_e,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _variance_method(
|
|
509
|
+
K: np.ndarray,
|
|
510
|
+
spec_safe: np.ndarray,
|
|
511
|
+
e_1: float,
|
|
512
|
+
nu: float,
|
|
513
|
+
K_AA: float,
|
|
514
|
+
fit_order: int,
|
|
515
|
+
n_freq: int,
|
|
516
|
+
) -> tuple[float, float, int]:
|
|
517
|
+
"""Variance integration method for epsilon estimation.
|
|
518
|
+
|
|
519
|
+
Returns (epsilon, K_max, n_range), where ``n_range`` is the count
|
|
520
|
+
of leading wavenumber bins used for the integration; the caller
|
|
521
|
+
treats it as an implicit ``np.arange(n_range)``.
|
|
522
|
+
"""
|
|
523
|
+
isr_limit = X_ISR * (e_1 / nu**3) ** 0.25
|
|
524
|
+
# K is monotone nondecreasing — use searchsorted to count points
|
|
525
|
+
# below isr_limit instead of building a bool+index array.
|
|
526
|
+
if int(np.searchsorted(K, isr_limit, side="right")) >= 20:
|
|
527
|
+
e_2, _, _ = _inertial_subrange(K, spec_safe, e_1, nu, min(K_LIMIT_MAX, K_AA))
|
|
528
|
+
else:
|
|
529
|
+
e_2 = e_1
|
|
530
|
+
|
|
531
|
+
K_95 = X_95 * (e_2 / nu**3) ** 0.25
|
|
532
|
+
valid_K_limit = min(K_AA, K_95)
|
|
533
|
+
# Plain min/max on scalars produces the same float64 result as
|
|
534
|
+
# ``np.clip`` here without the numpy dispatch overhead — and
|
|
535
|
+
# ``np.clip`` was being called per spectrum in the cProfile hot-list.
|
|
536
|
+
valid_K_limit = min(max(valid_K_limit, K_LIMIT_MIN), K_LIMIT_MAX)
|
|
537
|
+
index_limit = int(np.searchsorted(K, valid_K_limit, side="right"))
|
|
538
|
+
|
|
539
|
+
if index_limit <= 1:
|
|
540
|
+
index_limit = min(3, n_freq)
|
|
541
|
+
|
|
542
|
+
# Polynomial fit to find spectral minimum. Zero bins (possible after
|
|
543
|
+
# Goodman cleaning) are excluded: log10(0 + 1e-30) = -30 would enter
|
|
544
|
+
# the fit as an extreme outlier and drag the fitted minimum.
|
|
545
|
+
y = np.log10(spec_safe[1:index_limit] + 1e-30)
|
|
546
|
+
x = np.log10(K[1:index_limit] + 1e-30)
|
|
547
|
+
|
|
548
|
+
fit_order_eff = min(max(fit_order, 3), 8)
|
|
549
|
+
K_limit_log = np.log10(K_95)
|
|
550
|
+
|
|
551
|
+
if index_limit > fit_order_eff + 2:
|
|
552
|
+
valid = np.isfinite(y) & np.isfinite(x) & (spec_safe[1:index_limit] > 0)
|
|
553
|
+
if np.sum(valid) > fit_order_eff + 2:
|
|
554
|
+
p = np.polyfit(x[valid], y[valid], fit_order_eff)
|
|
555
|
+
pd1 = np.polyder(p)
|
|
556
|
+
roots = np.roots(pd1)
|
|
557
|
+
roots = roots[np.isreal(roots)].real
|
|
558
|
+
pd2 = np.polyder(pd1)
|
|
559
|
+
roots = roots[np.polyval(pd2, roots) > 0]
|
|
560
|
+
roots = roots[roots >= np.log10(K_INITIAL_CUTOFF)]
|
|
561
|
+
# np.roots returns eigenvalues in no particular order; take
|
|
562
|
+
# the LOWEST qualifying minimum, as ODAS does via
|
|
563
|
+
# ``pr1 = sort(roots(pd1))`` (get_diss_odas.m:590,597).
|
|
564
|
+
K_limit_log = np.min(roots) if len(roots) > 0 else np.log10(K_95)
|
|
565
|
+
|
|
566
|
+
# Final integration limit
|
|
567
|
+
K_limit_log = min(K_limit_log, np.log10(K_95), np.log10(K_AA))
|
|
568
|
+
K_limit_log = min(max(K_limit_log, np.log10(K_LIMIT_MIN)), np.log10(K_LIMIT_MAX))
|
|
569
|
+
|
|
570
|
+
# K is monotone nondecreasing — searchsorted gives the count. The
|
|
571
|
+
# implicit Range is ``np.arange(n_var)``; we use ``[:n_var]`` slicing
|
|
572
|
+
# below to avoid building the index array (slicing returns a view).
|
|
573
|
+
n_var = int(np.searchsorted(K, 10**K_limit_log, side="right"))
|
|
574
|
+
if n_var > 0 and K[n_var - 1] < K_LIMIT_MIN:
|
|
575
|
+
n_var = min(n_var + 1, n_freq)
|
|
576
|
+
if n_var < 3:
|
|
577
|
+
n_var = min(3, n_freq)
|
|
578
|
+
|
|
579
|
+
e_3 = ISOTROPY_FACTOR * nu * np.trapezoid(spec_safe[:n_var], K[:n_var])
|
|
580
|
+
e_3 = max(e_3, EPSILON_FLOOR)
|
|
581
|
+
|
|
582
|
+
K_last = K[n_var - 1]
|
|
583
|
+
|
|
584
|
+
# Iterative variance correction
|
|
585
|
+
e_4 = _variance_correction(e_3, K_last, nu)
|
|
586
|
+
|
|
587
|
+
# Low-wavenumber correction
|
|
588
|
+
if len(K) > 2:
|
|
589
|
+
e_4_vc = e_4
|
|
590
|
+
phi_low = nasmyth_grid(e_4, nu, K[1:3])
|
|
591
|
+
e_4 = e_4 + LOW_K_CORRECTION_FACTOR * ISOTROPY_FACTOR * nu * K[1] * phi_low[0]
|
|
592
|
+
if e_4 / e_4_vc > LOW_K_CORRECTION_THRESHOLD:
|
|
593
|
+
e_4 = _variance_correction(e_4, K_last, nu)
|
|
594
|
+
|
|
595
|
+
return e_4, K_last, n_var
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _compute_mad(
|
|
599
|
+
K: np.ndarray,
|
|
600
|
+
spec_safe: np.ndarray,
|
|
601
|
+
epsilon: float,
|
|
602
|
+
nu: float,
|
|
603
|
+
Range: np.ndarray,
|
|
604
|
+
) -> float:
|
|
605
|
+
"""Compute MAD (log10) for a given epsilon estimate over a spectral range."""
|
|
606
|
+
nas = nasmyth_grid(max(epsilon, EPSILON_FLOOR), nu, K + 1e-30)
|
|
607
|
+
Range_noDC = Range[K[Range] > 0]
|
|
608
|
+
if len(Range_noDC) == 0:
|
|
609
|
+
return np.inf
|
|
610
|
+
ratio = spec_safe[Range_noDC] / (nas[Range_noDC] + 1e-30)
|
|
611
|
+
ratio = ratio[ratio > 0]
|
|
612
|
+
if len(ratio) == 0:
|
|
613
|
+
return np.inf
|
|
614
|
+
return float(np.mean(np.abs(np.log10(ratio))))
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _variance_correction(e_3: float, K_upper: float, nu: float, max_iter: int = 50) -> float:
|
|
618
|
+
"""Iterative variance correction using Lueck's resolved-variance model."""
|
|
619
|
+
e_new = e_3
|
|
620
|
+
for _ in range(max_iter):
|
|
621
|
+
x_limit = K_upper * (nu**3 / e_new) ** 0.25
|
|
622
|
+
x_limit = x_limit ** (4 / 3)
|
|
623
|
+
variance_resolved = np.tanh(
|
|
624
|
+
VARIANCE_TANH_COEFF * x_limit
|
|
625
|
+
) - VARIANCE_EXP_COEFF * x_limit * np.exp(-VARIANCE_EXP_DECAY * x_limit)
|
|
626
|
+
if variance_resolved <= 0:
|
|
627
|
+
break
|
|
628
|
+
e_old = e_new
|
|
629
|
+
e_new = e_3 / variance_resolved
|
|
630
|
+
if e_new / e_old < VARIANCE_CONVERGENCE:
|
|
631
|
+
break
|
|
632
|
+
return e_new
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _variance_resolved_fraction(K_upper: float, epsilon: float, nu: float) -> float:
|
|
636
|
+
"""Fraction of Nasmyth variance resolved up to K_upper."""
|
|
637
|
+
epsilon = max(epsilon, EPSILON_FLOOR)
|
|
638
|
+
x_limit = K_upper * (nu**3 / epsilon) ** 0.25
|
|
639
|
+
x_limit = x_limit ** (4 / 3)
|
|
640
|
+
vr = np.tanh(VARIANCE_TANH_COEFF * x_limit) - VARIANCE_EXP_COEFF * x_limit * np.exp(
|
|
641
|
+
-VARIANCE_EXP_DECAY * x_limit
|
|
642
|
+
)
|
|
643
|
+
return float(np.clip(vr, 0, 1))
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _inertial_subrange(
|
|
647
|
+
K: np.ndarray, shear_spectrum: np.ndarray, e: float, nu: float, K_limit: float
|
|
648
|
+
) -> tuple[float, float, np.ndarray]:
|
|
649
|
+
"""Fit to the inertial subrange to estimate epsilon.
|
|
650
|
+
|
|
651
|
+
Returns (epsilon, K_max, fit_range) where ``fit_range`` is the index
|
|
652
|
+
array of wavenumber bins retained after flyer removal (used by the
|
|
653
|
+
caller for the MAD/FOM/FM statistics).
|
|
654
|
+
"""
|
|
655
|
+
isr_limit = min(X_ISR * (e / nu**3) ** 0.25, K_limit)
|
|
656
|
+
# K is monotone nondecreasing — searchsorted is equivalent to
|
|
657
|
+
# ``np.where(isr_limit >= K)[0]`` while skipping the bool array.
|
|
658
|
+
n_fit = int(np.searchsorted(K, isr_limit, side="right"))
|
|
659
|
+
if n_fit < 3:
|
|
660
|
+
n_fit = min(3, len(K))
|
|
661
|
+
fit_range = np.arange(n_fit)
|
|
662
|
+
|
|
663
|
+
k_max = K[n_fit - 1]
|
|
664
|
+
|
|
665
|
+
# Cache the post-DC slices that the iterative loops actually need.
|
|
666
|
+
# The previous code computed nas at K_safe[0] = K[0]+1e-30 ≈ 0 and
|
|
667
|
+
# threw it away via ``nas[1:]``; passing K_inner = K[1:n_fit]+1e-30
|
|
668
|
+
# to nasmyth_grid trims that wasted element from each interp call
|
|
669
|
+
# (8138 spectra * 6 calls each = ~49k calls). fit_range[0] = 0
|
|
670
|
+
# always (the flyer loop never marks keep[0] = False), so
|
|
671
|
+
# fit_range[1:] is the contiguous K[1:n_fit] view here.
|
|
672
|
+
K_inner = K[1:n_fit] + 1e-30
|
|
673
|
+
shear_tail = shear_spectrum[1:n_fit]
|
|
674
|
+
|
|
675
|
+
# Iterative fitting (3 passes)
|
|
676
|
+
for _ in range(3):
|
|
677
|
+
nas = nasmyth_grid(max(e, EPSILON_FLOOR), nu, K_inner)
|
|
678
|
+
ratio = shear_tail / (nas + 1e-30)
|
|
679
|
+
ratio = ratio[ratio > 0]
|
|
680
|
+
if len(ratio) == 0:
|
|
681
|
+
break
|
|
682
|
+
fit_error = float(np.mean(np.log10(ratio)))
|
|
683
|
+
e = e * 10 ** (3 * fit_error / 2)
|
|
684
|
+
|
|
685
|
+
# Remove flyers. The error vector keeps full length (non-positive
|
|
686
|
+
# ratio bins become NaN, which compares False below) so that index i
|
|
687
|
+
# in ``fit_error_vec`` always corresponds to ``fit_range[i+1]`` —
|
|
688
|
+
# compressing the array first (the previous behavior) misaligned
|
|
689
|
+
# the flyer indices whenever a zero bin was present. Note: ODAS
|
|
690
|
+
# itself has an off-by-one here (get_diss_odas.m:790-797 deletes
|
|
691
|
+
# ``fit_range(index)`` where ``index`` refers to ``fit_range(2:end)``);
|
|
692
|
+
# the ``keep[b + 1]`` mapping below is the corrected version.
|
|
693
|
+
#
|
|
694
|
+
# Worked example: fit_range = bins [1..10]; fit_error runs over
|
|
695
|
+
# fit_range(2:end) = bins [2..10] (length 9). A single flyer at
|
|
696
|
+
# fit_error position 5 is really bin fit_range(5+1) = bin 6.
|
|
697
|
+
# ODAS (1-based): index=5 -> ``fit_range(5)=[]`` removes bin 5 — one
|
|
698
|
+
# below the flyer (wrong); k_max/refit then use a
|
|
699
|
+
# mis-trimmed range.
|
|
700
|
+
# here (0-based): same flyer at fit_error_vec[4] -> ``keep[4+1]`` =
|
|
701
|
+
# keep[5] = False -> removes fit_range[5] = bin 6, the
|
|
702
|
+
# actual flyer (correct).
|
|
703
|
+
# This deliberate DEVIATION from the RSI reference is what the ATOMIX
|
|
704
|
+
# scor160 L2->L4 benchmark is validated against, so it is correct-by-test,
|
|
705
|
+
# not merely asserted.
|
|
706
|
+
nas = nasmyth_grid(max(e, EPSILON_FLOOR), nu, K_inner)
|
|
707
|
+
if len(fit_range) > 2:
|
|
708
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
709
|
+
ratio_full = shear_tail / (nas + 1e-30)
|
|
710
|
+
fit_error_vec = np.where(ratio_full > 0, np.log10(ratio_full), np.nan)
|
|
711
|
+
with np.errstate(invalid="ignore"):
|
|
712
|
+
bad = np.where(np.abs(fit_error_vec) > ISR_FLYER_THRESHOLD)[0]
|
|
713
|
+
if len(bad) > 0:
|
|
714
|
+
bad_limit = max(1, int(np.ceil(ISR_FLYER_FRAC * len(fit_range))))
|
|
715
|
+
if len(bad) > bad_limit:
|
|
716
|
+
order = np.argsort(fit_error_vec[bad])[::-1]
|
|
717
|
+
bad = bad[order[:bad_limit]]
|
|
718
|
+
keep = np.ones(len(fit_range), dtype=bool)
|
|
719
|
+
for b in bad:
|
|
720
|
+
if b + 1 < len(keep):
|
|
721
|
+
keep[b + 1] = False
|
|
722
|
+
fit_range = fit_range[keep]
|
|
723
|
+
k_max = K[fit_range[-1]]
|
|
724
|
+
# fit_range may now be non-contiguous — fancy index.
|
|
725
|
+
K_inner = K[fit_range[1:]] + 1e-30
|
|
726
|
+
shear_tail = shear_spectrum[fit_range[1:]]
|
|
727
|
+
|
|
728
|
+
# Re-fit (2 more passes)
|
|
729
|
+
for _ in range(2):
|
|
730
|
+
nas = nasmyth_grid(max(e, EPSILON_FLOOR), nu, K_inner)
|
|
731
|
+
ratio = shear_tail / (nas + 1e-30)
|
|
732
|
+
ratio = ratio[ratio > 0]
|
|
733
|
+
if len(ratio) == 0:
|
|
734
|
+
break
|
|
735
|
+
fit_error = float(np.mean(np.log10(ratio)))
|
|
736
|
+
e = e * 10 ** (3 * fit_error / 2)
|
|
737
|
+
|
|
738
|
+
return e, k_max, fit_range
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
# ---------------------------------------------------------------------------
|
|
742
|
+
# QC flags and EPSI_FINAL
|
|
743
|
+
# ---------------------------------------------------------------------------
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _sigma_ln_epsilon(epsi: np.ndarray, nu: np.ndarray, L: np.ndarray) -> np.ndarray:
|
|
747
|
+
"""Expected sigma of ln(epsilon) per probe/window (Lueck 2022a).
|
|
748
|
+
|
|
749
|
+
var(ln eps) = 5.5 / (1 + (L_hat/4)^(7/9)) with L_hat = L/L_K and
|
|
750
|
+
L_K = (nu^3/eps)^(1/4) — the same model used by mk_epsilon_mean.
|
|
751
|
+
|
|
752
|
+
Parameters
|
|
753
|
+
----------
|
|
754
|
+
epsi : (n_shear, n_spec)
|
|
755
|
+
nu : (n_spec,)
|
|
756
|
+
Kinematic viscosity per window [m^2/s].
|
|
757
|
+
L : (n_spec,)
|
|
758
|
+
Physical dissipation-window length [m] (duration * speed).
|
|
759
|
+
"""
|
|
760
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
761
|
+
L_K = (nu[np.newaxis, :] ** 3 / epsi) ** 0.25
|
|
762
|
+
L_hat = L[np.newaxis, :] / L_K
|
|
763
|
+
var_ln = 5.5 / (1.0 + (L_hat / 4.0) ** (7.0 / 9.0))
|
|
764
|
+
return np.asarray(np.sqrt(var_ln))
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _compute_flags(
|
|
768
|
+
epsi: np.ndarray,
|
|
769
|
+
fom: np.ndarray,
|
|
770
|
+
var_resolved: np.ndarray,
|
|
771
|
+
fom_limit: float,
|
|
772
|
+
var_resolved_limit: float,
|
|
773
|
+
despike_fraction: np.ndarray | None = None,
|
|
774
|
+
despike_passes: np.ndarray | None = None,
|
|
775
|
+
despike_fraction_limit: float = DEFAULT_DESPIKE_FRACTION_LIMIT,
|
|
776
|
+
despike_passes_limit: int = DEFAULT_DESPIKE_PASSES_LIMIT,
|
|
777
|
+
sigma_ln: np.ndarray | None = None,
|
|
778
|
+
diss_ratio_limit: float = DEFAULT_DISS_RATIO_LIMIT,
|
|
779
|
+
method: np.ndarray | None = None,
|
|
780
|
+
) -> np.ndarray:
|
|
781
|
+
"""Compute ATOMIX QC flags (conventions: shear probes group).
|
|
782
|
+
|
|
783
|
+
Bit flags (matching the benchmark EPSI_FLAGS flag_meanings):
|
|
784
|
+
0 = good
|
|
785
|
+
1 = FOM > limit
|
|
786
|
+
2 = despike fraction > limit
|
|
787
|
+
4 = inter-probe consistency: ln(e_i/e_min) > limit * mean(sigma_ln)
|
|
788
|
+
— only the larger (suspect) estimates are flagged, never the
|
|
789
|
+
window minimum, matching the benchmark convention
|
|
790
|
+
8 = too many despike passes
|
|
791
|
+
16 = variance resolved < limit — applied only to variance-method
|
|
792
|
+
estimates (method 0): the ISR fit never integrates the
|
|
793
|
+
dissipation range, so the criterion is not applicable there
|
|
794
|
+
(matching the benchmark, which leaves ISR estimates with
|
|
795
|
+
var_resolved ~ 0.1 unflagged)
|
|
796
|
+
255 = invalid estimate
|
|
797
|
+
"""
|
|
798
|
+
flags = np.zeros_like(epsi, dtype=np.float64)
|
|
799
|
+
flags[fom > fom_limit] += 1
|
|
800
|
+
|
|
801
|
+
if despike_fraction is not None and despike_fraction.shape == epsi.shape:
|
|
802
|
+
with np.errstate(invalid="ignore"):
|
|
803
|
+
flags[despike_fraction > despike_fraction_limit] += 2
|
|
804
|
+
|
|
805
|
+
if sigma_ln is not None and epsi.shape[0] > 1:
|
|
806
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
807
|
+
epsi_pos = np.where(np.isfinite(epsi) & (epsi > 0), epsi, np.nan)
|
|
808
|
+
e_min = np.nanmin(epsi_pos, axis=0) # (n_spec,)
|
|
809
|
+
ln_ratio = np.log(epsi_pos / e_min[np.newaxis, :])
|
|
810
|
+
mu_sigma = np.nanmean(np.where(np.isfinite(epsi_pos), sigma_ln, np.nan), axis=0)
|
|
811
|
+
inconsistent = ln_ratio > diss_ratio_limit * mu_sigma[np.newaxis, :]
|
|
812
|
+
flags[inconsistent] += 4
|
|
813
|
+
|
|
814
|
+
if despike_passes is not None:
|
|
815
|
+
passes = np.asarray(despike_passes)
|
|
816
|
+
if passes.ndim == 1 and len(passes) == epsi.shape[0]:
|
|
817
|
+
# Per-channel counts: broadcast across all windows
|
|
818
|
+
flags[passes > despike_passes_limit, :] += 8
|
|
819
|
+
elif passes.shape == epsi.shape:
|
|
820
|
+
# Per-window counts (each window's section pass count —
|
|
821
|
+
# the benchmark PASS_COUNT_SH(probe, section) semantics)
|
|
822
|
+
flags[passes > despike_passes_limit] += 8
|
|
823
|
+
|
|
824
|
+
under_resolved = var_resolved < var_resolved_limit
|
|
825
|
+
if method is not None:
|
|
826
|
+
under_resolved &= method == 0
|
|
827
|
+
flags[under_resolved] += 16
|
|
828
|
+
flags[~np.isfinite(epsi)] = 255
|
|
829
|
+
return flags
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _compute_epsi_final(epsi: np.ndarray, flags: np.ndarray) -> np.ndarray:
|
|
833
|
+
"""Compute EPSI_FINAL: geometric mean of the probes with ``flags == 0``.
|
|
834
|
+
|
|
835
|
+
A window in which *no* probe is clean (all flagged) returns ``NaN`` — it is
|
|
836
|
+
dropped, not silently backfilled. Averaging QC-failed probes would emit a
|
|
837
|
+
value indistinguishable from a clean estimate while contradicting Lueck et
|
|
838
|
+
al. 2024 §3.4.5 (average over Q=0 estimates only). NaN is also the safer
|
|
839
|
+
output for the Method-1 chi coupling: a finite-but-wrong epsilon rescales
|
|
840
|
+
chi ~linearly while the chi FOM stays ≈1 (so the FOM cut cannot reject it),
|
|
841
|
+
whereas NaN self-excludes at the ``l4_chi`` finiteness guard. (#104 U2-F2.)
|
|
842
|
+
"""
|
|
843
|
+
_n_shear, n_spec = epsi.shape
|
|
844
|
+
epsi_final = np.full(n_spec, np.nan)
|
|
845
|
+
for j in range(n_spec):
|
|
846
|
+
good = (flags[:, j] == 0) & np.isfinite(epsi[:, j]) & (epsi[:, j] > 0)
|
|
847
|
+
if good.any():
|
|
848
|
+
epsi_final[j] = np.exp(np.mean(np.log(epsi[good, j])))
|
|
849
|
+
# else: no clean probe -> leave NaN (all-failed windows drop out).
|
|
850
|
+
return epsi_final
|