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
odas_tpw/rsi/bench.py ADDED
@@ -0,0 +1,904 @@
1
+ # Jul-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """Bench-test diagnostic for Rockland Scientific instruments.
3
+
4
+ Python port of ODAS ``quick_bench.m``, extended to automatically evaluate the
5
+ **Rockland Bench Test Review Checklist** (V3).
6
+
7
+ A *bench test* is a pre-deployment health check: the instrument is rested on
8
+ soft foam with dummy probes installed and a short (>=60 s) file is recorded.
9
+ The raw-count time series and counts^2/Hz spectra reveal corroded connections,
10
+ dead channels and excessive electronic noise before the instrument goes in the
11
+ water. See ``quick_look`` / ``diss_look`` for the equivalent check on real
12
+ ocean profiles.
13
+
14
+ Two figures are produced, faithful to ``quick_bench.m``:
15
+
16
+ * **Time series** — every channel in *raw counts* (inclinometers converted to
17
+ physical units), with the fast thermistor gradient channels mean-subtracted
18
+ and their offset shown in the legend.
19
+ * **Spectra** — a log-log overlay of the auto-spectra in *counts^2/Hz*, to be
20
+ compared against the noise floor in the instrument's RSI calibration report.
21
+
22
+ An optional third **CT/CLTU** figure is drawn when JAC-T/C, turbidity or
23
+ chlorophyll channels are present.
24
+
25
+ Beyond ``quick_bench`` (which only plots), this module evaluates the numeric
26
+ criteria on the Rockland checklist and reports each as PASS / FAIL, marking the
27
+ genuinely subjective items ("similar to each other", "rising curve", "no
28
+ spikes") as REVIEW for a human.
29
+
30
+ Why raw counts require ``PFile(path, deconvolve=False)``: the checklist
31
+ thresholds for the pre-emphasized channels (``T1_dT1``, ``T2_dT2``, ``P_dP``)
32
+ are defined on the raw pre-emphasized signal (that is why their spectra
33
+ *rise*), not on the deconvolved high-resolution reconstruction that
34
+ ``PFile`` produces by default. ``deconvolve=False`` keeps those channels as the
35
+ raw int16 counts the checklist — and the RSI calibration report — refer to.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import logging
41
+ import re
42
+ from dataclasses import dataclass, field
43
+ from pathlib import Path
44
+
45
+ import matplotlib.pyplot as plt
46
+ import numpy as np
47
+ from matplotlib.axes import Axes
48
+ from matplotlib.figure import Figure
49
+
50
+ from odas_tpw.rsi.p_file import PFile
51
+ from odas_tpw.scor160.spectral import csd_odas
52
+
53
+ log = logging.getLogger(__name__)
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Checklist thresholds (Rockland Bench Test Review Checklist V3)
57
+ # ---------------------------------------------------------------------------
58
+ # Time series, raw counts:
59
+ _AXY_RANGE = 500.0 # |Ax|, |Ay| deviation from mean
60
+ _TDT_RANGE = 40.0 # |T*_dT*| deviation from mean
61
+ _TDT_OFFSET = 100.0 # |mean| of T*_dT* (legend offset)
62
+ _SH_MEAN = 10.0 # |mean| of sh*
63
+ _SH_RANGE = 30.0 # sh* deviation from mean
64
+ _P_RANGE = 2.0 # P deviation from mean
65
+ _PDP_RANGE = 10.0 # P_dP deviation from mean
66
+ _C1_RANGE = 50.0 # C1_dC1 deviation from mean (if present)
67
+ _C1_OFFSET = 6000.0 # |mean| of C1_dC1 (if present)
68
+ # Spectra, counts^2/Hz:
69
+ _PDP_PSD_MAX = 10.0 # P_dP spectral density everywhere below this
70
+ _PDP_PSD_PEAK = 3.0 # P_dP spectral peak below this
71
+ _AXY_PSD_MAX = 100.0 # Ax, Ay spectral peaks below this
72
+ # CT/CLTU, raw counts (page 3):
73
+ _JAC_T_RANGE = 50.0
74
+ _TURB_RANGE = 50.0
75
+ _CHLA_RANGE = 400.0
76
+ # JAC_C's I (<= +/-5) and V (~1e4, <= +/-100) sub-channels are drawn in the
77
+ # CT/CLTU figure for visual review rather than range-checked here, because the
78
+ # conductivity board packs both into one 32-bit word (see _ctclu_panels).
79
+
80
+ # Expected spectral-density band value near 100 Hz (order of magnitude; the
81
+ # checklist wording is "rising curve of approximately 10^N counts^2/Hz near
82
+ # 10^2 Hz"). Reported alongside a REVIEW verdict, not auto-passed.
83
+ _PROBE_FREQ = 100.0 # Hz
84
+
85
+ # The checklist criteria read "typically within +/-N counts". That is a robust
86
+ # envelope of the bulk signal, NOT the single worst sample: a lone DAQ glitch or
87
+ # a bench bump (accelerometers register any nearby movement) must not fail an
88
+ # otherwise-clean channel. We take the 99.9th percentile of |x - mean| as the
89
+ # "typical" deviation. The true maximum is kept separately for the spike hint.
90
+ _TYP_PCT = 99.9
91
+
92
+ # Status vocabulary
93
+ PASS = "PASS"
94
+ FAIL = "FAIL"
95
+ REVIEW = "REVIEW"
96
+ NA = "N/A"
97
+
98
+ # ODAS quick_bench uses a 2 s FFT for the bench spectra.
99
+ DEFAULT_FFT_SEC = 2.0
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Per-channel statistics
104
+ # ---------------------------------------------------------------------------
105
+
106
+
107
+ @dataclass
108
+ class ChannelStat:
109
+ """Time-series and (optionally) spectral statistics for one channel."""
110
+
111
+ name: str
112
+ ctype: str
113
+ is_fast: bool
114
+ unit: str
115
+ n: int
116
+ mean: float
117
+ std: float
118
+ typ_dev: float # 99.9th-percentile |x - mean| ("typically within +/-N")
119
+ max_abs_dev: float # true max|x - mean| (spike hint only)
120
+ # Spectrum (present only for the channels we transform):
121
+ freq: np.ndarray | None = None
122
+ psd: np.ndarray | None = None
123
+ psd_max: float = float("nan")
124
+ psd_at_probe: float = float("nan") # PSD nearest _PROBE_FREQ
125
+ peak_freq: float = float("nan") # frequency of psd_max
126
+
127
+
128
+ @dataclass
129
+ class BenchStats:
130
+ """All statistics needed to draw the figures and evaluate the checklist."""
131
+
132
+ filename: str
133
+ sn: str
134
+ fs_fast: float
135
+ fs_slow: float
136
+ fft_sec: float
137
+ start_time: str
138
+ channels: dict[str, ChannelStat] = field(default_factory=dict)
139
+
140
+ def get(self, name: str) -> ChannelStat | None:
141
+ return self.channels.get(name)
142
+
143
+
144
+ @dataclass
145
+ class CheckItem:
146
+ """One line of the evaluated checklist."""
147
+
148
+ section: str # "Time Series" | "Spectra" | "CT/CLTU"
149
+ label: str
150
+ measured: str
151
+ threshold: str
152
+ status: str # PASS | FAIL | REVIEW | N/A
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Channel classification
157
+ # ---------------------------------------------------------------------------
158
+
159
+ # Which channels get an auto-spectrum, mirroring quick_bench.m: all fast
160
+ # vibration/shear/thermistor channels, plus the slow pre-emphasized pressure.
161
+ _VIB_TYPES = {"piezo", "accel"}
162
+
163
+
164
+ def _spectrum_channels(pf: PFile) -> list[str]:
165
+ """Names to auto-spectrum: fast vibration/shear/therm channels + P_dP."""
166
+ names = []
167
+ for name in pf.channels_raw:
168
+ ctype = pf.channel_info.get(name, {}).get("type", "")
169
+ if pf.is_fast(name) and (ctype in _VIB_TYPES or ctype in {"shear", "therm"}):
170
+ names.append(name)
171
+ if "P_dP" in pf.channels_raw:
172
+ names.append("P_dP")
173
+ return names
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Spectra
178
+ # ---------------------------------------------------------------------------
179
+
180
+
181
+ def _psd(x: np.ndarray, nfft: int, fs: float) -> tuple[np.ndarray, np.ndarray] | None:
182
+ """One-sided auto-spectrum in input-units^2/Hz via Welch/cosine window.
183
+
184
+ Feeds the raw signal with linear per-segment detrending, exactly as ODAS
185
+ ``quick_bench`` calls ``csd_odas(x, x, nfft, fs, [], nfft/2, 'linear')``.
186
+ Returns ``(freq, psd)`` or ``None`` when the record is shorter than
187
+ ``2*nfft`` (``csd_matrix`` requires at least two segments).
188
+ """
189
+ x = np.asarray(x, dtype=np.float64).ravel()
190
+ if nfft < 2 or len(x) < 2 * nfft:
191
+ return None
192
+ res = csd_odas(x, None, nfft, fs, detrend="linear")
193
+ return res.F, res.Cxy
194
+
195
+
196
+ def _nearest(freq: np.ndarray, target: float) -> int:
197
+ return int(np.argmin(np.abs(freq - target)))
198
+
199
+
200
+ def compute_bench_stats(pf: PFile, fft_sec: float = DEFAULT_FFT_SEC) -> BenchStats:
201
+ """Compute the per-channel time-series and spectral statistics.
202
+
203
+ ``pf`` should be constructed with ``deconvolve=False`` so the pre-emphasized
204
+ channels carry raw counts. Count-based channels use ``channels_raw``;
205
+ inclinometers use the physical-unit ``channels`` (degrees / degC), matching
206
+ the one conversion ``quick_bench`` performs.
207
+ """
208
+ nfft_fast = round(fft_sec * pf.fs_fast)
209
+ nfft_slow = round(nfft_fast * pf.fs_slow / pf.fs_fast) if pf.fs_fast > 0 else 0
210
+
211
+ spec_names = set(_spectrum_channels(pf))
212
+ stats = BenchStats(
213
+ filename=pf.filepath.name,
214
+ sn=str(pf.config.get("instrument_info", {}).get("sn", "") or ""),
215
+ fs_fast=float(pf.fs_fast),
216
+ fs_slow=float(pf.fs_slow),
217
+ fft_sec=float(fft_sec),
218
+ start_time=pf.start_time.isoformat(),
219
+ )
220
+
221
+ for name in pf.channels_raw:
222
+ info = pf.channel_info.get(name, {})
223
+ ctype = info.get("type", "")
224
+ is_fast = pf.is_fast(name)
225
+ # Inclinometers are reported in physical units; everything else in raw
226
+ # counts. channels_raw is int16 (deconvolve=False); cast to float for
227
+ # the moment statistics.
228
+ if ctype in {"inclxy", "inclt"}:
229
+ arr = np.asarray(pf.channels[name], dtype=np.float64)
230
+ unit = info.get("units", "")
231
+ else:
232
+ arr = np.asarray(pf.channels_raw[name], dtype=np.float64)
233
+ unit = "counts"
234
+ if arr.size == 0:
235
+ continue
236
+ mean = float(np.mean(arr))
237
+ abs_dev = np.abs(arr - mean)
238
+ cs = ChannelStat(
239
+ name=name,
240
+ ctype=ctype,
241
+ is_fast=is_fast,
242
+ unit=unit,
243
+ n=int(arr.size),
244
+ mean=mean,
245
+ std=float(np.std(arr)),
246
+ typ_dev=float(np.percentile(abs_dev, _TYP_PCT)),
247
+ max_abs_dev=float(np.max(abs_dev)),
248
+ )
249
+
250
+ if name in spec_names:
251
+ nfft = nfft_fast if is_fast else nfft_slow
252
+ fs = pf.fs_fast if is_fast else pf.fs_slow
253
+ spec = _psd(pf.channels_raw[name], nfft, fs)
254
+ if spec is None:
255
+ log.warning(
256
+ "%s: %s too short for a %d-point spectrum (need >= %d samples); "
257
+ "skipping its spectrum",
258
+ pf.filepath.name,
259
+ name,
260
+ nfft,
261
+ 2 * nfft,
262
+ )
263
+ else:
264
+ freq, psd = spec
265
+ cs.freq = freq
266
+ cs.psd = psd
267
+ # Ignore the DC bin when locating the peak / maximum.
268
+ body = slice(1, None)
269
+ if psd[body].size:
270
+ imax = int(np.argmax(psd[body])) + 1
271
+ cs.psd_max = float(psd[imax])
272
+ cs.peak_freq = float(freq[imax])
273
+ cs.psd_at_probe = float(psd[_nearest(freq, _PROBE_FREQ)])
274
+ stats.channels[name] = cs
275
+
276
+ return stats
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # Checklist evaluation
281
+ # ---------------------------------------------------------------------------
282
+
283
+
284
+ def _fmt(value: float, unit: str = "") -> str:
285
+ if not np.isfinite(value):
286
+ return "n/a"
287
+ suffix = f" {unit}" if unit else ""
288
+ if abs(value) >= 1e4 or (value != 0 and abs(value) < 1e-2):
289
+ return f"{value:.2e}{suffix}"
290
+ return f"{value:.3g}{suffix}"
291
+
292
+
293
+ def _add_range(
294
+ items: list[CheckItem], section: str, label: str, cs: ChannelStat | None, limit: float
295
+ ) -> None:
296
+ """Append a "typically within +/-limit counts" PASS/FAIL check when present.
297
+
298
+ Uses the robust 99.9th-percentile deviation (``typ_dev``), not the absolute
299
+ maximum, matching the checklist's "typically within" wording. When the true
300
+ maximum still exceeds the limit (out-of-range samples in the outer 0.1%),
301
+ the max is surfaced in the measured value so the robust metric never
302
+ silently masks genuine excursions.
303
+ """
304
+ if cs is None:
305
+ return
306
+ status = PASS if cs.typ_dev <= limit else FAIL
307
+ measured = f"+/-{_fmt(cs.typ_dev)} counts"
308
+ if cs.max_abs_dev > limit:
309
+ measured += f" (max +/-{_fmt(cs.max_abs_dev)})"
310
+ items.append(
311
+ CheckItem(section, label, measured, f"<= +/-{limit:g} (p99.9)", status)
312
+ )
313
+
314
+
315
+ def _sorted_by_type(stats: BenchStats, ctype: str) -> list[ChannelStat]:
316
+ return [cs for cs in stats.channels.values() if cs.ctype == ctype]
317
+
318
+
319
+ def evaluate_checklist(stats: BenchStats) -> list[CheckItem]:
320
+ """Evaluate the Rockland bench-test checklist against ``stats``.
321
+
322
+ Quantitative criteria become PASS/FAIL with the measured value; genuinely
323
+ subjective ones ("similar", "rising curve", "seemingly random") become
324
+ REVIEW, carrying a helper number where one is cheap to compute. Optional
325
+ channels that are absent (e.g. micro-conductivity) are reported N/A so the
326
+ operator sees they were considered.
327
+ """
328
+ items: list[CheckItem] = []
329
+ ts = "Time Series"
330
+ sp = "Spectra"
331
+
332
+ # --- Vibration (Ax, Ay) ------------------------------------------------
333
+ vib = [cs for cs in stats.channels.values() if cs.ctype in _VIB_TYPES]
334
+ vib.sort(key=lambda c: c.name)
335
+ for cs in vib:
336
+ _add_range(items, ts, f"{cs.name} range", cs, _AXY_RANGE)
337
+ if len(vib) >= 2:
338
+ a, b = vib[0], vib[1]
339
+ ratio = a.std / b.std if b.std else float("nan")
340
+ items.append(
341
+ CheckItem(
342
+ ts,
343
+ f"{a.name}~{b.name} similar, {a.name}>{b.name}",
344
+ f"std ratio {_fmt(ratio)}",
345
+ "similar; larger first",
346
+ REVIEW,
347
+ )
348
+ )
349
+ for cs in vib:
350
+ # Whether a peak is a "large spike" vs cushioning vibration is a visual
351
+ # call; report the peak-to-std ratio as a hint and defer to a human.
352
+ ratio = cs.max_abs_dev / cs.std if cs.std else float("nan")
353
+ items.append(
354
+ CheckItem(
355
+ ts, f"{cs.name} large spikes?", f"peak/std {_fmt(ratio)}", "no large spikes", REVIEW
356
+ )
357
+ )
358
+
359
+ # --- Inclinometers -----------------------------------------------------
360
+ for name in ("Incl_T", "Incl_X", "Incl_Y"):
361
+ ics = stats.get(name)
362
+ if ics is not None:
363
+ items.append(
364
+ CheckItem(
365
+ ts,
366
+ f"{name} reasonable & constant",
367
+ f"{_fmt(ics.mean, ics.unit)} (std {_fmt(ics.std)})",
368
+ "reasonable, constant",
369
+ REVIEW,
370
+ )
371
+ )
372
+
373
+ # --- Fast thermistor gradient (T*_dT*) ---------------------------------
374
+ tdt = sorted(
375
+ (cs for cs in stats.channels.values() if cs.ctype == "therm" and cs.is_fast),
376
+ key=lambda c: c.name,
377
+ )
378
+ for cs in tdt:
379
+ _add_range(items, ts, f"{cs.name} range", cs, _TDT_RANGE)
380
+ status = PASS if abs(cs.mean) < _TDT_OFFSET else FAIL
381
+ items.append(
382
+ CheckItem(
383
+ ts, f"{cs.name} offset", f"{_fmt(cs.mean)} counts", f"< {_TDT_OFFSET:g}", status
384
+ )
385
+ )
386
+
387
+ # --- Shear (sh*) -------------------------------------------------------
388
+ shear = _sorted_by_type(stats, "shear")
389
+ shear.sort(key=lambda c: c.name)
390
+ for cs in shear:
391
+ status = PASS if abs(cs.mean) < _SH_MEAN else FAIL
392
+ items.append(
393
+ CheckItem(ts, f"{cs.name} mean", f"{_fmt(cs.mean)} counts", f"< {_SH_MEAN:g}", status)
394
+ )
395
+ _add_range(items, ts, f"{cs.name} range", cs, _SH_RANGE)
396
+
397
+ # --- Pressure ----------------------------------------------------------
398
+ _add_range(items, ts, "P range", stats.get("P"), _P_RANGE)
399
+ pdp = stats.get("P_dP")
400
+ if pdp is not None:
401
+ _add_range(items, ts, "P_dP range", pdp, _PDP_RANGE)
402
+ items.append(
403
+ CheckItem(ts, "P_dP seemingly random", "see figure", "no spikes/patterns", REVIEW)
404
+ )
405
+
406
+ # --- Micro-conductivity (optional; N/A when absent) --------------------
407
+ c1 = next((cs for cs in stats.channels.values() if cs.ctype == "ucond"), None)
408
+ if c1 is None:
409
+ items.append(CheckItem(ts, "C1_dC1 range", "absent", f"<= +/-{_C1_RANGE:g}", NA))
410
+ items.append(CheckItem(ts, "C1_dC1 offset", "absent", f"< {_C1_OFFSET:g}", NA))
411
+ else:
412
+ _add_range(items, ts, "C1_dC1 range", c1, _C1_RANGE)
413
+ status = PASS if abs(c1.mean) < _C1_OFFSET else FAIL
414
+ items.append(
415
+ CheckItem(ts, "C1_dC1 offset", f"{_fmt(c1.mean)} counts", f"< {_C1_OFFSET:g}", status)
416
+ )
417
+
418
+ # --- Spectra -----------------------------------------------------------
419
+ if pdp is not None and pdp.psd is not None:
420
+ s1 = PASS if pdp.psd_max < _PDP_PSD_MAX else FAIL
421
+ items.append(
422
+ CheckItem(sp, "P_dP density max", _fmt(pdp.psd_max), f"< {_PDP_PSD_MAX:g}", s1)
423
+ )
424
+ s2 = PASS if pdp.psd_max < _PDP_PSD_PEAK else FAIL
425
+ items.append(
426
+ CheckItem(
427
+ sp,
428
+ "P_dP peak",
429
+ f"{_fmt(pdp.psd_max)} at {_fmt(pdp.peak_freq)} Hz",
430
+ f"< {_PDP_PSD_PEAK:g}, rolloff ~2 Hz",
431
+ s2,
432
+ )
433
+ )
434
+ for cs in vib:
435
+ if cs.psd is None:
436
+ continue
437
+ status = PASS if cs.psd_max < _AXY_PSD_MAX else FAIL
438
+ items.append(
439
+ CheckItem(sp, f"{cs.name} peak", _fmt(cs.psd_max), f"< {_AXY_PSD_MAX:g}", status)
440
+ )
441
+ for cs in tdt:
442
+ if cs.psd is None:
443
+ continue
444
+ items.append(
445
+ CheckItem(
446
+ sp,
447
+ f"{cs.name} rising ~1e-1 near 100 Hz",
448
+ f"{_fmt(cs.psd_at_probe)} at 100 Hz",
449
+ "rising to ~1e-1",
450
+ REVIEW,
451
+ )
452
+ )
453
+ for cs in shear:
454
+ if cs.psd is None:
455
+ continue
456
+ items.append(
457
+ CheckItem(
458
+ sp,
459
+ f"{cs.name} rising ~1e-2 near 100 Hz",
460
+ f"{_fmt(cs.psd_at_probe)} at 100 Hz",
461
+ "rising to ~1e-2",
462
+ REVIEW,
463
+ )
464
+ )
465
+
466
+ # --- CT/CLTU (page 3) --------------------------------------------------
467
+ items.extend(_evaluate_ctclu(stats))
468
+
469
+ return items
470
+
471
+
472
+ def _evaluate_ctclu(stats: BenchStats) -> list[CheckItem]:
473
+ sec = "CT/CLTU"
474
+ items: list[CheckItem] = []
475
+ jt = next((cs for cs in stats.channels.values() if cs.ctype == "jac_t"), None)
476
+ _add_range(items, sec, "JAC_T range", jt, _JAC_T_RANGE)
477
+ for name, limit in (("Turbidity", _TURB_RANGE), ("Chlorophyll", _CHLA_RANGE)):
478
+ _add_range(items, sec, f"{name} range", stats.get(name), limit)
479
+ if any(cs.ctype == "jac_c" for cs in stats.channels.values()):
480
+ items.append(
481
+ CheckItem(sec, "JAC_C I/V", "see figure", "I <= +/-5, V ~1e4 +/-100", REVIEW)
482
+ )
483
+ return items
484
+
485
+
486
+ # ---------------------------------------------------------------------------
487
+ # Report rendering
488
+ # ---------------------------------------------------------------------------
489
+
490
+ _SYMBOL = {PASS: "[ ok ]", FAIL: "[FAIL]", REVIEW: "[rvw ]", NA: "[ -- ]"}
491
+
492
+
493
+ def render_checklist_text(items: list[CheckItem], header: str) -> str:
494
+ """Format the evaluated checklist as an aligned, section-grouped report."""
495
+ lines = [header, "=" * max((len(line) for line in header.splitlines()), default=0)]
496
+ n_pass = sum(1 for it in items if it.status == PASS)
497
+ n_fail = sum(1 for it in items if it.status == FAIL)
498
+ n_rev = sum(1 for it in items if it.status == REVIEW)
499
+ n_na = sum(1 for it in items if it.status == NA)
500
+ lines.append(
501
+ f"Summary: {n_pass} pass, {n_fail} fail, {n_rev} review, {n_na} n/a "
502
+ f"({len(items)} checks)"
503
+ )
504
+
505
+ label_w = max((len(it.label) for it in items), default=10)
506
+ meas_w = max((len(it.measured) for it in items), default=10)
507
+ last_section = None
508
+ for it in items:
509
+ if it.section != last_section:
510
+ lines.append("")
511
+ lines.append(f"-- {it.section} --")
512
+ last_section = it.section
513
+ lines.append(
514
+ f" {_SYMBOL[it.status]} {it.label:<{label_w}} "
515
+ f"{it.measured:<{meas_w}} ({it.threshold})"
516
+ )
517
+ lines.append("")
518
+ lines.append(
519
+ "REVIEW items need a human eye (compare spectra against the RSI "
520
+ "calibration report). Narrow-band spikes at 50/60 Hz (AC) or 15 Hz (EM) "
521
+ "are expected; broadband noise in one channel is not."
522
+ )
523
+ return "\n".join(lines)
524
+
525
+
526
+ # ---------------------------------------------------------------------------
527
+ # Figures
528
+ # ---------------------------------------------------------------------------
529
+
530
+ # Time-series groups: (title, [channel names], subtract_mean, physical_units).
531
+ # Only groups with at least one present channel are drawn.
532
+
533
+
534
+ def _time_and_data(
535
+ pf: PFile, name: str, physical: bool
536
+ ) -> tuple[np.ndarray, np.ndarray]:
537
+ """Return (time, data) sliced to equal length for one channel."""
538
+ src = pf.channels if physical else pf.channels_raw
539
+ y = np.asarray(src[name], dtype=np.float64)
540
+ t = pf.t_fast if pf.is_fast(name) else pf.t_slow
541
+ n = min(len(t), len(y))
542
+ return t[:n], y[:n]
543
+
544
+
545
+ def _timeseries_groups(pf: PFile) -> list[tuple[str, list[str], bool, bool]]:
546
+ """Build the ordered list of time-series panels present in ``pf``."""
547
+
548
+ def present(names: list[str]) -> list[str]:
549
+ return [n for n in names if n in pf.channels_raw]
550
+
551
+ def by_type(types: set[str], fast: bool | None = None) -> list[str]:
552
+ out = []
553
+ for n in sorted(pf.channels_raw):
554
+ ct = pf.channel_info.get(n, {}).get("type", "")
555
+ if ct in types and (fast is None or pf.is_fast(n) == fast):
556
+ out.append(n)
557
+ return out
558
+
559
+ groups: list[tuple[str, list[str], bool, bool]] = []
560
+ # (title, names, subtract_mean, physical)
561
+ vib = by_type(_VIB_TYPES)
562
+ if vib:
563
+ groups.append(("Vibration [counts]", vib, False, False))
564
+ shear = by_type({"shear"})
565
+ if shear:
566
+ groups.append(("Shear [counts]", shear, False, False))
567
+ tdt = by_type({"therm"}, fast=True)
568
+ if tdt:
569
+ groups.append(("Thermistor gradient [counts]", tdt, True, False))
570
+ ucond = by_type({"ucond"}, fast=True)
571
+ if ucond:
572
+ groups.append(("Micro-conductivity [counts]", ucond, True, False))
573
+ mag = by_type({"magn"})
574
+ if mag:
575
+ groups.append(("Magnetometer [counts]", mag, False, False))
576
+ pres = present(["P", "P_dP"])
577
+ if pres:
578
+ groups.append(("Pressure [counts]", pres, False, False))
579
+ incl = present(["Incl_X", "Incl_Y", "Incl_T"])
580
+ if incl:
581
+ groups.append(("Inclinometer [physical]", incl, False, True))
582
+ return groups
583
+
584
+
585
+ def _ts_range_limit(name: str, ctype: str) -> float | None:
586
+ """The "typically within +/-N counts" limit for a time-series channel.
587
+
588
+ Returns the +/- count limit whose band is shaded on the plot, or None for
589
+ channels with no numeric criterion (inclinometers, magnetometer).
590
+ """
591
+ if ctype in _VIB_TYPES:
592
+ return _AXY_RANGE
593
+ if ctype == "therm":
594
+ # Only the fast pre-emphasized gradient channels (T*_dT*) reach here:
595
+ # _timeseries_groups builds the therm group with fast=True, so the slow
596
+ # base T* (which would want a different criterion) is never shaded.
597
+ return _TDT_RANGE
598
+ if ctype == "shear":
599
+ return _SH_RANGE
600
+ if ctype == "ucond":
601
+ return _C1_RANGE
602
+ if ctype == "jac_t":
603
+ return _JAC_T_RANGE
604
+ return {
605
+ "P": _P_RANGE,
606
+ "P_dP": _PDP_RANGE,
607
+ "Turbidity": _TURB_RANGE,
608
+ "Chlorophyll": _CHLA_RANGE,
609
+ }.get(name)
610
+
611
+
612
+ def build_timeseries_figure(pf: PFile, stats: BenchStats, title: str) -> Figure:
613
+ """Stacked raw-count time series, one panel per channel group.
614
+
615
+ Each channel's checklist "valid range" (mean +/- its threshold) is drawn as
616
+ a shaded band in the channel's color, so an out-of-range excursion is
617
+ visible as the trace poking outside its band.
618
+ """
619
+ groups = _timeseries_groups(pf)
620
+ n = max(len(groups), 1)
621
+ fig, axes = plt.subplots(n, 1, figsize=(11, 1.6 * n + 1.2), squeeze=False, sharex=True)
622
+ ax_col = axes[:, 0]
623
+
624
+ for ax, (glabel, names, sub_mean, physical) in zip(ax_col, groups, strict=False):
625
+ for name in names:
626
+ t, y = _time_and_data(pf, name, physical)
627
+ cs = stats.get(name)
628
+ center = cs.mean if cs is not None else float(np.mean(y))
629
+ label = name
630
+ if sub_mean:
631
+ y = y - center
632
+ label = f"{name}{-center:+.0f}"
633
+ center = 0.0
634
+ (line,) = ax.plot(t, y, linewidth=0.5, label=label)
635
+ lim = _ts_range_limit(name, cs.ctype if cs is not None else "")
636
+ if lim is not None:
637
+ # Shaded "valid range" = mean +/- threshold, in the line's color.
638
+ ax.axhspan(
639
+ center - lim, center + lim, color=line.get_color(), alpha=0.10, lw=0, zorder=0
640
+ )
641
+ ax.set_ylabel(glabel, fontsize=7)
642
+ ax.legend(loc="upper right", fontsize=6, ncol=max(1, len(names)))
643
+ ax.grid(True, alpha=0.3)
644
+ for ax in ax_col[len(groups) :]:
645
+ ax.set_visible(False)
646
+ ax_col[min(len(groups), n) - 1].set_xlabel("t [s]")
647
+ ax_col[0].set_title(title, fontsize=9)
648
+ fig.tight_layout()
649
+ return fig
650
+
651
+
652
+ def _spectra_valid_ranges(ax: Axes, stats: BenchStats) -> None:
653
+ """Shade the checklist's spectral expectations onto the spectra axes.
654
+
655
+ Green target boxes near 100 Hz mark the "rising to ~10^N counts^2/Hz"
656
+ envelopes for the thermistor (~1e-1) and shear (~1e-2) channels; red dashed
657
+ ceilings mark the accelerometer (<100) and P_dP (<10) upper bounds.
658
+ """
659
+ from matplotlib.patches import Rectangle
660
+
661
+ types = {cs.ctype for cs in stats.channels.values()}
662
+ # Target boxes: half a decade around the expected level, 50-150 Hz.
663
+ targets = []
664
+ if "therm" in types:
665
+ targets.append((-1.0, "T target ~1e-1"))
666
+ if "shear" in types:
667
+ targets.append((-2.0, "sh target ~1e-2"))
668
+ for exp, label in targets:
669
+ lo, hi = 10.0 ** (exp - 0.5), 10.0 ** (exp + 0.5)
670
+ ax.add_patch(
671
+ Rectangle(
672
+ (50.0, lo), 100.0, hi - lo, facecolor="tab:green", alpha=0.15, edgecolor="none",
673
+ zorder=0,
674
+ )
675
+ )
676
+ ax.text(150.0, hi, label, fontsize=6, color="tab:green", va="bottom", ha="right")
677
+ # Ceilings: draw only the ones whose channels are present.
678
+ ceilings = []
679
+ if types & _VIB_TYPES:
680
+ ceilings.append((_AXY_PSD_MAX, "Ax/Ay max"))
681
+ if "P_dP" in stats.channels:
682
+ ceilings.append((_PDP_PSD_MAX, "P_dP max"))
683
+ x_right = 0.9 * stats.fs_fast / 2 # near Nyquist, clear of the top-left legend
684
+ for yval, label in ceilings:
685
+ ax.axhline(yval, color="tab:red", linestyle="--", linewidth=0.7, alpha=0.5, zorder=0)
686
+ ax.text(
687
+ x_right, yval, label, fontsize=6, color="tab:red", va="bottom", ha="right"
688
+ )
689
+
690
+
691
+ def build_spectra_figure(pf: PFile, stats: BenchStats, title: str) -> Figure:
692
+ """Log-log overlay of channel auto-spectra in counts^2/Hz.
693
+
694
+ Overlays the checklist's spectral "valid ranges": green target boxes for the
695
+ expected thermistor/shear noise envelopes near 100 Hz and red dashed ceilings
696
+ for the accelerometer and pre-emphasized-pressure upper bounds.
697
+ """
698
+ fig, ax = plt.subplots(figsize=(11, 6))
699
+ any_line = False
700
+ ymax = 1e2
701
+ for name in _spectrum_channels(pf):
702
+ cs = stats.get(name)
703
+ if cs is None or cs.psd is None or cs.freq is None:
704
+ continue
705
+ m = np.isfinite(cs.psd) & (cs.psd > 0) & (cs.freq > 0)
706
+ if not np.any(m):
707
+ continue
708
+ ax.loglog(cs.freq[m], cs.psd[m], linewidth=0.7, label=name)
709
+ any_line = True
710
+ ymax = max(ymax, float(np.nanmax(cs.psd[m])))
711
+
712
+ if any_line:
713
+ _spectra_valid_ranges(ax, stats)
714
+ ax.set_xlim(0.9 / stats.fft_sec, 1.1 * pf.fs_fast / 2)
715
+ ax.set_ylim(1e-4, ymax * 1.5)
716
+ ax.legend(loc="upper left", fontsize=7, ncol=2)
717
+ else:
718
+ ax.text(0.5, 0.5, "No spectra (record too short)", transform=ax.transAxes, ha="center")
719
+ ax.set_xlabel("f [Hz]")
720
+ ax.set_ylabel("counts$^2$ Hz$^{-1}$")
721
+ ax.set_title(title, fontsize=9)
722
+ ax.grid(True, which="both", alpha=0.3)
723
+ fig.tight_layout()
724
+ return fig
725
+
726
+
727
+ def _ctclu_panels(pf: PFile) -> list[tuple[str | None, str, np.ndarray, np.ndarray]]:
728
+ """(name, label, t, y) for each CT/CLTU channel present, JAC_C split into I/V.
729
+
730
+ ``name`` is the stats key for a valid-range band, or None for the derived
731
+ JAC_C I/V sub-channels (which have no single-channel threshold).
732
+ """
733
+ panels: list[tuple[str | None, str, np.ndarray, np.ndarray]] = []
734
+ for name in ("Turbidity", "Chlorophyll", "JAC_T"):
735
+ if name in pf.channels_raw:
736
+ t, y = _time_and_data(pf, name, physical=False)
737
+ panels.append((name, f"{name} [counts]", t, y))
738
+ if "JAC_C" in pf.channels_raw:
739
+ t = pf.t_slow
740
+ raw = np.asarray(pf.channels_raw["JAC_C"], dtype=np.float64)
741
+ n = min(len(t), len(raw))
742
+ raw_i = np.floor(raw[:n] / 2**16)
743
+ raw_v = np.mod(raw[:n], 2**16)
744
+ panels.append((None, "JAC_C_I [counts]", t[:n], raw_i))
745
+ panels.append((None, "JAC_C_V [counts]", t[:n], raw_v))
746
+ return panels
747
+
748
+
749
+ def build_ctclu_figure(pf: PFile, stats: BenchStats, title: str) -> Figure | None:
750
+ """Optional CT/CLTU time-series figure; None when no such channels exist.
751
+
752
+ Draws each channel's checklist valid range (mean +/- threshold) as a shaded
753
+ band, matching the main time-series figure.
754
+ """
755
+ panels = _ctclu_panels(pf)
756
+ if not panels:
757
+ return None
758
+ n = len(panels)
759
+ fig, axes = plt.subplots(n, 1, figsize=(11, 1.5 * n + 1.0), squeeze=False, sharex=True)
760
+ ax_col = axes[:, 0]
761
+ for ax, (name, label, t, y) in zip(ax_col, panels, strict=False):
762
+ (line,) = ax.plot(t, y, linewidth=0.5)
763
+ cs = stats.get(name) if name else None
764
+ lim = _ts_range_limit(name, cs.ctype) if (name and cs is not None) else None
765
+ if lim is not None and cs is not None:
766
+ ax.axhspan(
767
+ cs.mean - lim, cs.mean + lim, color=line.get_color(), alpha=0.10, lw=0, zorder=0
768
+ )
769
+ ax.set_ylabel(label, fontsize=7)
770
+ ax.grid(True, alpha=0.3)
771
+ ax_col[-1].set_xlabel("t [s]")
772
+ ax_col[0].set_title(title, fontsize=9)
773
+ fig.tight_layout()
774
+ return fig
775
+
776
+
777
+ # ---------------------------------------------------------------------------
778
+ # Driver
779
+ # ---------------------------------------------------------------------------
780
+
781
+
782
+ def _checklist_text_figure(report: str) -> Figure:
783
+ """Render the checklist report as a full-page monospace text figure.
784
+
785
+ Used as the final page of the ``pdf-bundle`` output. Long lines (the trailing
786
+ guidance note) are wrapped so they do not run off the page; the aligned
787
+ table rows are all short and pass through untouched.
788
+ """
789
+ import textwrap
790
+
791
+ lines: list[str] = []
792
+ for ln in report.split("\n"):
793
+ lines.extend(textwrap.wrap(ln, 108) if len(ln) > 108 else [ln])
794
+ fig = plt.figure(figsize=(8.5, 11))
795
+ fig.text(
796
+ 0.04, 0.98, "\n".join(lines), family="monospace", fontsize=8, va="top", ha="left"
797
+ )
798
+ return fig
799
+
800
+
801
+ def _save_pdf_bundle(figs: list[Figure], path: Path, dpi: int) -> None:
802
+ """Write all figures into one multi-page PDF, quick_bench-style.
803
+
804
+ Written to a temp file and atomically renamed, so a failure mid-write never
805
+ leaves a truncated PDF at ``path``.
806
+ """
807
+ from matplotlib.backends.backend_pdf import PdfPages
808
+
809
+ tmp = path.with_name(path.name + ".tmp")
810
+ try:
811
+ with PdfPages(tmp) as pdf:
812
+ for fig in figs:
813
+ pdf.savefig(fig, dpi=dpi, bbox_inches="tight")
814
+ tmp.replace(path)
815
+ finally:
816
+ if tmp.exists():
817
+ tmp.unlink()
818
+
819
+
820
+ def run_bench(
821
+ path: str | Path,
822
+ out_dir: str | Path | None = None,
823
+ *,
824
+ show: bool = False,
825
+ sn: str | None = None,
826
+ fft_sec: float = DEFAULT_FFT_SEC,
827
+ dpi: int = 150,
828
+ fmt: str = "png",
829
+ ) -> tuple[BenchStats, list[CheckItem]]:
830
+ """Run a bench test on one ``.p`` file.
831
+
832
+ Reads with ``deconvolve=False`` (raw pre-emphasized counts), computes the
833
+ statistics, evaluates the checklist (printed to stdout), and builds the
834
+ figures. When ``out_dir`` is given, writes ``QB_<SN>_<stem>_timeseries``,
835
+ ``_spectra`` (and ``_ctclu`` when applicable) plus ``_checklist.txt``.
836
+ ``fmt`` controls the figure files: ``png`` / ``pdf`` write one file per
837
+ figure; ``both`` writes both; ``pdf-bundle`` writes a single multi-page
838
+ ``QB_<SN>_<stem>.pdf`` with the checklist as a final page (quick_bench-style).
839
+ When ``show`` is true, opens the figures interactively.
840
+
841
+ Returns ``(stats, checklist_items)`` for scripting.
842
+ """
843
+ pf = PFile(path, deconvolve=False)
844
+ stats = compute_bench_stats(pf, fft_sec=fft_sec)
845
+ resolved_sn = sn or stats.sn or "___"
846
+ # Sanitize for use in output file names (a serial number like "a/b" would
847
+ # otherwise point the write at a nonexistent subdirectory).
848
+ safe_sn = re.sub(r"[^\w.-]", "_", resolved_sn)
849
+
850
+ stem = pf.filepath.stem
851
+ header = (
852
+ f"Bench test {pf.filepath.name}\n"
853
+ f"SN {resolved_sn} {stats.start_time} fs_fast={stats.fs_fast:.0f} Hz"
854
+ )
855
+ items = evaluate_checklist(stats)
856
+ report = render_checklist_text(items, header)
857
+ print(report)
858
+
859
+ title_base = f"{pf.filepath.name} SN {resolved_sn}"
860
+ figs: dict[str, Figure] = {
861
+ "timeseries": build_timeseries_figure(
862
+ pf, stats, f"{title_base} — Time Series (shaded = checklist valid range)"
863
+ ),
864
+ "spectra": build_spectra_figure(
865
+ pf, stats, f"{title_base} — Spectra (green = target band, red = ceiling)"
866
+ ),
867
+ }
868
+ ctclu = build_ctclu_figure(pf, stats, f"{title_base} — CT/CLTU")
869
+ if ctclu is not None:
870
+ figs["ctclu"] = ctclu
871
+
872
+ # try/finally so a save error (read-only dir, disk full, bad path) never
873
+ # leaks the open figures across a batch run — the CLI catches per-file and
874
+ # continues, so leaked figures would otherwise accumulate.
875
+ try:
876
+ if out_dir is not None:
877
+ out = Path(out_dir)
878
+ out.mkdir(parents=True, exist_ok=True)
879
+ base = f"QB_{safe_sn}_{stem}"
880
+ if fmt == "pdf-bundle":
881
+ # Append the evaluated checklist as the final page of the bundle.
882
+ checklist_fig = _checklist_text_figure(report)
883
+ try:
884
+ _save_pdf_bundle([*figs.values(), checklist_fig], out / f"{base}.pdf", dpi)
885
+ finally:
886
+ plt.close(checklist_fig)
887
+ else:
888
+ exts = ["png", "pdf"] if fmt == "both" else [fmt]
889
+ for fig_name, fig in figs.items():
890
+ for ext in exts:
891
+ fig.savefig(
892
+ out / f"{base}_{fig_name}.{ext}", dpi=dpi, bbox_inches="tight"
893
+ )
894
+ (out / f"{base}_checklist.txt").write_text(report)
895
+ print(f"\nWrote figures and checklist to {out}/{base}*")
896
+
897
+ if show:
898
+ plt.show() # pragma: no cover
899
+ finally:
900
+ if not show:
901
+ for fig in figs.values():
902
+ plt.close(fig)
903
+
904
+ return stats, items