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,624 @@
1
+ # Mar-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """Profile detection and extraction for vertical profilers.
3
+
4
+ Profile detection and smoothed fall-rate computation live in
5
+ :mod:`odas_tpw.scor160.profile` (instrument-independent). This module
6
+ re-exports them for backward compatibility and adds RSI-specific I/O
7
+ (NetCDF extraction from PFile).
8
+ """
9
+
10
+ import logging
11
+ import os
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Any, Literal, overload
15
+
16
+ import numpy as np
17
+
18
+ if TYPE_CHECKING:
19
+ from odas_tpw.rsi.p_file import PFile
20
+
21
+ # Re-export from scor160 — canonical implementations live there
22
+ from odas_tpw.scor160.profile import get_profiles, smooth_fall_rate
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Default longest slow-pressure gap [s] still treated as an isolated fill and
27
+ # linearly repaired. Longer non-finite spans are interpolated only so the
28
+ # fall-rate filter has a NaN-free array; profiles overlapping such a span are
29
+ # dropped rather than detected over fabricated pressure (audit / PR #79).
30
+ _MAX_REPAIR_GAP_S = 1.0
31
+
32
+ # Backward-compatible alias (previously underscore-prefixed)
33
+ _smooth_fall_rate = smooth_fall_rate
34
+
35
+
36
+ # Backward-compatible alias — computed from VEHICLE_ATTRIBUTES
37
+ def _build_vehicle_tau() -> dict[str, float]:
38
+ from odas_tpw.rsi.vehicle import VEHICLE_ATTRIBUTES
39
+
40
+ return {k: v[1] for k, v in VEHICLE_ATTRIBUTES.items()}
41
+
42
+
43
+ _VEHICLE_TAU = _build_vehicle_tau()
44
+
45
+
46
+ @overload
47
+ def extract_profiles(
48
+ source: "PFile | str | Path",
49
+ output_dir: str | Path,
50
+ profiles: list[tuple[int, int]] | None = ...,
51
+ gps: Any = ...,
52
+ return_scalars: Literal[False] = ...,
53
+ output_stem: str | None = ...,
54
+ max_repair_gap_s: float = ...,
55
+ **profile_kwargs: Any,
56
+ ) -> list[Path]: ...
57
+
58
+
59
+ @overload
60
+ def extract_profiles(
61
+ source: "PFile | str | Path",
62
+ output_dir: str | Path,
63
+ profiles: list[tuple[int, int]] | None = ...,
64
+ gps: Any = ...,
65
+ *,
66
+ return_scalars: Literal[True],
67
+ output_stem: str | None = ...,
68
+ max_repair_gap_s: float = ...,
69
+ **profile_kwargs: Any,
70
+ ) -> tuple[list[Path], list[dict[str, float]]]: ...
71
+
72
+
73
+ def extract_profiles(
74
+ source: "PFile | str | Path",
75
+ output_dir: str | Path,
76
+ profiles: list[tuple[int, int]] | None = None,
77
+ gps: Any = None,
78
+ return_scalars: bool = False,
79
+ output_stem: str | None = None,
80
+ max_repair_gap_s: float = _MAX_REPAIR_GAP_S,
81
+ **profile_kwargs: Any,
82
+ ) -> list[Path] | tuple[list[Path], list[dict[str, float]]]:
83
+ """Extract profiles from a PFile or full-record NetCDF.
84
+
85
+ Parameters
86
+ ----------
87
+ source : PFile, str, or Path
88
+ A PFile object, path to .p file, or path to full-record .nc file.
89
+ output_dir : Path
90
+ Directory for per-profile NetCDF files.
91
+ profiles : list of (start, end) tuples, optional
92
+ Pre-computed profile bounds (slow-rate indices). If supplied, the
93
+ internal call to :func:`get_profiles` is skipped — use this when
94
+ the caller has already adjusted the bounds (e.g. via top-trim or
95
+ bottom-crash detection).
96
+ gps : GPSProvider, optional
97
+ If supplied, lat/lon/stime/etime are written as scalar coordinate
98
+ variables on each per-profile NetCDF for CF §9 profile featureType
99
+ compliance. ``gps`` must expose ``lat(t)`` and ``lon(t)`` taking a
100
+ time array in *seconds since pf.start_time* (the same domain as
101
+ ``pf.t_slow``).
102
+ return_scalars : bool, default False
103
+ When True, also return a list of dicts (parallel to paths) holding
104
+ the per-profile scalar values written: ``{"lat", "lon", "stime",
105
+ "etime"}`` (each missing if not written). The pipeline uses this
106
+ to skip re-opening every profile NetCDF in the diss/chi steps.
107
+ output_stem : str, optional
108
+ Override the filename/global-title stem used for per-profile NetCDFs.
109
+ When omitted, the source file stem is used.
110
+ max_repair_gap_s : float, default 1.0
111
+ Longest slow-pressure non-finite span [s] still treated as an
112
+ isolated fill and linearly repaired. Longer gaps are interpolated
113
+ only so the fall-rate filter has a NaN-free array; any profile
114
+ overlapping such a fabricated span is dropped (relevant only to
115
+ external/partial NetCDF inputs — raw ``.p`` pressure has no fills).
116
+ **profile_kwargs
117
+ Keyword arguments passed to get_profiles (P_min, W_min, etc.).
118
+
119
+ Returns
120
+ -------
121
+ list of Path
122
+ Paths to per-profile NetCDF files written.
123
+ list of dict
124
+ Only when ``return_scalars=True``: per-profile raw scalar values
125
+ (lat/lon as float64, stime/etime as epoch seconds float64).
126
+ """
127
+ import netCDF4 as nc
128
+
129
+ output_dir = Path(output_dir)
130
+ output_dir.mkdir(parents=True, exist_ok=True)
131
+
132
+ data = _load_source(source)
133
+ stem = output_stem or data["stem"]
134
+ fs_slow = data["fs_slow"]
135
+ fs_fast = data["fs_fast"]
136
+ # Defect (audit M2 downstream): a single NaN in slow pressure (e.g. a
137
+ # _FillValue gap from a partial/external NetCDF) is smeared across the
138
+ # whole fall rate by smooth_fall_rate (gradient + zero-phase filtfilt),
139
+ # silently dropping every profile. Repair isolated NaNs before detection;
140
+ # long_gap flags fabricated long spans whose profiles are dropped below.
141
+ max_gap = max(1, round(max_repair_gap_s * fs_slow))
142
+ P_slow, long_gap = _repair_nans(data["P"], "P_slow", stem, max_gap)
143
+ ratio = round(fs_fast / fs_slow)
144
+ start_epoch_s = data.get("start_epoch_s")
145
+
146
+ # Compute smoothed fall rate from slow pressure
147
+ W = _smooth_fall_rate(P_slow, fs_slow)
148
+
149
+ if profiles is None:
150
+ profiles = get_profiles(P_slow, W, fs_slow, **profile_kwargs)
151
+ if long_gap.any():
152
+ # Drop profiles overlapping a fabricated long gap (their pressure /
153
+ # boundaries are interpolated, not measured). Other casts survive.
154
+ kept = [(s, e) for (s, e) in profiles if not long_gap[s : e + 1].any()]
155
+ n_dropped = len(profiles) - len(kept)
156
+ if n_dropped:
157
+ logger.warning(
158
+ "%s: dropped %d profile(s) overlapping a fabricated pressure gap",
159
+ stem,
160
+ n_dropped,
161
+ )
162
+ profiles = kept
163
+ if not profiles:
164
+ logger.warning(f"No profiles found in {stem}")
165
+ return ([], []) if return_scalars else []
166
+
167
+ # Lazy import: reaching perturb.atomic_io runs perturb/__init__ (config +
168
+ # pipeline). Deferring it keeps that heavier perturb graph out of rsi at
169
+ # module-load time (not a hard cycle — pipeline imports rsi.profile only
170
+ # lazily — just a layering choice). Mirrors the netcdf_schema import below.
171
+ from odas_tpw.perturb.atomic_io import tmp_sibling
172
+
173
+ output_paths = []
174
+ output_scalars: list[dict[str, float]] = []
175
+ for pi, (s_slow, e_slow) in enumerate(profiles, 1):
176
+ s_fast = s_slow * ratio
177
+ # Clamp to the fast-axis length (matches _compute_epsilon): for a
178
+ # full-record final profile (e_slow+1)*ratio can exceed len(t_fast) and
179
+ # over-run the fast slices / size the time_fast dim too large.
180
+ e_fast = min((e_slow + 1) * ratio, len(data["t_fast"]))
181
+ s_slow_end = e_slow + 1
182
+
183
+ prof_path = output_dir / f"{stem}_prof{pi:03d}.nc"
184
+
185
+ # Write to a temp sibling and os.replace into place only after a clean
186
+ # close, so a mid-write SMB/network drop on SeaChest never leaves a
187
+ # readable partial *_profNNN.nc that the manifest then locks in on a
188
+ # clean retry (identical source .p key -> skip). (#104 U5-2.)
189
+ prof_tmp = tmp_sibling(prof_path)
190
+ ds = nc.Dataset(str(prof_tmp), "w", format="NETCDF4")
191
+
192
+ # Copy global attributes
193
+ for attr in data["global_attrs"]:
194
+ setattr(ds, attr, data["global_attrs"][attr])
195
+
196
+ ds.Conventions = "CF-1.13, ACDD-1.3"
197
+ ds.title = f"{stem} profile {pi}"
198
+ ds.featureType = "profile"
199
+
200
+ # Profile metadata
201
+ ds.profile_number = pi
202
+ ds.profile_start_index_slow = int(s_slow)
203
+ ds.profile_end_index_slow = int(e_slow)
204
+ ds.profile_P_start = float(P_slow[s_slow])
205
+ ds.profile_P_end = float(P_slow[e_slow])
206
+ ds.profile_duration_s = float((e_slow - s_slow) / fs_slow)
207
+ ds.profile_mean_speed = float(np.mean(np.abs(W[s_slow:s_slow_end])))
208
+ ds.history = f"Profile {pi} extracted on {datetime.now(UTC).isoformat()}"
209
+
210
+ # Per-profile scalar coordinate variables (CF §9 profile featureType).
211
+ # Keep these on the slow-rate timeline; gps must be in seconds since
212
+ # the file's start_time, matching ``data["t_slow"]`` semantics.
213
+ t_prof_s = float(data["t_slow"][s_slow])
214
+ t_end_s = float(data["t_slow"][e_slow])
215
+ scalars: dict[str, float] = {}
216
+ if start_epoch_s is not None:
217
+ stime_epoch = start_epoch_s + t_prof_s
218
+ etime_epoch = start_epoch_s + t_end_s
219
+ stime_var = ds.createVariable("stime", "f8", ())
220
+ stime_var[...] = stime_epoch
221
+ stime_var.units = "seconds since 1970-01-01"
222
+ stime_var.standard_name = "time"
223
+ stime_var.long_name = "profile start time"
224
+ stime_var.calendar = "standard"
225
+ stime_var.units_metadata = "leap_seconds: utc"
226
+ stime_var.axis = "T"
227
+
228
+ etime_var = ds.createVariable("etime", "f8", ())
229
+ etime_var[...] = etime_epoch
230
+ etime_var.units = "seconds since 1970-01-01"
231
+ etime_var.standard_name = "time"
232
+ etime_var.long_name = "profile end time"
233
+ etime_var.calendar = "standard"
234
+ etime_var.units_metadata = "leap_seconds: utc"
235
+ scalars["stime"] = stime_epoch
236
+ scalars["etime"] = etime_epoch
237
+ if gps is not None:
238
+ # GPSProvider expects epoch seconds — t_prof_s is file-relative.
239
+ t_query = np.array([t_prof_s + (start_epoch_s or 0.0)])
240
+ lat_val = float(np.asarray(gps.lat(t_query))[0])
241
+ lon_val = float(np.asarray(gps.lon(t_query))[0])
242
+ lat_var = ds.createVariable("lat", "f8", ())
243
+ lat_var[...] = lat_val
244
+ lat_var.units = "degrees_north"
245
+ lat_var.standard_name = "latitude"
246
+ lat_var.long_name = "profile latitude"
247
+ lat_var.axis = "Y"
248
+ lon_var = ds.createVariable("lon", "f8", ())
249
+ lon_var[...] = lon_val
250
+ lon_var.units = "degrees_east"
251
+ lon_var.standard_name = "longitude"
252
+ lon_var.long_name = "profile longitude"
253
+ lon_var.axis = "X"
254
+ scalars["lat"] = lat_val
255
+ scalars["lon"] = lon_val
256
+
257
+ n_fast = e_fast - s_fast
258
+ n_slow = s_slow_end - s_slow
259
+ ds.createDimension("time_fast", n_fast)
260
+ ds.createDimension("time_slow", n_slow)
261
+
262
+ # Time variables
263
+ t_fast_var = ds.createVariable("t_fast", "f8", ("time_fast",), zlib=True)
264
+ t_fast_var[:] = data["t_fast"][s_fast:e_fast]
265
+ t_fast_var.units = data.get("t_fast_units", "seconds")
266
+ t_fast_var.long_name = "time (fast channels)"
267
+ t_fast_var.standard_name = "time"
268
+ t_fast_var.calendar = "standard"
269
+ t_fast_var.units_metadata = "leap_seconds: utc"
270
+ t_fast_var.axis = "T"
271
+
272
+ t_slow_var = ds.createVariable("t_slow", "f8", ("time_slow",), zlib=True)
273
+ t_slow_var[:] = data["t_slow"][s_slow:s_slow_end]
274
+ t_slow_var.units = data.get("t_slow_units", "seconds")
275
+ t_slow_var.long_name = "time (slow channels)"
276
+ t_slow_var.standard_name = "time"
277
+ t_slow_var.calendar = "standard"
278
+ t_slow_var.units_metadata = "leap_seconds: utc"
279
+ t_slow_var.axis = "T"
280
+
281
+ # Channel data — RSI's INI emits unit strings like ``umol_L-1`` and
282
+ # ``deg`` that UDUNITS won't parse; canonicalise via the schema.
283
+ from odas_tpw.perturb.netcdf_schema import COMBO_SCHEMA, canonicalize_units
284
+
285
+ for ch_name, ch_data, dim, attrs in data["channels"]:
286
+ trimmed = ch_data[s_fast:e_fast] if dim == "time_fast" else ch_data[s_slow:s_slow_end]
287
+ var_name = ch_name.replace(" ", "_")
288
+ var = ds.createVariable(var_name, "f4", (dim,), zlib=True)
289
+ var[:] = trimmed.astype(np.float32)
290
+ schema_entry = COMBO_SCHEMA.get(var_name, {})
291
+ for k, v in attrs.items():
292
+ # netCDF4 forbids setting these reserved attrs after creation
293
+ # (e.g. _FillValue must be passed to createVariable). An
294
+ # external/CF/ATOMIX source that declares a _FillValue would
295
+ # otherwise crash the per-profile write with AttributeError.
296
+ # The data is already NaN-filled (see _nc_filled), so dropping
297
+ # _FillValue here is lossless.
298
+ if k in _UNSETTABLE_NC_ATTRS:
299
+ continue
300
+ if k == "units":
301
+ v = canonicalize_units(str(v))
302
+ setattr(var, k, v)
303
+ # Layer the canonical schema attrs (long_name, standard_name,
304
+ # units_metadata, …) on top — schema wins where it has an opinion.
305
+ for k, v in schema_entry.items():
306
+ if k != "nc_name":
307
+ setattr(var, k, v)
308
+
309
+ ds.fs_fast = float(fs_fast)
310
+ ds.fs_slow = float(fs_slow)
311
+ ds.close()
312
+ # Publish atomically only after a complete close (U5-2): os.replace is
313
+ # atomic within a filesystem, so prof_path never appears as a partial.
314
+ # A mid-write crash leaves the hidden temp behind, but never a readable
315
+ # partial product that the bin/combo manifest would lock in.
316
+ os.replace(str(prof_tmp), str(prof_path))
317
+ output_paths.append(prof_path)
318
+ output_scalars.append(scalars)
319
+ logger.info(
320
+ f"Profile {pi}: P={P_slow[s_slow]:.1f}–{P_slow[e_slow]:.1f} dbar, " # noqa: RUF001
321
+ f"{(e_slow - s_slow) / fs_slow:.1f} s -> {prof_path.name}"
322
+ )
323
+
324
+ if return_scalars:
325
+ return output_paths, output_scalars
326
+ return output_paths
327
+
328
+
329
+ def _load_source(source: "PFile | str | Path") -> dict[str, Any]:
330
+ """Load data from PFile, .p path, or .nc path into a common dict."""
331
+ from odas_tpw.rsi.p_file import PFile
332
+
333
+ if isinstance(source, PFile):
334
+ return _load_from_pfile(source)
335
+
336
+ source = Path(source)
337
+ if source.suffix.lower() == ".p":
338
+ return _load_from_pfile(PFile(source))
339
+ elif source.suffix.lower() == ".nc":
340
+ return _load_from_nc(source)
341
+ else:
342
+ raise ValueError(f"Unsupported file type: {source.suffix}")
343
+
344
+
345
+ def _load_from_pfile(pf: "PFile") -> dict[str, Any]:
346
+ """Extract data dict from a PFile."""
347
+ channels = []
348
+ for ch_name, ch_data in pf.channels.items():
349
+ dim = "time_fast" if pf.is_fast(ch_name) else "time_slow"
350
+ info = pf.channel_info[ch_name]
351
+ # Honor a richer long_name/comment when the producer supplied one
352
+ # (e.g. injected derived channels like N2/dTdz); otherwise fall back to
353
+ # the bare channel name. The canonical schema still wins where it has an
354
+ # opinion for known variables.
355
+ attrs = {
356
+ "units": info["units"],
357
+ "sensor_type": info["type"],
358
+ "long_name": info.get("long_name", ch_name),
359
+ }
360
+ if info.get("comment"):
361
+ attrs["comment"] = info["comment"]
362
+ # Provenance (e.g. the in-situ FP07 calibration tag). Not a schema key,
363
+ # so it survives apply_schema; binning carries it to the combo.
364
+ if info.get("calibration"):
365
+ attrs["calibration"] = info["calibration"]
366
+ channels.append((ch_name, ch_data, dim, attrs))
367
+
368
+ global_attrs = {
369
+ "Conventions": "CF-1.13, ACDD-1.3",
370
+ "instrument_model": pf.config["instrument_info"].get("model", ""),
371
+ "instrument_sn": pf.config["instrument_info"].get("sn", ""),
372
+ "operator": pf.config["cruise_info"].get("operator", ""),
373
+ "project": pf.config["cruise_info"].get("project", ""),
374
+ "start_time": pf.start_time.isoformat(),
375
+ "source_file": pf.filepath.name,
376
+ }
377
+ if hasattr(pf, "config_str"):
378
+ global_attrs["configuration_string"] = pf.config_str
379
+
380
+ return {
381
+ "P": pf.channels["P"],
382
+ "fs_fast": pf.fs_fast,
383
+ "fs_slow": pf.fs_slow,
384
+ "t_fast": pf.t_fast,
385
+ "t_slow": pf.t_slow,
386
+ "t_fast_units": f"seconds since {pf.start_time.isoformat()}",
387
+ "t_slow_units": f"seconds since {pf.start_time.isoformat()}",
388
+ "start_epoch_s": pf.start_time.timestamp(),
389
+ "channels": channels,
390
+ "global_attrs": global_attrs,
391
+ "stem": pf.filepath.stem,
392
+ }
393
+
394
+
395
+ # netCDF4 reserved attributes that cannot be set with setattr after a variable
396
+ # is created (they must go through createVariable or are managed internally).
397
+ _UNSETTABLE_NC_ATTRS = frozenset(
398
+ {"_FillValue", "_Netcdf4Coordinates", "_Netcdf4Dimid", "_Unsigned", "_ChunkSizes"}
399
+ )
400
+
401
+
402
+ def _nc_filled(var) -> np.ndarray:
403
+ """Read a NetCDF variable as float64 with masked/_FillValue entries -> NaN.
404
+
405
+ netCDF4 returns a *masked* array whenever a variable carries a ``_FillValue``
406
+ or has unwritten elements. The previous ``var[:].data`` exposed the raw fill
407
+ buffer (~9.97e36) instead of NaN, which silently poisoned pressure and
408
+ fall-rate (a single fill produced a ~4.5e35 dbar/s spike that truncated the
409
+ rest of the cast) and leaked into every channel. ``np.ma.filled`` converts
410
+ masked entries to NaN and is a no-op on an already-unmasked ndarray, so this
411
+ is correct for both the package's own fully-written files and external /
412
+ partial (CF/ATOMIX) files that declare a ``_FillValue``.
413
+ """
414
+ return np.asarray(np.ma.filled(var[:].astype(np.float64), np.nan))
415
+
416
+
417
+ def _repair_nans(
418
+ x: np.ndarray, name: str, stem: str, max_gap: int
419
+ ) -> tuple[np.ndarray, np.ndarray]:
420
+ """Linearly interpolate NaNs (e.g. _FillValue gaps) over the sample index.
421
+
422
+ Defect (audit M2 downstream): ``_nc_filled`` correctly turns a masked
423
+ ``_FillValue`` into NaN, but a single NaN in the slow pressure is then
424
+ smeared across the *entire* fall rate by ``smooth_fall_rate`` (gradient
425
+ poisons two neighbors, zero-phase filtfilt poisons all samples), so a
426
+ lone mid-cast fill silently drops every profile in the file. Repairing
427
+ isolated NaNs by linear interpolation keeps the rest of the cast usable;
428
+ a warning makes a poisoned/partial cast visible rather than silently lost.
429
+
430
+ Every gap is interpolated so the fall-rate filter sees a NaN-free array,
431
+ but a contiguous non-finite run longer than ``max_gap`` samples is
432
+ *fabricated*, not repaired: linear interpolation across many missing
433
+ samples invents pressure that must not define a profile (PR #79 review).
434
+ The returned boolean mask flags those long-gap samples so the caller can
435
+ drop any profile overlapping them.
436
+
437
+ Returns ``(repaired, long_gap_mask)``.
438
+ """
439
+ long_gap = np.zeros(x.size, dtype=bool)
440
+ bad = ~np.isfinite(x)
441
+ n_bad = int(bad.sum())
442
+ if n_bad == 0:
443
+ return x, long_gap
444
+ good = ~bad
445
+ if good.sum() < 2:
446
+ # Not enough finite samples to interpolate; leave as-is and warn loudly.
447
+ logger.warning(
448
+ "%s: %s has %d/%d non-finite samples; cannot repair", stem, name, n_bad, x.size
449
+ )
450
+ return x, long_gap
451
+ # Flag contiguous non-finite runs longer than max_gap. Padding with a finite
452
+ # sentinel at both ends turns run starts into +1 and ends into -1 in the diff.
453
+ d = np.diff(np.concatenate(([0], bad.astype(np.int8), [0])))
454
+ starts = np.flatnonzero(d == 1)
455
+ ends = np.flatnonzero(d == -1) # exclusive
456
+ n_long = 0
457
+ for s, e in zip(starts, ends):
458
+ if (e - s) > max_gap:
459
+ long_gap[s:e] = True
460
+ n_long += 1
461
+ idx = np.arange(x.size, dtype=np.float64)
462
+ repaired = x.copy()
463
+ # np.interp clamps to the finite endpoints, so leading/trailing NaNs are
464
+ # held flat rather than re-introducing NaN.
465
+ repaired[bad] = np.interp(idx[bad], idx[good], x[good])
466
+ if n_long:
467
+ logger.warning(
468
+ "%s: %s has %d non-finite span(s) longer than %d samples; interpolated for "
469
+ "fall-rate only, profiles overlapping them will be dropped",
470
+ stem,
471
+ name,
472
+ n_long,
473
+ max_gap,
474
+ )
475
+ logger.warning("%s: interpolated %d/%d non-finite samples in %s", stem, n_bad, x.size, name)
476
+ return repaired, long_gap
477
+
478
+
479
+ def _load_from_nc(nc_path: Path) -> dict[str, Any]:
480
+ """Extract data dict from a full-record NetCDF file."""
481
+ import netCDF4 as nc
482
+
483
+ # Defect (audit io-hazard): the Dataset must be closed on every exit path,
484
+ # not just the success path — the ValueError/AttributeError raises below
485
+ # would otherwise leak the open file handle (and its lock).
486
+ ds = nc.Dataset(str(nc_path), "r")
487
+ stem = Path(nc_path).stem
488
+ try:
489
+ # Read global attributes
490
+ global_attrs = {}
491
+ for attr in ds.ncattrs():
492
+ if attr != "configuration_string":
493
+ global_attrs[attr] = getattr(ds, attr)
494
+ if "configuration_string" in ds.ncattrs():
495
+ global_attrs["configuration_string"] = ds.configuration_string
496
+
497
+ # Attributes may be on root or in L1_converted group
498
+ def _get_nc_attr(name: str, default=None):
499
+ for src in [ds]:
500
+ if name in {a for a in src.ncattrs()}:
501
+ return src.getncattr(name)
502
+ if "L1_converted" in ds.groups:
503
+ g = ds.groups["L1_converted"]
504
+ if name in {a for a in g.ncattrs()}:
505
+ return g.getncattr(name)
506
+ if default is not None:
507
+ return default
508
+ raise AttributeError(f"Attribute {name!r} not found in NetCDF file")
509
+
510
+ fs_fast = float(_get_nc_attr("fs_fast"))
511
+ fs_slow = float(_get_nc_attr("fs_slow"))
512
+
513
+ # Time vectors may be at root or inside L1_converted group
514
+ if "t_fast" in ds.variables:
515
+ t_fast = _nc_filled(ds.variables["t_fast"])
516
+ t_slow = _nc_filled(ds.variables["t_slow"])
517
+ elif "L1_converted" in ds.groups:
518
+ g = ds.groups["L1_converted"]
519
+ # L1_converted uses TIME/TIME_SLOW dimension names
520
+ t_fast = _nc_filled(g.variables["TIME"])
521
+ t_slow = _nc_filled(g.variables["TIME_SLOW"])
522
+ else:
523
+ raise ValueError("No time variables found in NetCDF file")
524
+ # Determine time units
525
+ if "t_fast" in ds.variables:
526
+ t_fast_units = (
527
+ ds.variables["t_fast"].units
528
+ if hasattr(ds.variables["t_fast"], "units")
529
+ else "seconds"
530
+ )
531
+ t_slow_units = (
532
+ ds.variables["t_slow"].units
533
+ if hasattr(ds.variables["t_slow"], "units")
534
+ else "seconds"
535
+ )
536
+ elif "L1_converted" in ds.groups:
537
+ g = ds.groups["L1_converted"]
538
+ t_fast_units = (
539
+ g.variables["TIME"].units if hasattr(g.variables["TIME"], "units") else "days"
540
+ )
541
+ t_slow_units = (
542
+ g.variables["TIME_SLOW"].units
543
+ if hasattr(g.variables["TIME_SLOW"], "units")
544
+ else "days"
545
+ )
546
+ else:
547
+ t_fast_units = "seconds"
548
+ t_slow_units = "seconds"
549
+
550
+ # Pressure — need slow-rate pressure for profile detection
551
+ if "P" in ds.variables:
552
+ P = _nc_filled(ds.variables["P"])
553
+ elif "L1_converted" in ds.groups:
554
+ g = ds.groups["L1_converted"]
555
+ if "PRES_SLOW" in g.variables:
556
+ P = _nc_filled(g.variables["PRES_SLOW"])
557
+ elif "PRES" in g.variables:
558
+ P = _nc_filled(g.variables["PRES"])
559
+ else:
560
+ raise ValueError("No pressure variable found in NetCDF file")
561
+ else:
562
+ raise ValueError("No pressure variable found in NetCDF file")
563
+
564
+ # Channels — scan both root and L1_converted group
565
+ channels = []
566
+ _seen = set()
567
+
568
+ def _scan_vars(source, time_dim_fast, time_dim_slow):
569
+ for vname in source.variables:
570
+ if vname in _seen or vname in ("t_fast", "t_slow", "TIME", "TIME_SLOW"):
571
+ continue
572
+ var = source.variables[vname]
573
+ dims = var.dimensions
574
+ if len(dims) != 1:
575
+ continue
576
+ dim = dims[0]
577
+ if dim == time_dim_fast:
578
+ mapped_dim = "time_fast"
579
+ elif dim == time_dim_slow:
580
+ mapped_dim = "time_slow"
581
+ else:
582
+ continue
583
+ attrs = {}
584
+ for a in var.ncattrs():
585
+ attrs[a] = getattr(var, a)
586
+ channels.append((vname, _nc_filled(var), mapped_dim, attrs))
587
+ _seen.add(vname)
588
+
589
+ # Scan root-level variables (old format)
590
+ _scan_vars(ds, "time_fast", "time_slow")
591
+ # Scan L1_converted group (new ATOMIX format)
592
+ if "L1_converted" in ds.groups:
593
+ _scan_vars(ds.groups["L1_converted"], "TIME", "TIME_SLOW")
594
+ finally:
595
+ ds.close()
596
+
597
+ # If time is in days, convert to seconds for consistency
598
+ if "day" in t_fast_units.lower():
599
+ # nanmin reference (not [0]): a _FillValue->NaN at index 0 of the TIME
600
+ # axis would otherwise poison the whole converted vector.
601
+ t_fast = (t_fast - np.nanmin(t_fast)) * 86400.0
602
+ t_slow = (t_slow - np.nanmin(t_slow)) * 86400.0
603
+ t_fast_units = "seconds"
604
+ t_slow_units = "seconds"
605
+
606
+ return {
607
+ "P": P.astype(np.float64),
608
+ "fs_fast": fs_fast,
609
+ "fs_slow": fs_slow,
610
+ "t_fast": t_fast,
611
+ "t_slow": t_slow,
612
+ "t_fast_units": t_fast_units,
613
+ "t_slow_units": t_slow_units,
614
+ "channels": channels,
615
+ "global_attrs": global_attrs,
616
+ "stem": stem,
617
+ }
618
+
619
+
620
+ def _extract_one(args: tuple) -> tuple[str, int]:
621
+ """Worker function for parallel profile extraction."""
622
+ source_path, output_dir, kwargs = args
623
+ paths = extract_profiles(source_path, output_dir, **kwargs)
624
+ return str(source_path), len(paths)