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,527 @@
1
+ # Mar-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """Shared helpers for loading instrument data and preparing profiles.
3
+
4
+ These functions bridge PFile/NetCDF reading with spectral processing.
5
+ Originally part of dissipation.py, extracted so that both epsilon and
6
+ chi packages can use them without circular dependencies.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import re
13
+ import warnings
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any, TypedDict
16
+
17
+ import numpy as np
18
+ import numpy.typing as npt
19
+
20
+ if TYPE_CHECKING:
21
+ import xarray as xr
22
+
23
+ from odas_tpw.rsi.p_file import PFile
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class ChannelsDict(TypedDict, total=False):
29
+ """Type for the dict returned by :func:`load_channels`."""
30
+
31
+ shear: list[tuple[str, np.ndarray]]
32
+ accel: list[tuple[str, np.ndarray]]
33
+ P: np.ndarray
34
+ T: np.ndarray
35
+ t_fast: np.ndarray
36
+ t_slow: np.ndarray
37
+ fs_fast: float
38
+ fs_slow: float
39
+ is_profile: bool
40
+ metadata: dict[str, str]
41
+ vehicle: str
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Channel name patterns for RSI instruments
46
+ # ---------------------------------------------------------------------------
47
+
48
+ SH_PATTERN = re.compile(r"^sh\d+$")
49
+ AC_PATTERN = re.compile(r"^A[xyz]$")
50
+ T_PATTERN = re.compile(r"^T\d+$")
51
+ DT_PATTERN = re.compile(r"^T\d+_dT\d+$")
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Channel loading
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ def load_channels(
59
+ source: PFile | str | Path,
60
+ shear_pattern: str = r"^sh\d+$",
61
+ accel_pattern: str = r"^A[xyz]$",
62
+ pressure_name: str = "P",
63
+ temperature_name: str = "T1",
64
+ ) -> ChannelsDict:
65
+ """Load channel data from any supported source.
66
+
67
+ Parameters
68
+ ----------
69
+ source : PFile, str, or Path
70
+ A PFile object, .p file path, full-record .nc path,
71
+ or per-profile .nc path.
72
+ shear_pattern : str
73
+ Regex pattern matching shear channel names.
74
+ accel_pattern : str
75
+ Regex pattern matching accelerometer channel names.
76
+ pressure_name : str
77
+ Name of the pressure channel.
78
+ temperature_name : str
79
+ Name of the temperature channel.
80
+
81
+ Returns
82
+ -------
83
+ dict with keys:
84
+ shear : list of (name, ndarray) — shear probe signals
85
+ accel : list of (name, ndarray) — accelerometer signals
86
+ P : ndarray — pressure (slow)
87
+ T : ndarray — temperature (slow)
88
+ t_fast : ndarray — fast time vector
89
+ t_slow : ndarray — slow time vector
90
+ fs_fast : float
91
+ fs_slow : float
92
+ is_profile : bool — whether source is a per-profile file
93
+ metadata : dict
94
+ """
95
+ from odas_tpw.rsi.p_file import PFile
96
+
97
+ if isinstance(source, PFile):
98
+ return _channels_from_pfile(
99
+ source, shear_pattern, accel_pattern, pressure_name, temperature_name
100
+ )
101
+
102
+ source = Path(source)
103
+ if source.suffix.lower() == ".p":
104
+ pf = PFile(source)
105
+ return _channels_from_pfile(
106
+ pf, shear_pattern, accel_pattern, pressure_name, temperature_name
107
+ )
108
+ elif source.suffix.lower() == ".nc":
109
+ return _channels_from_nc(
110
+ source, shear_pattern, accel_pattern, pressure_name, temperature_name
111
+ )
112
+ else:
113
+ raise ValueError(f"Unsupported file type: {source.suffix}")
114
+
115
+
116
+ def _channels_from_pfile(
117
+ pf: PFile, sh_pat: str, ac_pat: str, p_name: str, t_name: str
118
+ ) -> ChannelsDict:
119
+ sh_re = re.compile(sh_pat) if sh_pat != SH_PATTERN.pattern else SH_PATTERN
120
+ ac_re = re.compile(ac_pat) if ac_pat != AC_PATTERN.pattern else AC_PATTERN
121
+ shear = sorted(
122
+ [(n, pf.channels[n]) for n in pf._fast_channels if sh_re.match(n)],
123
+ key=lambda x: x[0],
124
+ )
125
+ accel = sorted(
126
+ [(n, pf.channels[n]) for n in pf._fast_channels if ac_re.match(n)],
127
+ key=lambda x: x[0],
128
+ )
129
+ out: dict[str, Any] = {
130
+ "shear": shear,
131
+ "accel": accel,
132
+ "P": pf.channels[p_name],
133
+ "T": pf.channels[t_name],
134
+ "t_fast": pf.t_fast,
135
+ "t_slow": pf.t_slow,
136
+ "fs_fast": pf.fs_fast,
137
+ "fs_slow": pf.fs_slow,
138
+ "is_profile": False,
139
+ "vehicle": pf.config["instrument_info"].get("vehicle", "").lower(),
140
+ "metadata": {
141
+ "source": str(pf.filepath),
142
+ "instrument": pf.config["instrument_info"].get("model", ""),
143
+ "sn": pf.config["instrument_info"].get("sn", ""),
144
+ "start_time": pf.start_time.isoformat(),
145
+ },
146
+ }
147
+ # Carry through the perturb-injected speed channel(s) if present.
148
+ if "speed_fast" in pf.channels:
149
+ out["speed_fast"] = pf.channels["speed_fast"]
150
+ if "W_slow" in pf.channels:
151
+ out["W_slow"] = pf.channels["W_slow"]
152
+ return out
153
+
154
+
155
+ def _channels_from_nc(
156
+ nc_path: Path, sh_pat: str, ac_pat: str, p_name: str, t_name: str
157
+ ) -> ChannelsDict:
158
+ import netCDF4 as nc
159
+
160
+ ds = nc.Dataset(str(nc_path), "r")
161
+ sh_re = re.compile(sh_pat) if sh_pat != SH_PATTERN.pattern else SH_PATTERN
162
+ ac_re = re.compile(ac_pat) if ac_pat != AC_PATTERN.pattern else AC_PATTERN
163
+
164
+ fs_fast = float(ds.fs_fast)
165
+ fs_slow = float(ds.fs_slow)
166
+ is_profile = hasattr(ds, "profile_number")
167
+
168
+ t_fast = ds.variables["t_fast"][:].data
169
+ t_slow = ds.variables["t_slow"][:].data
170
+ P = ds.variables[p_name][:].data.astype(np.float64)
171
+ T = ds.variables[t_name][:].data.astype(np.float64)
172
+
173
+ shear = []
174
+ accel = []
175
+ for vname in sorted(ds.variables.keys()):
176
+ var = ds.variables[vname]
177
+ if var.dimensions == ("time_fast",):
178
+ data = var[:].data.astype(np.float64)
179
+ if sh_re.match(vname):
180
+ shear.append((vname, data))
181
+ elif ac_re.match(vname):
182
+ accel.append((vname, data))
183
+
184
+ metadata = {"source": str(nc_path)}
185
+ for attr in ("instrument_model", "instrument_sn", "source_file", "start_time"):
186
+ if hasattr(ds, attr):
187
+ metadata[attr] = getattr(ds, attr)
188
+
189
+ vehicle = ""
190
+ if hasattr(ds, "vehicle"):
191
+ vehicle = ds.vehicle.lower()
192
+ elif hasattr(ds, "instrument_model"):
193
+ model = ds.instrument_model.lower()
194
+ if "vmp" in model:
195
+ vehicle = "vmp"
196
+ elif "xmp" in model:
197
+ vehicle = "xmp"
198
+ elif "mr" in model or "microrider" in model:
199
+ vehicle = "slocum_glider"
200
+
201
+ speed_fast = None
202
+ W_slow = None
203
+ if "speed_fast" in ds.variables:
204
+ speed_fast = ds.variables["speed_fast"][:].data.astype(np.float64)
205
+ if "W_slow" in ds.variables:
206
+ W_slow = ds.variables["W_slow"][:].data.astype(np.float64)
207
+
208
+ ds.close()
209
+
210
+ out: dict[str, Any] = {
211
+ "shear": shear,
212
+ "accel": accel,
213
+ "P": P,
214
+ "T": T,
215
+ "t_fast": t_fast,
216
+ "t_slow": t_slow,
217
+ "fs_fast": fs_fast,
218
+ "fs_slow": fs_slow,
219
+ "is_profile": is_profile,
220
+ "vehicle": vehicle,
221
+ "metadata": metadata,
222
+ }
223
+ if speed_fast is not None:
224
+ out["speed_fast"] = speed_fast
225
+ if W_slow is not None:
226
+ out["W_slow"] = W_slow
227
+ return out
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Profile preparation
232
+ # ---------------------------------------------------------------------------
233
+
234
+
235
+ def prepare_profiles(
236
+ data: ChannelsDict | dict[str, Any],
237
+ speed: float | None,
238
+ direction: str,
239
+ salinity: npt.ArrayLike | None,
240
+ tau: float | None = None,
241
+ speed_cutout: float = 0.05,
242
+ vehicle: str | None = None,
243
+ ) -> tuple | None:
244
+ """Profile detection, speed computation, and salinity interpolation.
245
+
246
+ Matches the ODAS odas_p2mat.m speed pipeline:
247
+ 1. W = gradient(P) filtered with Butterworth at 0.68/tau
248
+ 2. speed = abs(W)
249
+ 3. speed filtered again with Butterworth at 0.68/tau
250
+ 4. speed clamped to speed_cutout minimum
251
+
252
+ ``tau=None`` (default) resolves the smoothing time constant from the
253
+ vehicle table; pass an explicit value to override it.
254
+
255
+ Returns (profiles_slow, speed_fast, P_fast, T_fast, sal_fast, fs_fast,
256
+ fs_slow, ratio, t_fast).
257
+ """
258
+ from odas_tpw.rsi.vehicle import resolve_direction, resolve_tau
259
+
260
+ fs_fast = data["fs_fast"]
261
+ fs_slow = data["fs_slow"]
262
+ ratio = round(fs_fast / fs_slow)
263
+
264
+ P_slow = data["P"]
265
+ T_slow = data["T"]
266
+ t_fast = data["t_fast"]
267
+ t_slow = data["t_slow"]
268
+
269
+ # Resolve vehicle, direction, and tau
270
+ if vehicle is None:
271
+ vehicle = data.get("vehicle", "")
272
+ direction = resolve_direction(direction, vehicle)
273
+ if tau is None:
274
+ tau = resolve_tau(vehicle)
275
+
276
+ if data["is_profile"]:
277
+ profiles_slow = [(0, len(P_slow) - 1)]
278
+ else:
279
+ from odas_tpw.rsi.profile import _smooth_fall_rate, get_profiles
280
+
281
+ W_slow = _smooth_fall_rate(P_slow, fs_slow, tau=tau)
282
+ profiles_slow = get_profiles(P_slow, W_slow, fs_slow, direction=direction)
283
+ if not profiles_slow:
284
+ return None
285
+
286
+ # Prefer a precomputed ``speed_fast`` channel if the caller (perturb
287
+ # pipeline post-hotel-merge) has injected one; otherwise fall back to
288
+ # the historical ODAS pressure-rate path.
289
+ precomputed = data.get("speed_fast") if hasattr(data, "get") else None
290
+ if speed is not None:
291
+ speed_fast = np.full(len(t_fast), abs(speed))
292
+ elif precomputed is not None and len(precomputed) == len(t_fast):
293
+ speed_fast = np.asarray(precomputed, dtype=np.float64)
294
+ else:
295
+ from odas_tpw.scor160.profile import compute_speed_fast
296
+
297
+ if direction in ("glide", "horizontal"):
298
+ # ODAS uses a flight model (glide) or EM current meter /
299
+ # hotel speed (horizontal) for these vehicles; |dP/dt|
300
+ # underestimates the flow past the sensors, and epsilon has
301
+ # roughly U^4 leverage on the speed through the shear
302
+ # conversion and wavenumber transform.
303
+ warnings.warn(
304
+ f"Vehicle direction '{direction}' but speed is being computed "
305
+ "from |dP/dt|; provide an explicit speed or a precomputed "
306
+ "speed_fast channel for glider/horizontal platforms — "
307
+ "epsilon scales as ~U^4 and will be strongly biased",
308
+ stacklevel=2,
309
+ )
310
+ speed_fast, _W_slow = compute_speed_fast(
311
+ P_slow,
312
+ t_fast,
313
+ t_slow,
314
+ fs_fast,
315
+ fs_slow,
316
+ tau=tau,
317
+ speed_min=speed_cutout,
318
+ )
319
+
320
+ # Floor (and NaN-scrub) speed once at this choke point so every consumer
321
+ # gets a clean, positive speed: shear normalization (shear /= speed**2) and
322
+ # the chi wavenumber / fp07 axis both blow up on a zero/NaN speed. The
323
+ # fixed-speed and precomputed branches are otherwise unfloored;
324
+ # compute_speed_fast already floors, so this is a no-op there.
325
+ speed_fast = np.maximum(
326
+ np.nan_to_num(np.asarray(speed_fast, dtype=np.float64), nan=speed_cutout),
327
+ speed_cutout,
328
+ )
329
+
330
+ P_fast = np.interp(t_fast, t_slow, P_slow)
331
+ T_fast = np.interp(t_fast, t_slow, T_slow)
332
+
333
+ if salinity is not None:
334
+ salinity = np.asarray(salinity, dtype=float)
335
+ if salinity.ndim > 0:
336
+ if len(salinity) == len(t_slow):
337
+ sal_fast = np.interp(t_fast, t_slow, salinity)
338
+ elif len(salinity) == len(t_fast):
339
+ sal_fast = salinity
340
+ else:
341
+ raise ValueError(
342
+ f"salinity array length {len(salinity)} doesn't match "
343
+ f"slow ({len(t_slow)}) or fast ({len(t_fast)}) time series"
344
+ )
345
+ else:
346
+ sal_fast = float(salinity)
347
+ else:
348
+ sal_fast = None
349
+
350
+ return (profiles_slow, speed_fast, P_fast, T_fast, sal_fast, fs_fast, fs_slow, ratio, t_fast)
351
+
352
+
353
+ # ---------------------------------------------------------------------------
354
+ # L1Data construction from load_channels output
355
+ # ---------------------------------------------------------------------------
356
+
357
+
358
+ def _build_l1data_from_channels(
359
+ data: ChannelsDict | dict[str, Any],
360
+ s_fast: int,
361
+ e_fast: int,
362
+ speed_fast: np.ndarray,
363
+ P_fast: np.ndarray,
364
+ T_fast: np.ndarray,
365
+ direction: str = "down",
366
+ *,
367
+ therm_list: list[tuple[str, np.ndarray]] | None = None,
368
+ diff_gains: list[float] | None = None,
369
+ ) -> Any:
370
+ """Build L1Data from load_channels output and interpolated profile arrays.
371
+
372
+ Parameters
373
+ ----------
374
+ data : dict
375
+ Output of load_channels().
376
+ s_fast, e_fast : int
377
+ Fast-rate slice indices for this profile.
378
+ speed_fast : ndarray
379
+ Full-length speed array (fast rate).
380
+ P_fast, T_fast : ndarray
381
+ Full-length pressure and temperature (interpolated to fast rate).
382
+ direction : str
383
+ Profile direction.
384
+ therm_list : list of (name, array), optional
385
+ Thermistor data for chi computation.
386
+ diff_gains : list of float, optional
387
+ Per-thermistor differentiator gains.
388
+ """
389
+ from odas_tpw.scor160.io import L1Data
390
+
391
+ shear_arrays = [s[1] for s in data["shear"]]
392
+ accel_arrays = [a[1] for a in data["accel"]]
393
+ fs_fast = data["fs_fast"]
394
+ n = e_fast - s_fast
395
+
396
+ # Defensive floor (0.05 m/s cutout): callers normally pass a speed already
397
+ # floored by prepare_profiles, but a zero/tiny speed here would blow up the
398
+ # shear/speed**2 normalization. Matches adapter.pfile_to_l1data and speed.py.
399
+ speed_prof = np.maximum(speed_fast[s_fast:e_fast], 0.05)
400
+
401
+ # Shear: normalize by speed^2
402
+ if shear_arrays:
403
+ shear = np.stack(
404
+ [arr[s_fast:e_fast] / speed_prof**2 for arr in shear_arrays],
405
+ axis=0,
406
+ )
407
+ else:
408
+ shear = np.zeros((0, n), dtype=np.float64)
409
+
410
+ # Vibration/accelerometer
411
+ if accel_arrays:
412
+ vib = np.stack([arr[s_fast:e_fast] for arr in accel_arrays], axis=0)
413
+ vib_type = "ACC"
414
+ else:
415
+ vib = np.zeros((0, n), dtype=np.float64)
416
+ vib_type = "NONE"
417
+
418
+ # Optional fast temperature
419
+ if therm_list:
420
+ tf = np.stack([arr[s_fast:e_fast] for _, arr in therm_list], axis=0)
421
+ else:
422
+ tf = np.zeros((0, 0), dtype=np.float64)
423
+
424
+ return L1Data(
425
+ time=data["t_fast"][s_fast:e_fast],
426
+ pres=P_fast[s_fast:e_fast],
427
+ shear=shear,
428
+ vib=vib,
429
+ vib_type=vib_type,
430
+ fs_fast=fs_fast,
431
+ f_AA=98.0,
432
+ vehicle=data.get("vehicle", ""),
433
+ profile_dir=direction,
434
+ time_reference_year=2000,
435
+ pspd_rel=speed_prof,
436
+ temp=T_fast[s_fast:e_fast],
437
+ temp_fast=tf,
438
+ diff_gains=diff_gains or [],
439
+ )
440
+
441
+
442
+ # ---------------------------------------------------------------------------
443
+ # Shared file-level output
444
+ # ---------------------------------------------------------------------------
445
+
446
+
447
+ def write_profile_results(
448
+ results: list,
449
+ source_path: Path,
450
+ output_dir: Path,
451
+ suffix: str,
452
+ ) -> list[Path]:
453
+ """Write per-profile xarray Datasets to NetCDF files.
454
+
455
+ Parameters
456
+ ----------
457
+ results : list of xr.Dataset
458
+ Per-profile results.
459
+ source_path : Path
460
+ Original source file (stem used for naming).
461
+ output_dir : Path
462
+ Output directory (created if needed).
463
+ suffix : str
464
+ File suffix, e.g. 'eps' or 'chi'.
465
+
466
+ Returns
467
+ -------
468
+ list of Path
469
+ Paths to output files written.
470
+ """
471
+ output_dir.mkdir(parents=True, exist_ok=True)
472
+ output_paths: list[Path] = []
473
+ for i, ds in enumerate(results):
474
+ if len(results) == 1:
475
+ out_name = f"{source_path.stem}_{suffix}.nc"
476
+ else:
477
+ out_name = f"{source_path.stem}_prof{i + 1:03d}_{suffix}.nc"
478
+ out_path = output_dir / out_name
479
+ ds.to_netcdf(out_path)
480
+ output_paths.append(out_path)
481
+ logger.info(
482
+ f"{out_path.name}: {ds.sizes['time']} estimates, "
483
+ f"P={float(ds.P_mean.min()):.0f}-{float(ds.P_mean.max()):.0f} dbar"
484
+ )
485
+ return output_paths
486
+
487
+
488
+ # ---------------------------------------------------------------------------
489
+ # Shared dataset builder
490
+ # ---------------------------------------------------------------------------
491
+
492
+
493
+ def _build_result_dataset(
494
+ variables: list[tuple[str, list[str], np.ndarray, dict]],
495
+ probe_names: list[str],
496
+ t_out: np.ndarray,
497
+ probe_long_name: str,
498
+ global_attrs: dict,
499
+ ) -> xr.Dataset:
500
+ """Build an xarray Dataset from a list of variable specs.
501
+
502
+ Parameters
503
+ ----------
504
+ variables : list of (name, dims, data, attrs)
505
+ Variable definitions.
506
+ probe_names : list of str
507
+ Probe/sensor coordinate labels.
508
+ t_out : ndarray
509
+ Time coordinate values.
510
+ probe_long_name : str
511
+ Long name for the probe coordinate.
512
+ global_attrs : dict
513
+ Dataset-level attributes.
514
+ """
515
+ import xarray as xr
516
+
517
+ data_vars = {name: (dims, data, attrs) for name, dims, data, attrs in variables}
518
+ ds = xr.Dataset(
519
+ data_vars,
520
+ coords={
521
+ "probe": probe_names,
522
+ "t": (["time"], t_out),
523
+ },
524
+ attrs=global_attrs,
525
+ )
526
+ ds.coords["probe"].attrs["long_name"] = probe_long_name
527
+ return ds