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
@@ -0,0 +1,924 @@
1
+ """Full VMP data processing pipeline.
2
+
3
+ .P file → L1Data → L2 → L3 (shear spectra) → L4 (epsilon)
4
+ → L3_chi (temperature gradient spectra) → L4_chi
5
+ → L5 (depth binning) → L6 (combine profiles)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import warnings
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+ import xarray as xr
17
+
18
+ from odas_tpw.chi.l2_chi import L2ChiParams, process_l2_chi
19
+ from odas_tpw.chi.l3_chi import process_l3_chi
20
+ from odas_tpw.chi.l4_chi import (
21
+ _CHI_FOM_LIMIT,
22
+ _CHI_K_MAX_RATIO_MIN,
23
+ L4ChiData,
24
+ _compute_chi_final,
25
+ process_l4_chi_epsilon,
26
+ process_l4_chi_fit,
27
+ )
28
+ from odas_tpw.rsi.adapter import pfile_to_l1data
29
+ from odas_tpw.rsi.binning import bin_by_depth
30
+ from odas_tpw.rsi.combine import combine_profiles
31
+ from odas_tpw.scor160.io import L1Data, L2Params, L3Data, L3Params, L4Data
32
+ from odas_tpw.scor160.l2 import process_l2
33
+ from odas_tpw.scor160.l3 import process_l3
34
+ from odas_tpw.scor160.l4 import process_l4
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # Canonical chi (temperature-gradient) vibration high-pass cutoff [Hz]. Fixed
39
+ # across the modular chi path (chi_io.py) and perturb, independent of the chi
40
+ # FFT length, so the rsi pipeline pins it here too rather than reusing the
41
+ # epsilon shear high-pass (which scales with the epsilon FFT length).
42
+ _CHI_HP_CUT = 0.25
43
+
44
+ # QC thresholds for the per-probe chi used in the derived mixing quantities
45
+ # (K_T/Gamma) are the SINGLE source of truth in l4_chi.py (imported above), so
46
+ # the reported L4ChiData.chi_final and the mixing products here are guaranteed
47
+ # to filter with identical limits — they cannot drift out of sync. The band
48
+ # mirrors the chi-from-epsilon fom_limit in window.py and the K_max_ratio < 0.5
49
+ # "most variance extrapolated" convention (see CLAUDE.md).
50
+
51
+
52
+ def _qc_chi_final(
53
+ chi: np.ndarray,
54
+ fom: np.ndarray,
55
+ k_max_ratio: np.ndarray,
56
+ fom_limit: float = _CHI_FOM_LIMIT,
57
+ k_max_ratio_min: float = _CHI_K_MAX_RATIO_MIN,
58
+ ) -> np.ndarray:
59
+ """Per-window geometric-mean chi over probes that pass spectral QC.
60
+
61
+ Thin wrapper over :func:`odas_tpw.chi.l4_chi._compute_chi_final` with the QC
62
+ arguments always supplied. As of the 2026-07-03 review (Gemini C),
63
+ ``L4ChiData.chi_final`` is itself QC'd at the source with these same
64
+ thresholds, so the mixing products and the reported chi are filtered
65
+ identically; this wrapper is retained for the two-sided-band QC contract it
66
+ documents and tests. All inputs are ``(n_probe, n_window)``; returns
67
+ ``(n_window,)``.
68
+ """
69
+ return _compute_chi_final(chi, fom, k_max_ratio, fom_limit, k_max_ratio_min)
70
+
71
+
72
+ def _epsilon_hp_cut(fs_fast: float, fft_length: int, override: float | None) -> float:
73
+ """Shear high-pass cutoff [Hz] for epsilon, matching ``_compute_epsilon``.
74
+
75
+ Half the inverse FFT-segment duration (ODAS ``get_diss_odas.m`` guidance):
76
+ ``0.5 * fs_fast / fft_length`` — 0.25 Hz for a 1024-sample FFT at 512 Hz,
77
+ and it scales with the FFT length so a 256-sample window gives 1.0 Hz. An
78
+ explicit ``override`` (if not None) wins, for callers that pin the cutoff.
79
+ """
80
+ if override is not None:
81
+ return override
82
+ return 0.5 * fs_fast / fft_length
83
+
84
+
85
+ def _resolve_salinity(
86
+ l1: L1Data, salinity: float | np.ndarray | None
87
+ ) -> tuple[float | np.ndarray | None, bool]:
88
+ """Choose the salinity used for both chi viscosity and stratification.
89
+
90
+ Preference order, so the two consumers never disagree:
91
+
92
+ 1. The per-sample practical salinity measured from the profile's own
93
+ conductivity (VMP JAC C/T, computed on the L1 fast time base in
94
+ ``pfile_to_l1data``). MicroRiders have no .p-level conductivity, so this
95
+ is empty for them.
96
+ 2. A user-supplied ``salinity`` (scalar, or an array on the fast time base;
97
+ a mismatched-length array is collapsed to its nanmean).
98
+ 3. ``None`` → 35 PSU assumed downstream (``visc35`` / temperature-only N2).
99
+
100
+ Returns ``(salinity_value, measured)`` where ``measured`` is True only when
101
+ the per-sample measured salinity is used.
102
+ """
103
+ if l1.salinity.size == l1.n_time and bool(np.isfinite(l1.salinity).any()):
104
+ return l1.salinity, True
105
+ if isinstance(salinity, np.ndarray) and len(salinity) != l1.n_time:
106
+ return float(np.nanmean(salinity)), False
107
+ return salinity, False
108
+
109
+
110
+ def _epsilon_window_salinity(
111
+ salinity: float | np.ndarray | None,
112
+ sample_time: np.ndarray,
113
+ n_time: int,
114
+ win_time: np.ndarray,
115
+ n_spectra: int,
116
+ ) -> float | np.ndarray | None:
117
+ """Per-window salinity for L4 epsilon viscosity.
118
+
119
+ ``process_l4`` accepts only per-window salinity (size ``n_spectra``); it
120
+ collapses any other-sized array to its nanmean. A *measured* per-sample
121
+ series (size ``n_time``) would therefore drive epsilon viscosity with a
122
+ constant profile mean while chi and N2 use the per-window measured series —
123
+ so the three viscosities would not share a salinity basis. Interpolate the
124
+ per-sample series onto the window center times so they do. Scalars,
125
+ ``None``, and already-per-window arrays pass through unchanged.
126
+ """
127
+ if not (isinstance(salinity, np.ndarray) and salinity.size == n_time and n_spectra > 0):
128
+ return salinity
129
+ finite = np.isfinite(salinity)
130
+ if not finite.any():
131
+ return salinity
132
+ return np.asarray(np.interp(win_time, sample_time[finite], salinity[finite]), dtype=np.float64)
133
+
134
+
135
+ def run_pipeline(
136
+ p_files: list[Path],
137
+ output_dir: Path,
138
+ *,
139
+ # Profile detection
140
+ P_min: float = 0.5,
141
+ W_min: float = 0.3,
142
+ direction: str = "auto",
143
+ min_duration: float = 7.0,
144
+ speed: float | None = None,
145
+ speed_tau: float = 1.5,
146
+ speed_method: str = "pressure",
147
+ aoa_deg: float = 3.0,
148
+ vehicle: str | None = None,
149
+ # L2 params
150
+ HP_cut: float | None = None,
151
+ despike_thresh: float = 8.0,
152
+ # Epsilon spectral params
153
+ fft_length: int = 1024,
154
+ diss_length: int | None = None,
155
+ overlap: int | None = None,
156
+ f_AA: float = 98.0,
157
+ fit_order: int = 3,
158
+ salinity: float | None = None,
159
+ # Chi cleaning params
160
+ despike_T_thresh: float = 10.0,
161
+ # Chi spectral params
162
+ chi_fft_length: int = 1024,
163
+ chi_diss_length: int | None = None,
164
+ chi_overlap: int | None = None,
165
+ fp07_model: str = "single_pole",
166
+ spectrum_model: str = "kraichnan",
167
+ fit_method: str = "iterative",
168
+ # Chain selection
169
+ compute_chi_epsilon: bool = True,
170
+ compute_chi_fit: bool = False,
171
+ # Binning
172
+ bin_size: float = 1.0,
173
+ # Output
174
+ goodman: bool = True,
175
+ ) -> Path:
176
+ """Run the full processing pipeline.
177
+
178
+ Per .P file, per profile:
179
+ 1. pfile_to_l1data → L1Data
180
+ 2. process_l2 → L2Data
181
+ 3. process_l3 → L3Data (shear spectra)
182
+ 4. process_l4 → L4Data (epsilon)
183
+ 5. process_l3_chi → L3ChiData (temperature gradient spectra)
184
+ 6. process_l4_chi_epsilon or process_l4_chi_fit → L4ChiData
185
+ 7. bin_by_depth → L5
186
+ 8. combine_profiles → L6
187
+
188
+ Parameters
189
+ ----------
190
+ p_files : list of Path
191
+ Input .P files.
192
+ output_dir : Path
193
+ Base output directory.
194
+ (all other params documented in plan)
195
+
196
+ Returns
197
+ -------
198
+ Path
199
+ Output directory.
200
+ """
201
+ from odas_tpw.rsi.p_file import PFile
202
+ from odas_tpw.rsi.profile import _smooth_fall_rate, get_profiles
203
+
204
+ if diss_length is None:
205
+ diss_length = 4 * fft_length
206
+ if overlap is None:
207
+ overlap = diss_length // 2
208
+ if chi_diss_length is None:
209
+ chi_diss_length = 4 * chi_fft_length
210
+ if chi_overlap is None:
211
+ chi_overlap = chi_diss_length // 2
212
+
213
+ if diss_length != chi_diss_length:
214
+ warnings.warn(
215
+ f"Epsilon and chi use different dissipation lengths "
216
+ f"(diss_length={diss_length}, chi_diss_length={chi_diss_length}). "
217
+ f"Depth-binned estimates will have different vertical resolution.",
218
+ stacklevel=2,
219
+ )
220
+
221
+ output_dir = Path(output_dir)
222
+ output_dir.mkdir(parents=True, exist_ok=True)
223
+
224
+ for p_path in p_files:
225
+ logger.info("=" * 60)
226
+ logger.info(p_path.name)
227
+ logger.info("=" * 60)
228
+
229
+ pf = PFile(p_path)
230
+ pfile_dir = output_dir / p_path.stem
231
+ pfile_dir.mkdir(parents=True, exist_ok=True)
232
+
233
+ # Resolve vehicle, direction, and tau per file
234
+ from odas_tpw.rsi.vehicle import resolve_direction, resolve_tau
235
+
236
+ file_vehicle = vehicle or pf.config["instrument_info"].get("vehicle", "").lower()
237
+ file_direction = resolve_direction(direction, file_vehicle)
238
+ file_tau = resolve_tau(file_vehicle)
239
+ file_speed_tau = file_tau if speed_tau == 1.5 else speed_tau
240
+
241
+ # Detect profiles on slow-rate pressure
242
+ P_slow = pf.channels.get("P_dP", pf.channels.get("P"))
243
+ assert P_slow is not None, "No pressure channel (P or P_dP) found"
244
+ W_slow = _smooth_fall_rate(P_slow, pf.fs_slow, tau=file_speed_tau)
245
+ profiles = get_profiles(
246
+ P_slow,
247
+ W_slow,
248
+ pf.fs_slow,
249
+ P_min=P_min,
250
+ W_min=W_min,
251
+ direction=file_direction,
252
+ min_duration=min_duration,
253
+ )
254
+
255
+ if not profiles:
256
+ logger.warning("No profiles detected")
257
+ continue
258
+
259
+ logger.info(f"{len(profiles)} profile(s) detected")
260
+
261
+ # Epsilon shear high-pass cutoff, scaled with the epsilon FFT length
262
+ # exactly as the modular _compute_epsilon / perturb path: 0.25 Hz at the
263
+ # default 1024-sample FFT (512 Hz), 1.0 Hz at 256 samples. Chi keeps the
264
+ # fixed canonical 0.25 Hz (see _CHI_HP_CUT) regardless of its FFT length.
265
+ eps_hp_cut = _epsilon_hp_cut(pf.fs_fast, fft_length, HP_cut)
266
+
267
+ all_binned = []
268
+ profile_metadata = []
269
+
270
+ for prof_idx, (s_slow, e_slow) in enumerate(profiles):
271
+ prof_num = prof_idx + 1
272
+ prof_dir = pfile_dir / f"profile_{prof_num:03d}"
273
+ prof_dir.mkdir(parents=True, exist_ok=True)
274
+
275
+ logger.info(f"Profile {prof_num}: slow samples {s_slow}-{e_slow}")
276
+
277
+ binned = _process_profile(
278
+ pf,
279
+ prof_slice=(s_slow, e_slow),
280
+ prof_num=prof_num,
281
+ prof_dir=prof_dir,
282
+ p_path=p_path,
283
+ speed=speed,
284
+ direction=file_direction,
285
+ speed_tau=file_speed_tau,
286
+ speed_method=speed_method,
287
+ aoa_deg=aoa_deg,
288
+ l2_params=L2Params(
289
+ HP_cut=eps_hp_cut,
290
+ despike_sh=np.array([despike_thresh, 0.5, 0.04]),
291
+ despike_A=np.array([np.inf, 0.5, 0.04]),
292
+ profile_min_W=W_min,
293
+ profile_min_P=P_min,
294
+ profile_min_duration=min_duration,
295
+ # Vehicle-resolved tau (matches L1 speed + profile detection);
296
+ # the raw user default 1.5 would re-smooth glider speed at the
297
+ # wrong 0.68/1.5 cutoff for non-VMP vehicles.
298
+ speed_tau=file_speed_tau,
299
+ ),
300
+ l3_params=L3Params(
301
+ fft_length=fft_length,
302
+ diss_length=diss_length,
303
+ overlap=overlap,
304
+ HP_cut=eps_hp_cut,
305
+ fs_fast=0, # placeholder, overridden in _process_profile
306
+ goodman=goodman,
307
+ ),
308
+ f_AA=f_AA,
309
+ fit_order=fit_order,
310
+ salinity=salinity,
311
+ despike_T_thresh=despike_T_thresh,
312
+ chi_fft_length=chi_fft_length,
313
+ chi_diss_length=chi_diss_length,
314
+ chi_overlap=chi_overlap,
315
+ fp07_model=fp07_model,
316
+ spectrum_model=spectrum_model,
317
+ fit_method=fit_method,
318
+ compute_chi_epsilon=compute_chi_epsilon,
319
+ compute_chi_fit=compute_chi_fit,
320
+ bin_size=bin_size,
321
+ )
322
+
323
+ if binned is not None:
324
+ all_binned.append(binned)
325
+ profile_metadata.append(
326
+ {
327
+ "source_file": p_path.name,
328
+ "profile_number": prof_num,
329
+ "s_slow": s_slow,
330
+ "e_slow": e_slow,
331
+ }
332
+ )
333
+
334
+ # Step 8: Combine profiles
335
+ if all_binned:
336
+ combined = combine_profiles(all_binned, profile_metadata)
337
+ combined_path = pfile_dir / "L6_combined.nc"
338
+ combined.to_netcdf(combined_path)
339
+ logger.info(f"Combined: {combined_path}")
340
+
341
+ logger.info(f"Pipeline complete. Output: {output_dir}")
342
+ return output_dir
343
+
344
+
345
+ def _process_profile(
346
+ pf,
347
+ prof_slice: tuple[int, int],
348
+ prof_num: int,
349
+ prof_dir: Path,
350
+ p_path: Path,
351
+ *,
352
+ speed: float | None,
353
+ direction: str,
354
+ speed_tau: float,
355
+ speed_method: str = "pressure",
356
+ aoa_deg: float = 3.0,
357
+ l2_params: L2Params,
358
+ l3_params: L3Params,
359
+ f_AA: float,
360
+ fit_order: int,
361
+ salinity: float | None,
362
+ despike_T_thresh: float,
363
+ chi_fft_length: int,
364
+ chi_diss_length: int,
365
+ chi_overlap: int,
366
+ fp07_model: str,
367
+ spectrum_model: str,
368
+ fit_method: str,
369
+ compute_chi_epsilon: bool,
370
+ compute_chi_fit: bool,
371
+ bin_size: float,
372
+ ) -> xr.Dataset | None:
373
+ """Process a single profile: L1→L2→L3→L4→chi→binning→NetCDF."""
374
+ s_slow, e_slow = prof_slice
375
+
376
+ # Step 1: L1Data
377
+ l1 = pfile_to_l1data(
378
+ pf,
379
+ profile_slice=(s_slow, e_slow),
380
+ speed=speed,
381
+ direction=direction,
382
+ speed_tau=speed_tau,
383
+ speed_method=speed_method,
384
+ aoa_deg=aoa_deg,
385
+ )
386
+
387
+ # Step 2: L2
388
+ l2 = process_l2(l1, l2_params)
389
+
390
+ # Step 3: L3 shear spectra (update fs_fast from actual L1)
391
+ l3_params = L3Params(
392
+ fft_length=l3_params.fft_length,
393
+ diss_length=l3_params.diss_length,
394
+ overlap=l3_params.overlap,
395
+ HP_cut=l3_params.HP_cut,
396
+ fs_fast=l1.fs_fast,
397
+ goodman=l3_params.goodman,
398
+ )
399
+ l3 = process_l3(l2, l1, l3_params)
400
+
401
+ if l3.n_spectra == 0:
402
+ logger.warning("No valid spectral windows")
403
+ return None
404
+
405
+ # Salinity resolved once (measured JAC C/T > user-supplied > 35 PSU) so the
406
+ # epsilon, chi, and N2 viscosities never disagree within a run. This matches
407
+ # perturb's chi.salinity:"measured" feeding both process_l3_chi and N2.
408
+ chi_salinity, measured_sal = _resolve_salinity(l1, salinity)
409
+ # chi (process_l3_chi) and N2 (sorted_stratification) consume the per-sample
410
+ # series with timestamps; process_l4 only takes per-window salinity, so a
411
+ # measured series is interpolated onto the L3 window times here — otherwise
412
+ # epsilon would silently use the profile mean while chi/N2 use per-window.
413
+ eps_salinity = _epsilon_window_salinity(
414
+ chi_salinity, l1.time, l1.n_time, l3.time, l3.n_spectra
415
+ )
416
+
417
+ # Step 4: L4 epsilon
418
+ l4 = process_l4(
419
+ l3,
420
+ temp=l3.temp,
421
+ salinity=eps_salinity,
422
+ f_AA=f_AA,
423
+ fit_order=fit_order,
424
+ num_ffts=2 * (l3_params.diss_length // l3_params.fft_length) - 1,
425
+ n_v=l1.n_vib if l3_params.goodman else 0,
426
+ diss_length_s=l3_params.diss_length / l1.fs_fast,
427
+ )
428
+ logger.info(f"Epsilon: {l4.n_spectra} estimates")
429
+
430
+ # Step 5: L2_chi cleaning + chi spectra (if temperature data available)
431
+ l3_chi = None
432
+ l4_chi_eps = None
433
+ l4_chi_fit_result = None
434
+
435
+ if l1.has_temp_fast:
436
+ l2_chi_params = L2ChiParams(
437
+ HP_cut=_CHI_HP_CUT,
438
+ despike_T=np.array([despike_T_thresh, 0.5, 0.04]),
439
+ )
440
+ l2_chi = process_l2_chi(l1, l2, l2_chi_params)
441
+
442
+ l3_chi_params = L3Params(
443
+ fft_length=chi_fft_length,
444
+ diss_length=chi_diss_length,
445
+ overlap=chi_overlap,
446
+ HP_cut=_CHI_HP_CUT,
447
+ fs_fast=l1.fs_fast,
448
+ goodman=l3_params.goodman,
449
+ )
450
+
451
+ try:
452
+ l3_chi = process_l3_chi(
453
+ l2_chi,
454
+ l3_chi_params,
455
+ fp07_model=fp07_model,
456
+ salinity=chi_salinity,
457
+ )
458
+ except (ValueError, RuntimeError) as e:
459
+ logger.error(f"Chi spectra error: {e}")
460
+
461
+ if l3_chi is not None and l3_chi.n_spectra > 0:
462
+ # Step 6a: Chi from epsilon (Method 1)
463
+ if compute_chi_epsilon and l4.n_spectra > 0:
464
+ try:
465
+ l4_chi_eps = process_l4_chi_epsilon(
466
+ l3_chi,
467
+ l4,
468
+ spectrum_model=spectrum_model,
469
+ f_AA=f_AA,
470
+ )
471
+ n_valid = np.sum(np.isfinite(l4_chi_eps.chi_final))
472
+ logger.info(f"Chi (Method 1): {n_valid} valid estimates")
473
+ except (ValueError, RuntimeError) as e:
474
+ logger.error(f"Chi epsilon error: {e}")
475
+
476
+ # Step 6b: Chi from fit (Method 2)
477
+ if compute_chi_fit:
478
+ try:
479
+ l4_chi_fit_result = process_l4_chi_fit(
480
+ l3_chi,
481
+ spectrum_model=spectrum_model,
482
+ fit_method=fit_method,
483
+ f_AA=f_AA,
484
+ )
485
+ n_valid = np.sum(np.isfinite(l4_chi_fit_result.chi_final))
486
+ logger.info(f"Chi (Method 2): {n_valid} valid estimates")
487
+ except (ValueError, RuntimeError) as e:
488
+ logger.error(f"Chi fit error: {e}")
489
+
490
+ # Step 6c: Stratification + derived mixing quantities on the chi window
491
+ # grid. N2/dTdz need only the CTD profile (no epsilon/chi) and K_T needs
492
+ # only chi, so they are written whenever a chi result and temperature
493
+ # exist; Gamma/K_rho additionally require shear epsilon. chi_primary is the
494
+ # Method-1 result when present (chi-from-epsilon, the primary product) else
495
+ # the Method-2 fit, so a Method-2-only run still gets these instead of
496
+ # silently dropping the whole block (bug_003).
497
+ chi_primary = l4_chi_eps if l4_chi_eps is not None else l4_chi_fit_result
498
+ mixing_vars: dict[str, tuple[np.ndarray, dict]] | None = None
499
+ if chi_primary is not None and chi_primary.n_spectra > 0 and l1.temp.size == l1.n_time:
500
+ try:
501
+ from odas_tpw.processing.mixing import (
502
+ mixing_coefficients,
503
+ pair_nearest,
504
+ sorted_stratification,
505
+ )
506
+
507
+ # Same salinity as chi viscosity (resolved once, above): measured
508
+ # JAC C/T > user-supplied > 35 PSU.
509
+ half_w = 0.5 * chi_diss_length / l1.fs_fast
510
+ strat = sorted_stratification(
511
+ chi_primary.time,
512
+ half_w,
513
+ l1.time,
514
+ l1.pres,
515
+ l1.temp,
516
+ S=chi_salinity,
517
+ )
518
+ # Pair epsilon from the nearest shear window when available; with no
519
+ # shear epsilon K_T = chi/(2 dT/dz^2) stays valid while Gamma/K_rho
520
+ # fall to NaN (bug_003). The else is defensive: run_pipeline returns
521
+ # early when the shear L3 is empty (l4.n_spectra == l3.n_spectra),
522
+ # so l4.n_spectra > 0 always holds here today — but mixing_coefficients
523
+ # is kept robust to an all-NaN epsilon for reuse/future entry points.
524
+ if l4.n_spectra > 0:
525
+ eps_on_chi = pair_nearest(l4.time, l4.epsi_final, chi_primary.time)
526
+ else:
527
+ eps_on_chi = np.full(chi_primary.n_spectra, np.nan)
528
+ # QC the per-probe chi before the mixing products (K_T/Gamma) so a
529
+ # high-fom / poorly-resolved thermistor window cannot bias them. This
530
+ # matches chi_primary.chi_final, which is now QC'd at the source with
531
+ # the same thresholds; the raw per-probe chi_primary.chi array is what
532
+ # the L4_chi output carries for traceability.
533
+ chi_for_mixing = _qc_chi_final(
534
+ chi_primary.chi, chi_primary.fom, chi_primary.K_max_ratio
535
+ )
536
+ mix = mixing_coefficients(eps_on_chi, chi_for_mixing, strat.N2, strat.dTdz)
537
+ if measured_sal:
538
+ sal_note = "practical salinity from JAC_C/JAC_T/P (TEOS-10)"
539
+ elif salinity is None:
540
+ sal_note = (
541
+ "salinity assumed 35 PSU (none provided); N2 reflects "
542
+ "temperature stratification only"
543
+ )
544
+ else:
545
+ sal_note = "salinity from user-provided values"
546
+ mixing_vars = {
547
+ "N2": (
548
+ strat.N2,
549
+ {
550
+ "units": "s-2",
551
+ "long_name": "buoyancy frequency squared (window scale)",
552
+ "comment": (
553
+ "TEOS-10 (gsw.Nsquared) between the shallow- and "
554
+ "deep-half means of each chi window after Thorpe-"
555
+ f"sorting to a stable profile (overturns removed); {sal_note}."
556
+ ),
557
+ },
558
+ ),
559
+ "dTdz": (
560
+ strat.dTdz,
561
+ {
562
+ "units": "K m-1",
563
+ "long_name": "background temperature gradient (positive down)",
564
+ "comment": (
565
+ "Least-squares slope of the Thorpe-sorted in-situ "
566
+ "temperature vs depth over each chi window."
567
+ ),
568
+ },
569
+ ),
570
+ "K_T": (
571
+ mix.K_T,
572
+ {
573
+ "units": "m2 s-1",
574
+ "long_name": "Osborn-Cox eddy diffusivity of heat",
575
+ "comment": (
576
+ "K_T = chi / (2*(dT/dz)^2), Osborn & Cox (1972), "
577
+ "doi:10.1080/03091927208236085. NaN where "
578
+ "|dT/dz| < 1e-4 K/m (well-mixed)."
579
+ ),
580
+ },
581
+ ),
582
+ "Gamma": (
583
+ mix.Gamma,
584
+ {
585
+ "units": "1",
586
+ "long_name": "mixing coefficient (measured)",
587
+ "comment": (
588
+ "Gamma = N2*chi / (2*epsilon*(dT/dz)^2), Oakey "
589
+ "(1982), doi:10.1175/1520-0485(1982)012<0256:DOTROD>"
590
+ "2.0.CO;2. epsilon paired from the nearest shear-"
591
+ "probe window. NaN where N2 < 1e-9 s-2 or "
592
+ "|dT/dz| < 1e-4 K/m. Canonical value ~0.2."
593
+ ),
594
+ },
595
+ ),
596
+ "K_rho": (
597
+ mix.K_rho,
598
+ {
599
+ "units": "m2 s-1",
600
+ "long_name": "Osborn diapycnal diffusivity (Gamma_0 = 0.2)",
601
+ "comment": (
602
+ "K_rho = 0.2*epsilon/N2, Osborn (1980), "
603
+ "doi:10.1175/1520-0485(1980)010<0083:EOTLRO>2.0.CO;2. "
604
+ "Compare with K_T: agreement implies the measured "
605
+ "Gamma is near the canonical 0.2. NaN where "
606
+ "N2 < 1e-9 s-2 or K_rho > 10 m2 s-1 (physically "
607
+ "implausible diffusivity: the unbounded near-floor-N2 "
608
+ "artifact, or contaminated near-surface windows where "
609
+ "epsilon is itself spurious)."
610
+ ),
611
+ },
612
+ ),
613
+ }
614
+ n_gamma = int(np.sum(np.isfinite(mix.Gamma)))
615
+ logger.info(f"Mixing quantities: {n_gamma} valid Gamma estimates")
616
+ except (ValueError, RuntimeError) as e:
617
+ logger.error(f"Mixing quantities error: {e}")
618
+
619
+ # Step 7: Depth binning
620
+ binned_parts = []
621
+ have_eps = l4.n_spectra > 0
622
+ have_chi = chi_primary is not None and chi_primary.n_spectra > 0
623
+
624
+ # Shared depth grid so eps and chi bin onto a bit-identical depth_bin coord:
625
+ # independent per-array np.arange grids can differ by an ULP for a
626
+ # non-binary-exact bin_size, and the join="outer" merge below would then
627
+ # split one physical depth into two NaN-padded columns (M-16). With both
628
+ # present, snap a single range from the union of pressures.
629
+ shared_range: tuple[float, float] | None = None
630
+ if have_eps and chi_primary is not None and have_chi:
631
+ all_pres = np.concatenate(
632
+ [np.asarray(l4.pres, dtype=float), np.asarray(chi_primary.pres, dtype=float)]
633
+ )
634
+ finite = all_pres[np.isfinite(all_pres)]
635
+ if finite.size:
636
+ shared_range = (
637
+ float(np.floor(np.min(finite) / bin_size) * bin_size),
638
+ float(np.ceil(np.max(finite) / bin_size) * bin_size),
639
+ )
640
+
641
+ if have_eps:
642
+ eps_bin = bin_by_depth(
643
+ l4.pres,
644
+ {"epsilon": l4.epsi_final, "P_mean": l4.pres, "speed": l4.pspd_rel},
645
+ bin_size=bin_size,
646
+ pres_range=shared_range,
647
+ )
648
+ binned_parts.append(eps_bin)
649
+
650
+ if chi_primary is not None and have_chi:
651
+ chi_vals: dict[str, np.ndarray] = {"chi": chi_primary.chi_final}
652
+ if mixing_vars is not None:
653
+ chi_vals.update({name: arr for name, (arr, _a) in mixing_vars.items()})
654
+ chi_bin = bin_by_depth(
655
+ chi_primary.pres,
656
+ chi_vals,
657
+ bin_size=bin_size,
658
+ pres_range=shared_range,
659
+ )
660
+ binned_parts.append(chi_bin)
661
+
662
+ binned = None
663
+ if binned_parts:
664
+ binned = binned_parts[0]
665
+ for extra in binned_parts[1:]:
666
+ binned = binned.merge(extra, join="outer")
667
+ binned.attrs["profile_number"] = prof_num
668
+ binned.attrs["source_file"] = p_path.name
669
+ binned.attrs["bin_size"] = bin_size
670
+
671
+ binned.to_netcdf(prof_dir / "L5_binned.nc")
672
+
673
+ # Write per-level NetCDF
674
+ time_ref = pf.start_time.strftime("%Y-%m-%dT%H:%M:%SZ")
675
+ _write_l4_epsilon(l4, l3, prof_dir / "L4_epsilon.nc", pf)
676
+
677
+ # Attach the mixing vars to whichever chi product they were computed on
678
+ # (chi_primary): Method-1 when present, else the Method-2 fit (bug_003).
679
+ if l4_chi_eps is not None:
680
+ _write_l4_chi(
681
+ l4_chi_eps,
682
+ prof_dir / "L4_chi_epsilon.nc",
683
+ time_ref,
684
+ extra_vars=mixing_vars if chi_primary is l4_chi_eps else None,
685
+ )
686
+ if l4_chi_fit_result is not None:
687
+ _write_l4_chi(
688
+ l4_chi_fit_result,
689
+ prof_dir / "L4_chi_fit.nc",
690
+ time_ref,
691
+ extra_vars=mixing_vars if chi_primary is l4_chi_fit_result else None,
692
+ )
693
+
694
+ return binned
695
+
696
+
697
+ def _write_l4_epsilon(l4: L4Data, l3: L3Data, path: Path, pf) -> None:
698
+ """Write L4 epsilon to NetCDF."""
699
+ n_shear = l4.n_shear
700
+ probe_names = [f"sh{i + 1}" for i in range(n_shear)]
701
+ time_ref = pf.start_time.strftime("%Y-%m-%dT%H:%M:%SZ")
702
+
703
+ ds = xr.Dataset(
704
+ {
705
+ "epsilon": (
706
+ ["probe", "time"],
707
+ l4.epsi,
708
+ {
709
+ "units": "W kg-1",
710
+ "long_name": "TKE dissipation rate per shear probe",
711
+ },
712
+ ),
713
+ "epsilon_final": (
714
+ ["time"],
715
+ l4.epsi_final,
716
+ {
717
+ "units": "W kg-1",
718
+ "long_name": "TKE dissipation rate (best estimate)",
719
+ },
720
+ ),
721
+ "fom": (
722
+ ["probe", "time"],
723
+ l4.fom,
724
+ {
725
+ "units": "1",
726
+ "long_name": "figure of merit (observed/Nasmyth variance ratio)",
727
+ "comment": (
728
+ "Values near 1.0 indicate a good fit. NOT the MAD-based "
729
+ "ATOMIX/Rockland FM statistic; see the FM variable for that."
730
+ ),
731
+ },
732
+ ),
733
+ "FM": (
734
+ ["probe", "time"],
735
+ l4.FM if l4.FM is not None else np.full_like(l4.fom, np.nan),
736
+ {
737
+ "units": "1",
738
+ "long_name": "Lueck (2022) figure of merit",
739
+ "comment": (
740
+ "FM = MAD_ln / (T_M * sigma_ln) with sigma_ln = "
741
+ "sqrt(1.25 * N_eff**(-7/9)) and T_M = 0.8 + sqrt(1.56/N_s) "
742
+ "(Lueck 2022, doi:10.1175/JTECH-D-21-0051.1). A good fit "
743
+ "sits near its expected value ~0.7-0.8, NOT 0 (spectral "
744
+ "scatter floors this MAD statistic); ATOMIX recommends "
745
+ "rejecting FM > ~1.15."
746
+ ),
747
+ },
748
+ ),
749
+ "mad": (
750
+ ["probe", "time"],
751
+ l4.mad,
752
+ {
753
+ "units": "1",
754
+ "long_name": "mean absolute deviation ratio",
755
+ },
756
+ ),
757
+ "kmax": (
758
+ ["probe", "time"],
759
+ l4.kmax,
760
+ {
761
+ "units": "cpm",
762
+ "long_name": "upper integration wavenumber",
763
+ },
764
+ ),
765
+ "var_resolved": (
766
+ ["probe", "time"],
767
+ l4.var_resolved,
768
+ {
769
+ "units": "1",
770
+ "long_name": "fraction of variance resolved",
771
+ },
772
+ ),
773
+ "sea_water_pressure": (
774
+ ["time"],
775
+ l4.pres,
776
+ {
777
+ "units": "dbar",
778
+ "standard_name": "sea_water_pressure",
779
+ "long_name": "mean pressure per window",
780
+ },
781
+ ),
782
+ "pspd_rel": (
783
+ ["time"],
784
+ l4.pspd_rel,
785
+ {
786
+ "units": "m s-1",
787
+ "long_name": "profiling speed",
788
+ },
789
+ ),
790
+ },
791
+ coords={
792
+ "probe": probe_names,
793
+ "time": (
794
+ ["time"],
795
+ l4.time,
796
+ {
797
+ "units": f"seconds since {time_ref}",
798
+ "standard_name": "time",
799
+ "calendar": "standard",
800
+ },
801
+ ),
802
+ },
803
+ attrs={
804
+ "Conventions": "CF-1.13, ACDD-1.3",
805
+ "source_file": str(pf.filepath.name),
806
+ "history": f"Pipeline on {datetime.now(UTC).isoformat()}",
807
+ },
808
+ )
809
+ ds.to_netcdf(path)
810
+
811
+
812
+ def _write_l4_chi(
813
+ l4_chi: L4ChiData,
814
+ path: Path,
815
+ time_ref: str,
816
+ extra_vars: dict[str, tuple[np.ndarray, dict]] | None = None,
817
+ ) -> None:
818
+ """Write L4 chi to NetCDF.
819
+
820
+ ``extra_vars`` maps variable name -> (1-D array on the time dim,
821
+ attrs dict); used for the derived mixing quantities (N2, dTdz, K_T,
822
+ Gamma, K_rho).
823
+ """
824
+ n_gradt = l4_chi.n_gradt
825
+ probe_names = [f"T{i + 1}" for i in range(n_gradt)]
826
+
827
+ ds = xr.Dataset(
828
+ {
829
+ "chi": (
830
+ ["probe", "time"],
831
+ l4_chi.chi,
832
+ {
833
+ "units": "K2 s-1",
834
+ "long_name": "thermal variance dissipation rate per probe",
835
+ },
836
+ ),
837
+ "chi_final": (
838
+ ["time"],
839
+ l4_chi.chi_final,
840
+ {
841
+ "units": "K2 s-1",
842
+ "long_name": "thermal variance dissipation rate (best estimate)",
843
+ },
844
+ ),
845
+ "epsilon_T": (
846
+ ["probe", "time"],
847
+ l4_chi.epsilon_T,
848
+ {
849
+ "units": "W kg-1",
850
+ "long_name": "TKE dissipation rate from chi",
851
+ },
852
+ ),
853
+ "kB": (
854
+ ["probe", "time"],
855
+ l4_chi.kB,
856
+ {
857
+ "units": "cpm",
858
+ "long_name": "Batchelor wavenumber",
859
+ },
860
+ ),
861
+ "K_max": (
862
+ ["probe", "time"],
863
+ l4_chi.K_max,
864
+ {
865
+ "units": "cpm",
866
+ "long_name": "upper integration wavenumber",
867
+ },
868
+ ),
869
+ "fom": (
870
+ ["probe", "time"],
871
+ l4_chi.fom,
872
+ {
873
+ "units": "1",
874
+ "long_name": "figure of merit",
875
+ },
876
+ ),
877
+ "K_max_ratio": (
878
+ ["probe", "time"],
879
+ l4_chi.K_max_ratio,
880
+ {
881
+ "units": "1",
882
+ "long_name": "K_max to Batchelor wavenumber ratio",
883
+ },
884
+ ),
885
+ "sea_water_pressure": (
886
+ ["time"],
887
+ l4_chi.pres,
888
+ {
889
+ "units": "dbar",
890
+ "standard_name": "sea_water_pressure",
891
+ "long_name": "mean pressure per window",
892
+ },
893
+ ),
894
+ "pspd_rel": (
895
+ ["time"],
896
+ l4_chi.pspd_rel,
897
+ {
898
+ "units": "m s-1",
899
+ "long_name": "profiling speed",
900
+ },
901
+ ),
902
+ },
903
+ coords={
904
+ "probe": probe_names,
905
+ "time": (
906
+ ["time"],
907
+ l4_chi.time,
908
+ {
909
+ "units": f"seconds since {time_ref}",
910
+ "standard_name": "time",
911
+ "calendar": "standard",
912
+ },
913
+ ),
914
+ },
915
+ attrs={
916
+ "Conventions": "CF-1.13, ACDD-1.3",
917
+ "method": l4_chi.method,
918
+ "history": f"Pipeline on {datetime.now(UTC).isoformat()}",
919
+ },
920
+ )
921
+ if extra_vars:
922
+ for name, (arr, attrs) in extra_vars.items():
923
+ ds[name] = xr.DataArray(arr, dims=["time"], attrs=attrs)
924
+ ds.to_netcdf(path)