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,458 @@
1
+ # Mar-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """GPS providers for position interpolation.
3
+
4
+ All providers expect query times in **epoch seconds** (POSIX time, the
5
+ ``datetime.timestamp()`` value). Callers holding file-relative seconds
6
+ (e.g. ``pf.t_slow``) must add ``pf.start_time.timestamp()`` before
7
+ querying. The interpolator stored in :class:`GPSFromNetCDF` /
8
+ :class:`GPSFromCSV` normalizes datetime64 columns to epoch seconds at
9
+ construction time.
10
+
11
+ Reference: Code/GPS_base_class.m, GPS_NaN.m, GPS_fixed.m
12
+ """
13
+
14
+ import warnings
15
+ from pathlib import Path
16
+ from typing import Protocol, runtime_checkable
17
+
18
+ import numpy as np
19
+ import numpy.typing as npt
20
+
21
+
22
+ def _to_epoch_seconds(values: np.ndarray) -> np.ndarray:
23
+ """Normalize a time column to epoch seconds (float64).
24
+
25
+ datetime64[*] columns are converted via integer ns since epoch. Numeric
26
+ columns are passed through unchanged — callers are expected to hand in
27
+ epoch seconds when no datetime metadata is available.
28
+
29
+ NaT decodes to NaN (not the int64-min sentinel, which would otherwise become
30
+ a bogus ~-9.2e9 s epoch and poison the interpolation node).
31
+ """
32
+ if np.issubdtype(values.dtype, np.datetime64):
33
+ ns = values.astype("datetime64[ns]").astype(np.int64)
34
+ secs = ns.astype(np.float64) / 1e9
35
+ secs[ns == np.iinfo(np.int64).min] = np.nan
36
+ return secs
37
+ return values.astype(np.float64)
38
+
39
+
40
+ def _nc_values(var) -> np.ndarray:
41
+ """Read a netCDF4 variable, mapping any masked/_FillValue cells to NaN.
42
+
43
+ netCDF4 returns masked arrays by default (auto-masking is on in this
44
+ module). Reaching for ``.data`` would expose the raw _FillValue (e.g.
45
+ -999, 1e20) as if it were a real measurement — a missing GPS fix would
46
+ become a -999 latitude and drive ``interp1d`` to wildly wrong positions.
47
+ ``.filled(np.nan)`` turns those gaps into NaN instead.
48
+ """
49
+ arr = var[:]
50
+ if np.ma.isMaskedArray(arr):
51
+ # Cast to float before filling: filling an integer-dtype masked array
52
+ # with NaN raises (NaN cannot be cast to int).
53
+ return np.asarray(np.ma.filled(arr.astype(np.float64), np.nan))
54
+ return np.asarray(arr)
55
+
56
+
57
+ def _finite_interp1d(t: np.ndarray, vals: np.ndarray):
58
+ """Build a linear interpolator over the FINITE (t, vals) pairs.
59
+
60
+ ``interp1d`` propagates NaN to every interval touching a NaN node, so a
61
+ single fill-valued lat/lon (or time) would blank whole position spans.
62
+ Dropping the non-finite nodes interpolates across the gap instead.
63
+ """
64
+ from scipy.interpolate import interp1d
65
+
66
+ t = np.asarray(t, dtype=np.float64)
67
+ vals = np.asarray(vals, dtype=np.float64)
68
+ good = np.isfinite(t) & np.isfinite(vals)
69
+ if int(good.sum()) >= 2:
70
+ return interp1d(
71
+ t[good], vals[good], bounds_error=False, fill_value="extrapolate"
72
+ )
73
+ # < 2 finite nodes: constant (or all-NaN) -> a constant function.
74
+ fill = float(vals[good][0]) if good.any() else np.nan
75
+ return interp1d([0.0, 1.0], [fill, fill], bounds_error=False, fill_value=fill)
76
+
77
+
78
+ class _DatelineSafeLon:
79
+ """Callable that evaluates an unwrapped-phase longitude interpolator and
80
+ wraps only out-of-[-180, 180) results back into range.
81
+
82
+ A module-level class rather than a closure so a GPS provider holding one
83
+ stays picklable: the perturb pipeline ships providers to a
84
+ ``ProcessPoolExecutor``, and a local-function closure would raise
85
+ ``Can't pickle local object`` there. ``base`` is a scipy ``interp1d``,
86
+ which pickles cleanly. (#104 U5-4 follow-up.)
87
+ """
88
+
89
+ def __init__(self, base) -> None:
90
+ self._base = base
91
+
92
+ def __call__(self, query: npt.ArrayLike) -> np.ndarray:
93
+ out = np.asarray(self._base(query), dtype=np.float64)
94
+ wrap = np.isfinite(out) & ((out < -180.0) | (out >= 180.0))
95
+ return np.where(wrap, (out + 180.0) % 360.0 - 180.0, out)
96
+
97
+
98
+ def _lon_interp_dateline_safe(t: np.ndarray, lon: np.ndarray):
99
+ """Linear longitude interpolator that is safe across the +/-180 seam.
100
+
101
+ A plain linear interp of raw longitude sweeps ~360 deg the *wrong* way when
102
+ a track crosses the antimeridian (e.g. 179.9 -> -179.9 would pass through 0,
103
+ landing on the opposite side of the globe). Unwrap the finite nodes to a
104
+ continuous phase (``period=360``), interpolate that, then wrap only the
105
+ results that land outside [-180, 180) back into range. A track that never
106
+ crosses the seam unwraps to itself, and any interpolated value already in
107
+ [-180, 180) passes through untouched — so the result is bit-identical to the
108
+ old raw interpolation everywhere the position is in range (all of ARCTERX,
109
+ whose positions and time-bounded extrapolations stay near 145 E). Two kinds
110
+ of value differ from the old raw interp, both corrections rather than
111
+ regressions: (1) points on a seam-crossing segment, where the old code swept
112
+ the wrong way through 0 to the far side of the globe (still in range, but
113
+ wrong) while the new code takes the short path across +/-180; and (2) an
114
+ extreme extrapolation the old code pushed past +/-180, which the new code
115
+ wraps to the equivalent in-range meridian instead of a meaningless >180 deg
116
+ longitude.
117
+
118
+ Convention: output is normalized to [-180, 180). Inputs in the usual
119
+ [-180, 180) convention are unchanged (subject to the wrap above); a
120
+ [0, 360)-convention track is re-expressed in [-180, 180) — the same physical
121
+ positions, a different label. (#104 U5-4.)
122
+ """
123
+ from scipy.interpolate import interp1d
124
+
125
+ t = np.asarray(t, dtype=np.float64)
126
+ lon = np.asarray(lon, dtype=np.float64)
127
+ good = np.isfinite(t) & np.isfinite(lon)
128
+ if int(good.sum()) >= 2:
129
+ # unwrap needs the nodes in time order; interp1d otherwise sorts itself.
130
+ order = np.argsort(t[good])
131
+ t_g = t[good][order]
132
+ lon_u = np.unwrap(lon[good][order], period=360.0)
133
+ base = interp1d(t_g, lon_u, bounds_error=False, fill_value="extrapolate")
134
+ else:
135
+ fill = float(lon[good][0]) if good.any() else np.nan
136
+ base = interp1d([0.0, 1.0], [fill, fill], bounds_error=False, fill_value=fill)
137
+
138
+ return _DatelineSafeLon(base)
139
+
140
+
141
+ # CF time unit -> seconds factor. Only common units; falls back to
142
+ # ``num2date`` for the long tail (months, years, weird calendars, etc.)
143
+ _CF_TIME_UNIT_FACTORS: dict[str, float] = {
144
+ "s": 1.0,
145
+ "sec": 1.0,
146
+ "secs": 1.0,
147
+ "second": 1.0,
148
+ "seconds": 1.0,
149
+ "ms": 1e-3,
150
+ "msec": 1e-3,
151
+ "msecs": 1e-3,
152
+ "millisecond": 1e-3,
153
+ "milliseconds": 1e-3,
154
+ "us": 1e-6,
155
+ "microsecond": 1e-6,
156
+ "microseconds": 1e-6,
157
+ "ns": 1e-9,
158
+ "nanosecond": 1e-9,
159
+ "nanoseconds": 1e-9,
160
+ "min": 60.0,
161
+ "minute": 60.0,
162
+ "minutes": 60.0,
163
+ "h": 3600.0,
164
+ "hr": 3600.0,
165
+ "hrs": 3600.0,
166
+ "hour": 3600.0,
167
+ "hours": 3600.0,
168
+ "d": 86400.0,
169
+ "day": 86400.0,
170
+ "days": 86400.0,
171
+ }
172
+
173
+
174
+ def _decode_cf_times_fast(
175
+ raw: np.ndarray, units: str | None, calendar: str | None
176
+ ) -> np.ndarray | None:
177
+ """Decode CF "<unit> since <iso-datetime>" times to epoch seconds.
178
+
179
+ Returns the decoded float64 array, or ``None`` if the units string
180
+ isn't a form we can decode without falling back to ``num2date``.
181
+
182
+ Used as a fast path inside :class:`GPSFromNetCDF` for plain numeric
183
+ time variables; on a million-row GPS file this replaces a
184
+ per-element ``datetime.timestamp()`` loop with a single vector
185
+ multiply-add.
186
+ """
187
+ if units is None:
188
+ return None
189
+ units_str = str(units).strip().lower()
190
+ if " since " not in units_str:
191
+ return None
192
+ unit_part, _, ref_part = units_str.partition(" since ")
193
+ factor = _CF_TIME_UNIT_FACTORS.get(unit_part.strip())
194
+ if factor is None:
195
+ return None
196
+ if calendar and str(calendar).lower() not in (
197
+ "standard",
198
+ "gregorian",
199
+ "proleptic_gregorian",
200
+ ):
201
+ return None
202
+ if not np.issubdtype(raw.dtype, np.number):
203
+ return None
204
+
205
+ from datetime import UTC, datetime
206
+
207
+ ref_str = ref_part.strip().rstrip("z").rstrip("Z")
208
+ # Tolerate a few common CF reference-time spellings: pure ISO, with
209
+ # space separator, with or without timezone.
210
+ try:
211
+ ref_dt = datetime.fromisoformat(ref_str)
212
+ except ValueError:
213
+ try:
214
+ ref_dt = datetime.fromisoformat(ref_str.replace(" ", "T"))
215
+ except ValueError:
216
+ return None
217
+ if ref_dt.tzinfo is None:
218
+ ref_dt = ref_dt.replace(tzinfo=UTC)
219
+ epoch_s = ref_dt.timestamp()
220
+
221
+ return raw.astype(np.float64) * factor + epoch_s
222
+
223
+
224
+ @runtime_checkable
225
+ class GPSProvider(Protocol):
226
+ """Protocol for GPS position providers."""
227
+
228
+ def lat(self, t: npt.ArrayLike) -> np.ndarray: ...
229
+ def lon(self, t: npt.ArrayLike) -> np.ndarray: ...
230
+
231
+
232
+ class GPSNaN:
233
+ """Returns NaN for all GPS queries."""
234
+
235
+ def lat(self, t: npt.ArrayLike) -> np.ndarray:
236
+ """Return NaN latitude for all times."""
237
+ return np.full_like(np.asarray(t, dtype=np.float64), np.nan)
238
+
239
+ def lon(self, t: npt.ArrayLike) -> np.ndarray:
240
+ """Return NaN longitude for all times."""
241
+ return np.full_like(np.asarray(t, dtype=np.float64), np.nan)
242
+
243
+
244
+ class GPSFixed:
245
+ """Returns constant lat/lon."""
246
+
247
+ def __init__(self, lat: float, lon: float) -> None:
248
+ self._lat = float(lat)
249
+ self._lon = float(lon)
250
+
251
+ def lat(self, t: npt.ArrayLike) -> np.ndarray:
252
+ """Return fixed latitude for all times."""
253
+ return np.full_like(np.asarray(t, dtype=np.float64), self._lat)
254
+
255
+ def lon(self, t: npt.ArrayLike) -> np.ndarray:
256
+ """Return fixed longitude for all times."""
257
+ return np.full_like(np.asarray(t, dtype=np.float64), self._lon)
258
+
259
+
260
+ class GPSFromCSV:
261
+ """Interpolate GPS from a CSV file (time column expected in epoch seconds).
262
+
263
+ Positions requested more than ``max_time_diff`` seconds outside the
264
+ GPS record's time coverage are linearly extrapolated AND trigger a
265
+ warning — extrapolated positions feed the TEOS-10 absolute-salinity
266
+ conversion and the file geospatial attributes.
267
+ """
268
+
269
+ def __init__(
270
+ self,
271
+ file: str | Path,
272
+ time_col: str = "t",
273
+ lat_col: str = "lat",
274
+ lon_col: str = "lon",
275
+ max_time_diff: float = 60.0,
276
+ ) -> None:
277
+ import pandas as pd
278
+
279
+ df = pd.read_csv(file)
280
+ t = _to_epoch_seconds(np.asarray(df[time_col].values))
281
+ lat = np.asarray(df[lat_col].values, dtype=np.float64)
282
+ lon = np.asarray(df[lon_col].values, dtype=np.float64)
283
+ # Coverage window = the finite (time, lat, lon) span the interpolators
284
+ # honor without extrapolating (see GPSFromNetCDF for the rationale).
285
+ _cov = np.isfinite(t) & np.isfinite(lat) & np.isfinite(lon)
286
+ if _cov.any():
287
+ self._t_min = float(np.min(t[_cov]))
288
+ self._t_max = float(np.max(t[_cov]))
289
+ else:
290
+ self._t_min = float(np.nanmin(t))
291
+ self._t_max = float(np.nanmax(t))
292
+ self._max_time_diff = float(max_time_diff)
293
+ self._lat_interp = _finite_interp1d(t, lat)
294
+ self._lon_interp = _lon_interp_dateline_safe(t, lon)
295
+
296
+ def lat(self, t: npt.ArrayLike) -> np.ndarray:
297
+ """Interpolate latitude from CSV at the given times."""
298
+ t_arr = np.asarray(t, dtype=np.float64)
299
+ _warn_outside_coverage(t_arr, self._t_min, self._t_max, self._max_time_diff)
300
+ return np.asarray(self._lat_interp(t_arr))
301
+
302
+ def lon(self, t: npt.ArrayLike) -> np.ndarray:
303
+ """Interpolate longitude from CSV at the given times."""
304
+ t_arr = np.asarray(t, dtype=np.float64)
305
+ _warn_outside_coverage(t_arr, self._t_min, self._t_max, self._max_time_diff)
306
+ return np.asarray(self._lon_interp(t_arr))
307
+
308
+
309
+ def _warn_outside_coverage(
310
+ t: np.ndarray, t_min: float, t_max: float, max_time_diff: float
311
+ ) -> None:
312
+ """Warn when requested times fall outside the GPS record coverage.
313
+
314
+ Beyond the coverage, positions are linear extrapolations without
315
+ bound; ``max_time_diff`` (config key ``gps.max_time_diff``) sets the
316
+ tolerance before warning.
317
+ """
318
+ if t.size == 0 or not np.any(np.isfinite(t)):
319
+ return
320
+ lo = np.nanmin(t) - (t_min - max_time_diff)
321
+ hi = np.nanmax(t) - (t_max + max_time_diff)
322
+ if lo < 0 or hi > 0:
323
+ worst = max(-lo if lo < 0 else 0.0, hi if hi > 0 else 0.0)
324
+ warnings.warn(
325
+ f"GPS position requested {worst:.0f} s outside the GPS record "
326
+ f"coverage (tolerance {max_time_diff:.0f} s); positions are "
327
+ "linearly extrapolated and may be far from the instrument",
328
+ stacklevel=3,
329
+ )
330
+
331
+
332
+ class GPSFromNetCDF:
333
+ """Interpolate GPS from a NetCDF file.
334
+
335
+ Positions requested more than ``max_time_diff`` seconds outside the
336
+ GPS record's time coverage are linearly extrapolated AND trigger a
337
+ warning (see :class:`GPSFromCSV`).
338
+ """
339
+
340
+ def __init__(
341
+ self,
342
+ file: str | Path,
343
+ time_var: str = "time",
344
+ lat_var: str = "lat",
345
+ lon_var: str = "lon",
346
+ max_time_diff: float = 60.0,
347
+ ) -> None:
348
+ import netCDF4 as nc
349
+
350
+ # Read raw arrays inside the context manager so the Dataset is closed
351
+ # even if a read raises; decode times afterwards (raw/lat/lon are
352
+ # in-memory copies that outlive the file handle).
353
+ with nc.Dataset(str(file), "r") as ds:
354
+ t_var = ds.variables[time_var]
355
+ units = getattr(t_var, "units", None)
356
+ calendar = getattr(t_var, "calendar", "standard")
357
+ raw = _nc_values(t_var)
358
+ lat = _nc_values(ds.variables[lat_var]).astype(np.float64)
359
+ lon = _nc_values(ds.variables[lon_var]).astype(np.float64)
360
+
361
+ # Fast path: when the variable is plain numeric and its CF units
362
+ # are "<unit> since <iso-datetime>", we can compute epoch seconds
363
+ # by a single scalar+vector arithmetic step instead of going
364
+ # through ``num2date`` + a per-element ``datetime.timestamp()``
365
+ # list comprehension (which dominated startup on multi-million-
366
+ # row GPS files).
367
+ t = _decode_cf_times_fast(raw, units, calendar)
368
+ if t is None:
369
+ if units and "since" in str(units):
370
+ from datetime import UTC
371
+
372
+ decoded = nc.num2date(
373
+ raw,
374
+ units=str(units),
375
+ calendar=str(calendar),
376
+ only_use_cftime_datetimes=False,
377
+ )
378
+ dts_list = (
379
+ [decoded] if not hasattr(decoded, "__iter__") else list(decoded) # type: ignore[redundant-expr]
380
+ )
381
+ t = np.array(
382
+ [
383
+ (d.replace(tzinfo=UTC) if d.tzinfo is None else d).timestamp()
384
+ for d in dts_list
385
+ ],
386
+ dtype=np.float64,
387
+ )
388
+ else:
389
+ t = _to_epoch_seconds(raw)
390
+
391
+ # Coverage window = the span the interpolators can actually honor
392
+ # without extrapolating: the finite (time, lat, lon) nodes. Using the
393
+ # full decoded time range would hide fabricated extrapolations when
394
+ # lat/lon drop out at the record's temporal extremes (a GPS fix dropout
395
+ # at the start/end of the file), since _finite_interp1d silently
396
+ # extrapolates past the last finite fix.
397
+ _cov = np.isfinite(t) & np.isfinite(lat) & np.isfinite(lon)
398
+ if _cov.any():
399
+ self._t_min = float(np.min(t[_cov]))
400
+ self._t_max = float(np.max(t[_cov]))
401
+ else:
402
+ self._t_min = float(np.nanmin(t))
403
+ self._t_max = float(np.nanmax(t))
404
+ self._max_time_diff = float(max_time_diff)
405
+ self._lat_interp = _finite_interp1d(t, lat)
406
+ self._lon_interp = _lon_interp_dateline_safe(t, lon)
407
+
408
+ def lat(self, t: npt.ArrayLike) -> np.ndarray:
409
+ """Interpolate latitude from NetCDF at the given times."""
410
+ t_arr = np.asarray(t, dtype=np.float64)
411
+ _warn_outside_coverage(t_arr, self._t_min, self._t_max, self._max_time_diff)
412
+ return np.asarray(self._lat_interp(t_arr))
413
+
414
+ def lon(self, t: npt.ArrayLike) -> np.ndarray:
415
+ """Interpolate longitude from NetCDF at the given times."""
416
+ t_arr = np.asarray(t, dtype=np.float64)
417
+ _warn_outside_coverage(t_arr, self._t_min, self._t_max, self._max_time_diff)
418
+ return np.asarray(self._lon_interp(t_arr))
419
+
420
+
421
+ def create_gps(config: dict) -> GPSProvider:
422
+ """Factory: create a GPSProvider from the ``gps`` config section.
423
+
424
+ Parameters
425
+ ----------
426
+ config : dict
427
+ Merged GPS config (source, lat, lon, file, time_col, etc.).
428
+
429
+ Returns
430
+ -------
431
+ GPSProvider
432
+ """
433
+ source = config.get("source", "nan").lower()
434
+
435
+ if source == "nan":
436
+ return GPSNaN()
437
+ elif source == "fixed":
438
+ return GPSFixed(config["lat"], config["lon"])
439
+ elif source == "csv":
440
+ # time_col default is source-dependent ("t" for CSV, "time" for NetCDF);
441
+ # the shared gps.time_col default is null so each branch can pick its own.
442
+ return GPSFromCSV(
443
+ config["file"],
444
+ time_col=config.get("time_col") or "t",
445
+ lat_col=config.get("lat_col", "lat"),
446
+ lon_col=config.get("lon_col", "lon"),
447
+ max_time_diff=float(config.get("max_time_diff", 60)),
448
+ )
449
+ elif source == "netcdf":
450
+ return GPSFromNetCDF(
451
+ config["file"],
452
+ time_var=config.get("time_col") or "time",
453
+ lat_var=config.get("lat_col", "lat"),
454
+ lon_var=config.get("lon_col", "lon"),
455
+ max_time_diff=float(config.get("max_time_diff", 60)),
456
+ )
457
+ else:
458
+ raise ValueError(f"Unknown GPS source: {source!r}")