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/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