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,396 @@
|
|
|
1
|
+
# Jun-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""X-axis coordinate methods for perturb section plots.
|
|
3
|
+
|
|
4
|
+
A pure-geometry kernel: map per-point ``(lat, lon)`` and ``time`` to a 1-D
|
|
5
|
+
x-coordinate under a chosen method. Dimension-agnostic by design — the same
|
|
6
|
+
functions serve the per-sample CTD trajectory (``section`` subcommand) and,
|
|
7
|
+
later, per-profile section grids (one position per cast).
|
|
8
|
+
|
|
9
|
+
No NetCDF I/O, no matplotlib, no gridding here. Non-finite positions
|
|
10
|
+
propagate to non-finite x (callers drop those samples before binning); we
|
|
11
|
+
never substitute a fill value, so a missing GPS fix can't masquerade as a
|
|
12
|
+
real location.
|
|
13
|
+
|
|
14
|
+
References for the distance maths:
|
|
15
|
+
- Haversine great-circle distance (radial distance from a fixed point).
|
|
16
|
+
- Local equirectangular projection for along-line projection: accurate to
|
|
17
|
+
well under 1% over the few-hundred-km transects this is used for.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
# Mean Earth radius (spherical approximation; the sub-0.3% flattening error is
|
|
27
|
+
# negligible against the sampling/binning scales of a microstructure section).
|
|
28
|
+
_EARTH_RADIUS_M = 6_371_000.0
|
|
29
|
+
|
|
30
|
+
# Distance unit -> meters-per-unit scale factor. 1 nm = 1852 m exactly.
|
|
31
|
+
_UNIT_PER_M: dict[str, float] = {"m": 1.0, "km": 1.0e-3, "nm": 1.0 / 1852.0}
|
|
32
|
+
|
|
33
|
+
METHODS: tuple[str, ...] = (
|
|
34
|
+
"time",
|
|
35
|
+
"latitude",
|
|
36
|
+
"longitude",
|
|
37
|
+
"distance_from_point",
|
|
38
|
+
"along_line",
|
|
39
|
+
"signed_distance",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class XAxis:
|
|
45
|
+
"""Result of :func:`compute`.
|
|
46
|
+
|
|
47
|
+
``x`` is a numeric 1-D coordinate (epoch seconds for ``time``; degrees for
|
|
48
|
+
latitude/longitude; distance in the requested units otherwise). ``kind``
|
|
49
|
+
is ``"time"`` or ``"spatial"`` so the renderer can pick a date formatter.
|
|
50
|
+
``cross`` (along_line only) is each sample's cross-track distance in the
|
|
51
|
+
requested units, else ``None``.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
x: np.ndarray
|
|
55
|
+
label: str
|
|
56
|
+
kind: str
|
|
57
|
+
cross: np.ndarray | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _unit_scale(units: str) -> float:
|
|
61
|
+
try:
|
|
62
|
+
return _UNIT_PER_M[units]
|
|
63
|
+
except KeyError:
|
|
64
|
+
raise ValueError(f"unknown distance units {units!r}; use 'm', 'km', or 'nm'") from None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _wrap_deg(d: np.ndarray | float) -> np.ndarray:
|
|
68
|
+
"""Wrap a longitude difference into [-180, 180) so it is dateline-safe."""
|
|
69
|
+
return (np.asarray(d, dtype=float) + 180.0) % 360.0 - 180.0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _circmean_deg(lon: np.ndarray) -> float:
|
|
73
|
+
"""Circular mean of longitudes [deg], dateline-safe.
|
|
74
|
+
|
|
75
|
+
A plain ``nanmean`` of raw degrees averages the *wrapped* values, so a
|
|
76
|
+
transect straddling +/-180 lands on the antipode (e.g. [179, -179] -> 0,
|
|
77
|
+
not 180), which then distorts the local projection by ~180x. Averaging the
|
|
78
|
+
unit vectors and taking the angle keeps the reference on the correct side.
|
|
79
|
+
"""
|
|
80
|
+
rad = np.radians(np.asarray(lon, dtype=float))
|
|
81
|
+
finite = np.isfinite(rad)
|
|
82
|
+
if not np.any(finite):
|
|
83
|
+
return float("nan")
|
|
84
|
+
return float(
|
|
85
|
+
np.degrees(np.arctan2(np.nanmean(np.sin(rad[finite])), np.nanmean(np.cos(rad[finite]))))
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def to_epoch_seconds(time: np.ndarray) -> np.ndarray:
|
|
90
|
+
"""Return *time* as float epoch seconds.
|
|
91
|
+
|
|
92
|
+
Accepts datetime64 (decoded CF time) or an already-numeric epoch-seconds
|
|
93
|
+
array. datetime64 is assumed UTC, matching the CTD product's
|
|
94
|
+
``seconds since 1970-01-01`` encoding.
|
|
95
|
+
"""
|
|
96
|
+
t = np.asarray(time)
|
|
97
|
+
if np.issubdtype(t.dtype, np.datetime64):
|
|
98
|
+
return t.astype("datetime64[ns]").astype("int64") / 1.0e9
|
|
99
|
+
return t.astype(float)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def haversine_m(lat0: float, lon0: float, lat: np.ndarray, lon: np.ndarray) -> np.ndarray:
|
|
103
|
+
"""Great-circle distance [m] from the fixed point (lat0, lon0) to each point.
|
|
104
|
+
|
|
105
|
+
NaN positions propagate to NaN distance. Dateline-safe via longitude
|
|
106
|
+
wrapping.
|
|
107
|
+
"""
|
|
108
|
+
lat = np.asarray(lat, dtype=float)
|
|
109
|
+
lon = np.asarray(lon, dtype=float)
|
|
110
|
+
phi0 = np.radians(lat0)
|
|
111
|
+
phi = np.radians(lat)
|
|
112
|
+
dphi = np.radians(lat - lat0)
|
|
113
|
+
dlmb = np.radians(_wrap_deg(lon - lon0))
|
|
114
|
+
a = np.sin(dphi / 2.0) ** 2 + np.cos(phi0) * np.cos(phi) * np.sin(dlmb / 2.0) ** 2
|
|
115
|
+
dist = 2.0 * _EARTH_RADIUS_M * np.arcsin(np.sqrt(np.clip(a, 0.0, 1.0)))
|
|
116
|
+
return np.asarray(dist, dtype=float)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def distance_from_point(
|
|
120
|
+
lat: np.ndarray, lon: np.ndarray, point: tuple[float, float], units: str = "km"
|
|
121
|
+
) -> np.ndarray:
|
|
122
|
+
"""Radial great-circle distance from a fixed (lat, lon) reference point.
|
|
123
|
+
|
|
124
|
+
*point* is ``(lat, lon)`` in degrees. This is N independent distances to
|
|
125
|
+
one fixed point — deliberately a haversine rather than ``gsw.distance``
|
|
126
|
+
(which returns N-1 distances between *consecutive* points and takes its
|
|
127
|
+
arguments lon-first).
|
|
128
|
+
"""
|
|
129
|
+
lat0, lon0 = float(point[0]), float(point[1])
|
|
130
|
+
return haversine_m(lat0, lon0, lat, lon) * _unit_scale(units)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _to_local_xy(
|
|
134
|
+
lat: np.ndarray, lon: np.ndarray, lat_ref: float, lon_ref: float
|
|
135
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
136
|
+
"""Local equirectangular (east, north) meters about (lat_ref, lon_ref)."""
|
|
137
|
+
x = (
|
|
138
|
+
np.radians(_wrap_deg(np.asarray(lon, float) - lon_ref))
|
|
139
|
+
* np.cos(np.radians(lat_ref))
|
|
140
|
+
* _EARTH_RADIUS_M
|
|
141
|
+
)
|
|
142
|
+
y = np.radians(np.asarray(lat, float) - lat_ref) * _EARTH_RADIUS_M
|
|
143
|
+
return x, y
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def project_along_line(
|
|
147
|
+
lat: np.ndarray,
|
|
148
|
+
lon: np.ndarray,
|
|
149
|
+
waypoints: np.ndarray,
|
|
150
|
+
units: str = "km",
|
|
151
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
152
|
+
"""Project each (lat, lon) onto a waypoint polyline.
|
|
153
|
+
|
|
154
|
+
Returns ``(along, cross)``: cumulative along-line distance to the foot of
|
|
155
|
+
the perpendicular on the nearest segment, and the cross-track distance to
|
|
156
|
+
that foot, both in *units*. The foot is clamped to each segment, so
|
|
157
|
+
samples beyond the polyline ends fold onto the terminal waypoint.
|
|
158
|
+
|
|
159
|
+
*waypoints* is an ``(M, 2)`` array of ``[lat, lon]`` rows (M >= 2). The
|
|
160
|
+
projection uses a local equirectangular plane centered on the data
|
|
161
|
+
centroid — exact enough for the few-hundred-km transects this serves, but
|
|
162
|
+
an approximation, not a geodesic.
|
|
163
|
+
"""
|
|
164
|
+
lat = np.asarray(lat, dtype=float)
|
|
165
|
+
lon = np.asarray(lon, dtype=float)
|
|
166
|
+
wp = np.asarray(waypoints, dtype=float)
|
|
167
|
+
if wp.ndim != 2 or wp.shape[0] < 2 or wp.shape[1] != 2:
|
|
168
|
+
raise ValueError("along_line needs >= 2 waypoints as [[lat, lon], ...]")
|
|
169
|
+
|
|
170
|
+
lat_ref = float(np.nanmean(lat)) if np.any(np.isfinite(lat)) else float(wp[0, 0])
|
|
171
|
+
# Circular mean so a dateline-crossing transect's reference stays on the
|
|
172
|
+
# correct side of +/-180 (bug_001).
|
|
173
|
+
lon_ref = _circmean_deg(lon) if np.any(np.isfinite(lon)) else float(wp[0, 1])
|
|
174
|
+
|
|
175
|
+
px, py = _to_local_xy(lat, lon, lat_ref, lon_ref)
|
|
176
|
+
wx, wy = _to_local_xy(wp[:, 0], wp[:, 1], lat_ref, lon_ref)
|
|
177
|
+
|
|
178
|
+
seg_dx = np.diff(wx)
|
|
179
|
+
seg_dy = np.diff(wy)
|
|
180
|
+
seg_len2 = seg_dx**2 + seg_dy**2
|
|
181
|
+
seg_len = np.sqrt(seg_len2)
|
|
182
|
+
cumlen = np.concatenate(([0.0], np.cumsum(seg_len)))
|
|
183
|
+
|
|
184
|
+
n = px.size
|
|
185
|
+
finite = np.isfinite(px) & np.isfinite(py)
|
|
186
|
+
best_along = np.full(n, np.nan)
|
|
187
|
+
best_cross = np.full(n, np.inf)
|
|
188
|
+
for s in range(seg_dx.size):
|
|
189
|
+
if seg_len2[s] == 0.0:
|
|
190
|
+
continue # zero-length segment (duplicate waypoint)
|
|
191
|
+
t = ((px - wx[s]) * seg_dx[s] + (py - wy[s]) * seg_dy[s]) / seg_len2[s]
|
|
192
|
+
t = np.clip(t, 0.0, 1.0)
|
|
193
|
+
fx = wx[s] + t * seg_dx[s]
|
|
194
|
+
fy = wy[s] + t * seg_dy[s]
|
|
195
|
+
cross = np.hypot(px - fx, py - fy)
|
|
196
|
+
along = cumlen[s] + t * seg_len[s]
|
|
197
|
+
upd = finite & (cross < best_cross)
|
|
198
|
+
best_along = np.where(upd, along, best_along)
|
|
199
|
+
best_cross = np.where(upd, cross, best_cross)
|
|
200
|
+
|
|
201
|
+
best_cross = np.where(np.isfinite(best_along), best_cross, np.nan)
|
|
202
|
+
scale = _unit_scale(units)
|
|
203
|
+
return best_along * scale, best_cross * scale
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass
|
|
207
|
+
class SignedAxis:
|
|
208
|
+
"""Result of :func:`signed_distance_axis`.
|
|
209
|
+
|
|
210
|
+
``x`` is the signed along-axis distance from the midpoint. ``mid_lat`` /
|
|
211
|
+
``mid_lon`` are the centroid (the midpoint). ``bearing_deg`` is the
|
|
212
|
+
orientation of *increasing distance* clockwise from true north;
|
|
213
|
+
``bearing_std_deg`` is the circular standard deviation of the points'
|
|
214
|
+
orientation about the best-fit line. Orientation fields are NaN when the
|
|
215
|
+
track has no spatial spread.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
x: np.ndarray
|
|
219
|
+
mid_lat: float
|
|
220
|
+
mid_lon: float
|
|
221
|
+
bearing_deg: float
|
|
222
|
+
bearing_std_deg: float
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def signed_distance_axis(
|
|
226
|
+
lat: np.ndarray, lon: np.ndarray, time: np.ndarray, units: str = "km"
|
|
227
|
+
) -> SignedAxis:
|
|
228
|
+
"""Project (lat, lon) onto the best-fit principal axis of the points.
|
|
229
|
+
|
|
230
|
+
Returns the signed along-axis distance from the centroid (earliest sample
|
|
231
|
+
negative, latest positive) plus the midpoint and the axis orientation
|
|
232
|
+
relative to true north with a circular standard deviation.
|
|
233
|
+
|
|
234
|
+
Orientation and spread come from the orientation tensor (a total-least-
|
|
235
|
+
squares line fit through the centroid). With centroid-relative
|
|
236
|
+
displacements ``z = east + i*north``, ``S = sum(z**2)`` has argument
|
|
237
|
+
``2*theta`` (theta = major-axis angle) and modulus-ratio
|
|
238
|
+
``R = |S| / sum|z|**2`` is the doubled-angle resultant length; the circular
|
|
239
|
+
standard deviation of the (undirected) orientation is ``sqrt(-2 ln R) / 2``.
|
|
240
|
+
|
|
241
|
+
Non-finite positions propagate to NaN x. A single station (no spatial
|
|
242
|
+
spread) yields all-zero distances and NaN orientation.
|
|
243
|
+
"""
|
|
244
|
+
lat = np.asarray(lat, dtype=float)
|
|
245
|
+
lon = np.asarray(lon, dtype=float)
|
|
246
|
+
finite = np.isfinite(lat) & np.isfinite(lon)
|
|
247
|
+
nan = float("nan")
|
|
248
|
+
if not np.any(finite):
|
|
249
|
+
return SignedAxis(np.full(lat.shape, np.nan), nan, nan, nan, nan)
|
|
250
|
+
|
|
251
|
+
mid_lat = float(np.nanmean(lat))
|
|
252
|
+
# Circular mean so a dateline-crossing transect's reference (and the
|
|
253
|
+
# midpoint label) stays on the correct side of +/-180 (bug_001).
|
|
254
|
+
mid_lon = _circmean_deg(lon)
|
|
255
|
+
east, north = _to_local_xy(lat, lon, mid_lat, mid_lon)
|
|
256
|
+
de = east - float(np.mean(east[finite])) # east displacement from centroid
|
|
257
|
+
dn = north - float(np.mean(north[finite])) # north displacement
|
|
258
|
+
ef, nf = de[finite], dn[finite]
|
|
259
|
+
|
|
260
|
+
sumsq = float(np.sum(ef**2 + nf**2))
|
|
261
|
+
if sumsq <= 0.0: # single station / no spatial spread
|
|
262
|
+
return SignedAxis(np.where(finite, 0.0, np.nan), mid_lat, mid_lon, nan, nan)
|
|
263
|
+
|
|
264
|
+
# Orientation tensor via the doubled-angle complex sum.
|
|
265
|
+
s = complex(np.sum((ef + 1j * nf) ** 2))
|
|
266
|
+
phi = float(np.angle(s)) / 2.0 # major-axis angle from east
|
|
267
|
+
axis = np.array([np.cos(phi), np.sin(phi)]) # (east, north) unit vector
|
|
268
|
+
|
|
269
|
+
proj = de * axis[0] + dn * axis[1]
|
|
270
|
+
# Orient earliest -> negative, latest -> positive.
|
|
271
|
+
tf = to_epoch_seconds(time)[finite]
|
|
272
|
+
pf = proj[finite]
|
|
273
|
+
if np.std(pf) > 0 and np.std(tf) > 0 and float(np.cov(pf, tf)[0, 1]) < 0:
|
|
274
|
+
proj = -proj
|
|
275
|
+
axis = -axis
|
|
276
|
+
|
|
277
|
+
bearing = float(np.degrees(np.arctan2(axis[0], axis[1])) % 360.0) # CW from N
|
|
278
|
+
r = min(max(abs(s) / sumsq, 1e-12), 1.0)
|
|
279
|
+
std_deg = float(np.degrees(np.sqrt(-2.0 * np.log(r)) / 2.0))
|
|
280
|
+
return SignedAxis(
|
|
281
|
+
np.asarray(proj * _unit_scale(units), dtype=float),
|
|
282
|
+
mid_lat,
|
|
283
|
+
mid_lon,
|
|
284
|
+
bearing,
|
|
285
|
+
std_deg,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def signed_distance_from_midpoint(
|
|
290
|
+
lat: np.ndarray, lon: np.ndarray, time: np.ndarray, units: str = "km"
|
|
291
|
+
) -> np.ndarray:
|
|
292
|
+
"""Signed distance from the midpoint along the points' principal axis.
|
|
293
|
+
|
|
294
|
+
Thin wrapper over :func:`signed_distance_axis` returning only the
|
|
295
|
+
coordinate (earliest negative, latest positive; non-finite -> NaN).
|
|
296
|
+
"""
|
|
297
|
+
return signed_distance_axis(lat, lon, time, units).x
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _fmt_lat_lon(lat: float, lon: float) -> str:
|
|
301
|
+
"""Latitude/longitude to 4 significant digits with hemisphere suffixes."""
|
|
302
|
+
ns = "N" if lat >= 0 else "S"
|
|
303
|
+
ew = "E" if lon >= 0 else "W"
|
|
304
|
+
return f"{abs(lat):.4g}°{ns}, {abs(lon):.4g}°{ew}"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def project_onto_signed_axis(
|
|
308
|
+
lat: np.ndarray,
|
|
309
|
+
lon: np.ndarray,
|
|
310
|
+
mid_lat: float,
|
|
311
|
+
mid_lon: float,
|
|
312
|
+
bearing_deg: float,
|
|
313
|
+
units: str = "km",
|
|
314
|
+
) -> np.ndarray:
|
|
315
|
+
"""Signed along-axis distance of ``(lat, lon)`` from a FIXED frame.
|
|
316
|
+
|
|
317
|
+
The frame is a centroid ``(mid_lat, mid_lon)`` and an orientation
|
|
318
|
+
``bearing_deg`` (clockwise from true north, the direction of increasing
|
|
319
|
+
distance) — typically the ``mid_*`` / ``bearing_deg`` of a
|
|
320
|
+
:func:`signed_distance_axis` fit over a *reference* point set. Unlike
|
|
321
|
+
:func:`signed_distance_axis` (which derives the frame from the points it is
|
|
322
|
+
handed), this projects an arbitrary point set onto a *given* frame, so
|
|
323
|
+
several products can share one coordinate system instead of each deriving
|
|
324
|
+
its own centroid/orientation. Non-finite positions propagate to NaN.
|
|
325
|
+
"""
|
|
326
|
+
east, north = _to_local_xy(
|
|
327
|
+
np.asarray(lat, dtype=float), np.asarray(lon, dtype=float), mid_lat, mid_lon
|
|
328
|
+
)
|
|
329
|
+
# bearing is CW-from-north for increasing distance -> unit vector
|
|
330
|
+
# (east, north) = (sin, cos); east/north are displacements from the origin.
|
|
331
|
+
br = np.radians(bearing_deg)
|
|
332
|
+
proj = east * np.sin(br) + north * np.cos(br)
|
|
333
|
+
return np.asarray(proj * _unit_scale(units), dtype=float)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def signed_axis_label(r: SignedAxis, units: str) -> str:
|
|
337
|
+
"""Human-readable x-axis label for a :func:`signed_distance_axis` fit *r*."""
|
|
338
|
+
if np.isfinite(r.mid_lat) and np.isfinite(r.bearing_deg):
|
|
339
|
+
return (
|
|
340
|
+
f"Signed distance from {_fmt_lat_lon(r.mid_lat, r.mid_lon)} [{units}], "
|
|
341
|
+
f"orientation {r.bearing_deg:.1f}°±{r.bearing_std_deg:.1f}° T"
|
|
342
|
+
)
|
|
343
|
+
if np.isfinite(r.mid_lat):
|
|
344
|
+
return f"Signed distance from {_fmt_lat_lon(r.mid_lat, r.mid_lon)} [{units}]"
|
|
345
|
+
return f"Signed distance from midpoint [{units}]"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def compute(
|
|
349
|
+
method: str,
|
|
350
|
+
lat: np.ndarray,
|
|
351
|
+
lon: np.ndarray,
|
|
352
|
+
time: np.ndarray,
|
|
353
|
+
params: dict | None = None,
|
|
354
|
+
) -> XAxis:
|
|
355
|
+
"""Dispatch to the requested x-axis method.
|
|
356
|
+
|
|
357
|
+
*params* carries method-specific options: ``units`` (distance methods),
|
|
358
|
+
``point`` ([lat, lon], distance_from_point), ``waypoints`` ([[lat,lon]..],
|
|
359
|
+
along_line). Raises ``ValueError`` on an unknown method or missing param.
|
|
360
|
+
"""
|
|
361
|
+
params = params or {}
|
|
362
|
+
units = params.get("units", "km")
|
|
363
|
+
|
|
364
|
+
if method == "time":
|
|
365
|
+
return XAxis(x=to_epoch_seconds(time), label="Time (UTC)", kind="time")
|
|
366
|
+
if method == "latitude":
|
|
367
|
+
return XAxis(x=np.asarray(lat, float), label=r"Latitude ($^{\circ}$N)", kind="spatial")
|
|
368
|
+
if method == "longitude":
|
|
369
|
+
# Unwrap about the circular mean so a track straddling +/-180 stays
|
|
370
|
+
# contiguous and monotone instead of splitting into two antipodal
|
|
371
|
+
# clusters with a ~358-deg blank gap once columns are sorted by x
|
|
372
|
+
# (bug_001 dateline). A non-crossing track is unchanged: the wrapped
|
|
373
|
+
# offset equals the raw offset, so x == lon to within rounding.
|
|
374
|
+
ref = _circmean_deg(np.asarray(lon, float))
|
|
375
|
+
if np.isfinite(ref):
|
|
376
|
+
x = ref + _wrap_deg(np.asarray(lon, float) - ref)
|
|
377
|
+
else:
|
|
378
|
+
x = np.asarray(lon, float)
|
|
379
|
+
return XAxis(x=np.asarray(x, float), label=r"Longitude ($^{\circ}$E)", kind="spatial")
|
|
380
|
+
if method == "distance_from_point":
|
|
381
|
+
point = params.get("point")
|
|
382
|
+
if point is None or len(point) != 2:
|
|
383
|
+
raise ValueError("distance_from_point needs params['point'] = [lat, lon]")
|
|
384
|
+
x = distance_from_point(lat, lon, (float(point[0]), float(point[1])), units)
|
|
385
|
+
label = f"Distance from ({float(point[0]):.4f}, {float(point[1]):.4f}) [{units}]"
|
|
386
|
+
return XAxis(x=x, label=label, kind="spatial")
|
|
387
|
+
if method == "along_line":
|
|
388
|
+
waypoints = params.get("waypoints")
|
|
389
|
+
if waypoints is None:
|
|
390
|
+
raise ValueError("along_line needs params['waypoints'] = [[lat, lon], ...]")
|
|
391
|
+
along, cross = project_along_line(lat, lon, np.asarray(waypoints, float), units)
|
|
392
|
+
return XAxis(x=along, label=f"Along-track distance [{units}]", kind="spatial", cross=cross)
|
|
393
|
+
if method == "signed_distance":
|
|
394
|
+
r = signed_distance_axis(lat, lon, time, units)
|
|
395
|
+
return XAxis(x=r.x, label=signed_axis_label(r, units), kind="spatial")
|
|
396
|
+
raise ValueError(f"unknown x-axis method {method!r}; choose from {METHODS}")
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# May-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Per-segment QC gate.
|
|
3
|
+
|
|
4
|
+
For each entry along a dissipation / chi dataset's ``time`` dimension we
|
|
5
|
+
sample each named hotel-injected QC channel over that segment's time
|
|
6
|
+
window and OR-reduce. Nonzero output = "drop this segment." The flag is
|
|
7
|
+
always written; whether the corresponding epsilon / chi values are
|
|
8
|
+
NaN'd is governed by ``qc.drop_action``.
|
|
9
|
+
|
|
10
|
+
The hotel-injected channels are loaded as slow-rate numpy arrays on
|
|
11
|
+
``pf.channels``. They are expected to be uint8 CF bitfields (or
|
|
12
|
+
boolean), so a bitwise OR across all named channels gives one uint8
|
|
13
|
+
``qc_drop`` per segment, preserving the source ``flag_meanings`` /
|
|
14
|
+
``flag_masks`` attrs.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import warnings
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def compute_segment_drop(
|
|
27
|
+
seg_times: np.ndarray,
|
|
28
|
+
diss_length_seconds: float,
|
|
29
|
+
pf: Any,
|
|
30
|
+
drop_from: Sequence[str],
|
|
31
|
+
) -> tuple[np.ndarray, dict[str, Any]]:
|
|
32
|
+
"""OR-reduce the named slow-rate hotel channels over each segment.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
seg_times : (n_seg,) float64
|
|
37
|
+
Segment center times in seconds since ``pf.start_time``. (Matches
|
|
38
|
+
the ``time`` coord on the diss / chi xr.Dataset returned by
|
|
39
|
+
``_compute_epsilon`` / ``_compute_chi``.)
|
|
40
|
+
diss_length_seconds : float
|
|
41
|
+
Length of one dissipation window in seconds. The drop test
|
|
42
|
+
covers ``[t_center - L/2, t_center + L/2]``.
|
|
43
|
+
pf : PFile
|
|
44
|
+
The merged PFile holding ``channels``, ``channel_info``, and
|
|
45
|
+
``t_slow``. The named drop_from channels must exist on
|
|
46
|
+
``pf.channels`` at slow rate.
|
|
47
|
+
drop_from : list of str
|
|
48
|
+
Hotel channel names to OR together. Channels missing from
|
|
49
|
+
``pf.channels`` are skipped silently — that lets a config name
|
|
50
|
+
a channel that an instrument's hotel file simply doesn't have
|
|
51
|
+
(e.g. a VMP run without a thruster).
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
qc_drop : (n_seg,) uint8
|
|
56
|
+
Bitwise-OR of the source channel bits over each segment window.
|
|
57
|
+
attrs : dict
|
|
58
|
+
``flag_meanings`` / ``flag_masks`` aggregated across the source
|
|
59
|
+
channels. Empty when no source channel had those attrs.
|
|
60
|
+
"""
|
|
61
|
+
n_seg = len(seg_times)
|
|
62
|
+
qc = np.zeros(n_seg, dtype=np.uint8)
|
|
63
|
+
|
|
64
|
+
if not drop_from:
|
|
65
|
+
return qc, {}
|
|
66
|
+
|
|
67
|
+
t_slow = np.asarray(pf.t_slow, dtype=np.float64)
|
|
68
|
+
half = float(diss_length_seconds) / 2.0
|
|
69
|
+
starts = np.asarray(seg_times, dtype=np.float64) - half
|
|
70
|
+
ends = np.asarray(seg_times, dtype=np.float64) + half
|
|
71
|
+
|
|
72
|
+
# searchsorted gives O(log n) lookup of segment boundaries on the
|
|
73
|
+
# slow grid; for each segment, OR-reduce the slice.
|
|
74
|
+
lo = np.searchsorted(t_slow, starts, side="left")
|
|
75
|
+
hi = np.searchsorted(t_slow, ends, side="right")
|
|
76
|
+
|
|
77
|
+
flag_meanings: list[str] = []
|
|
78
|
+
flag_masks: list[int] = []
|
|
79
|
+
seen_meanings: set[str] = set()
|
|
80
|
+
|
|
81
|
+
for name in drop_from:
|
|
82
|
+
if name not in pf.channels:
|
|
83
|
+
continue
|
|
84
|
+
arr = np.asarray(pf.channels[name])
|
|
85
|
+
# The lo/hi segment indices are positions on the slow grid, so a
|
|
86
|
+
# drop_from channel of any other length would be mis-sampled
|
|
87
|
+
# (wrong/partial time region). Skip-and-warn rather than silently
|
|
88
|
+
# under/over-flag.
|
|
89
|
+
if arr.shape[0] != t_slow.size:
|
|
90
|
+
warnings.warn(
|
|
91
|
+
f"qc gate: drop_from channel {name!r} has length "
|
|
92
|
+
f"{arr.shape[0]} != slow length {t_slow.size}; skipping",
|
|
93
|
+
stacklevel=2,
|
|
94
|
+
)
|
|
95
|
+
continue
|
|
96
|
+
if np.issubdtype(arr.dtype, np.floating):
|
|
97
|
+
# Float flag channels (e.g. interpolation-smeared hotel flags) lose
|
|
98
|
+
# sub-1 flags under a raw uint8 truncation and over-flag on
|
|
99
|
+
# inf/large values; treat any |sample| >= 0.5 as set.
|
|
100
|
+
arr_u = (np.abs(arr) >= 0.5).astype(np.uint8)
|
|
101
|
+
else:
|
|
102
|
+
# Integer/bool bitfields: preserve the bits for the OR-reduce.
|
|
103
|
+
arr_u = arr.astype(np.uint8, copy=False)
|
|
104
|
+
for i in range(n_seg):
|
|
105
|
+
a, b = lo[i], hi[i]
|
|
106
|
+
if a >= b:
|
|
107
|
+
continue
|
|
108
|
+
qc[i] |= np.bitwise_or.reduce(arr_u[a:b])
|
|
109
|
+
|
|
110
|
+
# Lift CF flag attrs through if the source channel carries them.
|
|
111
|
+
info = pf.channel_info.get(name, {}) if hasattr(pf, "channel_info") else {}
|
|
112
|
+
meanings = info.get("flag_meanings")
|
|
113
|
+
masks = info.get("flag_masks")
|
|
114
|
+
if meanings and masks is not None:
|
|
115
|
+
mlist = (
|
|
116
|
+
meanings.split() if isinstance(meanings, str) else list(meanings)
|
|
117
|
+
)
|
|
118
|
+
mklist: list[int] = (
|
|
119
|
+
np.asarray(masks).ravel().astype(np.int64).tolist()
|
|
120
|
+
if not np.isscalar(masks)
|
|
121
|
+
else [int(masks)] # type: ignore[arg-type]
|
|
122
|
+
)
|
|
123
|
+
for word, mask in zip(mlist, mklist):
|
|
124
|
+
if word not in seen_meanings:
|
|
125
|
+
seen_meanings.add(word)
|
|
126
|
+
flag_meanings.append(word)
|
|
127
|
+
flag_masks.append(int(mask))
|
|
128
|
+
|
|
129
|
+
attrs: dict[str, Any] = {}
|
|
130
|
+
if flag_meanings:
|
|
131
|
+
attrs["flag_meanings"] = " ".join(flag_meanings)
|
|
132
|
+
attrs["flag_masks"] = np.array(flag_masks, dtype=np.uint8)
|
|
133
|
+
return qc, attrs
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def apply_qc_to_dataset(
|
|
137
|
+
ds,
|
|
138
|
+
pf: Any,
|
|
139
|
+
drop_from: Sequence[str],
|
|
140
|
+
diss_length_seconds: float,
|
|
141
|
+
flag_var_name: str,
|
|
142
|
+
value_vars: Sequence[str],
|
|
143
|
+
drop_action: str = "nan",
|
|
144
|
+
) -> Any:
|
|
145
|
+
"""Add ``flag_var_name`` to *ds* and (optionally) NaN the value vars.
|
|
146
|
+
|
|
147
|
+
Returns the (mutated) dataset. The dataset must expose segment
|
|
148
|
+
center times in seconds since ``pf.start_time`` either as a ``t``
|
|
149
|
+
variable along the ``time`` dim (the diss / chi schema), or, as a
|
|
150
|
+
fallback, as the ``time`` coord values themselves.
|
|
151
|
+
"""
|
|
152
|
+
import xarray as xr
|
|
153
|
+
|
|
154
|
+
if "t" in ds.variables:
|
|
155
|
+
seg_times = np.asarray(ds["t"].values, dtype=np.float64)
|
|
156
|
+
else:
|
|
157
|
+
seg_times = np.asarray(ds["time"].values, dtype=np.float64)
|
|
158
|
+
qc, attrs = compute_segment_drop(
|
|
159
|
+
seg_times, diss_length_seconds, pf, drop_from
|
|
160
|
+
)
|
|
161
|
+
ds[flag_var_name] = xr.DataArray(qc, dims=["time"], attrs=attrs)
|
|
162
|
+
if drop_action == "nan":
|
|
163
|
+
mask = qc > 0
|
|
164
|
+
if np.any(mask):
|
|
165
|
+
for v in value_vars:
|
|
166
|
+
if v not in ds.data_vars:
|
|
167
|
+
continue
|
|
168
|
+
# Float-only fields get NaN'd; integer fields are left as is.
|
|
169
|
+
if np.issubdtype(ds[v].dtype, np.floating):
|
|
170
|
+
arr = ds[v].values.copy()
|
|
171
|
+
if arr.ndim == 1:
|
|
172
|
+
arr[mask] = np.nan
|
|
173
|
+
else:
|
|
174
|
+
# 2-D (probe, time) — broadcast the mask.
|
|
175
|
+
arr[..., mask] = np.nan
|
|
176
|
+
ds[v].values[...] = arr
|
|
177
|
+
return ds
|