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/rsi/chi_io.py ADDED
@@ -0,0 +1,980 @@
1
+ # Mar-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """Convenience wrappers for chi computation from PFile / NetCDF sources.
3
+
4
+ These functions bridge rsi instrument I/O with the pure chi
5
+ computation in chi.chi. They handle data loading, profile
6
+ detection, and file-level orchestration.
7
+
8
+ .. deprecated::
9
+ ``get_chi`` is deprecated. Use :func:`odas_tpw.rsi.pipeline.run_pipeline`
10
+ or the modular ``process_l2_chi`` → ``process_l3_chi`` → ``process_l4_chi_*``
11
+ chain instead.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import functools
17
+ import re
18
+ import warnings
19
+ from datetime import UTC, datetime
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ import numpy as np
24
+ import numpy.typing as npt
25
+ import xarray as xr
26
+
27
+ if TYPE_CHECKING:
28
+ from odas_tpw.rsi.p_file import PFile
29
+
30
+ # Max acceptable epsilon FM (Lueck 2022 MAD-based reject statistic; a good fit
31
+ # sits near ~0.7-0.8, NOT 0, ATOMIX rejects FM > ~1.15) for including a probe in the Method-1
32
+ # mean. Named for FM, not the variance-ratio `fom` (centered on 1.0) it gates
33
+ # only as a fallback when FM is absent. (matches compute_chi_window's default.)
34
+ _EPS_FM_LIMIT = 1.15
35
+
36
+
37
+ def _compute_chi(
38
+ source: PFile | str | Path,
39
+ epsilon_ds: xr.Dataset | None = None,
40
+ fft_length: int = 1024,
41
+ diss_length: int | None = None,
42
+ overlap: int | None = None,
43
+ speed: float | None = None,
44
+ direction: str = "auto",
45
+ fp07_model: str = "single_pole",
46
+ goodman: bool = True,
47
+ f_AA: float = 98.0,
48
+ fit_method: str = "iterative",
49
+ spectrum_model: str = "kraichnan",
50
+ salinity: npt.ArrayLike | None = None,
51
+ vehicle: str | None = None,
52
+ _pre_loaded: dict[str, Any] | None = None,
53
+ ) -> list[xr.Dataset]:
54
+ """Compute chi from temperature gradient spectra (internal, no deprecation warning).
55
+
56
+ ``_pre_loaded`` is a private hook accepting the dict produced by
57
+ :func:`_load_therm_channels` (i.e. channels + ``therm`` + ``diff_gains`` +
58
+ ``therm_cal``) so callers like ``perturb.pipeline`` can avoid the
59
+ redundant NC reads when diss and chi share the same source.
60
+ """
61
+ from odas_tpw.chi.chi import _spectrum_func
62
+ from odas_tpw.chi.l2_chi import process_l2_chi
63
+ from odas_tpw.chi.l3_chi import process_l3_chi
64
+ from odas_tpw.chi.l4_chi import process_l4_chi_epsilon, process_l4_chi_fit
65
+ from odas_tpw.rsi.helpers import _build_l1data_from_channels, prepare_profiles
66
+ from odas_tpw.scor160.io import L2Params, L3Params
67
+ from odas_tpw.scor160.l2 import process_l2
68
+
69
+ if diss_length is None:
70
+ diss_length = 4 * fft_length
71
+ if overlap is None:
72
+ overlap = diss_length // 2
73
+
74
+ if isinstance(salinity, str):
75
+ # The "measured" sentinel is resolved upstream (perturb.pipeline reads
76
+ # the profile's conductivity); _compute_chi only accepts a concrete
77
+ # value here, so fail clearly rather than crash in np.asarray(...).
78
+ raise ValueError(
79
+ f"salinity={salinity!r} is not resolved at this layer; pass a "
80
+ "number, an array, or None. The 'measured' option is resolved by "
81
+ "the perturb pipeline from the profile's C/T/P."
82
+ )
83
+
84
+ # Load channels including thermistor data
85
+ data = _pre_loaded if _pre_loaded is not None else _load_therm_channels(source)
86
+
87
+ therm_names = [t[0] for t in data["therm"]]
88
+ n_therm = len(therm_names)
89
+ diff_gains = data.get("diff_gains", [0.94] * n_therm)
90
+ therm_cal = data.get("therm_cal", [{}] * n_therm)
91
+
92
+ if n_therm == 0:
93
+ raise ValueError("No thermistor gradient channels found")
94
+
95
+ fs_fast = data["fs_fast"]
96
+ if vehicle is None:
97
+ vehicle = data.get("vehicle", "")
98
+
99
+ # Resolve "auto" to a concrete up/down so it doesn't leak into
100
+ # L1Data.profile_dir (prepare_profiles resolves internally but doesn't
101
+ # return it). Mirrors adapter.py / _compute_epsilon.
102
+ from odas_tpw.rsi.vehicle import resolve_direction
103
+
104
+ direction = resolve_direction(direction, vehicle)
105
+ prepared = prepare_profiles(data, speed, direction, salinity, vehicle=vehicle)
106
+ if prepared is None:
107
+ return []
108
+ (profiles_slow, speed_fast, P_fast, T_fast, sal_fast, fs_fast, _fs_slow, ratio, t_fast) = (
109
+ prepared
110
+ )
111
+
112
+ # Pipeline parameters
113
+ l2_params = L2Params(
114
+ HP_cut=0.25,
115
+ despike_sh=np.array([np.inf, 0.5, 0.04]), # don't despike shear for chi
116
+ despike_A=np.array([np.inf, 0.5, 0.04]),
117
+ profile_min_W=0.05,
118
+ profile_min_P=0.0,
119
+ profile_min_duration=0.0,
120
+ speed_tau=0.0,
121
+ )
122
+ l3_params = L3Params(
123
+ fft_length=fft_length,
124
+ diss_length=diss_length,
125
+ overlap=overlap,
126
+ HP_cut=0.25,
127
+ fs_fast=fs_fast,
128
+ goodman=goodman,
129
+ )
130
+
131
+ grad_func, _ = _spectrum_func(spectrum_model)
132
+
133
+ results = []
134
+ for s_slow, e_slow in profiles_slow:
135
+ s_fast = s_slow * ratio
136
+ e_fast = min((e_slow + 1) * ratio, len(t_fast))
137
+
138
+ sal_prof = sal_fast[s_fast:e_fast] if isinstance(sal_fast, np.ndarray) else sal_fast
139
+
140
+ # Build L1Data with temp_fast
141
+ l1 = _build_l1data_from_channels(
142
+ data,
143
+ s_fast,
144
+ e_fast,
145
+ speed_fast,
146
+ P_fast,
147
+ T_fast,
148
+ direction,
149
+ therm_list=data["therm"],
150
+ diff_gains=diff_gains,
151
+ )
152
+
153
+ # L2: section selection and speed
154
+ l2 = process_l2(l1, l2_params)
155
+
156
+ # L2_chi: despike temperature, HP-filter vib, compute gradient
157
+ l2_chi = process_l2_chi(l1, l2)
158
+
159
+ # L3_chi: temperature gradient spectra
160
+ l3_chi = process_l3_chi(
161
+ l2_chi,
162
+ l3_params,
163
+ fp07_model=fp07_model,
164
+ salinity=sal_prof,
165
+ therm_cal=therm_cal,
166
+ )
167
+
168
+ if l3_chi.n_spectra == 0:
169
+ continue
170
+
171
+ # L4_chi: chi estimation
172
+ if epsilon_ds is not None:
173
+ l4_diss = _epsilon_ds_to_l4data(epsilon_ds)
174
+ l4_chi = process_l4_chi_epsilon(
175
+ l3_chi,
176
+ l4_diss,
177
+ spectrum_model=spectrum_model,
178
+ f_AA=f_AA,
179
+ )
180
+ else:
181
+ l4_chi = process_l4_chi_fit(
182
+ l3_chi,
183
+ spectrum_model=spectrum_model,
184
+ fit_method=fit_method,
185
+ f_AA=f_AA,
186
+ )
187
+
188
+ # Build output dataset
189
+ ds = _build_chi_ds_from_pipeline(
190
+ l3_chi,
191
+ l4_chi,
192
+ therm_names,
193
+ fft_length=fft_length,
194
+ diss_length=diss_length,
195
+ overlap=overlap,
196
+ fs_fast=fs_fast,
197
+ fp07_model=fp07_model,
198
+ spectrum_model=spectrum_model,
199
+ fit_method=fit_method,
200
+ f_AA=f_AA,
201
+ grad_func=grad_func,
202
+ chi_method=l4_chi.method,
203
+ )
204
+ ds.attrs.update(data["metadata"])
205
+ ds.attrs["history"] = f"Computed with microstructure-tpw on {datetime.now(UTC).isoformat()}"
206
+ start_time = data["metadata"].get("start_time", "")
207
+ t_units = f"seconds since {start_time}" if start_time else "seconds"
208
+ ds.coords["t"].attrs.update(
209
+ {
210
+ "standard_name": "time",
211
+ "long_name": "time of chi estimate",
212
+ "units": t_units,
213
+ "calendar": "standard",
214
+ "axis": "T",
215
+ }
216
+ )
217
+ results.append(ds)
218
+
219
+ return results
220
+
221
+
222
+ def get_chi(
223
+ source: PFile | str | Path,
224
+ epsilon_ds: xr.Dataset | None = None,
225
+ fft_length: int = 1024,
226
+ diss_length: int | None = None,
227
+ overlap: int | None = None,
228
+ speed: float | None = None,
229
+ direction: str = "down",
230
+ fp07_model: str = "single_pole",
231
+ goodman: bool = True,
232
+ f_AA: float = 98.0,
233
+ fit_method: str = "iterative",
234
+ spectrum_model: str = "kraichnan",
235
+ salinity: npt.ArrayLike | None = None,
236
+ ) -> list[xr.Dataset]:
237
+ """Compute chi from temperature gradient spectra.
238
+
239
+ .. deprecated::
240
+ Use :func:`odas_tpw.rsi.pipeline.run_pipeline` or the modular
241
+ ``process_l2_chi`` → ``process_l3_chi`` → ``process_l4_chi_*``
242
+ chain instead.
243
+
244
+ Parameters
245
+ ----------
246
+ source : PFile, str, or Path
247
+ Input data (same multi-source support as get_diss).
248
+ epsilon_ds : xarray.Dataset or None
249
+ If provided: Method 1 — use known epsilon from shear probes.
250
+ If None: Method 2 — fit Batchelor spectrum for kB.
251
+ fft_length : int
252
+ FFT segment length [samples], default 1024.
253
+ diss_length : int or None
254
+ Dissipation window length [samples], default 4 * fft_length.
255
+ overlap : int or None
256
+ Window overlap [samples], default diss_length // 2.
257
+ speed : float or None
258
+ Fixed profiling speed [m/s]. If None, from dP/dt.
259
+ direction : str
260
+ 'up' or 'down' for speed sign convention.
261
+ fp07_model : str
262
+ 'single_pole' or 'double_pole' for FP07 transfer function.
263
+ goodman : bool
264
+ Apply Goodman coherent noise removal using accelerometers (default True).
265
+ f_AA : float
266
+ Anti-aliasing filter cutoff [Hz].
267
+ fit_method : str
268
+ Method 2 only: 'mle' (Ruddick et al. 2000) or 'iterative'
269
+ (Peterson & Fer 2014).
270
+ spectrum_model : str
271
+ 'batchelor' or 'kraichnan'.
272
+ salinity : float or array_like or None
273
+ Practical salinity [PSU]. If provided, uses gsw-based viscosity
274
+ instead of visc35. Scalar or array matching slow time series.
275
+
276
+ Returns
277
+ -------
278
+ list of xarray.Dataset, one per profile, with variables:
279
+ chi (probe, time) — thermal dissipation rate [K^2/s]
280
+ epsilon_T (probe, time) — epsilon from T (Method 2) or input epsilon
281
+ kB (probe, time) — Batchelor wavenumber [cpm]
282
+ K_max_T (probe, time) — integration limit [cpm]
283
+ fom (probe, time) — figure of merit (obs/model variance)
284
+ K_max_ratio (probe, time) — K_max / kB (spectral resolution)
285
+ var_resolved (probe, time) — Batchelor gradient-variance resolved
286
+ fraction (chi-side V_f; feeds chiLnSigma)
287
+ speed (time) — profiling speed [m/s]
288
+ nu (time) — kinematic viscosity [m^2/s]
289
+ T_mean (time) — mean temperature [deg C]
290
+ P_mean (time) — mean pressure [dbar]
291
+ spec_gradT (probe, freq, time) — temperature gradient spectra
292
+ spec_batch (probe, freq, time) — fitted Batchelor spectra
293
+ spec_noise (probe, freq, time) — noise spectra (per-probe)
294
+ K (freq, time) — wavenumber vectors
295
+ """
296
+ warnings.warn(
297
+ "get_chi() is deprecated. Use run_pipeline() or the modular "
298
+ "process_l2_chi → process_l3_chi → process_l4_chi_* chain "
299
+ "instead. See odas_tpw.rsi.pipeline.run_pipeline().",
300
+ DeprecationWarning,
301
+ stacklevel=2,
302
+ )
303
+ return _compute_chi(
304
+ source,
305
+ epsilon_ds=epsilon_ds,
306
+ fft_length=fft_length,
307
+ diss_length=diss_length,
308
+ overlap=overlap,
309
+ speed=speed,
310
+ direction=direction,
311
+ fp07_model=fp07_model,
312
+ goodman=goodman,
313
+ f_AA=f_AA,
314
+ fit_method=fit_method,
315
+ spectrum_model=spectrum_model,
316
+ salinity=salinity,
317
+ )
318
+
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # Pipeline-to-Dataset helpers
322
+ # ---------------------------------------------------------------------------
323
+
324
+
325
+ def _epsilon_ds_to_l4data(epsilon_ds: xr.Dataset) -> Any:
326
+ """Convert old-format epsilon xr.Dataset to L4Data for chi computation.
327
+
328
+ If the dataset already carries ``epsilonMean`` (written by
329
+ ``processing.epsilon_combine.mk_epsilon_mean``), that QC-filtered geometric
330
+ mean is used as ``epsi_final``. Otherwise we fall back to a plain
331
+ ``nanmean`` across the per-probe ``epsilon`` array — the historical
332
+ behavior, retained for inputs produced by the lower-level rsi pipeline
333
+ that does not run the multi-probe combine step.
334
+
335
+ Time reference: ``L3ChiData.time`` is in "seconds since start_time"
336
+ (small floats). When the diss dataset is read back from NetCDF, xarray
337
+ decodes its ``t`` coord to absolute ``datetime64``. We convert it back
338
+ to seconds-since-``start_time`` here so ``process_l4_chi_epsilon``'s
339
+ nearest-neighbor matching is over a common reference — otherwise every
340
+ chi window collapses onto ``epsilonMean[0]``.
341
+ """
342
+ from odas_tpw.scor160.io import L4Data
343
+
344
+ eps_vals = epsilon_ds["epsilon"].values
345
+ if eps_vals.ndim == 2:
346
+ n_sh = eps_vals.shape[0]
347
+ else:
348
+ n_sh = 1
349
+ eps_vals = eps_vals[np.newaxis]
350
+
351
+ def _probe_var(name: str) -> np.ndarray | None:
352
+ """Read a per-probe (n_sh, n) variable from the epsilon dataset."""
353
+ if name not in epsilon_ds:
354
+ return None
355
+ a = np.asarray(epsilon_ds[name].values, dtype=np.float64)
356
+ return a[np.newaxis] if a.ndim == 1 else a
357
+
358
+ # Probe-rejection filters on the Lueck (2022) MAD-based FM statistic (good
359
+ # a good fit sits near ~0.7-0.8 not 0; ATOMIX rejects FM > ~1.15), NOT the variance-ratio `fom`
360
+ # (centered on 1.0, 0.7-1.4 is a GOOD fit). Thresholding `fom` at 1.15 wrongly
361
+ # drops good probes. Fall back to `fom` only when FM is absent (older files),
362
+ # mirroring l4.py's fom_for_flags.
363
+ fom_arr = _probe_var("FM")
364
+ if fom_arr is None:
365
+ fom_arr = _probe_var("fom")
366
+ mad_arr = _probe_var("mad")
367
+
368
+ if "epsilonMean" in epsilon_ds:
369
+ epsi_final = np.asarray(epsilon_ds["epsilonMean"].values, dtype=np.float64)
370
+ elif n_sh > 1:
371
+ # Exclude bad-probe estimates (FM > limit) before averaging across
372
+ # probes, mirroring compute_chi_window's Method-1 filter: a high-FM
373
+ # shear probe must not bias the epsilon that drives every thermistor's
374
+ # chi. Where a window has no good probe, fall back to the all-probe mean.
375
+ eps_for_mean = eps_vals.astype(np.float64, copy=True)
376
+ if fom_arr is not None and fom_arr.shape == eps_for_mean.shape:
377
+ bad = ~np.isfinite(fom_arr) | (fom_arr > _EPS_FM_LIMIT)
378
+ eps_for_mean = np.where(bad, np.nan, eps_for_mean)
379
+ # catch_warnings (not just errstate): an all-NaN window makes nanmean
380
+ # emit a "Mean of empty slice" RuntimeWarning via warnings.warn, which
381
+ # np.errstate does not suppress. Legitimate dropout windows must not
382
+ # produce a warning storm (#9).
383
+ with np.errstate(invalid="ignore"), warnings.catch_warnings():
384
+ warnings.simplefilter("ignore", RuntimeWarning)
385
+ epsi_final = np.nanmean(eps_for_mean, axis=0)
386
+ allnan = ~np.isfinite(epsi_final)
387
+ if np.any(allnan):
388
+ fallback = np.nanmean(eps_vals, axis=0)
389
+ epsi_final = epsi_final.copy()
390
+ epsi_final[allnan] = fallback[allnan]
391
+ else:
392
+ epsi_final = eps_vals[0]
393
+
394
+ times = epsilon_ds.coords["t"].values
395
+ if np.issubdtype(times.dtype, np.datetime64):
396
+ start_str = epsilon_ds.attrs.get("start_time")
397
+ if start_str:
398
+ # HONOR the timezone offset (decode_cf did when it decoded `t`), then
399
+ # normalize to naive UTC — do NOT strip the offset and reinterpret the
400
+ # local wall-clock as the reference, which shifts the epsilon time base
401
+ # by the offset and silently NaNs all chi for a non-UTC instrument.
402
+ _dt = datetime.fromisoformat(start_str)
403
+ if _dt.tzinfo is not None:
404
+ _dt = _dt.astimezone(UTC).replace(tzinfo=None)
405
+ start = np.datetime64(_dt)
406
+ else:
407
+ start = times[0]
408
+ times = ((times - start) / np.timedelta64(1, "ns")).astype(np.float64) / 1e9
409
+ n = len(times)
410
+
411
+ return L4Data(
412
+ time=times,
413
+ pres=epsilon_ds["P_mean"].values if "P_mean" in epsilon_ds else np.zeros(n),
414
+ pspd_rel=epsilon_ds["speed"].values if "speed" in epsilon_ds else np.zeros(n),
415
+ section_number=np.ones(n),
416
+ epsi=eps_vals,
417
+ epsi_final=epsi_final,
418
+ epsi_flags=np.zeros((n_sh, n)),
419
+ fom=fom_arr if fom_arr is not None and fom_arr.shape == (n_sh, n)
420
+ else np.zeros((n_sh, n)),
421
+ mad=mad_arr if mad_arr is not None and mad_arr.shape == (n_sh, n)
422
+ else np.zeros((n_sh, n)),
423
+ kmax=np.zeros((n_sh, n)),
424
+ method=np.zeros((n_sh, n)),
425
+ var_resolved=np.zeros((n_sh, n)),
426
+ )
427
+
428
+
429
+ def _reconstruct_spec_batch(l3_chi, l4_chi, grad_func) -> np.ndarray:
430
+ """``(n_gradt, n_wavenumber, n_spec)`` fitted gradient spectra for the
431
+ perturb-diag overlay.
432
+
433
+ Bind the per-window molecular thermal diffusivity ``kappa_T`` (mirroring the
434
+ amplitude/fit at ``chi.py:258``) so the overlay matches the stored chi. The
435
+ bare ``grad_func`` would fall back to the module default ``KAPPA_T=1.4e-7``,
436
+ and since the gradient amplitude scales as ``1/kappa_T`` the overlay would
437
+ run ~6% high in warm water where the per-window kappa exceeds the default.
438
+ Falls back to the bare ``grad_func`` only when ``kappa_T`` is not per-window
439
+ sized (e.g. the degenerate no-spectra path). Diagnostic-only — published
440
+ chi/kB/K_T/Gamma come from ``l4_chi`` (already computed with per-window
441
+ kappa), not this reconstruction. (#104 I2a-4.)
442
+ """
443
+ n_spec = l3_chi.n_spectra
444
+ n_gradt = l3_chi.n_gradt
445
+ n_wave = l3_chi.n_wavenumber
446
+ spec_batch = np.full((n_gradt, n_wave, n_spec), np.nan)
447
+ kappa_per_window = l3_chi.kappa_T.size == n_spec
448
+ for j in range(n_spec):
449
+ gf = (
450
+ functools.partial(grad_func, kappa_T=float(l3_chi.kappa_T[j]))
451
+ if kappa_per_window
452
+ else grad_func
453
+ )
454
+ for ci in range(n_gradt):
455
+ kB = l4_chi.kB[ci, j]
456
+ chi_val = l4_chi.chi[ci, j]
457
+ if np.isfinite(kB) and np.isfinite(chi_val):
458
+ spec_batch[ci, :, j] = gf(l3_chi.kcyc[:, j], kB, chi_val)
459
+ return spec_batch
460
+
461
+
462
+ def _build_chi_ds_from_pipeline(
463
+ l3_chi,
464
+ l4_chi,
465
+ therm_names: list[str],
466
+ *,
467
+ fft_length: int,
468
+ diss_length: int,
469
+ overlap: int,
470
+ fs_fast: float,
471
+ fp07_model: str,
472
+ spectrum_model: str,
473
+ fit_method: str,
474
+ f_AA: float,
475
+ grad_func,
476
+ chi_method: str = "epsilon",
477
+ ) -> xr.Dataset:
478
+ """Build old-format xr.Dataset from L3ChiData + L4ChiData."""
479
+ n_spec = l3_chi.n_spectra
480
+ n_freq = l3_chi.n_wavenumber
481
+
482
+ # DOF
483
+ num_ffts = 2 * (diss_length // fft_length) - 1
484
+ dof_spec = 1.9 * num_ffts
485
+
486
+ # Reconstruct fitted gradient spectra for the diagnostic overlay.
487
+ spec_batch = _reconstruct_spec_batch(l3_chi, l4_chi, grad_func)
488
+
489
+ # F vector (constant across windows) — broadcast view is read-only
490
+ # but suffices for xarray storage and NetCDF serialization.
491
+ F_const = l3_chi.freq
492
+ F_out = np.broadcast_to(F_const[:, np.newaxis], (n_freq, n_spec))
493
+
494
+ return _build_chi_dataset(
495
+ chi_out=l4_chi.chi,
496
+ eps_out=l4_chi.epsilon_T,
497
+ kB_out=l4_chi.kB,
498
+ K_max_out=l4_chi.K_max,
499
+ fom_out=l4_chi.fom,
500
+ K_max_ratio_out=l4_chi.K_max_ratio,
501
+ var_resolved_out=l4_chi.var_resolved,
502
+ speed_out=l3_chi.pspd_rel,
503
+ nu_out=l3_chi.nu,
504
+ P_out=l3_chi.pres,
505
+ T_out=l3_chi.temp,
506
+ t_out=l3_chi.time,
507
+ spec_gradT=l3_chi.gradt_spec,
508
+ spec_batch=spec_batch,
509
+ spec_noise_out=l3_chi.noise_spec,
510
+ K_out=l3_chi.kcyc,
511
+ F_out=F_out,
512
+ therm_names=therm_names,
513
+ fft_length=fft_length,
514
+ diss_length=diss_length,
515
+ overlap=overlap,
516
+ fs_fast=fs_fast,
517
+ dof_spec=dof_spec,
518
+ fp07_model=fp07_model,
519
+ spectrum_model=spectrum_model,
520
+ fit_method=fit_method,
521
+ f_AA=f_AA,
522
+ chi_method=chi_method,
523
+ )
524
+
525
+
526
+ def _build_chi_dataset(
527
+ *,
528
+ chi_out: np.ndarray,
529
+ eps_out: np.ndarray,
530
+ kB_out: np.ndarray,
531
+ K_max_out: np.ndarray,
532
+ fom_out: np.ndarray,
533
+ K_max_ratio_out: np.ndarray,
534
+ var_resolved_out: np.ndarray,
535
+ speed_out: np.ndarray,
536
+ nu_out: np.ndarray,
537
+ P_out: np.ndarray,
538
+ T_out: np.ndarray,
539
+ t_out: np.ndarray,
540
+ spec_gradT: np.ndarray,
541
+ spec_batch: np.ndarray,
542
+ spec_noise_out: np.ndarray,
543
+ K_out: np.ndarray,
544
+ F_out: np.ndarray,
545
+ therm_names: list[str],
546
+ fft_length: int,
547
+ diss_length: int,
548
+ overlap: int,
549
+ fs_fast: float,
550
+ dof_spec: float,
551
+ fp07_model: str,
552
+ spectrum_model: str,
553
+ fit_method: str,
554
+ f_AA: float,
555
+ chi_method: str = "epsilon",
556
+ ) -> xr.Dataset:
557
+ """Build an xarray Dataset from chi estimation output arrays."""
558
+ from odas_tpw.rsi.helpers import _build_result_dataset
559
+
560
+ variables = [
561
+ (
562
+ "chi",
563
+ ["probe", "time"],
564
+ chi_out,
565
+ {
566
+ "units": "K2 s-1",
567
+ "long_name": "thermal variance dissipation rate",
568
+ },
569
+ ),
570
+ (
571
+ "epsilon_T",
572
+ ["probe", "time"],
573
+ eps_out,
574
+ {
575
+ "units": "W kg-1",
576
+ "long_name": "TKE dissipation rate from temperature",
577
+ },
578
+ ),
579
+ (
580
+ "kB",
581
+ ["probe", "time"],
582
+ kB_out,
583
+ {
584
+ "units": "cpm",
585
+ "long_name": "Batchelor wavenumber",
586
+ },
587
+ ),
588
+ (
589
+ "K_max_T",
590
+ ["probe", "time"],
591
+ K_max_out,
592
+ {
593
+ "units": "cpm",
594
+ "long_name": "upper wavenumber integration limit for chi",
595
+ },
596
+ ),
597
+ (
598
+ "fom",
599
+ ["probe", "time"],
600
+ fom_out,
601
+ {
602
+ "units": "1",
603
+ "long_name": "figure of merit (observed/model variance ratio)",
604
+ "comment": (
605
+ "Ratio of observed to attenuated-model variance over the fit "
606
+ "range; values near 1.0 indicate a good fit. NOT the MAD-based "
607
+ "ATOMIX/Rockland FM statistic (Lueck 2022), for which a good "
608
+ "fit sits near ~0.7-0.8 (NOT 0) and the recommended QC limit is ~1.15."
609
+ ),
610
+ },
611
+ ),
612
+ (
613
+ "K_max_ratio",
614
+ ["probe", "time"],
615
+ K_max_ratio_out,
616
+ {
617
+ "units": "1",
618
+ "long_name": "K_max / kB spectral resolution ratio",
619
+ },
620
+ ),
621
+ (
622
+ "var_resolved",
623
+ ["probe", "time"],
624
+ var_resolved_out,
625
+ {
626
+ "units": "1",
627
+ "long_name": "Batchelor gradient-spectrum variance-resolved fraction",
628
+ "comment": (
629
+ "Fraction of the model temperature-gradient (Batchelor/"
630
+ "Kraichnan) variance within the fit band [K_min, K_max], "
631
+ "relative to the full spectrum; NO FP07 |H|^2 attenuation "
632
+ "(the chi-side analog of the Nasmyth V_f, Lueck 2022 eq 17). "
633
+ "mk_chi_mean applies the eq (18) truncation "
634
+ "L_hat_f = L_hat * var_resolved**0.75 to widen chiLnSigma "
635
+ "where the spectrum is truncated at K_max. (issue #104 U4-F1.) "
636
+ "NOTE: the diss (epsilon) product also has a 'var_resolved' "
637
+ "but it is the Nasmyth SHEAR fraction; the two are distinct "
638
+ "per-product quantities and must not be cross-read (mk_chi_mean "
639
+ "reads only the chi product, mk_epsilon_mean only the diss product)."
640
+ ),
641
+ },
642
+ ),
643
+ (
644
+ "speed",
645
+ ["time"],
646
+ speed_out,
647
+ {
648
+ "units": "m s-1",
649
+ "long_name": "profiling speed",
650
+ },
651
+ ),
652
+ (
653
+ "nu",
654
+ ["time"],
655
+ nu_out,
656
+ {
657
+ "units": "m2 s-1",
658
+ "long_name": "kinematic viscosity of sea water",
659
+ },
660
+ ),
661
+ (
662
+ "P_mean",
663
+ ["time"],
664
+ P_out,
665
+ {
666
+ "units": "dbar",
667
+ "long_name": "mean sea water pressure",
668
+ "standard_name": "sea_water_pressure",
669
+ "positive": "down",
670
+ },
671
+ ),
672
+ (
673
+ "T_mean",
674
+ ["time"],
675
+ T_out,
676
+ {
677
+ "units": "degree_Celsius",
678
+ "long_name": "mean sea water temperature",
679
+ "standard_name": "sea_water_temperature",
680
+ },
681
+ ),
682
+ (
683
+ "spec_gradT",
684
+ ["probe", "freq", "time"],
685
+ spec_gradT,
686
+ {
687
+ "units": "K2 m-1",
688
+ "long_name": "temperature gradient wavenumber spectrum (observed)",
689
+ },
690
+ ),
691
+ (
692
+ "spec_batch",
693
+ ["probe", "freq", "time"],
694
+ spec_batch,
695
+ {
696
+ "units": "K2 m-1",
697
+ "long_name": "fitted Batchelor temperature gradient spectrum",
698
+ },
699
+ ),
700
+ (
701
+ "spec_noise",
702
+ ["probe", "freq", "time"],
703
+ spec_noise_out,
704
+ {
705
+ "units": "K2 m-1",
706
+ "long_name": "FP07 electronics noise spectrum",
707
+ },
708
+ ),
709
+ (
710
+ "K",
711
+ ["freq", "time"],
712
+ K_out,
713
+ {
714
+ "units": "cpm",
715
+ "long_name": "wavenumber (cycles per meter)",
716
+ },
717
+ ),
718
+ (
719
+ "F",
720
+ ["freq", "time"],
721
+ F_out,
722
+ {
723
+ "units": "Hz",
724
+ "long_name": "frequency",
725
+ },
726
+ ),
727
+ ]
728
+ return _build_result_dataset(
729
+ variables,
730
+ therm_names,
731
+ t_out,
732
+ "thermistor probe name",
733
+ {
734
+ "Conventions": "CF-1.13, ACDD-1.3",
735
+ "fft_length": fft_length,
736
+ "diss_length": diss_length,
737
+ "overlap": overlap,
738
+ "fs_fast": fs_fast,
739
+ "dof_spec": dof_spec,
740
+ "fp07_model": fp07_model,
741
+ "spectrum_model": spectrum_model,
742
+ "fit_method": fit_method,
743
+ "f_AA": f_AA,
744
+ "chi_method": chi_method,
745
+ },
746
+ )
747
+
748
+
749
+ # ---------------------------------------------------------------------------
750
+ # Channel loading helpers
751
+ # ---------------------------------------------------------------------------
752
+
753
+
754
+ def _extract_therm_cal(ch_cfg: dict[str, Any]) -> dict[str, float]:
755
+ """Extract thermistor calibration parameters from PFile channel config.
756
+
757
+ Config keys are lowercase (parse_config lowercases all keys); 'g' and
758
+ 't_0' are renamed to the 'gain'/'T_0' parameter names expected by
759
+ noise_thermchannel.
760
+ """
761
+ cal = {}
762
+ for key in ("e_b", "b", "g", "beta_1", "beta_2", "adc_fs", "adc_bits", "t_0"):
763
+ val = ch_cfg.get(key)
764
+ if val is not None:
765
+ cal[key] = float(val)
766
+ # Map config keys to noise_thermchannel parameter names
767
+ if "g" in cal:
768
+ cal["gain"] = cal.pop("g")
769
+ if "t_0" in cal:
770
+ cal["T_0"] = cal.pop("t_0")
771
+ return cal
772
+
773
+
774
+ def _load_therm_channels(source: PFile | str | Path) -> dict[str, Any]:
775
+ """Load thermistor gradient channels from any source.
776
+
777
+ Looks for channels matching T*_dT* pattern (pre-emphasized temperature),
778
+ or falls back to T1/T2 channels for first-difference gradient.
779
+ """
780
+ from odas_tpw.rsi.helpers import load_channels
781
+ from odas_tpw.rsi.p_file import PFile
782
+
783
+ # First load standard channels (convert TypedDict → plain dict for extra keys)
784
+ data: dict[str, Any] = dict(load_channels(source))
785
+
786
+ therm: list[tuple[str, np.ndarray]] = []
787
+ diff_gains: list[float] = []
788
+
789
+ if isinstance(source, PFile):
790
+ pf = source
791
+ elif Path(source).suffix.lower() == ".p":
792
+ pf = PFile(source)
793
+ else:
794
+ pf = None
795
+
796
+ therm_cal: list[dict[str, float]] = []
797
+
798
+ if pf is not None:
799
+ from odas_tpw.rsi.helpers import DT_PATTERN, T_PATTERN
800
+
801
+ for name in sorted(pf._fast_channels):
802
+ if DT_PATTERN.match(name):
803
+ therm.append((name, pf.channels[name]))
804
+ # Get diff_gain from config
805
+ ch_cfg: dict = next(
806
+ (ch for ch in pf.config["channels"] if ch.get("name") == name),
807
+ {},
808
+ )
809
+ dg = ch_cfg.get("diff_gain", "0.94")
810
+ diff_gains.append(float(dg))
811
+ # Extract calibration from base channel (T1 for T1_dT1)
812
+ m = re.match(r"^(\w+)_d\1$", name)
813
+ base_name = m.group(1) if m else name
814
+ base_cfg: dict = next(
815
+ (ch for ch in pf.config["channels"] if ch.get("name") == base_name),
816
+ {},
817
+ )
818
+ therm_cal.append(_extract_therm_cal(base_cfg))
819
+
820
+ # If no pre-emphasized channels, use T channels with first-difference
821
+ if not therm:
822
+ for name in sorted(pf._fast_channels):
823
+ if T_PATTERN.match(name):
824
+ # Compute gradient via first-difference
825
+ T_data = pf.channels[name]
826
+ therm.append((name, T_data))
827
+ diff_gains.append(0.94)
828
+ ch_cfg = next(
829
+ (ch for ch in pf.config["channels"] if ch.get("name") == name),
830
+ {},
831
+ )
832
+ therm_cal.append(_extract_therm_cal(ch_cfg))
833
+ else:
834
+ # NetCDF source — look for gradient channels or T channels
835
+ import netCDF4 as nc
836
+
837
+ from odas_tpw.rsi.helpers import DT_PATTERN, T_PATTERN
838
+
839
+ ds = nc.Dataset(str(source), "r")
840
+
841
+ for vname in sorted(ds.variables.keys()):
842
+ var = ds.variables[vname]
843
+ if var.dimensions == ("time_fast",):
844
+ arr = var[:].data.astype(np.float64)
845
+ if DT_PATTERN.match(vname):
846
+ therm.append((vname, arr))
847
+ diff_gains.append(0.94)
848
+
849
+ if not therm:
850
+ for vname in sorted(ds.variables.keys()):
851
+ var = ds.variables[vname]
852
+ if var.dimensions == ("time_fast",) and T_PATTERN.match(vname):
853
+ therm.append((vname, var[:].data.astype(np.float64)))
854
+ diff_gains.append(0.94)
855
+
856
+ ds.close()
857
+
858
+ data["therm"] = therm
859
+ data["diff_gains"] = diff_gains
860
+ data["therm_cal"] = therm_cal if therm_cal else [{}] * len(therm)
861
+ return data
862
+
863
+
864
+ # ---------------------------------------------------------------------------
865
+ # File-level processing
866
+ # ---------------------------------------------------------------------------
867
+
868
+
869
+ def compute_chi_file(
870
+ source_path: str | Path,
871
+ output_dir: str | Path,
872
+ **chi_kwargs: Any,
873
+ ) -> list[Path]:
874
+ """Compute chi for one file and write NetCDF output(s).
875
+
876
+ Parameters
877
+ ----------
878
+ source_path : str or Path
879
+ Input file.
880
+ output_dir : str or Path
881
+ Output directory.
882
+ **chi_kwargs
883
+ Keyword arguments forwarded to ``_compute_chi`` (the chi parameter
884
+ set; ``get_chi`` is the deprecated public wrapper).
885
+
886
+ Returns
887
+ -------
888
+ list of Path
889
+ Paths to output files written.
890
+ """
891
+ from odas_tpw.rsi.helpers import write_profile_results
892
+
893
+ source_path = Path(source_path)
894
+ output_dir = Path(output_dir)
895
+ results = _compute_chi(source_path, **chi_kwargs)
896
+ return write_profile_results(results, source_path, output_dir, "chi")
897
+
898
+
899
+ def load_epsilon_dataset(
900
+ source_path: str | Path, epsilon_dir: str | Path
901
+ ) -> xr.Dataset | None:
902
+ """Locate and open the epsilon dataset(s) for *source_path* (Method 1).
903
+
904
+ ``rsi-tpw eps`` writes into hash-tracked subdirectories
905
+ (``epsilon_dir/eps_00/``...) and names files per profile
906
+ (``{stem}_prof001_eps.nc``). This helper searches *epsilon_dir*
907
+ itself first, then its ``eps_*`` subdirectories (most recently
908
+ modified first), and concatenates multiple per-profile files along
909
+ ``time`` so every chi window can pair with its own profile's epsilon
910
+ estimates.
911
+
912
+ Returns None when no matching epsilon files exist (the caller falls
913
+ back to Method 2).
914
+ """
915
+ stem = Path(source_path).stem
916
+ base = Path(epsilon_dir)
917
+ candidates = [
918
+ base,
919
+ *sorted(
920
+ (d for d in base.glob("eps_*") if d.is_dir()),
921
+ key=lambda d: d.stat().st_mtime,
922
+ reverse=True,
923
+ ),
924
+ ]
925
+ for d in candidates:
926
+ files = sorted(d.glob(f"{stem}_prof*_eps.nc"))
927
+ # The single-profile (`{stem}_eps.nc`) and per-profile
928
+ # (`{stem}_prof00N_eps.nc`) naming schemes are mutually exclusive within
929
+ # one run. Only fall back to the single-profile file when NO per-profile
930
+ # file exists: concatenating both (e.g. when a re-run with different
931
+ # trim/direction left stale products of both forms in the dir) would
932
+ # double-count epsilon times and silently bias the Method-1 match.
933
+ if not files:
934
+ single = d / f"{stem}_eps.nc"
935
+ if single.exists():
936
+ files = [single]
937
+ if not files:
938
+ continue
939
+ if len(files) == 1:
940
+ return xr.open_dataset(files[0])
941
+ datasets = [xr.open_dataset(p).load() for p in files]
942
+ try:
943
+ return xr.concat(datasets, dim="time", combine_attrs="override")
944
+ finally:
945
+ for ds in datasets:
946
+ ds.close()
947
+ return None
948
+
949
+
950
+ def _compute_chi_one(args: tuple) -> tuple[str, int]:
951
+ """Worker for parallel chi computation.
952
+
953
+ args is (source_path, output_dir, kwargs) or
954
+ (source_path, output_dir, kwargs, epsilon_dir).
955
+ When epsilon_dir is provided, the worker opens the matching epsilon
956
+ file itself (xr.Datasets cannot be pickled across process boundaries).
957
+ """
958
+ if len(args) == 4:
959
+ source_path, output_dir, kwargs, epsilon_dir = args
960
+ else:
961
+ source_path, output_dir, kwargs = args
962
+ epsilon_dir = None
963
+
964
+ eps_ds = load_epsilon_dataset(source_path, epsilon_dir) if epsilon_dir else None
965
+ if eps_ds is not None:
966
+ try:
967
+ kwargs = dict(kwargs)
968
+ kwargs["epsilon_ds"] = eps_ds
969
+ paths = compute_chi_file(source_path, output_dir, **kwargs)
970
+ finally:
971
+ eps_ds.close()
972
+ else:
973
+ if epsilon_dir is not None:
974
+ print(
975
+ f" Warning: no epsilon files for {Path(source_path).stem} "
976
+ f"under {epsilon_dir} (searched eps_* subdirectories too); "
977
+ "using Method 2"
978
+ )
979
+ paths = compute_chi_file(source_path, output_dir, **kwargs)
980
+ return str(source_path), len(paths)