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/profile.py
ADDED
|
@@ -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)
|