From 845ec38f96211cf027ca831b8c03a4fff2fbb76d Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 10 Sep 2026 18:53:27 +0000 Subject: [PATCH 01/13] 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 From 11e3e515f3d375bb088e6112db0cfa32db617f28 Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 10 Sep 2026 18:56:54 +0000 Subject: [PATCH 02/13] =?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) From 2902ab373e7b356e2ed7cbf5269bbd3f6268e092 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 14 Sep 2026 17:15:16 +0000 Subject: [PATCH 03/13] 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 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 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]) From dad35e47feff486877c3565495c2e3864d269255 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 14 Sep 2026 18:34:18 +0000 Subject: [PATCH 04/13] 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 From db818f716cd808877c03de3007fdf9c01c78a9c1 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 14 Sep 2026 20:18:29 +0000 Subject: [PATCH 05/13] 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]]]: From 6ad4fd73dd1eb17e7b29e7c663d593cd12f9212b Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 14 Sep 2026 20:21:17 +0000 Subject: [PATCH 06/13] 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()) From 95f926c3187f05020005c344187fe47febec95b0 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 14 Sep 2026 20:33:09 +0000 Subject: [PATCH 07/13] 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]]]: From dc74c97ade6c6bc1d6ca36dc3638bddd12af177b Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 14 Sep 2026 22:56:44 +0000 Subject: [PATCH 08/13] 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: From 6341432524bc9032fb1d6a1b098f693b8f27b3c1 Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 15 Sep 2026 05:26:28 +0000 Subject: [PATCH 09/13] 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"") == {} From ab9d84fde6df4c38e3bc7c4d34a35319fd508394 Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 15 Sep 2026 05:33:38 +0000 Subject: [PATCH 10/13] 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 From 2c5c20cfd77171055ccff97b37d47bf3ad62fe81 Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 15 Sep 2026 05:39:53 +0000 Subject: [PATCH 11/13] 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") From 2a747f6893fd56189bd264cddca474aeba8e1c83 Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 15 Sep 2026 05:48:31 +0000 Subject: [PATCH 12/13] 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 From 4f73e919a0954c1b199d9d9f2f85ee69ee21704f Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 15 Sep 2026 14:34:04 +0000 Subject: [PATCH 13/13] =?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