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
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
# Mar-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Hotel file support — external telemetry from gliders/AUVs/Remus.
|
|
3
|
+
|
|
4
|
+
Hotel files provide vehicle-mounted sensor data (speed, pitch, roll, heading,
|
|
5
|
+
CTD) that gets interpolated onto instrument time axes as new channels.
|
|
6
|
+
|
|
7
|
+
The ``hotel.channels`` YAML block supports three forms per source name:
|
|
8
|
+
|
|
9
|
+
channels:
|
|
10
|
+
# Take with all defaults from the section
|
|
11
|
+
lat:
|
|
12
|
+
# Legacy flat name map: rename source -> output
|
|
13
|
+
m_speed: "speed"
|
|
14
|
+
# Per-variable options
|
|
15
|
+
pitch:
|
|
16
|
+
name: "theta" # rename target (default: same as source)
|
|
17
|
+
interp: "nearest" # override hotel.interpolation for this var
|
|
18
|
+
scale: 0.0174533 # multiplicative factor (default 1.0)
|
|
19
|
+
offset: 0.0 # additive offset (default 0.0)
|
|
20
|
+
units: "rad" # CF units string (default: source file's units)
|
|
21
|
+
fast: false # interpolate to fast rate? (default: name in hotel.fast_channels)
|
|
22
|
+
time_column: "time_flight" # NetCDF time variable for this channel
|
|
23
|
+
# (default: hotel.time_column). Lets a single
|
|
24
|
+
# hotel file carry channels on multiple native
|
|
25
|
+
# time grids, e.g. CTD on time_sci, flight
|
|
26
|
+
# vars on time_flight, sparse modem events
|
|
27
|
+
# on time_modem_event.
|
|
28
|
+
|
|
29
|
+
If ``channels`` is empty or omitted, every source variable is loaded with
|
|
30
|
+
default options. Otherwise only the source names listed are kept.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
|
|
38
|
+
# Interpolation kinds we accept. ``pchip`` uses scipy's PchipInterpolator; the
|
|
39
|
+
# others are passed straight through to ``scipy.interpolate.interp1d`` as the
|
|
40
|
+
# ``kind`` argument.
|
|
41
|
+
_INTERP_KINDS = frozenset({
|
|
42
|
+
"pchip", "linear", "nearest", "previous", "next",
|
|
43
|
+
"zero", "slinear", "quadratic", "cubic",
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
_CHANNEL_OPTION_KEYS = frozenset({
|
|
47
|
+
"name", "interp", "scale", "offset", "units", "fast", "time_column", "replace",
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class HotelData:
|
|
53
|
+
"""Container for loaded hotel file data.
|
|
54
|
+
|
|
55
|
+
Attributes
|
|
56
|
+
----------
|
|
57
|
+
time : np.ndarray
|
|
58
|
+
Default time vector for channels that don't override
|
|
59
|
+
``time_column``. Epoch seconds (or relative seconds — see
|
|
60
|
+
``time_is_relative``).
|
|
61
|
+
channels : dict[str, np.ndarray]
|
|
62
|
+
Source channel name → data array. Renaming and per-variable
|
|
63
|
+
transforms are applied later by :func:`merge_hotel_into_pfile`.
|
|
64
|
+
channel_times : dict[str, np.ndarray]
|
|
65
|
+
Per-channel time array override. Keys are source channel names;
|
|
66
|
+
values are time arrays in the same units as ``time``. Channels
|
|
67
|
+
not in this dict use the default ``time``. Lets a single hotel
|
|
68
|
+
file carry channels on multiple native time grids.
|
|
69
|
+
units : dict[str, str]
|
|
70
|
+
Source channel name → CF-compatible units string from the file.
|
|
71
|
+
Empty if not known. The merge step may override these.
|
|
72
|
+
time_is_relative : bool
|
|
73
|
+
True if time values are relative seconds (not epoch). Applies to
|
|
74
|
+
every time array (default and per-channel) — mixing relative and
|
|
75
|
+
absolute time arrays in one hotel file is not supported.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
time: np.ndarray
|
|
79
|
+
channels: dict[str, np.ndarray] = field(default_factory=dict)
|
|
80
|
+
channel_times: dict[str, np.ndarray] = field(default_factory=dict)
|
|
81
|
+
units: dict[str, str] = field(default_factory=dict)
|
|
82
|
+
time_is_relative: bool = False
|
|
83
|
+
|
|
84
|
+
def time_for(self, channel: str) -> np.ndarray:
|
|
85
|
+
"""Time array a channel lives on (override → default fallback)."""
|
|
86
|
+
return self.channel_times.get(channel, self.time)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _normalize_channels_cfg(
|
|
90
|
+
channels_cfg: dict | None,
|
|
91
|
+
) -> tuple[bool, dict[str, dict]]:
|
|
92
|
+
"""Parse the YAML ``hotel.channels`` block.
|
|
93
|
+
|
|
94
|
+
Returns ``(filter_active, options)`` where:
|
|
95
|
+
|
|
96
|
+
- ``filter_active`` is ``False`` for an empty / missing block (meaning
|
|
97
|
+
"take every source channel with default options"), and ``True`` when
|
|
98
|
+
the user explicitly listed source names.
|
|
99
|
+
- ``options`` maps source_name → ``{"name", "interp", "scale", "offset",
|
|
100
|
+
"units", "fast", "replace"}`` (any subset; missing keys mean "use the
|
|
101
|
+
default"). ``replace: true`` permits a hotel channel to overwrite a
|
|
102
|
+
native instrument channel of the same output name (refused otherwise;
|
|
103
|
+
U5-1) — pair it with ``fast: false`` when overwriting a slow native
|
|
104
|
+
channel such as ``P``, or the fast-gridded replacement is dropped.
|
|
105
|
+
|
|
106
|
+
Per-source values may be:
|
|
107
|
+
|
|
108
|
+
- ``None`` or ``{}`` — include this source with all defaults.
|
|
109
|
+
- a string — legacy "rename to this output name".
|
|
110
|
+
- a dict — full per-variable options.
|
|
111
|
+
"""
|
|
112
|
+
if not channels_cfg:
|
|
113
|
+
return False, {}
|
|
114
|
+
out: dict[str, dict] = {}
|
|
115
|
+
for src, val in channels_cfg.items():
|
|
116
|
+
if val is None or val == {}:
|
|
117
|
+
out[src] = {}
|
|
118
|
+
elif isinstance(val, str):
|
|
119
|
+
out[src] = {"name": val}
|
|
120
|
+
elif isinstance(val, dict):
|
|
121
|
+
unknown = set(val) - _CHANNEL_OPTION_KEYS
|
|
122
|
+
if unknown:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"hotel.channels[{src!r}]: unknown options {sorted(unknown)}. "
|
|
125
|
+
f"Valid: {sorted(_CHANNEL_OPTION_KEYS)}"
|
|
126
|
+
)
|
|
127
|
+
interp_kind = val.get("interp")
|
|
128
|
+
if interp_kind is not None and interp_kind not in _INTERP_KINDS:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"hotel.channels[{src!r}].interp={interp_kind!r}: not in "
|
|
131
|
+
f"{sorted(_INTERP_KINDS)}"
|
|
132
|
+
)
|
|
133
|
+
out[src] = dict(val)
|
|
134
|
+
else:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"hotel.channels[{src!r}]: must be a string, dict, or null; "
|
|
137
|
+
f"got {type(val).__name__}"
|
|
138
|
+
)
|
|
139
|
+
return True, out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _nc_array(var) -> np.ndarray:
|
|
143
|
+
"""Read a netCDF4 variable, mapping masked/_FillValue cells to NaN.
|
|
144
|
+
|
|
145
|
+
netCDF4 auto-masks by default; reaching for ``.data`` would expose the raw
|
|
146
|
+
_FillValue (e.g. -999, 1e20) as a real measurement. Hotel telemetry stores
|
|
147
|
+
gaps as fill values, so a -999 speed/pitch/CTD would be interpolated onto
|
|
148
|
+
the instrument axis and corrupt the dissipation/chi estimates silently.
|
|
149
|
+
"""
|
|
150
|
+
arr = var[:]
|
|
151
|
+
if np.ma.isMaskedArray(arr):
|
|
152
|
+
# Cast to float before filling: filling an integer-dtype masked array
|
|
153
|
+
# with NaN raises (NaN cannot be cast to int).
|
|
154
|
+
return np.asarray(np.ma.filled(arr.astype(np.float64), np.nan))
|
|
155
|
+
return np.asarray(arr)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _dt64_to_epoch_s(values: np.ndarray) -> np.ndarray:
|
|
159
|
+
"""datetime64 -> epoch seconds (float64), mapping NaT to NaN.
|
|
160
|
+
|
|
161
|
+
NaT casts to the int64 minimum; converting straight to seconds would yield a
|
|
162
|
+
bogus ~-9.2e9 s epoch that then poisons interpolation. Detect the sentinel
|
|
163
|
+
and emit NaN instead.
|
|
164
|
+
"""
|
|
165
|
+
ns = np.asarray(values).astype("datetime64[ns]").astype(np.int64)
|
|
166
|
+
secs = ns.astype(np.float64) / 1e9
|
|
167
|
+
secs[ns == np.iinfo(np.int64).min] = np.nan
|
|
168
|
+
return secs
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _parse_time(raw_time: np.ndarray, time_format: str) -> tuple[np.ndarray, bool]:
|
|
172
|
+
"""Convert raw time values to epoch seconds.
|
|
173
|
+
|
|
174
|
+
Returns (time_array, is_relative).
|
|
175
|
+
"""
|
|
176
|
+
if time_format == "seconds":
|
|
177
|
+
return raw_time.astype(np.float64), True
|
|
178
|
+
elif time_format == "epoch":
|
|
179
|
+
return raw_time.astype(np.float64), False
|
|
180
|
+
elif time_format == "iso":
|
|
181
|
+
import pandas as pd
|
|
182
|
+
|
|
183
|
+
t = _dt64_to_epoch_s(pd.to_datetime(raw_time).values)
|
|
184
|
+
return t, False
|
|
185
|
+
elif time_format == "auto":
|
|
186
|
+
if not np.issubdtype(raw_time.dtype, np.number):
|
|
187
|
+
# Non-numeric (ISO strings / datetime64 / object) -> parse as dates.
|
|
188
|
+
# Feeding these to ``.astype(float64)`` below would raise, so the
|
|
189
|
+
# old code never actually reached its ISO fallback for string input.
|
|
190
|
+
import pandas as pd
|
|
191
|
+
|
|
192
|
+
t = _dt64_to_epoch_s(pd.to_datetime(raw_time).values)
|
|
193
|
+
return t, False
|
|
194
|
+
vals = raw_time.astype(np.float64)
|
|
195
|
+
# nanmedian: a single NaN timestamp must not blank the whole test and
|
|
196
|
+
# push every file through the ambiguous branch.
|
|
197
|
+
median = np.nanmedian(vals)
|
|
198
|
+
if median < 1e6:
|
|
199
|
+
return vals, True
|
|
200
|
+
elif median > 1e9:
|
|
201
|
+
return vals, False
|
|
202
|
+
else:
|
|
203
|
+
# Ambiguous numeric magnitude: epoch seconds here mean 1970..2001,
|
|
204
|
+
# while relative seconds mean an 11.6-day..31.7-year span -- both
|
|
205
|
+
# plausible. pd.to_datetime would silently read bare numbers as
|
|
206
|
+
# nanoseconds-since-epoch (garbage), so refuse to guess.
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"hotel time_format='auto' cannot disambiguate numeric times "
|
|
209
|
+
f"with median {median:g} s (in [1e6, 1e9)). Set time_format "
|
|
210
|
+
f"explicitly to 'seconds' (relative) or 'epoch' (POSIX)."
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
raise ValueError(f"Unknown time_format: {time_format!r}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _per_channel_time_columns(
|
|
217
|
+
options: dict[str, dict], default_time_column: str,
|
|
218
|
+
) -> dict[str, str]:
|
|
219
|
+
"""Map source-name → time variable name for any channel overriding it.
|
|
220
|
+
|
|
221
|
+
Returns ``{channel: time_column}`` only for channels whose option dict
|
|
222
|
+
has a ``time_column`` different from *default_time_column*. Channels
|
|
223
|
+
that don't override are left out (they implicitly use the default).
|
|
224
|
+
"""
|
|
225
|
+
out: dict[str, str] = {}
|
|
226
|
+
for src, opts in options.items():
|
|
227
|
+
tc = opts.get("time_column")
|
|
228
|
+
if tc and tc != default_time_column:
|
|
229
|
+
out[src] = tc
|
|
230
|
+
return out
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def load_hotel(
|
|
234
|
+
path: str | Path,
|
|
235
|
+
time_column: str = "time",
|
|
236
|
+
time_format: str = "auto",
|
|
237
|
+
channels: dict | None = None,
|
|
238
|
+
) -> HotelData:
|
|
239
|
+
"""Load a hotel file (CSV, NetCDF, or .mat).
|
|
240
|
+
|
|
241
|
+
Parameters
|
|
242
|
+
----------
|
|
243
|
+
path : str or Path
|
|
244
|
+
Path to hotel file.
|
|
245
|
+
time_column : str
|
|
246
|
+
Name of the *default* time column / variable. Channels in the
|
|
247
|
+
``channels`` mapping may override this with their own
|
|
248
|
+
``time_column`` to ride a different time grid in the same file.
|
|
249
|
+
time_format : str
|
|
250
|
+
Time format: ``"auto"``, ``"seconds"``, ``"epoch"``, ``"iso"``.
|
|
251
|
+
channels : dict, optional
|
|
252
|
+
``hotel.channels`` block (see module docstring). Only the source
|
|
253
|
+
names listed are loaded; rename / scale / offset / units /
|
|
254
|
+
interp / fast / time_column options are applied later by
|
|
255
|
+
:func:`merge_hotel_into_pfile`. ``None`` or ``{}`` loads every
|
|
256
|
+
source channel under its native name on the default time grid.
|
|
257
|
+
|
|
258
|
+
Returns
|
|
259
|
+
-------
|
|
260
|
+
HotelData
|
|
261
|
+
Channels and units keyed by *source* name (no rename applied).
|
|
262
|
+
``HotelData.channel_times`` is populated for any channel using
|
|
263
|
+
a non-default ``time_column``; the rest use ``HotelData.time``.
|
|
264
|
+
"""
|
|
265
|
+
path = Path(path)
|
|
266
|
+
ext = path.suffix.lower()
|
|
267
|
+
|
|
268
|
+
filter_active, options = _normalize_channels_cfg(channels)
|
|
269
|
+
allowed = set(channels.keys()) if filter_active and channels else None
|
|
270
|
+
per_chan_time = _per_channel_time_columns(options, time_column)
|
|
271
|
+
|
|
272
|
+
if ext == ".csv":
|
|
273
|
+
return _load_csv(path, time_column, time_format, allowed, per_chan_time)
|
|
274
|
+
elif ext in (".nc", ".nc4"):
|
|
275
|
+
return _load_netcdf(path, time_column, time_format, allowed, per_chan_time)
|
|
276
|
+
elif ext == ".mat":
|
|
277
|
+
return _load_mat(path, time_column, time_format, allowed, per_chan_time)
|
|
278
|
+
else:
|
|
279
|
+
raise ValueError(f"Unsupported hotel file format: {ext!r}. Supported: .csv, .nc, .mat")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _load_csv(
|
|
283
|
+
path: Path,
|
|
284
|
+
time_column: str,
|
|
285
|
+
time_format: str,
|
|
286
|
+
allowed: set[str] | None,
|
|
287
|
+
per_chan_time: dict[str, str],
|
|
288
|
+
) -> HotelData:
|
|
289
|
+
import pandas as pd
|
|
290
|
+
|
|
291
|
+
df = pd.read_csv(path)
|
|
292
|
+
raw_time = np.asarray(df[time_column].values)
|
|
293
|
+
time, is_relative = _parse_time(raw_time, time_format)
|
|
294
|
+
|
|
295
|
+
extra_time_cols = set(per_chan_time.values())
|
|
296
|
+
data_cols = [c for c in df.columns if c != time_column and c not in extra_time_cols]
|
|
297
|
+
if allowed is not None:
|
|
298
|
+
data_cols = [c for c in data_cols if c in allowed]
|
|
299
|
+
ch = {c: df[c].values.astype(np.float64) for c in data_cols}
|
|
300
|
+
units = dict.fromkeys(ch, "")
|
|
301
|
+
|
|
302
|
+
channel_times: dict[str, np.ndarray] = {}
|
|
303
|
+
for src, tc in per_chan_time.items():
|
|
304
|
+
if allowed is not None and src not in allowed:
|
|
305
|
+
continue
|
|
306
|
+
if tc not in df.columns:
|
|
307
|
+
raise ValueError(f"hotel: time_column {tc!r} not found in {path}")
|
|
308
|
+
t_arr, _ = _parse_time(np.asarray(df[tc].values), time_format)
|
|
309
|
+
channel_times[src] = t_arr
|
|
310
|
+
|
|
311
|
+
return HotelData(time=time, channels=ch, channel_times=channel_times,
|
|
312
|
+
units=units, time_is_relative=is_relative)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _load_netcdf(
|
|
316
|
+
path: Path,
|
|
317
|
+
time_column: str,
|
|
318
|
+
time_format: str,
|
|
319
|
+
allowed: set[str] | None,
|
|
320
|
+
per_chan_time: dict[str, str],
|
|
321
|
+
) -> HotelData:
|
|
322
|
+
import netCDF4 as nc
|
|
323
|
+
|
|
324
|
+
# Context manager closes the Dataset even if _parse_time or a variable read
|
|
325
|
+
# raises partway through (e.g. an ambiguous-time ValueError).
|
|
326
|
+
with nc.Dataset(str(path), "r") as ds:
|
|
327
|
+
raw_time = _nc_array(ds.variables[time_column])
|
|
328
|
+
time, is_relative = _parse_time(raw_time, time_format)
|
|
329
|
+
|
|
330
|
+
extra_time_cols = set(per_chan_time.values())
|
|
331
|
+
skip = {time_column} | extra_time_cols
|
|
332
|
+
data_vars = [v for v in ds.variables if v not in skip]
|
|
333
|
+
if allowed is not None:
|
|
334
|
+
data_vars = [v for v in data_vars if v in allowed]
|
|
335
|
+
ch: dict[str, np.ndarray] = {}
|
|
336
|
+
units: dict[str, str] = {}
|
|
337
|
+
for v in data_vars:
|
|
338
|
+
var = ds.variables[v]
|
|
339
|
+
ch[v] = _nc_array(var).astype(np.float64)
|
|
340
|
+
units[v] = getattr(var, "units", "") or ""
|
|
341
|
+
|
|
342
|
+
channel_times: dict[str, np.ndarray] = {}
|
|
343
|
+
for src, tc in per_chan_time.items():
|
|
344
|
+
if allowed is not None and src not in allowed:
|
|
345
|
+
continue
|
|
346
|
+
if tc not in ds.variables:
|
|
347
|
+
raise ValueError(f"hotel: time_column {tc!r} not found in {path}")
|
|
348
|
+
raw = _nc_array(ds.variables[tc])
|
|
349
|
+
t_arr, _ = _parse_time(raw, time_format)
|
|
350
|
+
channel_times[src] = t_arr
|
|
351
|
+
|
|
352
|
+
return HotelData(time=time, channels=ch, channel_times=channel_times,
|
|
353
|
+
units=units, time_is_relative=is_relative)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _load_mat(
|
|
357
|
+
path: Path,
|
|
358
|
+
time_column: str,
|
|
359
|
+
time_format: str,
|
|
360
|
+
allowed: set[str] | None,
|
|
361
|
+
per_chan_time: dict[str, str],
|
|
362
|
+
) -> HotelData:
|
|
363
|
+
from scipy.io import loadmat
|
|
364
|
+
|
|
365
|
+
mat = loadmat(str(path), squeeze_me=True)
|
|
366
|
+
|
|
367
|
+
# ODAS convention: struct fields with .data/.time subfields
|
|
368
|
+
# Also handle flat arrays
|
|
369
|
+
ch: dict[str, np.ndarray] = {}
|
|
370
|
+
raw_time: np.ndarray | None = None
|
|
371
|
+
extra_time_cols = set(per_chan_time.values())
|
|
372
|
+
extra_time_arrays: dict[str, np.ndarray] = {}
|
|
373
|
+
|
|
374
|
+
for key, val in mat.items():
|
|
375
|
+
if key.startswith("_"):
|
|
376
|
+
continue
|
|
377
|
+
if (
|
|
378
|
+
hasattr(val, "dtype")
|
|
379
|
+
and val.dtype.names
|
|
380
|
+
and "time" in val.dtype.names
|
|
381
|
+
and "data" in val.dtype.names
|
|
382
|
+
):
|
|
383
|
+
struct = val.flat[0] if val.ndim > 0 else val
|
|
384
|
+
if key == time_column:
|
|
385
|
+
raw_time = np.asarray(struct["time"]).flatten().astype(np.float64)
|
|
386
|
+
elif key in extra_time_cols:
|
|
387
|
+
extra_time_arrays[key] = (
|
|
388
|
+
np.asarray(struct["time"]).flatten().astype(np.float64)
|
|
389
|
+
)
|
|
390
|
+
else:
|
|
391
|
+
if allowed is None or key in allowed:
|
|
392
|
+
ch[key] = np.asarray(struct["data"]).flatten().astype(np.float64)
|
|
393
|
+
if raw_time is None:
|
|
394
|
+
raw_time = np.asarray(struct["time"]).flatten().astype(np.float64)
|
|
395
|
+
continue
|
|
396
|
+
# Flat array
|
|
397
|
+
arr = np.asarray(val).flatten()
|
|
398
|
+
if key == time_column:
|
|
399
|
+
raw_time = arr.astype(np.float64)
|
|
400
|
+
elif key in extra_time_cols:
|
|
401
|
+
extra_time_arrays[key] = arr.astype(np.float64)
|
|
402
|
+
else:
|
|
403
|
+
if allowed is None or key in allowed:
|
|
404
|
+
ch[key] = arr.astype(np.float64)
|
|
405
|
+
|
|
406
|
+
if raw_time is None:
|
|
407
|
+
raise ValueError(f"Time column {time_column!r} not found in .mat file")
|
|
408
|
+
|
|
409
|
+
time, is_relative = _parse_time(raw_time, time_format)
|
|
410
|
+
units = dict.fromkeys(ch, "")
|
|
411
|
+
|
|
412
|
+
channel_times: dict[str, np.ndarray] = {}
|
|
413
|
+
for src, tc in per_chan_time.items():
|
|
414
|
+
if allowed is not None and src not in allowed:
|
|
415
|
+
continue
|
|
416
|
+
if tc not in extra_time_arrays:
|
|
417
|
+
raise ValueError(f"hotel: time_column {tc!r} not found in {path}")
|
|
418
|
+
t_arr, _ = _parse_time(extra_time_arrays[tc], time_format)
|
|
419
|
+
channel_times[src] = t_arr
|
|
420
|
+
|
|
421
|
+
return HotelData(time=time, channels=ch, channel_times=channel_times,
|
|
422
|
+
units=units, time_is_relative=is_relative)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _interp_one(
|
|
426
|
+
hotel_t: np.ndarray,
|
|
427
|
+
data: np.ndarray,
|
|
428
|
+
target_t: np.ndarray,
|
|
429
|
+
kind: str,
|
|
430
|
+
) -> np.ndarray:
|
|
431
|
+
"""Interpolate one channel onto ``target_t`` with the requested kind."""
|
|
432
|
+
from scipy.interpolate import PchipInterpolator, interp1d
|
|
433
|
+
|
|
434
|
+
# Drop NaN samples (fill-valued gaps / NaT times): PchipInterpolator raises
|
|
435
|
+
# on a NaN in the data, and interp1d would propagate it. Interpolate across
|
|
436
|
+
# the gaps instead; with < 2 valid points there is nothing to interpolate.
|
|
437
|
+
finite = np.isfinite(hotel_t) & np.isfinite(data)
|
|
438
|
+
if int(finite.sum()) < 2:
|
|
439
|
+
return np.full(np.shape(target_t), np.nan, dtype=np.float64)
|
|
440
|
+
if not finite.all():
|
|
441
|
+
hotel_t = np.asarray(hotel_t)[finite]
|
|
442
|
+
data = np.asarray(data)[finite]
|
|
443
|
+
|
|
444
|
+
if kind == "pchip":
|
|
445
|
+
interp = PchipInterpolator(hotel_t, data, extrapolate=False)
|
|
446
|
+
out = interp(target_t)
|
|
447
|
+
# Fill NaN edges with boundary values, matching the historical behavior.
|
|
448
|
+
mask = np.isnan(out)
|
|
449
|
+
if np.any(mask):
|
|
450
|
+
below = target_t < hotel_t[0]
|
|
451
|
+
above = target_t > hotel_t[-1]
|
|
452
|
+
out[mask & below] = data[0]
|
|
453
|
+
out[mask & above] = data[-1]
|
|
454
|
+
return np.asarray(out, dtype=np.float64)
|
|
455
|
+
interp = interp1d(
|
|
456
|
+
hotel_t, data, kind=kind, bounds_error=False,
|
|
457
|
+
fill_value=(data[0], data[-1]),
|
|
458
|
+
)
|
|
459
|
+
return np.asarray(interp(target_t), dtype=np.float64)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def interpolate_hotel(hotel_data: HotelData, pf, hotel_cfg: dict) -> dict[str, np.ndarray]:
|
|
463
|
+
"""Interpolate hotel channels onto PFile time axes.
|
|
464
|
+
|
|
465
|
+
Honors per-variable ``interp`` overrides from the ``hotel.channels``
|
|
466
|
+
block; falls back to the global ``hotel.interpolation`` default for any
|
|
467
|
+
channel without an explicit override.
|
|
468
|
+
|
|
469
|
+
Returns a dict keyed by *source* channel name. The merge helper applies
|
|
470
|
+
rename / scale / offset / units / fast overrides on top of this.
|
|
471
|
+
"""
|
|
472
|
+
fast_channels = set(hotel_cfg.get("fast_channels", ["speed", "P"]))
|
|
473
|
+
default_kind = hotel_cfg.get("interpolation", "pchip")
|
|
474
|
+
if default_kind not in _INTERP_KINDS:
|
|
475
|
+
raise ValueError(
|
|
476
|
+
f"hotel.interpolation={default_kind!r}: not in {sorted(_INTERP_KINDS)}"
|
|
477
|
+
)
|
|
478
|
+
_, channels_opts = _normalize_channels_cfg(hotel_cfg.get("channels"))
|
|
479
|
+
|
|
480
|
+
pf_start_offset = 0.0 if hotel_data.time_is_relative else pf.start_time.timestamp()
|
|
481
|
+
|
|
482
|
+
result: dict[str, np.ndarray] = {}
|
|
483
|
+
for src, data in hotel_data.channels.items():
|
|
484
|
+
opts = channels_opts.get(src, {})
|
|
485
|
+
out_name = opts.get("name", src)
|
|
486
|
+
kind = opts.get("interp") or default_kind
|
|
487
|
+
if "fast" in opts:
|
|
488
|
+
target_t = pf.t_fast if opts["fast"] else pf.t_slow
|
|
489
|
+
else:
|
|
490
|
+
target_t = pf.t_fast if out_name in fast_channels else pf.t_slow
|
|
491
|
+
hotel_t = hotel_data.time_for(src) - pf_start_offset
|
|
492
|
+
# Sparse time grids (e.g. one-row-per-event modem listings) can have
|
|
493
|
+
# a single sample, which interpolators won't accept. Skip — caller
|
|
494
|
+
# gets nothing for that channel, same as a missing channel.
|
|
495
|
+
if hotel_t.size < 2:
|
|
496
|
+
continue
|
|
497
|
+
result[src] = _interp_one(hotel_t, data, target_t, kind)
|
|
498
|
+
|
|
499
|
+
return result
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def merge_hotel_into_pfile(hotel_data: HotelData, pf, hotel_cfg: dict) -> None:
|
|
503
|
+
"""Interpolate hotel channels and register them on ``pf`` in-place.
|
|
504
|
+
|
|
505
|
+
Applies the full ``hotel.channels`` schema: per-variable rename, interp
|
|
506
|
+
method, ``scale`` / ``offset`` linear transform, ``units`` override, and
|
|
507
|
+
``fast`` rate override.
|
|
508
|
+
|
|
509
|
+
Adds each resulting channel to ``pf.channels`` under the (possibly
|
|
510
|
+
renamed) output name, registers a ``pf.channel_info`` entry so
|
|
511
|
+
:func:`extract_profiles` can read units, and updates
|
|
512
|
+
``pf._fast_channels`` so :meth:`PFile.is_fast` returns the correct dim.
|
|
513
|
+
A hotel channel whose output name collides with one the instrument
|
|
514
|
+
already carries raises ``ValueError`` unless it sets ``replace: true`` —
|
|
515
|
+
this protects instrument channels that downstream processing depends on
|
|
516
|
+
(notably ``P``, whose fast-rate hotel version silently breaks profile
|
|
517
|
+
detection; issue #104 U5-1).
|
|
518
|
+
"""
|
|
519
|
+
fast_channels = set(hotel_cfg.get("fast_channels", ["speed", "P"]))
|
|
520
|
+
_, channels_opts = _normalize_channels_cfg(hotel_cfg.get("channels"))
|
|
521
|
+
interpolated = interpolate_hotel(hotel_data, pf, hotel_cfg)
|
|
522
|
+
|
|
523
|
+
# Snapshot the instrument's own channels so a hotel variable cannot silently
|
|
524
|
+
# clobber one profile detection depends on (a hotel "P" would overwrite the
|
|
525
|
+
# native slow pressure with a fast-rate array, understating dP/dt ~8x and
|
|
526
|
+
# silently dropping the file). Require explicit opt-in to replace. (U5-1.)
|
|
527
|
+
native = set(pf.channels)
|
|
528
|
+
|
|
529
|
+
for src, data in interpolated.items():
|
|
530
|
+
opts = channels_opts.get(src, {})
|
|
531
|
+
out_name = opts.get("name", src)
|
|
532
|
+
if out_name in native and not bool(opts.get("replace", False)):
|
|
533
|
+
# If replacing would still land the hotel channel on a different grid
|
|
534
|
+
# than the native one it overwrites (e.g. native slow P but hotel P
|
|
535
|
+
# defaults to the fast set), replace: true alone is a trap — the
|
|
536
|
+
# regridded channel is later dropped downstream. Name the second
|
|
537
|
+
# remedy (fast:) so the escape hatch actually works. (U5-1.)
|
|
538
|
+
native_is_fast = out_name in getattr(pf, "_fast_channels", set())
|
|
539
|
+
would_be_fast = bool(opts["fast"]) if "fast" in opts else out_name in fast_channels
|
|
540
|
+
msg = (
|
|
541
|
+
f"hotel channel {src!r} would overwrite the instrument's own "
|
|
542
|
+
f"{out_name!r} channel; rename it (hotel.channels.{src}.name: ...) "
|
|
543
|
+
f"or set hotel.channels.{src}.replace: true to override."
|
|
544
|
+
)
|
|
545
|
+
if would_be_fast != native_is_fast:
|
|
546
|
+
grid = "fast" if native_is_fast else "slow"
|
|
547
|
+
msg += (
|
|
548
|
+
f" Note: the native {out_name!r} is on the {grid} grid, so also "
|
|
549
|
+
f"set hotel.channels.{src}.fast: {str(native_is_fast).lower()} "
|
|
550
|
+
f"so the replacement matches it (otherwise it is dropped)."
|
|
551
|
+
)
|
|
552
|
+
raise ValueError(msg)
|
|
553
|
+
scale = float(opts.get("scale", 1.0))
|
|
554
|
+
offset = float(opts.get("offset", 0.0))
|
|
555
|
+
if scale != 1.0 or offset != 0.0:
|
|
556
|
+
data = data * scale + offset
|
|
557
|
+
pf.channels[out_name] = data
|
|
558
|
+
|
|
559
|
+
units = opts.get("units")
|
|
560
|
+
if units is None:
|
|
561
|
+
units = hotel_data.units.get(src, "")
|
|
562
|
+
pf.channel_info[out_name] = {
|
|
563
|
+
"units": units,
|
|
564
|
+
"type": "hotel",
|
|
565
|
+
"name": out_name,
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
is_fast = bool(opts["fast"]) if "fast" in opts else out_name in fast_channels
|
|
569
|
+
if is_fast:
|
|
570
|
+
pf._fast_channels.add(out_name)
|
|
571
|
+
else:
|
|
572
|
+
pf._fast_channels.discard(out_name)
|