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