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.
Files changed (119) hide show
  1. microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
  2. microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
  3. microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
  4. microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
  5. microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
  6. microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
  7. odas_tpw/__init__.py +18 -0
  8. odas_tpw/chi/__init__.py +48 -0
  9. odas_tpw/chi/batchelor.py +189 -0
  10. odas_tpw/chi/chi.py +857 -0
  11. odas_tpw/chi/fp07.py +649 -0
  12. odas_tpw/chi/l2_chi.py +153 -0
  13. odas_tpw/chi/l3_chi.py +388 -0
  14. odas_tpw/chi/l4_chi.py +391 -0
  15. odas_tpw/config_base.py +423 -0
  16. odas_tpw/perturb/__init__.py +18 -0
  17. odas_tpw/perturb/adcp.py +274 -0
  18. odas_tpw/perturb/atomic_io.py +47 -0
  19. odas_tpw/perturb/binning.py +761 -0
  20. odas_tpw/perturb/cli.py +591 -0
  21. odas_tpw/perturb/combo.py +346 -0
  22. odas_tpw/perturb/config.py +1046 -0
  23. odas_tpw/perturb/ctd.py +424 -0
  24. odas_tpw/perturb/diag/__init__.py +12 -0
  25. odas_tpw/perturb/diag/_common.py +140 -0
  26. odas_tpw/perturb/diag/chi.py +42 -0
  27. odas_tpw/perturb/diag/cli.py +67 -0
  28. odas_tpw/perturb/diag/data.py +662 -0
  29. odas_tpw/perturb/diag/epsilon.py +45 -0
  30. odas_tpw/perturb/diag/inspector.py +342 -0
  31. odas_tpw/perturb/diag/mixing.py +94 -0
  32. odas_tpw/perturb/diag/render.py +543 -0
  33. odas_tpw/perturb/discover.py +40 -0
  34. odas_tpw/perturb/fp07_cal.py +354 -0
  35. odas_tpw/perturb/gps.py +458 -0
  36. odas_tpw/perturb/hotel.py +572 -0
  37. odas_tpw/perturb/logging_setup.py +181 -0
  38. odas_tpw/perturb/merge.py +320 -0
  39. odas_tpw/perturb/mk_sections.py +310 -0
  40. odas_tpw/perturb/netcdf_schema.py +520 -0
  41. odas_tpw/perturb/pipeline.py +2654 -0
  42. odas_tpw/perturb/plot/__init__.py +15 -0
  43. odas_tpw/perturb/plot/cli.py +107 -0
  44. odas_tpw/perturb/plot/diagnostics.py +195 -0
  45. odas_tpw/perturb/plot/eps_chi.py +524 -0
  46. odas_tpw/perturb/plot/figure.py +679 -0
  47. odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
  48. odas_tpw/perturb/plot/grid.py +112 -0
  49. odas_tpw/perturb/plot/layout.py +361 -0
  50. odas_tpw/perturb/plot/overview.py +547 -0
  51. odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
  52. odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
  53. odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
  54. odas_tpw/perturb/plot/profiles.py +652 -0
  55. odas_tpw/perturb/plot/scalar.py +391 -0
  56. odas_tpw/perturb/plot/sections.py +506 -0
  57. odas_tpw/perturb/plot/xaxis.py +396 -0
  58. odas_tpw/perturb/qc_gate.py +177 -0
  59. odas_tpw/perturb/qc_rules.py +373 -0
  60. odas_tpw/perturb/resolve.py +306 -0
  61. odas_tpw/perturb/seawater.py +76 -0
  62. odas_tpw/perturb/speed.py +14 -0
  63. odas_tpw/perturb/trim.py +313 -0
  64. odas_tpw/processing/__init__.py +62 -0
  65. odas_tpw/processing/bottom.py +215 -0
  66. odas_tpw/processing/chi_combine.py +300 -0
  67. odas_tpw/processing/ct_align.py +172 -0
  68. odas_tpw/processing/epsilon_combine.py +210 -0
  69. odas_tpw/processing/mixing.py +630 -0
  70. odas_tpw/processing/thorpe.py +458 -0
  71. odas_tpw/processing/top_trim.py +274 -0
  72. odas_tpw/pyturb/__init__.py +3 -0
  73. odas_tpw/pyturb/__main__.py +5 -0
  74. odas_tpw/pyturb/_compat.py +204 -0
  75. odas_tpw/pyturb/_profind.py +124 -0
  76. odas_tpw/pyturb/bin.py +113 -0
  77. odas_tpw/pyturb/cli.py +175 -0
  78. odas_tpw/pyturb/eps.py +353 -0
  79. odas_tpw/pyturb/merge.py +101 -0
  80. odas_tpw/pyturb/p2nc.py +76 -0
  81. odas_tpw/rsi/__init__.py +31 -0
  82. odas_tpw/rsi/adapter.py +314 -0
  83. odas_tpw/rsi/bench.py +904 -0
  84. odas_tpw/rsi/binning.py +193 -0
  85. odas_tpw/rsi/channels.py +376 -0
  86. odas_tpw/rsi/chi_io.py +980 -0
  87. odas_tpw/rsi/cli.py +1411 -0
  88. odas_tpw/rsi/combine.py +120 -0
  89. odas_tpw/rsi/config.py +126 -0
  90. odas_tpw/rsi/config_patch.py +790 -0
  91. odas_tpw/rsi/convert.py +651 -0
  92. odas_tpw/rsi/deconvolve.py +119 -0
  93. odas_tpw/rsi/diss_look.py +343 -0
  94. odas_tpw/rsi/dissipation.py +669 -0
  95. odas_tpw/rsi/helpers.py +527 -0
  96. odas_tpw/rsi/mixing_look.py +509 -0
  97. odas_tpw/rsi/p_file.py +771 -0
  98. odas_tpw/rsi/pipeline.py +924 -0
  99. odas_tpw/rsi/profile.py +624 -0
  100. odas_tpw/rsi/quick_look.py +758 -0
  101. odas_tpw/rsi/sensor_inventory.py +684 -0
  102. odas_tpw/rsi/shear_noise.py +128 -0
  103. odas_tpw/rsi/speed.py +221 -0
  104. odas_tpw/rsi/vehicle.py +74 -0
  105. odas_tpw/rsi/viewer_base.py +1071 -0
  106. odas_tpw/rsi/window.py +459 -0
  107. odas_tpw/scor160/__init__.py +36 -0
  108. odas_tpw/scor160/cli.py +242 -0
  109. odas_tpw/scor160/compare.py +484 -0
  110. odas_tpw/scor160/despike.py +227 -0
  111. odas_tpw/scor160/goodman.py +288 -0
  112. odas_tpw/scor160/io.py +690 -0
  113. odas_tpw/scor160/l2.py +311 -0
  114. odas_tpw/scor160/l3.py +279 -0
  115. odas_tpw/scor160/l4.py +850 -0
  116. odas_tpw/scor160/nasmyth.py +208 -0
  117. odas_tpw/scor160/ocean.py +287 -0
  118. odas_tpw/scor160/profile.py +231 -0
  119. 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