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.
- microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
- microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
- microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
- microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
- microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
- microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
- odas_tpw/__init__.py +18 -0
- odas_tpw/chi/__init__.py +48 -0
- odas_tpw/chi/batchelor.py +189 -0
- odas_tpw/chi/chi.py +857 -0
- odas_tpw/chi/fp07.py +649 -0
- odas_tpw/chi/l2_chi.py +153 -0
- odas_tpw/chi/l3_chi.py +388 -0
- odas_tpw/chi/l4_chi.py +391 -0
- odas_tpw/config_base.py +423 -0
- odas_tpw/perturb/__init__.py +18 -0
- odas_tpw/perturb/adcp.py +274 -0
- odas_tpw/perturb/atomic_io.py +47 -0
- odas_tpw/perturb/binning.py +761 -0
- odas_tpw/perturb/cli.py +591 -0
- odas_tpw/perturb/combo.py +346 -0
- odas_tpw/perturb/config.py +1046 -0
- odas_tpw/perturb/ctd.py +424 -0
- odas_tpw/perturb/diag/__init__.py +12 -0
- odas_tpw/perturb/diag/_common.py +140 -0
- odas_tpw/perturb/diag/chi.py +42 -0
- odas_tpw/perturb/diag/cli.py +67 -0
- odas_tpw/perturb/diag/data.py +662 -0
- odas_tpw/perturb/diag/epsilon.py +45 -0
- odas_tpw/perturb/diag/inspector.py +342 -0
- odas_tpw/perturb/diag/mixing.py +94 -0
- odas_tpw/perturb/diag/render.py +543 -0
- odas_tpw/perturb/discover.py +40 -0
- odas_tpw/perturb/fp07_cal.py +354 -0
- odas_tpw/perturb/gps.py +458 -0
- odas_tpw/perturb/hotel.py +572 -0
- odas_tpw/perturb/logging_setup.py +181 -0
- odas_tpw/perturb/merge.py +320 -0
- odas_tpw/perturb/mk_sections.py +310 -0
- odas_tpw/perturb/netcdf_schema.py +520 -0
- odas_tpw/perturb/pipeline.py +2654 -0
- odas_tpw/perturb/plot/__init__.py +15 -0
- odas_tpw/perturb/plot/cli.py +107 -0
- odas_tpw/perturb/plot/diagnostics.py +195 -0
- odas_tpw/perturb/plot/eps_chi.py +524 -0
- odas_tpw/perturb/plot/figure.py +679 -0
- odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
- odas_tpw/perturb/plot/grid.py +112 -0
- odas_tpw/perturb/plot/layout.py +361 -0
- odas_tpw/perturb/plot/overview.py +547 -0
- odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
- odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
- odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
- odas_tpw/perturb/plot/profiles.py +652 -0
- odas_tpw/perturb/plot/scalar.py +391 -0
- odas_tpw/perturb/plot/sections.py +506 -0
- odas_tpw/perturb/plot/xaxis.py +396 -0
- odas_tpw/perturb/qc_gate.py +177 -0
- odas_tpw/perturb/qc_rules.py +373 -0
- odas_tpw/perturb/resolve.py +306 -0
- odas_tpw/perturb/seawater.py +76 -0
- odas_tpw/perturb/speed.py +14 -0
- odas_tpw/perturb/trim.py +313 -0
- odas_tpw/processing/__init__.py +62 -0
- odas_tpw/processing/bottom.py +215 -0
- odas_tpw/processing/chi_combine.py +300 -0
- odas_tpw/processing/ct_align.py +172 -0
- odas_tpw/processing/epsilon_combine.py +210 -0
- odas_tpw/processing/mixing.py +630 -0
- odas_tpw/processing/thorpe.py +458 -0
- odas_tpw/processing/top_trim.py +274 -0
- odas_tpw/pyturb/__init__.py +3 -0
- odas_tpw/pyturb/__main__.py +5 -0
- odas_tpw/pyturb/_compat.py +204 -0
- odas_tpw/pyturb/_profind.py +124 -0
- odas_tpw/pyturb/bin.py +113 -0
- odas_tpw/pyturb/cli.py +175 -0
- odas_tpw/pyturb/eps.py +353 -0
- odas_tpw/pyturb/merge.py +101 -0
- odas_tpw/pyturb/p2nc.py +76 -0
- odas_tpw/rsi/__init__.py +31 -0
- odas_tpw/rsi/adapter.py +314 -0
- odas_tpw/rsi/bench.py +904 -0
- odas_tpw/rsi/binning.py +193 -0
- odas_tpw/rsi/channels.py +376 -0
- odas_tpw/rsi/chi_io.py +980 -0
- odas_tpw/rsi/cli.py +1411 -0
- odas_tpw/rsi/combine.py +120 -0
- odas_tpw/rsi/config.py +126 -0
- odas_tpw/rsi/config_patch.py +790 -0
- odas_tpw/rsi/convert.py +651 -0
- odas_tpw/rsi/deconvolve.py +119 -0
- odas_tpw/rsi/diss_look.py +343 -0
- odas_tpw/rsi/dissipation.py +669 -0
- odas_tpw/rsi/helpers.py +527 -0
- odas_tpw/rsi/mixing_look.py +509 -0
- odas_tpw/rsi/p_file.py +771 -0
- odas_tpw/rsi/pipeline.py +924 -0
- odas_tpw/rsi/profile.py +624 -0
- odas_tpw/rsi/quick_look.py +758 -0
- odas_tpw/rsi/sensor_inventory.py +684 -0
- odas_tpw/rsi/shear_noise.py +128 -0
- odas_tpw/rsi/speed.py +221 -0
- odas_tpw/rsi/vehicle.py +74 -0
- odas_tpw/rsi/viewer_base.py +1071 -0
- odas_tpw/rsi/window.py +459 -0
- odas_tpw/scor160/__init__.py +36 -0
- odas_tpw/scor160/cli.py +242 -0
- odas_tpw/scor160/compare.py +484 -0
- odas_tpw/scor160/despike.py +227 -0
- odas_tpw/scor160/goodman.py +288 -0
- odas_tpw/scor160/io.py +690 -0
- odas_tpw/scor160/l2.py +311 -0
- odas_tpw/scor160/l3.py +279 -0
- odas_tpw/scor160/l4.py +850 -0
- odas_tpw/scor160/nasmyth.py +208 -0
- odas_tpw/scor160/ocean.py +287 -0
- odas_tpw/scor160/profile.py +231 -0
- odas_tpw/scor160/spectral.py +482 -0
odas_tpw/rsi/pipeline.py
ADDED
|
@@ -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)
|