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/scor160/io.py ADDED
@@ -0,0 +1,690 @@
1
+ """I/O for ATOMIX-format NetCDF benchmark files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import NamedTuple
8
+
9
+ import netCDF4
10
+ import numpy as np
11
+
12
+
13
+ @dataclass
14
+ class L1Data:
15
+ """Level-1 converted time series in physical units."""
16
+
17
+ time: np.ndarray # (N_TIME,), decimal days
18
+ pres: np.ndarray # (N_TIME,), dbar
19
+ shear: np.ndarray # (N_SHEAR, N_TIME), s⁻¹
20
+ # Vibration/acceleration — whichever the instrument provides
21
+ vib: np.ndarray # (N_VIB, N_TIME)
22
+ vib_type: str # "ACC" or "VIB"
23
+
24
+ fs_fast: float # fast sampling rate [Hz]
25
+ f_AA: float # anti-alias filter cutoff [Hz]
26
+ vehicle: str # "vmp", "mss", etc.
27
+ profile_dir: str # "down", "up", or "horizontal"
28
+ time_reference_year: int
29
+
30
+ # Optional: pre-computed speed (e.g. Epsifish, MSS provide this in L1)
31
+ pspd_rel: np.ndarray = field(default_factory=lambda: np.array([]))
32
+
33
+ # Optional slow-rate arrays (may be empty)
34
+ time_slow: np.ndarray = field(default_factory=lambda: np.array([]))
35
+ pres_slow: np.ndarray = field(default_factory=lambda: np.array([]))
36
+ pitch: np.ndarray = field(default_factory=lambda: np.array([]))
37
+ roll: np.ndarray = field(default_factory=lambda: np.array([]))
38
+ temp: np.ndarray = field(default_factory=lambda: np.array([]))
39
+ fs_slow: float = 0.0
40
+
41
+ # Optional fast-rate FP07 temperature (for chi computation)
42
+ temp_fast: np.ndarray = field(default_factory=lambda: np.zeros((0, 0)))
43
+ diff_gains: list[float] = field(default_factory=list)
44
+
45
+ # Optional fast-rate practical salinity, derived from CTD conductivity when
46
+ # the .p file carries a JAC C/T pair (VMP). Empty for instruments without
47
+ # conductivity at the .p level (e.g. MicroRiders), in which case the
48
+ # stratification falls back to a supplied/assumed salinity.
49
+ salinity: np.ndarray = field(default_factory=lambda: np.array([]))
50
+
51
+ @property
52
+ def n_shear(self) -> int:
53
+ """Number of shear probe channels."""
54
+ return self.shear.shape[0]
55
+
56
+ @property
57
+ def n_vib(self) -> int:
58
+ """Number of vibration/accelerometer channels."""
59
+ return self.vib.shape[0]
60
+
61
+ @property
62
+ def n_time(self) -> int:
63
+ """Number of fast-rate time samples."""
64
+ return self.time.shape[0]
65
+
66
+ @property
67
+ def has_speed(self) -> bool:
68
+ """Whether pre-computed profiling speed is available."""
69
+ return self.pspd_rel.size > 0
70
+
71
+ @property
72
+ def n_temp(self) -> int:
73
+ """Number of fast thermistor channels."""
74
+ return self.temp_fast.shape[0] if self.temp_fast.ndim == 2 else 0
75
+
76
+ @property
77
+ def has_temp_fast(self) -> bool:
78
+ """Whether fast-rate temperature data is available."""
79
+ return self.temp_fast.size > 0 and self.temp_fast.ndim == 2
80
+
81
+
82
+ @dataclass
83
+ class L2Params:
84
+ """Processing parameters for L1→L2, read from L2_cleaned group attributes."""
85
+
86
+ HP_cut: float # high-pass cutoff frequency [Hz]
87
+ despike_sh: np.ndarray # [threshold, smooth_freq, half_width_frac]
88
+ despike_A: np.ndarray # same for accelerometers
89
+ profile_min_W: float # minimum speed [m/s]
90
+ profile_min_P: float # minimum pressure [dbar]
91
+ profile_min_duration: float # minimum section duration [s]
92
+ speed_tau: float # speed smoothing time constant [s] (ODAS default 1.5)
93
+
94
+
95
+ @dataclass
96
+ class L2Data:
97
+ """Level-2 cleaned time series."""
98
+
99
+ time: np.ndarray
100
+ shear: np.ndarray # (N_SHEAR, N_TIME), despiked + HP-filtered
101
+ vib: np.ndarray # (N_VIB, N_TIME), despiked + HP-filtered
102
+ vib_type: str
103
+ pspd_rel: np.ndarray # (N_TIME,), profiling speed [m/s]
104
+ section_number: np.ndarray # (N_TIME,), 0 = excluded
105
+ # Despike bookkeeping for ATOMIX QC flags (empty when despiking was
106
+ # disabled or for benchmark reference reads): per-sample
107
+ # replaced-by-despike masks, and pass counts per (channel, section)
108
+ # — the benchmark's PASS_COUNT_SH layout, with the section ids in
109
+ # ``despike_section_ids``.
110
+ despike_mask_sh: np.ndarray = field(default_factory=lambda: np.zeros((0, 0), dtype=bool))
111
+ despike_passes_sh: np.ndarray = field(
112
+ default_factory=lambda: np.zeros((0, 0), dtype=np.int64)
113
+ )
114
+ despike_mask_A: np.ndarray = field(default_factory=lambda: np.zeros((0, 0), dtype=bool))
115
+ despike_passes_A: np.ndarray = field(
116
+ default_factory=lambda: np.zeros((0, 0), dtype=np.int64)
117
+ )
118
+ despike_section_ids: np.ndarray = field(
119
+ default_factory=lambda: np.zeros(0, dtype=np.int64)
120
+ )
121
+
122
+
123
+ @dataclass
124
+ class L3Params:
125
+ """Processing parameters for L2→L3, read from L3_spectra group attributes."""
126
+
127
+ fft_length: int # FFT segment length [samples]
128
+ diss_length: int # dissipation window length [samples]
129
+ overlap: int # dissipation window overlap [samples]
130
+ HP_cut: float # high-pass cutoff frequency [Hz]
131
+ fs_fast: float # fast sampling rate [Hz]
132
+ goodman: bool # whether Goodman cleaning was applied
133
+
134
+
135
+ @dataclass
136
+ class L3Data:
137
+ """Level-3 wavenumber spectra."""
138
+
139
+ time: np.ndarray # (N_SPECTRA,), center time of each window
140
+ pres: np.ndarray # (N_SPECTRA,), mean pressure per window
141
+ temp: np.ndarray # (N_SPECTRA,), mean temperature per window
142
+ pspd_rel: np.ndarray # (N_SPECTRA,), mean speed per window
143
+ section_number: np.ndarray # (N_SPECTRA,)
144
+ kcyc: np.ndarray # (N_WAVENUMBER, N_SPECTRA), wavenumber [cpm]
145
+ sh_spec: np.ndarray # (N_SHEAR, N_WAVENUMBER, N_SPECTRA), raw spectrum
146
+ sh_spec_clean: np.ndarray # (N_SHEAR, N_WAVENUMBER, N_SPECTRA), Goodman-cleaned
147
+ # Despike bookkeeping for ATOMIX QC flags (empty for benchmark
148
+ # reference reads): per-window fraction of shear samples replaced by
149
+ # despiking, and the pass count of each window's section.
150
+ despike_fraction: np.ndarray = field(
151
+ default_factory=lambda: np.zeros((0, 0))
152
+ ) # (N_SHEAR, N_SPECTRA)
153
+ despike_passes: np.ndarray = field(
154
+ default_factory=lambda: np.zeros((0, 0), dtype=np.int64)
155
+ ) # (N_SHEAR, N_SPECTRA)
156
+
157
+ @property
158
+ def n_spectra(self) -> int:
159
+ """Number of spectral windows."""
160
+ return self.time.shape[0]
161
+
162
+ @property
163
+ def n_wavenumber(self) -> int:
164
+ """Number of wavenumber bins."""
165
+ return self.kcyc.shape[0]
166
+
167
+ @property
168
+ def n_shear(self) -> int:
169
+ """Number of shear probe channels."""
170
+ return self.sh_spec.shape[0]
171
+
172
+
173
+ @dataclass
174
+ class L4Params:
175
+ """Processing parameters for L3→L4, read from L4_dissipation group attributes."""
176
+
177
+ fft_length: int # FFT segment length [samples]
178
+ diss_length: int # dissipation window length [samples]
179
+ overlap: int # dissipation window overlap [samples]
180
+ fs_fast: float # fast sampling rate [Hz]
181
+ fit_order: int # polynomial order for spectral minimum detection
182
+ f_AA: float # anti-alias filter cutoff [Hz]
183
+ FOM_limit: float # QC threshold for figure of merit
184
+ variance_resolved_limit: float # QC threshold for variance resolved
185
+
186
+
187
+ @dataclass
188
+ class L4Data:
189
+ """Level-4 dissipation estimates."""
190
+
191
+ time: np.ndarray # (N_SPECTRA,)
192
+ pres: np.ndarray # (N_SPECTRA,)
193
+ pspd_rel: np.ndarray # (N_SPECTRA,)
194
+ section_number: np.ndarray # (N_SPECTRA,)
195
+ epsi: np.ndarray # (N_SHEAR, N_SPECTRA), epsilon per probe
196
+ epsi_final: np.ndarray # (N_SPECTRA,), combined epsilon
197
+ epsi_flags: np.ndarray # (N_SHEAR, N_SPECTRA), QC flags
198
+ # NOTE: dual-use depending on the constructor. _read_l4 (benchmark reference
199
+ # load) fills this with the benchmark FOM = ATOMIX/Lueck (2022)
200
+ # MAD-normalized statistic; the compute path (process_l4) fills it with the
201
+ # obs/Nasmyth variance-ratio fom. They are different statistics — check the
202
+ # source before thresholding (the 1.15 limit belongs to the Lueck FM).
203
+ fom: np.ndarray # (N_SHEAR, N_SPECTRA), FOM — see note (benchmark FM vs variance-ratio fom)
204
+ mad: np.ndarray # (N_SHEAR, N_SPECTRA), mean absolute deviation
205
+ kmax: np.ndarray # (N_SHEAR, N_SPECTRA), upper integration limit
206
+ method: np.ndarray # (N_SHEAR, N_SPECTRA), 0=variance, 1=ISR
207
+ var_resolved: np.ndarray # (N_SHEAR, N_SPECTRA), fraction of variance resolved
208
+ FM: np.ndarray | None = None # (N_SHEAR, N_SPECTRA), Lueck (2022) MAD-based FM statistic
209
+ despike_fraction: np.ndarray | None = None # (N_SHEAR, N_SPECTRA), per-window
210
+
211
+ @property
212
+ def n_spectra(self) -> int:
213
+ """Number of spectral windows."""
214
+ return self.time.shape[0]
215
+
216
+ @property
217
+ def n_shear(self) -> int:
218
+ """Number of shear probe channels."""
219
+ return self.epsi.shape[0]
220
+
221
+
222
+ class AtomixData(NamedTuple):
223
+ """All data from an ATOMIX benchmark NetCDF file."""
224
+
225
+ l1: L1Data
226
+ l2_params: L2Params
227
+ l2_ref: L2Data
228
+ l3_params: L3Params
229
+ l3_ref: L3Data
230
+ l4_ref: L4Data
231
+
232
+
233
+ def read_l4_qc_limits(path: str | Path) -> dict[str, float]:
234
+ """Read the dataset's own QC thresholds from the L4 group attributes.
235
+
236
+ The ATOMIX benchmark files store per-dataset QC limits
237
+ (``FOM_limit``, ``variance_resolved_limit``,
238
+ ``despike_shear_fraction_limit``, ``despike_shear_iterations_limit``,
239
+ ``diss_ratio_limit``) plus ``diss_length``/``fs_fast``; comparing
240
+ computed flags against the reference is only meaningful with the
241
+ same limits. Missing attributes fall back to the package defaults.
242
+ """
243
+ from odas_tpw.scor160.l4 import (
244
+ DEFAULT_DESPIKE_FRACTION_LIMIT,
245
+ DEFAULT_DESPIKE_PASSES_LIMIT,
246
+ DEFAULT_DISS_RATIO_LIMIT,
247
+ DEFAULT_FOM_LIMIT,
248
+ DEFAULT_VAR_RESOLVED_LIMIT,
249
+ )
250
+
251
+ defaults = {
252
+ "FOM_limit": DEFAULT_FOM_LIMIT,
253
+ "variance_resolved_limit": DEFAULT_VAR_RESOLVED_LIMIT,
254
+ "despike_shear_fraction_limit": DEFAULT_DESPIKE_FRACTION_LIMIT,
255
+ "despike_shear_iterations_limit": float(DEFAULT_DESPIKE_PASSES_LIMIT),
256
+ "diss_ratio_limit": DEFAULT_DISS_RATIO_LIMIT,
257
+ }
258
+ out: dict[str, float] = {}
259
+ ds = netCDF4.Dataset(str(path), "r")
260
+ try:
261
+ g = ds.groups.get("L4_dissipation")
262
+ for name, default in defaults.items():
263
+ val = getattr(g, name, None) if g is not None else None
264
+ out[name] = float(val) if val is not None else default
265
+ # diss window duration [s] for the sigma_ln model
266
+ diss_len = getattr(g, "diss_length", None) if g is not None else None
267
+ fs = getattr(g, "fs_fast", None) if g is not None else None
268
+ out["diss_length_s"] = (
269
+ float(diss_len) / float(fs) if diss_len is not None and fs else 0.0
270
+ )
271
+ finally:
272
+ ds.close()
273
+ return out
274
+
275
+
276
+ def read_atomix(path: str | Path) -> AtomixData:
277
+ """Read an ATOMIX benchmark NetCDF file.
278
+
279
+ Returns L1 data, L2 processing parameters, reference L2 result,
280
+ L3 processing parameters, reference L3 result, and reference L4 result.
281
+ """
282
+ path = Path(path)
283
+ ds = netCDF4.Dataset(str(path), "r")
284
+ try:
285
+ l1 = _read_l1(ds)
286
+ l2_params = _read_l2_params(ds, l1)
287
+ l2_ref = _read_l2(ds)
288
+ l3_params = _read_l3_params(ds)
289
+ l3_ref = _read_l3(ds)
290
+ l4_ref = _read_l4(ds)
291
+
292
+ # If L1 has no speed but L2 does (e.g. Nemo: speed from hotel file),
293
+ # copy L2 reference speed into L1 so process_l2 can use it.
294
+ if not l1.has_speed and l2_ref.pspd_rel.shape[0] == l1.n_time:
295
+ l1.pspd_rel = l2_ref.pspd_rel.copy()
296
+ finally:
297
+ ds.close()
298
+ return AtomixData(l1, l2_params, l2_ref, l3_params, l3_ref, l4_ref)
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # Helpers for resolving attributes across groups
303
+ # ---------------------------------------------------------------------------
304
+
305
+
306
+ def _resolve_attr(ds: netCDF4.Dataset, name: str, default=None):
307
+ """Look for an attribute in L1_converted, then L2_cleaned, then root."""
308
+ for source in [ds.groups["L1_converted"], ds.groups["L2_cleaned"], ds]:
309
+ if name in {a for a in source.ncattrs()}:
310
+ return source.getncattr(name)
311
+ return default
312
+
313
+
314
+ # ---------------------------------------------------------------------------
315
+ # L1 reader
316
+ # ---------------------------------------------------------------------------
317
+
318
+
319
+ def _read_l1(ds: netCDF4.Dataset) -> L1Data:
320
+ g = ds.groups["L1_converted"]
321
+
322
+ time = np.asarray(g.variables["TIME"][:], dtype=np.float64)
323
+ shear = np.asarray(g.variables["SHEAR"][:], dtype=np.float64)
324
+
325
+ # Pressure may be on a slower grid (e.g. Epsifish: TIME_CTD)
326
+ pres_var = g.variables["PRES"]
327
+ pres_raw = np.asarray(pres_var[:], dtype=np.float64)
328
+ if pres_raw.shape[0] == len(time):
329
+ pres = pres_raw
330
+ else:
331
+ pres_dim = pres_var.dimensions[0]
332
+ if pres_dim in g.variables:
333
+ time_pres = np.asarray(g.variables[pres_dim][:], dtype=np.float64)
334
+ pres = np.interp(time, time_pres, pres_raw)
335
+ else:
336
+ pres = np.interp(
337
+ np.linspace(0, 1, len(time)),
338
+ np.linspace(0, 1, len(pres_raw)),
339
+ pres_raw,
340
+ )
341
+
342
+ # ACC or VIB
343
+ if "ACC" in g.variables:
344
+ vib = np.asarray(g.variables["ACC"][:], dtype=np.float64)
345
+ vib_type = "ACC"
346
+ elif "VIB" in g.variables:
347
+ vib = np.asarray(g.variables["VIB"][:], dtype=np.float64)
348
+ vib_type = "VIB"
349
+ else:
350
+ vib = np.zeros((0, len(time)), dtype=np.float64)
351
+ vib_type = "NONE"
352
+
353
+ # Pre-computed speed (Epsifish, MSS provide this in L1).
354
+ # May be on a slower time grid (e.g. CTD rate) — interpolate to fast.
355
+ pspd_rel = np.array([])
356
+ if "PSPD_REL" in g.variables:
357
+ pspd_var = g.variables["PSPD_REL"]
358
+ pspd_raw = np.asarray(pspd_var[:], dtype=np.float64)
359
+ if pspd_raw.shape[0] == len(time):
360
+ pspd_rel = pspd_raw
361
+ else:
362
+ # On a different time grid — find the corresponding time variable
363
+ pspd_dims = pspd_var.dimensions
364
+ time_dim = pspd_dims[0] # e.g. "TIME_CTD"
365
+ if time_dim in g.variables:
366
+ time_slow_spd = np.asarray(g.variables[time_dim][:], dtype=np.float64)
367
+ pspd_rel = np.interp(time, time_slow_spd, pspd_raw)
368
+ else:
369
+ # Fallback: assume uniform spacing, just resample
370
+ pspd_rel = np.interp(
371
+ np.linspace(0, 1, len(time)),
372
+ np.linspace(0, 1, len(pspd_raw)),
373
+ pspd_raw,
374
+ )
375
+
376
+ # Optional slow-rate channels
377
+ time_slow = np.array([])
378
+ pres_slow = np.array([])
379
+ pitch = np.array([])
380
+ roll = np.array([])
381
+ temp = np.array([])
382
+
383
+ if "TIME_SLOW" in g.variables:
384
+ time_slow = np.asarray(g.variables["TIME_SLOW"][:], dtype=np.float64)
385
+ if "PRES_SLOW" in g.variables:
386
+ pres_slow = np.asarray(g.variables["PRES_SLOW"][:], dtype=np.float64)
387
+ if "PITCH" in g.variables:
388
+ pitch = np.asarray(g.variables["PITCH"][:], dtype=np.float64)
389
+ if "ROLL" in g.variables:
390
+ roll = np.asarray(g.variables["ROLL"][:], dtype=np.float64)
391
+ if "TEMP" in g.variables:
392
+ temp = np.asarray(g.variables["TEMP"][:], dtype=np.float64)
393
+ # Some datasets (MSS Baltic, VMP2000 Faroe Bank) store TEMP as
394
+ # (N_TEMP_SENSORS, TIME); use the first sensor.
395
+ if temp.ndim == 2:
396
+ temp = temp[0] if temp.shape[0] <= temp.shape[1] else temp[:, 0]
397
+ # TEMP may be sampled on the slow grid; interpolate to the fast
398
+ # time grid so per-window means (and hence viscosity) are defined.
399
+ # Without this, L3 windows get temp=NaN -> visc35(NaN)=NaN -> all
400
+ # epsilon estimates NaN, silently.
401
+ if temp.shape[0] != len(time):
402
+ temp_dims = g.variables["TEMP"].dimensions
403
+ temp_dim = temp_dims[-1] if len(temp_dims) > 1 else temp_dims[0]
404
+ if temp_dim in g.variables:
405
+ time_temp = np.asarray(g.variables[temp_dim][:], dtype=np.float64)
406
+ elif time_slow.size == temp.shape[0]:
407
+ time_temp = time_slow
408
+ else:
409
+ time_temp = np.linspace(time[0], time[-1], temp.shape[0])
410
+ temp = np.interp(time, time_temp, temp)
411
+
412
+ fs_fast = float(_resolve_attr(ds, "fs_fast", 512.0))
413
+ fs_slow = float(_resolve_attr(ds, "fs_slow", 0.0))
414
+
415
+ return L1Data(
416
+ time=time,
417
+ pres=pres,
418
+ shear=shear,
419
+ vib=vib,
420
+ vib_type=vib_type,
421
+ fs_fast=fs_fast,
422
+ f_AA=float(_resolve_attr(ds, "f_AA", 98.0)),
423
+ vehicle=str(_resolve_attr(ds, "vehicle", "unknown")),
424
+ profile_dir=str(_resolve_attr(ds, "profile_dir", "down")),
425
+ time_reference_year=int(_resolve_attr(ds, "time_reference_year", 2000)),
426
+ pspd_rel=pspd_rel,
427
+ time_slow=time_slow,
428
+ pres_slow=pres_slow,
429
+ pitch=pitch,
430
+ roll=roll,
431
+ temp=temp,
432
+ fs_slow=fs_slow,
433
+ )
434
+
435
+
436
+ # ---------------------------------------------------------------------------
437
+ # L2 params reader
438
+ # ---------------------------------------------------------------------------
439
+
440
+
441
+ def _read_l2_params(ds: netCDF4.Dataset, l1: L1Data) -> L2Params:
442
+ """Read L2 processing parameters, falling back to sensible defaults.
443
+
444
+ Parameters are read from the L2_cleaned group attributes first, then
445
+ from global attributes (e.g. ``speed_cutout``), then defaults.
446
+ """
447
+ g = ds.groups["L2_cleaned"]
448
+ attrs = {a: g.getncattr(a) for a in g.ncattrs()}
449
+ gattrs = {a: ds.getncattr(a) for a in ds.ncattrs()}
450
+
451
+ # Defaults per Lueck et al. (2024) recommendations.
452
+ # HP_cut from the global attrs has a different meaning (e.g. 0 for
453
+ # already-differentiated shear on epsifish) so only fall back to it
454
+ # for speed_cutout, not HP_cut.
455
+ HP_cut = float(attrs.get("HP_cut", 0.25))
456
+ despike_sh = np.asarray(attrs.get("despike_sh", [8.0, 0.5, 0.04]), dtype=np.float64)
457
+ despike_A = np.asarray(attrs.get("despike_A", [np.inf, 0.5, 0.04]), dtype=np.float64)
458
+ profile_min_W = float(attrs.get("profile_min_W", gattrs.get("speed_cutout", 0.1)))
459
+ profile_min_P = float(attrs.get("profile_min_P", 1.0))
460
+ profile_min_duration = float(attrs.get("profile_min_duration", 10.0))
461
+ speed_tau = float(attrs.get("speed_tau", gattrs.get("speed_tau", 1.5)))
462
+
463
+ return L2Params(
464
+ HP_cut=HP_cut,
465
+ despike_sh=despike_sh,
466
+ despike_A=despike_A,
467
+ profile_min_W=profile_min_W,
468
+ profile_min_P=profile_min_P,
469
+ profile_min_duration=profile_min_duration,
470
+ speed_tau=speed_tau,
471
+ )
472
+
473
+
474
+ # ---------------------------------------------------------------------------
475
+ # L2 reference reader
476
+ # ---------------------------------------------------------------------------
477
+
478
+
479
+ def _read_l2(ds: netCDF4.Dataset) -> L2Data:
480
+ g = ds.groups["L2_cleaned"]
481
+ time = np.asarray(g.variables["TIME"][:], dtype=np.float64)
482
+
483
+ shear = np.asarray(g.variables["SHEAR"][:], dtype=np.float64)
484
+ section_number = np.asarray(g.variables["SECTION_NUMBER"][:], dtype=np.float64)
485
+
486
+ # PSPD_REL may be on a slower time grid (e.g. Epsifish: TIME_CTD)
487
+ pspd_var = g.variables["PSPD_REL"]
488
+ pspd_raw = np.asarray(pspd_var[:], dtype=np.float64)
489
+ if pspd_raw.shape[0] == len(time):
490
+ pspd_rel = pspd_raw
491
+ else:
492
+ pspd_dim = pspd_var.dimensions[0]
493
+ if pspd_dim in g.variables:
494
+ time_pspd = np.asarray(g.variables[pspd_dim][:], dtype=np.float64)
495
+ pspd_rel = np.interp(time, time_pspd, pspd_raw)
496
+ else:
497
+ pspd_rel = np.interp(
498
+ np.linspace(0, 1, len(time)),
499
+ np.linspace(0, 1, len(pspd_raw)),
500
+ pspd_raw,
501
+ )
502
+
503
+ if "ACC" in g.variables:
504
+ vib = np.asarray(g.variables["ACC"][:], dtype=np.float64)
505
+ vib_type = "ACC"
506
+ elif "VIB" in g.variables:
507
+ vib = np.asarray(g.variables["VIB"][:], dtype=np.float64)
508
+ vib_type = "VIB"
509
+ else:
510
+ vib = np.zeros((0, len(time)), dtype=np.float64)
511
+ vib_type = "NONE"
512
+
513
+ return L2Data(
514
+ time=time,
515
+ shear=shear,
516
+ vib=vib,
517
+ vib_type=vib_type,
518
+ pspd_rel=pspd_rel,
519
+ section_number=section_number,
520
+ )
521
+
522
+
523
+ # ---------------------------------------------------------------------------
524
+ # L3 params reader
525
+ # ---------------------------------------------------------------------------
526
+
527
+
528
+ def _read_l3_params(ds: netCDF4.Dataset) -> L3Params:
529
+ """Read L3 processing parameters from L3_spectra group attributes.
530
+
531
+ Handles three cases:
532
+ 1. ODAS-style attributes (fft_length, diss_length, overlap in samples).
533
+ 2. Epsifish-style attributes (spectral_fft_length, spectral_disslength,
534
+ overlap as percentage).
535
+ 3. No attributes — infer fft_length from the KCYC/SH_SPEC shape.
536
+ """
537
+ g = ds.groups["L3_spectra"]
538
+ attrs = {a: g.getncattr(a) for a in g.ncattrs()}
539
+
540
+ fs_fast = float(attrs.get("fs_fast", _resolve_attr(ds, "fs_fast", 512.0)))
541
+
542
+ # --- FFT length ---
543
+ if "fft_length" in attrs:
544
+ fft_length = int(attrs["fft_length"])
545
+ elif "spectral_fft_length" in attrs:
546
+ fft_length = int(attrs["spectral_fft_length"])
547
+ elif "KCYC" in g.variables:
548
+ # Infer from wavenumber dimension: n_freq = nfft/2 + 1
549
+ n_freq = g.variables["KCYC"].shape[0]
550
+ fft_length = (n_freq - 1) * 2
551
+ else:
552
+ fft_length = 512
553
+
554
+ # --- Dissipation window length ---
555
+ if "diss_length" in attrs:
556
+ diss_length = int(attrs["diss_length"])
557
+ elif "spectral_disslength" in attrs:
558
+ diss_length = int(attrs["spectral_disslength"])
559
+ else:
560
+ # Infer from reference time spacing if possible
561
+ diss_length = _infer_diss_length(g, fft_length, fs_fast)
562
+
563
+ # --- Overlap ---
564
+ if "overlap" in attrs:
565
+ overlap = int(attrs["overlap"])
566
+ elif "spectral_disslength_overlap" in attrs:
567
+ # Overlap given as percentage
568
+ overlap = int(diss_length * float(attrs["spectral_disslength_overlap"]) / 100)
569
+ else:
570
+ overlap = diss_length // 2
571
+
572
+ return L3Params(
573
+ fft_length=fft_length,
574
+ diss_length=diss_length,
575
+ overlap=overlap,
576
+ HP_cut=float(attrs.get("HP_cut", 0.25)),
577
+ fs_fast=fs_fast,
578
+ goodman=bool(attrs.get("goodman", True)),
579
+ )
580
+
581
+
582
+ def _infer_diss_length(g: netCDF4.Group, fft_length: int, fs_fast: float) -> int:
583
+ """Infer dissipation window length from reference L3 time spacing."""
584
+ if "TIME" not in g.variables or "SECTION_NUMBER" not in g.variables:
585
+ return fft_length * 4 # fallback
586
+
587
+ time = np.asarray(g.variables["TIME"][:], dtype=np.float64)
588
+ sec = np.asarray(g.variables["SECTION_NUMBER"][:], dtype=np.float64)
589
+
590
+ if len(time) < 2:
591
+ return fft_length * 4
592
+
593
+ # Use within-section time spacing to infer diss_step
594
+ dt_all = []
595
+ for sid in np.unique(sec[sec > 0]):
596
+ t_sec = time[sec == sid]
597
+ if len(t_sec) >= 2:
598
+ dt_all.extend(np.diff(t_sec).tolist())
599
+ if not dt_all:
600
+ return fft_length * 4
601
+
602
+ dt_s = np.median(dt_all) * 86400 # days → seconds
603
+ diss_step = round(dt_s * fs_fast)
604
+
605
+ # Also need total section to infer diss_length.
606
+ # Use the L2_cleaned section to find total samples, then:
607
+ # n_spectra = (sec_len - diss_length) // diss_step + 1
608
+ # → diss_length = sec_len - (n_spectra - 1) * diss_step
609
+ # But we don't have the L2 here. Use a heuristic: diss_length = 2 * diss_step
610
+ # (50% overlap), rounded to a multiple of fft_length.
611
+ # Assume 50% overlap: diss_length = 2 * diss_step.
612
+ diss_length = 2 * diss_step
613
+ return max(2 * fft_length, diss_length)
614
+
615
+
616
+ # ---------------------------------------------------------------------------
617
+ # L3 reference reader
618
+ # ---------------------------------------------------------------------------
619
+
620
+
621
+ def _read_l3(ds: netCDF4.Dataset) -> L3Data:
622
+ g = ds.groups["L3_spectra"]
623
+
624
+ time = np.asarray(g.variables["TIME"][:], dtype=np.float64)
625
+ pres = np.asarray(g.variables["PRES"][:], dtype=np.float64)
626
+ pspd_rel = np.asarray(g.variables["PSPD_REL"][:], dtype=np.float64)
627
+ section_number = np.asarray(g.variables["SECTION_NUMBER"][:], dtype=np.float64)
628
+
629
+ # NaN marks missing temperature (process_l4 falls back to 10 degC with
630
+ # a warning). A 0 degC default would silently bias viscosity +35 %
631
+ # relative to 10 degC, and epsilon with it.
632
+ temp = np.full_like(time, np.nan)
633
+ if "TEMP" in g.variables:
634
+ temp = np.asarray(g.variables["TEMP"][:], dtype=np.float64)
635
+
636
+ kcyc = np.asarray(g.variables["KCYC"][:], dtype=np.float64)
637
+ sh_spec = np.asarray(g.variables["SH_SPEC"][:], dtype=np.float64)
638
+
639
+ sh_spec_clean = sh_spec.copy()
640
+ if "SH_SPEC_CLEAN" in g.variables:
641
+ sh_spec_clean = np.asarray(g.variables["SH_SPEC_CLEAN"][:], dtype=np.float64)
642
+
643
+ return L3Data(
644
+ time=time,
645
+ pres=pres,
646
+ temp=temp,
647
+ pspd_rel=pspd_rel,
648
+ section_number=section_number,
649
+ kcyc=kcyc,
650
+ sh_spec=sh_spec,
651
+ sh_spec_clean=sh_spec_clean,
652
+ )
653
+
654
+
655
+ # ---------------------------------------------------------------------------
656
+ # L4 reference reader
657
+ # ---------------------------------------------------------------------------
658
+
659
+
660
+ def _read_l4(ds: netCDF4.Dataset) -> L4Data:
661
+ g = ds.groups["L4_dissipation"]
662
+
663
+ time = np.asarray(g.variables["TIME"][:], dtype=np.float64)
664
+ pres = np.asarray(g.variables["PRES"][:], dtype=np.float64)
665
+ pspd_rel = np.asarray(g.variables["PSPD_REL"][:], dtype=np.float64)
666
+ section_number = np.asarray(g.variables["SECTION_NUMBER"][:], dtype=np.float64)
667
+
668
+ epsi = np.asarray(g.variables["EPSI"][:], dtype=np.float64)
669
+ epsi_final = np.asarray(g.variables["EPSI_FINAL"][:], dtype=np.float64)
670
+ epsi_flags = np.asarray(g.variables["EPSI_FLAGS"][:], dtype=np.float64)
671
+ fom = np.asarray(g.variables["FOM"][:], dtype=np.float64)
672
+ mad = np.asarray(g.variables["MAD"][:], dtype=np.float64)
673
+ kmax = np.asarray(g.variables["KMAX"][:], dtype=np.float64)
674
+ method = np.asarray(g.variables["METHOD"][:], dtype=np.float64)
675
+ var_resolved = np.asarray(g.variables["VAR_RESOLVED"][:], dtype=np.float64)
676
+
677
+ return L4Data(
678
+ time=time,
679
+ pres=pres,
680
+ pspd_rel=pspd_rel,
681
+ section_number=section_number,
682
+ epsi=epsi,
683
+ epsi_final=epsi_final,
684
+ epsi_flags=epsi_flags,
685
+ fom=fom,
686
+ mad=mad,
687
+ kmax=kmax,
688
+ method=method,
689
+ var_resolved=var_resolved,
690
+ )