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