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/perturb/gps.py
ADDED
|
@@ -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}")
|