Release v0.31.0 — report parity + the inverted rescue
Ships two releases' worth: prod is on v0.29.0, so this carries v0.30.0 and
v0.31.0 together. 31 commits.
v0.31.0 — report parity + the inverted rescue
Blastware Event/FFT-Report parity. The FFT, the USBM RI8507 compliance
chart and the sensor self-check now render on the event report,
reverse-engineered against BE12844 (MiniMate Plus) and UM (Thor) events. The
FFT matches Blastware to the exact bin on all 28 oracle channels. The sensor
check is decoded for both series and standardized into the .h5
(schema v2, a new /sensor_check group) so SFM serves it
device-agnostically rather than decoding at report time.
Field rescue for a runaway unit.bridges/ach_server.py gains --stop-monitoring / --disable-ach / --rescue, which invert the
wedged-unit recovery: rather than racing a Stop into the gaps between a unit's
dial-outs, point the modem's Destination at our own ACH server and answer the
call. Proven in production on BE12599 — the stop landed on the first call-in
and held. Runbook rewritten around it.
Plus the Inspector (annotated hex reader for series-3 binaries), two
event-report layout fixes, and a written changelog/release convention in CLAUDE.md.
v0.30.0 — series-4 correctness (never shipped to prod)
The Thor / Micromate counterpart to v0.26.0's series-3 work. Decoder verified
per-sample against Thor's own CSV exports: 459 files, 3,807,158 / 3,807,165
samples exact across three ground-truth corpora; production IDFW 575/575,
zero truncations, zero decode failures. Series-3 re-verified unchanged at
14,338/14,338.
⚠ Deploy owes a backfill — budget ~2 hours
Both releases owe one, and they combine into a single pass.
.h5 schema v1 → v2 — existing files carry no /sensor_check group, so
their reports show no sensor-check strip until regenerated.
TOOL_VERSION is 0.31.0, so the standard backfill regenerates every
event and picks the traces up with no --force.
Run scripts/backfill_thor_events.py for series-4 (it already owed the
v0.30.0 Thor backfill — this rides along) and the series-3 sidecar/shape
backfill for MiniMate events.
DB backup first. ~1.5 files/sec on the NAS vs ~85/sec on the dev box.
Purely additive — no decoded value changes, and v1 .h5 files read fine until
then (empty strip).
Deploy
Build the image from main after merging, push to the Gitea registry,
recreate the sfm service on the NAS. See the Gitea Registry +
Container-Manager Prod Deploy runbook.
No schema change to the DB. No Terra-View coupling — nothing here changes a /db/* shape it reads.
## Release v0.31.0 — report parity + the inverted rescue
Ships **two releases' worth**: prod is on v0.29.0, so this carries v0.30.0 and
v0.31.0 together. 31 commits.
---
### v0.31.0 — report parity + the inverted rescue
**Blastware Event/FFT-Report parity.** The FFT, the USBM RI8507 compliance
chart and the sensor self-check now render on the event report,
reverse-engineered against BE12844 (MiniMate Plus) and UM (Thor) events. The
FFT matches Blastware to the exact bin on all 28 oracle channels. The sensor
check is decoded for **both** series and standardized into the `.h5`
(schema **v2**, a new `/sensor_check` group) so SFM serves it
device-agnostically rather than decoding at report time.
**Field rescue for a runaway unit.** `bridges/ach_server.py` gains
`--stop-monitoring` / `--disable-ach` / `--rescue`, which invert the
wedged-unit recovery: rather than racing a Stop into the gaps between a unit's
dial-outs, point the modem's Destination at our own ACH server and answer the
call. Proven in production on BE12599 — the stop landed on the first call-in
and held. Runbook rewritten around it.
Plus the Inspector (annotated hex reader for series-3 binaries), two
event-report layout fixes, and a written changelog/release convention in
`CLAUDE.md`.
### v0.30.0 — series-4 correctness (never shipped to prod)
The Thor / Micromate counterpart to v0.26.0's series-3 work. Decoder verified
per-sample against Thor's own CSV exports: **459 files, 3,807,158 / 3,807,165
samples exact** across three ground-truth corpora; production IDFW **575/575**,
zero truncations, zero decode failures. Series-3 re-verified **unchanged at
14,338/14,338**.
---
## ⚠ Deploy owes a backfill — budget ~2 hours
Both releases owe one, and they combine into a single pass.
- **`.h5` schema v1 → v2** — existing files carry no `/sensor_check` group, so
their reports show no sensor-check strip until regenerated.
- **`TOOL_VERSION` is 0.31.0**, so the standard backfill regenerates every
event and picks the traces up with **no `--force`**.
- Run `scripts/backfill_thor_events.py` for series-4 (it already owed the
v0.30.0 Thor backfill — this rides along) and the series-3 sidecar/shape
backfill for MiniMate events.
- **DB backup first.** ~1.5 files/sec on the NAS vs ~85/sec on the dev box.
Purely additive — no decoded value changes, and v1 `.h5` files read fine until
then (empty strip).
## Deploy
Build the image **from `main`** after merging, push to the Gitea registry,
recreate the `sfm` service on the NAS. See the *Gitea Registry +
Container-Manager Prod Deploy* runbook.
No schema change to the DB. No Terra-View coupling — nothing here changes a
`/db/*` shape it reads.
The event-report waveform plot scaled each geo lane to its own peak, so a small
channel filled its lane looking as big as a large one — and the "Geo: X in/s/div"
footer only reflected whichever channel was checked first, so its div value was
wrong for the other two. Now all three geo lanes share ONE symmetric scale =
max |sample| across them (padded, 0.05 in/s floor), matching the event modal and
BW's single amp/div; the footer reflects that shared scale. Mic keeps its own psi
scale. Big events are unchanged (e.g. BE12844 stays 0.185 in/s/div).
Test-first: tests/test_report_pdf_geo_scale.py (shared scale + floor), 2 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
Verified against Thor's own CSV exports, which carry a per-sample
four-column block beside every binary (CSV/<name>.IDFW.csv). Those 1,012
paired files were in the corpus all along; the decoder had been pinned to
a superseded walker on the stated grounds that "Thor has no ASCII ground
truth in the corpus and its geo scaling is separately suspect". Both
premises were false.
IDFW per-sample exact 39.1% -> 100.000% (1,057,536/1,057,536)
IDFW files fully exact 0/153 -> 153/153
IDFW PPV median error -3.32% -> -0.002%
IDFH within 2% of Thor PPV 51.1% -> 100.0% (858/858)
prod IDFW, 8 units -3.3% -> -0.001%
Four independent root causes:
- Geo LSB was 0.0003, the 4-dp *display rounding* of the real
0.000310308 mistaken for the LSB, so every series-4 geophone sample
read 3.3% low. Pinned to +-6e-11 by intersecting 991,415 rounding
constraints; corroborated by the +-full-scale seed (+-32226) left in
unwritten IDFH slots. IDFH had a separate, also wrong, 10.0/32768.
- IDFH histograms were capped at 250 intervals: the segment validator
required the interval counter's high byte to be zero, but the counter
is a uint16 cumulative index, so every segment past interval 255 was
rejected. Runs over ~4 hours lost their tail, often the peak.
540/858 corpus files affected.
- Record mode 00 00 (raw int16, 10-byte header) was unhandled and fell
through the dispatch, silently dropping each channel's first 512
samples -- the long-standing "loud events truncate" symptom.
MODE_ABSOLUTE is now also accepted as a segment-0 preamble.
- The body-offset search matched 00 02 00 *inside* record headers,
selecting a candidate part-way down the chain and decoding a
rotation-shifted body. It now anchors on record headers and takes the
chain head (6 ms/file).
Also fixes the separately tracked "UM-series decodes ~1000x low" bug.
Series-3 re-verified unchanged at 14,338/14,338 exact after the shared
waveform_codec change.
Known open: 41/575 prod IDFW files (7%, mostly UM12947/UM20147) decode
with unequal channel lengths and also fail metadata extraction -- a
different header variant with no Thor export in the store.
NOTE: this is a codec change; the Thor store owes a regeneration via
scripts/backfill_thor_events.py.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
Verified against a second Thor corpus (9-10-26-csv-req: UM11402, UM12947,
UM20147) with per-sample CSV exports: 139/139 waveforms exact
(1,273,380/1,273,380 samples) and 877/877 histograms within 2% of Thor's
reported PPV -- up from 66.9% and 56.6%.
Some units run with the microphone disabled, which changes two structural
things that were both hardcoded to the 4-channel shape:
- Waveform body head sat below the scan floor. A 3-channel unit has a
shorter fixed header and puts its record chain head at 0x0dba, under the
old _BODY_SCAN_FLOOR of 0x0E00. The scan could not see it and fell
through to the Vert segment-0 record, decoding a body shifted one
position around the channel rotation -- Vert came up exactly 512 samples
short. Floor lowered to 0x0C00. The body-offset scoring also had to stop
requiring four channels, or `equal` is permanently False for these events
and the pick falls back to raw sample count.
- Histogram interval record is 56 bytes, not 72. It is
16 * n_channels + 8, and is not inferable from the segment length alone.
The interval count now comes from the segment's cumulative counter
(n = counter - prev_counter) and the stride is derived from it. Assuming
72 read 7 intervals out of every 10-interval segment, then walked off
alignment into garbage that decoded as ~10 in/s peaks -- inflating some
files' PPV by up to 191,000%. Also recovers 4 files that previously
decoded no intervals at all.
Combined across both corpora: 292/292 waveform files,
2,330,916/2,330,916 samples exact. Production IDFW truncations 41 -> 22.
Series-3 unaffected (no shared-codec change in this commit; last full run
14,338/14,338).
Known open, diagnosed but NOT verified: the remaining 22 unequal + 1 failing
production IDFW files (all UM12947, 2025-07-14..09-23) stop the block walker
on tag 40 0c. data_block_len() caps the 40 NN int16 block at NN > 0x08 while
those files use NN up to 196. Both verified corpora only ever use
NN in {1,2,3,4,8}, so the cap is untested there and lifting it leaves both at
100.000% -- which is not evidence it decodes these correctly. Deliberately
not shipped; needs Thor CSV exports for UM12947 in that date range.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
data_block_len() rejected any `40 NN` block with NN > 0x08. That guard had
no evidence behind it: every corpus available when it was written used only
NN in {1,2,3,4,8}, so it was never exercised. Loud UM12947 events use NN of
12, 16, 20 ... up to 196.
Because walk_body/run stop at the first unrecognised tag rather than
raising, rejecting those blocks surfaced as silently short channels -- e.g.
Tran 1812 / Vert 2132 / Long 2324 on a file whose export carries 2324 for
all three. The real bound is the buffer; the caller additionally clamps to
the record end.
Verified against Thor's own CSV exports for UM12947 (2025-07-14 .. 09-25,
167 waveforms, supplied as CSV.zip):
length mismatches 22 -> 0
per-sample exact 1,476,242 / 1,476,249
These are NOT truncated recordings, which was the competing hypothesis --
the exports carry the full sample count.
tests/test_waveform_codec.py asserted the cap as intended behaviour. That
assertion encoded an assumption, not a verified fact, and is replaced with
one pinning the opposite plus the evidence.
Across all three ground-truth corpora: 459 waveform files,
3,807,158 / 3,807,165 samples exact. Production IDFW is now 575/575 with
zero truncations and zero decode failures (median PPV error -0.0007% across
8 units). Series-3 re-verified unchanged at 14,338/14,338.
The 7 residual samples each differ by one 4th-decimal tick and are Thor's
own rounding: intersecting the per-sample rounding constraints over that
corpus is infeasible (binding pair contradict by 2.3e-11, 7e-5 relative),
so no single linear LSB reproduces every printed value. _GEO_LSB_IPS is
already pinned to ~1e-11; do not retune it to chase these.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
Bumps package version, README banner, CLAUDE.md header and TOOL_VERSION to
0.30.0, and cuts the CHANGELOG entry for the Thor / Micromate decoder work.
Also documents the previously-unreleased event-report PDF fix (91b9b45),
which had landed on dev without a CHANGELOG entry.
TOOL_VERSION is bumped so refreshed sidecars carry the new codec version and
a future fix gates regeneration correctly. Note it was NOT required to
unblock this backfill: all 4,529 prod series-4 sidecars sit at 0.18.0-0.23.0,
well under the previous 0.29.0, so they were never being skipped. Verified by
dry-running scripts/backfill_thor_events.py against a copy of the prod store
(refreshed=379, skipped=0) both before and after the bump.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
annotate_blastware_binary(raw) → a gap-free tiling of labelled Spans
(header / STRT / per-channel sample records / footer / unknown) for a hex
viewer to paint. Every byte is covered; anything the decoder can't account
for is a first-class `unknown` span, so undecoded regions stand out.
Composes the existing waveform_codec.walk_records over the body between the
STRT record and the 26-byte footer. On the cracking fixtures this already
surfaces a ~1700-byte undecoded trailing region (stream-end marker + serial +
…) per file — a candidate home for stored spectral/FFT data.
TDD: tests assert the spans tile the whole file, STRT is located, the geo
sample records are labelled, and the footer is last.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
New top-level "Inspector" tab: open any Series-3 waveform binary and read it as
a colour-coded hex dump driven by binary_annotate. Each region is labelled with
its offset range and size (header / STRT / per-channel sample records / footer),
and everything the decoder can't account for is painted UNKNOWN (red) so gaps
stand out — the point being to comb for undecoded data (e.g. a stored FFT/
spectral block). A summary shows total size, region count, and % unknown.
Read-only reader/translator; Series-3 only for now (Series-4 later). The GUI
needs tkinter + a display (not available in the dev venv); the annotator core it
calls is unit-tested headless.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
channel_spectrum(samples, sps) → the single-sided amplitude spectrum Blastware's
FFT Report draws, and dominant_frequency() picks its peak in the 2–250 Hz band.
Reverse-engineered against 7 BE12844 (MiniMate Plus) events with Blastware FFT
reports as ground truth. Recipe: DC-remove, NO window (a window smears the peak
and worsens the match), zero-pad to 4096 (→ 0.25 Hz bins at 1024 sps — the
resolution every reported dominant frequency lands on), single-sided 2/N
amplitude. Reproduces Blastware's dominant frequency to the exact bin on all
28 channels and the amplitude to report precision.
This is the missing piece for both the USBM RI8507 compliance chart (its scatter
is these (freq, amp) points vs the limit curve) and the FFT view.
Pure numpy, series-agnostic (feed it in/s samples from either decoder). The 7
events land in tests/fixtures as the oracle (force-added past the fixtures
gitignore, matching 5-11-26 / decode-re-5-8-26).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
sfm/compliance.py renders the velocity-vs-frequency blasting compliance chart
Blastware draws on its Event Report:
- limit_at()/limit_curve() — the RI8507 Fig B-1 / 30 CFR 816.67 curve as data
(Drywall 0.75 + plaster 0.50 lines): 0.030in low-freq bound, plateau, 0.008in
rising diagonal to a 2.0 in/s cap at ~40 Hz, drawn continuous.
- channel_compliance_points() — the per-cycle (freq, peak-velocity) scatter by
the zero-crossing method (matches Blastware; cloud ceiling = channel PPV).
- draw_compliance_chart() — matplotlib rendering (both lines + scatter, BW tick
scales + channel markers).
Verified against 7 BE12844 Blastware reports. docs/ri8507_compliance_curve.md
captures the curve construction, the SHM basis, and the scatter method.
Not yet wired into report_pdf.py — that placeholder is the next step.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
Replace the "[compliance chart coming soon]" placeholder in
_draw_mic_and_usbm with a real inset axes calling
sfm.compliance.draw_compliance_chart on rd.channels / rd.sample_rate_sps
(the full-rate in/s waveform samples). Title updated "USBM RI8507 And OSMRE"
→ "USBM RI8507" — we draw only the RI8507 lines (Drywall 0.75 + plaster 0.50);
the OSMRE overlay is dropped by choice.
Waveform events only (the histogram layout has no USBM chart). Falls back to a
"(no waveform data)" note when samples are unavailable. Closes the 1.0
compliance-chart blocker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
The compliance chart sits in the short, wide mic-and-USBM band on the event
report; without a fixed aspect matplotlib stretched it wide-and-short. Force a
square plot box, which is how log-log compliance charts are conventionally drawn.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
The chart was cramped into the short mic band (~2in) and rendered tiny. Move it
to its own large square panel (_draw_compliance_panel) spanning the mic + stats
rows on the right, clear of the stats columns — matching Blastware's Event
Report proportions. _draw_mic_and_usbm now draws only the mic block.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
The compliance chart on the event-report PDF was correctly drawn but far
too small ("it's tiny") — a ~2.6in square dropped between the mic and
stats rows. Resize + reposition it to match Blastware's Event Report,
measured directly off a BW reference PDF (n844lqhbzt0w) rasterized with
fitz: the chart data box now spans figure fractions x[0.489,0.951]
y[0.502,0.867] — a ~3.9in square running from just under the header down
through the stats band, hard against the right page margin, exactly as BW
draws it. Title updated to BW's "USBM RI8507 And OSMRE".
To clear room for the BW-sized chart (waveform layout only):
* _draw_stats_table gains bbox_width/col_widths/fontsize params; the
waveform layout packs the Tran/Vert/Long table into the left ~0.42 so
its columns no longer sit under the chart. Histogram layout keeps the
wider defaults (byte-identical output; it has no compliance chart).
* the mic block's long "Channel Test Passed (Freq … Amp … mv)" line gets
a tighter indent + one-point-smaller font so it ends before the chart's
left edge instead of running behind it (_kv gains a fontsize param).
* the Peak Vector Sum line left-aligns under the compacted table (one pt
smaller) so it clears the chart's bottom-left tick labels.
Chart placement centralized in the _COMPLIANCE_BOX constant. No change to
the compliance math, the scatter, or the histogram report.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
The Blastware Event Report draws a "Sensor Check" strip on the right of the
waveform panel — the little traces the unit records when it pulses each sensor
before monitoring. Those live in the series-3 binary's trailing block, after
the main waveform record-chain and the per-channel calibration records, as four
length-prefixed records tagged 0x3c-0x3f (Tran/Vert/Long geophone ring-downs +
MicL pulse train). Reverse-engineered against 7 BE12844 oracle events.
New minimateplus/sensor_check.py: decode_sensor_check(raw) locates the record
chain (validated by walking the ids 0x3c->0x3f via their length prefixes) and
decodes each record's delta stream (payload[20:len-8]) with the same 10/20/30/00
delta-block tags as the main waveform codec, from an anchor of 0. Returns
{Tran,Vert,Long,MicL: [samples]} in raw 16-count units, or {} when absent.
Validated: mic pulse-train zero-crossing frequency = 20.1 Hz (exact match to
BW's mic Channel Test freq); geophone ring-downs are consistent ~-990 raw
deflections that damp to a ~-310 settle across all 7 events (a fixed
calibration pulse, so near-identical every run).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
Wire the decoded sensor self-check waveforms (previous commit) onto the event
report PDF, and fold in two related waveform-panel cleanups.
Sensor-check strip (matches Blastware):
* ReportData gains sensor_check_waveforms; gather_report_data decodes it from
the retained raw BW binary (store.paths_for) at report time — no ingest or
.h5 change, waveform events only.
* _draw_waveform_subplot now draws a narrow right-hand strip of per-channel
mini-plots (MicL pulse train + Long/Vert/Tran ring-downs) aligned to the
lanes, captioned "Sensor Check".
* stats table gains the "Frequency" / "Overswing Ratio" sub-rows under Sensor
Check (7.5/7.7/7.3 Hz, 3.6/3.3/3.7), formatted to 1 decimal like BW; values
come from the already-parsed sensor_check scalars.
Cleanups (pre-existing, in the same panel):
* fix the stacked-lane y-tick collision — adjacent lanes' -1.0 / 1.0 labels
overprinted at the shared boundary; prune the extreme ticks (MaxNLocator
prune="both") so each lane shows clean interior ticks only.
* fix the header serial+firmware line running off the right page edge —
tighter right-column indent + BW's slightly smaller 7.5pt header.
Tests: sensor-check + compliance + geo-scale + fft all green (15). The
test_bw_ascii_report failures are pre-existing (gitignored decode-re fixtures
absent in this worktree), unrelated to this change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
The sensor-check strip used a symmetric ±max scale, so the one-sided geophone
ring-downs (a dip to ~-990 with the baseline at 0) sat in the bottom half of
each mini-box with the top half blank — visibly off next to Blastware. Scale
each mini-plot to its actual data range with a small pad instead, and draw a
faint zero baseline, so the ring-downs and the mic pulse train fill their boxes
the way BW draws them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
Match Blastware's layout, measured off the reference PDF: the sensor-check
strip shares a border with the main waveform panel (no gap between them), and
the per-lane "0.0" baseline labels sit to the RIGHT of the strip. Previously
the strip floated with a gap and the "0.0" label overprinted the strip's left
edge. Purely layout — the traces and decode are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
Document the feat/fft-series3 work under Unreleased: Blastware-compatible
channel FFT, the USBM RI8507/OSMRE compliance chart on the event-report PDF,
the decoded sensor self-check strip + Frequency/Overswing sub-rows, and the
seismo_lab Inspector hex reader — plus the two report-panel fixes (tick
collision, header serial fit). Additive, no .h5/DB change or backfill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
The Thor/Micromate (series-4) IDFW binary carries the sensor self-check in its
fixed-header region (before the waveform body), as up to four records tagged
01 0e 3c/3d/3e/3f — the SAME channel ids as series-3 (Tran/Vert/Long/MicL).
Unlike series-3's delta-coded trailing block, series-4 stores each trace as a
raw int16-BE array after an 18-byte record header (2-byte sample count at
offset +8). Three-channel (mic-disabled) units carry only 3c/3d/3e.
New micromate/sensor_check.py: decode_idf_sensor_check(raw) locates the record
chain (id-ordered marker run, so a stray body match can't chain) and reads each
trace's int16 samples → {Tran,Vert,Long[,MicL]: [counts]}, or {} when absent.
Reverse-engineered + validated against 4 UM oracle events (added as fixtures):
clean geophone ring-downs on all, mic pulse trains on the 4-channel units,
correctly no MicL on the two 3-channel units. Validated by shape + cross-event
consistency (no Thor report strip to exact-match, unlike series-3's BW reports).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
Make the sensor self-check a first-class part of the standardized decoded event
so SFM stops decoding it at report time — device-agnostic, per the store's
decoder→standardized-.h5→SFM model.
* Event gains a `sensor_check` field; both decoders attach the traces where
they set raw_samples — series-3 in event_file_io.read_blastware_file
(minimateplus.sensor_check), series-4 in waveform_store's IDF path
(micromate.sensor_check). Covers ingest and backfill (both re-decode).
* event_hdf5 bumps schema_version 1→2 and writes an optional /sensor_check
group (raw counts, int32, per channel present). read_event_hdf5 returns
it; plot_json_from_hdf5 carries it as a top-level key. Old v1 files still
read cleanly (no group → None), so nothing breaks before the backfill.
* gather_report_data reads sensor_check_waveforms from the .h5 and drops the
report-time series-3 decode — the report no longer reaches into a decoder,
and a series-4 event now lights up the same strip automatically.
Stored as raw counts (a shape diagnostic, rendered fit-to-box): the per-series
count scale differs and a physical mic unit is ill-defined, so conversion would
add complexity for no display benefit — easy to add later if a numeric use
appears.
Tests: .h5 roundtrip + backward-compat + plot_json + real series-3 decode
attaches to the Event.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
Complete the sensor-check standardization: existing events need their .h5
regenerated to gain the v2 /sensor_check group.
* backfill_thor_events.py attaches the decoded series-4 traces
(micromate.sensor_check) on its own IDF decode path, mirroring
save_imported_idf, so regenerated Thor .h5 files get the group. Series-3
backfill needs no change — it re-decodes via read_blastware_file, which now
attaches the traces itself.
* TOOL_VERSION 0.30.0 → 0.31.0 so the standard backfill regenerates every
event (no --force): the tool now produces the /sensor_check group. Purely
additive — no decoded value changes.
* CHANGELOG (Unreleased): sensor-check now series-3 + series-4, standardized
into the .h5 (schema v2), with the ⚠ backfill note; FFT + compliance stay
no-backfill (they read existing .h5 samples).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
A unit whose geophone offset has grown past its trigger level records
back-to-back and, with ACH set to "after event recorded", re-dials every
time. The wedged_unit_recovery runbook handles that by reaching the unit
inbound and clearing the modem's Destination Address so it stops dialing.
That fails when the device is wedged mid-modem-init. BE12599 (2026-09-16)
sat repeating a 29-byte AT setup string — ATQ1/ATE0/ATS0=2, no ATD — every
75 s. The modem is in TCP data mode, never interprets it, never answers OK,
so the device never progresses into S3 mode and ignores every frame we send.
Worse, each attempt makes ALEOS log "tcpmode trying to send to invalid
socket" and re-run "Initialize Auto answer on port 9034", which orphans any
held inbound session — slow_drip reports a clean 120 s hold with
bytes_received=0 because the modem stopped bridging after the first re-init.
Inbound cannot win that race. But the modem auto-dials its Destination
whenever serial data arrives while closed, so pointing Destination at an
ach_server turns those 75 s attempts into a device-initiated session that
the modem bridges correctly.
Adds --stop-monitoring, --disable-ach and --rescue. They run as step 1.5,
after the handshake and before the event walk, each independently guarded so
a failure does not abort the download. Outcome is written to rescue.json.
Startup banner reports both, and warns when --restart-monitoring would undo
--stop-monitoring.
Prefer --stop-monitoring alone on first contact: --disable-ach stops the unit
calling, which is the only channel to a unit in this state, and halting the
recording ends the loop on its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qcu9ByJfuKBQxmrWb8rSrN
The wedged_unit_recovery runbook covered exactly one failure mode. BE12599
turned out to be a second one wearing the same symptoms, and the existing
procedure did not work on it.
Adds a "TWO failure modes" table up front so the next incident branches
correctly, and a full second-incident section covering what the ALEOS serial
debug log revealed: the device repeating a 29-byte AT modem-init string
(ATQ1/ATE0/ATS0=2, no ATD) every 75 s, never getting an OK because the modem
is in TCP data mode, and therefore never entering S3 mode at all. Inbound
cannot win against that, no matter how well framed.
Also records the two red herrings, since together they cost ~90 minutes:
the RV50 trusted-IP whitelist drops non-listed sources silently (presents as
a connect timeout, and Brian's dynamic dev IP had rotated off the list), and
sfm/server.py returns 502 for BOTH "Protocol error:" and "Connection error:",
so a 502 was misread as "TCP connected, device mute" and a theory built on it.
And the gotchas worth never re-deriving: slow_drip's send_error=null plus a
full duration is not success (only bytes_received > 0 is); stopping monitoring
removes the call-in trigger, so it costs you the channel; --events-only skips
the device-info step, so the serial is never read and ach_state keys on
peer:ephemeral_port, silently breaking dedup and re-downloading the same event
every session.
The plan doc captures the tool Brian wants built out of this — a rescue
listener with a real lifecycle and, critically, a confirmation gate before
shutdown, because leaving the modem's Destination pointed at a dead listener
is worse than never having started. Open questions are listed rather than
guessed at.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qcu9ByJfuKBQxmrWb8rSrN
The previous commit called BE12599 a second failure mode and claimed the
device "never enters S3 mode at all" and that no inbound work could reach it.
That was an overclaim built on a single slow_drip attempt, and Brian was right
to push back.
It is the same disease. Method B's step 1 worked fine on BE12599 — clearing
the Destination did stop the dial-outs. It was step 2 that did not land, on
one attempt, run ~90 s after a modem reboot with a dead session visible in the
log in that same window; BE9558H needed hours of attempts before one landed.
And the AT-init loop the ALEOS log revealed is almost certainly what BE9558H
was doing too — we just never turned on serial debug in May to look. The
device speaks S3 fine; it handshook cleanly the moment it had a session.
What is genuinely new is the cure, and it deserves to be the default rather
than a footnote. Racing a Stop into the gaps between dial-outs is a coin
flip. Intercepting is deterministic: the unit dials every ~75 s, so give it
somewhere to dial and answer it. It will not answer us because it is on the
phone — so be the one it calls.
Restructures accordingly: a "two cures" table up top, the intercept promoted
to Method A with its own procedure (listener before modem, stop at step 1.5,
drain before disabling ACH, restore the Destination and confirm it), and the
original inbound procedure kept intact as Method B for when there is no
listener the modem can reach.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qcu9ByJfuKBQxmrWb8rSrN
Brian asked what the standard is; there wasn't a written one, only a de facto
pattern in the history. This writes it down in CLAUDE.md and fixes the one
place the repo already diverged from it.
The rule: write the entry in the same commit as the work, under ## Unreleased;
cut the version on dev in a dedicated chore(release) commit; never touch the
changelog at a merge boundary. The entry goes in with the change because that
is the only moment you still know why.
Two additions beyond what the history already did:
No preamble under ## Unreleased. The themed opening paragraph gets written at
release time, when the whole release is visible and can be named honestly. The
current one proved the point — "Blastware Event/FFT-Report parity: the FFT,
the USBM compliance chart, and the sensor self-check" was accurate when the
first item landed and stopped being accurate once rescue-on-connect landed
under the same heading. Removed here; the release commit writes a new one
covering everything actually in the release.
And the operational consequence is now mandatory on any entry touching the
codec, the waveform store, or the DB — including when it is "none". This
repo's changelog is how future-you learns whether a deploy costs two hours on
the NAS, so silence is ambiguous and "none" is information. The old preamble's
load-bearing half is preserved as an explicit ### Migration block rather than
dropped with the prose around it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qcu9ByJfuKBQxmrWb8rSrN
Brian described the practice: Unreleased is the staging area for what is going
into the next release, and the version bump happens when enough has
accumulated to be worth shipping — not per commit, not per merge. The
convention already implied it ("never touch the changelog at a merge
boundary") but never said it outright.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qcu9ByJfuKBQxmrWb8rSrN
Reverses the "entry goes in with the work" rule from two commits ago. That was
wrong on the evidence: of the docs(changelog) commits in history, 3 of 4 in
seismo-relay and 2 of 4 in Terra-View were made directly on dev. The rule was
generalized from one unrepresentative commit rather than from the pattern.
It also caused the exact problem it was supposed to avoid. With four worktrees
in flight, every branch edits the same few lines at the top of CHANGELOG.md;
feat/ach-rescue-on-connect and feat/sensor-check-h5 collide on that file and
nothing else. Writing the entry once, on dev, after the merge removes the
whole conflict class.
The second benefit is accuracy: an entry written after the merge describes
what actually landed, including anything that changed during conflict
resolution. The sensor-check branch is a live example — its Unreleased
preamble describes a release that no longer looks like that.
The failure mode of writing it later is forgetting, so the merge is explicitly
not finished until Unreleased is updated — same sitting, reconstructed from the
branch commit messages.
Unchanged: no preamble under Unreleased, the mandatory operational consequence,
and cutting the version on dev when ready to ship to main.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qcu9ByJfuKBQxmrWb8rSrN
Sensor self-check standardized into the .h5 (schema v2, /sensor_check group),
decoded for both series-3 and series-4, plus the Thor backfill script.
CHANGELOG resolved per the convention adopted today: the incoming Unreleased
preamble was dropped rather than reconciled — no preamble under Unreleased, the
theme gets written at release time — and its load-bearing half was folded into
### Migration, which said "None" and is now false.
That block now states the real cost: .h5 schema v1 -> v2, TOOL_VERSION 0.31.0
so the standard backfill picks the traces up with no --force, and ~2 h on the
NAS. The FFT, the compliance chart and the ach_server rescue flags still owe
nothing.
The branch's rewritten "Sensor self-check — both series" entry merged cleanly
and is kept.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qcu9ByJfuKBQxmrWb8rSrN
Cuts Unreleased to v0.31.0 and writes the theme now that the whole release is
visible, per the convention adopted today.
Two threads landed. Blastware Event/FFT-Report parity — the FFT, the USBM
RI8507 compliance chart, and the sensor self-check decoded for both series and
standardized into the .h5 (schema v2, /sensor_check). And the ach_server rescue
flags out of the BE12599 field emergency, which invert the wedged-unit recovery:
answer the unit's call instead of racing a Stop into the gaps between its
dial-outs.
Version stamped in pyproject.toml, CLAUDE.md and README.md. TOOL_VERSION was
already at 0.31.0 — it came in with the sensor-check work, and it is what makes
the backfill pick up the new /sensor_check group without --force.
⚠ This release owes prod a backfill: .h5 schema v1 -> v2, ~2 h on the NAS.
Stated in the Migration block.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qcu9ByJfuKBQxmrWb8rSrN
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Release v0.31.0 — report parity + the inverted rescue
Ships two releases' worth: prod is on v0.29.0, so this carries v0.30.0 and
v0.31.0 together. 31 commits.
v0.31.0 — report parity + the inverted rescue
Blastware Event/FFT-Report parity. The FFT, the USBM RI8507 compliance
chart and the sensor self-check now render on the event report,
reverse-engineered against BE12844 (MiniMate Plus) and UM (Thor) events. The
FFT matches Blastware to the exact bin on all 28 oracle channels. The sensor
check is decoded for both series and standardized into the
.h5(schema v2, a new
/sensor_checkgroup) so SFM serves itdevice-agnostically rather than decoding at report time.
Field rescue for a runaway unit.
bridges/ach_server.pygains--stop-monitoring/--disable-ach/--rescue, which invert thewedged-unit recovery: rather than racing a Stop into the gaps between a unit's
dial-outs, point the modem's Destination at our own ACH server and answer the
call. Proven in production on BE12599 — the stop landed on the first call-in
and held. Runbook rewritten around it.
Plus the Inspector (annotated hex reader for series-3 binaries), two
event-report layout fixes, and a written changelog/release convention in
CLAUDE.md.v0.30.0 — series-4 correctness (never shipped to prod)
The Thor / Micromate counterpart to v0.26.0's series-3 work. Decoder verified
per-sample against Thor's own CSV exports: 459 files, 3,807,158 / 3,807,165
samples exact across three ground-truth corpora; production IDFW 575/575,
zero truncations, zero decode failures. Series-3 re-verified unchanged at
14,338/14,338.
⚠ Deploy owes a backfill — budget ~2 hours
Both releases owe one, and they combine into a single pass.
.h5schema v1 → v2 — existing files carry no/sensor_checkgroup, sotheir reports show no sensor-check strip until regenerated.
TOOL_VERSIONis 0.31.0, so the standard backfill regenerates everyevent and picks the traces up with no
--force.scripts/backfill_thor_events.pyfor series-4 (it already owed thev0.30.0 Thor backfill — this rides along) and the series-3 sidecar/shape
backfill for MiniMate events.
Purely additive — no decoded value changes, and v1
.h5files read fine untilthen (empty strip).
Deploy
Build the image from
mainafter merging, push to the Gitea registry,recreate the
sfmservice on the NAS. See the Gitea Registry +Container-Manager Prod Deploy runbook.
No schema change to the DB. No Terra-View coupling — nothing here changes a
/db/*shape it reads.Verified against a second Thor corpus (9-10-26-csv-req: UM11402, UM12947, UM20147) with per-sample CSV exports: 139/139 waveforms exact (1,273,380/1,273,380 samples) and 877/877 histograms within 2% of Thor's reported PPV -- up from 66.9% and 56.6%. Some units run with the microphone disabled, which changes two structural things that were both hardcoded to the 4-channel shape: - Waveform body head sat below the scan floor. A 3-channel unit has a shorter fixed header and puts its record chain head at 0x0dba, under the old _BODY_SCAN_FLOOR of 0x0E00. The scan could not see it and fell through to the Vert segment-0 record, decoding a body shifted one position around the channel rotation -- Vert came up exactly 512 samples short. Floor lowered to 0x0C00. The body-offset scoring also had to stop requiring four channels, or `equal` is permanently False for these events and the pick falls back to raw sample count. - Histogram interval record is 56 bytes, not 72. It is 16 * n_channels + 8, and is not inferable from the segment length alone. The interval count now comes from the segment's cumulative counter (n = counter - prev_counter) and the stride is derived from it. Assuming 72 read 7 intervals out of every 10-interval segment, then walked off alignment into garbage that decoded as ~10 in/s peaks -- inflating some files' PPV by up to 191,000%. Also recovers 4 files that previously decoded no intervals at all. Combined across both corpora: 292/292 waveform files, 2,330,916/2,330,916 samples exact. Production IDFW truncations 41 -> 22. Series-3 unaffected (no shared-codec change in this commit; last full run 14,338/14,338). Known open, diagnosed but NOT verified: the remaining 22 unequal + 1 failing production IDFW files (all UM12947, 2025-07-14..09-23) stop the block walker on tag 40 0c. data_block_len() caps the 40 NN int16 block at NN > 0x08 while those files use NN up to 196. Both verified corpora only ever use NN in {1,2,3,4,8}, so the cap is untested there and lifting it leaves both at 100.000% -- which is not evidence it decodes these correctly. Deliberately not shipped; needs Thor CSV exports for UM12947 in that date range. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmLdata_block_len() rejected any `40 NN` block with NN > 0x08. That guard had no evidence behind it: every corpus available when it was written used only NN in {1,2,3,4,8}, so it was never exercised. Loud UM12947 events use NN of 12, 16, 20 ... up to 196. Because walk_body/run stop at the first unrecognised tag rather than raising, rejecting those blocks surfaced as silently short channels -- e.g. Tran 1812 / Vert 2132 / Long 2324 on a file whose export carries 2324 for all three. The real bound is the buffer; the caller additionally clamps to the record end. Verified against Thor's own CSV exports for UM12947 (2025-07-14 .. 09-25, 167 waveforms, supplied as CSV.zip): length mismatches 22 -> 0 per-sample exact 1,476,242 / 1,476,249 These are NOT truncated recordings, which was the competing hypothesis -- the exports carry the full sample count. tests/test_waveform_codec.py asserted the cap as intended behaviour. That assertion encoded an assumption, not a verified fact, and is replaced with one pinning the opposite plus the evidence. Across all three ground-truth corpora: 459 waveform files, 3,807,158 / 3,807,165 samples exact. Production IDFW is now 575/575 with zero truncations and zero decode failures (median PPV error -0.0007% across 8 units). Series-3 re-verified unchanged at 14,338/14,338. The 7 residual samples each differ by one 4th-decimal tick and are Thor's own rounding: intersecting the per-sample rounding constraints over that corpus is infeasible (binding pair contradict by 2.3e-11, 7e-5 relative), so no single linear LSB reproduces every printed value. _GEO_LSB_IPS is already pinned to ~1e-11; do not retune it to chase these. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmLThe compliance chart on the event-report PDF was correctly drawn but far too small ("it's tiny") — a ~2.6in square dropped between the mic and stats rows. Resize + reposition it to match Blastware's Event Report, measured directly off a BW reference PDF (n844lqhbzt0w) rasterized with fitz: the chart data box now spans figure fractions x[0.489,0.951] y[0.502,0.867] — a ~3.9in square running from just under the header down through the stats band, hard against the right page margin, exactly as BW draws it. Title updated to BW's "USBM RI8507 And OSMRE". To clear room for the BW-sized chart (waveform layout only): * _draw_stats_table gains bbox_width/col_widths/fontsize params; the waveform layout packs the Tran/Vert/Long table into the left ~0.42 so its columns no longer sit under the chart. Histogram layout keeps the wider defaults (byte-identical output; it has no compliance chart). * the mic block's long "Channel Test Passed (Freq … Amp … mv)" line gets a tighter indent + one-point-smaller font so it ends before the chart's left edge instead of running behind it (_kv gains a fontsize param). * the Peak Vector Sum line left-aligns under the compacted table (one pt smaller) so it clears the chart's bottom-left tick labels. Chart placement centralized in the _COMPLIANCE_BOX constant. No change to the compliance math, the scatter, or the histogram report. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzfThe Blastware Event Report draws a "Sensor Check" strip on the right of the waveform panel — the little traces the unit records when it pulses each sensor before monitoring. Those live in the series-3 binary's trailing block, after the main waveform record-chain and the per-channel calibration records, as four length-prefixed records tagged 0x3c-0x3f (Tran/Vert/Long geophone ring-downs + MicL pulse train). Reverse-engineered against 7 BE12844 oracle events. New minimateplus/sensor_check.py: decode_sensor_check(raw) locates the record chain (validated by walking the ids 0x3c->0x3f via their length prefixes) and decodes each record's delta stream (payload[20:len-8]) with the same 10/20/30/00 delta-block tags as the main waveform codec, from an anchor of 0. Returns {Tran,Vert,Long,MicL: [samples]} in raw 16-count units, or {} when absent. Validated: mic pulse-train zero-crossing frequency = 20.1 Hz (exact match to BW's mic Channel Test freq); geophone ring-downs are consistent ~-990 raw deflections that damp to a ~-310 settle across all 7 events (a fixed calibration pulse, so near-identical every run). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzfWire the decoded sensor self-check waveforms (previous commit) onto the event report PDF, and fold in two related waveform-panel cleanups. Sensor-check strip (matches Blastware): * ReportData gains sensor_check_waveforms; gather_report_data decodes it from the retained raw BW binary (store.paths_for) at report time — no ingest or .h5 change, waveform events only. * _draw_waveform_subplot now draws a narrow right-hand strip of per-channel mini-plots (MicL pulse train + Long/Vert/Tran ring-downs) aligned to the lanes, captioned "Sensor Check". * stats table gains the "Frequency" / "Overswing Ratio" sub-rows under Sensor Check (7.5/7.7/7.3 Hz, 3.6/3.3/3.7), formatted to 1 decimal like BW; values come from the already-parsed sensor_check scalars. Cleanups (pre-existing, in the same panel): * fix the stacked-lane y-tick collision — adjacent lanes' -1.0 / 1.0 labels overprinted at the shared boundary; prune the extreme ticks (MaxNLocator prune="both") so each lane shows clean interior ticks only. * fix the header serial+firmware line running off the right page edge — tighter right-column indent + BW's slightly smaller 7.5pt header. Tests: sensor-check + compliance + geo-scale + fft all green (15). The test_bw_ascii_report failures are pre-existing (gitignored decode-re fixtures absent in this worktree), unrelated to this change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzfThe Thor/Micromate (series-4) IDFW binary carries the sensor self-check in its fixed-header region (before the waveform body), as up to four records tagged 01 0e 3c/3d/3e/3f — the SAME channel ids as series-3 (Tran/Vert/Long/MicL). Unlike series-3's delta-coded trailing block, series-4 stores each trace as a raw int16-BE array after an 18-byte record header (2-byte sample count at offset +8). Three-channel (mic-disabled) units carry only 3c/3d/3e. New micromate/sensor_check.py: decode_idf_sensor_check(raw) locates the record chain (id-ordered marker run, so a stray body match can't chain) and reads each trace's int16 samples → {Tran,Vert,Long[,MicL]: [counts]}, or {} when absent. Reverse-engineered + validated against 4 UM oracle events (added as fixtures): clean geophone ring-downs on all, mic pulse trains on the 4-channel units, correctly no MicL on the two 3-channel units. Validated by shape + cross-event consistency (no Thor report strip to exact-match, unlike series-3's BW reports). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzfMake the sensor self-check a first-class part of the standardized decoded event so SFM stops decoding it at report time — device-agnostic, per the store's decoder→standardized-.h5→SFM model. * Event gains a `sensor_check` field; both decoders attach the traces where they set raw_samples — series-3 in event_file_io.read_blastware_file (minimateplus.sensor_check), series-4 in waveform_store's IDF path (micromate.sensor_check). Covers ingest and backfill (both re-decode). * event_hdf5 bumps schema_version 1→2 and writes an optional /sensor_check group (raw counts, int32, per channel present). read_event_hdf5 returns it; plot_json_from_hdf5 carries it as a top-level key. Old v1 files still read cleanly (no group → None), so nothing breaks before the backfill. * gather_report_data reads sensor_check_waveforms from the .h5 and drops the report-time series-3 decode — the report no longer reaches into a decoder, and a series-4 event now lights up the same strip automatically. Stored as raw counts (a shape diagnostic, rendered fit-to-box): the per-series count scale differs and a physical mic unit is ill-defined, so conversion would add complexity for no display benefit — easy to add later if a numeric use appears. Tests: .h5 roundtrip + backward-compat + plot_json + real series-3 decode attaches to the Event. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzfComplete the sensor-check standardization: existing events need their .h5 regenerated to gain the v2 /sensor_check group. * backfill_thor_events.py attaches the decoded series-4 traces (micromate.sensor_check) on its own IDF decode path, mirroring save_imported_idf, so regenerated Thor .h5 files get the group. Series-3 backfill needs no change — it re-decodes via read_blastware_file, which now attaches the traces itself. * TOOL_VERSION 0.30.0 → 0.31.0 so the standard backfill regenerates every event (no --force): the tool now produces the /sensor_check group. Purely additive — no decoded value changes. * CHANGELOG (Unreleased): sensor-check now series-3 + series-4, standardized into the .h5 (schema v2), with the ⚠ backfill note; FFT + compliance stay no-backfill (they read existing .h5 samples). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzfBrian described the practice: Unreleased is the staging area for what is going into the next release, and the version bump happens when enough has accumulated to be worth shipping — not per commit, not per merge. The convention already implied it ("never touch the changelog at a merge boundary") but never said it outright. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qcu9ByJfuKBQxmrWb8rSrN