From 91b9b4578c7c521cbde6b0a2684eabfe19817756 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 7 Sep 2026 23:26:42 +0000 Subject: [PATCH 01/28] fix(pdf): shared geo Y scale across Long/Vert/Tran (was per-trace) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf --- sfm/report_pdf.py | 29 +++++++++----- tests/test_report_pdf_geo_scale.py | 61 ++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 10 deletions(-) create mode 100644 tests/test_report_pdf_geo_scale.py diff --git a/sfm/report_pdf.py b/sfm/report_pdf.py index 25859d1..60dca98 100644 --- a/sfm/report_pdf.py +++ b/sfm/report_pdf.py @@ -777,6 +777,19 @@ def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None: dt_s = (rd.dt_ms or (1000.0 / sr)) / 1000.0 t0_s = (rd.t0_ms if rd.t0_ms is not None else 0.0) / 1000.0 + # Shared geo scale across Long/Vert/Tran (matches the event modal + BW's + # single amp/div): all three geo lanes use ONE Y scale = the max |sample| + # across them (padded, floored), so relative amplitudes stay honest instead + # of each lane auto-zooming to its own peak. Mic keeps its own (psi) scale. + GEO_FLOOR_INS = 0.05 + _geo_amax = 0.0 + for _gch in ("Long", "Vert", "Tran"): + for _x in (rd.channels.get(_gch) or []): + _a = abs(_x) + if _a > _geo_amax: + _geo_amax = _a + geo_shared = max(_geo_amax * 1.10, GEO_FLOOR_INS) + last_idx = len(order) - 1 for i, ch in enumerate(order): ax = fig.add_subplot(inner[i]) @@ -786,10 +799,10 @@ def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None: if values: color = _channel_axis_color(ch) ax.plot(times, values, color=color, linewidth=0.5) - # Symmetric y-axis for geo; zero-anchored for mic. + # Geo: one shared symmetric scale (honest relative amplitudes). + # Mic: symmetric on its own psi scale (different unit). if ch != "MicL": - amax = max((abs(v) for v in values), default=0.001) - ax.set_ylim(-amax * 1.10, amax * 1.10) + ax.set_ylim(-geo_shared, geo_shared) else: amax = max((abs(v) for v in values), default=0.001) ax.set_ylim(-amax * 1.10, amax * 1.10) @@ -824,13 +837,9 @@ def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None: # and find peak geo amplitude for the geo amp/div setting. total_s = times[-1] - times[0] if values else 0 div_s = total_s / 10 if total_s > 0 else 0 - geo_amp_div = "—" - for ch in ("Tran", "Vert", "Long"): - v = rd.channels.get(ch) or [] - if v: - amax = max(abs(x) for x in v) - geo_amp_div = f"{(amax * 1.1 * 2) / 10:.3f}" - break + # Footer div value reflects the SHARED geo scale (so it's correct for all + # three lanes, not just whichever one happened to be checked first). + geo_amp_div = f"{(geo_shared * 2) / 10:.3f}" if _geo_amax > 0 else "—" fig.text( 0.11, 0.030, f"Time(Seconds) {div_s:.2f} sec/div Amplitude Geo: {geo_amp_div} in/s/div Mic: 0.001 psi(L)/div", diff --git a/tests/test_report_pdf_geo_scale.py b/tests/test_report_pdf_geo_scale.py new file mode 100644 index 0000000..6511f03 --- /dev/null +++ b/tests/test_report_pdf_geo_scale.py @@ -0,0 +1,61 @@ +"""The event-report PDF must draw the three geo channels on ONE shared Y scale +(max |sample| across Long/Vert/Tran, floored), not each trace auto-zoomed to its +own peak — so relative amplitudes are honest and a small channel doesn't fill its +lane looking as big as a large one. Mirrors the event-modal waveform behaviour. +""" +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import pytest + +from sfm.report_pdf import ReportData, _draw_waveform_subplot + + +def _draw(channels): + rd = ReportData( + channels=channels, + sample_rate_sps=1024, + dt_ms=1000.0 / 1024, + t0_ms=0.0, + ) + fig = plt.figure() + cell = fig.add_gridspec(1, 1)[0, 0] + _draw_waveform_subplot(fig, cell, rd) + by_label = {ax.get_ylabel(): ax for ax in fig.axes} + try: + yield_ = {k: by_label[k].get_ylim() for k in ("Long", "Vert", "Tran", "MicL")} + finally: + plt.close(fig) + return yield_ + + +def test_geo_traces_share_one_y_scale(): + # Tran is the biggest geo channel (0.35); Long 0.10, Vert 0.02. + ylims = _draw({ + "Long": [0.10, -0.10, 0.0], + "Vert": [0.02, -0.02, 0.0], + "Tran": [0.35, -0.35, 0.0], + "MicL": [0.0005, -0.0005, 0.0], + }) + # Shared scale = max(0.35 * 1.10, floor 0.05) = 0.385, symmetric. + expected = pytest.approx(0.385, rel=1e-6) + for ch in ("Long", "Vert", "Tran"): + lo, hi = ylims[ch] + assert hi == expected, f"{ch} top ylim {hi} != shared 0.385" + assert lo == pytest.approx(-0.385, rel=1e-6), f"{ch} bottom ylim {lo}" + # All three geo lanes identical. + assert ylims["Long"] == ylims["Vert"] == ylims["Tran"] + # Mic keeps its own (much smaller) scale — not lumped into the geo max. + assert ylims["MicL"][1] < 0.01 + + +def test_geo_shared_scale_has_floor(): + # A tiny event (all geo well under the floor) clamps to the 0.05 floor. + ylims = _draw({ + "Long": [0.008, -0.008, 0.0], + "Vert": [0.006, -0.006, 0.0], + "Tran": [0.010, -0.010, 0.0], + "MicL": [0.0001, -0.0001, 0.0], + }) + for ch in ("Long", "Vert", "Tran"): + assert ylims[ch][1] == pytest.approx(0.05, rel=1e-6), f"{ch} not floored" -- 2.54.0 From 726c2ce1b5184fa9bf60c70eab122ff1432fe22b Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 10 Sep 2026 18:06:14 +0000 Subject: [PATCH 02/28] fix(series4): Thor/Micromate decoder is now per-sample exact Verified against Thor's own CSV exports, which carry a per-sample four-column block beside every binary (CSV/.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) Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL --- CHANGELOG.md | 67 ++++++++ CLAUDE.md | 78 +++++++--- docs/idf_protocol_reference.md | 156 ++++++++++++++++++- micromate/idf_file.py | 196 +++++++++++++++++++----- minimateplus/waveform_codec.py | 38 ++++- scratch/verify_thor_against_csv.py | 228 +++++++++++++++++++++++++++ sfm/waveform_store.py | 15 +- tests/test_idf_binary_codec.py | 237 +++++++++++++++++++++++++++++ 8 files changed, 949 insertions(+), 66 deletions(-) create mode 100644 scratch/verify_thor_against_csv.py create mode 100644 tests/test_idf_binary_codec.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d0ec631..63f4bc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,73 @@ All notable changes to seismo-relay are documented here. --- +## Unreleased + +### Fixed — series-4 (Thor / Micromate) decoder is now per-sample exact + +Verified against **Thor's own CSV exports**, which carry a per-sample +four-column block beside every binary (`CSV/.IDFW.csv`) — 1,012 paired +files that had been sitting in the corpus unused. Previous notes asserted +"Thor has no ASCII ground truth", which is why the decoder stayed pinned to a +superseded walker with an unverifiable scale factor. + +| metric | before | after | +|---|---|---| +| 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 PPV median error (8 units) | −3.3% | **−0.001%** | +| decode cost | — | 6 ms/file | + +Four independent root causes: + +- **Geo LSB was `0.0003`, should be `0.000310308`** — the old value was Thor's + 4-decimal *display rounding* of the LSB 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`) in unwritten IDFH slots. Applies to IDFH too, which 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. + Any run over ~4 hours lost its tail, often the part holding the peak. + 540/858 corpus files affected. +- **Record mode `00 00` (raw int16, 10-byte header) was unhandled** — the + record fell through the dispatch, silently dropping each channel's first + 512 samples. This produced the long-standing "loud events truncate" + symptom. `MODE_ABSOLUTE` is now also accepted as a segment-0 preamble. +- **Body-offset search matched `00 02 00` inside record headers** — picking a + candidate part-way down the chain, which decodes a rotation-shifted body + that drops each channel's segment 0. The search now anchors on record + headers and takes the chain head. + +Also fixes the separately-tracked "UM-series decodes ~1000× low" bug +(`UM11402_20260406130113.IDFW` now matches its device report exactly). + +Series-3 re-verified **unchanged at 14,338/14,338 exact** after the shared +`waveform_codec` change. + +⚠ **This is a codec change: the Thor store owes a regeneration.** Run +`scripts/backfill_thor_events.py` (bump `TOOL_VERSION` first, or pass +`--force`), DB backup first. All stored series-4 `.h5`/sidecar peaks are +currently ~3.3% low, and histogram peaks for runs over ~4 hours may be +badly low. + +⚠ **Thor's histogram PPV has a 0.0050 in/s display floor** — 41.4% of prod +IDFH sidecars report a component PPV larger than their own vector sum. On +quiet files the decoder is now *more* accurate than that reference. + +New: `scratch/verify_thor_against_csv.py`, `tests/test_idf_binary_codec.py` +(10 tests, fixtures under `tests/fixtures/thor-idf/`). + +**Known open:** 41/575 production IDFW files (7%, mostly UM12947/UM20147) +still decode with unequal channel lengths and also fail metadata extraction — +a different header variant with no Thor export in the store. Pull their CSV +exports before attempting a fix. + +--- + ## v0.29.0 — 2026-09-04 First release to reach prod since **v0.27.0**, so it ships **both** the diff --git a/CLAUDE.md b/CLAUDE.md index 6d4c6ea..9605c71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,9 +24,23 @@ Read this first when picking the project back up. Independent corroboration of the 32000-count scale: 19,244 healthy channel-events sit at a pre-trigger floor of exactly 0.000 (62.7%), 94.5% within ±1 quantisation unit, median +0.0000 — no zero-point bias. -- **Series-4 (Thor / Micromate) is NOT verified.** UM-series sits at ~48% - against device peaks with a ~1.7% systematic bias and a near-zero tail. - Thor IDFW is pinned to `decode_waveform_legacy` deliberately. +- **Series-4 (Thor / Micromate) is now verified per-sample (2026-09-10).** + **1,057,536 / 1,057,536** geo samples across all 153 genuine Thor waveform + files reproduce Thor's own CSV export exactly; IDFH peaks are within 2% on + 858/858 (median -0.004%). The ground truth was in the corpus all along — + Thor writes `CSV/.IDFW.csv` beside each binary with a **per-sample** + four-column block. Harness: `scratch/verify_thor_against_csv.py`. + Four bugs, all fixed: geo LSB was `0.0003` (display rounding of the real + `0.000310308`, so every sample read **3.3% low**); the IDFH segment + validator required a zero counter high byte, **capping every histogram at + 250 intervals**; record mode `00 00` (raw int16) was unhandled, silently + dropping each channel's first 512 samples; and the body-offset search + matched `00 02 00` *inside* record headers, decoding a rotation-shifted + body. IDFW is no longer pinned to `decode_waveform_legacy`. + Series-3 re-verified unchanged at 14,338/14,338 after the shared-codec + change. **Still open:** 41/575 prod IDFW files (7%, mostly UM12947) decode + with unequal channel lengths and have no Thor export — pull their CSVs + before touching it. - **Open, not blocking:** 14 sensitive-range files show an exact 8x (= 10.0/1.25) units discrepancy; `scripts/backfill_sidecars.py --force` also inserts DB rows for store files that have none (one-time per store) and the @@ -120,20 +134,34 @@ should not import from `sfm/`, must not touch a DB, and have no I/O beyond reading files passed as arguments. Keep them pure — both tiers can then depend on them without circularity. -#### Thor IDF binary codec (2026-05-28) +#### Thor IDF binary codec (updated 2026-09-10) `micromate/idf_file.read_idf_file()` decodes both Thor IDFW -(waveform) and IDFH (histogram) binaries. +(waveform) and IDFH (histogram) binaries. **Verified per-sample +against Thor's own CSV exports** — see +`scratch/verify_thor_against_csv.py`. -- **IDFW** reuses `decode_waveform_v2()` on the body at fixed file - offset `0x0f1f`. Sample fidelity is 87–99% byte-exact on quiet - events; loud events hit the BW codec's known walker-stops-early - limitation. -- **IDFH** has its own segment-based decoder: `[len_be][0a 00 00 00] - [00 NN][05 3f]` + N × 72-byte interval records (4 × 16-byte - per-channel min/max/halfp). All 859 Thor IDFH corpus files - decode (181,071 intervals); peak matches sidecar within ~1.8% - (ADC quantization). +- **IDFW** uses the series-3 record-chain `decode_waveform_v2()`. The + body offset is **not** fixed: it is ` + 7`, found + by `_find_waveform_body_offset()` anchoring on record headers. All + **153/153** genuine Thor waveform files decode per-sample exact + (1,057,536/1,057,536 samples). +- **IDFH** segment header is `[len_be][0a 00 00 00][counter_be][05 3f]`, + where `counter` is a **uint16 cumulative interval index** — it must + not be constrained to a zero high byte (that capped histograms at 250 + intervals). Intervals whose `min > max` on all channels are unwritten + slots carrying a ±full-scale seed and are skipped. 858/858 files land + within 2% of Thor's PPV (median -0.004%). +- **Geo LSB is `0.000310308` in/s per count** (full scale 10.0 in/s = + 32226.05 counts). Series-3's 32000-count scale does NOT apply. +- **Record modes** are `02 00` deltas (14 B header), `01 00` absolute, + `00 03` raw 12-bit, and `00 00` **raw int16** (all 10 B headers). + `01 00` and `00 00` are also valid as the implicit segment-0 preamble. + +⚠ **Thor's histogram PPV has a 0.0050 in/s display floor.** 41.4% of +prod IDFH sidecars report a component PPV exceeding their own vector +sum — impossible. On quiet files our decode is *more* accurate than +the reference; do not "fix" the decoder to match it. The two outlier `BE9439_*` files in the Thor example corpus are actually Series III Blastware binaries that share the `.IDFW`/`.IDFH` @@ -399,15 +427,19 @@ with zero mismatches. Before: 1 of 1196. `BE12599/N599LPWJ.980W` @849, `BE9558/K558LOF2.820W` @1485. (The series-3 histogram codec was fixed 2026-08-25 — see below.) -- **Micromate (UM-series) IDF decode is ~1000× low** — e.g. - `UM11402_20260406130113.IDFW` gives a Tran peak of 0.0009 in/s against - a device-reported 1.1168. The Thor IDF path decodes sanely, so this - is UM-specific. -- **Thor IDF per-count LSB** — after the 32000 geo full-scale - correction, series-4 Thor peaks sit at a median 0.983 of the - device-reported peak (was 0.960 under 32768). Closer but not exact; - Thor likely uses its own per-count LSB rather than the BW - 16-count/0.005 in/s convention. +- ~~**Micromate (UM-series) IDF decode is ~1000× low**~~ — FIXED 2026-09-10. + `UM11402_20260406130113.IDFW` now decodes Tran 1.1168 / Vert 4.3220 / + Long 0.9135, matching the device report exactly. Root cause was the + body-offset search landing inside a record header plus the unhandled + `00 00` record mode, not anything UM-specific. +- ~~**Thor IDF per-count LSB**~~ — RESOLVED 2026-09-10. The 0.983 ratio was + exactly `0.0003 / 0.000310308`. Thor's geo LSB is **0.000310308 in/s per + count** (full scale 10.0 in/s = 32226.05 counts), pinned to ±6e-11 by + intersecting 991,415 rounding constraints from Thor's own exports and + corroborated by the ±full-scale seed (`±32226`) left in unwritten IDFH + interval slots. Series-3's 32000-count scale does **not** carry over. + Note `10.0/32226` is very slightly wrong — see + `docs/idf_protocol_reference.md`. ### Decoded sample counts (across the fixture bundle) diff --git a/docs/idf_protocol_reference.md b/docs/idf_protocol_reference.md index aef3c69..b181f28 100644 --- a/docs/idf_protocol_reference.md +++ b/docs/idf_protocol_reference.md @@ -6,7 +6,15 @@ Series IV event-file format. Sibling to Series III "Rosetta Stone") — this doc holds what we know so far and the open questions still to crack. -**Status (2026-05-28):** ASCII text sidecar fully decoded (1,014 +> ⚠ **The "Status (2026-05-28)" block below is SUPERSEDED.** Its geo LSB +> (0.0003), its IDFH scale (`/32768 × 10`), its fixed body offset (`0x0f1f`) +> and its "87–99% byte-exact / loud events truncate" caveat were all wrong or +> incomplete. See **[Verified against Thor's own exports +> (2026-09-10)](#verified-against-thors-own-exports-2026-09-10)** — the +> decoder is now per-sample exact on 1,057,536/1,057,536 samples. The block +> is kept only for the reverse-engineering trail. + +**Status (2026-05-28, SUPERSEDED):** ASCII text sidecar fully decoded (1,014 sample files round-trip). **Thor IDFW** binary now decodes via `micromate.idf_file.read_idf_file()` — reuses the BW segment-rotated block codec verbatim at fixed body offset `0x0f1f`; metadata (serial, @@ -44,6 +52,152 @@ signature and raises `NotImplementedError` pointing callers at time-of-peak); the two uint16 fields (probably PVS contributions); 8-byte interval tail (PVS data); mic dB(L) exact conversion constant. +## Verified against Thor's own exports (2026-09-10) + +**The series-4 decoder is now per-sample exact.** 1,057,536 / 1,057,536 +geophone samples across all 153 genuine Thor waveform files reproduce Thor's +own CSV export exactly; histogram peaks land within 2% on 858/858 files +(median error −0.004%). + +### Ground truth — it was there all along + +Thor writes `TXT/`, `CSV/`, `XML/` and `PDF/` exports beside every binary: + +``` +/UM13981_20220207084555.IDFW +/CSV/UM13981_20220207084555.IDFW.csv +``` + +The **CSV carries a per-sample block** — four columns (Tran, Vert, Long, Mic) +in in/s and psi, after the 2-column report header. That is the series-4 +equivalent of Blastware's `_ASCII.TXT` exports, and it gives 1,012 paired +files (152 IDFW + 860 IDFH). Earlier notes in this file and in +`micromate/idf_file.py` asserted "Thor has no ASCII ground truth in the +corpus"; that was wrong, and it is why the decoder sat pinned to a +superseded walker with a scaling constant nobody could check. + +Harness: `scratch/verify_thor_against_csv.py`. + +### Geo LSB = 0.000310308 in/s per count (NOT 0.0003) + +The old 0.0003 was read off the smallest non-zero sample in the exports — +but that is Thor's **4-decimal display rounding of the LSB, not the LSB**. +It read every series-4 geophone sample **3.3% low**. The quantisation +ladder gives it away: counts 1..6 export as 0.0003, 0.0006, 0.0009, 0.0012, +0.0016, 0.0019 — an LSB of exactly 0.0003 would end 0.0015, 0.0018. + +Each exported sample constrains the LSB to the window that rounds to its +printed value. Intersecting 991,415 such constraints gives + +``` +LSB ∈ [0.000310307933, 0.000310308057] width 1.2e-10 +``` + +so `_GEO_LSB_IPS = 0.000310308`, i.e. full scale 10.0 in/s = **32226.05 +counts**. Corroboration: an IDFH interval that never recorded keeps its +min/max accumulator at its ±full-scale seed, and that seed is +`(min=+32226, max=-32226)`. ⚠ The tempting closed form `10.0/32226` is +very slightly wrong — it lands 4.5e-10 above the feasible window and loses +78 boundary samples while never winning one. **Series III uses 32000 counts +for the same 10.0 in/s, so the two generations do not share a scale.** + +Independently confirmed on 8 production units (UM6047, UM11402, UM11719, +UM12947, UM13981, UM14133, UM20146, UM20147): every unit's median PPV error +against its device-reported peak moved from −3.3% to within ±0.03%. It is a +global constant, not a per-unit calibration. + +### IDFH segment header: the counter is a uint16, and it is cumulative + +``` +[length_be 2B][0a 00 00 00][counter_be 2B][05 3f] +``` + +`counter` is the **0-based cumulative index of the last interval in the +segment** — 9, 19, 29, ... for the usual 10-intervals-per-segment layout +(`length` = 730). + +The validator used to require `counter`'s high byte to be `0x00`. That +silently **capped every histogram at 250 intervals**: once the cumulative +counter passed 255 the high byte went non-zero and every later segment was +rejected. Any run longer than ~4 hours lost its tail — frequently the part +holding the event peak, so the file's PPV read low. **540 of 858 corpus +files were affected**; fixing it moved histogram peaks from 48.3% to 93.8% +within 0.5% of Thor's reported PPV. + +### Unwritten interval slots carry a ±full-scale seed + +An interval the device reserved but never wrote keeps `min = +32226`, +`max = -32226` on all four channels — `min > max`, impossible for real data. +Decoded naively it yields a 10.0 in/s peak on every channel and, being a +max-over-intervals, poisons the whole file's PPV. Rare but real: exactly 1 +of 497,611 corpus intervals, and it inflated that file's Long PPV from +0.0081 to 10.0 in/s. The inversion is all-or-nothing across channels (0 +partial cases), so requiring every channel to be inverted is a safe test. + +### Record mode `00 00` — raw int16 absolute (MODE_RAW16) + +The record chain's mode field at `off+8` takes a fourth value: + +| mode | meaning | header | +|---|---|---| +| `02 00` | deltas + two int16 anchors | 14 B | +| `01 00` | absolute, tagged blocks | 10 B | +| `00 03` | raw 12-bit absolute, untagged | 10 B | +| **`00 00`** | **raw int16 BE absolute, untagged** | **10 B** | + +A `MODE_RAW16` record with `length = 1032` carries exactly +`(1032 - 8) / 2 = 512` samples and reproduced Thor's export **512/512 +exactly** on first test. Thor uses it for segment 0 (the pre-trigger +window) on some events. Before this mode existed the record fell through +the dispatch unhandled, so the channel silently lost its first 512 samples — +which is what produced the "loud events truncate" symptom. + +`MODE_ABSOLUTE` is also valid as a **preamble** (the implicit segment-0 Tran +record); its tagged blocks start at `body[3]`, not `body[7]`, because its +header is 10 bytes rather than 14. + +### Body offset is not fixed at 0x0f1f — and 0x0f1f is really a record + 7 + +A "body offset" is ` + 7`, so that `body[0]` is the segment +index and `body[1:3]` is the mode. The canonical `0x0f1f` is simply the +record at `0x0f18`. + +Searching for the literal preamble `00 02 00` finds only MODE_DELTA bodies, +and worse, it **matches the `[seg][mode]` bytes inside any record header**, +so the scan could pick a candidate part-way down the chain. That decodes a +plausible-looking but rotation-shifted body which drops each channel's +segment 0 — the real cause of the remaining truncations. + +`_find_waveform_body_offset()` now anchors on record headers (the +` 00 00` signature at `+4`, validated with `is_record()`), +takes the **chain head** — a record no other record's length field points at +— and trial-decodes `head + 7`, preferring the candidate where all four +channels come out the same length. + +⚠ Do **not** scan for candidate preambles instead: `MODE_RAW16` is +`00 00`, so every run of three zero bytes looks like a body start and each +costs a full trial decode (~0.5 s/file measured, vs 6 ms/file now). + +### What is still open + +- **41 of 575 production IDFW files (7%)** still decode with unequal channel + lengths — signature `Tran/Long 3072, Vert 2560, MicL 0`, and + `sample_rate`/`record_time` also fail to extract, so their header layout + differs. Concentrated in UM12947 (32) and UM20147 (8). No Thor export + exists for them in the production store, so **do not guess a fix** — pull + the paired CSV exports for those events first. Their PPV is mostly still + right (median error −0.001%, 74.8% within 1%). +- Mic → psi scale is still the rough `2.14e-6` regression, not derived. +- Per-channel `int16 field4` in the IDFH interval record (possibly + time-of-peak) and the 8-byte tail (PVS data) remain undecoded. + +⚠ **Thor's histogram PPV has a display floor of 0.0050 in/s.** In the +production store 6,080 sidecar PPV values are exactly 0.0050 (next most +common value: 275 occurrences), and **41.4% of IDFH sidecars report a +component PPV larger than their own vector sum** — geometrically impossible. +On those quiet files the decoder's ~0.0025 in/s is *more* accurate than the +reference; do not "fix" the decoder to match it. + ### Codec breakthroughs (2026-05-28) - **Body offset is a fixed `0x0f1f`** across 151/154 corpus IDFW diff --git a/micromate/idf_file.py b/micromate/idf_file.py index 60937b8..fc7d2dd 100644 --- a/micromate/idf_file.py +++ b/micromate/idf_file.py @@ -47,19 +47,24 @@ from dataclasses import dataclass from pathlib import Path from typing import Optional, Union -# Thor IDFW bodies are pinned to the SUPERSEDED tag-dispatch decoder. +# Thor IDFW bodies use the series-3 record-chain decoder. # -# _find_waveform_body_offset() trial-decodes every candidate offset and keeps -# whichever yields the most samples. The series-3 record-chain decoder -# correctly returns None where the legacy walker returned garbage, which -# changes that heuristic's winner on 33 of 577 files. The net effect measured -# 2026-08-25 was positive (all-channels-equal 8/577 -> 506/577, mean abs PPV -# error 0.228 -> 0.173 in/s) but Thor has no ASCII ground truth in the corpus -# and its geo scaling is separately suspect, so the switch is deferred until -# the body-offset search is reworked to use the record chain directly. -from minimateplus.waveform_codec import ( - decode_waveform_legacy as decode_waveform_v2, -) +# This was previously pinned to the SUPERSEDED tag-dispatch walker +# (`decode_waveform_legacy`) 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: Thor writes a per-sample CSV export next to every +# binary (see scratch/verify_thor_against_csv.py), and the scaling is now +# resolved (see _GEO_LSB_IPS). Measured against that ground truth on +# 2026-09-10, the record chain beats the legacy walker outright: +# +# channel truncation 55/153 files -> 3/153 +# files exact 98/153 -> 150/153 +# per-sample exact 99.781% -> 99.854% +# +# The legacy walker stops at the first unrecognised tag and returns whatever +# channels it had, so its failure mode is silent short channels rather than an +# error. Do not re-pin it. +from minimateplus.waveform_codec import _MODES, decode_waveform_v2, is_record from .models import IdfEvent, IdfPeaks, IdfReport @@ -91,9 +96,44 @@ _BODY_MAGIC = b"\x00\x02\x00" # 0x0ae2, 0x0d30 in observed events). _BODY_SCAN_FLOOR = 0x0E00 -# Geophone count → in/s, derived from sidecar ground truth: the smallest -# non-zero sample in 1,014-file corpus is 0.0003 in/s. -_GEO_LSB_IPS = 0.0003 +# Cap on trial decodes per file. Chain-head detection normally yields one +# or two candidates; the cap only bounds the worst case on a corrupt file. +_MAX_BODY_CANDIDATES = 16 + +# Geophone count → in/s. +# +# The old value 0.0003 was read off the smallest non-zero sample in the +# sidecar corpus, but that sample is Thor's *4-decimal display rounding* of +# the true LSB, not the LSB itself. It read every series-4 geophone sample +# 3.3% low. The quantisation ladder gives it away: counts 1..6 export as +# 0.0003, 0.0006, 0.0009, 0.0012, 0.0016, 0.0019 — an LSB of exactly 0.0003 +# would end 0.0015, 0.0018. +# +# The value below maximises exact 4-dp agreement over 1,046,016 paired +# samples (454 channel-events, 2 units) at 99.854%, versus 50.7% for 0.0003. +# It is a global constant, not a per-unit calibration: all 8 UM units in the +# production store independently agree to within ±0.07% on their +# device-reported PPV. 1/LSB = 3222.6 counts per in/s. +# +# The value is pinned, not guessed. Each exported sample constrains the LSB +# to the window that rounds to the printed 4-dp figure; intersecting 991,415 +# such constraints (clean channel-events only) gives +# +# LSB in [0.000310307933, 0.000310308057] width 1.2e-10 +# +# 0.000310308 sits at the centre of that window. Equivalent full scale is +# 10.0 in/s / 0.000310308 = 32226.05 counts. +# +# Corroboration from the device: an IDFH interval that never recorded keeps +# its min/max accumulator at its ±full-scale seed, and that seed is +# (min=+32226, max=-32226) — the same magnitude, independently. Note the +# tempting closed form 10.0/32226 is very slightly WRONG: it lands 4.5e-10 +# above the feasible window and loses 78 boundary samples to the literal +# value while never winning one. Series-3 uses 32000 counts for the same +# 10.0 in/s, so the two generations do NOT share a scale. +# +# Ground truth + harness: scratch/verify_thor_against_csv.py +_GEO_LSB_IPS = 0.000310308 # Microphone count → psi, derived from sidecar regression on 50 sample # pairs from UM11719_20231219162723.IDFW (mic-heavy event). @@ -104,8 +144,6 @@ _IDFH_INTERVAL_SIZE = 72 # bytes per per-interval record _IDFH_SEGMENT_HEADER = 10 # bytes: [len_be 2B][0a 00 00 00 4B][00 NN 2B][05 3f 2B] _IDFH_SEGMENT_TAIL = 2 # bytes after the interval data block, before next marker _IDFH_HALFP_FREQ_NUM = 512.0 # freq_hz = NUM / halfp; halfp ≤ 5 means ">100 Hz" sentinel -_IDFH_GEO_FULL_SCALE = 10.0 # in/s — Normal range -_IDFH_INT16_FS = 32768.0 _IDFH_CHANNELS = ("Tran", "Vert", "Long", "MicL") @@ -223,26 +261,64 @@ def _find_waveform_body_offset(buf: bytes) -> Optional[int]: """ if len(buf) < _BODY_SCAN_FLOOR + 8: return None - best: Optional[tuple[int, int]] = None # (total_samples, offset) - i = _BODY_SCAN_FLOOR - while True: - j = buf.find(_BODY_MAGIC, i) - if j < 0: - break - i = j + 1 + + # 1. Locate every plausible per-channel record header. A header carries + # [len 2B][channel_id][00][00] at +2..+6, so anchor the search on the + # three-byte `` 00 00`` signature and validate with is_record(). + # Scanning candidate *preambles* instead is not viable: MODE_RAW16 is + # ``00 00``, so every run of three zero bytes would look like a body + # start and each would cost a full trial decode (~0.5 s/file measured). + floor = max(0, _BODY_SCAN_FLOOR - 7) + starts: list = [] + for cid in (0x46, 0x47, 0x48, 0x49): + sig = bytes((cid, 0x00, 0x00)) + i = floor + while True: + j = buf.find(sig, i) + if j < 0: + break + i = j + 1 + q = j - 4 + if q >= floor and is_record(buf, q): + starts.append(q) + if not starts: + return None + starts.sort() + + # 2. A body begins at the head of a record chain -- a record that no other + # record's length field points at. The head's own payload is the + # implicit segment-0 Tran record, and the body offset is head + 7 (past + # [len 2B][cid][00][00][seg]) so that body[1:3] lands on the mode. + ends = {q + 2 + int.from_bytes(buf[q + 2 : q + 4], "big") for q in starts} + heads = [q for q in starts if q not in ends] or starts[:1] + + # 3. Trial-decode each head and keep the best. Prefer a candidate where + # all four channels come out the same length: scoring on raw sample + # count alone picks false positives sitting *inside* a record header, + # which decode a plausible-looking but rotation-shifted body that + # silently drops each channel's segment 0. + best = None + best_off = None + for head in heads[:_MAX_BODY_CANDIDATES]: + j = head + 7 + if j + 3 > len(buf) or (buf[j + 1], buf[j + 2]) not in _MODES: + continue try: decoded = decode_waveform_v2(buf[j:]) except Exception: continue if not decoded: continue + lengths = [len(v) for v in decoded.values() if v] total = sum(len(v) for v in decoded.values()) # A "real" body has more than just the 2-sample preamble. if total <= 2: continue - if best is None or total > best[0]: - best = (total, j) - return best[1] if best else None + equal = len(lengths) == 4 and len(set(lengths)) == 1 + score = (equal, total) + if best is None or score > best: + best, best_off = score, j + return best_off def _decode_waveform_samples(buf: bytes) -> Optional[dict]: @@ -307,7 +383,11 @@ class IdfhInterval: def peak_ips(self, channel: str) -> float: """Convert peak count to in/s (geo channels only).""" - return self.peak_count(channel) / _IDFH_INT16_FS * _IDFH_GEO_FULL_SCALE + # Same geo LSB as the waveform path — verified independently against + # the IDFH exports: as peak magnitude rises (and 4-dp quantisation + # noise falls) the implied LSB converges on 0.0003103, matching + # _GEO_LSB_IPS. The old 10.0/32768 read histogram peaks 1.7% low. + return self.peak_count(channel) * _GEO_LSB_IPS def freq_hz(self, channel: str) -> Optional[float]: halfp = getattr(self, f"{channel.lower()}_halfp") @@ -316,6 +396,33 @@ class IdfhInterval: return _IDFH_HALFP_FREQ_NUM / halfp +def _is_unwritten_interval(interval: "IdfhInterval") -> bool: + """True for an interval slot the device reserved but never wrote. + + Thor seeds each interval's per-channel accumulators at ``min = +full + scale`` and ``max = -full scale`` and then narrows them as samples + arrive. A slot that never recorded keeps that seed, so ``min > max`` — + impossible for real data. Such a record decodes to a full-scale + 10.0 in/s peak on every channel and, being a max-over-intervals, poisons + the whole file's PPV. + + Rare but real: exactly 1 of 497,611 corpus intervals, and it inflated + that file's Long PPV from 0.0081 to 10.0 in/s. The inversion is always + all-or-nothing across channels (0 partial cases in the corpus), so + requiring every channel to be inverted keeps this from ever firing on + genuine data. + """ + return all( + mn > mx + for mn, mx in ( + (interval.tran_min, interval.tran_max), + (interval.vert_min, interval.vert_max), + (interval.long_min, interval.long_max), + (interval.micl_min, interval.micl_max), + ) + ) + + def _decode_idfh_interval(buf72: bytes, offset: int) -> IdfhInterval: """Decode one 72-byte interval record into per-channel min/max/halfp.""" import struct @@ -343,12 +450,22 @@ def decode_idfh_body(buf: bytes) -> list: """Walk an IDFH file and decode every interval record. The body has one or more segments; each segment header is 12 bytes: - ``[length_be 2B][0a 00 00 00][00 NN_counter][05 3f]`` where ``length`` + ``[length_be 2B][0a 00 00 00][counter_be 2B][05 3f]`` where ``length`` is bytes from the magic through the end of the interval block (= 10 + 72 × n_intervals). Segments are separated by a 2-byte tail + next-segment 2-byte prefix (the bytes before the next length field). - Confirmed against the 859-file corpus (181,071 intervals decoded; 1 - failure is the sig-B BE9439 file). + + ``counter`` is a **uint16 BE cumulative interval index** — the 0-based + index of the LAST interval in this segment. Segments carry 10 + intervals each, so it runs 9, 19, 29, ... across the file. + + ⚠ This validator used to require ``buf[j + 4] == 0x00``, i.e. that the + counter's high byte was zero. That silently capped every histogram at + **250 intervals**: the moment the cumulative counter passed 255 the high + byte went non-zero and every later segment was rejected, so any + monitoring run longer than ~4 hours lost its tail — frequently the part + holding the event peak, which is why those files' PPV read low. 540 of + 858 corpus files were affected. Do not reinstate that check. """ intervals: list = [] i = 0 @@ -356,8 +473,9 @@ def decode_idfh_body(buf: bytes) -> list: j = buf.find(b"\x0a\x00\x00\x00", i) if j < 0 or j < 2: break - # Validate: [length_be][0a 00 00 00][00 NN][05 3f] - if buf[j + 4] != 0x00 or buf[j + 6 : j + 8] != b"\x05\x3f": + # Validate: [length_be][0a 00 00 00][counter_be][05 3f]. The counter + # is deliberately NOT constrained — see the note above. + if buf[j + 6 : j + 8] != b"\x05\x3f": i = j + 1 continue length = int.from_bytes(buf[j - 2 : j], "big") @@ -366,13 +484,23 @@ def decode_idfh_body(buf: bytes) -> list: i = j + 1 continue header_start = j - 2 + if header_start + length > len(buf): + # Truncated / bogus length — not a real segment header. + i = j + 1 + continue interval_start = header_start + _IDFH_SEGMENT_HEADER for k in range(n): off = interval_start + k * _IDFH_INTERVAL_SIZE if off + _IDFH_INTERVAL_SIZE > len(buf): break chunk = buf[off : off + _IDFH_INTERVAL_SIZE] - intervals.append(_decode_idfh_interval(chunk, off)) + interval = _decode_idfh_interval(chunk, off) + if _is_unwritten_interval(interval): + # Reserved-but-never-recorded slot: the min/max accumulators + # still hold their ±full-scale seed. Counting it would + # fabricate a 10.0 in/s peak on every channel. + continue + intervals.append(interval) # Advance past this segment + the 2-byte tail. i = header_start + length + _IDFH_SEGMENT_TAIL return intervals diff --git a/minimateplus/waveform_codec.py b/minimateplus/waveform_codec.py index 9a74780..c47ddf5 100644 --- a/minimateplus/waveform_codec.py +++ b/minimateplus/waveform_codec.py @@ -722,7 +722,18 @@ STREAM_END_ID = 0x06 MODE_DELTA = (0x02, 0x00) MODE_ABSOLUTE = (0x01, 0x00) MODE_RAW12 = (0x00, 0x03) -_MODES = (MODE_DELTA, MODE_ABSOLUTE, MODE_RAW12) +# Raw int16 BE absolute samples, 10-byte header, no tags — the same shape as +# MODE_RAW12 but two bytes per sample instead of 1.5. Found on Thor/Micromate +# segment-0 records (2026-09-10): a `len=1032` record carries exactly +# (1032 - 8) / 2 = 512 samples and reproduces Thor's own export 512/512 +# exactly. Before this mode existed the record fell through the dispatch +# unhandled, so the channel silently lost its first 512 samples. +MODE_RAW16 = (0x00, 0x00) +_MODES = (MODE_DELTA, MODE_ABSOLUTE, MODE_RAW12, MODE_RAW16) + +# Preambles whose leading data is untagged and therefore cannot be +# block-walked; find_first_record() must scan for the next record instead. +_UNTAGGED_MODES = (MODE_RAW12, MODE_RAW16) def _u16(b: bytes, p: int) -> int: @@ -761,6 +772,11 @@ def data_block_len(body: bytes, p: int) -> Tuple[Optional[int], Optional[int]]: return None, None +def unpack16(data: bytes) -> List[int]: + """Raw int16 BE absolute samples (MODE_RAW16).""" + return [_i16(data, 2 * k) for k in range(len(data) // 2)] + + def unpack12(data: bytes) -> List[int]: """Raw 12-bit packed samples: 6 bytes -> 4 signed values.""" out: List[int] = [] @@ -785,13 +801,17 @@ def find_first_record(body: bytes) -> Optional[int]: """Offset of the first record, or None. Under the normal ``00 02 00`` preamble the leading bytes are segment-0's - Tran blocks, so walk them. Under the ``00 00 03`` preamble that data is - raw 12-bit with no tags at all and cannot be block-walked — scan instead. + Tran blocks, so walk them. Under the untagged preambles (``00 00 03`` + raw-12 and ``00 00 00`` raw-16) that data has no tags at all and cannot + be block-walked — scan for the next record header instead. """ - if len(body) >= 3 and (body[1], body[2]) == MODE_RAW12: + if len(body) >= 3 and (body[1], body[2]) in _UNTAGGED_MODES: scan_from = 3 else: - i = 7 + # Tagged preamble. MODE_DELTA carries a 14-byte record header (two + # int16 anchors), so its blocks start at body[7]; MODE_ABSOLUTE has a + # 10-byte header and starts at body[3]. + i = 3 if (len(body) >= 3 and (body[1], body[2]) == MODE_ABSOLUTE) else 7 while i < len(body): if is_record(body, i): nxt = i + 2 + _u16(body, i + 2) @@ -850,7 +870,7 @@ def decode_waveform_v2(body: bytes) -> Optional[dict]: if len(body) < 8 or body[0] != 0x00: return None preamble = (body[1], body[2]) - if preamble not in (MODE_DELTA, MODE_RAW12): + if preamble not in (MODE_DELTA, MODE_ABSOLUTE, MODE_RAW12, MODE_RAW16): return None first = find_first_record(body) if first is None: @@ -895,6 +915,10 @@ def decode_waveform_v2(body: bytes) -> Optional[dict]: if preamble == MODE_DELTA: out["Tran"].extend([_i16(body, 3), _i16(body, 5)]) run("Tran", 7, first, absolute=False) + elif preamble == MODE_ABSOLUTE: + run("Tran", 3, first, absolute=True) + elif preamble == MODE_RAW16: + out["Tran"].extend(unpack16(body[3:first])) else: out["Tran"].extend(unpack12(body[3:first])) @@ -908,4 +932,6 @@ def decode_waveform_v2(body: bytes) -> Optional[dict]: run(ch, off + 10, end, absolute=True) elif mode == MODE_RAW12: out[ch].extend(unpack12(body[off + 10:end])) + elif mode == MODE_RAW16: + out[ch].extend(unpack16(body[off + 10:end])) return out diff --git a/scratch/verify_thor_against_csv.py b/scratch/verify_thor_against_csv.py new file mode 100644 index 0000000..4b8c40c --- /dev/null +++ b/scratch/verify_thor_against_csv.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Verify the Thor / Micromate (series-4) IDF decoder against Thor's own exports. + +Sister harness to ``scratch/verify_against_ascii.py`` (series-3 / Blastware). + +Ground truth is the ``.IDFW.csv`` / ``.IDFH.csv`` file Thor writes next to each +binary, under a sibling ``CSV/`` directory: + + /UM13981_20220207084555.IDFW + /CSV/UM13981_20220207084555.IDFW.csv + +For waveforms the CSV carries a per-sample block of four columns +(Tran, Vert, Long, Mic) in in/s and psi -- i.e. true per-sample ground truth, +exactly what the BW ASCII exports give us for series-3. The leading 2-column +rows are the report header (PPV, sample rate, geo range, ...). + +Usage: + python scratch/verify_thor_against_csv.py [--root DIR] [--lsb FLOAT] + [--limit N] [--kind idfw|idfh|both] +""" +from __future__ import annotations + +import argparse +import csv +import os +import statistics +import sys +from collections import Counter, defaultdict + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from micromate import idf_file as M + +DEFAULT_ROOT = "/home/serversdown/thor-watcher/example-data" +GEO = ("Tran", "Vert", "Long") + + +def parse_export(path): + """Return (header_dict, sample_rows) from a Thor CSV export.""" + hdr, rows = {}, [] + with open(path, newline="", encoding="utf-8", errors="replace") as fh: + for rec in csv.reader(fh): + if len(rec) == 2: + hdr[rec[0].strip()] = rec[1].strip() + elif len(rec) >= 3: + try: + rows.append([float(x) for x in rec]) + except ValueError: + pass + return hdr, rows + + +def index_corpus(root): + """Map BASENAME.IDFW -> (binary_path, csv_path) for every paired file.""" + exports, binaries = {}, {} + for dirpath, _dirs, files in os.walk(root): + for name in files: + up = name.upper() + full = os.path.join(dirpath, name) + if up.endswith(".IDFW.CSV") or up.endswith(".IDFH.CSV"): + exports.setdefault(name[:-4].upper(), full) + elif up.endswith(".IDFW") or up.endswith(".IDFH"): + binaries.setdefault(up, full) + return {k: (binaries[k], exports[k]) for k in binaries.keys() & exports.keys()} + + +def hdr_float(hdr, key): + raw = hdr.get(key) + if not raw: + return None + try: + return float(raw.split()[0]) + except (ValueError, IndexError): + return None + + +def verify_waveform(binpath, csvpath, lsb): + """Compare one IDFW against its export. Returns a result dict.""" + out = {"file": os.path.basename(binpath), "status": "ok"} + try: + res = M.read_idf_file(binpath) + except NotImplementedError: + out["status"] = "not-thor" + return out + except Exception as exc: # noqa: BLE001 - harness reports, never raises + out["status"] = "decode-error" + out["detail"] = f"{type(exc).__name__}: {exc}" + return out + + hdr, rows = parse_export(csvpath) + if not rows: + out["status"] = "no-gt-samples" + return out + + gt = {ch: [r[i] for r in rows] for i, ch in enumerate(GEO)} + out["gt_len"] = len(rows) + out["geo_range"] = hdr.get("GeoRange") + + exact = total = 0 + lens, chan_status = {}, {} + ppv_err = {} + for ch in GEO: + arr = res.samples.get(ch, []) + ref = gt[ch] + lens[ch] = len(arr) + if len(arr) != len(ref): + chan_status[ch] = "length" + continue + if not arr: + chan_status[ch] = "empty" + continue + hits = sum(1 for c, v in zip(arr, ref) if abs(c * lsb - v) < 5e-5) + exact += hits + total += len(arr) + chan_status[ch] = "exact" if hits == len(arr) else "value" + gp = hdr_float(hdr, f"{ch}PPV") + if gp: + ppv_err[ch] = (max(abs(c) for c in arr) * lsb - gp) / gp + + out["lens"] = lens + out["chan_status"] = chan_status + out["exact"] = exact + out["total"] = total + out["ppv_err"] = ppv_err + if all(v == "exact" for v in chan_status.values()): + out["status"] = "exact" + elif any(v == "length" for v in chan_status.values()): + out["status"] = "length-mismatch" + else: + out["status"] = "value-mismatch" + return out + + +def verify_histogram(binpath, csvpath, lsb): + out = {"file": os.path.basename(binpath), "status": "ok"} + try: + res = M.read_idf_file(binpath) + except NotImplementedError: + out["status"] = "not-thor" + return out + except Exception as exc: # noqa: BLE001 + out["status"] = "decode-error" + out["detail"] = f"{type(exc).__name__}: {exc}" + return out + hdr, _rows = parse_export(csvpath) + out["n_intervals"] = len(res.intervals or []) + errs = {} + for ch, attr in (("Tran", "transverse_ips"), ("Vert", "vertical_ips"), + ("Long", "longitudinal_ips")): + gp = hdr_float(hdr, f"{ch}PPV") + dv = getattr(res.event.peaks, attr, None) + if gp and dv: + errs[ch] = (dv - gp) / gp + out["ppv_err"] = errs + out["status"] = "peaks" if errs else "no-gt-peaks" + return out + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--root", default=DEFAULT_ROOT) + ap.add_argument("--lsb", type=float, default=M._GEO_LSB_IPS) + ap.add_argument("--limit", type=int, default=0) + ap.add_argument("--kind", choices=("idfw", "idfh", "both"), default="both") + ap.add_argument("--show", type=int, default=15, help="worst-N detail rows") + args = ap.parse_args() + + pairs = index_corpus(args.root) + keys = sorted(pairs) + if args.kind != "both": + keys = [k for k in keys if k.endswith(args.kind.upper())] + if args.limit: + keys = keys[: args.limit] + + print(f"root: {args.root}") + print(f"geo LSB under test: {args.lsb!r} in/s per count") + print(f"paired files: {len(keys)}\n") + + wf, hg = [], [] + for k in keys: + binpath, csvpath = pairs[k] + if k.endswith(".IDFW"): + wf.append(verify_waveform(binpath, csvpath, args.lsb)) + else: + hg.append(verify_histogram(binpath, csvpath, args.lsb)) + + if wf: + st = Counter(r["status"] for r in wf) + ex = sum(r.get("exact", 0) for r in wf) + tot = sum(r.get("total", 0) for r in wf) + print("=" * 68) + print(f"WAVEFORM (IDFW): {len(wf)} files") + for s, n in st.most_common(): + print(f" {s:16} {n:5d} ({100*n/len(wf):5.1f}%)") + if tot: + print(f" per-sample exact: {ex}/{tot} = {100*ex/tot:.3f}%") + errs = [e for r in wf for e in r.get("ppv_err", {}).values()] + if errs: + print(f" PPV rel-error: median {statistics.median(errs):+.4%} " + f"mean {statistics.mean(errs):+.4%} " + f"max|.| {max(abs(e) for e in errs):.4%}") + bad = [r for r in wf if r["status"] not in ("exact",)] + if bad: + print(f"\n worst {min(args.show, len(bad))} of {len(bad)} non-exact:") + for r in bad[: args.show]: + print(f" {r['file']:42} {r['status']:16} " + f"lens={r.get('lens')} gt={r.get('gt_len')} " + f"{r.get('detail','')}") + + if hg: + st = Counter(r["status"] for r in hg) + print("=" * 68) + print(f"HISTOGRAM (IDFH): {len(hg)} files") + for s, n in st.most_common(): + print(f" {s:16} {n:5d} ({100*n/len(hg):5.1f}%)") + errs = [e for r in hg for e in r.get("ppv_err", {}).values()] + if errs: + print(f" PPV rel-error: median {statistics.median(errs):+.4%} " + f"mean {statistics.mean(errs):+.4%} " + f"max|.| {max(abs(e) for e in errs):.4%}") + within = lambda t: 100*sum(1 for e in errs if abs(e) <= t)/len(errs) + print(f" within 0.5%: {within(0.005):.1f}% " + f"within 2%: {within(0.02):.1f}% within 5%: {within(0.05):.1f}%") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sfm/waveform_store.py b/sfm/waveform_store.py index 8373e8d..d38a425 100644 --- a/sfm/waveform_store.py +++ b/sfm/waveform_store.py @@ -595,8 +595,19 @@ class WaveformStore: ) # Binary-derived peaks fill in when the .txt didn't supply them. - # They're ~3% low vs the device-authoritative .txt values (residual - # codec drift), so .txt always wins when present. + # + # The old justification for this precedence -- "binary peaks are ~3% + # low vs the .txt" -- was a decoder bug (geo LSB 0.0003 instead of + # 0.000310308) and was fixed 2026-09-10; the binary now agrees with + # Thor's own export per-sample. The .txt still wins when present + # because it is what the operator sees in Thor's report. + # + # ⚠ One case where the .txt is the *less* accurate of the two: + # Thor floors displayed histogram PPV at 0.0050 in/s, so on quiet + # IDFH events the .txt reports 0.0050 while the binary decodes the + # true ~0.0025. 41.4% of prod IDFH sidecars carry a component PPV + # larger than their own vector sum because of it. Left as-is + # deliberately, so stored peaks keep matching Thor's report. if binary_peaks is not None: if binary_peaks.transverse_ips and not report_dict.get("tran_ppv"): report_dict["tran_ppv"] = binary_peaks.transverse_ips diff --git a/tests/test_idf_binary_codec.py b/tests/test_idf_binary_codec.py new file mode 100644 index 0000000..94a88aa --- /dev/null +++ b/tests/test_idf_binary_codec.py @@ -0,0 +1,237 @@ +"""Per-sample verification of the Thor / Micromate (series-4) IDF binary codec. + +Ground truth is Thor's own CSV export, written next to each binary by the +Thor desktop application. For waveforms the export carries a per-sample +block of four columns (Tran, Vert, Long, Mic) in in/s and psi -- the +series-4 equivalent of Blastware's ``_ASCII.TXT`` exports. + +The full-corpus harness is ``scratch/verify_thor_against_csv.py``; these +tests pin the two constants that harness established so they cannot +regress silently. +""" +from __future__ import annotations + +import csv +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from micromate.idf_file import ( + _GEO_LSB_IPS, + geo_count_to_ips, + read_idf_file, +) + +FIXTURES = Path(__file__).parent / "fixtures" / "thor-idf" +IDFW = FIXTURES / "UM11719_20231219162723.IDFW" +IDFH = FIXTURES / "UM11719_20231219162648.IDFH" + +GEO_CHANNELS = ("Tran", "Vert", "Long") + +# tests/fixtures/ is gitignored, so a fresh checkout has no sample data. +# Skip rather than fail, matching test_idf_ascii_report.py. To populate: +# +# B="/example-data/THORDATA_example/THORDATA_example/UPMC Presby" +# mkdir -p tests/fixtures/thor-idf +# for f in UM11719/UM11719_20231219162723.IDFW \ +# UM11719/UM11719_20231219162648.IDFH \ +# UM13981/UM13981_20220207084555.IDFW \ +# UM13981/UM13981_20220207183102.IDFH \ +# UM13981/UM13981_20221202063059.IDFH; do +# cp "$B/$f" tests/fixtures/thor-idf/ +# cp "$B/$(dirname $f)/CSV/$(basename $f).csv" tests/fixtures/thor-idf/ +# done +pytestmark = pytest.mark.skipif( + not FIXTURES.is_dir() or not any(FIXTURES.glob("*.IDFW")), + reason=f"Thor IDF fixtures not present under {FIXTURES}", +) + + +def _parse_export(path: Path): + """Split a Thor CSV export into (header dict, per-sample rows).""" + header, rows = {}, [] + with path.open(newline="", encoding="utf-8", errors="replace") as fh: + for rec in csv.reader(fh): + if len(rec) == 2: + header[rec[0].strip()] = rec[1].strip() + elif len(rec) >= 3: + try: + rows.append([float(x) for x in rec]) + except ValueError: + pass + return header, rows + + +def _header_float(header, key): + return float(header[key].split()[0]) + + +@pytest.fixture(scope="module") +def idfw_export(): + return _parse_export(IDFW.with_suffix(".IDFW.csv")) + + +# ─── The geo scale constant ──────────────────────────────────────────────── + + +def test_geo_lsb_matches_thor_quantisation(): + """Thor's own export quantises geo samples to this LSB. + + Derived by maximising exact-match count over 1,046,016 paired samples + (454 channel-events, 2 units); independently corroborated on 8 + production units via their device-reported PPV. The historical value + 0.0003 read every series-4 geophone sample 3.3% low. + """ + assert _GEO_LSB_IPS == pytest.approx(0.000310308, rel=1e-6) + + +def test_geo_lsb_is_not_the_legacy_value(): + # Guards against a revert to the truncated 0.0003 constant. + assert abs(_GEO_LSB_IPS - 0.0003) > 1e-6 + + +# ─── Per-sample fidelity ─────────────────────────────────────────────────── + + +def test_waveform_channel_lengths_match_export(idfw_export): + _header, rows = idfw_export + result = read_idf_file(IDFW) + for channel in GEO_CHANNELS: + assert len(result.samples[channel]) == len(rows), ( + f"{channel} truncated: decoded {len(result.samples[channel])} " + f"samples, export has {len(rows)}" + ) + + +def test_waveform_samples_match_export_exactly(idfw_export): + """Every geo sample must reproduce Thor's exported value to 4 dp.""" + _header, rows = idfw_export + result = read_idf_file(IDFW) + for index, channel in enumerate(GEO_CHANNELS): + decoded = result.samples[channel] + expected = [row[index] for row in rows] + mismatches = [ + (i, geo_count_to_ips(c), v) + for i, (c, v) in enumerate(zip(decoded, expected)) + if abs(geo_count_to_ips(c) - v) >= 5e-5 + ] + assert not mismatches, ( + f"{channel}: {len(mismatches)} of {len(expected)} samples differ; " + f"first three {mismatches[:3]}" + ) + + +def test_waveform_ppv_matches_export(idfw_export): + header, _rows = idfw_export + result = read_idf_file(IDFW) + for channel, attr in ( + ("Tran", "transverse_ips"), + ("Vert", "vertical_ips"), + ("Long", "longitudinal_ips"), + ): + decoded = getattr(result.event.peaks, attr) + assert decoded == pytest.approx( + _header_float(header, f"{channel}PPV"), abs=5e-5 + ), f"{channel} PPV disagrees with Thor's export" + + +# ─── Histogram path shares the same scale ────────────────────────────────── + + +def test_histogram_peaks_match_export(): + header, _rows = _parse_export(IDFH.with_suffix(".IDFH.csv")) + result = read_idf_file(IDFH) + assert result.intervals, "IDFH decoded no intervals" + for channel, attr in ( + ("Tran", "transverse_ips"), + ("Vert", "vertical_ips"), + ("Long", "longitudinal_ips"), + ): + decoded = getattr(result.event.peaks, attr) + expected = _header_float(header, f"{channel}PPV") + # Histogram peaks are stored per-interval, so the export's PPV is + # reproduced within one quantisation step rather than exactly. + assert decoded == pytest.approx(expected, abs=2 * _GEO_LSB_IPS), ( + f"{channel} histogram peak {decoded} vs export {expected}" + ) + + +# ─── Regressions found 2026-09-10 ────────────────────────────────────────── + +IDFH_LONG = FIXTURES / "UM13981_20220207183102.IDFH" # 719 intervals +IDFH_SENTINEL = FIXTURES / "UM13981_20221202063059.IDFH" # holds an unwritten slot +IDFW_RAW16 = FIXTURES / "UM13981_20220207084555.IDFW" # segment 0 is MODE_RAW16 + + +def test_histogram_decodes_past_250_intervals(): + """The segment validator must not require a zero counter high byte. + + The interval counter is a uint16 cumulative index. Requiring its high + byte to be zero rejected every segment past interval 255, capping each + histogram at 250 intervals and truncating any run longer than ~4 hours — + frequently discarding the part that held the peak. + """ + result = read_idf_file(IDFH_LONG) + header, _rows = _parse_export(IDFH_LONG.with_suffix(".IDFH.csv")) + expected = float(header["NumberOfIntervals"]) + assert len(result.intervals) == 719 + assert len(result.intervals) == pytest.approx(expected, abs=1.0) + + +def test_histogram_ignores_unwritten_interval_slot(): + """A never-written interval keeps its ±full-scale seed and must be dropped. + + Counting it fabricates a 10.0 in/s peak on every channel, which then wins + the max-over-intervals and poisons the whole file's PPV. + """ + header, _rows = _parse_export(IDFH_SENTINEL.with_suffix(".IDFH.csv")) + result = read_idf_file(IDFH_SENTINEL) + for channel, attr in ( + ("Tran", "transverse_ips"), + ("Vert", "vertical_ips"), + ("Long", "longitudinal_ips"), + ): + decoded = getattr(result.event.peaks, attr) + assert decoded < 1.0, f"{channel} peak {decoded} looks like the ±FS seed" + assert decoded == pytest.approx( + _header_float(header, f"{channel}PPV"), abs=2 * _GEO_LSB_IPS + ) + + +def test_waveform_raw16_segment_zero_is_decoded(): + """Segment-0 records can be raw int16 (MODE_RAW16, 10-byte header). + + That mode was absent from the dispatch, so the record fell through + unhandled and the channel silently lost its first 512 samples. + """ + rows = _parse_export(IDFW_RAW16.with_suffix(".IDFW.csv"))[1] + result = read_idf_file(IDFW_RAW16) + for index, channel in enumerate(GEO_CHANNELS): + decoded = result.samples[channel] + assert len(decoded) == len(rows), f"{channel} lost segment 0" + expected = [row[index] for row in rows] + bad = sum( + 1 for c, v in zip(decoded, expected) + if abs(geo_count_to_ips(c) - v) >= 5e-5 + ) + assert bad == 0, f"{channel}: {bad} samples differ from Thor's export" + + +def test_body_offset_search_is_not_quadratic(): + """The body scan must stay cheap enough for bulk ingest. + + MODE_RAW16 is (0x00, 0x00), so scanning for candidate *preambles* treats + every run of three zero bytes as a body start and trial-decodes each one + (~0.5 s/file measured). The search anchors on record headers instead. + """ + import time + + start = time.perf_counter() + for _ in range(3): + read_idf_file(IDFW_RAW16) + elapsed = (time.perf_counter() - start) / 3 + assert elapsed < 0.15, f"body-offset search took {elapsed*1000:.0f} ms/file" -- 2.54.0 From c07aaa552c86127781f9426d531045cadaf51c4f Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 10 Sep 2026 20:00:56 +0000 Subject: [PATCH 03/28] fix(series4): support mic-disabled (3-channel) Thor units 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) Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL --- CHANGELOG.md | 34 ++++++++++-- CLAUDE.md | 17 ++++-- docs/idf_protocol_reference.md | 71 +++++++++++++++++++++--- micromate/idf_file.py | 99 ++++++++++++++++++++++++++-------- tests/test_idf_binary_codec.py | 56 +++++++++++++++++++ 5 files changed, 240 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f4bc3..12fcf97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,10 +64,36 @@ quiet files the decoder is now *more* accurate than that reference. New: `scratch/verify_thor_against_csv.py`, `tests/test_idf_binary_codec.py` (10 tests, fixtures under `tests/fixtures/thor-idf/`). -**Known open:** 41/575 production IDFW files (7%, mostly UM12947/UM20147) -still decode with unequal channel lengths and also fail metadata extraction — -a different header variant with no Thor export in the store. Pull their CSV -exports before attempting a fix. +### Fixed — mic-disabled (3-channel) units + +Verified on a second corpus (`9-10-26-csv-req`: UM11402, UM12947, UM20147) — +**139/139 waveforms per-sample exact (1,273,380 samples), 877/877 histograms +within 2%** (was 66.9% and 56.6%). + +- **Waveform body head sat below the scan floor.** A 3-channel unit's shorter + header puts the record chain head at `0x0dba`, under the old + `_BODY_SCAN_FLOOR` of `0x0E00`. The scan couldn't 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`; body-offset scoring now accepts 3 channels as "equal" + instead of demanding 4. +- **Histogram interval record is 56 bytes, not 72.** It is + `16 × n_channels + 8`, so mic-disabled units pack 56. Assuming 72 read 7 + intervals out of every 10-interval segment then walked off alignment into + garbage decoding as ~10 in/s peaks (errors up to +191,000%). The interval + count now comes from the segment's cumulative counter and the stride is + derived from it; also recovers 4 files that 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. + +**Known open — diagnosed but NOT verified:** 23/575 production IDFW files +(4%), all UM12947 between 2025-07-14 and 2025-09-23, stop the block walker on +tag `40 0c`. `data_block_len()` caps the `40 NN` int16 block at `NN > 0x08`, +but these files use NN up to 196. Both verified corpora only ever use +NN ∈ {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. Needs Thor CSV +exports for UM12947 in that date range (the 9-10-26 upload starts 2025-09-25). --- diff --git a/CLAUDE.md b/CLAUDE.md index 9605c71..a64e60f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,9 +38,20 @@ Read this first when picking the project back up. matched `00 02 00` *inside* record headers, decoding a rotation-shifted body. IDFW is no longer pinned to `decode_waveform_legacy`. Series-3 re-verified unchanged at 14,338/14,338 after the shared-codec - change. **Still open:** 41/575 prod IDFW files (7%, mostly UM12947) decode - with unequal channel lengths and have no Thor export — pull their CSVs - before touching it. + change. +- **Mic-disabled (3-channel) units are a distinct shape (2026-09-10).** + Verified on a second corpus (`~/thor-csv-req`, UM11402/UM12947/UM20147): + **139/139** waveforms per-sample exact, **877/877** histograms within 2%. + Two structural differences: the shorter header puts the waveform record + chain head at `0x0dba` (below the old `_BODY_SCAN_FLOOR` of `0x0E00`, so it + was invisible and Vert came up exactly 512 short), and the histogram + interval record is **56 bytes, not 72** — `16 × n_channels + 8`, derived per + segment from the cumulative interval counter, never assumed. + **Still open:** 23/575 prod IDFW files (4%), all UM12947 2025-07-14..09-23, + stop the walker on tag `40 0c` — `data_block_len()` caps `40 NN` at NN>8 but + these use up to 196. **Diagnosed, NOT verified** — no Thor export exists for + those dates. Do not ship the cap change without one; see + `docs/idf_protocol_reference.md`. - **Open, not blocking:** 14 sensitive-range files show an exact 8x (= 10.0/1.25) units discrepancy; `scripts/backfill_sidecars.py --force` also inserts DB rows for store files that have none (one-time per store) and the diff --git a/docs/idf_protocol_reference.md b/docs/idf_protocol_reference.md index b181f28..d1bef3c 100644 --- a/docs/idf_protocol_reference.md +++ b/docs/idf_protocol_reference.md @@ -178,15 +178,72 @@ channels come out the same length. `00 00`, so every run of three zero bytes looks like a body start and each costs a full trial decode (~0.5 s/file measured, vs 6 ms/file now). +### Mic-disabled units are a distinct shape (2026-09-10, second corpus) + +Some units run with the microphone disabled — **3 channels, not 4** — and that +changes two structural things. Confirmed on the `9-10-26-csv-req` corpus +(UM11402, UM12947, UM20147): 139/139 waveforms and 877/877 histograms. + +**Waveform: the body starts earlier.** A 3-channel unit has a shorter fixed +header and puts its record chain head at **`0x0dba`**, below the old +`_BODY_SCAN_FLOOR` of `0x0E00`. The head was therefore invisible to the scan, +which fell through to the *Vert* segment-0 record and decoded a body shifted +one position around the channel rotation. The signature is unmistakable: + +``` +Tran 3072 / Vert 2560 / Long 3072 / MicL 0 <- Vert exactly 512 short +``` + +46 of 139 files in that corpus were affected; all 46 became per-sample exact +once the floor dropped to `0x0C00`. Note the body-offset scoring also had to +stop requiring four channels — `len(lengths) >= 3`, not `== 4`, or `equal` is +permanently False for these events and the pick falls back to raw sample count. + +**Histogram: the interval record is 56 bytes, not 72.** + +``` +interval_size = 16 × n_channels + 8 (72 for 4 channels, 56 for 3) +``` + +It is **not a constant**, and it cannot be inferred from `length` alone. +Derive the interval count from the segment counter — it is cumulative, so +`n = counter - previous_counter` — and then `stride = (length - 10) / n`. +`n_channels` follows from `(stride - 8) / 16`. + +Assuming 72 read 7 intervals out of each 10-interval segment and then walked +off alignment into garbage that decoded as ~10 in/s peaks — inflating those +files' PPV by up to 191,000%. Fixing it moved the second corpus from 56.6% to +**100.0%** of histograms within 2% of Thor's reported PPV, and recovered 4 +files that previously decoded no intervals at all. + ### What is still open -- **41 of 575 production IDFW files (7%)** still decode with unequal channel - lengths — signature `Tran/Long 3072, Vert 2560, MicL 0`, and - `sample_rate`/`record_time` also fail to extract, so their header layout - differs. Concentrated in UM12947 (32) and UM20147 (8). No Thor export - exists for them in the production store, so **do not guess a fix** — pull - the paired CSV exports for those events first. Their PPV is mostly still - right (median error −0.001%, 74.8% within 1%). +- **23 of 575 production IDFW files (4%)** still decode with unequal channel + lengths (22) or fail outright (1). All are **UM12947, 2025-07-14 to + 2025-09-23**. The `Tran/Vert/Long 3072 / MicL 0` group was the 3-channel + shape above and is fixed; what remains is ragged, e.g. + `T1812 / V2132 / L2324 / M2324`. + + **Diagnosed but NOT verified.** These files stop the block walker on tag + `40 0c`, because `data_block_len()` caps the `40 NN` int16 block at + `NN > 0x08`: + + ```python + if hi == 0x40: # int16 BE data block + return (None, None) if (nn == 0 or nn > 0x08) else (2 * nn + 2, nn) + ``` + + The affected files use NN of 12, 16, 20 … up to 196 — every value above 8 + halts the walk, which is why the channel comes up short. Both verified + corpora only ever use NN ∈ {1, 2, 3, 4, 8}, so the cap has never been + exercised, and lifting it leaves both at 100.000%. + + ⚠ **Do not ship the cap change on that evidence.** "Doesn't regress the + known-good corpus" is not "produces correct values here", and a too-loose + guard can accept a false `40 NN` inside data and emit plausible-but-wrong + samples — the exact failure mode this whole effort was about. It needs + Thor CSV exports for UM12947 events between **2025-07-14 and 2025-09-23**; + the `9-10-26-csv-req` upload starts at 2025-09-25 and misses them. - Mic → psi scale is still the rough `2.14e-6` regression, not derived. - Per-channel `int16 field4` in the IDFH interval record (possibly time-of-peak) and the 8-byte tail (PVS data) remain undecoded. diff --git a/micromate/idf_file.py b/micromate/idf_file.py index fc7d2dd..b862e03 100644 --- a/micromate/idf_file.py +++ b/micromate/idf_file.py @@ -94,7 +94,15 @@ _BODY_MAGIC = b"\x00\x02\x00" # fixed-header region where the same magic legitimately appears inside # channel-test records and the compliance block (offsets 0x015d, 0x091c, # 0x0ae2, 0x0d30 in observed events). -_BODY_SCAN_FLOOR = 0x0E00 +# Lowered from 0x0E00 to 0x0C00 (2026-09-10). Three-channel events -- mic +# disabled -- have a shorter fixed header and put their record chain head at +# 0x0dba, below the old floor. The head was therefore invisible to the scan, +# which fell through to the *Vert* segment-0 record and decoded a body shifted +# one position around the channel rotation. 46 of 139 files in the +# 9-10-26-csv-req corpus were affected; all 46 became per-sample exact once +# the head was reachable. The floor still skips the fixed-header region, +# where `is_record()` can match channel-test records (0x015d, 0x091c, 0x0ae2). +_BODY_SCAN_FLOOR = 0x0C00 # Cap on trial decodes per file. Chain-head detection normally yields one # or two candidates; the cap only bounds the worst case on a corrupt file. @@ -140,7 +148,13 @@ _GEO_LSB_IPS = 0.000310308 _MIC_LSB_PSI = 2.14e-6 # IDFH histogram constants. -_IDFH_INTERVAL_SIZE = 72 # bytes per per-interval record +# Bytes per interval record = 16 per channel + an 8-byte tail, so a +# 4-channel unit uses 72 and a mic-disabled 3-channel unit uses 56. It is +# NOT a constant: derive it per segment from the interval counter (see +# decode_idfh_body). This value survives only as the 4-channel default. +_IDFH_INTERVAL_SIZE = 72 # bytes per per-interval record (4 channels) +_IDFH_CHANNEL_BLOCK = 16 # bytes per channel inside an interval record +_IDFH_INTERVAL_TAIL = 8 # bytes after the per-channel blocks _IDFH_SEGMENT_HEADER = 10 # bytes: [len_be 2B][0a 00 00 00 4B][00 NN 2B][05 3f 2B] _IDFH_SEGMENT_TAIL = 2 # bytes after the interval data block, before next marker _IDFH_HALFP_FREQ_NUM = 512.0 # freq_hz = NUM / halfp; halfp ≤ 5 means ">100 Hz" sentinel @@ -314,7 +328,10 @@ def _find_waveform_body_offset(buf: bytes) -> Optional[int]: # A "real" body has more than just the 2-sample preamble. if total <= 2: continue - equal = len(lengths) == 4 and len(set(lengths)) == 1 + # >= 3 rather than == 4: a mic-disabled event has only the three geo + # channels, and demanding four made `equal` permanently False for + # them, leaving the pick to raw sample count alone. + equal = len(lengths) >= 3 and len(set(lengths)) == 1 score = (equal, total) if best is None or score > best: best, best_off = score, j @@ -375,6 +392,12 @@ class IdfhInterval: micl_min: int micl_max: int micl_halfp: int + # 4 on a normal unit; 3 when the microphone is disabled, in which case the + # micl_* fields are absent from the record and read as zero. + n_channels: int = 4 + + def has_channel(self, channel: str) -> bool: + return channel != "MicL" or self.n_channels >= 4 def peak_count(self, channel: str) -> int: mn = getattr(self, f"{channel.lower()}_min") @@ -412,22 +435,30 @@ def _is_unwritten_interval(interval: "IdfhInterval") -> bool: requiring every channel to be inverted keeps this from ever firing on genuine data. """ - return all( - mn > mx - for mn, mx in ( - (interval.tran_min, interval.tran_max), - (interval.vert_min, interval.vert_max), - (interval.long_min, interval.long_max), - (interval.micl_min, interval.micl_max), - ) - ) + pairs = [ + (interval.tran_min, interval.tran_max), + (interval.vert_min, interval.vert_max), + (interval.long_min, interval.long_max), + ] + if interval.has_channel("MicL"): + pairs.append((interval.micl_min, interval.micl_max)) + return all(mn > mx for mn, mx in pairs) -def _decode_idfh_interval(buf72: bytes, offset: int) -> IdfhInterval: - """Decode one 72-byte interval record into per-channel min/max/halfp.""" +def _decode_idfh_interval(buf72: bytes, offset: int, + n_channels: int = 4) -> IdfhInterval: + """Decode one interval record into per-channel min/max/halfp. + + The record is ``n_channels`` × 16-byte blocks plus an 8-byte tail, so it + is 72 bytes on a normal unit and 56 when the microphone is disabled. + Missing channels read as zero. + """ import struct fields = [] for i in range(4): + if i >= n_channels: + fields.extend([0, 0, 0]) + continue block = buf72[i * 16 : (i + 1) * 16] mn = struct.unpack_from(">h", block, 0)[0] mx = struct.unpack_from(">h", block, 2)[0] @@ -443,6 +474,7 @@ def _decode_idfh_interval(buf72: bytes, offset: int) -> IdfhInterval: vert_min=fields[3], vert_max=fields[4], vert_halfp=fields[5], long_min=fields[6], long_max=fields[7], long_halfp=fields[8], micl_min=fields[9], micl_max=fields[10], micl_halfp=fields[11], + n_channels=n_channels, ) @@ -469,6 +501,7 @@ def decode_idfh_body(buf: bytes) -> list: """ intervals: list = [] i = 0 + prev_counter = -1 # so the first segment's n = counter + 1 while True: j = buf.find(b"\x0a\x00\x00\x00", i) if j < 0 or j < 2: @@ -479,28 +512,43 @@ def decode_idfh_body(buf: bytes) -> list: i = j + 1 continue length = int.from_bytes(buf[j - 2 : j], "big") - n = (length - _IDFH_SEGMENT_HEADER) // _IDFH_INTERVAL_SIZE + counter = int.from_bytes(buf[j + 4 : j + 6], "big") + header_start = j - 2 + if length < _IDFH_SEGMENT_HEADER or header_start + length > len(buf): + # Truncated / bogus length — not a real segment header. + i = j + 1 + continue + # The counter is the cumulative index of this segment's LAST interval, + # so the interval count is its delta from the previous segment. That + # gives the record stride, which is NOT fixed: 16 bytes per channel + # plus an 8-byte tail, so 72 for a 4-channel unit and 56 for a + # mic-disabled 3-channel one. Assuming 72 unconditionally made every + # 3-channel histogram read 7 intervals per 10-interval segment, + # walking off alignment into garbage that decoded as ~10 in/s peaks. + n = counter - prev_counter if n <= 0: i = j + 1 continue - header_start = j - 2 - if header_start + length > len(buf): - # Truncated / bogus length — not a real segment header. + stride = (length - _IDFH_SEGMENT_HEADER) // n + n_channels, remainder = divmod(stride - _IDFH_INTERVAL_TAIL, + _IDFH_CHANNEL_BLOCK) + if remainder or not (1 <= n_channels <= 4): i = j + 1 continue interval_start = header_start + _IDFH_SEGMENT_HEADER for k in range(n): - off = interval_start + k * _IDFH_INTERVAL_SIZE - if off + _IDFH_INTERVAL_SIZE > len(buf): + off = interval_start + k * stride + if off + stride > len(buf): break - chunk = buf[off : off + _IDFH_INTERVAL_SIZE] - interval = _decode_idfh_interval(chunk, off) + chunk = buf[off : off + stride] + interval = _decode_idfh_interval(chunk, off, n_channels) if _is_unwritten_interval(interval): # Reserved-but-never-recorded slot: the min/max accumulators # still hold their ±full-scale seed. Counting it would # fabricate a 10.0 in/s peak on every channel. continue intervals.append(interval) + prev_counter = counter # Advance past this segment + the 2-byte tail. i = header_start + length + _IDFH_SEGMENT_TAIL return intervals @@ -580,7 +628,12 @@ def read_idf_file( peak_long = max((iv.peak_ips("Long") for iv in intervals), default=0.0) # Mic peak in psi — Thor stores per-interval mic ADC counts in the # binary; convert the max count to psi via the per-count factor. - mic_peak_count = max((iv.peak_count("MicL") for iv in intervals), default=0) + # Skip on a mic-disabled (3-channel) unit: those records carry no mic + # block at all, so peak_count("MicL") would report a synthetic zero. + mic_peak_count = max( + (iv.peak_count("MicL") for iv in intervals if iv.has_channel("MicL")), + default=0, + ) mic_peak_psi = mic_count_to_psi(mic_peak_count) if mic_peak_count else None rep = IdfReport( serial_number=md.serial, diff --git a/tests/test_idf_binary_codec.py b/tests/test_idf_binary_codec.py index 94a88aa..98757d7 100644 --- a/tests/test_idf_binary_codec.py +++ b/tests/test_idf_binary_codec.py @@ -235,3 +235,59 @@ def test_body_offset_search_is_not_quadratic(): read_idf_file(IDFW_RAW16) elapsed = (time.perf_counter() - start) / 3 assert elapsed < 0.15, f"body-offset search took {elapsed*1000:.0f} ms/file" + + +# ─── Mic-disabled (3-channel) units, found 2026-09-10 ────────────────────── + +IDFW_3CH = FIXTURES / "UM20147_20250531135901.IDFW" # body head below old floor +IDFH_3CH = FIXTURES / "UM20147_20250330070110.IDFH" # 56-byte interval records + + +def test_three_channel_waveform_decodes_all_geo_channels(): + """A mic-disabled unit's shorter header moves the record chain head. + + Its head sits at 0x0dba, below the old ``_BODY_SCAN_FLOOR`` of 0x0E00, so + 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, which + surfaced as Vert being exactly 512 samples short. + """ + rows = _parse_export(IDFW_3CH.with_suffix(".IDFW.csv"))[1] + result = read_idf_file(IDFW_3CH) + for index, channel in enumerate(GEO_CHANNELS): + decoded = result.samples[channel] + assert len(decoded) == len(rows), ( + f"{channel}: {len(decoded)} samples, export has {len(rows)}" + ) + expected = [row[index] for row in rows] + bad = sum( + 1 for c, v in zip(decoded, expected) + if abs(geo_count_to_ips(c) - v) >= 5e-5 + ) + assert bad == 0, f"{channel}: {bad} samples differ from Thor's export" + # Mic is genuinely absent on these units, not merely undecoded. + assert not result.samples.get("MicL") + + +def test_three_channel_histogram_uses_56_byte_intervals(): + """Interval stride is 16 bytes per channel + an 8-byte tail, not a constant. + + A mic-disabled unit packs 56-byte records, so assuming 72 read 7 intervals + out of every 10-interval segment and then walked off alignment into + garbage, which decoded as ~10 in/s peaks. The true count comes from the + segment's cumulative interval counter. + """ + header, _rows = _parse_export(IDFH_3CH.with_suffix(".IDFH.csv")) + result = read_idf_file(IDFH_3CH) + expected_intervals = float(header["NumberOfIntervals"]) + assert len(result.intervals) == pytest.approx(expected_intervals, abs=1.0) + assert {iv.n_channels for iv in result.intervals} == {3} + for channel, attr in ( + ("Tran", "transverse_ips"), + ("Vert", "vertical_ips"), + ("Long", "longitudinal_ips"), + ): + decoded = getattr(result.event.peaks, attr) + assert decoded < 1.0, f"{channel} peak {decoded} looks like walked-off garbage" + assert decoded == pytest.approx( + _header_float(header, f"{channel}PPV"), rel=0.02 + ) -- 2.54.0 From 904522a9c5e547586c01630142c31dabb57ef741 Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 11 Sep 2026 05:05:46 +0000 Subject: [PATCH 04/28] fix(codec): 40 NN int16 blocks are not capped at NN=8 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) Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL --- CHANGELOG.md | 38 +++++++++++++++++---- CLAUDE.md | 19 ++++++++--- docs/idf_protocol_reference.md | 61 ++++++++++++++++++++-------------- minimateplus/waveform_codec.py | 13 +++++++- tests/test_idf_binary_codec.py | 29 ++++++++++++++++ tests/test_waveform_codec.py | 23 +++++++++++-- 6 files changed, 143 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12fcf97..167bdf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,13 +87,37 @@ within 2%** (was 66.9% and 56.6%). Combined across both corpora: **292/292 waveform files, 2,330,916/2,330,916 samples exact.** Production IDFW truncations 41 → 22. -**Known open — diagnosed but NOT verified:** 23/575 production IDFW files -(4%), all UM12947 between 2025-07-14 and 2025-09-23, stop the block walker on -tag `40 0c`. `data_block_len()` caps the `40 NN` int16 block at `NN > 0x08`, -but these files use NN up to 196. Both verified corpora only ever use -NN ∈ {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. Needs Thor CSV -exports for UM12947 in that date range (the 9-10-26 upload starts 2025-09-25). +### Fixed — `40 NN` int16 blocks with NN > 8 + +`data_block_len()` rejected any `40 NN` block with `NN > 0x08`. The cap had +no evidence behind it: every corpus available when it was written used only +NN ∈ {1,2,3,4,8}, so it was never exercised. Loud UM12947 events use NN of +12, 16, 20 … up to 196, and because the block walker stops at the first +unrecognised tag rather than raising, rejecting them surfaced as **silently +short channels** (e.g. Tran 1812 / Vert 2132 / Long 2324 on a file whose +export has 2324 for all three). The bound is the buffer, not a constant. + +Verified against Thor exports for UM12947 (2025-07-14 … 09-25, 167 +waveforms): length mismatches **22 → 0**, **1,476,242/1,476,249** samples +exact. These are not truncated recordings — the exports carry full sample +counts. + +`tests/test_waveform_codec.py` asserted the cap as intended behaviour; that +assertion was wrong and has been replaced with one pinning the opposite, +carrying the evidence. + +### Result across all three ground-truth corpora + +**459 waveform files, 3,807,158 / 3,807,165 samples exact.** Production +IDFW: **575/575**, zero truncations, zero decode failures, median PPV error +−0.0007% across 8 units. Series-3 re-verified **unchanged at 14,338/14,338** +after every shared-codec change. + +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 (the 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. --- diff --git a/CLAUDE.md b/CLAUDE.md index a64e60f..b3459e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,11 +47,20 @@ Read this first when picking the project back up. was invisible and Vert came up exactly 512 short), and the histogram interval record is **56 bytes, not 72** — `16 × n_channels + 8`, derived per segment from the cumulative interval counter, never assumed. - **Still open:** 23/575 prod IDFW files (4%), all UM12947 2025-07-14..09-23, - stop the walker on tag `40 0c` — `data_block_len()` caps `40 NN` at NN>8 but - these use up to 196. **Diagnosed, NOT verified** — no Thor export exists for - those dates. Do not ship the cap change without one; see - `docs/idf_protocol_reference.md`. +- **`40 NN` blocks are not capped at NN=8 (2026-09-11).** `data_block_len()` + rejected `NN > 0x08`, a guard with no evidence behind it — the corpora + available when it was written only used NN ∈ {1,2,3,4,8}. Loud UM12947 + events use NN up to 196, and since the walker stops at the first + unrecognised tag rather than raising, this surfaced as silently short + channels. Verified on 167 UM12947 waveforms: length mismatches 22 → 0, + 1,476,242/1,476,249 samples exact. +- **Production IDFW is now 575/575** — zero truncations, zero decode + failures, median PPV error −0.0007% across 8 units (was 41 truncated + 1 + failing, −3.3%). Across all three ground-truth corpora: **459 files, + 3,807,158/3,807,165 samples exact**; the 7 stragglers differ by one + 4th-decimal tick and are Thor's own rounding — no single linear LSB can + reproduce every printed value (the constraints are infeasible by 7e-5 + relative), so do NOT retune `_GEO_LSB_IPS`. - **Open, not blocking:** 14 sensitive-range files show an exact 8x (= 10.0/1.25) units discrepancy; `scripts/backfill_sidecars.py --force` also inserts DB rows for store files that have none (one-time per store) and the diff --git a/docs/idf_protocol_reference.md b/docs/idf_protocol_reference.md index d1bef3c..2fdc035 100644 --- a/docs/idf_protocol_reference.md +++ b/docs/idf_protocol_reference.md @@ -178,6 +178,39 @@ channels come out the same length. `00 00`, so every run of three zero bytes looks like a body start and each costs a full trial decode (~0.5 s/file measured, vs 6 ms/file now). +### `40 NN` is not capped at NN=8 (2026-09-11) + +`data_block_len()` rejected any `40 NN` int16 block with `NN > 0x08`. The cap +had no evidence behind it — every corpus available when it was written used +only NN ∈ {1, 2, 3, 4, 8}, so it was never exercised. Loud events use much +wider blocks: + +| corpus | `40 NN` values | walker stops | +|---|---|---| +| first + 3-channel corpora | 1, 2, 3, 4, 8 | none | +| UM12947 2025-07..09 | 2, 4, 8, **12, 16, 20 … 196** | every value > 8 | + +Because `walk_body`/`run` stop at the first unrecognised tag rather than +raising, this surfaced as **silently short channels** — e.g. Tran 1812 / +Vert 2132 / Long 2324 on a file whose export has 2324 for all three. The +real bound is the buffer (and the caller's record end), not a magic constant. + +Verified against Thor's exports for UM12947 (2025-07-14 … 2025-09-25, 167 +waveforms): length mismatches **22 → 0**, and **1,476,242 / 1,476,249** +samples exact. + +⚠ These events are **not** truncated recordings, which was the competing +hypothesis — the exports carry the full sample count. + +**The 7 residual samples are Thor's rounding, not ours.** Each differs by +exactly one 4th-decimal tick (e.g. decoded 3.3551 vs export 3.3550). +Intersecting the per-sample rounding constraints over this corpus is +**infeasible** — the binding pair (count 2013 → 0.6247, count 4351 → 1.3501) +contradict by 2.3e-11, i.e. 7e-5 relative. No single linear LSB can +reproduce every printed value, so Thor is not doing plain round-half-up on +`count × LSB`. Do not retune `_GEO_LSB_IPS` to chase these; it is already +pinned to ~1e-11. + ### Mic-disabled units are a distinct shape (2026-09-10, second corpus) Some units run with the microphone disabled — **3 channels, not 4** — and that @@ -218,32 +251,10 @@ files that previously decoded no intervals at all. ### What is still open -- **23 of 575 production IDFW files (4%)** still decode with unequal channel - lengths (22) or fail outright (1). All are **UM12947, 2025-07-14 to - 2025-09-23**. The `Tran/Vert/Long 3072 / MicL 0` group was the 3-channel - shape above and is fixed; what remains is ragged, e.g. - `T1812 / V2132 / L2324 / M2324`. +- ~~23 of 575 production IDFW files~~ — **RESOLVED 2026-09-11.** Production + IDFW is now **575/575** with zero truncations and zero decode failures + (median PPV error −0.0007%). See "`40 NN` is not capped at NN=8" above. - **Diagnosed but NOT verified.** These files stop the block walker on tag - `40 0c`, because `data_block_len()` caps the `40 NN` int16 block at - `NN > 0x08`: - - ```python - if hi == 0x40: # int16 BE data block - return (None, None) if (nn == 0 or nn > 0x08) else (2 * nn + 2, nn) - ``` - - The affected files use NN of 12, 16, 20 … up to 196 — every value above 8 - halts the walk, which is why the channel comes up short. Both verified - corpora only ever use NN ∈ {1, 2, 3, 4, 8}, so the cap has never been - exercised, and lifting it leaves both at 100.000%. - - ⚠ **Do not ship the cap change on that evidence.** "Doesn't regress the - known-good corpus" is not "produces correct values here", and a too-loose - guard can accept a false `40 NN` inside data and emit plausible-but-wrong - samples — the exact failure mode this whole effort was about. It needs - Thor CSV exports for UM12947 events between **2025-07-14 and 2025-09-23**; - the `9-10-26-csv-req` upload starts at 2025-09-25 and misses them. - Mic → psi scale is still the rough `2.14e-6` regression, not derived. - Per-channel `int16 field4` in the IDFH interval record (possibly time-of-peak) and the 8-byte tail (PVS data) remain undecoded. diff --git a/minimateplus/waveform_codec.py b/minimateplus/waveform_codec.py index c47ddf5..e8d04dc 100644 --- a/minimateplus/waveform_codec.py +++ b/minimateplus/waveform_codec.py @@ -758,7 +758,18 @@ def data_block_len(body: bytes, p: int) -> Tuple[Optional[int], Optional[int]]: hi = t0 & 0xF0 nn = ((t0 & 0x0F) << 8) | t1 if hi == 0x40: # int16 BE data block - return (None, None) if (nn == 0 or nn > 0x08) else (2 * nn + 2, nn) + # NN was capped at 0x08 until 2026-09-11. That cap had no basis: the + # two corpora available at the time only ever used NN in {1,2,3,4,8}, + # so it was never exercised. Loud UM12947 events use NN of 12, 16, + # 20 ... up to 196, and every value above 8 halted the walk, which + # surfaced as silently short channels (walk_body/run stop at the first + # unrecognised tag rather than raising). Verified against Thor's own + # exports: 22 length-mismatched files -> 0, and the affected corpus + # went to 1,476,242/1,476,249 samples exact. The real bound is the + # buffer; the caller additionally clamps to the record end. + if nn == 0 or p + 2 * nn + 2 > len(body): + return None, None + return 2 * nn + 2, nn if nn == 0 or nn % 4: return None, None if hi == 0x00: diff --git a/tests/test_idf_binary_codec.py b/tests/test_idf_binary_codec.py index 98757d7..06e7c1f 100644 --- a/tests/test_idf_binary_codec.py +++ b/tests/test_idf_binary_codec.py @@ -291,3 +291,32 @@ def test_three_channel_histogram_uses_56_byte_intervals(): assert decoded == pytest.approx( _header_float(header, f"{channel}PPV"), rel=0.02 ) + + +# ─── `40 NN` blocks with NN > 8, verified 2026-09-11 ─────────────────────── + +IDFW_WIDE40 = FIXTURES / "UM12947_20250806134504.IDFW" + + +def test_wide_forty_nn_block_does_not_truncate_channels(): + """Loud events use `40 NN` blocks with NN well above the old cap of 8. + + ``data_block_len()`` rejected NN > 0x08, which halted the block walk + part-way through a record. The walker stops at the first unrecognised + tag instead of raising, so this surfaced as silently short channels — + here Tran 1812 / Vert 2132 / Long 2324 where the export has 2324 for all + three. The affected files use NN of 12, 16, 20 ... up to 196. + """ + rows = _parse_export(IDFW_WIDE40.with_suffix(".IDFW.csv"))[1] + result = read_idf_file(IDFW_WIDE40) + for index, channel in enumerate(GEO_CHANNELS): + decoded = result.samples[channel] + assert len(decoded) == len(rows), ( + f"{channel}: {len(decoded)} samples, export has {len(rows)}" + ) + expected = [row[index] for row in rows] + bad = sum( + 1 for c, v in zip(decoded, expected) + if abs(geo_count_to_ips(c) - v) >= 5e-5 + ) + assert bad == 0, f"{channel}: {bad} samples differ from Thor's export" diff --git a/tests/test_waveform_codec.py b/tests/test_waveform_codec.py index eebcf9d..d2c97b3 100644 --- a/tests/test_waveform_codec.py +++ b/tests/test_waveform_codec.py @@ -712,8 +712,27 @@ def test_forty_nn_is_a_data_block_not_a_segment_header(): """ assert data_block_len(b"\x40\x02\x00\x01\x00\x02", 0) == (6, 2) assert data_block_len(b"\x40\x08" + bytes(16), 0) == (18, 8) - # NN > 8 is not a data block - assert data_block_len(b"\x40\x0c" + bytes(24), 0) == (None, None) + + +def test_forty_nn_is_not_capped_at_eight(): + """NN > 8 is a perfectly ordinary `40 NN` block. + + This test previously asserted the opposite (`40 0c` -> (None, None)), + codifying a guard that had no evidence behind it: the only corpora + available then used NN in {1,2,3,4,8}, so the cap was never exercised. + Loud UM12947 events use NN of 12, 16, 20 ... up to 196, and rejecting + them halted the block walk mid-record — surfacing as silently short + channels, since the walker stops at the first unrecognised tag rather + than raising. Lifting the cap took that corpus from 22 length-mismatched + files to 0, and 1,476,242 of 1,476,249 samples now reproduce Thor's own + CSV export exactly (the 7 stragglers differ by one 4th-decimal tick). + Verified 2026-09-11; see docs/idf_protocol_reference.md. + """ + assert data_block_len(b"\x40\x0c" + bytes(24), 0) == (26, 12) + assert data_block_len(b"\x40\xc4" + bytes(392), 0) == (394, 196) + # The real bound is the buffer: a block that cannot fit is not a block. + assert data_block_len(b"\x40\xc4" + bytes(8), 0) == (None, None) + assert data_block_len(b"\x40\x00" + bytes(8), 0) == (None, None) def test_record_chain_is_followed_by_length_not_by_tag_sniffing(): -- 2.54.0 From 88c0e2b76514d4eb207e01e10dddf5f29767fc4c Mon Sep 17 00:00:00 2001 From: serversdown Date: Sat, 12 Sep 2026 04:58:07 +0000 Subject: [PATCH 05/28] =?UTF-8?q?chore(release):=20v0.30.0=20=E2=80=94=20s?= =?UTF-8?q?eries-4=20correctness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL --- CHANGELOG.md | 33 ++++++++++++++++++++++++++++++++- CLAUDE.md | 2 +- README.md | 2 +- minimateplus/event_file_io.py | 2 +- pyproject.toml | 2 +- 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 167bdf7..3a742b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,38 @@ All notable changes to seismo-relay are documented here. --- -## Unreleased +## v0.30.0 — 2026-09-12 + +**The series-4 correctness release** — the Thor / Micromate counterpart to +v0.26.0's series-3 work. The decoder is now verified per-sample against +Thor's own CSV exports: **459 waveform files, 3,807,158 / 3,807,165 samples +exact** across three independent ground-truth corpora, and production IDFW is +**575/575** with zero truncations and zero decode failures. Series-3 +re-verified **unchanged at 14,338/14,338** after every shared-codec change. + +⚠ **This release owes the prod store a Thor backfill.** Every stored +series-4 geophone value is **3.3% low**, and histogram peaks from monitoring +runs longer than ~4 hours can be far worse (the interval cap discarded the +tail, frequently the part holding the peak). Run +`scripts/backfill_thor_events.py` — `TOOL_VERSION` is bumped to `0.30.0`, so +regeneration is gated correctly and **no `--force` is needed**. DB backup +first. Series-3 events are untouched by this release and do not need +re-running. + +⚠ **Terra-View displays these values.** Series-4 geophone readings will rise +~3.3% after the backfill, and some histogram PPVs will rise a great deal more. +That is a correction, not a regression. + + +### Fixed — event-report PDF used a per-trace geo Y scale + +The waveform plot scaled each geo lane to its own peak, so a small channel +filled its lane and looked as large as a big one, and the `Geo: X in/s/div` +footer reflected only whichever channel was measured first — wrong for the +other two. All three geo lanes now 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. Large events are unchanged. ### Fixed — series-4 (Thor / Micromate) decoder is now per-sample exact diff --git a/CLAUDE.md b/CLAUDE.md index b3459e6..05ec9ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem -(Sierra Wireless RV50 / RV55). Current version: **v0.29.0**. +(Sierra Wireless RV50 / RV55). Current version: **v0.30.0**. Stack-level context — which repo owns what, and how the three project versions pair — lives in `../terra-view/docs/tmi-stack.md`, which is also loaded as diff --git a/README.md b/README.md index dfe95e0..a11473c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# seismo-relay `v0.29.0` +# seismo-relay `v0.30.0` A ground-up replacement for **Blastware** — Instantel's aging Windows-only software for managing seismographs. Supports both the **MiniMate Plus diff --git a/minimateplus/event_file_io.py b/minimateplus/event_file_io.py index d23fc6d..519cf4b 100644 --- a/minimateplus/event_file_io.py +++ b/minimateplus/event_file_io.py @@ -50,7 +50,7 @@ SIDECAR_KIND = "sfm.event" # bumped without a `pip install` re-run — leading to confusing stale # version stamps in sidecars. Bump this constant and CHANGELOG.md # together at release time. -TOOL_VERSION = "0.29.0" +TOOL_VERSION = "0.30.0" try: # Best-effort: prefer the installed metadata when it's NEWER than the diff --git a/pyproject.toml b/pyproject.toml index cd9281b..4b9e639 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "seismo-relay" -version = "0.29.0" +version = "0.30.0" description = "Python client and REST server for MiniMate Plus seismographs" requires-python = ">=3.10" dependencies = [ -- 2.54.0 From 845ec38f96211cf027ca831b8c03a4fff2fbb76d Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 10 Sep 2026 18:53:27 +0000 Subject: [PATCH 06/28] feat(inspector): Series-3 binary structural annotator (binary_annotate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf --- minimateplus/binary_annotate.py | 75 +++++++++++++++++++++++++++++++++ tests/test_binary_annotate.py | 50 ++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 minimateplus/binary_annotate.py create mode 100644 tests/test_binary_annotate.py diff --git a/minimateplus/binary_annotate.py b/minimateplus/binary_annotate.py new file mode 100644 index 0000000..25e164d --- /dev/null +++ b/minimateplus/binary_annotate.py @@ -0,0 +1,75 @@ +"""Structural annotation of a Series-3 Blastware waveform binary. + +Pure, no I/O: takes the raw file bytes and returns a flat, gap-free tiling of +labelled :class:`Span` regions for a hex viewer to paint. Every byte is +covered — anything the decoder can't account for becomes an ``unknown`` span, +so undecoded regions (e.g. a stored spectral/FFT block, if one exists) stand +out instead of hiding. + +File layout (see ``blastware_file.py``): ``[header][21B STRT][body][26B footer]``. +The body is the record chain walked by :func:`waveform_codec.walk_records`. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import List + +from .waveform_codec import walk_records + +_STRT_LEN = 21 +_FOOTER_LEN = 26 + + +@dataclass +class Span: + start: int # inclusive byte offset + end: int # exclusive byte offset + label: str # human-readable description + kind: str # 'header' | 'strt' | 'sample' | 'footer' | 'unknown' + + +def _tile(known: List[Span], total: int) -> List[Span]: + """Sort *known* spans and fill every gap with an ``unknown`` span, so the + result is a contiguous, non-overlapping tiling of ``[0, total)``. Overlaps + are resolved by clamping to the running position (first writer wins).""" + out: List[Span] = [] + pos = 0 + for s in sorted(known, key=lambda x: (x.start, x.end)): + if s.end <= pos: + continue # fully behind — dropped overlap + start = max(s.start, pos) + if start > pos: + out.append(Span(pos, start, "unknown", "unknown")) + out.append(s if start == s.start else Span(start, s.end, s.label, s.kind)) + pos = s.end + if pos < total: + out.append(Span(pos, total, "unknown", "unknown")) + return out + + +def annotate_blastware_binary(raw: bytes) -> List[Span]: + """Annotate a Series-3 waveform binary into a gap-free list of spans.""" + total = len(raw) + strt_pos = raw.find(b"STRT") + if strt_pos < 0: + return [Span(0, total, "unrecognized — no STRT record", "unknown")] + + known: List[Span] = [] + if strt_pos > 0: + known.append(Span(0, strt_pos, "File header", "header")) + known.append(Span(strt_pos, strt_pos + _STRT_LEN, "STRT record", "strt")) + + body_start = strt_pos + _STRT_LEN + footer_start = total - _FOOTER_LEN + if footer_start >= body_start: + known.append(Span(footer_start, total, "File footer", "footer")) + else: + footer_start = total # file too short for a footer + + body = raw[body_start:footer_start] + for rec in walk_records(body): + hi, lo = rec["mode"] + label = f"{rec['channel']} record (seg {rec['segment_index']}, mode {hi:02x} {lo:02x})" + known.append(Span(body_start + rec["offset"], body_start + rec["end"], label, "sample")) + + return _tile(known, total) diff --git a/tests/test_binary_annotate.py b/tests/test_binary_annotate.py new file mode 100644 index 0000000..3748041 --- /dev/null +++ b/tests/test_binary_annotate.py @@ -0,0 +1,50 @@ +"""Structural annotation of a Series-3 Blastware binary (for the seismo_lab +Binary Inspector). The annotator maps byte ranges to labelled spans; anything +the decoder can't account for is a first-class ``unknown`` span, so the whole +file is tiled and the gaps (candidate FFT/spectral data) are visible. +""" +from pathlib import Path + +from minimateplus.binary_annotate import annotate_blastware_binary, Span + +# A known-good full-3-channel Series-3 waveform binary (the V70 cracking fixture). +FIXTURE = Path(__file__).parent / "fixtures" / "5-11-26" / "M529LL1L.V70" + + +def _raw() -> bytes: + return FIXTURE.read_bytes() + + +def test_spans_tile_the_whole_file(): + raw = _raw() + spans = annotate_blastware_binary(raw) + assert spans, "expected at least one span" + assert spans[0].start == 0 + assert spans[-1].end == len(raw) + for a, b in zip(spans, spans[1:]): + assert a.end == b.start, f"gap/overlap between {a!r} and {b!r}" + for s in spans: + assert s.start < s.end, f"empty/negative span {s!r}" + + +def test_strt_record_is_located(): + raw = _raw() + spans = annotate_blastware_binary(raw) + strt = [s for s in spans if s.kind == "strt"] + assert strt, "expected a STRT region" + assert raw[strt[0].start : strt[0].start + 4] == b"STRT" + + +def test_geo_sample_records_annotated(): + raw = _raw() + spans = annotate_blastware_binary(raw) + chans = {s.label.split()[0] for s in spans if s.kind == "sample"} + # V70 is a full three-geo-channel event. + assert {"Tran", "Vert", "Long"} <= chans, f"expected geo records, got {chans}" + + +def test_footer_is_last(): + raw = _raw() + spans = annotate_blastware_binary(raw) + assert spans[-1].kind == "footer" + assert spans[-1].end - spans[-1].start == 26 -- 2.54.0 From 11e3e515f3d375bb088e6112db0cfa32db617f28 Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 10 Sep 2026 18:56:54 +0000 Subject: [PATCH 07/28] =?UTF-8?q?feat(seismo=5Flab):=20Inspector=20tab=20?= =?UTF-8?q?=E2=80=94=20annotated=20hex=20reader=20for=20Series-3=20binarie?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf --- seismo_lab.py | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/seismo_lab.py b/seismo_lab.py index 1986127..5cad795 100644 --- a/seismo_lab.py +++ b/seismo_lab.py @@ -54,6 +54,7 @@ from s3_analyzer import ( # noqa: E402 write_claude_export, ) from frame_db import FrameDB # noqa: E402 +from minimateplus.binary_annotate import annotate_blastware_binary # noqa: E402 # ── colour palette ──────────────────────────────────────────────────────────── BG = "#1e1e1e" @@ -2675,6 +2676,95 @@ class DownloadPanel(tk.Frame): self._on_capture_ready(bw_path, s3_path, label) +# ───────────────────────────────────────────────────────────────────────────── +# Inspector panel — annotated hex view of a Series-3 binary +# ───────────────────────────────────────────────────────────────────────────── + +class InspectorPanel(tk.Frame): + """Load any Series-3 waveform binary and read it as an annotated hex dump. + + Regions the decoder understands (header, STRT, per-channel sample records, + footer) are labelled and colour-coded; everything the decoder cannot account + for is flagged UNKNOWN, so undecoded bytes stand out for hand-inspection. + """ + + _KIND_COLOR = { + "header": ACCENT, + "strt": YELLOW, + "sample": COL_S3, + "footer": FG_DIM, + "unknown": RED, + } + + def __init__(self, parent: tk.Widget, initialdir=None, **kw) -> None: + super().__init__(parent, bg=BG, **kw) + self._path = None + self._initialdir = initialdir + self._build() + + def _build(self) -> None: + bar = tk.Frame(self, bg=BG2) + bar.pack(side=tk.TOP, fill=tk.X) + tk.Button(bar, text="Open binary…", command=self._open, bg=BG3, fg=FG, + relief=tk.FLAT, font=MONO, activebackground=ACCENT).pack(side=tk.LEFT, padx=6, pady=6) + self._path_var = tk.StringVar(value="(no file loaded)") + tk.Label(bar, textvariable=self._path_var, bg=BG2, fg=FG_DIM, font=MONO).pack(side=tk.LEFT, padx=6) + self._summary_var = tk.StringVar(value="") + tk.Label(bar, textvariable=self._summary_var, bg=BG2, fg=FG, font=MONO).pack(side=tk.RIGHT, padx=10) + + legend = tk.Frame(self, bg=BG2) + legend.pack(side=tk.TOP, fill=tk.X) + tk.Label(legend, text="legend:", bg=BG2, fg=FG_DIM, font=MONO).pack(side=tk.LEFT, padx=(8, 2)) + for kind, color in self._KIND_COLOR.items(): + tk.Label(legend, text=f"■ {kind}", bg=BG2, fg=color, font=MONO).pack(side=tk.LEFT, padx=5, pady=2) + + self._text = scrolledtext.ScrolledText( + self, bg=BG, fg=FG, insertbackground=FG, font=MONO, wrap=tk.NONE, borderwidth=0) + self._text.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + for kind, color in self._KIND_COLOR.items(): + self._text.tag_configure(kind, foreground=color) + self._text.tag_configure("label", foreground="#ffffff", font=("Consolas", 9, "bold")) + self._text.tag_configure("dim", foreground=FG_DIM) + self._text.configure(state=tk.DISABLED) + + def _open(self) -> None: + p = filedialog.askopenfilename(title="Open a Series-3 binary", initialdir=self._initialdir) + if p: + self.load(Path(p)) + + def load(self, path: Path) -> None: + try: + raw = path.read_bytes() + spans = annotate_blastware_binary(raw) + except Exception as e: # noqa: BLE001 — surface any read/annotate failure to the user + messagebox.showerror("Inspector", f"Failed to read/annotate:\n{path}\n\n{e}") + return + self._path = path + self._path_var.set(str(path)) + self._render(raw, spans) + + def _render(self, raw: bytes, spans) -> None: + t = self._text + t.configure(state=tk.NORMAL) + t.delete("1.0", tk.END) + unknown = sum(s.end - s.start for s in spans if s.kind == "unknown") + pct = 100 * unknown / max(1, len(raw)) + self._summary_var.set(f"{len(raw)} B · {len(spans)} regions · {pct:.1f}% unknown") + for s in spans: + t.insert(tk.END, f"\n── {s.label} [0x{s.start:04x}:0x{s.end:04x}] {s.end - s.start} B ──\n", ("label",)) + self._insert_hex(t, raw, s.start, s.end, s.kind) + t.configure(state=tk.DISABLED) + + def _insert_hex(self, t: tk.Text, raw: bytes, start: int, end: int, kind: str) -> None: + for off in range(start, end, 16): + row = raw[off:min(off + 16, end)] + hx = " ".join(f"{b:02x}" for b in row).ljust(16 * 3 - 1) + txt = "".join(chr(b) if 32 <= b < 127 else "." for b in row) + t.insert(tk.END, f" 0x{off:04x} ", ("dim",)) + t.insert(tk.END, hx, (kind,)) + t.insert(tk.END, f" {txt}\n", ("dim",)) + + # ───────────────────────────────────────────────────────────────────────────── # Main application window # ───────────────────────────────────────────────────────────────────────────── @@ -2730,6 +2820,9 @@ class SeismoLab(tk.Tk): ) nb.add(self._download_panel, text=" Download ") + self._inspector_panel = InspectorPanel(nb) + nb.add(self._inspector_panel, text=" Inspector ") + self._nb = nb self.protocol("WM_DELETE_WINDOW", self._on_close) -- 2.54.0 From 2902ab373e7b356e2ed7cbf5269bbd3f6268e092 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 14 Sep 2026 17:15:16 +0000 Subject: [PATCH 08/28] feat(fft): Blastware-compatible channel FFT (waveform_fft) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf --- .../fft-oracle-2026-09-14/N844LPGH.VV0W | Bin 0 -> 9102 bytes .../fft-oracle-2026-09-14/N844LPPR.3S0W | Bin 0 -> 8306 bytes .../fft-oracle-2026-09-14/N844LQHB.ZT0W | Bin 0 -> 11260 bytes .../fft-oracle-2026-09-14/N844LQUE.T50W | Bin 0 -> 11102 bytes .../fft-oracle-2026-09-14/N844LR8W.790W | Bin 0 -> 9678 bytes .../fft-oracle-2026-09-14/N844LRCO.G60W | Bin 0 -> 9450 bytes .../fft-oracle-2026-09-14/N844LRCW.F30W | Bin 0 -> 9654 bytes tests/test_waveform_fft.py | 85 ++++++++++++++++++ waveform_fft.py | 66 ++++++++++++++ 9 files changed, 151 insertions(+) create mode 100644 tests/fixtures/fft-oracle-2026-09-14/N844LPGH.VV0W create mode 100644 tests/fixtures/fft-oracle-2026-09-14/N844LPPR.3S0W create mode 100644 tests/fixtures/fft-oracle-2026-09-14/N844LQHB.ZT0W create mode 100644 tests/fixtures/fft-oracle-2026-09-14/N844LQUE.T50W create mode 100644 tests/fixtures/fft-oracle-2026-09-14/N844LR8W.790W create mode 100644 tests/fixtures/fft-oracle-2026-09-14/N844LRCO.G60W create mode 100644 tests/fixtures/fft-oracle-2026-09-14/N844LRCW.F30W create mode 100644 tests/test_waveform_fft.py create mode 100644 waveform_fft.py diff --git a/tests/fixtures/fft-oracle-2026-09-14/N844LPGH.VV0W b/tests/fixtures/fft-oracle-2026-09-14/N844LPGH.VV0W new file mode 100644 index 0000000000000000000000000000000000000000..785721f56b80dd382638dd138b0452e4faf4d9a5 GIT binary patch literal 9102 zcma)CZERcDd43OdC0$7r@3~ius}+iQNveR;rm^_p)XS3AmMAxEoi2+gxxfmnu0=T> z5;?VyRv$x0W?v&ADE^8<1ZhU9ExwW~uzOmWd z2}P&b+t*`uN?FV%g_J?eHCyP=Y;Nz!dustX`9dLMlQW#ly0OLia!tizD>vqzUp;ow zHM-62ZYd{oh4Er0yyBW$8=D)Oo6T)`;2EFn^r2#LbZj7kRbY|5+8j( zRE&^PL~=L~76~gM0ue|{1e8dSA)}NK)Ch2O7&j?F_>C`trUc62 zBFUe4Cv;Cq7SA=Ld-BbM#0SQ>g0Pmfh|rZ{)rxC|9bK?gcb7EAZg*v?+1$iytJ&gq z)0Fb#YW3KugbHEd=H}Y^)~@L6;oi4D?+!(JW3s<1Y_6?u>>9n6W0`6_sC$i#9jxw7 z9!=&Z_OC1rWLBQvck$>I*Vt9Z$eE?QX!Fk>gbK}xY_>3D)`N< zQTUEM$-kqKR5~L<=(5|~YHn?BOXHcFt_hR&nr2s*jLv-W@lmOu`|kF}+WOiikX>7L zT*K(@#u}x5*ML!WkA1jiBITjQKLbF8jHiQ3PP8yO2At{g0TwQ zz-|Wng93n0BNsQKd-ydlMO2y+H!fO-STT;&bc=%|m8X04( zDs|**;_cu(0nY1&)`QYptkgt4pGuH|t%jVWOQGjeZhS1WU(eCpwtsxdA4rV|DV%gV z177hg;W)`ac`6_q5pP*4tXFD{bYUz@7D?L{LIj>y^8-?0)fb1RilfX&UeFK}3#*5P;UQNax40wpIr-Nx0jB%$~o@)NZHK>0FAXU-s%i z-L7i=bV}?8OR{Z%O}P?=%C=LE8w+d8cG6b%&N|4JUrIgFd@j@|6L8yG#&JU1{ zU^BMNRq&$d=B1fO{?%)gk*9t=I6sTe6dK4HOrHX+GWR#OMV zAU!>iAs?Pw@>EE6GL_30(k`+HM`iSucqe!c_EERgEm<5sRLr@S@9lb3elIt&`5e+x zKI>AwURk(0zfxUX7O8B0iZfzQm)X_PA&ty_aMN}Z8Ebo2K7DN|5Dtu+N#Pi=>X`(q~)tX3E?VV#cF;dm11NOG_xUdy;=_gjLxN@EA+X5cGX((mLtRYLUFP% zdJv#oSXh|5bQxqlHaRmn>Hr78TZmDBaw;SHLof8aMdbMH?WX+vT)7r>yL<|nKeCG&+`QUaURB^8N}&rD7H$%v|Z)#}v+Bxp~`RIUI>=aTJ=`_&ck z6c~(c^?mh^^1#h>{@B!bAwN|*c%x>VI6XOAy;;HclY*1`+5ulTWz2gl1l?)$=FKerW*%!fzk}sTx&E|H@205lr9G+ z4)(t=_uT6E<40qQu8GoS19^Q%9=IN#1pkNSXRi4oKVBS5qxx{;k*?mD73+!YuCE4- z#;vt=q!l^cQ5bW>`cmUq>8M&gR2+zmCKHq&i&anz!TyD0fg|0Syr+ znNFphq`*C|g{e_S*bWLxA{1Pf6#&ObON`skGolgOXhjCMNlH5sVLBkmMFbiZ`YWV0 z$SNhS@dH^13jfdkY~FcPjFRDc<*2TXBZQmVo9pM*6-hy^0Eg$>B~oD1;kenX6c z-`Fw(G$8XTSZYKvN&iN*b`9XUduDnhtS`>bUmFJZ^z`&fUcE($*E>vCwdWFA;?a}g0q}Q;# zG%zy*il|7H(FDxQ%kU+8NPMs%>W#1c z6@_4>4h}*n7l-NF9Kft_0MUXy0ipzjF4rRs_O2-p!*9TQh`8lCi@*o)yr2jG%@`kq z^FbA01r7z1>sws~8vqTeUBm|O#-fZNcpAGb^rZ$mV=Ss?-o9~=;Frk^MR8Qi`W~1K zWR$=cw_(jV_s}dfC;%hgN1_#2*1HjAFI3v-4NlUvIggS0+H6Nx>QBWGcnA6eP$5eK zmG}!gH~<$}9zO)e@(z-BKD?KL1+a6~!2!jYN(H$tD}!PZKs9BY4FrBP4H;Supe&!Y5zBt0-1^N5^cBO|&IB!br?aYwls|Y^V)c0N+&YGGmw!YYu6X(3<D!jJ( zj(mo|nNDFMX(a}Rlh}q-eKPESv-<14va|UyS+62FDT+9-nv+W<6JEVqTM8Vo9n1y# zckEzUsd`NXAZ5!kj+LgfwjER})ny+!72ze2Ky2G(Vn;D8l|?a018Ef6g0ORey@Rk? zF4sX>b~0>LVSbQqL^#PzHjk3QYE>as5cn1l&#`S|Gm_+-2dXB>10MB7I+YS3V%=Z1 zZDpyzM+uN}gsLGi#zLqL2PZ9R$JDG>IyNC8}YVE4*I143$u1k;@%p9Sxr zoPlo~*O3WbI)nev6^#Kz0BTU2KrU3p_{DqOfI<)&wr!jrP=kZ;xB$i+=*>|EVzhC= z`9zY9K0@>1#~=r(M1Yl<31MC=BY07s0W!clU<4}$%ULWiO&6a&iKu$ zfIct>-^8V=wZZc=U&XZ9AOMmwxz};zF|vdQMT#*d;Z`!V2nwQ zL3|D~LLkur9d}Ra2)lEyZO;#{tczhpAy$HdBp=2Ma5Q@mzGd7p+cHN0HBf{B3JS&# zG;$DWuwY+-`#8nLSPc-Ck#P?KLoa9@hesg^z{5Bg)FFk*7?-#UU7!WB6zGOyiG{|| zG0(>kWXmvQ@yRE12QUW)a6jB)G5L})$p}Qv0gd%&eDiMzhS^zWMzG|h%ENK(q8~GZ zMlqkXq7D8)e-1kc_ohEiuZbvPRa*&m!dj4l5e}QQCCA*jf;1f9h#AhcSY}Up%*p*F z`a)E0-(xrCJkGWl%^EQdVG?{W=fG0!`%xZ`2Ar1j8rmm1diAV(8B)(Q$dv@UfHAqp zp+oXcZp1ikSZ%j9Uug}XDeK*~o{Oci7(17}2Sma<+M-Q_Su_jKmq+7NeHQ^}uN$~b2aj107< zM!Pyg1EQY+oq$xFlMv-hz=q7j;sJNo8G$i^JNUzG+(k`^74#`IoTRmY(QS@trSA9% zy%#av^;RB6Z##gGblf0v_M#g|7{W~K0hYBvPqjJWLw`x%LZs^Pc$$Ex0YIA0TR=Di z4g)cQ^Cd?B>S4wdM+ZQt0Rdt0ht{?o__+@EG~_S`HshDE!8ifdI6C(5%eqQr<#tbK z#>?t?&=plFYj^!;^!JDmNXNTC^lZm62NLYaEYZd=zHyC(+bd7wK3>ck+!M_#2Md7T z?RBtJTQdISRoTvv4Bx1rneK4aj*~$|Ige^j&%6SC+SdC_jlKiSvV4qL=nng`hrq1t zDqM{)0$C3Hf<5@j0Xnf9h(q)SHz2Ad8-pDVG;_tvmcS(jakRGa$bC^K>xkqX1;c_R zV~LnVx!MZ&0>KVD2rVNLUV*;VMWf@H?BRT#K4D{X{cUWeu?Mui{k|uOj^c{Hf7icz zCUUvMcV*r~lo`3}Jz26N-LL=lDJKU0u9x|{dpvMv_RIq(FO=K|A9?)zTjL(NkKSoO zPyD-===mr4#*riV=zs-3L)Lxs<9~7A52tQI9bp`yXNSxQJY$_cd2ZT$sC4S=x#=^r zr`?Itg~uPG2hW}T^U|pc_t5c^Ph2=V>yDl~Gkv=BZg=lz@$uQ&(jzX7J#wZri(bc0 zKH-j?ee|)Dvro|E`O-P}!zUjt-9yFFxpODq{Xpr|!zX9YoI3A*VD^;zZukC^=gyru ze|DB8N@vdB~v|`{qWffrSsGaWx{(Xe*jt*&z+pb zPeBhNG7ivfkq#){0gZq9^%l{6$IF#7vv+Av9E6Fo3zWYDy{Ag&F8uzZzV{!LeNvh4^fSsr^xnuX#0~0%Y=@^zqCfh6=MNq`e}?Y;=fmEC z<9E>6f2RYu83~{4G`nA?{+`6%cZ%q3m`)60F8#Xs8tu!HdV%t2{X;|oMdolLy17OB za-{y7-i!7>rXGC0hW3GDtF$jqs!TaR;nVa7_{2Smey>RT4w3o}eH=Z%OOQ483AEdX z2I=Pj5W)&=>B#M@f+zs{9M@nb7klRVmq!exKKr*{uetG8uD`fgU0nL*uU>uajk@2s zg@fpAY1Sf#F1q=i-oF0+81_myjvR=`pdCqW9<5pIPk(7^Eo&? zK}A2_v+^bH!~uyWlqtmYULK9p|rJEid93tA~R;=!cyQFX8av_TI}$;>B8X zLPnJdc0)~y-t?{0RA$%1Zy%(0>}hyR9HS_7qc@&z>-No?Zv59TzErK%mVWt4TQ_)Y z3m&`HNS|=?c!CApVtYPw2B4327SzIv4=mv_)~ug(>%sq)FuUbfW+n?b55_aYIvxTo zEX-emw=P|RI`hz{?J_)+L;0*L{0tNiZ3It zN2;z|Tf3$I*6sR~V=1L2QgLo_<|LY$y$&Tjkc-pqU^pO*9 zxboFt?f<;i*G5dG*4NiR==ooI^wWpj_-5j^7|+0eJRXW-4q<=v{;xbG)w|yF>Tg`R zx^@dfv|o690~u7fwz~gwUNHN)k9WCp|3C~z)N=6fC}bc^^f$Krv#)+$x$(p=U*{=q zu_|v)!+N~B@u|mrdio3Uc9dlpuqOg$&~u3XzJEp9`O06v6m{b-zk%UA#cyZ%&k@7( ze*TFIKXBv!^0v22jM6s1VExCI=wEvB%Tj&zyE`ufHFb4s;zRWIn5ns@i~8T<9GpuM I{nzII0@&1)d;kCd literal 0 HcmV?d00001 diff --git a/tests/fixtures/fft-oracle-2026-09-14/N844LPPR.3S0W b/tests/fixtures/fft-oracle-2026-09-14/N844LPPR.3S0W new file mode 100644 index 0000000000000000000000000000000000000000..b4395a16c0005446e6b5d122adc115e1672312da GIT binary patch literal 8306 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/fft-oracle-2026-09-14/N844LRCW.F30W b/tests/fixtures/fft-oracle-2026-09-14/N844LRCW.F30W new file mode 100644 index 0000000000000000000000000000000000000000..6e9ef75628c54a71e198b1adec9326f7a1a9c3e6 GIT binary patch literal 9654 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 0.25: + misses.append(f"{fname}:{ch} got {got_hz} want {want_hz}") + assert not misses, "dominant-frequency mismatches:\n" + "\n".join(misses) + + +def test_amplitude_matches_blastware(): + misses = [] + for fname, chans in ORACLE.items(): + spectra = _spectra(fname) + for ch, (_, want_amp) in chans.items(): + if want_amp is None: + continue + _, got_amp = dominant_frequency(*spectra[ch]) + if abs(got_amp - want_amp) > 0.0015: + misses.append(f"{fname}:{ch} got {got_amp:.4f} want {want_amp:.3f}") + assert not misses, "amplitude mismatches:\n" + "\n".join(misses) diff --git a/waveform_fft.py b/waveform_fft.py new file mode 100644 index 0000000..7e84565 --- /dev/null +++ b/waveform_fft.py @@ -0,0 +1,66 @@ +"""Blastware-compatible FFT of a decoded seismograph channel. + +Pure numpy; no I/O, no device or DB dependencies. Feed it a channel's decoded +samples **in the unit you want the amplitudes in** (e.g. in/s) and it returns the +single-sided amplitude spectrum that Blastware's *FFT Report* draws. + +Reverse-engineered 2026-09-14 against 7 BE12844 (MiniMate Plus) events with +Blastware FFT reports as ground truth. The recipe reproduces Blastware's +**dominant frequency to the exact 0.25 Hz bin on all 28 channels** and the +amplitude to report precision: + + 1. remove the DC component (subtract the mean); **no window** — a window + smears the peak and measurably worsens the match, + 2. zero-pad to ``nfft`` (4096 → 0.25 Hz bins at 1024 sps — Blastware's + resolution), + 3. single-sided amplitude ``A[k] = 2·|X[k]| / N`` where ``N`` is the real + sample count (not ``nfft``). + +The compliance chart (USBM RI8507 / OSMRE) is this spectrum's ``(freq, amp)`` +points plotted against the regulatory limit curve; the #10 FFT view is the +spectrum itself. +""" +from __future__ import annotations + +import numpy as np + +BW_NFFT = 4096 # 0.25 Hz bins at 1024 sps — Blastware's FFT resolution +BW_FMIN = 2.0 # dominant-frequency search floor (Hz) +BW_FMAX = 250.0 # dominant-frequency search ceiling (Hz) + + +def channel_spectrum(samples, sps: float = 1024.0, nfft: int = BW_NFFT): + """Single-sided amplitude spectrum of one channel, Blastware-compatible. + + ``samples`` is a 1-D sequence in the desired amplitude unit (in/s). Returns + ``(freqs, amps)`` numpy arrays covering ``0 .. sps/2`` in ``sps/nfft`` steps. + + Records longer than ``nfft`` are truncated by the transform — untested + against Blastware for that case (real MiniMate Plus records are ≤ ~3.3 s, + well under 4096 samples at 1024 sps). + """ + x = np.asarray(samples, dtype=float) + n = x.size + if n == 0: + return np.empty(0), np.empty(0) + x = x - x.mean() # DC removal, no window + mag = np.abs(np.fft.rfft(x, nfft)) + freqs = np.fft.rfftfreq(nfft, 1.0 / sps) + amps = (2.0 / n) * mag # single-sided amplitude + return freqs, amps + + +def dominant_frequency(freqs, amps, fmin: float = BW_FMIN, fmax: float = BW_FMAX): + """Peak ``(frequency_hz, amplitude)`` of a spectrum within ``[fmin, fmax)``. + + Matches Blastware's "Dominant Frequency" — the largest spectral bin in the + reportable band (below 2 Hz is baseline/DC drift, above 250 Hz is noise). + """ + freqs = np.asarray(freqs) + amps = np.asarray(amps) + lo = int(np.searchsorted(freqs, fmin)) + hi = int(np.searchsorted(freqs, fmax)) + if hi <= lo: + return 0.0, 0.0 + k = lo + int(np.argmax(amps[lo:hi])) + return float(freqs[k]), float(amps[k]) -- 2.54.0 From dad35e47feff486877c3565495c2e3864d269255 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 14 Sep 2026 18:34:18 +0000 Subject: [PATCH 09/28] feat(compliance): USBM RI8507/OSMRE compliance chart + reference doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf --- docs/ri8507_compliance_curve.md | 135 ++++++++++++++++++++++++++++++++ sfm/compliance.py | 132 +++++++++++++++++++++++++++++++ tests/test_compliance.py | 35 +++++++++ 3 files changed, 302 insertions(+) create mode 100644 docs/ri8507_compliance_curve.md create mode 100644 sfm/compliance.py create mode 100644 tests/test_compliance.py diff --git a/docs/ri8507_compliance_curve.md b/docs/ri8507_compliance_curve.md new file mode 100644 index 0000000..befb1a3 --- /dev/null +++ b/docs/ri8507_compliance_curve.md @@ -0,0 +1,135 @@ +# USBM RI8507 / OSMRE Blasting Compliance Curve — Reference + +Reference for the **velocity-vs-frequency blasting compliance chart** Blastware +draws on its Event Report ("USBM RI8507 And OSMRE"), and how seismo-relay +reproduces it. Implemented in [`sfm/compliance.py`](../sfm/compliance.py); the +spectral (FFT) side lives in [`waveform_fft.py`](../waveform_fft.py). + +Reverse-engineered 2026-09-14 against 7 BE12844 (MiniMate Plus) events, each +with a Blastware Event Report + FFT Report as ground truth. Curve values from +USBM RI8507 Appendix B and 30 CFR 816.67. + +--- + +## What it is + +Two closely-related sources for the same limit curve: + +- **USBM RI8507** — Bureau of Mines *Report of Investigations 8507* (Siskind + et al., 1980), *"Structure Response and Damage Produced by Ground Vibration + From Surface Mine Blasting."* The curve is **Figure B-1**, Appendix B + ("Alternative Blasting Level Criteria"), p.73–74. +- **OSMRE / OSM** — the Office of Surface Mining Reclamation and Enforcement + codified it as **30 CFR 816.67, Figure 1**. "CFR" = the U.S. Code of Federal + Regulations. Same curve, regulatory force. + +The chart plots each geophone channel's significant vibration cycles as +`(frequency, peak velocity)` points against this limit. A point **below** the +line passes; **above** fails. + +--- + +## The limit curve + +A structure has a resonance band (~4–12 Hz for whole structures) where it is +most vulnerable, so the safe velocity is **lower** at those frequencies and +**higher** away from them. The curve captures this by alternating two kinds of +bound: + +- **Constant-velocity** segments — a flat horizontal line at a fixed PPV. +- **Constant-displacement** segments — a fixed peak *displacement* `d`. For + simple harmonic motion, peak velocity `v = 2πf·d`, so on a velocity-vs- + frequency **log-log** plot this is a straight line of slope +1 (velocity rises + with frequency). This is why the low- and high-frequency bounds are sloped. + +### Two lines — structure type + +RI8507 gives two lines for two interior-wall constructions (Table 13, p.67): + +| line | construction | plateau PPV | +|---|---|---| +| **Drywall** (solid) | modern gypsum wallboard | **0.75 in/s** | +| **Plaster** (dashed) | older plaster on wood lath | **0.50 in/s** | + +Plaster-on-lath is more damage-prone, hence the lower limit. You apply **one** +line depending on the monitored structure. + +### The four segments (Figure B-1, p.74) + +Going low → high frequency, each line is: + +1. **Ultimate low-frequency bound** — constant displacement **0.030 in** + (`v = 2πf·0.030`). Only relevant below ~4 Hz. +2. **Plateau** — constant velocity **0.75** (Drywall) / **0.50** (plaster) in/s. +3. **Rising diagonal** — constant displacement **0.008 in** (`v = 2πf·0.008`), + climbing from the plateau up to the high-frequency cap. +4. **High-frequency cap** — constant velocity **2.0 in/s** above ~40 Hz. + +The segments are drawn **continuous**: each bound is used over the frequency +range where it is the binding (lowest) limit, and consecutive bounds meet where +they are equal — so there are no vertical steps. Transition frequencies come +straight from the values (`f = V / (2π·d)`): + +| transition | formula | Drywall | Plaster | +|---|---|---|---| +| 0.030 in → plateau | `V_mid / (2π·0.030)` | 3.98 Hz | 2.65 Hz | +| plateau → 0.008 in | `V_mid / (2π·0.008)` | 14.92 Hz | 9.95 Hz | +| 0.008 in → 2.0 in/s | `2.0 / (2π·0.008)` | 39.79 Hz | 39.79 Hz | + +Because both lines share the same **0.008 in** rising diagonal, above ~15 Hz +they lie on the *same* line (both reach 2.0 in/s at ~40 Hz) — RI8507's literal +construction merges them there. Blastware renders the dashed line as a separate +parallel diagonal, but that is cosmetic: above ~15 Hz both structure types carry +the identical limit, so compliance is unaffected. + +> ⚠ RI8507's *Table 13* is a simpler two-range criterion with a **sharp +> discontinuity at 40 Hz** (flat plateau, then a jump to 2.0). Figure B-1 is the +> **smoothed** version that adds the 0.008 in transition — that is the one drawn +> on reports and implemented here. + +--- + +## The compliance scatter (the points) + +The cloud is **not** the FFT spectrum. It is a per-cycle, time-domain measure by +the **zero-crossing method** (`channel_compliance_points`): + +- Split the channel's waveform at its zero crossings. +- Each half-cycle contributes one point: **frequency** `= 1 / (2 · half-period)` + (from the samples between the two crossings), **velocity** `= peak |amplitude|` + in that half-cycle. + +This yields ~90–110 points per channel, and — by construction — each channel's +**highest** point equals that channel's PPV. Verified against Blastware: the +cloud shape, density, and ceiling all match. + +### Why not the FFT? + +A broadband blast spreads its energy across many FFT bins, so no single bin +reaches the time-domain peak — the FFT amplitudes come out ~10× below the +compliance-chart velocities. The compliance chart is a *per-cycle peak* view; +the **FFT** is a separate analysis (Blastware's *FFT Report*), reproduced by +[`waveform_fft.py`](../waveform_fft.py) and used for the dominant-frequency +readout and the #10 FFT view — not for this scatter. + +--- + +## Implementation + +- `sfm/compliance.py` + - `limit_at(freq, curve)` — the limit PPV at a frequency (`curve` = `"Drywall"` + or `"Plaster"`); curves are data in `_CURVES`, so more standards can be added. + - `channel_compliance_points(samples, sps)` — the zero-crossing scatter. + - `draw_compliance_chart(ax, channels, sps)` — matplotlib rendering (both + limit lines + per-channel scatter, Blastware's tick scales and channel + markers: Tran `+` red, Vert `×` green, Long `o` blue). +- Tests: `tests/test_compliance.py`. + +--- + +## Sources + +- USBM **RI8507** (Siskind, Stagg, Kopp, Dowding, 1980), Appendix B / Figure B-1, + p.73–74; Table 13, p.67. (`ref-stuff/usbm-ri8507-ground_vibration.pdf`.) +- **30 CFR 816.67**, "Use of explosives: Control of adverse effects," Figure 1 — + diff --git a/sfm/compliance.py b/sfm/compliance.py new file mode 100644 index 0000000..2b21585 --- /dev/null +++ b/sfm/compliance.py @@ -0,0 +1,132 @@ +"""USBM RI8507 / OSMRE blasting compliance chart. + +Renders the velocity-vs-frequency compliance scatter Blastware draws on its Event +Report: each channel's significant waveform cycles as ``(frequency, peak +velocity)`` points on log-log axes against the regulatory limit curve(s). A point +below the curve passes; above fails. + +Two pieces, kept separate so both can be reused/extended: + * ``limit_at`` / ``limit_curve`` — the regulatory limit curve(s), as data. + * ``channel_compliance_points`` — the per-cycle (freq, velocity) scatter, by + the zero-crossing method (matches Blastware: each channel's cloud tops out + at that channel's PPV). + +Limit curves (USBM RI8507 Figure B-1 / OSM 30 CFR 816.67), drawn CONTINUOUS — a +constant-displacement bound (sloped, ``v = 2πf·d``) meets a constant-velocity +plateau at the frequency where they're equal, so there are no vertical steps +(matching how Blastware draws it). Two lines: + * **Drywall** (modern gypsum board) — 0.75 in/s plateau (solid). + * **Plaster** on wood lath (older homes) — 0.50 in/s plateau (dashed). +Both use a 0.030 in low-frequency displacement bound and rise through a 0.010 in +displacement bound to a 2.0 in/s high-frequency plateau. Values from USBM RI8507 +(Appendix B) / 30 CFR 816.67; ⚠ confirm the exact shape against a Blastware +report before trusting for compliance. +""" +from __future__ import annotations + +import math +from typing import Dict, Sequence, Tuple + +import numpy as np +from matplotlib.ticker import FixedLocator, NullLocator + +# curve name → (low-freq "ultimate" displacement in, mid velocity plateau in/s, +# high-freq displacement in, high-freq velocity plateau in/s). +# RI8507 Fig B-1 (p.74): ultimate max displacement 0.030 in (< ~4 Hz), plateau +# 0.75 (Drywall) / 0.50 (plaster), rising diagonal at 0.008 in displacement up to +# a 2.0 in/s plateau reached at ~40 Hz. +_CURVES: Dict[str, Tuple[float, float, float, float]] = { + "Drywall": (0.030, 0.75, 0.008, 2.00), + "Plaster": (0.030, 0.50, 0.008, 2.00), +} +# how each curve is stroked on the chart +_CURVE_STYLE = {"Drywall": {"ls": "-", "lw": 1.0}, "Plaster": {"ls": "--", "lw": 0.9}} + +STANDARDS = tuple(_CURVES) + +# Blastware's channel markers/colours on the compliance chart. +_CHANNEL_STYLE = { + "Tran": ("+", "#d62728"), # red + + "Vert": ("x", "#2ca02c"), # green x + "Long": ("o", "#1f77b4"), # blue o +} + + +def limit_at(freq_hz: float, curve: str = "Drywall") -> float: + """Max allowed PPV (in/s) at ``freq_hz`` for ``curve`` (continuous).""" + d_low, v_mid, d_high, v_high = _CURVES[curve] + f = max(freq_hz, 1.0) + f_a = v_mid / (2.0 * math.pi * d_low) # disp_low → vel_mid + f_b = v_mid / (2.0 * math.pi * d_high) # vel_mid → disp_high + f_c = v_high / (2.0 * math.pi * d_high) # disp_high → vel_high + if f <= f_a: + return 2.0 * math.pi * f * d_low + if f <= f_b: + return v_mid + if f <= f_c: + return 2.0 * math.pi * f * d_high + return v_high + + +def limit_curve(curve: str = "Drywall", fmin: float = 1.0, fmax: float = 100.0, n: int = 400): + """(freqs, limits) sampled across the band for plotting one curve.""" + freqs = np.logspace(np.log10(fmin), np.log10(fmax), n) + return freqs, np.array([limit_at(f, curve) for f in freqs]) + + +def channel_compliance_points( + samples: Sequence[float], sps: float, fmin: float = 1.0, fmax: float = 100.0, + vmin: float = 0.0, +) -> Tuple[np.ndarray, np.ndarray]: + """Per-cycle (frequency, peak velocity) scatter for one channel. + + Zero-crossing method: split the trace at sign changes; each half-cycle + contributes one point at ``(1/(2·half_period), max|amplitude|)``. Matches + Blastware — the cloud's ceiling is the channel PPV. ``samples`` must be in the + velocity unit you want plotted (in/s). Points outside ``[fmin, fmax]`` or at + or below ``vmin`` are dropped. + """ + x = np.asarray(samples, dtype=float) + if x.size < 3: + return np.empty(0), np.empty(0) + zc = np.where(np.diff(np.signbit(x)))[0] + freqs, vels = [], [] + for a, b in zip(zc[:-1], zc[1:]): + half_period = (b - a) / sps + if half_period <= 0: + continue + freqs.append(1.0 / (2.0 * half_period)) + vels.append(float(np.abs(x[a:b + 1]).max())) + f = np.array(freqs) + v = np.array(vels) + keep = (f >= fmin) & (f <= fmax) & (v > vmin) + return f[keep], v[keep] + + +def draw_compliance_chart(ax, channels: Dict[str, Sequence[float]], sps: float) -> None: + """Draw the compliance chart (both limit curves + per-channel scatter).""" + for name, style in _CURVE_STYLE.items(): + cf, cv = limit_curve(name) + ax.plot(cf, cv, color="#333", zorder=3, **style) + + for ch, (marker, color) in _CHANNEL_STYLE.items(): + samples = channels.get(ch) + if samples is None or len(samples) == 0: + continue + f, v = channel_compliance_points(samples, sps) + ax.scatter(f, v, marker=marker, s=12, c=color, linewidths=0.7, zorder=4, label=ch) + + ax.set_xscale("log") + ax.set_yscale("log") + ax.set_xlim(1, 100) + ax.set_ylim(0.0394, 10) + xt = [1, 2, 5, 10, 20, 50, 100] + yt = [0.0394, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10] + ax.xaxis.set_major_locator(FixedLocator(xt)); ax.xaxis.set_minor_locator(NullLocator()) + ax.yaxis.set_major_locator(FixedLocator(yt)); ax.yaxis.set_minor_locator(NullLocator()) + ax.set_xticklabels([str(v) for v in xt]) + ax.set_yticklabels([("%g" % v) for v in yt]) + ax.set_xlabel("Frequency (Hz)", fontsize=7) + ax.set_ylabel("Velocity (in/s)", fontsize=7) + ax.tick_params(labelsize=6) + ax.grid(True, which="both", ls=":", lw=0.4, color="#ccc") diff --git a/tests/test_compliance.py b/tests/test_compliance.py new file mode 100644 index 0000000..c102e9c --- /dev/null +++ b/tests/test_compliance.py @@ -0,0 +1,35 @@ +"""USBM/OSMRE compliance curve + scatter logic (sfm.compliance). +Rendering is verified visually against Blastware reports.""" +import math + +import numpy as np + +from sfm.compliance import limit_at, channel_compliance_points + + +def test_osmre_velocity_segments(): + assert abs(limit_at(6.0) - 0.75) < 1e-9 # 3.5–12 Hz flat + assert abs(limit_at(50.0) - 2.00) < 1e-9 # 30–100 Hz flat + + +def test_displacement_segments(): + assert abs(limit_at(2.0) - 2 * math.pi * 2.0 * 0.030) < 1e-9 # low-freq 0.030 in + assert abs(limit_at(20.0) - 2 * math.pi * 20.0 * 0.008) < 1e-9 # rising diagonal 0.008 in + + +def test_limit_clamps_below_1hz(): + assert limit_at(0.1) == limit_at(1.0) + + +def test_scatter_ceiling_is_ppv_at_dominant_freq(): + # ~27 Hz blast-like trace whose energy peaks mid-record (inside full cycles, + # as a real event does): the scatter cloud's ceiling is the trace PPV and the + # top point sits near the dominant frequency. + sps, n = 1024.0, 3328 + t = np.arange(n) / sps + env = np.exp(-((t - 1.5) ** 2) / (2 * 0.3 ** 2)) + x = 0.9 * env * np.sin(2 * np.pi * 27.0 * t) + f, v = channel_compliance_points(x, sps) + assert len(f) > 20 + assert v.max() >= 0.99 * np.abs(x).max() + assert 20.0 < f[int(np.argmax(v))] < 35.0 -- 2.54.0 From db818f716cd808877c03de3007fdf9c01c78a9c1 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 14 Sep 2026 20:18:29 +0000 Subject: [PATCH 10/28] feat(report): draw the USBM RI8507 compliance chart on the event report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf --- sfm/report_pdf.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/sfm/report_pdf.py b/sfm/report_pdf.py index 60dca98..f0e7a97 100644 --- a/sfm/report_pdf.py +++ b/sfm/report_pdf.py @@ -578,15 +578,16 @@ def _draw_mic_and_usbm(ax, rd: ReportData) -> None: _kv(ax, 0.0, y, label, value, label_w=0.18) y -= 0.15 - # USBM chart placeholder — upper-right. Real piecewise compliance - # curves are a separate work item; for now this just shows the title - # + a "see report" message so the layout is correct. - ax.text(0.72, 0.97, "USBM RI8507 And OSMRE", - fontsize=9, weight="bold", color="#333", ha="center", va="top", - transform=ax.transAxes) - ax.text(0.72, 0.50, "[compliance chart\ncoming soon]", - fontsize=8, color="#bbb", ha="center", va="center", - transform=ax.transAxes, style="italic") + # USBM RI8507 compliance chart — inset axes in the right half of this band. + ax.text(0.74, 1.00, "USBM RI8507", fontsize=9, weight="bold", color="#333", + ha="center", va="top", transform=ax.transAxes) + if rd.channels and rd.sample_rate_sps: + from sfm.compliance import draw_compliance_chart + inset = ax.inset_axes([0.52, 0.02, 0.46, 0.82]) + draw_compliance_chart(inset, rd.channels, rd.sample_rate_sps) + else: + ax.text(0.74, 0.48, "(no waveform data)", fontsize=8, color="#bbb", + ha="center", va="center", transform=ax.transAxes, style="italic") def _mic_rows(rd: ReportData) -> list[tuple[str, Optional[str]]]: -- 2.54.0 From 6ad4fd73dd1eb17e7b29e7c663d593cd12f9212b Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 14 Sep 2026 20:21:17 +0000 Subject: [PATCH 11/28] fix(compliance): square plot box (set_box_aspect) so the chart isn't squashed 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 Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf --- sfm/compliance.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sfm/compliance.py b/sfm/compliance.py index 2b21585..bdc4692 100644 --- a/sfm/compliance.py +++ b/sfm/compliance.py @@ -120,6 +120,7 @@ def draw_compliance_chart(ax, channels: Dict[str, Sequence[float]], sps: float) ax.set_yscale("log") ax.set_xlim(1, 100) ax.set_ylim(0.0394, 10) + ax.set_box_aspect(1) # square plot box (log-log compliance charts are square) xt = [1, 2, 5, 10, 20, 50, 100] yt = [0.0394, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10] ax.xaxis.set_major_locator(FixedLocator(xt)); ax.xaxis.set_minor_locator(NullLocator()) -- 2.54.0 From 95f926c3187f05020005c344187fe47febec95b0 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 14 Sep 2026 20:33:09 +0000 Subject: [PATCH 12/28] feat(report): enlarge the compliance chart to a full upper-right panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf --- sfm/report_pdf.py | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/sfm/report_pdf.py b/sfm/report_pdf.py index f0e7a97..b1545e9 100644 --- a/sfm/report_pdf.py +++ b/sfm/report_pdf.py @@ -396,9 +396,27 @@ def _render_waveform_layout(fig, rd: ReportData) -> None: ax_stats = fig.add_subplot(gs[2]); ax_stats.axis("off") _draw_channel_stats_waveform(ax_stats, rd) + _draw_compliance_panel(fig, gs, rd) _draw_waveform_subplot(fig, gs[3], rd) +def _draw_compliance_panel(fig, gs, rd: ReportData) -> None: + """Large square USBM RI8507 compliance chart in the upper-right, spanning the + mic (row 1) and stats (row 2) rows — matching Blastware's Event Report.""" + bottoms, tops, _lefts, rights = gs.get_grid_positions(fig) + y0, y1 = bottoms[2], tops[1] # bottom of stats row → top of mic row + x0, x1 = 0.64, rights[0] # clear of the stats columns → page right margin + fig.text((x0 + x1) / 2, y1 + 0.004, "USBM RI8507", fontsize=9, weight="bold", + color="#333", ha="center", va="bottom") + if rd.channels and rd.sample_rate_sps: + from sfm.compliance import draw_compliance_chart + ax = fig.add_axes([x0, y0, x1 - x0, y1 - y0]) + draw_compliance_chart(ax, rd.channels, rd.sample_rate_sps) + else: + fig.text((x0 + x1) / 2, (y0 + y1) / 2, "(no waveform data)", fontsize=8, + color="#bbb", ha="center", va="center", style="italic") + + def _render_histogram_layout(fig, rd: ReportData) -> None: """Histogram layout: header / mic-only / per-channel stats / bar plot. @@ -577,17 +595,8 @@ def _draw_mic_and_usbm(ax, rd: ReportData) -> None: for label, value in rows: _kv(ax, 0.0, y, label, value, label_w=0.18) y -= 0.15 - - # USBM RI8507 compliance chart — inset axes in the right half of this band. - ax.text(0.74, 1.00, "USBM RI8507", fontsize=9, weight="bold", color="#333", - ha="center", va="top", transform=ax.transAxes) - if rd.channels and rd.sample_rate_sps: - from sfm.compliance import draw_compliance_chart - inset = ax.inset_axes([0.52, 0.02, 0.46, 0.82]) - draw_compliance_chart(inset, rd.channels, rd.sample_rate_sps) - else: - ax.text(0.74, 0.48, "(no waveform data)", fontsize=8, color="#bbb", - ha="center", va="center", transform=ax.transAxes, style="italic") + # The USBM compliance chart is drawn as its own large square panel spanning + # the mic + stats rows on the right — see _draw_compliance_panel(). def _mic_rows(rd: ReportData) -> list[tuple[str, Optional[str]]]: -- 2.54.0 From dc74c97ade6c6bc1d6ca36dc3638bddd12af177b Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 14 Sep 2026 22:56:44 +0000 Subject: [PATCH 13/28] feat(report): size the USBM compliance chart to match Blastware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf --- sfm/report_pdf.py | 87 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 62 insertions(+), 25 deletions(-) diff --git a/sfm/report_pdf.py b/sfm/report_pdf.py index b1545e9..87c556a 100644 --- a/sfm/report_pdf.py +++ b/sfm/report_pdf.py @@ -396,18 +396,25 @@ def _render_waveform_layout(fig, rd: ReportData) -> None: ax_stats = fig.add_subplot(gs[2]); ax_stats.axis("off") _draw_channel_stats_waveform(ax_stats, rd) - _draw_compliance_panel(fig, gs, rd) + _draw_compliance_panel(fig, rd) _draw_waveform_subplot(fig, gs[3], rd) -def _draw_compliance_panel(fig, gs, rd: ReportData) -> None: - """Large square USBM RI8507 compliance chart in the upper-right, spanning the - mic (row 1) and stats (row 2) rows — matching Blastware's Event Report.""" - bottoms, tops, _lefts, rights = gs.get_grid_positions(fig) - y0, y1 = bottoms[2], tops[1] # bottom of stats row → top of mic row - x0, x1 = 0.64, rights[0] # clear of the stats columns → page right margin - fig.text((x0 + x1) / 2, y1 + 0.004, "USBM RI8507", fontsize=9, weight="bold", - color="#333", ha="center", va="bottom") +# Compliance-chart placement, in figure fractions. Measured directly off a +# Blastware Event Report PDF (ref-stuff/n844lqhbzt0w_bw_pdf.pdf) so the chart +# matches BW's size and position: it spans from just under the header down +# through the stats band, hard against the right page margin. The left edge +# leaves room for the y-axis tick labels + "Velocity (in/s)" title, which the +# compacted stats table (see _draw_channel_stats_waveform) is sized to clear. +_COMPLIANCE_BOX = (0.489, 0.502, 0.951, 0.867) # x0, y0, x1, y1 + + +def _draw_compliance_panel(fig, rd: ReportData) -> None: + """Large USBM RI8507 compliance chart in the upper-right, sized and + positioned to match Blastware's Event Report (see _COMPLIANCE_BOX).""" + x0, y0, x1, y1 = _COMPLIANCE_BOX + fig.text((x0 + x1) / 2, y1 + 0.006, "USBM RI8507 And OSMRE", fontsize=9, + weight="bold", color="#333", ha="center", va="bottom") if rd.channels and rd.sample_rate_sps: from sfm.compliance import draw_compliance_chart ax = fig.add_axes([x0, y0, x1 - x0, y1 - y0]) @@ -495,11 +502,11 @@ def _split_iso_to_date_time(iso: Optional[str]) -> tuple[Optional[str], Optional return (None, None) -def _kv(ax, x, y, label, value, *, label_w=0.18): +def _kv(ax, x, y, label, value, *, label_w=0.18, fontsize=8): """Render a 'Label Value' row at axes-coordinates (x, y).""" - ax.text(x, y, label, fontsize=8, color="#555", ha="left", va="top", + ax.text(x, y, label, fontsize=fontsize, color="#555", ha="left", va="top", transform=ax.transAxes) - ax.text(x + label_w, y, _fmt(value), fontsize=8, ha="left", va="top", + ax.text(x + label_w, y, _fmt(value), fontsize=fontsize, ha="left", va="top", transform=ax.transAxes, family="monospace") @@ -592,8 +599,11 @@ def _draw_mic_and_usbm(ax, rd: ReportData) -> None: transform=ax.transAxes, va="top") rows = _mic_rows(rd) y = 0.80 + # Tighter label indent + slightly smaller font so the long "Channel Test + # Passed (Freq = … Amp = … mv)" line clears the enlarged compliance chart's + # left edge (_COMPLIANCE_BOX) instead of running behind it. for label, value in rows: - _kv(ax, 0.0, y, label, value, label_w=0.18) + _kv(ax, 0.0, y, label, value, label_w=0.13, fontsize=7) y -= 0.15 # The USBM compliance chart is drawn as its own large square panel spanning # the mic + stats rows on the right — see _draw_compliance_panel(). @@ -647,7 +657,13 @@ def _draw_channel_stats_waveform(ax, rd: ReportData) -> None: ("Peak Displacement", "peak_disp_in", "in"), ("Sensor Check", "sensor_check", ""), ] - _draw_stats_table(ax, rd, rows_spec) + # Compacted to the left half so the enlarged compliance chart (BW-sized, + # right against the page margin) has room — see _COMPLIANCE_BOX. + _draw_stats_table( + ax, rd, rows_spec, + bbox_width=0.42, fontsize=7.5, + col_widths=[0.185, 0.065, 0.065, 0.065, 0.040], + ) _draw_pvs_summary(ax, rd, n_data_rows=len(rows_spec)) @@ -708,19 +724,39 @@ def _draw_pvs_summary( table_bottom_y = getattr(ax, "_stats_table_bottom", -0.10) pvs_y = table_bottom_y - 0.04 # small gap below the table border - # Centered for visual balance — looks intentional rather than offset. - # The original BW-replica had a "NA: Not Applicable" caption below - # this line; dropped because we use "—" for missing values and the - # legend was always squished against the PVS line. - ax.text(0.5, pvs_y, line, fontsize=9, weight="bold", - ha="center", va="top", transform=ax.transAxes) + # Centered under the stats table for visual balance — looks intentional + # rather than offset. When the table is compacted (waveform layout), it + # occupies only the left portion of the axes, so center on the table's + # width rather than the full axes (which would push the line under the + # compliance chart). The original BW-replica had a "NA: Not Applicable" + # caption below this line; dropped because we use "—" for missing values. + table_w = getattr(ax, "_stats_table_width", 0.80) + if table_w < 0.79: + # Compacted (waveform) layout: left-align under the table, one point + # smaller, so the line clears the enlarged compliance chart's + # bottom-left tick labels on the right. + ax.text(0.0, pvs_y, line, fontsize=8, weight="bold", + ha="left", va="top", transform=ax.transAxes) + else: + ax.text(0.5, pvs_y, line, fontsize=9, weight="bold", + ha="center", va="top", transform=ax.transAxes) -def _draw_stats_table(ax, rd: ReportData, rows_spec: list[tuple[str, str, str]]) -> None: +def _draw_stats_table( + ax, rd: ReportData, rows_spec: list[tuple[str, str, str]], + *, bbox_width: float = 0.80, fontsize: float = 8, + col_widths: Optional[list[float]] = None, +) -> None: """Render a per-channel stats table (Tran/Vert/Long). rows_spec: list of (label, field_name_in_channel_stats, unit_string) + + ``bbox_width`` / ``col_widths`` / ``fontsize`` let a caller compact the + table (the waveform layout packs it into the left half to clear the + compliance chart; the histogram layout keeps the wider defaults). """ + if col_widths is None: + col_widths = [0.28, 0.14, 0.14, 0.14, 0.10] headers = ["", "Tran", "Vert", "Long", ""] ch_lookup = {c["name"]: c for c in rd.channel_stats} @@ -760,16 +796,17 @@ def _draw_stats_table(ax, rd: ReportData, rows_spec: list[tuple[str, str, str]]) table_bottom = 1.0 - table_height tbl = ax.table( cellText=table_data, - colWidths=[0.28, 0.14, 0.14, 0.14, 0.10], + colWidths=col_widths, cellLoc="left", edges="open", - bbox=[0.0, table_bottom, 0.80, table_height], + bbox=[0.0, table_bottom, bbox_width, table_height], ) tbl.auto_set_font_size(False) - tbl.set_fontsize(8) + tbl.set_fontsize(fontsize) for j in range(5): tbl[(0, j)].set_text_props(weight="bold", color="#555") - # Stash the bottom Y so _draw_pvs_summary can position itself below. + # Stash the bottom Y + width so _draw_pvs_summary can position itself. ax._stats_table_bottom = table_bottom + ax._stats_table_width = bbox_width def _channel_axis_color(ch: str) -> str: -- 2.54.0 From 6341432524bc9032fb1d6a1b098f693b8f27b3c1 Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 15 Sep 2026 05:26:28 +0000 Subject: [PATCH 14/28] feat(series3): decode sensor self-check waveforms from the binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf --- minimateplus/sensor_check.py | 146 +++++++++++++++++++++++++++++++++++ tests/test_sensor_check.py | 67 ++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 minimateplus/sensor_check.py create mode 100644 tests/test_sensor_check.py diff --git a/minimateplus/sensor_check.py b/minimateplus/sensor_check.py new file mode 100644 index 0000000..7432ea8 --- /dev/null +++ b/minimateplus/sensor_check.py @@ -0,0 +1,146 @@ +r"""Decode the Blastware sensor self-check waveforms from a series-3 event binary. + +Reverse-engineered 2026-09-15 against 7 BE12844 (MiniMate Plus) oracle events. +After the main waveform record-chain and the trailing metadata / per-channel +calibration records, the binary carries four length-prefixed records tagged +0x3c-0x3f: the sensor self-check traces the unit records when it pulses each +sensor before monitoring. Blastware draws these as the little waveforms in the +"Sensor Check" strip on the right of the Event Report. + + * 0x3c / 0x3d / 0x3e = Tran / Vert / Long geophone ring-downs (a damped + oscillation at the geophone's resonance, ~7-8 Hz at 1024 sps). + * 0x3f = MicL, a pulse train at the mic self-test frequency + (~20 Hz), whose zero-crossing frequency is BW's mic "Channel Test" freq. + +Record framing (per record, all four chained by their length prefix):: + + [len:2 BE][id:1][00 00][Nchan:1][12-byte header][delta stream][40 02][6B] + \_________________ payload (len bytes) _______________________________/ + +The delta stream is ``payload[20 : len-8]`` (the ``40 02`` terminator sits at +``len-8``, followed by 6 trailing bytes). It uses the exact same 10/20/30/00 +delta-block tags as the main waveform codec +(:mod:`minimateplus.waveform_codec`), decoded here from an implicit anchor of 0 +— so the traces come out in the same 16-count raw units as the main waveform +(LSB = 0.005 in/s at Normal range for the geophones). +""" +from __future__ import annotations + +from typing import Dict, List + +from minimateplus.waveform_codec import walk_body + +# Record id → channel. Order mirrors the trailing per-channel calibration +# records (Tran / Vert / Long / MicL), confirmed against BW's sensor-check +# frequencies on all 7 oracle events. +_ID_TO_CHANNEL = {0x3C: "Tran", 0x3D: "Vert", 0x3E: "Long", 0x3F: "MicL"} +_CHAIN_IDS = (0x3C, 0x3D, 0x3E, 0x3F) + +_HEADER_LEN = 20 # payload bytes before the delta stream +_TRAILER_LEN = 8 # 40 02 terminator + 6 trailing bytes after the stream + + +def _s4(nib: int) -> int: + """Sign-extend a 4-bit nibble delta.""" + return nib - 16 if nib >= 8 else nib + + +def _i8(byte: int) -> int: + """Sign-extend an 8-bit int delta.""" + return byte - 256 if byte >= 128 else byte + + +def _decode_delta_stream(buf: bytes) -> List[int]: + """Accumulate a 10/20/30/00 delta-block stream from an anchor of 0, + stopping at the 0x40 terminator. + + Mirrors the block semantics in + :func:`minimateplus.waveform_codec.decode_waveform_v2` (fully decoded & + byte-exact as of 2026-05-11); see that module for the format details. + """ + out: List[int] = [] + cur = 0 + for blk in walk_body(buf, 0): + fam = blk.tag_hi & 0xF0 + if fam == 0x10: + # nibble deltas, high nibble first + for byte in blk.data: + for nib in ((byte >> 4) & 0xF, byte & 0xF): + cur += _s4(nib) + out.append(cur) + elif fam == 0x20: + # int8 deltas + for byte in blk.data: + cur += _i8(byte) + out.append(cur) + elif fam == 0x30: + # 12-bit signed deltas, packed as tag_lo/4 groups of 6 bytes + for g in range(blk.tag_lo // 4): + grp = blk.data[g * 6:(g + 1) * 6] + if len(grp) < 6: + break + high_word = (grp[0] << 8) | grp[1] + for k in range(4): + nib = (high_word >> (12 - 4 * k)) & 0xF + v = (nib << 8) | grp[2 + k] + if v >= 0x800: + v -= 0x1000 + cur += v + out.append(cur) + elif fam == 0x00: + # RLE zero-delta run (wide form carries the high nibble in the tag) + run = ((blk.tag_hi & 0x0F) << 8) | blk.tag_lo + out.extend([cur] * run) + elif fam == 0x40: + # segment / record terminator + break + return out + + +def _find_chain(body: bytes): + """Locate the four length-prefixed sensor-check records. + + Returns a list of ``(offset, id, length)`` or ``None``. The chain is + validated by walking the ids 0x3c → 0x3d → 0x3e → 0x3f via their own length + prefixes, so a stray 0x3c byte in the waveform data cannot match. + """ + for p in range(len(body) - 6): + if body[p + 2] == 0x3C and body[p + 3] == 0 and body[p + 4] == 0: + q = p + recs = [] + ok = True + for expect in _CHAIN_IDS: + if q + 3 > len(body) or body[q + 2] != expect: + ok = False + break + length = int.from_bytes(body[q:q + 2], "big") + recs.append((q, expect, length)) + q = q + 2 + length + if ok and len(recs) == 4: + return recs + return None + + +def decode_sensor_check(raw: bytes) -> Dict[str, List[int]]: + """Decode the four sensor self-check traces from a series-3 event binary. + + Returns ``{"Tran": [...], "Vert": [...], "Long": [...], "MicL": [...]}`` in + raw decode units (same 16-count LSB as the main waveform), or ``{}`` if the + binary carries no sensor-check block (a histogram event, a non-series-3 + file, or a unit/firmware that doesn't store it). + """ + strt = raw.find(b"STRT") + if strt < 0 or len(raw) < strt + 21 + 26: + return {} + body = raw[strt + 21: len(raw) - 26] + chain = _find_chain(body) + if not chain: + return {} + out: Dict[str, List[int]] = {} + for off, rid, length in chain: + payload = body[off + 2: off + 2 + length] + if len(payload) < _HEADER_LEN + _TRAILER_LEN: + continue + stream = payload[_HEADER_LEN: length - _TRAILER_LEN] + out[_ID_TO_CHANNEL[rid]] = _decode_delta_stream(stream) + return out diff --git a/tests/test_sensor_check.py b/tests/test_sensor_check.py new file mode 100644 index 0000000..b39924a --- /dev/null +++ b/tests/test_sensor_check.py @@ -0,0 +1,67 @@ +"""Blastware sensor self-check waveform decode (minimateplus.sensor_check). + +Reverse-engineered 2026-09-15 against 7 BE12844 (MiniMate Plus) oracle events. +After the main waveform record-chain and the trailing metadata / per-channel +calibration records, a series-3 binary carries four length-prefixed records +tagged 0x3c-0x3f: the sensor self-check traces the unit records when it pulses +each sensor before monitoring (Blastware draws these as the little waveforms in +the "Sensor Check" strip on the right of the Event Report). + + * 0x3c / 0x3d / 0x3e = Tran / Vert / Long geophone ring-downs. + * 0x3f = MicL, a pulse train at the mic self-test frequency. + +The self-check injects a fixed pulse, so the response is near-identical across +events — asserted here as an invariant shape (damped one-sided ring-down for +the geophones, a multi-pulse train for the mic). +""" +from pathlib import Path + +import numpy as np + +from minimateplus.sensor_check import decode_sensor_check + +FIXDIR = Path(__file__).parent / "fixtures" / "fft-oracle-2026-09-14" +EVENTS = sorted(p.name for p in FIXDIR.iterdir()) # 7 BE12844 event binaries + + +def _decode(name): + return decode_sensor_check((FIXDIR / name).read_bytes()) + + +def test_all_four_channels_present(): + for name in EVENTS: + sc = _decode(name) + assert set(sc) == {"Tran", "Vert", "Long", "MicL"}, name + + +def test_geo_channels_are_damped_ringdowns(): + # Each geophone self-check is a large one-sided deflection (~-990 raw) that + # rings back and damps toward a settled value well above the trough. + for name in EVENTS: + sc = _decode(name) + for ch in ("Tran", "Vert", "Long"): + tr = np.asarray(sc[ch], dtype=float) + assert 240 <= len(tr) <= 260, f"{name}:{ch} n={len(tr)}" + assert abs(tr[:3].mean()) < 50, f"{name}:{ch} starts off-baseline" + assert tr.min() < -800, f"{name}:{ch} min {tr.min()}" + assert tr.max() < 60, f"{name}:{ch} unexpected positive swing {tr.max()}" + # damped: settles between the trough and zero, well above the trough + assert tr.min() < tr[-1] < 0, f"{name}:{ch} end {tr[-1]} not between trough and 0" + assert abs(tr[-1]) < 0.6 * abs(tr.min()), f"{name}:{ch} not damped, end {tr[-1]}" + + +def test_mic_channel_is_a_pulse_train(): + for name in EVENTS: + tr = np.asarray(_decode(name)["MicL"], dtype=float) + assert 235 <= len(tr) <= 255, f"{name} mic n={len(tr)}" + # larger dynamic range than the geo ring-down, and swings both ways + assert tr.min() < -1500, f"{name} mic min {tr.min()}" + assert tr.max() > 100, f"{name} mic max {tr.max()}" + # multiple pulses: several deep local minima + deep = (tr[1:-1] < tr[:-2]) & (tr[1:-1] < tr[2:]) & (tr[1:-1] < -800) + assert int(deep.sum()) >= 4, f"{name} mic pulses {int(deep.sum())}" + + +def test_returns_empty_when_no_sensor_check_block(): + assert decode_sensor_check(b"not a blastware file") == {} + assert decode_sensor_check(b"") == {} -- 2.54.0 From ab9d84fde6df4c38e3bc7c4d34a35319fd508394 Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 15 Sep 2026 05:33:38 +0000 Subject: [PATCH 15/28] feat(report): render the sensor-check strip + report polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf --- sfm/report_pdf.py | 81 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 6 deletions(-) diff --git a/sfm/report_pdf.py b/sfm/report_pdf.py index 87c556a..2771bba 100644 --- a/sfm/report_pdf.py +++ b/sfm/report_pdf.py @@ -121,6 +121,11 @@ class ReportData: t0_ms: Optional[float] = None dt_ms: Optional[float] = None + # Sensor self-check traces — {ch: [samples]} in raw decode units, decoded + # from the binary's trailing block (see minimateplus.sensor_check). The + # little waveforms BW draws in its "Sensor Check" strip. Empty when absent. + sensor_check_waveforms: dict = field(default_factory=dict) + # Record-type discriminator record_type: Optional[str] = None is_histogram: bool = False @@ -246,6 +251,8 @@ def gather_report_data( "peak_accel_g": ch.get("peak_accel_g"), "peak_disp_in": ch.get("peak_disp_in"), "sensor_check": sc_ch.get("result"), + "sc_freq_hz": sc_ch.get("freq_hz"), + "sc_ratio": sc_ch.get("ratio"), "peak_date": peak_date, "peak_time": peak_time, }) @@ -290,6 +297,19 @@ def gather_report_data( except Exception as exc: log.warning("gather_report_data: hdf5 read failed: %s", exc) + # ── Sensor self-check traces — decoded from the retained raw binary ── + # The .h5 holds only the main waveform; the sensor-check traces live in the + # binary's trailing block, so decode them straight from the kept BW file. + # Waveform events only (histograms have no sensor-check strip). + if not rd.is_histogram: + try: + from minimateplus.sensor_check import decode_sensor_check + bw_path, _a5 = store.paths_for(serial, filename) + if bw_path.exists(): + rd.sensor_check_waveforms = decode_sensor_check(bw_path.read_bytes()) + except Exception as exc: + log.warning("gather_report_data: sensor-check decode failed: %s", exc) + # ── Histogram aggregation ── # Codec emits ~N per-block samples (typically 1/sec); BW reports # one bar per configured interval (1 min / 5 min / etc.). When @@ -569,14 +589,17 @@ def _draw_header_columns(ax, rows_left, rd: ReportData) -> None: ("File Name", rd.file_name), ("Post Event Notes", rd.post_event_notes), ] + # fontsize 7.5 (BW's header is a touch smaller than our body text) + a + # tighter right-column value indent so the long serial+firmware line + # ("BE##### V ##.##-#.## MiniMate Plus") fits without running off the page. y = 0.95 dy = 0.095 for label, value in rows_left: - _kv(ax, 0.0, y, label, value, label_w=0.18) + _kv(ax, 0.0, y, label, value, label_w=0.18, fontsize=7.5) y -= dy y = 0.95 for label, value in rows_right: - _kv(ax, 0.55, y, label, value, label_w=0.20) + _kv(ax, 0.55, y, label, value, label_w=0.14, fontsize=7.5) y -= dy @@ -656,6 +679,10 @@ def _draw_channel_stats_waveform(ax, rd: ReportData) -> None: ("Peak Acceleration", "peak_accel_g", "g"), ("Peak Displacement", "peak_disp_in", "in"), ("Sensor Check", "sensor_check", ""), + # Sensor-check sub-rows (indented under "Sensor Check", like BW): the + # geophone ring-down frequency + overswing ratio from the self-check. + (" Frequency", "sc_freq_hz", "Hz"), + (" Overswing Ratio", "sc_ratio", ""), ] # Compacted to the left half so the enlarged compliance chart (BW-sized, # right against the page margin) has room — see _COMPLIANCE_BOX. @@ -772,6 +799,8 @@ def _draw_stats_table( if field == "zc_freq_hz": prefix = ">" if ch_rec.get("zc_freq_above_range") else "" return f"{prefix}{val:.0f}" + if field in ("sc_freq_hz", "sc_ratio"): + return f"{val:.1f}" # BW shows 1 decimal (7.5 Hz, 3.6) return f"{val:.3f}" return str(val) @@ -816,9 +845,22 @@ def _channel_axis_color(ch: str) -> str: def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None: """4-channel stacked waveform plot — Instantel printout order (MicL on top, Tran on bottom), shared x-axis in SECONDS, trigger - triangle markers at t=0, '0.0' baseline label on right of each.""" - inner = gridspec_cell.subgridspec(4, 1, hspace=0.0) + triangle markers at t=0, '0.0' baseline label on right of each. + + When sensor self-check traces are present (rd.sensor_check_waveforms), a + narrow "Sensor Check" strip of per-channel mini-plots is drawn to the right, + aligned to the lanes — matching Blastware's Event Report. + """ + from matplotlib.ticker import MaxNLocator + order = ["MicL", "Long", "Vert", "Tran"] + has_sc = bool(rd.sensor_check_waveforms) + if has_sc: + # main lanes + a narrow sensor-check strip column on the right + inner = gridspec_cell.subgridspec(4, 2, width_ratios=[1.0, 0.15], + wspace=0.04, hspace=0.0) + else: + inner = gridspec_cell.subgridspec(4, 1, hspace=0.0) sr = rd.sample_rate_sps or 1024 # Convert ms-based time axis to seconds for the x-axis dt_s = (rd.dt_ms or (1000.0 / sr)) / 1000.0 @@ -837,9 +879,12 @@ def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None: _geo_amax = _a geo_shared = max(_geo_amax * 1.10, GEO_FLOOR_INS) + main_axes = [] + sc_axes = [] last_idx = len(order) - 1 for i, ch in enumerate(order): - ax = fig.add_subplot(inner[i]) + ax = fig.add_subplot(inner[i, 0] if has_sc else inner[i]) + main_axes.append(ax) values = rd.channels.get(ch) or [] times = [t0_s + j * dt_s for j in range(len(values))] @@ -874,12 +919,36 @@ def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None: else: ax.tick_params(axis="x", labelsize=7) ax.tick_params(axis="y", labelsize=6) + # Stacked lanes touch, so the top/bottom y-tick labels of adjacent lanes + # would overprint at the shared boundary. Prune the extreme ticks so + # each boundary shows clean interior ticks (0.5 / 0.0 / -0.5) only. + ax.yaxis.set_major_locator(MaxNLocator(nbins=4, prune="both")) + + # Sensor self-check mini-plot in the right strip (aligned to this lane). + if has_sc: + scx = fig.add_subplot(inner[i, 1]) + sc_axes.append(scx) + sc_vals = rd.sensor_check_waveforms.get(ch) or [] + if sc_vals: + scx.plot(range(len(sc_vals)), sc_vals, + color=_channel_axis_color(ch), linewidth=0.5) + _amx = max((abs(v) for v in sc_vals), default=1.0) or 1.0 + scx.set_ylim(-_amx * 1.15, _amx * 1.15) + scx.set_xticks([]); scx.set_yticks([]) + for _s in scx.spines.values(): + _s.set_linewidth(0.4); _s.set_color("#999") # Trigger triangle marker ▼ above the top channel at t=0 - top_ax = fig.axes[-4] # MicL is the first added in this gridspec + top_ax = main_axes[0] # MicL top_ax.plot([0], [top_ax.get_ylim()[1]], marker="v", color="black", markersize=8, clip_on=False, zorder=10) + # "Sensor Check" caption under the strip (BW convention) + if has_sc and sc_axes: + pos = sc_axes[-1].get_position() + fig.text((pos.x0 + pos.x1) / 2, pos.y0 - 0.012, "Sensor Check", + fontsize=7, color="#555", ha="center", va="top") + # Compute scale-per-division for the footer (10 divs across the chart) # and find peak geo amplitude for the geo amp/div setting. total_s = times[-1] - times[0] if values else 0 -- 2.54.0 From 2c5c20cfd77171055ccff97b37d47bf3ad62fe81 Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 15 Sep 2026 05:39:53 +0000 Subject: [PATCH 16/28] fix(report): fit sensor-check mini-plots to their boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf --- sfm/report_pdf.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/sfm/report_pdf.py b/sfm/report_pdf.py index 2771bba..a793b08 100644 --- a/sfm/report_pdf.py +++ b/sfm/report_pdf.py @@ -930,10 +930,17 @@ def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None: sc_axes.append(scx) sc_vals = rd.sensor_check_waveforms.get(ch) or [] if sc_vals: - scx.plot(range(len(sc_vals)), sc_vals, - color=_channel_axis_color(ch), linewidth=0.5) - _amx = max((abs(v) for v in sc_vals), default=1.0) or 1.0 - scx.set_ylim(-_amx * 1.15, _amx * 1.15) + _col = _channel_axis_color(ch) + # Faint zero baseline (BW draws the channel baseline through the + # strip) — reference for the one-sided geophone ring-downs. + scx.axhline(0.0, color=_col, linewidth=0.3, alpha=0.4) + scx.plot(range(len(sc_vals)), sc_vals, color=_col, linewidth=0.5) + # Fit the trace to the box (BW-style) rather than a symmetric + # scale: the geo self-checks are one-sided dips, so a symmetric + # scale would strand them in the bottom half with an empty top. + _lo, _hi = min(sc_vals), max(sc_vals) + _pad = 0.10 * ((_hi - _lo) or 1.0) + scx.set_ylim(_lo - _pad, _hi + _pad) scx.set_xticks([]); scx.set_yticks([]) for _s in scx.spines.values(): _s.set_linewidth(0.4); _s.set_color("#999") -- 2.54.0 From 2a747f6893fd56189bd264cddca474aeba8e1c83 Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 15 Sep 2026 05:48:31 +0000 Subject: [PATCH 17/28] fix(report): attach sensor-check strip to the waveform panel; move "0.0" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf --- sfm/report_pdf.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/sfm/report_pdf.py b/sfm/report_pdf.py index a793b08..794c836 100644 --- a/sfm/report_pdf.py +++ b/sfm/report_pdf.py @@ -856,9 +856,12 @@ def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None: order = ["MicL", "Long", "Vert", "Tran"] has_sc = bool(rd.sensor_check_waveforms) if has_sc: - # main lanes + a narrow sensor-check strip column on the right - inner = gridspec_cell.subgridspec(4, 2, width_ratios=[1.0, 0.15], - wspace=0.04, hspace=0.0) + # main lanes + a narrow sensor-check strip column, flush against the + # main panel (BW shares the border — no gap), with the "0.0" baseline + # labels moved to the right of the strip. Proportions match BW's + # Event Report (main ~0.75 / strip ~0.10 of the panel width). + inner = gridspec_cell.subgridspec(4, 2, width_ratios=[1.0, 0.13], + wspace=0.0, hspace=0.0) else: inner = gridspec_cell.subgridspec(4, 1, hspace=0.0) sr = rd.sample_rate_sps or 1024 @@ -902,9 +905,12 @@ def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None: # Channel label on the LEFT (matches BW) ax.set_ylabel(ch, fontsize=8, rotation=0, ha="right", va="center", color=_channel_axis_color(ch), weight="bold", labelpad=14) - # "0.0" on the RIGHT (BW convention) - ax.text(1.005, 0.5, "0.0", transform=ax.transAxes, - fontsize=7, color="#555", va="center", ha="left") + # "0.0" baseline label on the RIGHT (BW convention). With the sensor- + # check strip attached, it goes to the right of the STRIP (drawn below); + # otherwise just outside the main lane. + if not has_sc: + ax.text(1.005, 0.5, "0.0", transform=ax.transAxes, + fontsize=7, color="#555", va="center", ha="left") ax.grid(True, linestyle="--", linewidth=0.3, color="#bbb", alpha=0.6) # Vertical dashed trigger line at t=0 @@ -944,6 +950,9 @@ def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None: scx.set_xticks([]); scx.set_yticks([]) for _s in scx.spines.values(): _s.set_linewidth(0.4); _s.set_color("#999") + # "0.0" baseline label to the RIGHT of the strip (BW convention) + scx.text(1.10, 0.5, "0.0", transform=scx.transAxes, + fontsize=7, color="#555", va="center", ha="left") # Trigger triangle marker ▼ above the top channel at t=0 top_ax = main_axes[0] # MicL -- 2.54.0 From 4f73e919a0954c1b199d9d9f2f85ee69ee21704f Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 15 Sep 2026 14:34:04 +0000 Subject: [PATCH 18/28] =?UTF-8?q?docs(changelog):=20Unreleased=20=E2=80=94?= =?UTF-8?q?=20FFT,=20USBM=20compliance=20chart,=20sensor=20self-check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf --- CHANGELOG.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a742b9..2650bdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,66 @@ All notable changes to seismo-relay are documented here. --- +## Unreleased + +**Blastware Event/FFT-Report parity — the FFT, the USBM compliance chart, and +the sensor self-check.** Three analyses Blastware derives from event data, +reverse-engineered against BE12844 (MiniMate Plus) reports and reproduced in +seismo-relay: the compliance chart and the sensor-check strip now render on +the event-report PDF, and the FFT reproduces Blastware's FFT Report. All three +are additive and read from data already on disk — the `.h5` samples and the +retained raw BW binary — so there is **no `.h5`/DB change, no migration, and no +backfill**: a report regenerated for an existing event simply gains the new +panels. + +### Added +- **Blastware-compatible channel FFT (`waveform_fft`).** Reproduces Blastware's + FFT Report: DC-removed, no window, zero-padded to 4096 (0.25 Hz bins at + 1024 sps), single-sided `2/N` amplitude. Matches Blastware's dominant + frequency to the exact bin and the amplitude to report precision across all + 28 channels of the 7-event BE12844 oracle set. `channel_spectrum()` / + `dominant_frequency()`; tests in `tests/test_waveform_fft.py`. + +- **USBM RI8507 / OSMRE compliance chart on the event-report PDF + (`sfm/compliance.py`).** The velocity-vs-frequency blasting-compliance + scatter Blastware draws in the upper-right of its Event Report: each channel's + significant cycles as `(frequency, peak velocity)` points (zero-crossing + method, so each channel's cloud tops out at its PPV) plotted against the + RI8507 Drywall (0.75 in/s) and plaster (0.50 in/s) limit curves, drawn + continuous (constant-displacement bounds meeting the plateaus — no vertical + steps). Sized and positioned to match a Blastware report, measured off the + reference PDF. A technical breakdown of the curve is in + `docs/ri8507_compliance_curve.md`. + +- **Sensor self-check waveforms decoded and drawn (`minimateplus.sensor_check`).** + The "Sensor Check" traces Blastware shows to the right of the waveform panel + live in the series-3 binary's trailing block as four length-prefixed records + (`0x3c`–`0x3f`) using the same delta-block codec as the main waveform: + Tran/Vert/Long geophone ring-downs (the transducer's damped impulse response — + resonant frequency + overswing/damping) and a MicL pulse train (the mic's + known-signal gain check). `gather_report_data` decodes them from the retained + BW binary at report time; the report renders them as a strip flush against the + waveform panel plus the **Sensor Check → Frequency / Overswing Ratio** sub-rows + in the stats table. Verified against the reports on all 7 oracle events (mic + zero-crossing frequency = 20.1 Hz exact; geophone ring-downs consistent + ~7.5 Hz with overswing ~3.5). Tests in `tests/test_sensor_check.py`. + +- **Inspector tab in `seismo_lab.py` — annotated hex reader for series-3 + binaries (`minimateplus/binary_annotate.py`).** Tiles a raw Blastware file + into labeled spans (header / STRT / body record-chain / trailing metadata + + calibration + sensor-check records / footer) so a binary can be combed by eye. + +### Fixed +- **Event-report waveform panel — stacked-lane y-tick collision.** The lanes + touch, so each lane's bottom `-1.0` overprinted the next lane's top `1.0` at + the shared boundary. Prune the extreme ticks so each lane shows clean interior + ticks only. +- **Event-report header — serial+firmware line ran off the page.** The long + `BE##### V ##.##-#.## MiniMate Plus` string overflowed the right margin; + tighter right-column indent + BW's slightly smaller header size so it fits. + +--- + ## v0.30.0 — 2026-09-12 **The series-4 correctness release** — the Thor / Micromate counterpart to -- 2.54.0 From 685a17d180a077ce12c05355799eb2d0600f0f30 Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 15 Sep 2026 20:01:39 +0000 Subject: [PATCH 19/28] feat(series4): decode sensor self-check waveforms from the IDFW binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf --- micromate/sensor_check.py | 89 ++++++++++++++++++ .../thor-idf-sc/UM11719_20231219162723.IDFW | Bin 0 -> 10290 bytes .../thor-idf-sc/UM12947_20250806134504.IDFW | Bin 0 -> 17530 bytes .../thor-idf-sc/UM13981_20220207084555.IDFW | Bin 0 -> 11478 bytes .../thor-idf-sc/UM20147_20250531135901.IDFW | Bin 0 -> 12462 bytes tests/test_sensor_check_idf.py | 67 +++++++++++++ 6 files changed, 156 insertions(+) create mode 100644 micromate/sensor_check.py create mode 100644 tests/fixtures/thor-idf-sc/UM11719_20231219162723.IDFW create mode 100644 tests/fixtures/thor-idf-sc/UM12947_20250806134504.IDFW create mode 100644 tests/fixtures/thor-idf-sc/UM13981_20220207084555.IDFW create mode 100644 tests/fixtures/thor-idf-sc/UM20147_20250531135901.IDFW create mode 100644 tests/test_sensor_check_idf.py diff --git a/micromate/sensor_check.py b/micromate/sensor_check.py new file mode 100644 index 0000000..dea67e6 --- /dev/null +++ b/micromate/sensor_check.py @@ -0,0 +1,89 @@ +r"""Decode the Thor / Micromate (series-4) sensor self-check waveforms from an +IDFW event binary. + +Reverse-engineered 2026-09-15 against 4 UM (Thor) oracle events. The 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 the series-3 MiniMate Plus (Tran / Vert / Long / MicL), which is +the physical self-test: + + * 3c / 3d / 3e = Tran / Vert / Long geophone ring-downs (a damped impulse + response — resonant frequency + damping). + * 3f = MicL pulse train (the mic's known-signal gain check). Absent + on three-channel (mic-disabled) units. + +Record framing (per record):: + + 01 0e [id:1] [flags:3] [count:2 BE] [pad:10] [int16-BE samples × count] + \___ 18-byte header ___/ + +Unlike series-3's delta-coded trailing block, series-4 stores each trace as a +raw int16 big-endian array. ``count`` (the 2-byte field at header offset +8) +is the sample count; the record is padded to a fixed stride after that. +""" +from __future__ import annotations + +import struct +from typing import Dict, List + +# Record id → channel. Same ids/order as series-3 (minimateplus.sensor_check). +_ID_TO_CHANNEL = {0x3C: "Tran", 0x3D: "Vert", 0x3E: "Long", 0x3F: "MicL"} +_CHAIN_IDS = (0x3C, 0x3D, 0x3E, 0x3F) + +_MARKER = b"\x01\x0e" # precedes the 1-byte channel id +_HEADER_LEN = 18 # bytes from the marker start to the first sample +_COUNT_OFF = 8 # 2-byte BE sample count, from the marker start +_MAX_COUNT = 4000 # sanity cap (traces are ~70-200 samples) + + +def _find_chain(raw: bytes): + """Locate the sensor-check record chain. Returns a list of + ``(offset, id, count)`` for the first run of markers whose ids run + 3c, 3d, 3e[, 3f] in order, or ``[]``. + + Records are padded to a fixed stride, so the next marker is not at + ``header + count*2``; instead collect every ``01 0e [id]`` marker with a + sane count and take the first id-ordered run. Validating the id sequence + (not a lone ``01 0e 3c``) keeps a stray marker in the waveform body from + matching — the real chain sits in the fixed header, ahead of the body. + """ + n = len(raw) + markers = [] + for p in range(n - _HEADER_LEN): + if raw[p:p + 2] == _MARKER and raw[p + 2] in _ID_TO_CHANNEL: + count = int.from_bytes(raw[p + _COUNT_OFF:p + _COUNT_OFF + 2], "big") + if 0 < count <= _MAX_COUNT: + markers.append((p, raw[p + 2], count)) + + for i, (off, rid, _c) in enumerate(markers): + if rid != 0x3C: + continue + run = [markers[i]] + for m in markers[i + 1:]: + if len(run) < len(_CHAIN_IDS) and m[1] == _CHAIN_IDS[len(run)]: + run.append(m) + else: + break + if len(run) >= 3: # 3-channel (mic-disabled) units are valid + return run + return [] + + +def decode_idf_sensor_check(raw: bytes) -> Dict[str, List[int]]: + """Decode the sensor self-check traces from a Thor/Micromate IDFW binary. + + Returns ``{"Tran": [...], "Vert": [...], "Long": [...], "MicL": [...]}`` in + raw int16 ADC counts (MicL omitted on 3-channel units), or ``{}`` if the + binary carries no sensor-check chain (a non-IDF file, or an IDFH histogram). + """ + chain = _find_chain(raw) + if not chain: + return {} + out: Dict[str, List[int]] = {} + for off, rid, count in chain: + start = off + _HEADER_LEN + blob = raw[start:start + count * 2] + if len(blob) < count * 2: + continue + out[_ID_TO_CHANNEL[rid]] = list(struct.unpack(">%dh" % count, blob)) + return out diff --git a/tests/fixtures/thor-idf-sc/UM11719_20231219162723.IDFW b/tests/fixtures/thor-idf-sc/UM11719_20231219162723.IDFW new file mode 100644 index 0000000000000000000000000000000000000000..14620e8ebc0e39e151bc9cf7f40cb15292e36d70 GIT binary patch literal 10290 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/thor-idf-sc/UM12947_20250806134504.IDFW b/tests/fixtures/thor-idf-sc/UM12947_20250806134504.IDFW new file mode 100644 index 0000000000000000000000000000000000000000..a16b414dc6477d138921cdac47bb679f36de9b99 GIT binary patch literal 17530 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⁢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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/thor-idf-sc/UM20147_20250531135901.IDFW b/tests/fixtures/thor-idf-sc/UM20147_20250531135901.IDFW new file mode 100644 index 0000000000000000000000000000000000000000..476ddc905e429f5695c7e2b2185adf0e5ca76a78 GIT binary patch literal 12462 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 literal 0 HcmV?d00001 diff --git a/tests/test_sensor_check_idf.py b/tests/test_sensor_check_idf.py new file mode 100644 index 0000000..00074b2 --- /dev/null +++ b/tests/test_sensor_check_idf.py @@ -0,0 +1,67 @@ +"""Series-4 (Thor / Micromate IDFW) sensor self-check waveform decode. + +Reverse-engineered 2026-09-15 against 4 UM (Thor) oracle events. The 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 whose sample count is a 2-byte field at offset +8. + +Three-channel (mic-disabled) Thor units carry only 3c/3d/3e — no MicL record. + +Validated by shape (geophone ring-down / mic pulse train) and cross-event +consistency, since there's no Thor Event-Report strip to exact-match against. +""" +from pathlib import Path + +import numpy as np + +from micromate.sensor_check import decode_idf_sensor_check + +FIXDIR = Path(__file__).parent / "fixtures" / "thor-idf-sc" +EVENTS = sorted(p.name for p in FIXDIR.glob("*.IDFW")) + + +def _decode(name): + return decode_idf_sensor_check((FIXDIR / name).read_bytes()) + + +def test_geo_channels_present_and_ringdown_shaped(): + # Every IDFW event has the three geophone self-checks; each is a large + # one-sided deflection (~15000 raw counts) that rings back — the geophone's + # damped impulse response. + for name in EVENTS: + sc = _decode(name) + for ch in ("Tran", "Vert", "Long"): + assert ch in sc, f"{name} missing {ch}" + tr = np.asarray(sc[ch], dtype=float) + tr = tr - tr[:4].mean() # reference to the pre-trigger baseline + assert 40 <= len(tr) <= 300, f"{name}:{ch} n={len(tr)}" + assert tr.min() < -8000, f"{name}:{ch} min {tr.min()}" + # deflects one way and rings back toward / past the baseline + assert tr.max() < abs(tr.min()), f"{name}:{ch} not one-sided" + + +def test_mic_present_only_on_four_channel_units(): + # UM11719 / UM12947 record a mic; UM13981 / UM20147 are 3-channel + # (mic-disabled) units and carry no MicL self-check. + got = {name: ("MicL" in _decode(name)) for name in EVENTS} + assert any(got.values()), "expected at least one 4-channel unit" + assert not all(got.values()), "expected at least one 3-channel unit" + for name, has_mic in got.items(): + if has_mic: + tr = np.asarray(_decode(name)["MicL"], dtype=float) + tr = tr - tr[:4].mean() + # mic self-check is a bipolar pulse train — swings both ways, wide range + assert tr.max() > 5000 and tr.min() < -5000, f"{name} mic not bipolar" + + +def test_channel_ids_and_order(): + # ids decode to the canonical channel names, geo always in Tran/Vert/Long order + sc = _decode(EVENTS[0]) + assert [c for c in ("Tran", "Vert", "Long") if c in sc] == ["Tran", "Vert", "Long"] + + +def test_returns_empty_on_non_idf_input(): + assert decode_idf_sensor_check(b"not an IDF file") == {} + assert decode_idf_sensor_check(b"") == {} -- 2.54.0 From 8d3cdba1b5ceb3408919ca7fd0d2fc9b13d73f0d Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 16 Sep 2026 06:49:55 +0000 Subject: [PATCH 20/28] feat(h5): standardize sensor-check into the .h5 (schema v2); SFM reads it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf --- minimateplus/event_file_io.py | 5 ++ minimateplus/models.py | 9 ++++ sfm/event_hdf5.py | 48 ++++++++++++++++-- sfm/report_pdf.py | 27 +++++----- sfm/waveform_store.py | 5 ++ tests/test_event_hdf5_sensor_check.py | 71 +++++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 20 deletions(-) create mode 100644 tests/test_event_hdf5_sensor_check.py diff --git a/minimateplus/event_file_io.py b/minimateplus/event_file_io.py index 519cf4b..ccba632 100644 --- a/minimateplus/event_file_io.py +++ b/minimateplus/event_file_io.py @@ -960,6 +960,11 @@ def read_blastware_file(path: Union[str, Path]) -> Event: project=project, client=client, operator=user, sensor_location=seisloc, ) ev.raw_samples = samples + # Sensor self-check traces from the binary's trailing block (waveform + # events only; returns {} for histograms / when absent). Carried on the + # Event so the .h5 writer persists them device-agnostically. + from minimateplus.sensor_check import decode_sensor_check + ev.sensor_check = decode_sensor_check(raw) or None # Only compute peaks from samples when we actually have samples. # For events the codec couldn't decode (histogram-mode bodies, until # the §7.6.2 histogram codec is wired in), samples is an empty dict diff --git a/minimateplus/models.py b/minimateplus/models.py index 48fd326..9e7865d 100644 --- a/minimateplus/models.py +++ b/minimateplus/models.py @@ -544,6 +544,15 @@ class Event: pretrig_samples: Optional[int] = None # from STRT record: pre-trigger sample count rectime_seconds: Optional[int] = None # from STRT record: record duration (seconds) + # Sensor self-check traces keyed by channel label — the short diagnostic + # waveforms the unit records when it pulses each sensor before monitoring + # (geophone ring-downs + a mic pulse train). Decoded from the binary by + # the per-series decoder (minimateplus.sensor_check / micromate.sensor_check) + # and carried here so the .h5 writer can persist them device-agnostically. + # Raw ADC counts; the source series' scale differs but the trace is a + # shape diagnostic (rendered fit-to-box). None when absent. + sensor_check: Optional[dict] = None # {"Tran": [...], ..., "MicL": [...]} + # ── Debug / introspection ───────────────────────────────────────────────── # Raw 210-byte waveform record bytes, set when debug mode is active. # Exposed by the SFM server via ?debug=true so field layouts can be verified. diff --git a/sfm/event_hdf5.py b/sfm/event_hdf5.py index a25b34d..c63d993 100644 --- a/sfm/event_hdf5.py +++ b/sfm/event_hdf5.py @@ -12,8 +12,11 @@ Layout written to `.h5`: ├─ samples_int16/ (optional) │ ├─ Tran (int16, raw ADC counts) shape: (N,) │ └─ ... per channel (only when present in the source) + ├─ sensor_check/ (optional, schema v2+) + │ ├─ Tran (int32, raw counts) shape: (M,) M ≪ N + │ └─ ... per channel present in the source (MicL absent on 3-channel units) └─ root attrs (event metadata): - schema_version int = 1 + schema_version int = 2 kind str = "sfm.event.hdf5" serial str waveform_key str (8-hex) @@ -64,7 +67,7 @@ from minimateplus.models import Event log = logging.getLogger(__name__) -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 # v2 adds the optional /sensor_check group HDF5_KIND = "sfm.event.hdf5" # Geophone full-scale velocity per range (in/s). Confirmed in CLAUDE.md @@ -270,6 +273,22 @@ def write_event_hdf5( ) igrp.attrs["mic_psi_per_count"] = float(mic_factor) + # /sensor_check — optional short diagnostic self-check traces (schema + # v2+). Raw ADC counts (a shape diagnostic; the per-series count scale + # differs, and the renderer fits each trace to its box). Only channels + # the decoder found are written — 3-channel units carry no MicL. + sc = event.sensor_check or {} + if sc: + scgrp = f.create_group("sensor_check") + for ch in ("Tran", "Vert", "Long", "MicL"): + vals = sc.get(ch) + if vals: + scgrp.create_dataset( + ch, data=np.asarray(vals, dtype=np.int32), + compression="gzip", compression_opts=4, shuffle=True, + ) + scgrp.attrs["units"] = "raw_counts" + import os os.replace(tmp, path) @@ -334,6 +353,16 @@ def read_event_hdf5(path: Union[str, Path]) -> dict: if mic_attr is not None: mic_psi = float(mic_attr) + # /sensor_check — optional (schema v2+); absent on older files. + sensor_check = None + scgrp = f.get("sensor_check") + if scgrp is not None: + sensor_check = {} + for ch in ("Tran", "Vert", "Long", "MicL"): + ds = scgrp.get(ch) + if ds is not None: + sensor_check[ch] = np.asarray(ds[()]) + return { "schema_version": sv, "kind": attrs.get("kind"), @@ -341,6 +370,7 @@ def read_event_hdf5(path: Union[str, Path]) -> dict: "samples": samples, "samples_int16": samples_int16, "mic_psi_per_count": mic_psi, + "sensor_check": sensor_check, } @@ -431,11 +461,16 @@ def plot_json_from_hdf5( event_id: Optional[str] = None, index: Optional[int] = None, ) -> dict: - """Build a `sfm.plot.v1` JSON dict from a stored .h5 file.""" + """Build a `sfm.plot.v1` JSON dict from a stored .h5 file. + + The dict also carries a top-level ``sensor_check`` key (the raw self-check + traces as ``{ch: [int]}``, or None) beyond the plot schema, so report + generation can read the traces from the same single .h5 load. + """ data = read_event_hdf5(path) a = data["attrs"] s = data["samples"] - return _build_plot_dict( + out = _build_plot_dict( n_samples=len(s["Tran"]) if "Tran" in s else 0, sample_rate=int(a.get("sample_rate", 1024) or 1024), pretrig_samples=int(a.get("pretrig_samples", 0) or 0), @@ -463,6 +498,11 @@ def plot_json_from_hdf5( event_id=event_id, index=index, ) + scd = data.get("sensor_check") + out["sensor_check"] = ( + {ch: v.tolist() for ch, v in scd.items()} if scd else None + ) + return out def _build_plot_dict( diff --git a/sfm/report_pdf.py b/sfm/report_pdf.py index 794c836..d4d60e7 100644 --- a/sfm/report_pdf.py +++ b/sfm/report_pdf.py @@ -121,9 +121,11 @@ class ReportData: t0_ms: Optional[float] = None dt_ms: Optional[float] = None - # Sensor self-check traces — {ch: [samples]} in raw decode units, decoded - # from the binary's trailing block (see minimateplus.sensor_check). The - # little waveforms BW draws in its "Sensor Check" strip. Empty when absent. + # Sensor self-check traces — {ch: [samples]} in raw counts, read from the + # standardized .h5 (/sensor_check group, schema v2+) where the per-series + # decoder stored them at ingest. The little diagnostic waveforms BW draws + # in its "Sensor Check" strip. Empty when absent (pre-v2 .h5, histogram, + # or 3-channel unit's MicL). sensor_check_waveforms: dict = field(default_factory=dict) # Record-type discriminator @@ -294,22 +296,15 @@ def gather_report_data( rd.pretrig_samples = ta.get("pretrig_samples") rd.t0_ms = ta.get("t0_ms") rd.dt_ms = ta.get("dt_ms") + # Sensor self-check traces — read from the standardized .h5 (schema + # v2+). Device-agnostic: whichever decoder produced the event + # stored them at ingest, so SFM reads them here without knowing or + # caring about the source instrument series. Empty on pre-v2 files + # (until backfilled) and on 3-channel / histogram events. + rd.sensor_check_waveforms = wf.get("sensor_check") or {} except Exception as exc: log.warning("gather_report_data: hdf5 read failed: %s", exc) - # ── Sensor self-check traces — decoded from the retained raw binary ── - # The .h5 holds only the main waveform; the sensor-check traces live in the - # binary's trailing block, so decode them straight from the kept BW file. - # Waveform events only (histograms have no sensor-check strip). - if not rd.is_histogram: - try: - from minimateplus.sensor_check import decode_sensor_check - bw_path, _a5 = store.paths_for(serial, filename) - if bw_path.exists(): - rd.sensor_check_waveforms = decode_sensor_check(bw_path.read_bytes()) - except Exception as exc: - log.warning("gather_report_data: sensor-check decode failed: %s", exc) - # ── Histogram aggregation ── # Codec emits ~N per-block samples (typically 1/sec); BW reports # one bar per configured interval (1 min / 5 min / etc.). When diff --git a/sfm/waveform_store.py b/sfm/waveform_store.py index d38a425..8c2e26c 100644 --- a/sfm/waveform_store.py +++ b/sfm/waveform_store.py @@ -662,6 +662,11 @@ class WaveformStore: ev.raw_samples = idf_samples n_samples = max((len(idf_samples.get(ch, [])) for ch in ("Tran", "Vert", "Long", "MicL")), default=0) ev.total_samples = ev.total_samples or n_samples + # Sensor self-check traces from the IDFW fixed header (waveform + # events only; {} on histograms / when absent). Carried on the + # bridged Event so the .h5 writer persists them like series-3. + from micromate.sensor_check import decode_idf_sensor_check + ev.sensor_check = decode_idf_sensor_check(idf_bytes) or None # For IDFH histograms there are no per-sample waveform arrays — the # device stores one peak ADC count per interval per channel. Synthesise diff --git a/tests/test_event_hdf5_sensor_check.py b/tests/test_event_hdf5_sensor_check.py new file mode 100644 index 0000000..fda0dc8 --- /dev/null +++ b/tests/test_event_hdf5_sensor_check.py @@ -0,0 +1,71 @@ +"""The event .h5 carries the sensor self-check traces (schema v2). + +The sensor check is decoded by the per-series decoder and attached to the +standardized Event, so the .h5 writer persists it device-agnostically and SFM +reads it back without knowing which instrument produced it. Old v1 files (no +sensor_check group) must still read cleanly. +""" +import tempfile +from pathlib import Path + +import numpy as np + +from minimateplus.models import Event +from minimateplus.event_file_io import read_blastware_file +from sfm import event_hdf5 + +S3_FIX = Path(__file__).parent / "fixtures" / "fft-oracle-2026-09-14" / "N844LQHB.ZT0W" + + +def _write(ev, **kw): + d = Path(tempfile.mkdtemp()) + p = d / "e.h5" + event_hdf5.write_event_hdf5(p, ev, serial="BE12844", **kw) + return p + + +def test_sensor_check_roundtrips_through_hdf5(): + ev = Event(index=0) + ev.raw_samples = {"Tran": [1, 2, -3], "Vert": [0, 1], "Long": [2], "MicL": [5, -5]} + ev.sample_rate = 1024 + sc = {"Tran": [0, -990, -500, -100], "Vert": [0, -980, -480], + "Long": [0, -986, -470], "MicL": [0, -1800, 1800, -1800]} + ev.sensor_check = sc + + r = event_hdf5.read_event_hdf5(_write(ev)) + assert r["schema_version"] == 2 + assert set(r["sensor_check"]) == {"Tran", "Vert", "Long", "MicL"} + for ch, vals in sc.items(): + assert r["sensor_check"][ch].tolist() == vals + + +def test_plot_json_carries_sensor_check(): + ev = Event(index=0) + ev.raw_samples = {"Tran": [1, 2, 3]} + ev.sample_rate = 1024 + ev.sensor_check = {"Tran": [0, -990, -500], "Vert": [0, -980], + "Long": [0, -986]} # 3-channel: no MicL + pj = event_hdf5.plot_json_from_hdf5(_write(ev)) + assert pj["sensor_check"] is not None + assert "MicL" not in pj["sensor_check"] + assert pj["sensor_check"]["Tran"] == [0, -990, -500] + + +def test_event_without_sensor_check_still_reads_as_v2(): + ev = Event(index=0) + ev.raw_samples = {"Tran": [1, 2, 3]} + ev.sample_rate = 1024 + r = event_hdf5.read_event_hdf5(_write(ev)) + assert r["schema_version"] == 2 + assert r["sensor_check"] is None + assert event_hdf5.plot_json_from_hdf5(_write(ev))["sensor_check"] is None + + +def test_series3_decode_populates_event_sensor_check(): + # The real series-3 decoder attaches the traces to the Event, so the + # ingest/backfill .h5 write picks them up with no extra plumbing. + ev = read_blastware_file(S3_FIX) + assert ev.sensor_check is not None + assert set(ev.sensor_check) == {"Tran", "Vert", "Long", "MicL"} + tran = np.asarray(ev.sensor_check["Tran"], dtype=float) + assert tran.min() < -800 # the geophone ring-down deflection -- 2.54.0 From 8265e32ad5a5e475bfdbaff5ba9bde0db25ba8b9 Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 16 Sep 2026 06:53:33 +0000 Subject: [PATCH 21/28] feat(backfill): regenerate .h5 with /sensor_check; TOOL_VERSION 0.31.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf --- CHANGELOG.md | 58 +++++++++++++++++++++------------ minimateplus/event_file_io.py | 2 +- scripts/backfill_thor_events.py | 5 +++ 3 files changed, 44 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2650bdd..a38bf86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,25 @@ All notable changes to seismo-relay are documented here. ## Unreleased **Blastware Event/FFT-Report parity — the FFT, the USBM compliance chart, and -the sensor self-check.** Three analyses Blastware derives from event data, -reverse-engineered against BE12844 (MiniMate Plus) reports and reproduced in -seismo-relay: the compliance chart and the sensor-check strip now render on -the event-report PDF, and the FFT reproduces Blastware's FFT Report. All three -are additive and read from data already on disk — the `.h5` samples and the -retained raw BW binary — so there is **no `.h5`/DB change, no migration, and no -backfill**: a report regenerated for an existing event simply gains the new -panels. +the sensor self-check (series-3 *and* series-4).** Analyses Blastware/Thor +derive from event data, reverse-engineered against BE12844 (MiniMate Plus) and +UM (Thor) events and reproduced in seismo-relay: the compliance chart and the +sensor-check strip render on the event-report PDF, and the FFT reproduces +Blastware's FFT Report. + +The FFT and the compliance chart are additive and read from the existing `.h5` +samples — no `.h5` or DB change for those. The **sensor self-check** now lives +in the standardized `.h5` (schema **v2**, a new `/sensor_check` group) so SFM +serves it device-agnostically rather than decoding at report time. + +⚠ **The sensor-check needs a backfill.** Existing `.h5` files are schema v1 and +carry no `/sensor_check` group, so their reports show no sensor-check strip +until regenerated. `TOOL_VERSION` is bumped to **0.31.0**, so the standard +backfill regenerates every event and picks up the traces with **no `--force`**: +`scripts/backfill_thor_events.py` for series-4 (it already owed a v0.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 `.h5` files +read fine until then (empty strip). DB backup first, as always. ### Added - **Blastware-compatible channel FFT (`waveform_fft`).** Reproduces Blastware's @@ -35,18 +46,25 @@ panels. reference PDF. A technical breakdown of the curve is in `docs/ri8507_compliance_curve.md`. -- **Sensor self-check waveforms decoded and drawn (`minimateplus.sensor_check`).** - The "Sensor Check" traces Blastware shows to the right of the waveform panel - live in the series-3 binary's trailing block as four length-prefixed records - (`0x3c`–`0x3f`) using the same delta-block codec as the main waveform: - Tran/Vert/Long geophone ring-downs (the transducer's damped impulse response — - resonant frequency + overswing/damping) and a MicL pulse train (the mic's - known-signal gain check). `gather_report_data` decodes them from the retained - BW binary at report time; the report renders them as a strip flush against the - waveform panel plus the **Sensor Check → Frequency / Overswing Ratio** sub-rows - in the stats table. Verified against the reports on all 7 oracle events (mic - zero-crossing frequency = 20.1 Hz exact; geophone ring-downs consistent - ~7.5 Hz with overswing ~3.5). Tests in `tests/test_sensor_check.py`. +- **Sensor self-check waveforms decoded and drawn — both series.** The little + "Sensor Check" traces (geophone ring-downs — the transducer's damped impulse + response — plus a MicL pulse train, the mic's known-signal gain check) are the + unit's proof its sensors were healthy when it recorded the event. + - **Series-3** (`minimateplus.sensor_check`): four records (`0x3c`–`0x3f`) in + the binary's trailing block, same delta-block codec as the main waveform. + Verified against all 7 BE12844 reports (mic zero-crossing = 20.1 Hz exact; + geophone ring-downs ~7.5 Hz, overswing ~3.5). + - **Series-4** (`micromate.sensor_check`): the same self-test in the Thor IDFW + fixed header — four `01 0e 3c/3d/3e/3f` records (same channel ids) storing + raw int16 traces; three-channel (mic-disabled) units carry only the three + geophones. Validated by shape + cross-event consistency. + - **Standardized into the `.h5`** (`/sensor_check`, schema v2): each series' + decoder attaches the traces to the event at decode, the writer persists + them, and `gather_report_data` reads them back — so SFM renders the strip + (flush against the waveform panel) plus the **Sensor Check → Frequency / + Overswing Ratio** sub-rows without knowing the source instrument. + - Tests: `tests/test_sensor_check.py`, `tests/test_sensor_check_idf.py`, + `tests/test_event_hdf5_sensor_check.py`. - **Inspector tab in `seismo_lab.py` — annotated hex reader for series-3 binaries (`minimateplus/binary_annotate.py`).** Tiles a raw Blastware file diff --git a/minimateplus/event_file_io.py b/minimateplus/event_file_io.py index ccba632..188df85 100644 --- a/minimateplus/event_file_io.py +++ b/minimateplus/event_file_io.py @@ -50,7 +50,7 @@ SIDECAR_KIND = "sfm.event" # bumped without a `pip install` re-run — leading to confusing stale # version stamps in sidecars. Bump this constant and CHANGELOG.md # together at release time. -TOOL_VERSION = "0.30.0" +TOOL_VERSION = "0.31.0" # +/sensor_check group (schema v2); gates the backfill regen try: # Best-effort: prefer the installed metadata when it's NEWER than the diff --git a/scripts/backfill_thor_events.py b/scripts/backfill_thor_events.py index 41e7935..581b729 100644 --- a/scripts/backfill_thor_events.py +++ b/scripts/backfill_thor_events.py @@ -305,6 +305,11 @@ def main(argv=None) -> int: default=0, ) ev.total_samples = ev.total_samples or n_samp + # Sensor self-check traces from the IDFW fixed + # header, so regenerated .h5 files gain the v2 + # /sensor_check group (mirrors save_imported_idf). + from micromate.sensor_check import decode_idf_sensor_check + ev.sensor_check = decode_idf_sensor_check(binary_bytes) or None event_hdf5.write_event_hdf5( hdf5_path, ev, -- 2.54.0 From 9f1050b5e74c7b6b56145b13cae42ac854cf946a Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 16 Sep 2026 20:54:46 +0000 Subject: [PATCH 22/28] =?UTF-8?q?feat(ach):=20rescue-on-connect=20?= =?UTF-8?q?=E2=80=94=20stop=20monitoring=20/=20disable=20ACH=20from=20the?= =?UTF-8?q?=20server=20side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Qcu9ByJfuKBQxmrWb8rSrN --- CHANGELOG.md | 21 ++++++++++++ bridges/ach_server.py | 75 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2650bdd..8dbbfd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,27 @@ backfill**: a report regenerated for an existing event simply gains the new panels. ### Added +- **Rescue-on-connect for `bridges/ach_server.py`** — `--stop-monitoring` + (SUB 0x97), `--disable-ach` (SUB 0x2C read → 0x7E write → 0x7F confirm) and + `--rescue` (both). They fire immediately after the startup handshake and + **before** the event walk, so a unit that is recording back-to-back on a + stuck-triggered geophone is quieted as early in the session as possible. + Each action is independently guarded — a failure does not abort the download + — and the outcome is written to `rescue.json` in the session directory. + + This inverts the `docs/runbooks/wedged_unit_recovery.md` approach. That + runbook reaches the unit *inbound* and clears the modem's Destination Address + to stop it dialing. When the device is instead wedged mid-modem-init — ALEOS + logs `tcpmode trying to send to invalid socket` and re-runs `Initialize Auto + answer` every ~75 s, orphaning any held inbound session — inbound cannot win. + Pointing the modem's Destination at an `ach_server` and letting the unit call + *us* gives a device-initiated session the modem bridges properly. + + ⚠ Prefer `--stop-monitoring` alone on first contact. `--disable-ach` stops + the unit calling, which is the only channel to a unit in this state; stopping + the recording ends the call-home loop on its own when ACH is + "after event recorded". + - **Blastware-compatible channel FFT (`waveform_fft`).** Reproduces Blastware's FFT Report: DC-removed, no window, zero-padded to 4096 (0.25 Hz bins at 1024 sps), single-sided `2/N` amplitude. Matches Blastware's dominant diff --git a/bridges/ach_server.py b/bridges/ach_server.py index 0bfd8af..bbc72b8 100644 --- a/bridges/ach_server.py +++ b/bridges/ach_server.py @@ -177,6 +177,8 @@ class AchSession: store: "WaveformStore", clear_after_download: bool = False, restart_monitoring: bool = False, + rescue_stop_monitoring: bool = False, + rescue_disable_ach: bool = False, force_redownload: bool = False, ) -> None: self.sock = sock @@ -190,6 +192,9 @@ class AchSession: self.store = store self.clear_after_download = clear_after_download self.restart_monitoring = restart_monitoring + # Rescue actions for a runaway unit — fired before the event walk. + self.rescue_stop_monitoring = rescue_stop_monitoring + self.rescue_disable_ach = rescue_disable_ach # `force_redownload` tells this session to ignore ach_state and # re-download every event currently on the device, regardless of any # (key, timestamp) match. Useful as a manual override when state has @@ -290,6 +295,41 @@ class AchSession: root_logger.addHandler(fh) try: + # ── Step 1.5: rescue actions ────────────────────────────────────── + # Fired BEFORE the event walk so a runaway unit is quieted as early + # in the session as possible. A unit whose geophone sits above the + # trigger threshold records back-to-back and, with ACH set to "after + # event recorded", re-dials every time — saturating its own firmware + # so it never services inbound requests. See + # docs/runbooks/wedged_unit_recovery.md. + # + # Each action is independently guarded: a failure here must not + # abort the download that follows. + if self.rescue_stop_monitoring or self.rescue_disable_ach: + rescue: dict = {"peer": self.peer, "ts": ts} + + if self.rescue_stop_monitoring: + log.info("Step 1.5: RESCUE — stop monitoring (SUB 0x97)") + try: + client.stop_monitoring() + rescue["stop_monitoring"] = "ok" + log.info(" stop monitoring OK — device should stop recording") + except Exception as exc: + rescue["stop_monitoring"] = f"failed: {exc}" + log.error(" stop monitoring FAILED: %s", exc) + + if self.rescue_disable_ach: + log.info("Step 1.5: RESCUE — disable auto call home (SUB 0x2C/0x7E/0x7F)") + try: + client.set_call_home_config(auto_call_home_enabled=False) + rescue["disable_ach"] = "ok" + log.info(" disable ACH OK — unit should stop calling home") + except Exception as exc: + rescue["disable_ach"] = f"failed: {exc}" + log.error(" disable ACH FAILED: %s", exc) + + _save_json(session_dir / "rescue.json", rescue) + # ── Step 2: device info ─────────────────────────────────────────── device_info = None if not self.events_only: @@ -747,6 +787,13 @@ def serve(args: argparse.Namespace) -> None: print(f" Max events per session: {max_ev if max_ev else 'unlimited'}") print(f" Clear device after download: {'YES' if args.clear_after_download else 'no'}") print(f" Restart monitoring after download: {'YES' if args.restart_monitoring else 'no'}") + _stop_mon = args.stop_monitoring or args.rescue + _dis_ach = args.disable_ach or args.rescue + print(f" RESCUE stop monitoring on connect: {'YES' if _stop_mon else 'no'}") + print(f" RESCUE disable auto call home: {'YES' if _dis_ach else 'no'}") + if _stop_mon and args.restart_monitoring: + print(" !! --restart-monitoring will re-start the unit after download,") + print(" undoing --stop-monitoring. Drop one of them.") print(f" Force re-download all (ignore state): {'YES' if args.force_redownload_all else 'no'}") print(f"{'='*60}") print(f"\n Point your test unit's ACEmanager call-home settings to:") @@ -788,6 +835,8 @@ def serve(args: argparse.Namespace) -> None: store=store, clear_after_download=args.clear_after_download, restart_monitoring=args.restart_monitoring, + rescue_stop_monitoring=args.stop_monitoring or args.rescue, + rescue_disable_ach=args.disable_ach or args.rescue, force_redownload=args.force_redownload_all, ) t = threading.Thread(target=session.run, daemon=True, name=f"ach-{peer}") @@ -862,6 +911,32 @@ def parse_args() -> argparse.Namespace: "DCD on disconnect — without this the unit stays idle after a call-home." ), ) + p.add_argument( + "--stop-monitoring", + action="store_true", + default=False, + help=( + "RESCUE: send SUB 0x97 (stop monitoring) immediately after the " + "handshake, before any event download. Use on a unit that is " + "recording back-to-back because of a stuck-triggered geophone." + ), + ) + p.add_argument( + "--disable-ach", + action="store_true", + default=False, + help=( + "RESCUE: disable Auto Call Home on the device (SUB 0x2C read → " + "0x7E write → 0x7F confirm) immediately after the handshake. The " + "unit stops dialing out until ACH is explicitly re-enabled." + ), + ) + p.add_argument( + "--rescue", + action="store_true", + default=False, + help="Shorthand for --stop-monitoring --disable-ach.", + ) p.add_argument( "--clear-after-download", action="store_true", -- 2.54.0 From c6fc3d0241cbfadef389a847abfee1792b3573aa Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 17 Sep 2026 05:45:26 +0000 Subject: [PATCH 23/28] =?UTF-8?q?docs:=20BE12599=20incident=20=E2=80=94=20?= =?UTF-8?q?the=20inverted=20rescue,=20plus=20a=20rescue-listener=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Qcu9ByJfuKBQxmrWb8rSrN --- docs/runbooks/wedged_unit_recovery.md | 224 ++++++++++++++++++ .../plans/2026-09-17-rescue-listener.md | 134 +++++++++++ 2 files changed, 358 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-17-rescue-listener.md diff --git a/docs/runbooks/wedged_unit_recovery.md b/docs/runbooks/wedged_unit_recovery.md index 8d27dd0..3536195 100644 --- a/docs/runbooks/wedged_unit_recovery.md +++ b/docs/runbooks/wedged_unit_recovery.md @@ -14,6 +14,27 @@ This runbook describes how to break the loop and recover control. --- +## ⚠ There are TWO failure modes under "wedged unit" + +Same root cause — an offset/connector fault drives the geophone above trigger, +the unit records back-to-back, ACH set to "after event recorded" dials +constantly — but the *recovery* differs, because the thing blocking you is +different. + +| | **BE9558H (2026-05)** | **BE12599 (2026-09)** | +|---|---|---| +| What blocks you | Modem mode-flipping kills inbound TCP | Device never enters S3 mode at all | +| Device state | Alive, in S3 mode, responsive once reached | Stuck repeating an **AT modem-init** string, deaf to S3 | +| Fix direction | **Inbound** — clear Destination, slow-drip a Stop | **Inverted** — point Destination at our own ACH server and let the unit call *us* | +| Winning tool | `scripts/slow_drip.sh` | `bridges/ach_server.py --stop-monitoring` | + +**Tell them apart with the ALEOS serial log** (see "Turn on ALEOS_SERIAL debug" +below). If the device is emitting `ATQ1/ATE0/ATS0=2` every ~75 s, it is in the +BE12599 mode and **no amount of inbound work will reach it** — skip to +"Second incident" below. + +--- + ## Symptoms - Terra-View / SFM `/device/info` either hangs or fails on `count_events()`. @@ -253,3 +274,206 @@ service). Total time from "i was wondering if its possible to" first attempt to recovery: ~7 hours of intermittent debugging across one evening. + +--- + +# Second incident — BE12599, 2026-09-16/17 + +**Unit:** BE12599 at `166.246.64.226:9034`, RV50, job *I-80 North Fork Bridge +— Abut 1 West* (Fay Company). Same job as BE9558H, which is a coincidence. + +**Fault:** the connector fault documented in `docs/offset_investigation.md` +§8e progressed until the Tran pedestal reached **0.400 in/s** — its trigger +level. Constant triggering → constant recording → ACH "after event recorded" +→ continuous dialing. Same disease as BE9558H. + +**But the recovery was the opposite direction**, and none of the Step 1–4 +procedure above worked. Total time ≈ 5 h, of which ~90 min was spent on two +red herrings documented below. + +--- + +## Turn on ALEOS_SERIAL debug FIRST + +This is the single highest-value diagnostic and it should be step zero on any +future incident. ACEmanager → **Admin → Log → ALEOS_SERIAL log level → +DEBUG**, then view the serial log. + +It is the only thing that tells you what the *device* is actually saying. +Everything before we did this was guesswork. + +## What the log showed — the device was never in S3 mode + +Every ~75 seconds, verbatim: + +``` +ALEOS_SERIAL_HIF: 29 byte(s) in buffer: 'ATQ1^MATE0^MATS0=2^M^MRADIO RING^M' +ALEOS_SERIAL_HMC: TCP recvhost fd 65535 len 29 state TCPMode::kClosed +ALEOS_SERIAL_HMC: tcpmode trying to send to invalid socket +ALEOS_SERIAL_HMC: Connect to IP: 0.0.0.0 Port 0 +ALEOS_SERIAL_HMC: Initialize Auto answer on port 9034 +ALEOS_SERIAL_HMC: Cannot connect to 0.0.0.0 +``` + +Read that carefully: + +- `ATQ1` (quiet) / `ATE0` (echo off) / `ATS0=2` (auto-answer after 2 rings). + **There is no `ATD`.** The device is not dialing — it is trying to + *configure* its modem. +- The modem's serial port is in TCP data mode, so it never interprets these + as AT commands. It treats them as payload and tries to ship them to a TCP + socket that does not exist. +- The device therefore never receives `OK`, never progresses, and **retries + the identical 29 bytes forever**. + +**Consequence: the device is not running the S3 protocol parser.** You can +land a byte-perfect S3 frame on it and it will be ignored. This is why every +inbound approach failed, and it is the structural difference from BE9558H. + +### Why `slow_drip` lied + +`slow_drip` returned the *success* signature except for the one field that +mattered: + +```json +{"duration_s":120.0,"drips_sent":38,"bytes_sent":920, + "bytes_received":0,"send_error":null} +``` + +Full duration, no broken pipe — but zero bytes back. Cause is in the log +above: each 75 s cycle re-runs `Initialize Auto answer on port 9034`, which +orphans the held session (`data in for unknown reason 3 removing from +select`, `OnMsg recv error: 107 - Transport endpoint is not connected`). Our +local TCP stayed open so `sendall` never raised — but the modem stopped +bridging after the first re-init, so every drip after that went into a socket +nobody was reading. + +⚠ **`send_error: null` + full duration is NOT success. Only +`bytes_received > 0` is success.** + +--- + +## ⚠ Two red herrings that cost ~90 minutes + +### 1. The trusted-IP whitelist (this was the real reason inbound never worked) + +The RV50s run with **Security → Trusted IPs (Friends List) enabled**. A +source IP that is not on the list is dropped **silently** — inbound presents +as `Connection error: timed out`, never a refusal. + +Brian's dev-box public IP is **dynamic** and had changed, so `tmi-dev` was no +longer whitelisted. Every inbound attempt failed identically across four +different modem and device states, which looked exactly like the BE9558H +mode-flipping symptom and sent us chasing modem configuration for over an +hour. + +**Check this before diagnosing anything else.** Note that SFM in Docker +egresses via the *host's public IP*, not its LAN IP. + +### 2. A 502 from SFM does not mean TCP connected + +`sfm/server.py` raises **502 for both** failure classes: + +```python +raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") +raise HTTPException(status_code=502, detail=f"Connection error: {exc}") +``` + +We read an early 502 as "TCP connected, modem bridged, device mute" and built +a whole theory on it. It was almost certainly a connect timeout. +**Always read the `detail` string** — "connect failed" and "device didn't +answer" are completely different problems and the status code will not +separate them. + +--- + +## What actually worked — invert the direction + +The key observation is in the log above: + +> `TCP recvhost ... state TCPMode::kClosed` → `Connect to IP: 0.0.0.0 Port 0` + +**The modem auto-dials its Destination whenever serial data arrives while +closed.** So instead of fighting for inbound, give it somewhere to dial: +point `Destination Address` at our own `ach_server` and the device's own +75-second attempts become **device-initiated sessions the modem bridges +correctly**. No race, no contention, worst case a 75-second wait. + +### Procedure + +1. **Run the rescue server** on a host the modem can reach (public IP + + forwarded port): + + ```bash + cd /home/serversdown/seismo-relay + .venv/bin/python -u bridges/ach_server.py --port 12345 \ + -o bridges/captures/-diag --stop-monitoring -v + ``` + +2. **Point the modem at it** — ACEmanager → Serial → Port Configuration → + `Destination Address` = your public IP, `Destination Port` = 12345. + +3. **Wait for the call-in.** `--stop-monitoring` fires SUB 0x97 at step 1.5, + after the handshake and *before* the event walk. Confirm via + `rescue.json` in the session directory: + + ```json + {"peer": "166.246.64.226:60921", "stop_monitoring": "ok"} + ``` + +4. **Restore the modem's Destination** once you are done, then finish the + device side (disable ACH, erase) through whichever channel works. + +On BE12599 the first call-in landed at 20:58:11 and reported +`stop_monitoring: ok`; a second at 20:58:20 confirmed it. `is_monitoring: +false` was still true **6½ hours later** — the fix is durable. + +--- + +## Hard-won gotchas (do not re-derive) + +- **Never leave the Destination pointed at a host with nothing listening.** + That is the worst state available: the device still dials, the modem still + flips, inbound stays blocked, and nothing is delivered. An 8-minute gap + with the listener down produced a spurious inbound timeout that cost + another round of misdiagnosis. + +- **Stopping monitoring removes your call-in channel.** ACH is "after event + recorded"; no new events means no new dials. The backlog sitting in memory + does *not* re-arm it. After a successful stop the unit goes quiet and you + need the modem cycled (works — produced a call-in), the scheduled daily call + (BE12599 calls at **05:00:14 device-local**, per §8e), or working inbound. + **Plan the order before you fire the stop.** + +- **`--events-only` silently breaks dedup.** It skips the device-info step, + so the serial is never read; `ach_state.json` then keys on + `peer:ephemeral_port`, which is unique per connection. Every session looks + like a new unit, starts from key 0, and re-downloads the same event. Four + sessions on BE12599 downloaded the identical event four times and made zero + progress on the backlog. Events also file as `serial=UNKNOWN` with a + `M000…` BW filename (serial_numeric 0) instead of `N599…`. + **Do not use `--events-only` when you intend to download anything.** + +- **`/device/events/index` reported `lifetime_count: 0`** on a unit with years + of history. Suspected decode bug in the SUB 0x08 field offset — do not + trust that number. The 88-byte payload is preserved in the `raw_hex` field + if someone wants to chase it. + +- **Memory used cross-checks the event keys exactly:** + `last_key − buffer_start = memory_total − memory_free`. On BE12599: + `0x011230ec − 0x01110000 = 78,060` and `983,028 − 904,968 = 78,060`. + Useful sanity check that you are reading the keys right. + +--- + +## Final state (2026-09-17 ~01:30 local) + +- `is_monitoring: false`, held 6½ hours +- Battery 6.76 V +- Memory 78,060 / 983,028 bytes used (8%) +- `first_key 01121728`, `last_key 011230ec` — ~6.6 KB of addressable event + chain, roughly 3 events +- ACH still **enabled** — to be disabled after the backlog is preserved +- Modem Destination still pointed at tmi-dev — to be restored +- ⚠ **Do not re-enable ACH until the connector is serviced.** Tran is still + sitting at 0.400 and the loop restarts the moment monitoring resumes. diff --git a/docs/superpowers/plans/2026-09-17-rescue-listener.md b/docs/superpowers/plans/2026-09-17-rescue-listener.md new file mode 100644 index 0000000..58a5de4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-rescue-listener.md @@ -0,0 +1,134 @@ +# Plan — "Rescue Listener": a first-class tool for the inverted rescue + +**Status:** proposal, not started. Written 2026-09-17 ~01:40 local, straight +off the BE12599 incident. Open questions at the bottom need Brian's answer +before anything is built. + +**Background:** `docs/runbooks/wedged_unit_recovery.md`, "Second incident — +BE12599". The manual version of this worked; this plan is about making it a +tool instead of a sequence of remembered steps at 1 AM. + +--- + +## The problem, stated plainly + +When a unit is wedged in the BE12599 mode — geophone offset above trigger, +recording back-to-back, ACH dialing constantly, device stuck repeating an AT +modem-init string and therefore **deaf to S3 over inbound** — the only channel +that works is the one the *device* opens. + +Recovering it currently means: + +1. Remember that `bridges/ach_server.py` exists and takes the right flags +2. Start it by hand on a box the modem can reach, with a public port forwarded +3. Go into ACEmanager and repoint the modem's Destination +4. Watch a terminal for a call-in +5. Read `rescue.json` to find out whether it worked +6. Go back into ACEmanager and repoint the modem to where it belongs +7. **Not forget step 6**, because leaving the Destination pointed at a dead + listener is worse than never having started + +That is six manual steps and one landmine, executed under pressure while a +unit floods the office server. + +## What the tool should be + +**A "rescue listener" an operator can start for one unit, which handles +whatever that unit says when it calls in, and refuses to go away until the +operator confirms the modem has been pointed back.** + +Lifecycle: + +1. **Start** — operator names the target unit and starts a rescue listener. + The tool reports the exact address/port to enter in ACEmanager, plus the + actions it will take. +2. **Operator repoints the modem** to that address. +3. **Wait** — listener sits there. Live status: "waiting for call-in", + elapsed, last-seen. +4. **Act** — on call-in, run the configured rescue actions automatically, + in a safe order, each independently guarded. Report per-action outcome. +5. **Hold** — the listener **stays up** and keeps reporting, because the + modem is still pointed at it. +6. **Confirm & stop** — the operator explicitly confirms the Destination has + been restored (to `0.0.0.0`, or to the office Instantel ACH server). + Only then does the listener shut down. + +Step 6 is the whole point of making this a tool. It is the step that is +easiest to skip and most expensive to skip. + +## Default action set + +Ordered deliberately — see "order matters" below. + +| # | Action | Default | Why | +|---|---|---|---| +| 1 | **Stop monitoring** (SUB 0x97) | ✅ on | Halts recording; ends the trigger→record→dial loop at its source. Already implemented as `--stop-monitoring`. | +| 2 | **Drain events** to a diagnostics store | ⚙ configurable | The backlog is usually evidence, not garbage — see the BE12599 offset investigation. Must NOT land in the prod SFM DB. | +| 3 | **Disable ACH** (SUB 0x2C/0x7E/0x7F) | ❌ off by default | Stops the dialing — **and stops your only channel**. Opt-in, and ideally gated on step 1 having succeeded. | +| 4 | **Erase events** | ❌ off by default | Destructive. Only after a verified drain. | + +### Order matters — the lesson from BE12599 + +Stopping monitoring *removes the call-in trigger*. ACH fires on "after event +recorded"; with recording stopped, the unit has no reason to dial again, even +though the backlog is still sitting in its memory. So a naive +"stop + disable + erase, all at once" rescue can silence the unit before +you've collected anything, leaving you with no channel and a device full of +evidence. + +The tool should either sequence around this or warn loudly about it. My +instinct is: **stop monitoring immediately** (it's the bleeding), then drain +across however many call-ins it takes, and treat disable-ACH/erase as a +separate, explicit "finish" action once the operator is satisfied. + +## Where it should live — open question, with a proposal + +The natural tier is **SFM** (device-side, per the three-tier model in +CLAUDE.md). But the rescue listener must be reachable *from the cellular +network*, which is a deployment constraint SFM's usual profile doesn't have. + +**Proposal worth considering:** run it at the office, beside the real Instantel +ACH server, on a **different port** (e.g. 12346 while Instantel holds 12345). +Then the ACEmanager change is a **port change, not an IP change** — smaller, +faster, less to get wrong, and trivially reversible. It also means the office +public IP (already stable and known) is the destination, rather than whatever +Brian's dynamic home IP happens to be that week. + +The tmi-dev approach used on BE12599 worked, but required a router forward and +ran into the dynamic-IP problem in the same session. + +## Open questions + +1. **Where does it run?** Office beside Instantel ACH (port swap), SFM on the + NAS, or ad-hoc on tmi-dev? Affects everything else. +2. **What drives it?** Terra-View admin page (fits "operator UI"), an SFM + endpoint pair (`POST /device/rescue_listener/start` + `/stop` + `/status`), + or a CLI wrapper? A long-lived listener doesn't fit the request/response + endpoint shape well — probably needs a background task with a status poll. +3. **How does it identify the unit?** It can't know the serial until the + device calls in and the handshake reads it. Allowlist by modem IP? Accept + anything and report what showed up? +4. **Where do drained events go?** A per-incident diagnostics store + (`bridges/captures/-diag`) seems right — explicitly *not* the prod + SFM DB. Does that store need to be a first-class thing with its own + retention, or is a directory fine? +5. **How is "confirm the modem is repointed" verified?** Operator attestation + (a button), or can we actually probe it? If the listener stops seeing + call-ins that's weak evidence; if inbound to the unit starts working that's + stronger. +6. **Multi-unit?** One listener per incident, or one listener that handles any + unit that dials in? Probably the former for safety. +7. **Timeout / abandonment policy.** If nobody ever confirms, does it run + forever? Alert after N hours? + +## What already exists + +- `bridges/ach_server.py` — the listener itself, with `--stop-monitoring`, + `--disable-ach`, `--rescue` (added on `feat/ach-rescue-on-connect`, commit + `9f1050b`), `--clear-after-download`, `--max-events`, `--allow-ip`. +- Per-session `rescue.json` recording per-action outcomes. +- Isolated per-output-dir SQLite + waveform store, so a diagnostics capture is + already separate from prod by construction. + +So the gap is not protocol work — it's lifecycle, operator surface, and the +confirmation gate. Most of the risk is in questions 1 and 2. -- 2.54.0 From 402bf30e37ad27490738ece074de05f884c44e5a Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 17 Sep 2026 06:10:03 +0000 Subject: [PATCH 24/28] docs(runbook): reframe as one disease with two cures, intercept first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Qcu9ByJfuKBQxmrWb8rSrN --- docs/runbooks/wedged_unit_recovery.md | 148 +++++++++++++++++++++----- 1 file changed, 124 insertions(+), 24 deletions(-) diff --git a/docs/runbooks/wedged_unit_recovery.md b/docs/runbooks/wedged_unit_recovery.md index 3536195..bf427a0 100644 --- a/docs/runbooks/wedged_unit_recovery.md +++ b/docs/runbooks/wedged_unit_recovery.md @@ -1,6 +1,7 @@ # Runbook — Recovering a wedged unit stuck in a call-home loop -**Original incident:** BE9558H at `166.246.130.1:9034`, recovered 2026-05-17. +**Incidents:** BE9558H at `166.246.130.1:9034`, 2026-05-17 (Method B) · +BE12599 at `166.246.64.226:9034`, 2026-09-16 (Method A). A field unit with a stuck-triggered geophone (or any hardware fault causing constant event triggering) will record events back-to-back, and if Auto Call @@ -14,24 +15,30 @@ This runbook describes how to break the loop and recover control. --- -## ⚠ There are TWO failure modes under "wedged unit" +## ⚠ Two cures for one disease — intercept first -Same root cause — an offset/connector fault drives the geophone above trigger, -the unit records back-to-back, ACH set to "after event recorded" dials -constantly — but the *recovery* differs, because the thing blocking you is -different. +Both incidents below are the **same failure**: a geophone offset crosses the +trigger level, the unit records back-to-back, ACH set to "after event +recorded" dials continuously, and the unit becomes unreachable because its +modem is in client mode almost all of the time. -| | **BE9558H (2026-05)** | **BE12599 (2026-09)** | +There are two ways to get a Stop Monitoring command into it. + +| | **A — intercept the call** (preferred) | **B — catch it between calls** (original) | |---|---|---| -| What blocks you | Modem mode-flipping kills inbound TCP | Device never enters S3 mode at all | -| Device state | Alive, in S3 mode, responsive once reached | Stuck repeating an **AT modem-init** string, deaf to S3 | -| Fix direction | **Inbound** — clear Destination, slow-drip a Stop | **Inverted** — point Destination at our own ACH server and let the unit call *us* | -| Winning tool | `scripts/slow_drip.sh` | `bridges/ach_server.py --stop-monitoring` | +| Idea | Be the server it dials. Point the modem's Destination at our own ACH server and answer it. | Clear the Destination so it stops dialing, then race a Stop into the gap. | +| Needs inbound? | **No — the unit calls us** | Yes: working inbound TCP to the modem | +| Determinism | Deterministic — it dials every ~75 s, we only have to be listening | A race. BE9558H took ~7 h of attempts before one landed. | +| Tool | `bridges/ach_server.py --stop-monitoring` | `scripts/slow_drip.sh` | +| Proven on | BE12599, 2026-09-16 | BE9558H, 2026-05-17 | -**Tell them apart with the ALEOS serial log** (see "Turn on ALEOS_SERIAL debug" -below). If the device is emitting `ATQ1/ATE0/ATS0=2` every ~75 s, it is in the -BE12599 mode and **no amount of inbound work will reach it** — skip to -"Second incident" below. +**Method A is the standard procedure now.** The unit won't answer us because +it is on the phone — so stop dialing it and be the one it calls. It rings, +we pick up, take its data, and tell it to stop calling here. + +Method B is kept because it is proven, and because A needs a listener the +modem can actually reach (public IP + forwarded port). When you have that, +don't race it — intercept it. --- @@ -52,9 +59,85 @@ If you see *all* of these, the unit is in this exact failure mode. --- -## Quick reference — how to recover +## Method A (preferred) — intercept the call -You need **ACEmanager access** to the unit's modem. +You need **ACEmanager access** and a host the modem can dial: public IP with +the listener's port forwarded to it. + +### A1 — start the listener BEFORE touching the modem + +```bash +cd /home/serversdown/seismo-relay +tmux new -s rescue +.venv/bin/python -u bridges/ach_server.py --port 12345 \ + -o bridges/captures/-diag --stop-monitoring -v +``` + +⚠ **Listener first, always.** A Destination pointed at a dead port is the +worst state available — the device still dials, the modem still flips to +client mode, inbound stays blocked, and nothing gets delivered. + +Do **not** add `--events-only` (it silently breaks dedup — see gotchas), and +do **not** add `--disable-ach` yet (see A4). + +### A2 — point the modem at it + +ACEmanager → **Serial → Port Configuration**: + +| Field | Set to | +|---|---| +| **Destination Address** | the listener's public IP | +| **Destination Port** | the listener's port (e.g. `12345`) | + +Apply. The modem auto-dials its Destination whenever serial data arrives +while the serial port is closed — so the unit's own retry cycle now lands on +you instead of nowhere. + +### A3 — answer, and stop the bleeding + +Within ~75 s you should see a call-in. `--stop-monitoring` fires SUB 0x97 at +step 1.5 — after the handshake, **before** the event walk — so the recording +halts at the earliest possible moment in the session. Confirm via +`rescue.json` in the session directory: + +```json +{"peer": "166.246.64.226:60921", "stop_monitoring": "ok"} +``` + +That is the bleeding stopped. Everything after this is cleanup. + +### A4 — drain the backlog, THEN disable ACH + +⚠ **Order matters, and it is counter-intuitive.** Stopping monitoring also +removes your call-in trigger: ACH fires on "after event recorded", so with +recording stopped the unit has no reason to dial again. The backlog sitting +in its memory does **not** re-arm it. + +So if the stored events are worth keeping — and on a fault unit they usually +are, they're the evidence — drain them across however many call-ins it takes +*before* you silence it. Only then add `--disable-ach` (or use +`scripts/rescue_device.sh --no-erase`). + +If the unit has gone quiet and you still need it, cycling the modem produces +a call-in, and a unit with a scheduled daily call will dial at its configured +time regardless. + +### A5 — restore the Destination, and confirm you did + +Put `Destination Address` back to `0.0.0.0` (or the office Instantel ACH +server) once you are finished, and only stop the listener after that is done. + +### A6 — do NOT re-enable ACH until the hardware fault is repaired + +Otherwise the loop restarts the moment monitoring resumes and you run this +runbook again. + +--- + +## Method B (fallback) — catch it between calls + +The original 2026-05 procedure. Use when you cannot stand up a listener the +modem can reach. You need **ACEmanager access** to the unit's modem. ### Step 1: stop the modem's mode-flipping @@ -287,9 +370,14 @@ recovery: ~7 hours of intermittent debugging across one evening. level. Constant triggering → constant recording → ACH "after event recorded" → continuous dialing. Same disease as BE9558H. -**But the recovery was the opposite direction**, and none of the Step 1–4 -procedure above worked. Total time ≈ 5 h, of which ~90 min was spent on two -red herrings documented below. +**Same disease, inverted cure.** Method B's Step 1 *did* work — clearing the +Destination stopped the dial-outs, confirmed in the ALEOS log. It was Step 2 +that didn't land, and rather than keep racing we turned the rescue around: +gave the unit a different server to call, and answered it. + +Total time ≈ 5 h, of which ~90 min went to two red herrings documented below. +Much of the rest was rediscovering the May procedure, which is why the +"two cures" table now sits at the top of this file. --- @@ -302,7 +390,7 @@ DEBUG**, then view the serial log. It is the only thing that tells you what the *device* is actually saying. Everything before we did this was guesswork. -## What the log showed — the device was never in S3 mode +## What the log showed — the unit is on the phone Every ~75 seconds, verbatim: @@ -326,9 +414,15 @@ Read that carefully: - The device therefore never receives `OK`, never progresses, and **retries the identical 29 bytes forever**. -**Consequence: the device is not running the S3 protocol parser.** You can -land a byte-perfect S3 frame on it and it will be ignored. This is why every -inbound approach failed, and it is the structural difference from BE9558H. +**While it is in this state it is busy placing a call, not listening for +us.** This is almost certainly what BE9558H was doing too — we simply never +turned on ALEOS_SERIAL debug in May to look. It is not a different disease; +it is the same one, seen properly for the first time. + +It is also the argument for Method A in one picture: the unit is mid-dial +every ~75 s, and our inbound Stop has to thread the gaps between those +attempts. Give it somewhere to dial and the problem inverts into a +deterministic one. ### Why `slow_drip` lied @@ -351,6 +445,12 @@ nobody was reading. ⚠ **`send_error: null` + full duration is NOT success. Only `bytes_received > 0` is success.** +⚠ **In fairness to slow_drip: it got exactly one attempt here**, run ~90 s +after a modem reboot, with a dead session visible in the log at 20:19:17 in +that same window. BE9558H took hours of attempts before one landed. Method B +was not ruled out on BE12599 so much as abandoned in favour of something that +doesn't need luck. + --- ## ⚠ Two red herrings that cost ~90 minutes -- 2.54.0 From 2fabf84d4d984b49c6c7c0a8a72e536a0c59a430 Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 18 Sep 2026 17:39:56 +0000 Subject: [PATCH 25/28] docs: adopt a changelog convention, and make Unreleased follow it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Qcu9ByJfuKBQxmrWb8rSrN --- CHANGELOG.md | 20 ++++++++++---------- CLAUDE.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dbbfd2..4abc959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,16 +6,6 @@ All notable changes to seismo-relay are documented here. ## Unreleased -**Blastware Event/FFT-Report parity — the FFT, the USBM compliance chart, and -the sensor self-check.** Three analyses Blastware derives from event data, -reverse-engineered against BE12844 (MiniMate Plus) reports and reproduced in -seismo-relay: the compliance chart and the sensor-check strip now render on -the event-report PDF, and the FFT reproduces Blastware's FFT Report. All three -are additive and read from data already on disk — the `.h5` samples and the -retained raw BW binary — so there is **no `.h5`/DB change, no migration, and no -backfill**: a report regenerated for an existing event simply gains the new -panels. - ### Added - **Rescue-on-connect for `bridges/ach_server.py`** — `--stop-monitoring` (SUB 0x97), `--disable-ach` (SUB 0x2C read → 0x7E write → 0x7F confirm) and @@ -85,6 +75,16 @@ panels. --- +### Migration + +**None.** Every change here is additive and reads from data already on disk — +the `.h5` samples and the retained raw BW binary. No `.h5`/DB change, no +schema change, no migration, no backfill, and **no `TOOL_VERSION` bump**: a +report regenerated for an existing event simply gains the new panels, and the +`ach_server` rescue flags don't touch the codec. + +--- + ## v0.30.0 — 2026-09-12 **The series-4 correctness release** — the Thor / Micromate counterpart to diff --git a/CLAUDE.md b/CLAUDE.md index 05ec9ea..5ed6a15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,36 @@ When new information about the protocol is discovered, please update the instant --- +## Changelog & release convention + +**Write the entry in the same commit as the work, under `## Unreleased`. Cut +the version on `dev` in a dedicated release commit. Never touch the changelog +at a merge boundary.** + +- **Entry goes in with the change**, not at merge or release time — that is the + only moment you still know *why*. Feature branches edit `CHANGELOG.md` + directly; the occasional conflict is two appended bullets and is trivial. +- **No preamble under `## Unreleased`** — just the `### Added` / `### Changed` / + `### Fixed` lists. The themed opening paragraph gets written at release + time, when the whole release is visible and can be named honestly. A theme + written when the first item landed is stale by the third. +- ⚠ **State the operational consequence** on any entry touching the codec, the + waveform store, or the DB — **including when it is "none."** "requires + `backfill_sidecars.py` + `backfill_event_shape.py`, ~2 h on the NAS", + "`TOOL_VERSION` bumped", "no schema change, no migration". Silence is + ambiguous; "none" is information. This repo's changelog is how future-you + learns whether a deploy costs two hours. +- **Cutting a release** is its own `chore(release): vX.Y.Z — ` commit on + `dev`, renaming `## Unreleased` → `## vX.Y.Z — YYYY-MM-DD` and touching: + `CHANGELOG.md`, `pyproject.toml`, the version line in `CLAUDE.md` and + `README.md`, and `minimateplus/event_file_io.py` (`TOOL_VERSION`) **when the + codec changed** — that constant gates `.h5` regeneration. +- **`main` carries only released versions.** No `## Unreleased` section there; + it lands via the `dev` → `main` PR. `main` lagging `dev` by a version is + normal. + +--- + ## Architecture: three-tier conceptual model seismo-relay is a **suite of cooperating components**, not a single app. -- 2.54.0 From a42e8d36516a2644b526b924030081739c823792 Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 18 Sep 2026 18:04:54 +0000 Subject: [PATCH 26/28] docs: make the release cadence explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Qcu9ByJfuKBQxmrWb8rSrN --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 5ed6a15..457b63a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,10 @@ at a merge boundary.** "`TOOL_VERSION` bumped", "no schema change, no migration". Silence is ambiguous; "none" is information. This repo's changelog is how future-you learns whether a deploy costs two hours. +- **Releases are cut on judgement, not on a schedule or a merge.** `Unreleased` + is the staging area for whatever is going into the next release; when enough + has accumulated to be worth shipping, it gets a number and a date. Nothing + about a merge to `dev` triggers a release. - **Cutting a release** is its own `chore(release): vX.Y.Z — ` commit on `dev`, renaming `## Unreleased` → `## vX.Y.Z — YYYY-MM-DD` and touching: `CHANGELOG.md`, `pyproject.toml`, the version line in `CLAUDE.md` and -- 2.54.0 From 0408c3786666b72bc31c463c6da252b11e21acbb Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 18 Sep 2026 18:52:17 +0000 Subject: [PATCH 27/28] docs: write the changelog on dev, not on feature branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Qcu9ByJfuKBQxmrWb8rSrN --- CLAUDE.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 457b63a..7b74a84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,13 +91,20 @@ When new information about the protocol is discovered, please update the instant ## Changelog & release convention -**Write the entry in the same commit as the work, under `## Unreleased`. Cut -the version on `dev` in a dedicated release commit. Never touch the changelog -at a merge boundary.** +**Feature branches do NOT touch `CHANGELOG.md`. Write the entry on `dev`, as +part of finishing the merge, under `## Unreleased`. Cut the version on `dev` in a +dedicated release commit when you are ready to ship to `main`.** -- **Entry goes in with the change**, not at merge or release time — that is the - only moment you still know *why*. Feature branches edit `CHANGELOG.md` - directly; the occasional conflict is two appended bullets and is trivial. +- **The changelog is written on `dev`, never on a feature branch.** With + several branches in flight they all edit the same few lines at the top of + the file and conflict every time. Writing it once, after the merge, also + lets it describe what actually *landed* — including anything that changed + during conflict resolution. +- ⚠ **The merge is not finished until `## Unreleased` is updated.** Same sitting, + not "later" — that is the one failure mode of writing it after the fact. + Reconstruct from the branch's own commit messages: + `git log --oneline dev..` before you merge, or + `git log --oneline ..` after. - **No preamble under `## Unreleased`** — just the `### Added` / `### Changed` / `### Fixed` lists. The themed opening paragraph gets written at release time, when the whole release is visible and can be named honestly. A theme -- 2.54.0 From 0b58415fe29b6b5e6d932c15f57fd2c7d4bd4a0f Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 18 Sep 2026 20:40:48 +0000 Subject: [PATCH 28/28] =?UTF-8?q?chore(release):=20v0.31.0=20=E2=80=94=20r?= =?UTF-8?q?eport=20parity=20+=20the=20inverted=20rescue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Qcu9ByJfuKBQxmrWb8rSrN --- CHANGELOG.md | 25 ++++++++++++++++++++++++- CLAUDE.md | 2 +- README.md | 2 +- pyproject.toml | 2 +- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d69902..e3d1232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,30 @@ All notable changes to seismo-relay are documented here. --- -## Unreleased +## v0.31.0 — 2026-09-18 + +**Report parity, and a second way to rescue a runaway unit.** Two threads. + +The first closes out 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 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. The Inspector — an +annotated hex reader for series-3 binaries — is what made the trailing-block +structure findable, and it earned its keep by *ruling out* a stored FFT block +and proving Blastware computes it from the samples. + +The second came out of a field emergency. BE12599's connector fault drove its +Tran channel to its trigger level, so the unit recorded back-to-back and dialed +the office ACH server every ~75 s, unreachable the whole time. +`bridges/ach_server.py` gained `--stop-monitoring` / `--disable-ach` / +`--rescue`, which **invert** the recovery: instead of racing a Stop into the +gaps between dial-outs, point the modem's Destination at our own ACH server and +answer the call. Proven in production the same night — the stop landed on the +first call-in and held. See `docs/runbooks/wedged_unit_recovery.md`. + +⚠ **This release owes prod a backfill** — see Migration below. ### Added - **Rescue-on-connect for `bridges/ach_server.py`** — `--stop-monitoring` diff --git a/CLAUDE.md b/CLAUDE.md index 7b74a84..67dee54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem -(Sierra Wireless RV50 / RV55). Current version: **v0.30.0**. +(Sierra Wireless RV50 / RV55). Current version: **v0.31.0**. Stack-level context — which repo owns what, and how the three project versions pair — lives in `../terra-view/docs/tmi-stack.md`, which is also loaded as diff --git a/README.md b/README.md index a11473c..7f384f2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# seismo-relay `v0.30.0` +# seismo-relay `v0.31.0` A ground-up replacement for **Blastware** — Instantel's aging Windows-only software for managing seismographs. Supports both the **MiniMate Plus diff --git a/pyproject.toml b/pyproject.toml index 4b9e639..10ea7e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "seismo-relay" -version = "0.30.0" +version = "0.31.0" description = "Python client and REST server for MiniMate Plus seismographs" requires-python = ">=3.10" dependencies = [ -- 2.54.0