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,373 @@
|
|
|
1
|
+
# May-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Declarative internal QC rules.
|
|
3
|
+
|
|
4
|
+
Evaluates simple range checks against the merged ``pf.channels`` and
|
|
5
|
+
emits synthetic uint8 channels that drop into the same per-segment
|
|
6
|
+
``compute_segment_drop`` machinery as hotel-injected QC channels.
|
|
7
|
+
|
|
8
|
+
YAML schema (under the perturb config's ``qc:`` block):
|
|
9
|
+
|
|
10
|
+
qc:
|
|
11
|
+
rules:
|
|
12
|
+
speed_oor: # rule name = synthetic channel name
|
|
13
|
+
channel: speed_fast
|
|
14
|
+
min: 0.05 # any of {min, max, abs_max} can be set
|
|
15
|
+
max: 1.5
|
|
16
|
+
bit: 8 # bit set in the uint8 output
|
|
17
|
+
pitch_oor:
|
|
18
|
+
channel: pitch # pseudo-name → auto-pick from Incl_X/Y
|
|
19
|
+
abs_max: 45 # |angle| > 45 deg → flag
|
|
20
|
+
bit: 16
|
|
21
|
+
roll_oor:
|
|
22
|
+
channel: roll # pseudo-name → "the other" inclinometer
|
|
23
|
+
abs_max: 10
|
|
24
|
+
bit: 32
|
|
25
|
+
|
|
26
|
+
Behavior
|
|
27
|
+
--------
|
|
28
|
+
- A rule's ``channel`` is read from ``pf.channels``. Pseudo-names
|
|
29
|
+
``"pitch"`` and ``"roll"`` auto-pick from ``Incl_X`` / ``Incl_Y`` by
|
|
30
|
+
amplitude — same heuristic as the flight-model speed; pitch is the
|
|
31
|
+
axis with the larger swing, roll is the other.
|
|
32
|
+
- Missing channels are warned-and-skipped rather than fatal: one config
|
|
33
|
+
can target multiple instrument types where some channels don't exist.
|
|
34
|
+
- Fast-rate channels are max-pooled to slow rate so the qc_gate can
|
|
35
|
+
uniformly consume bool-on-pf.t_slow arrays.
|
|
36
|
+
- Each rule writes a ``uint8`` channel with the named bit set wherever
|
|
37
|
+
the condition is met, plus CF ``flag_meanings`` / ``flag_masks`` so
|
|
38
|
+
``compute_segment_drop`` can lift them into the per-segment bitfield.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import logging
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
import numpy as np
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
# Maximum bit value we accept (uint8 fits 8 distinct flags).
|
|
51
|
+
_MAX_BIT = 1 << 7
|
|
52
|
+
|
|
53
|
+
# Range rule keys (the original / default rule type).
|
|
54
|
+
_RANGE_OPTION_KEYS = frozenset({
|
|
55
|
+
"type", "channel", "min", "max", "abs_max", "bit", "amplitude_quantile",
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
# pitch_w_consistency rule keys.
|
|
59
|
+
_FLIGHT_CONSISTENCY_OPTION_KEYS = frozenset({
|
|
60
|
+
"type", "bit", "amplitude_quantile",
|
|
61
|
+
"pitch_min_deg", "W_min_dbar_per_s", "pitch_positive",
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
# Default low / high percentiles for inclinometer-axis amplitude detection.
|
|
65
|
+
# 1..99 strips the outlier tails (e.g. surface tumbles or brief sensor
|
|
66
|
+
# saturation spikes that can dominate ``nanmax - nanmin``) while still
|
|
67
|
+
# capturing the bulk of the in-flight swing.
|
|
68
|
+
_DEFAULT_AMPLITUDE_Q: tuple[float, float] = (1.0, 99.0)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resolve_inclinometer_axis(
|
|
72
|
+
pf: Any,
|
|
73
|
+
want: str,
|
|
74
|
+
amplitude_q: tuple[float, float] = _DEFAULT_AMPLITUDE_Q,
|
|
75
|
+
) -> str | None:
|
|
76
|
+
"""Return ``Incl_X`` or ``Incl_Y`` (whichever is pitch / roll on this
|
|
77
|
+
instrument) by percentile-spread amplitude. ``None`` when neither
|
|
78
|
+
inclinometer is present.
|
|
79
|
+
|
|
80
|
+
The spread is the high minus the low percentile of each axis
|
|
81
|
+
(default 99-1). Percentiles keep brief outliers — e.g. a surface
|
|
82
|
+
tumble or sensor saturation that briefly drives one axis to
|
|
83
|
+
-90 deg — from masquerading as the high-amplitude flight axis.
|
|
84
|
+
Pitch is then the axis with the larger spread (the glider rotates
|
|
85
|
+
around its body axis during dive / climb cycles); the other axis
|
|
86
|
+
is roll.
|
|
87
|
+
|
|
88
|
+
Same heuristic is shared with :mod:`odas_tpw.perturb.speed`'s
|
|
89
|
+
flight model so a single deployment's QC and speed estimate agree
|
|
90
|
+
on which axis is which.
|
|
91
|
+
"""
|
|
92
|
+
iX = pf.channels.get("Incl_X")
|
|
93
|
+
iY = pf.channels.get("Incl_Y")
|
|
94
|
+
if iX is None or iY is None:
|
|
95
|
+
return None
|
|
96
|
+
lo_q, hi_q = float(amplitude_q[0]), float(amplitude_q[1])
|
|
97
|
+
rx = float(np.nanpercentile(iX, hi_q) - np.nanpercentile(iX, lo_q))
|
|
98
|
+
ry = float(np.nanpercentile(iY, hi_q) - np.nanpercentile(iY, lo_q))
|
|
99
|
+
pitch_name, roll_name = ("Incl_X", "Incl_Y") if rx >= ry else ("Incl_Y", "Incl_X")
|
|
100
|
+
return pitch_name if want == "pitch" else roll_name
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _coarsen_to_slow(flag: np.ndarray, ratio: int) -> np.ndarray:
|
|
104
|
+
"""Max-pool a fast-rate boolean to slow rate. ``ratio = fs_fast/fs_slow``."""
|
|
105
|
+
n_slow = flag.size // ratio
|
|
106
|
+
if n_slow == 0:
|
|
107
|
+
return np.zeros(0, dtype=bool)
|
|
108
|
+
return np.asarray(flag[: n_slow * ratio].reshape(n_slow, ratio).any(axis=1))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _validate_bit(name: str, bit_val: int) -> None:
|
|
112
|
+
if bit_val < 1 or bit_val > _MAX_BIT or bit_val & (bit_val - 1):
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"qc.rules.{name}: 'bit' must be a power of 2 in 1..{_MAX_BIT}, "
|
|
115
|
+
f"got {bit_val}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _validate_amplitude_q(name: str, aq) -> tuple[float, float]:
|
|
120
|
+
if (not hasattr(aq, "__len__") or len(aq) != 2
|
|
121
|
+
or not (0.0 <= float(aq[0]) < float(aq[1]) <= 100.0)):
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"qc.rules.{name}.amplitude_quantile: must be a pair "
|
|
124
|
+
f"[lo, hi] with 0 <= lo < hi <= 100, got {aq!r}"
|
|
125
|
+
)
|
|
126
|
+
return (float(aq[0]), float(aq[1]))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _evaluate_range(pf: Any, name: str, cfg: dict, n_slow: int,
|
|
130
|
+
ratio: int) -> np.ndarray | None:
|
|
131
|
+
"""Per-channel min/max/abs_max range check. Original / default rule type."""
|
|
132
|
+
ch_name = cfg.get("channel")
|
|
133
|
+
if not ch_name:
|
|
134
|
+
raise ValueError(f"qc.rules.{name}: 'channel' is required")
|
|
135
|
+
|
|
136
|
+
if ch_name in ("pitch", "roll"):
|
|
137
|
+
aq = cfg.get("amplitude_quantile") or _DEFAULT_AMPLITUDE_Q
|
|
138
|
+
aq = _validate_amplitude_q(name, aq)
|
|
139
|
+
resolved = _resolve_inclinometer_axis(pf, ch_name, aq)
|
|
140
|
+
if resolved is None:
|
|
141
|
+
logger.warning(
|
|
142
|
+
"qc.rules.%s: %r requires Incl_X and Incl_Y; skipping",
|
|
143
|
+
name, ch_name,
|
|
144
|
+
)
|
|
145
|
+
return None
|
|
146
|
+
actual = resolved
|
|
147
|
+
else:
|
|
148
|
+
actual = ch_name
|
|
149
|
+
|
|
150
|
+
if actual not in pf.channels:
|
|
151
|
+
logger.warning(
|
|
152
|
+
"qc.rules.%s: channel %r not on pf.channels; skipping",
|
|
153
|
+
name, actual,
|
|
154
|
+
)
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
arr = np.asarray(pf.channels[actual], dtype=np.float64)
|
|
158
|
+
flag = np.zeros(arr.shape, dtype=bool)
|
|
159
|
+
if "min" in cfg:
|
|
160
|
+
flag |= arr < float(cfg["min"])
|
|
161
|
+
if "max" in cfg:
|
|
162
|
+
flag |= arr > float(cfg["max"])
|
|
163
|
+
if "abs_max" in cfg:
|
|
164
|
+
flag |= np.abs(arr) > float(cfg["abs_max"])
|
|
165
|
+
# NaN samples are flagged too — they're as bad as out-of-range for QC.
|
|
166
|
+
flag |= ~np.isfinite(arr)
|
|
167
|
+
|
|
168
|
+
# Coarsen fast-rate channels to slow so qc_gate consumes uniformly.
|
|
169
|
+
if actual in pf._fast_channels and arr.size != n_slow:
|
|
170
|
+
flag = _coarsen_to_slow(flag, ratio)
|
|
171
|
+
return np.asarray(flag, dtype=bool)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _evaluate_pitch_w_consistency(pf: Any, name: str, cfg: dict,
|
|
175
|
+
n_slow: int) -> np.ndarray | None:
|
|
176
|
+
"""Flag samples where pitch direction and dP/dt sign disagree.
|
|
177
|
+
|
|
178
|
+
Healthy glide has nose-up + ascending or nose-down + descending; a
|
|
179
|
+
stalled glider can be pitched up while still sinking (or vice
|
|
180
|
+
versa) and shouldn't contribute turbulence estimates.
|
|
181
|
+
|
|
182
|
+
The check operates on:
|
|
183
|
+
|
|
184
|
+
- ``pitch``: auto-resolved Incl_Y / Incl_X. The inclinometer sign
|
|
185
|
+
convention DOES matter here: the test assumes positive pitch =
|
|
186
|
+
nose-down unless ``pitch_positive`` says otherwise.
|
|
187
|
+
- ``W``: signed dP/dt at slow rate (positive = depth increasing =
|
|
188
|
+
sinking), computed from ``pf.channels["P"]`` with the same
|
|
189
|
+
smoothing as :func:`smooth_fall_rate` in the speed module.
|
|
190
|
+
|
|
191
|
+
Within ±``pitch_min_deg`` of level or ±``W_min_dbar_per_s`` of
|
|
192
|
+
stationary, samples are *not* flagged — too close to a sign-zero
|
|
193
|
+
crossing to call confidently.
|
|
194
|
+
|
|
195
|
+
``pitch_positive`` resolves the mounting polarity:
|
|
196
|
+
|
|
197
|
+
- ``"auto"`` (default): infer from the deployment-wide majority sign
|
|
198
|
+
of pitch*W — steady flight dominates a healthy record, so the
|
|
199
|
+
majority sign identifies the consistent pairing. The choice is
|
|
200
|
+
logged.
|
|
201
|
+
- ``"nose_down"``: positive pitch = nose-down (sign agreement with W
|
|
202
|
+
is healthy).
|
|
203
|
+
- ``"nose_up"``: positive pitch = nose-up (sign disagreement with W
|
|
204
|
+
is healthy) — e.g. an inclinometer mounted with opposite polarity.
|
|
205
|
+
|
|
206
|
+
YAML schema::
|
|
207
|
+
|
|
208
|
+
flight_consistency:
|
|
209
|
+
type: pitch_w_consistency
|
|
210
|
+
pitch_min_deg: 5.0
|
|
211
|
+
W_min_dbar_per_s: 0.02
|
|
212
|
+
pitch_positive: auto
|
|
213
|
+
bit: 64
|
|
214
|
+
"""
|
|
215
|
+
from odas_tpw.scor160.profile import smooth_fall_rate
|
|
216
|
+
|
|
217
|
+
aq = cfg.get("amplitude_quantile") or _DEFAULT_AMPLITUDE_Q
|
|
218
|
+
aq = _validate_amplitude_q(name, aq)
|
|
219
|
+
pitch_name = _resolve_inclinometer_axis(pf, "pitch", aq)
|
|
220
|
+
if pitch_name is None:
|
|
221
|
+
logger.warning(
|
|
222
|
+
"qc.rules.%s: pitch_w_consistency requires Incl_X and Incl_Y; skipping",
|
|
223
|
+
name,
|
|
224
|
+
)
|
|
225
|
+
return None
|
|
226
|
+
if "P" not in pf.channels:
|
|
227
|
+
logger.warning(
|
|
228
|
+
"qc.rules.%s: pitch_w_consistency requires P; skipping",
|
|
229
|
+
name,
|
|
230
|
+
)
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
pitch = np.asarray(pf.channels[pitch_name], dtype=np.float64)
|
|
234
|
+
P = np.asarray(pf.channels["P"], dtype=np.float64)
|
|
235
|
+
fs_slow = float(pf.fs_slow)
|
|
236
|
+
# smooth_fall_rate runs P through filtfilt, which has no NaN handling: a
|
|
237
|
+
# single non-finite pressure sample would smear NaN across the ENTIRE
|
|
238
|
+
# record's fall rate and flag the whole record (asymmetric with the
|
|
239
|
+
# per-sample range rule). Interpolate isolated gaps before filtering so W
|
|
240
|
+
# stays usable, and flag only the genuinely-bad samples locally below.
|
|
241
|
+
finite_P = np.isfinite(P)
|
|
242
|
+
if finite_P.any() and not finite_P.all():
|
|
243
|
+
idx = np.arange(P.size)
|
|
244
|
+
P_for_filter = P.copy()
|
|
245
|
+
P_for_filter[~finite_P] = np.interp(idx[~finite_P], idx[finite_P], P[finite_P])
|
|
246
|
+
else:
|
|
247
|
+
P_for_filter = P # all-finite (no-op) or all-NaN (W stays NaN -> flagged)
|
|
248
|
+
W = smooth_fall_rate(P_for_filter, fs_slow) # signed, dbar/s
|
|
249
|
+
|
|
250
|
+
pitch_min = float(cfg.get("pitch_min_deg", 5.0))
|
|
251
|
+
w_min = float(cfg.get("W_min_dbar_per_s", 0.02))
|
|
252
|
+
|
|
253
|
+
moving = (
|
|
254
|
+
(np.abs(pitch) >= pitch_min)
|
|
255
|
+
& (np.abs(W) >= w_min)
|
|
256
|
+
& np.isfinite(pitch)
|
|
257
|
+
& np.isfinite(W)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Resolve the inclinometer mounting polarity: +1 means positive
|
|
261
|
+
# pitch = nose-down (pitch and W share sign in healthy flight).
|
|
262
|
+
polarity = str(cfg.get("pitch_positive", "auto")).lower()
|
|
263
|
+
if polarity == "nose_up":
|
|
264
|
+
sign = -1.0
|
|
265
|
+
elif polarity == "nose_down":
|
|
266
|
+
sign = 1.0
|
|
267
|
+
else: # auto: majority sign of pitch*W — steady flight dominates
|
|
268
|
+
if np.any(moving):
|
|
269
|
+
med = float(np.median(np.sign(pitch[moving] * W[moving])))
|
|
270
|
+
sign = 1.0 if med >= 0 else -1.0
|
|
271
|
+
else:
|
|
272
|
+
sign = 1.0
|
|
273
|
+
logger.info(
|
|
274
|
+
"qc.rules.%s: auto-detected inclinometer polarity: positive pitch = %s",
|
|
275
|
+
name,
|
|
276
|
+
"nose-down" if sign > 0 else "nose-up",
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Signs disagree (after polarity correction) and both are clearly
|
|
280
|
+
# nonzero (outside the noise zone around level / stationary).
|
|
281
|
+
flag = (sign * pitch * W < 0) & moving
|
|
282
|
+
# Local non-finite flagging: a bad pitch or a bad raw pressure flags only
|
|
283
|
+
# its own sample (~finite_P, not the filtfilt-smeared ~isfinite(W)). The
|
|
284
|
+
# ~isfinite(W) term still catches the all-NaN-P case (W never recovers).
|
|
285
|
+
flag |= ~np.isfinite(pitch) | ~finite_P | ~np.isfinite(W)
|
|
286
|
+
return np.asarray(flag, dtype=bool)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# Dispatch table. New rule types register here.
|
|
290
|
+
_RULE_TYPES: dict[str, tuple[frozenset, Any]] = {
|
|
291
|
+
"range": (_RANGE_OPTION_KEYS, _evaluate_range),
|
|
292
|
+
"pitch_w_consistency": (
|
|
293
|
+
_FLIGHT_CONSISTENCY_OPTION_KEYS, _evaluate_pitch_w_consistency,
|
|
294
|
+
),
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def evaluate_rules(pf: Any, rules: dict[str, dict] | None) -> dict[str, np.ndarray]:
|
|
299
|
+
"""Evaluate the rules block against ``pf.channels``.
|
|
300
|
+
|
|
301
|
+
Returns a dict ``{rule_name: uint8_array}`` keyed by the YAML rule
|
|
302
|
+
name. Each array sits on ``pf.t_slow`` with the rule's ``bit`` set
|
|
303
|
+
wherever its condition is met.
|
|
304
|
+
|
|
305
|
+
Rule type is selected via ``type:`` (default ``range`` for backwards
|
|
306
|
+
compat). See :data:`_RULE_TYPES` for registered evaluators.
|
|
307
|
+
|
|
308
|
+
Rules referencing a missing channel are warned-and-skipped (the
|
|
309
|
+
output dict simply omits them — downstream ``drop_from`` lookups
|
|
310
|
+
treat absent channels the same as channels containing only zeros).
|
|
311
|
+
"""
|
|
312
|
+
if not rules:
|
|
313
|
+
return {}
|
|
314
|
+
|
|
315
|
+
out: dict[str, np.ndarray] = {}
|
|
316
|
+
ratio = max(1, round(float(pf.fs_fast) / float(pf.fs_slow)))
|
|
317
|
+
n_slow = len(pf.t_slow)
|
|
318
|
+
|
|
319
|
+
for name, raw in rules.items():
|
|
320
|
+
cfg = dict(raw or {})
|
|
321
|
+
rtype = str(cfg.get("type", "range"))
|
|
322
|
+
if rtype not in _RULE_TYPES:
|
|
323
|
+
raise ValueError(
|
|
324
|
+
f"qc.rules.{name}: unknown type {rtype!r}. "
|
|
325
|
+
f"Valid: {sorted(_RULE_TYPES)}"
|
|
326
|
+
)
|
|
327
|
+
valid_keys, evaluator = _RULE_TYPES[rtype]
|
|
328
|
+
unknown = set(cfg) - valid_keys
|
|
329
|
+
if unknown:
|
|
330
|
+
raise ValueError(
|
|
331
|
+
f"qc.rules.{name} (type={rtype}): unknown options {sorted(unknown)}. "
|
|
332
|
+
f"Valid: {sorted(valid_keys)}"
|
|
333
|
+
)
|
|
334
|
+
bit_val = int(cfg.get("bit", 1))
|
|
335
|
+
_validate_bit(name, bit_val)
|
|
336
|
+
|
|
337
|
+
if rtype == "range":
|
|
338
|
+
flag = evaluator(pf, name, cfg, n_slow, ratio)
|
|
339
|
+
else:
|
|
340
|
+
flag = evaluator(pf, name, cfg, n_slow)
|
|
341
|
+
if flag is None:
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
if flag.size != n_slow:
|
|
345
|
+
# Pad / truncate to slow length — keeps qc_gate slicing valid.
|
|
346
|
+
if flag.size < n_slow:
|
|
347
|
+
pad = np.zeros(n_slow - flag.size, dtype=bool)
|
|
348
|
+
flag = np.concatenate((flag, pad))
|
|
349
|
+
else:
|
|
350
|
+
flag = flag[:n_slow]
|
|
351
|
+
out[name] = flag.astype(np.uint8) * np.uint8(bit_val)
|
|
352
|
+
|
|
353
|
+
return out
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def register_rule_channels(pf: Any, rule_arrays: dict[str, np.ndarray],
|
|
357
|
+
rules: dict[str, dict]) -> None:
|
|
358
|
+
"""Register evaluated rule arrays on ``pf`` so the qc_gate sees them.
|
|
359
|
+
|
|
360
|
+
Adds each rule's array to ``pf.channels`` and writes a
|
|
361
|
+
``channel_info`` entry with CF ``flag_meanings`` / ``flag_masks``
|
|
362
|
+
that ``compute_segment_drop`` lifts into the per-segment bitfield.
|
|
363
|
+
"""
|
|
364
|
+
for name, arr in rule_arrays.items():
|
|
365
|
+
bit_val = int(rules[name].get("bit", 1))
|
|
366
|
+
pf.channels[name] = arr
|
|
367
|
+
pf.channel_info[name] = {
|
|
368
|
+
"units": "",
|
|
369
|
+
"type": "qc_rule",
|
|
370
|
+
"name": name,
|
|
371
|
+
"flag_meanings": name,
|
|
372
|
+
"flag_masks": [bit_val],
|
|
373
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# Jun-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Resolve perturb output directories from a config (read-only).
|
|
3
|
+
|
|
4
|
+
Given a perturb config (the same YAML the pipeline ran), locate the
|
|
5
|
+
``{stage}_NN`` output directory whose stored ``.params_sha256_*`` signature
|
|
6
|
+
matches that config — so plotting can point at a config instead of guessing
|
|
7
|
+
the newest versioned directory (which silently mixes products from different
|
|
8
|
+
configs when several share an ``output_root``).
|
|
9
|
+
|
|
10
|
+
Matching compares the canonical signature JSON section-by-section but **ignores
|
|
11
|
+
``files.output_root``**: ``--output`` rewrites it at run time without
|
|
12
|
+
normalization, so a run's stored signature can hold an output path the config
|
|
13
|
+
YAML never contained, and recomputing the hash would never match — yet the
|
|
14
|
+
output location has no bearing on the data. Everything else in ``files`` is
|
|
15
|
+
kept, including ``p_file_root``/``p_file_pattern`` (they identify the input
|
|
16
|
+
*dataset*, so two runs of different inputs into one ``output_root`` stay
|
|
17
|
+
distinguishable) and ``trim``/``merge`` (they change the data). A run done with
|
|
18
|
+
``--p-file-root`` therefore won't match by signature and falls back to the
|
|
19
|
+
single-dir / drift path below.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import warnings
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from ruamel.yaml.error import YAMLError
|
|
30
|
+
|
|
31
|
+
from odas_tpw.config_base import iter_stage_dirs
|
|
32
|
+
from odas_tpw.perturb import config as _cfg
|
|
33
|
+
|
|
34
|
+
# Everything load_config can raise for a bad --config: missing/unreadable
|
|
35
|
+
# (OSError, incl. FileNotFoundError), unknown section/key (ValueError), or a
|
|
36
|
+
# YAML syntax error (ruamel YAMLError — NOT an OSError/ValueError subclass).
|
|
37
|
+
# A bad config is ALWAYS fatal; these are converted to a clean SystemExit
|
|
38
|
+
# wherever a config is loaded, so the user never sees a raw traceback.
|
|
39
|
+
_CONFIG_LOAD_ERRORS = (OSError, ValueError, YAMLError)
|
|
40
|
+
|
|
41
|
+
# files key rewritten by --output at run time and irrelevant to the data;
|
|
42
|
+
# excluded from the signature comparison. p_file_root/p_file_pattern are kept
|
|
43
|
+
# (they identify the input dataset).
|
|
44
|
+
_VOLATILE_FILES_KEYS = ("output_root",)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class StageConflict(RuntimeError):
|
|
48
|
+
"""The stage directory is ambiguous w.r.t. the config (multiple matches, or
|
|
49
|
+
no match with several candidates / under ``strict``).
|
|
50
|
+
|
|
51
|
+
Distinct from ``FileNotFoundError`` (the stage simply does not exist): a
|
|
52
|
+
conflict must surface even for an *optional* stage rather than silently
|
|
53
|
+
degrading, since it means the user's data is present but doesn't agree with
|
|
54
|
+
the config.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _strip_volatile(canon: dict) -> dict:
|
|
59
|
+
"""Drop signature components that must not affect *plot-time* matching.
|
|
60
|
+
|
|
61
|
+
Plotting resolves a dir by **config**, independent of the run environment
|
|
62
|
+
(``files.output_root``) and of the processing-code version (``_engine``):
|
|
63
|
+
you plot data regardless of which code wrote it. The pipeline keeps both in
|
|
64
|
+
the hash so its output dirs stay code-aware; the resolver strips them so a
|
|
65
|
+
code update doesn't stop it matching dirs an older engine produced.
|
|
66
|
+
"""
|
|
67
|
+
canon.pop("_engine", None)
|
|
68
|
+
files = canon.get("files")
|
|
69
|
+
if isinstance(files, dict):
|
|
70
|
+
for key in _VOLATILE_FILES_KEYS:
|
|
71
|
+
files.pop(key, None)
|
|
72
|
+
return canon
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _want_signature(config: dict, stage: str) -> dict:
|
|
76
|
+
"""The canonical signature *this* config would produce for *stage*."""
|
|
77
|
+
section, params, upstream = _cfg.stage_signature(stage, config)
|
|
78
|
+
return _strip_volatile(json.loads(_cfg.canonicalize(section, params, upstream)))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _read_signature(directory: Path) -> dict | None:
|
|
82
|
+
"""The stored canonical signature of *directory*, or None if unreadable."""
|
|
83
|
+
sigs = sorted(directory.glob(".params_sha256_*"))
|
|
84
|
+
if not sigs:
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
return _strip_volatile(json.loads(sigs[0].read_text()))
|
|
88
|
+
except (OSError, ValueError):
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _describe(directory: Path) -> str:
|
|
93
|
+
"""One-line note on a candidate dir's config, for error messages."""
|
|
94
|
+
sig = _read_signature(directory)
|
|
95
|
+
if sig is None:
|
|
96
|
+
return f"{directory.name} (no signature)"
|
|
97
|
+
bits = [
|
|
98
|
+
f"{sec}={json.dumps(sig[sec], sort_keys=True)}"
|
|
99
|
+
for sec in ("binning", "chi", "epsilon", "top_trim")
|
|
100
|
+
if sec in sig
|
|
101
|
+
]
|
|
102
|
+
return f"{directory.name}" + (": " + "; ".join(bits) if bits else "")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def stage_dir(
|
|
106
|
+
config: dict,
|
|
107
|
+
stage: str,
|
|
108
|
+
output_root: str | Path | None = None,
|
|
109
|
+
*,
|
|
110
|
+
strict: bool = False,
|
|
111
|
+
latest: bool = False,
|
|
112
|
+
) -> Path:
|
|
113
|
+
"""Return the ``{stage}_NN`` directory under *output_root* matching *config*.
|
|
114
|
+
|
|
115
|
+
*output_root* defaults to ``config["files"]["output_root"]``. Resolution:
|
|
116
|
+
|
|
117
|
+
- exact signature match → that directory;
|
|
118
|
+
- ``latest=True`` → the newest ``{stage}_NN`` regardless of config;
|
|
119
|
+
- no match, exactly one ``{stage}_NN`` exists → that one, with a warning
|
|
120
|
+
(the common "tweak config, re-plot" case — it cannot be a *different*
|
|
121
|
+
config's data, there is only one);
|
|
122
|
+
- no match, two or more exist → ``StageConflict`` listing each directory's
|
|
123
|
+
config (refuse to guess which is the caller's);
|
|
124
|
+
- multiple *exact* matches (a concurrent same-config run that raced for a
|
|
125
|
+
sequence number, or a hand-copied signature) → the newest, with a warning
|
|
126
|
+
(identical signature ⇒ identical config and inputs ⇒ equivalent data);
|
|
127
|
+
- ``strict=True`` → ``StageConflict`` on any non-exact match.
|
|
128
|
+
|
|
129
|
+
Raises ``ValueError`` for an unknown *stage*, ``FileNotFoundError`` when no
|
|
130
|
+
``{stage}_NN`` directory exists at all (stage disabled or never run), and
|
|
131
|
+
``StageConflict`` when the directory is ambiguous w.r.t. the config. Never
|
|
132
|
+
creates a directory.
|
|
133
|
+
"""
|
|
134
|
+
if stage not in _cfg.STAGES:
|
|
135
|
+
raise ValueError(f"unknown stage {stage!r}; known: {sorted(_cfg.STAGES)}")
|
|
136
|
+
raw_root = output_root or (config.get("files") or {}).get("output_root") or "."
|
|
137
|
+
root = Path(
|
|
138
|
+
_cfg.expand_config_dir(raw_root, _cfg.config_dir_of(config))
|
|
139
|
+
).expanduser()
|
|
140
|
+
candidates = iter_stage_dirs(root, stage)
|
|
141
|
+
if not candidates:
|
|
142
|
+
raise FileNotFoundError(
|
|
143
|
+
f"no {stage}_NN directory under {root} — the {stage} stage was never "
|
|
144
|
+
f"run here (or is disabled in the config)."
|
|
145
|
+
)
|
|
146
|
+
if latest:
|
|
147
|
+
return candidates[-1][1]
|
|
148
|
+
|
|
149
|
+
want = _want_signature(config, stage)
|
|
150
|
+
matches = [d for _, d in candidates if _read_signature(d) == want]
|
|
151
|
+
if len(matches) == 1:
|
|
152
|
+
return matches[0]
|
|
153
|
+
if len(matches) > 1:
|
|
154
|
+
# Multiple dirs carry the *same* canonical signature. The expected
|
|
155
|
+
# cause is two identical-config `perturb run`s racing to claim a
|
|
156
|
+
# sequence number under one output_root (one creates {stage}_00, the
|
|
157
|
+
# other loses the mkdir, advances, and writes the same signature into
|
|
158
|
+
# {stage}_01); output_root is stripped before comparison and nothing
|
|
159
|
+
# else differs, so those two were produced from the same config and
|
|
160
|
+
# inputs and their data is equivalent — picking the newest is safe and
|
|
161
|
+
# avoids needlessly locking the user out. A signature file is copied
|
|
162
|
+
# *text*, not a checksum of the dir's data, so a hand-copied signature
|
|
163
|
+
# into a dir with different data would also land here and is NOT
|
|
164
|
+
# guaranteed equivalent — hence we warn (listing the dirs) rather than
|
|
165
|
+
# resolve silently.
|
|
166
|
+
listing = ", ".join(d.name for d in matches)
|
|
167
|
+
warnings.warn(
|
|
168
|
+
f"{stage}: {len(matches)} directories under {root} share the given "
|
|
169
|
+
f"config's signature ({listing}) — same config and inputs, so using "
|
|
170
|
+
f"the newest ({matches[-1].name}).",
|
|
171
|
+
stacklevel=2,
|
|
172
|
+
)
|
|
173
|
+
return matches[-1]
|
|
174
|
+
|
|
175
|
+
if not strict and len(candidates) == 1:
|
|
176
|
+
only = candidates[0][1]
|
|
177
|
+
warnings.warn(
|
|
178
|
+
f"{stage}: config does not match {only.name}'s signature (config "
|
|
179
|
+
f"drift) but it is the only {stage} directory under {root} — using "
|
|
180
|
+
f"it. Pass strict=True / --strict to require an exact match.",
|
|
181
|
+
stacklevel=2,
|
|
182
|
+
)
|
|
183
|
+
return only
|
|
184
|
+
|
|
185
|
+
listing = "\n ".join(_describe(d) for _, d in candidates)
|
|
186
|
+
reason = "config drift" if strict else "and none matches the given config"
|
|
187
|
+
raise StageConflict(
|
|
188
|
+
f"{len(candidates)} {stage}_NN directories under {root} {reason} — "
|
|
189
|
+
f"refusing to guess which is yours. Candidates:\n {listing}\n"
|
|
190
|
+
f"Edit the config to match, re-run the pipeline, or force the newest "
|
|
191
|
+
f"with --latest."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
# CLI glue for the existing plot subcommands
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def add_resolve_args(p: argparse.ArgumentParser) -> None:
|
|
200
|
+
"""Register ``--config``/``--latest``/``--strict`` on a plot subparser.
|
|
201
|
+
|
|
202
|
+
``--root`` is registered separately (and is now optional); at least one of
|
|
203
|
+
``--config``/``--root`` must be given (``--root`` may accompany ``--config``
|
|
204
|
+
to override the search location).
|
|
205
|
+
"""
|
|
206
|
+
p.add_argument(
|
|
207
|
+
"--config", metavar="PERTURB.YAML",
|
|
208
|
+
help="perturb config; output directories are resolved from its hash "
|
|
209
|
+
"signatures instead of guessing the newest {stage}_NN.",
|
|
210
|
+
)
|
|
211
|
+
p.add_argument(
|
|
212
|
+
"--latest", action="store_true",
|
|
213
|
+
help="with --config, use the newest {stage}_NN even if no signature matches.",
|
|
214
|
+
)
|
|
215
|
+
p.add_argument(
|
|
216
|
+
"--strict", action="store_true",
|
|
217
|
+
help="with --config, error (don't fall back) when no signature matches.",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def require_root(args: argparse.Namespace) -> str:
|
|
222
|
+
"""The output root for a plot run: ``--root`` if given, else the
|
|
223
|
+
``--config``'s ``files.output_root``.
|
|
224
|
+
|
|
225
|
+
Call once at the top of a subcommand's ``run`` to backfill ``args.root``
|
|
226
|
+
(used for titles, default output paths, and per-profile attr lookups) when
|
|
227
|
+
only ``--config`` was given. Raises ``SystemExit`` if neither is provided.
|
|
228
|
+
"""
|
|
229
|
+
root = getattr(args, "root", None)
|
|
230
|
+
if root:
|
|
231
|
+
return str(root)
|
|
232
|
+
cfg_path = getattr(args, "config", None)
|
|
233
|
+
if cfg_path:
|
|
234
|
+
# require_root runs FIRST in every subcommand's run(), so a bad config
|
|
235
|
+
# must be cleanly fatal here too (not just in resolve_for_args) — else
|
|
236
|
+
# the user gets a raw traceback before resolve_for_args is ever reached.
|
|
237
|
+
try:
|
|
238
|
+
cfg = _cfg.load_config(cfg_path)
|
|
239
|
+
except _CONFIG_LOAD_ERRORS as exc:
|
|
240
|
+
raise SystemExit(str(exc)) from exc
|
|
241
|
+
out = (cfg.get("files") or {}).get("output_root")
|
|
242
|
+
if out:
|
|
243
|
+
return str(_cfg.expand_config_dir(out, _cfg.config_dir_of(cfg)))
|
|
244
|
+
raise SystemExit("one of --config or --root is required")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def resolve_for_args(
|
|
248
|
+
args: argparse.Namespace,
|
|
249
|
+
stage: str,
|
|
250
|
+
*,
|
|
251
|
+
optional: bool = False,
|
|
252
|
+
conflict_ok: bool = False,
|
|
253
|
+
) -> str | None:
|
|
254
|
+
"""Resolve a stage directory from parsed plot args.
|
|
255
|
+
|
|
256
|
+
With ``--config``, resolve by signature; otherwise fall back to the legacy
|
|
257
|
+
newest-``{stage}_NN`` discovery. Returns ``None`` when an *optional* stage
|
|
258
|
+
is absent (so callers can degrade gracefully, e.g. eps-chi without a
|
|
259
|
+
per-profile ``chi`` dir, profiles' diagnostics overlay) — matching the
|
|
260
|
+
legacy ``latest_stage_dir`` behavior. A required stage that cannot be
|
|
261
|
+
resolved raises a clean ``SystemExit``.
|
|
262
|
+
|
|
263
|
+
*optional* makes a *missing* stage (no ``{stage}_NN``) degrade to None; by
|
|
264
|
+
default a config *conflict* (ambiguous/drift) still raises, even for an
|
|
265
|
+
optional stage, so the user's data is never silently dropped. *conflict_ok*
|
|
266
|
+
additionally degrades a conflict to None — for **cosmetic** lookups (e.g.
|
|
267
|
+
eps-chi's per-profile ``diss`` dir, used only for the title's processing
|
|
268
|
+
params) that must never fail the whole figure over which dir to caption it.
|
|
269
|
+
"""
|
|
270
|
+
cfg_path = getattr(args, "config", None)
|
|
271
|
+
root = getattr(args, "root", None)
|
|
272
|
+
if cfg_path:
|
|
273
|
+
# Loading the config is separate from finding the stage dir: a
|
|
274
|
+
# missing/unreadable/malformed --config (e.g. a typo, or invalid YAML)
|
|
275
|
+
# is ALWAYS fatal and must never be mistaken for "this optional stage
|
|
276
|
+
# was not run". Only a FileNotFoundError from stage_dir (no {stage}_NN
|
|
277
|
+
# dir) degrades to None.
|
|
278
|
+
try:
|
|
279
|
+
config = _cfg.load_config(cfg_path)
|
|
280
|
+
except _CONFIG_LOAD_ERRORS as exc:
|
|
281
|
+
raise SystemExit(str(exc)) from exc
|
|
282
|
+
try:
|
|
283
|
+
return str(
|
|
284
|
+
stage_dir(
|
|
285
|
+
config, stage, output_root=root,
|
|
286
|
+
strict=getattr(args, "strict", False),
|
|
287
|
+
latest=getattr(args, "latest", False),
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
except FileNotFoundError as exc:
|
|
291
|
+
if optional: # stage simply absent -> degrade gracefully
|
|
292
|
+
return None
|
|
293
|
+
raise SystemExit(str(exc)) from exc
|
|
294
|
+
except StageConflict as exc:
|
|
295
|
+
if conflict_ok: # cosmetic lookup: ambiguity is non-fatal
|
|
296
|
+
return None
|
|
297
|
+
# For a data stage a conflict must surface even when optional,
|
|
298
|
+
# rather than silently dropping the user's data.
|
|
299
|
+
raise SystemExit(str(exc)) from exc
|
|
300
|
+
except (ValueError, OSError) as exc:
|
|
301
|
+
raise SystemExit(str(exc)) from exc
|
|
302
|
+
if not root:
|
|
303
|
+
raise SystemExit("one of --config or --root is required")
|
|
304
|
+
from odas_tpw.perturb.plot import layout
|
|
305
|
+
|
|
306
|
+
return layout.latest_stage_dir(root, stage)
|