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