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