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,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)