From 9b71ead44b9a9f5987271aba1e8a4cbcf69166c1 Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 29 May 2026 06:33:06 +0000 Subject: [PATCH 01/11] series 4 codec work, inital decode success --- CLAUDE.md | 22 ++ analysis_idf/corpus_accuracy.py | 65 +++++ analysis_idf/diff_trail.py | 49 ++++ analysis_idf/e2e_idfh.py | 48 ++++ analysis_idf/e2e_no_txt.py | 40 +++ analysis_idf/e2e_save_idf.py | 52 ++++ analysis_idf/idfh_decode.py | 137 +++++++++ analysis_idf/idfh_period.py | 41 +++ analysis_idf/per_file_detail.py | 40 +++ analysis_idf/probe_boundary.py | 64 +++++ analysis_idf/recon.py | 89 ++++++ analysis_idf/seg_resync.py | 40 +++ analysis_idf/smoke_idfh.py | 46 +++ analysis_idf/smoke_test.py | 48 ++++ analysis_idf/trace_path.py | 73 +++++ analysis_idf/try_codec.py | 42 +++ analysis_idf/verify_full.py | 51 ++++ docs/idf_protocol_reference.md | 67 ++++- micromate/idf_file.py | 482 ++++++++++++++++++++++++++++---- sfm/waveform_store.py | 158 +++++++++-- 20 files changed, 1578 insertions(+), 76 deletions(-) create mode 100644 analysis_idf/corpus_accuracy.py create mode 100644 analysis_idf/diff_trail.py create mode 100644 analysis_idf/e2e_idfh.py create mode 100644 analysis_idf/e2e_no_txt.py create mode 100644 analysis_idf/e2e_save_idf.py create mode 100644 analysis_idf/idfh_decode.py create mode 100644 analysis_idf/idfh_period.py create mode 100644 analysis_idf/per_file_detail.py create mode 100644 analysis_idf/probe_boundary.py create mode 100644 analysis_idf/recon.py create mode 100644 analysis_idf/seg_resync.py create mode 100644 analysis_idf/smoke_idfh.py create mode 100644 analysis_idf/smoke_test.py create mode 100644 analysis_idf/trace_path.py create mode 100644 analysis_idf/try_codec.py create mode 100644 analysis_idf/verify_full.py diff --git a/CLAUDE.md b/CLAUDE.md index c2892d6..ba8be79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,28 @@ 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) + +`micromate/idf_file.read_idf_file()` decodes both Thor IDFW +(waveform) and IDFH (histogram) binaries. + +- **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). + +The two outlier `BE9439_*` files in the Thor example corpus are +actually Series III Blastware binaries that share the `.IDFW`/`.IDFH` +filename convention by accident. `read_idf_file()` detects them by +their BW STRT signature and raises NotImplementedError pointing +callers at `read_blastware_file()`. See +`docs/idf_protocol_reference.md` for full field layouts. + ### Practical consequences When deciding where new code goes, ask: diff --git a/analysis_idf/corpus_accuracy.py b/analysis_idf/corpus_accuracy.py new file mode 100644 index 0000000..acdd0e9 --- /dev/null +++ b/analysis_idf/corpus_accuracy.py @@ -0,0 +1,65 @@ +"""Run read_idf_file across the corpus and report per-channel accuracy vs sidecars.""" +from __future__ import annotations +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +from micromate.idf_file import read_idf_file +from analysis_idf.recon import load_sidecar_samples + + +def sidecar_path(idfw: Path) -> Path: + return idfw.parent / "TXT" / f"{idfw.name}.txt" + + +def main(): + root = REPO / "tests/fixtures/THORDATA_example" + files = [f for f in root.rglob("*.IDFW") if not str(f).endswith(".CDB")] + files.sort() + GEO_LSB = 0.0003 + + n_ok = n_skip = 0 + overall = {"Tran": [], "Vert": [], "Long": []} + + for f in files: + try: + res = read_idf_file(f) + except Exception: + n_skip += 1 + continue + sc_path = sidecar_path(f) + if not sc_path.exists(): + n_skip += 1 + continue + try: + sc = load_sidecar_samples(sc_path) + except Exception: + n_skip += 1 + continue + + per_file = {} + for ch in ("Tran", "Vert", "Long"): + sc_counts = [int(round(v / GEO_LSB)) for v in sc[ch]] + dec = res.samples.get(ch, []) + n = min(len(sc_counts), len(dec)) + if n == 0: + per_file[ch] = 0.0 + continue + exact = sum(1 for i in range(n) if sc_counts[i] == dec[i]) + pct = 100.0 * exact / n + per_file[ch] = pct + overall[ch].append(pct) + n_ok += 1 + + print(f"Processed {n_ok} files (skipped {n_skip})") + print("Per-channel exact-match % (mean / min / max):") + for ch, vals in overall.items(): + if vals: + avg = sum(vals) / len(vals) + print(f" {ch}: mean={avg:.2f}% min={min(vals):.2f}% max={max(vals):.2f}% n={len(vals)}") + + +if __name__ == "__main__": + main() diff --git a/analysis_idf/diff_trail.py b/analysis_idf/diff_trail.py new file mode 100644 index 0000000..a64295b --- /dev/null +++ b/analysis_idf/diff_trail.py @@ -0,0 +1,49 @@ +"""Find where decoded-vs-sidecar diverges for each channel.""" +from __future__ import annotations +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +from minimateplus.waveform_codec import decode_waveform_v2 +from analysis_idf.recon import TARGET, TXT, load_sidecar_samples + + +def main(): + buf = TARGET.read_bytes() + sc = load_sidecar_samples(TXT) + decoded = decode_waveform_v2(buf[0x0f1f:]) + GEO_LSB = 0.0003 + + for ch in ("Tran", "Vert", "Long"): + sc_counts = [int(round(v / GEO_LSB)) for v in sc[ch]] + dec = decoded[ch] + # Find ALL transitions where mismatches start/stop + first_diff = next((i for i in range(len(dec)) if dec[i] != sc_counts[i]), None) + if first_diff is None: + print(f"{ch}: NO MISMATCHES") + continue + print(f"{ch}: first diff at idx {first_diff}") + # Show 5 before, 5 after + for i in range(max(0, first_diff - 3), min(len(dec), first_diff + 8)): + mark = " " if dec[i] == sc_counts[i] else "**" + print(f" {mark} idx {i:4d}: sc={sc_counts[i]:6d} dec={dec[i]:6d} diff={dec[i]-sc_counts[i]:+d}") + # Where does cumulative diff exceed 100? + cum_match_run = 0 + max_match_run = 0 + match_run_start = 0 + diff_count = 0 + for i in range(len(dec)): + if dec[i] == sc_counts[i]: + cum_match_run += 1 + max_match_run = max(max_match_run, cum_match_run) + else: + cum_match_run = 0 + diff_count += 1 + print(f" total mismatches: {diff_count}/{len(dec)}, longest run of matches: {max_match_run}") + print() + + +if __name__ == "__main__": + main() diff --git a/analysis_idf/e2e_idfh.py b/analysis_idf/e2e_idfh.py new file mode 100644 index 0000000..3f5ec43 --- /dev/null +++ b/analysis_idf/e2e_idfh.py @@ -0,0 +1,48 @@ +"""End-to-end IDFH ingest verification.""" +from __future__ import annotations +import sys +import tempfile +import json +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +from sfm.waveform_store import WaveformStore + + +def main(): + idfh = REPO / "tests/fixtures/THORDATA_example/THORDATA_example/UPMC Presby/UM13981/UM13981_20220805075441.IDFH" + txt = idfh.parent / "TXT" / f"{idfh.name}.txt" + + with tempfile.TemporaryDirectory() as td: + store = WaveformStore(Path(td)) + ev, rec = store.save_imported_idf( + idfh.read_bytes(), + idfh, + idf_report_text=txt.read_text(errors="replace"), + ) + print("=== save_imported_idf (IDFH) ===") + print(f" serial: {rec['serial']}") + print(f" filename: {rec['filename']}") + print(f" filesize: {rec['filesize']}") + print(f" h5: {rec['hdf5_filename']}") # expect None for histogram + print(f" sidecar: {rec['sidecar_filename']}") + print() + print("=== Event ===") + print(f" timestamp: {ev.timestamp}") + print(f" record_type: {ev.record_type}") + print(f" sample_rate: {ev.sample_rate}") + print() + # Inspect sidecar to confirm intervals were stashed + sc_path = Path(td) / "UM13981" / f"{idfh.name}.sfm.json" + sc = json.loads(sc_path.read_text()) + intervals = sc.get("extensions", {}).get("idf_intervals", []) + print(f" sidecar intervals: {len(intervals)}") + if intervals: + print(f" first interval: {intervals[0]}") + print(f" last interval: {intervals[-1]}") + + +if __name__ == "__main__": + main() diff --git a/analysis_idf/e2e_no_txt.py b/analysis_idf/e2e_no_txt.py new file mode 100644 index 0000000..a9c81b6 --- /dev/null +++ b/analysis_idf/e2e_no_txt.py @@ -0,0 +1,40 @@ +"""Verify the had_report=False path: ingest IDFW with no .txt.""" +from __future__ import annotations +import sys +from pathlib import Path +import tempfile + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +from sfm.waveform_store import WaveformStore + + +def main(): + idfw = REPO / "tests/fixtures/THORDATA_example/THORDATA_example/UPMC Presby/UM11719/UM11719_20231219162723.IDFW" + with tempfile.TemporaryDirectory() as td: + store = WaveformStore(Path(td)) + ev, rec = store.save_imported_idf( + idfw.read_bytes(), + idfw, + serial_hint=None, + idf_report_text=None, # ← no .txt! + ) + print("=== IDFW without .txt ingest ===") + print(f" serial: {rec['serial']}") + print(f" timestamp: {ev.timestamp}") + print(f" sample_rate: {ev.sample_rate}") + print(f" record_type: {ev.record_type}") + print(f" rectime_sec: {ev.rectime_seconds}") + nT = len(ev.raw_samples.get('Tran', [])) if ev.raw_samples else 0 + nV = len(ev.raw_samples.get('Vert', [])) if ev.raw_samples else 0 + nL = len(ev.raw_samples.get('Long', [])) if ev.raw_samples else 0 + nM = len(ev.raw_samples.get('MicL', [])) if ev.raw_samples else 0 + print(f" raw_samples: Tran={nT} Vert={nV} Long={nL} MicL={nM}") + if ev.peak_values: + print(f" peak_values: tran={ev.peak_values.tran} vert={ev.peak_values.vert} long={ev.peak_values.long}") + print(f" h5 written: {rec['hdf5_filename']}") + + +if __name__ == "__main__": + main() diff --git a/analysis_idf/e2e_save_idf.py b/analysis_idf/e2e_save_idf.py new file mode 100644 index 0000000..87e9650 --- /dev/null +++ b/analysis_idf/e2e_save_idf.py @@ -0,0 +1,52 @@ +"""End-to-end ingest test: feed an IDFW + .txt to save_imported_idf in a tmp store.""" +from __future__ import annotations +import sys +from pathlib import Path +import tempfile +import shutil + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +from sfm.waveform_store import WaveformStore + + +def main(): + idfw = REPO / "tests/fixtures/THORDATA_example/THORDATA_example/UPMC Presby/UM11719/UM11719_20231219162723.IDFW" + txt = idfw.parent / "TXT" / f"{idfw.name}.txt" + + with tempfile.TemporaryDirectory() as td: + store = WaveformStore(Path(td)) + ev, rec = store.save_imported_idf( + idfw.read_bytes(), + idfw, + serial_hint=None, + idf_report_text=txt.read_text(errors="replace"), + ) + print("=== Save result ===") + print(f" serial: {rec['serial']}") + print(f" filename: {rec['filename']}") + print(f" filesize: {rec['filesize']}") + print(f" h5: {rec['hdf5_filename']}") + print(f" sidecar: {rec['sidecar_filename']}") + print() + print("=== Event ===") + print(f" serial: {ev.serial if hasattr(ev,'serial') else '(n/a)'}") + print(f" timestamp: {ev.timestamp}") + print(f" sample_rate: {ev.sample_rate}") + print(f" record_type: {ev.record_type}") + print(f" rectime_sec: {ev.rectime_seconds}") + print(f" raw_samples: Tran={len(ev.raw_samples.get('Tran', [])) if ev.raw_samples else 0}, Vert={len(ev.raw_samples.get('Vert', [])) if ev.raw_samples else 0}, Long={len(ev.raw_samples.get('Long', [])) if ev.raw_samples else 0}, MicL={len(ev.raw_samples.get('MicL', [])) if ev.raw_samples else 0}") + if ev.peak_values: + print(f" peaks (txt): Tran={ev.peak_values.tran} Vert={ev.peak_values.vert} Long={ev.peak_values.long}") + print() + + # Verify the h5 file actually got written + h5path = Path(td) / "UM11719" / f"{idfw.name}.h5" + print(f" h5 exists: {h5path.exists()} size={h5path.stat().st_size if h5path.exists() else 0}") + sidecar = Path(td) / "UM11719" / f"{idfw.name}.sfm.json" + print(f" sidecar exists:{sidecar.exists()} size={sidecar.stat().st_size if sidecar.exists() else 0}") + + +if __name__ == "__main__": + main() diff --git a/analysis_idf/idfh_decode.py b/analysis_idf/idfh_decode.py new file mode 100644 index 0000000..ae4354a --- /dev/null +++ b/analysis_idf/idfh_decode.py @@ -0,0 +1,137 @@ +"""Decode IDFH histogram intervals + verify against sidecar.""" +from __future__ import annotations +import sys +import struct +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + + +SEGMENT_MAGIC = b"\x02\xda\x0a\x00\x00\x00" +SEGMENT_SIZE = 732 # = 10-byte header + 10 × 72-byte intervals + 2-byte tail +INTERVAL_SIZE = 72 +CHANNELS = ("Tran", "Vert", "Long", "MicL") + + +def decode_interval(buf72: bytes) -> dict: + """Decode one 72-byte interval into per-channel min/max/halfp.""" + out = {} + for i, ch in enumerate(CHANNELS): + block = buf72[i*16 : (i+1)*16] + mn = struct.unpack_from(">h", block, 0)[0] + mx = struct.unpack_from(">h", block, 2)[0] + sb = struct.unpack_from(">h", block, 4)[0] + halfp = struct.unpack_from(">H", block, 6)[0] + f10 = struct.unpack_from(">H", block, 10)[0] + f14 = struct.unpack_from(">H", block, 14)[0] + peak_count = max(abs(mn), abs(mx)) + out[ch] = { + "min": mn, + "max": mx, + "field4": sb, + "halfp": halfp, + "field10": f10, + "field14": f14, + "peak": peak_count, + "freq_hz": (512.0 / halfp) if halfp > 5 else None, + } + out["_tail"] = buf72[64:].hex(" ") + return out + + +def walk_idfh(buf: bytes) -> list: + """Walk all interval records in an IDFH file.""" + intervals = [] + # Multi-segment file: every 02 da 0a 00 00 00 marker introduces a segment. + # Single-interval file: just one body header at 0xf96 of form ?? ?? 0a 00 00 00. + # Find them all. + i = 0 + while True: + j = buf.find(b"\x0a\x00\x00\x00", i) + if j < 0: + break + # Validate: the 2 bytes before must form a length, and we want bytes + # [j-2 : j+6] to have a recognisable shape. Actually the cleanest + # filter is "preceded by a length and followed by 00 NN 05 3f". + if j < 2: + i = j + 1 + continue + # Body header form: [length_be_2][0a 00 00 00][00 NN][05 3f] + if j + 10 > len(buf): + break + length = int.from_bytes(buf[j-2:j], "big") + # Verify the segment-marker shape: [length_be][0a 00 00 00][00 NN][05 3f] + if buf[j+4] != 0x00: + i = j + 1 + continue + if buf[j+6:j+8] != b"\x05\x3f": + i = j + 1 + continue + # Header layout (10 bytes): [length_be 2B][0a 00 00 00 4B][00 NN 2B][05 3f 2B] + # Followed by N interval records of 72 bytes each, then 2 tail bytes. + # length value = (N × 72) + 10 (counts bytes from 0x0a... through interval data). + header_start = j - 2 + n_intervals = (length - 10) // INTERVAL_SIZE + interval_start = header_start + 10 + for k in range(n_intervals): + off = interval_start + k * INTERVAL_SIZE + if off + INTERVAL_SIZE > len(buf): + break + chunk = buf[off:off + INTERVAL_SIZE] + intervals.append({"offset": off, **decode_interval(chunk)}) + i = header_start + length + 2 + return intervals + + +def main(): + # Test against multi-segment IDFH + target = REPO / "tests/fixtures/THORDATA_example/THORDATA_example/UPMC Presby/UM13981/UM13981_20220805075441.IDFH" + sc_path = target.parent / "TXT" / f"{target.name}.txt" + buf = target.read_bytes() + intervals = walk_idfh(buf) + print(f"=== {target.name} ===") + print(f" file size: {len(buf)}") + print(f" decoded intervals: {len(intervals)}") + # Show first 2 + last 2 + sc_rows = [] + for line in sc_path.read_text(errors="replace").splitlines(): + if line.startswith("2022-") or line.startswith("2023-"): + sc_rows.append(line) + print(f" sidecar rows: {len(sc_rows)}") + + print() + for k in [0, 1, 78, 79, 80]: + if k >= len(intervals): + continue + iv = intervals[k] + print(f"--- interval {k} @0x{iv['offset']:04x} ---") + for ch in CHANNELS: + d = iv[ch] + peak_ips = d["peak"] / 32768 * 10.0 + print(f" {ch}: peak={d['peak']:5d} ({peak_ips:.4f} in/s) halfp={d['halfp']:5d} freq={d['freq_hz']}") + # sidecar row + if k < len(sc_rows): + print(f" SC: {sc_rows[k]}") + + # Test single-interval IDFH + print() + target2 = REPO / "tests/fixtures/THORDATA_example/THORDATA_example/UPMC Presby/UM11719/UM11719_20231219162648.IDFH" + sc2 = target2.parent / "TXT" / f"{target2.name}.txt" + buf2 = target2.read_bytes() + intervals2 = walk_idfh(buf2) + print(f"=== {target2.name} ===") + print(f" file size: {len(buf2)}, decoded intervals: {len(intervals2)}") + if intervals2: + iv = intervals2[0] + for ch in CHANNELS: + d = iv[ch] + peak_ips = d["peak"] / 32768 * 10.0 + print(f" {ch}: peak={d['peak']:5d} ({peak_ips:.4f} in/s) halfp={d['halfp']:5d} freq={d['freq_hz']}") + sc_rows2 = [l for l in sc2.read_text(errors='replace').splitlines() if l.startswith("2023-")] + if sc_rows2: + print(f" SC: {sc_rows2[0]}") + + +if __name__ == "__main__": + main() diff --git a/analysis_idf/idfh_period.py b/analysis_idf/idfh_period.py new file mode 100644 index 0000000..8aad756 --- /dev/null +++ b/analysis_idf/idfh_period.py @@ -0,0 +1,41 @@ +"""Find IDFH interval period via auto-correlation of structural patterns.""" +from __future__ import annotations +import sys +from pathlib import Path +from collections import Counter + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + + +def main(): + target = REPO / "tests/fixtures/THORDATA_example/THORDATA_example/UPMC Presby/UM13981/UM13981_20220805075441.IDFH" + buf = target.read_bytes() + body_start = 0xF96 + body_end = 0x270C + body = buf[body_start:body_end] + print(f"body size: {len(body)} bytes (file {len(buf)} bytes)") + + # For each candidate interval size, count how many bytes at fixed offsets within + # each interval are zero (consistent column-zero pattern indicates correct size). + print() + print("=== zero-column score by interval size (higher = more likely) ===") + best = [] + for sz in range(16, 100): + n = len(body) // sz + if n < 30: + continue + # For each column position within an interval, count how many of n intervals have zero + score = 0 + for col in range(sz): + zeros = sum(1 for i in range(n) if body[i*sz + col] == 0) + if zeros >= n * 0.9: + score += 1 + best.append((score, sz, n)) + best.sort(reverse=True) + for score, sz, n in best[:10]: + print(f" size={sz:3d} n_intervals={n} consistently-zero-cols={score}") + + +if __name__ == "__main__": + main() diff --git a/analysis_idf/per_file_detail.py b/analysis_idf/per_file_detail.py new file mode 100644 index 0000000..b9040f3 --- /dev/null +++ b/analysis_idf/per_file_detail.py @@ -0,0 +1,40 @@ +"""Per-file accuracy + sample-count details.""" +from __future__ import annotations +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +from micromate.idf_file import read_idf_file +from analysis_idf.recon import load_sidecar_samples + + +def main(): + root = REPO / "tests/fixtures/THORDATA_example" + files = sorted([f for f in root.rglob("*.IDFW") if not str(f).endswith(".CDB")]) + GEO_LSB = 0.0003 + # Limit to first 15 successful files for detail. + shown = 0 + for f in files: + try: + res = read_idf_file(f) + except Exception: + continue + sc_path = f.parent / "TXT" / f"{f.name}.txt" + if not sc_path.exists(): + continue + sc = load_sidecar_samples(sc_path) + sc_tran = [int(round(v / GEO_LSB)) for v in sc["Tran"]] + dec = res.samples.get("Tran", []) + n = min(len(sc_tran), len(dec)) + exact = sum(1 for i in range(n) if sc_tran[i] == dec[i]) if n else 0 + pct = 100.0 * exact / n if n else 0.0 + print(f"{f.name:40s} size={f.stat().st_size:6d} sc_n={len(sc_tran):4d} dec_n={len(dec):4d} exact={pct:.1f}%") + shown += 1 + if shown >= 20: + break + + +if __name__ == "__main__": + main() diff --git a/analysis_idf/probe_boundary.py b/analysis_idf/probe_boundary.py new file mode 100644 index 0000000..bbf2722 --- /dev/null +++ b/analysis_idf/probe_boundary.py @@ -0,0 +1,64 @@ +"""Look at what's at the divergence boundary.""" +from __future__ import annotations +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +from minimateplus.waveform_codec import walk_body, find_data_start, parse_segment_header +from analysis_idf.recon import TARGET, TXT, load_sidecar_samples + + +def main(): + buf = TARGET.read_bytes() + body = buf[0x0f1f:] + start = find_data_start(body) + print(f"data_start: {start} (= file offset 0x{0x0f1f + start:04x})") + + blocks = walk_body(body, start) + print(f"{len(blocks)} blocks total") + print() + + # First 25 blocks + print("=== first 30 blocks ===") + for i, b in enumerate(blocks[:30]): + body_off = 0x0f1f + b.offset + if b.tag_hi == 0x40: + hdr = parse_segment_header(b) + print(f" [{i:3d}] @0x{body_off:04x} {b.kind} (segment header) counter={hdr['counter'] if hdr else '?'} field2={hdr['field2'].hex() if hdr else '?'} anchor={hdr['anchor_bytes'].hex() if hdr else '?'} tail={hdr['tail'].hex() if hdr else '?'}") + else: + print(f" [{i:3d}] @0x{body_off:04x} {b.kind} len={b.length} data={b.data[:16].hex()}") + print() + + # Cumulative sample counts per block to find which block contains sample 254 + print("=== cumulative samples through blocks ===") + cur_ch = "Tran" + rotation = ["Vert", "Long", "MicL", "Tran"] + seg_count = 0 + samples_in_curseg = 2 # preamble Tran[0], Tran[1] + for i, b in enumerate(blocks[:30]): + if b.tag_hi == 0x40: + seg_count += 1 + prev_ch = cur_ch + cur_ch = rotation[(seg_count - 1) % 4] + print(f" [{i:3d}] 40 02 -> end of {prev_ch} segment, start {cur_ch} (segment {seg_count})") + samples_in_curseg = 2 # anchors + elif (b.tag_hi & 0xF0) == 0x10: + nn = ((b.tag_hi & 0x0F) << 8) | b.tag_lo + samples_in_curseg += nn + print(f" [{i:3d}] {b.kind} nibble: +{nn} samples, ch={cur_ch}, ch_total~{samples_in_curseg}") + elif (b.tag_hi & 0xF0) == 0x20: + nn = ((b.tag_hi & 0x0F) << 8) | b.tag_lo + samples_in_curseg += nn + print(f" [{i:3d}] {b.kind} int8: +{nn} samples, ch={cur_ch}, ch_total~{samples_in_curseg}") + elif b.tag_hi == 0x00: + samples_in_curseg += b.tag_lo + print(f" [{i:3d}] {b.kind} RLE: +{b.tag_lo}, ch={cur_ch}, ch_total~{samples_in_curseg}") + elif b.tag_hi == 0x30: + samples_in_curseg += b.tag_lo + print(f" [{i:3d}] {b.kind} packed12: +{b.tag_lo} samples, ch={cur_ch}, ch_total~{samples_in_curseg}") + + +if __name__ == "__main__": + main() diff --git a/analysis_idf/recon.py b/analysis_idf/recon.py new file mode 100644 index 0000000..f87a060 --- /dev/null +++ b/analysis_idf/recon.py @@ -0,0 +1,89 @@ +"""Reconnaissance helpers for cracking the Thor IDFW binary.""" +from __future__ import annotations + +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +TARGET = REPO / "tests/fixtures/THORDATA_example/THORDATA_example/UPMC Presby/UM11719/UM11719_20231219162723.IDFW" +TXT = REPO / "tests/fixtures/THORDATA_example/THORDATA_example/UPMC Presby/UM11719/TXT/UM11719_20231219162723.IDFW.txt" + + +def hex_at(buf: bytes, off: int, n: int = 32) -> str: + chunk = buf[off : off + n] + hexs = " ".join(f"{b:02x}" for b in chunk) + asc = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) + return f"{off:04x}: {hexs} {asc}" + + +def find_all(buf: bytes, needle: bytes) -> list[int]: + out: list[int] = [] + i = 0 + while True: + j = buf.find(needle, i) + if j < 0: + break + out.append(j) + i = j + 1 + return out + + +def load_sidecar_samples(path: Path) -> dict[str, list[float]]: + """Parse the txt sample table — Tran/Vert/Long/MicL.""" + out = {"Tran": [], "Vert": [], "Long": [], "MicL": []} + in_block = False + for line in path.read_text(errors="replace").splitlines(): + if not in_block: + if line.strip() == "Waveform Data Channels": + in_block = True + continue + if line.startswith("Waveform Data USB Channels"): + break + parts = line.split("\t") + # First row is the header "\tTran\tVert\tLong\tMicL" + if len(parts) >= 5 and parts[1] == "Tran": + continue + if len(parts) < 5: + continue + try: + out["Tran"].append(float(parts[1])) + out["Vert"].append(float(parts[2])) + out["Long"].append(float(parts[3])) + out["MicL"].append(float(parts[4])) + except ValueError: + continue + return out + + +def main(): + buf = TARGET.read_bytes() + samples = load_sidecar_samples(TXT) + print(f"file size: {len(buf)} bytes") + print(f"sample rows: Tran={len(samples['Tran'])} Vert={len(samples['Vert'])} Long={len(samples['Long'])} MicL={len(samples['MicL'])}") + print(f"first 6 Tran samples: {samples['Tran'][:6]}") + print(f"first 6 Vert samples: {samples['Vert'][:6]}") + print(f"first 6 Long samples: {samples['Long'][:6]}") + print(f"first 6 MicL samples: {samples['MicL'][:6]}") + + print() + print("=== BW magic '00 02 00' positions ===") + hits = find_all(buf, b"\x00\x02\x00") + print(f"{len(hits)} hits") + for h in hits[:20]: + print(hex_at(buf, h, 24)) + + print() + print("=== '40 02' segment-header positions ===") + hits = find_all(buf, b"\x40\x02") + print(f"{len(hits)} hits") + for h in hits: + ctx_pre = buf[max(0, h - 4): h].hex() + ctx_post = buf[h: h + 20].hex() + # Show byte preceding to help identify real headers vs casual occurrences + print(f" 0x{h:04x} pre={ctx_pre} post={ctx_post}") + + +if __name__ == "__main__": + main() diff --git a/analysis_idf/seg_resync.py b/analysis_idf/seg_resync.py new file mode 100644 index 0000000..6697cc8 --- /dev/null +++ b/analysis_idf/seg_resync.py @@ -0,0 +1,40 @@ +"""Find each segment boundary in the channel and check if errors reset there.""" +from __future__ import annotations +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +from minimateplus.waveform_codec import decode_waveform_v2 +from analysis_idf.recon import TARGET, TXT, load_sidecar_samples + + +def main(): + buf = TARGET.read_bytes() + sc = load_sidecar_samples(TXT) + decoded = decode_waveform_v2(buf[0x0f1f:]) + GEO_LSB = 0.0003 + + for ch in ("Tran", "Vert", "Long"): + sc_counts = [int(round(v / GEO_LSB)) for v in sc[ch]] + dec = decoded[ch] + # Find every transition where error becomes zero from nonzero (or grows from zero) + # Print indices where dec resyncs back to exact match. + n = min(len(sc_counts), len(dec)) + events = [] + prev_match = True + for i in range(n): + match = sc_counts[i] == dec[i] + if match != prev_match: + kind = "RESYNC" if match else "DIVERGE" + events.append((i, kind, sc_counts[i], dec[i])) + prev_match = match + print(f"{ch}: {len(events)} transitions") + for i, kind, sc_v, dec_v in events[:20]: + print(f" idx {i:4d} {kind:8s} sc={sc_v:6d} dec={dec_v:6d} diff={dec_v-sc_v:+d}") + print() + + +if __name__ == "__main__": + main() diff --git a/analysis_idf/smoke_idfh.py b/analysis_idf/smoke_idfh.py new file mode 100644 index 0000000..ab1eb64 --- /dev/null +++ b/analysis_idf/smoke_idfh.py @@ -0,0 +1,46 @@ +"""Smoke-test read_idf_file on IDFH across the corpus.""" +from __future__ import annotations +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +from micromate.idf_file import read_idf_file + + +def main(): + target = REPO / "tests/fixtures/THORDATA_example/THORDATA_example/UPMC Presby/UM11719/UM11719_20231219162648.IDFH" + result = read_idf_file(target) + ev = result.event + print(f"=== {target.name} ===") + print(f" signature: {result.signature}") + print(f" serial: {ev.serial}") + print(f" timestamp: {ev.timestamp}") + print(f" sample_rate: {ev.sample_rate}") + print(f" kind: {ev.kind}") + print(f" intervals: {len(result.intervals or [])}") + print(f" peaks: T={ev.peaks.transverse_ips:.4f} V={ev.peaks.vertical_ips:.4f} L={ev.peaks.longitudinal_ips:.4f}") + print() + + root = REPO / "tests/fixtures/THORDATA_example" + files = list(root.rglob("*.IDFH")) + ok = fail = nyi = 0 + total_intervals = 0 + for f in files: + try: + r = read_idf_file(f) + ok += 1 + total_intervals += len(r.intervals or []) + except NotImplementedError: + nyi += 1 + except Exception as exc: + fail += 1 + if fail <= 3: + print(f" FAIL: {f.name}: {type(exc).__name__}: {exc}") + print(f"Corpus: {len(files)} IDFH files | ok={ok} fail={fail} nyi={nyi}") + print(f"Total intervals decoded: {total_intervals}") + + +if __name__ == "__main__": + main() diff --git a/analysis_idf/smoke_test.py b/analysis_idf/smoke_test.py new file mode 100644 index 0000000..a0be7c6 --- /dev/null +++ b/analysis_idf/smoke_test.py @@ -0,0 +1,48 @@ +"""Smoke-test read_idf_file across the sample corpus.""" +from __future__ import annotations +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +from micromate.idf_file import read_idf_file, geo_count_to_ips, mic_count_to_psi + + +def main(): + target = REPO / "tests/fixtures/THORDATA_example/THORDATA_example/UPMC Presby/UM11719/UM11719_20231219162723.IDFW" + result = read_idf_file(target) + ev = result.event + print(f"=== {target.name} ===") + print(f" signature: {result.signature}") + print(f" serial: {ev.serial}") + print(f" timestamp: {ev.timestamp}") + print(f" sample_rate: {ev.sample_rate}") + print(f" record_time: {ev.record_time_sec}") + print(f" calibration: {result.binary_metadata.calibration_date}") + print(f" Tran samples: {len(result.samples['Tran'])}, peak_ips={ev.peaks.transverse_ips:.4f}") + print(f" Vert samples: {len(result.samples['Vert'])}, peak_ips={ev.peaks.vertical_ips:.4f}") + print(f" Long samples: {len(result.samples['Long'])}, peak_ips={ev.peaks.longitudinal_ips:.4f}") + print(f" MicL samples: {len(result.samples['MicL'])}") + print() + + # Corpus sweep + root = REPO / "tests/fixtures/THORDATA_example" + files = [f for f in root.rglob("*.IDFW") if not str(f).endswith(".CDB")] + ok = fail = nyi = 0 + for f in files: + try: + r = read_idf_file(f) + ok += 1 + except NotImplementedError: + nyi += 1 + except Exception as exc: + fail += 1 + if fail <= 5: + print(f" FAIL: {f.name}: {type(exc).__name__}: {exc}") + print() + print(f"Corpus: {len(files)} IDFW files | ok={ok} fail={fail} not-implemented={nyi}") + + +if __name__ == "__main__": + main() diff --git a/analysis_idf/trace_path.py b/analysis_idf/trace_path.py new file mode 100644 index 0000000..fb9fb04 --- /dev/null +++ b/analysis_idf/trace_path.py @@ -0,0 +1,73 @@ +"""Trace Tran sample-by-sample to find exactly where the codec drifts.""" +from __future__ import annotations +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +from analysis_idf.recon import TARGET, TXT, load_sidecar_samples + + +def s4(n: int) -> int: + return n if n < 8 else n - 16 + + +def i8(b: int) -> int: + return b if b < 128 else b - 256 + + +def main(): + buf = TARGET.read_bytes() + sc = load_sidecar_samples(TXT) + GEO_LSB = 0.0003 + sc_tran = [int(round(v / GEO_LSB)) for v in sc["Tran"]] + + body = buf[0x0f1f:] + # Tran[0], Tran[1] from preamble + t0 = int.from_bytes(body[3:5], "big", signed=True) + t1 = int.from_bytes(body[5:7], "big", signed=True) + print(f"preamble Tran[0]={t0} Tran[1]={t1} (sidecar: {sc_tran[0]}, {sc_tran[1]})") + + # Block 0: 10 f8 at body[7:9] + print(f"block 0: tag {body[7]:02x} {body[8]:02x}") + print(f" block 0 first 10 data bytes: {body[9:19].hex()}") + + # Walk block 0 manually, comparing each sample + cur = t1 + samples = [t0, t1] + block_off = 7 + nn = body[8] + print(f" NN = {nn}") + data = body[9 : 9 + nn // 2] + for byi, byte in enumerate(data): + for nib_idx, nib in enumerate(((byte >> 4) & 0xF, byte & 0xF)): + cur += s4(nib) + samples.append(cur) + idx = len(samples) - 1 + if 0 <= idx < len(sc_tran): + sc_v = sc_tran[idx] + match = "✓" if sc_v == cur else "✗" + if idx < 12 or 240 <= idx <= 260: + print(f" idx {idx:3d}: nibble byte={byte:02x} nib={nib:x} delta={s4(nib):+d} cur={cur:+d} sc={sc_v:+d} {match}") + + print(f"end of block 0: cur={cur}, len(samples)={len(samples)}, decoder expected 250 here") + # Block 1: 20 28 starts at offset 9 + 124 = 133 from block_off=7 + block1_off = 9 + nn // 2 + print(f"block 1: tag {body[block1_off]:02x} {body[block1_off+1]:02x} (expecting 20 28)") + nn1 = body[block1_off + 1] + print(f" block 1 NN = {nn1}") + data1 = body[block1_off + 2 : block1_off + 2 + nn1] + for byi, byte in enumerate(data1): + cur += i8(byte) + samples.append(cur) + idx = len(samples) - 1 + if idx < len(sc_tran): + sc_v = sc_tran[idx] + match = "✓" if sc_v == cur else "✗" + if 248 <= idx <= 295: + print(f" idx {idx:3d}: int8 byte={byte:02x} delta={i8(byte):+d} cur={cur:+d} sc={sc_v:+d} {match}") + + +if __name__ == "__main__": + main() diff --git a/analysis_idf/try_codec.py b/analysis_idf/try_codec.py new file mode 100644 index 0000000..e0f5269 --- /dev/null +++ b/analysis_idf/try_codec.py @@ -0,0 +1,42 @@ +"""Feed candidate body offsets to the BW codec and compare with sidecar.""" +from __future__ import annotations +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +from minimateplus.waveform_codec import decode_waveform_v2, walk_body, find_data_start +from analysis_idf.recon import TARGET, TXT, load_sidecar_samples + + +def main(): + buf = TARGET.read_bytes() + sc = load_sidecar_samples(TXT) + # Sidecar samples in 0.0003 counts (Thor geo LSB). + sc_tran = [int(round(v / 0.0003)) for v in sc["Tran"][:30]] + sc_vert = [int(round(v / 0.0003)) for v in sc["Vert"][:30]] + sc_long = [int(round(v / 0.0003)) for v in sc["Long"][:30]] + sc_micl = [int(round(v / 1e-6)) for v in sc["MicL"][:30]] # 1 µ unit for mic? Will iterate. + print(f"sidecar Tran (counts): {sc_tran}") + print(f"sidecar Vert (counts): {sc_vert}") + print(f"sidecar Long (counts): {sc_long}") + print(f"sidecar MicL (×1e-6): {sc_micl}") + print() + + # Try candidate body start offsets. + for off in (0x0f1f, 0x1057, 0x11f1, 0x1333, 0x1bde, 0x0d30): + print(f"=== body @ 0x{off:04x} ===") + body = buf[off:] + decoded = decode_waveform_v2(body) + if not decoded: + print(" decode_waveform_v2 returned None") + continue + for ch in ("Tran", "Vert", "Long", "MicL"): + arr = decoded.get(ch, []) + print(f" {ch}[{len(arr)}]: {arr[:20]}") + print() + + +if __name__ == "__main__": + main() diff --git a/analysis_idf/verify_full.py b/analysis_idf/verify_full.py new file mode 100644 index 0000000..ebc8b49 --- /dev/null +++ b/analysis_idf/verify_full.py @@ -0,0 +1,51 @@ +"""Verify decode_waveform_v2 against sidecar across all 2304 samples per channel.""" +from __future__ import annotations +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +from minimateplus.waveform_codec import decode_waveform_v2 +from analysis_idf.recon import TARGET, TXT, load_sidecar_samples + + +def main(): + buf = TARGET.read_bytes() + sc = load_sidecar_samples(TXT) + body = buf[0x0f1f:] + decoded = decode_waveform_v2(body) + + print(f"Sidecar lengths: Tran={len(sc['Tran'])} Vert={len(sc['Vert'])} Long={len(sc['Long'])} MicL={len(sc['MicL'])}") + print(f"Decoded lengths: Tran={len(decoded['Tran'])} Vert={len(decoded['Vert'])} Long={len(decoded['Long'])} MicL={len(decoded['MicL'])}") + print() + + GEO_LSB = 0.0003 # in/s per count + for ch in ("Tran", "Vert", "Long"): + sc_counts = [int(round(v / GEO_LSB)) for v in sc[ch]] + dec = decoded[ch] + n = min(len(sc_counts), len(dec)) + matches = sum(1 for i in range(n) if sc_counts[i] == dec[i]) + first_mismatch = next((i for i in range(n) if sc_counts[i] != dec[i]), None) + print(f"{ch}: compared {n}, exact matches {matches} ({100*matches/n:.2f}%)") + if first_mismatch is not None: + i = first_mismatch + print(f" first mismatch at idx {i}: sidecar={sc_counts[i]} ({sc[ch][i]}), decoded={dec[i]}") + print(f" context sidecar[{i-2}..{i+5}]: {sc_counts[max(0,i-2):i+5]}") + print(f" context decoded[{i-2}..{i+5}]: {dec[max(0,i-2):i+5]}") + + # MicL: find the multiplicative factor that fits + print() + print("=== MicL scale analysis ===") + sc_micl = sc["MicL"] + dec_micl = decoded["MicL"] + # Skip zero values when computing ratio + ratios = [sc_micl[i] / dec_micl[i] for i in range(min(50, len(sc_micl), len(dec_micl))) if dec_micl[i] != 0] + if ratios: + avg = sum(ratios) / len(ratios) + print(f" avg ratio sidecar/decoded over first 50 nonzero: {avg:.4e} (n={len(ratios)})") + print(f" ratios sample: {[f'{r:.4e}' for r in ratios[:6]]}") + + +if __name__ == "__main__": + main() diff --git a/docs/idf_protocol_reference.md b/docs/idf_protocol_reference.md index 643de53..aef3c69 100644 --- a/docs/idf_protocol_reference.md +++ b/docs/idf_protocol_reference.md @@ -6,11 +6,68 @@ 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-20):** ASCII text sidecar fully decoded (1,014 -sample files round-trip). Binary `.IDFH` / `.IDFW` codec -**not yet implemented** — binaries are stored opaquely by -`WaveformStore.save_imported_idf`, with metadata sourced from the -paired `.txt` sidecar. +**Status (2026-05-28):** 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, +timestamp, sample_rate, record_time, calibration_date) extracted from +the binary header. Sample fidelity is 87–99% byte-exact on quiet +events; loud events hit the BW codec's known walker-stops-early +limitation. Residual ~3% drift on per-sample deltas (likely a +Thor-specific 12-bit delta refinement not yet modelled). + +**Thor IDFH histograms also decoded.** Body has one or more segments; +each 12-byte segment header `[length_be 2B][0a 00 00 00][00 NN][05 3f]` +introduces `N = (length - 10) // 72` interval records of 72 bytes +each. Each interval = 4 × 16-byte per-channel records: +`[int16 min][int16 max][int16 ??][uint16 halfp][2B 00][uint16 ??][2B 00][uint16 ??]`. +Geo peak `= max(|min|, |max|) / 32768 × 10` in/s (matches sidecar +~1.8%); freq `= 512 / halfp` Hz (None for halfp ≤ 5 → ">100" +sentinel). Corpus: **all 859 Thor IDFH files decode, 181,071 +intervals**. Wired through `read_idf_file()` → +`save_imported_idf()` → sidecar's `extensions.idf_intervals`. + +**Note on the BE9439 outliers in the example corpus:** Two files +(`BE9439_20200713131747.IDFW` and `BE9439_20200713124251.IDFH`) are +**Series III Blastware** binaries, not Thor. Provenance: TMI tried +to use Thor to manage auto-call-homes for Series III units; the +experiment didn't work out, but it did leave a few BW event files +in Thor's per-serial directory structure with `.IDFW`/`.IDFH` +extensions — Thor's forwarder applied its own naming convention to +the BW bodies it was relaying. Their header `10 00 01 80 00 00 +Instantel STRT ff fe ` is the BW SUB 5A STRT +record, not a Thor body preamble. The reader detects them by +signature and raises `NotImplementedError` pointing callers at +`read_blastware_file()`, which extracts BW-format peaks from them. + +**Still NYI for Thor IDFH:** per-channel `int16 field4` (possibly +time-of-peak); the two uint16 fields (probably PVS contributions); +8-byte interval tail (PVS data); mic dB(L) exact conversion constant. + +### Codec breakthroughs (2026-05-28) + +- **Body offset is a fixed `0x0f1f`** across 151/154 corpus IDFW + files. Preceded by a 4-byte record-type marker (`46 00 00 00`) + + magic preamble `00 02 00 [Tran[0] BE] [Tran[1] BE]`. +- **Sample stream is BW's segment-rotated block codec verbatim.** + Thor reuses `10 NN` (nibble), `20 NN` (int8), `00 NN` (RLE), + `30 NN` (packed12), `40 02` (segment header) tags with the same + semantics. Channel rotation Tran→Vert→Long→MicL. +- **Geo LSB = 0.0003 in/s** (not BW's 0.005), because Thor's 16-bit + ADC range maps to 10 in/s without the 16-count BW quantization step. +- **Mic ≈ 2.14×10⁻⁶ psi/count** (rough scale; refine after channel + block calibration constants are decoded). +- **BW compliance anchor `\xbe\x80\x00\x00\x00\x00` reappears at + IDFW offset 0x952** — sample_rate at anchor−6 (uint16 BE), + record_time at anchor+6 (float32 BE), same layout as BW. +- **Event timestamp at offset 0x97A** — 8 bytes `[day][month] + [year_be][unk][hour][min][sec]`. Stop-time mirrors at 0x982. +- **Serial as null-terminated ASCII at 0x14E**. +- **Calibration date** at 0x194–0x197 (day, month, year_be). +- Per-sample residual drift of ~3% suggests Thor encodes int8/nibble + deltas with an extra refinement bit that BW doesn't carry — + unsolved; errors resync within a few samples so cumulative impact + is small. --- diff --git a/micromate/idf_file.py b/micromate/idf_file.py index b3cd669..bee7555 100644 --- a/micromate/idf_file.py +++ b/micromate/idf_file.py @@ -1,64 +1,450 @@ """ -micromate/idf_file.py — placeholder for the Thor IDF binary codec. +micromate/idf_file.py — Thor IDF binary codec. -Thor's ``.IDFH`` (histogram) and ``.IDFW`` (waveform) event files are an -Instantel proprietary binary format that has not yet been reverse- -engineered. Today seismo-relay treats them as opaque blobs: -``WaveformStore.save_imported_idf`` stores the bytes verbatim and reads -all device-authoritative metadata from the paired ``.IDFW.txt`` / -``.IDFH.txt`` ASCII sidecar (parsed by ``idf_ascii_report.py``). +Decodes the Instantel Micromate Series IV ``.IDFW`` (waveform) and +``.IDFH`` (histogram) binary on-disk format. Sister module to +``minimateplus/event_file_io.py``. -When we crack the binary codec — same reverse-engineering playbook we -used to byte-perfect-parse Series III BW files (see -``docs/instantel_protocol_reference.md`` and ``minimateplus/event_file_io.py``) -— this module will grow: +Status (2026-05-28): - - ``read_idf_file(path) -> IdfEvent`` - Parse a ``.IDFW``/``.IDFH`` binary and return a fully populated - ``IdfEvent`` whose waveform-sample arrays come from the binary - (the .txt sidecar's tabular sample block being a best-effort - check). Lets us ingest Thor events even when the operator - hasn't enabled the .txt exporter — closing the - ``had_report=False`` gap that the thor-watcher forwarder - currently tolerates as a known limitation. +- **Genuine Series IV / Thor binaries** are all signed + ``00 12 01 00 00 00 Instantel\\0`` (sig-A in earlier notes). Two + Series III (Blastware) binaries appear in the example corpus + (``BE9439_*``) — they share the ``.IDFW``/``.IDFH`` extension by + filing convention but carry a BW STRT header (``10 00 01 80 00 00 + Instantel STRT...``) and are NOT Thor data. The reader detects + them by signature and raises NotImplementedError pointing callers + at ``minimateplus.event_file_io.read_blastware_file()``. +- **IDFW waveform body** reuses the BW segment-rotated block codec + verbatim. Body always starts at file offset ``0x0f1f``. Samples + decoded via ``minimateplus.waveform_codec.decode_waveform_v2`` + with 87–99% byte-exact match against ``.IDFW.txt`` sidecar (quiet + events). Loud events hit the BW codec's known walker-stops-early + limit. Residual ~3% drift on per-sample deltas — likely a + Thor-specific 12-bit delta refinement that BW's codec doesn't + model. Geo LSB = 0.0003 in/s; mic factor ~2.14e-6 psi/count. +- **IDFH histogram body**: 12-byte segment header + ``[len_be 2B] 0a 00 00 00 [00 NN_counter] 05 3f`` introduces a + segment of ``N`` 72-byte interval records (``N = (len - 10) // 72``). + Each record holds 4 × 16-byte per-channel min/max/halfp + 8-byte + tail. Geo peaks via ``max(|min|, |max|) / 32768 × 10`` in/s + (matches sidecar within ~1.8%), freq via ``512 / halfp`` Hz. + **All 859 Thor IDFH files in the corpus decode (181,071 intervals).** +- Binary metadata directly extracted: serial, timestamp, sample_rate, + record_time, calibration_date. Other fields fall back to the paired + ``.IDFW.txt`` / ``.IDFH.txt`` sidecar (consumed by + ``WaveformStore.save_imported_idf``). - - ``write_idf_file(path, event)`` (eventually) - Round-trip event reconstruction, used for verifying the codec - against captured device files the way ``write_blastware_file`` - verifies the Series III codec. - - - Helpers for decoding the binary's per-channel sample arrays into - physical units, the per-event flash buffer's monitor-log records, - etc. - -The reverse-engineering path: pair every ``.IDFW`` binary in -``thor-watcher/example-data/`` with its sibling ``.IDFW.txt``, treating -the txt's "Waveform Data Channels" block as ground-truth, and align -the binary's per-channel int16-or-similar arrays against it. Header -fields (sample rate, channel count, record time, timestamps) sit before -the sample block — same approach as the BW codec where ASCII strings -inside the binary (``Project:``, ``Client:``, etc.) anchored field -discovery. +The full reverse-engineering writeup lives in +``docs/idf_protocol_reference.md``. """ from __future__ import annotations +import datetime +import struct +from dataclasses import dataclass from pathlib import Path -from typing import Union +from typing import Optional, Union -from .models import IdfEvent +from minimateplus.waveform_codec import decode_waveform_v2 + +from .models import IdfEvent, IdfPeaks, IdfReport -def read_idf_file(path: Union[str, Path]) -> "IdfEvent": - """Parse a Thor ``.IDFW``/``.IDFH`` binary into an ``IdfEvent``. +# Genuine Series IV / Thor IDF binary signature: 6 bytes, then ASCII "Instantel". +_THOR_PREFIX = b"\x00\x12\x01\x00\x00\x00" +# Stray Series III (Blastware) binaries that occasionally turn up in Thor +# corpus directories renamed to the .IDFW/.IDFH convention. Their header +# (`10 00 01 80 00 00 Instantel STRT ...`) is byte-for-byte a BW SUB 5A +# STRT record, not a Thor binary. Detected so we can refuse-and-route +# rather than mis-parse. +_BW_STRAY_PREFIX = b"\x10\x00\x01\x80\x00\x00" +_INSTANTEL_TAG = b"Instantel" - Not yet implemented. When implemented, this will be the canonical - entry point for reading Thor binaries — the ASCII sidecar parser - becomes an optional fast-path metadata supplement rather than the - sole source of device-authoritative data. +# Constant body offset for sig-A IDFW files (verified across 151/154 corpus +# files in tests/fixtures/THORDATA_example). The body is the segment-rotated +# block stream consumed by decode_waveform_v2; bytes [0:3] are the magic +# ``00 02 00`` preamble. +_BODY_START_SIG_A = 0x0F1F + +# 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 + +# Microphone count → psi, derived from sidecar regression on 50 sample +# pairs from UM11719_20231219162723.IDFW (mic-heavy event). +_MIC_LSB_PSI = 2.14e-6 + +# IDFH histogram constants. +_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") + + +# ─── Binary metadata extraction ───────────────────────────────────────────── + + +@dataclass +class IdfBinaryMetadata: + """Fields recoverable from the sig-A binary header (no .txt needed).""" + serial: Optional[str] = None + event_datetime: Optional[datetime.datetime] = None + sample_rate: Optional[int] = None + record_time_sec: Optional[float] = None + calibration_date: Optional[datetime.date] = None + + +def _read_ascii_z(buf: bytes, off: int, maxlen: int = 64) -> Optional[str]: + if off >= len(buf): + return None + end = buf.find(b"\x00", off, off + maxlen) + if end < 0: + end = min(off + maxlen, len(buf)) + s = buf[off:end].decode("ascii", errors="replace").strip() + return s or None + + +def _decode_8byte_timestamp(buf: bytes, off: int) -> Optional[datetime.datetime]: + """Layout: ``[day][month][year_hi][year_lo][unknown][hour][min][sec]``.""" + if off + 8 > len(buf): + return None + day, mon, yh, yl, _unk, hr, mn, sc = buf[off : off + 8] + year = (yh << 8) | yl + if not (2015 <= year <= 2050 and 1 <= mon <= 12 and 1 <= day <= 31 + and 0 <= hr < 24 and 0 <= mn < 60 and 0 <= sc < 60): + return None + try: + return datetime.datetime(year, mon, day, hr, mn, sc) + except ValueError: + return None + + +def extract_binary_metadata(buf: bytes) -> IdfBinaryMetadata: + """Pull serial/timestamp/sample_rate/record_time/calibration from the + sig-A binary header. + + Field positions confirmed against UM11719_20231219162723.IDFW; stable + across the 151-file sig-A corpus. """ - raise NotImplementedError( - "IDF binary codec not yet implemented; the .IDFW/.IDFH binary format " - "is undecoded. Use parse_idf_report() on the paired .txt sidecar " - "for device-authoritative metadata." + md = IdfBinaryMetadata() + + # Serial: null-terminated ASCII at 0x14E. + md.serial = _read_ascii_z(buf, 0x14E, maxlen=16) + + # Sample rate + record time live in a BW-compatible compliance block. + # Locate the 6-byte anchor `be 80 00 00 00 00` and read offsets relative + # to it: anchor-6 = sample_rate uint16 BE; anchor+6 = record_time float32 BE. + anchor = buf.find(b"\xbe\x80\x00\x00\x00\x00", 0x800, 0xA00) + if anchor > 0: + sr_bytes = buf[anchor - 6 : anchor - 4] + if len(sr_bytes) == 2: + sr = int.from_bytes(sr_bytes, "big") + if sr in (256, 512, 1024, 2048, 4096): + md.sample_rate = sr + rt_bytes = buf[anchor + 6 : anchor + 10] + if len(rt_bytes) == 4: + try: + rt = struct.unpack(">f", rt_bytes)[0] + if 0.1 <= rt <= 600.0: + md.record_time_sec = float(rt) + except struct.error: + pass + + # Event timestamp: 8 bytes. Position differs between IDFW (0x97A) and + # IDFH (0x9F8); scan a small range and accept the first valid decode. + for off in (0x97A, 0x9F8): + ts = _decode_8byte_timestamp(buf, off) + if ts is not None: + md.event_datetime = ts + break + + # Calibration date: day, month, year_be at 0x194-0x197. + if len(buf) > 0x197: + day, mon = buf[0x194], buf[0x195] + year = int.from_bytes(buf[0x196 : 0x198], "big") + if 1 <= mon <= 12 and 1 <= day <= 31 and 2015 <= year <= 2050: + try: + md.calibration_date = datetime.date(year, mon, day) + except ValueError: + pass + + return md + + +# ─── Sample decoder + unit conversion ─────────────────────────────────────── + + +def _decode_waveform_samples(buf: bytes) -> Optional[dict]: + """Decode samples from the sig-A body starting at file offset 0x0f1f. + + Returns the raw decoder counts dict — geo LSB = 0.0003 in/s, mic in + its own count unit (see :func:`mic_count_to_psi`). Returns None if + decoding fails. + """ + if len(buf) < _BODY_START_SIG_A + 8: + return None + body = buf[_BODY_START_SIG_A:] + return decode_waveform_v2(body) + + +def geo_count_to_ips(count: int) -> float: + """Convert a Thor geo decoder count to in/s. LSB = 0.0003 in/s.""" + return count * _GEO_LSB_IPS + + +def mic_count_to_psi(count: int) -> float: + """Convert a Thor mic decoder count to psi. Scale derived from + regression over 50 sample pairs in UM11719_20231219162723.IDFW; + consistent to ~5%. Calibration constants from the channel block + can refine this once decoded. + """ + return count * _MIC_LSB_PSI + + +# ─── IDFH histogram decoder ───────────────────────────────────────────────── + + +@dataclass +class IdfhInterval: + """One decoded histogram interval (typically one minute of monitoring).""" + offset: int # file byte offset of the 72-byte record + # Per-channel min/max ADC counts (int16 BE), half-period samples, peak count. + # Peak = max(|min|, |max|). freq_hz = 512/halfp (None if halfp ≤ 5 → + # ">100 Hz" sentinel; matches sidecar convention). + tran_min: int + tran_max: int + tran_halfp: int + vert_min: int + vert_max: int + vert_halfp: int + long_min: int + long_max: int + long_halfp: int + micl_min: int + micl_max: int + micl_halfp: int + + def peak_count(self, channel: str) -> int: + mn = getattr(self, f"{channel.lower()}_min") + mx = getattr(self, f"{channel.lower()}_max") + return max(abs(mn), abs(mx)) + + 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 + + def freq_hz(self, channel: str) -> Optional[float]: + halfp = getattr(self, f"{channel.lower()}_halfp") + if halfp <= 5: + return None + return _IDFH_HALFP_FREQ_NUM / halfp + + +def _decode_idfh_interval(buf72: bytes, offset: int) -> IdfhInterval: + """Decode one 72-byte interval record into per-channel min/max/halfp.""" + import struct + fields = [] + for i in range(4): + block = buf72[i * 16 : (i + 1) * 16] + mn = struct.unpack_from(">h", block, 0)[0] + mx = struct.unpack_from(">h", block, 2)[0] + # block[4:6] = int16 BE, role unknown (possibly time-of-peak) + halfp = struct.unpack_from(">H", block, 6)[0] + # block[10:12] and block[14:16] are uint16 BE with unknown semantics + # (likely sum / count contributions for the PVS computation). + fields.extend([mn, mx, halfp]) + # Tail 8 bytes (buf72[64:72]) carry PVS-related data; not yet decoded. + return IdfhInterval( + offset=offset, + tran_min=fields[0], tran_max=fields[1], tran_halfp=fields[2], + 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], + ) + + +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`` + 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). + """ + intervals: list = [] + i = 0 + while True: + 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": + i = j + 1 + continue + length = int.from_bytes(buf[j - 2 : j], "big") + n = (length - _IDFH_SEGMENT_HEADER) // _IDFH_INTERVAL_SIZE + if n <= 0: + i = j + 1 + continue + header_start = j - 2 + 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)) + # Advance past this segment + the 2-byte tail. + i = header_start + length + _IDFH_SEGMENT_TAIL + return intervals + + +# ─── Top-level reader ─────────────────────────────────────────────────────── + + +@dataclass +class IdfReadResult: + """Return type for :func:`read_idf_file`. + + For waveforms (``.IDFW``), ``samples`` holds the per-channel sample + arrays in Thor decoder counts. For histograms (``.IDFH``), + ``samples`` is empty and ``intervals`` holds the per-interval + record list (peaks, freqs). + """ + event: IdfEvent + samples: dict # {"Tran": [...], ...} for IDFW; empty for IDFH + binary_metadata: IdfBinaryMetadata + signature: str # always "thor" for now (sig-A genuine Thor) + intervals: Optional[list] = None # list[IdfhInterval] for IDFH; None for IDFW + + +def read_idf_file(path: Union[str, Path]) -> IdfReadResult: + """Parse a Thor ``.IDFW`` binary into an ``IdfEvent`` + decoded samples. + + Currently implements signature-A waveforms only. Signature-B + (old-firmware) and ``.IDFH`` histograms raise NotImplementedError; + use the paired ``.IDFW.txt`` / ``.IDFH.txt`` sidecar for those via + ``parse_idf_report()``. + + Returns an :class:`IdfReadResult`. The caller converts int sample + counts to physical units via :func:`geo_count_to_ips` / + :func:`mic_count_to_psi`. + """ + p = Path(path) + buf = p.read_bytes() + + if len(buf) < 16 or buf[6:16] != _INSTANTEL_TAG + b"\x00": + raise ValueError(f"{p.name}: not an IDF file (missing Instantel magic)") + + sig_prefix = buf[:6] + if sig_prefix == _THOR_PREFIX: + signature = "thor" + elif sig_prefix == _BW_STRAY_PREFIX: + raise NotImplementedError( + f"{p.name}: file has a Series III (Blastware) STRT header in " + "an IDF-named container — not a Thor binary. Route through " + "minimateplus.event_file_io.read_blastware_file() instead " + "(peaks decode; samples & full metadata don't, but it's not " + "Thor data so the Thor codec doesn't apply)." + ) + else: + raise ValueError(f"{p.name}: unknown IDF signature {sig_prefix.hex()}") + + is_histogram = p.suffix.upper() == ".IDFH" + md = extract_binary_metadata(buf) + + if is_histogram: + intervals = decode_idfh_body(buf) + if not intervals: + raise ValueError(f"{p.name}: IDFH body decoded no intervals") + # Peaks: max across all intervals on each channel (per-channel max + # of stored max-magnitudes; sidecar's PPV row carries the same). + peak_tran = max((iv.peak_ips("Tran") for iv in intervals), default=0.0) + peak_vert = max((iv.peak_ips("Vert") for iv in intervals), default=0.0) + peak_long = max((iv.peak_ips("Long") for iv in intervals), default=0.0) + rep = IdfReport( + serial_number=md.serial, + event_type="Full Histogram", + event_datetime=md.event_datetime, + filename=p.name, + sample_rate=md.sample_rate, + record_time_sec=md.record_time_sec, + ) + peaks = IdfPeaks( + transverse_ips=peak_tran, + vertical_ips=peak_vert, + longitudinal_ips=peak_long, + peak_vector_sum_ips=None, + mic_pspl_dbl=None, + ) + event = IdfEvent( + serial=md.serial or "UNKNOWN", + timestamp=md.event_datetime or datetime.datetime(1970, 1, 1), + kind="Histogram", + filename=p.name, + sample_rate=md.sample_rate, + record_time_sec=md.record_time_sec, + peaks=peaks, + report=rep, + ) + return IdfReadResult( + event=event, + samples={}, + binary_metadata=md, + signature=signature, + intervals=intervals, + ) + + # Waveform path. + decoded = _decode_waveform_samples(buf) + if decoded is None: + raise ValueError(f"{p.name}: waveform body codec failed") + + rep = IdfReport( + serial_number=md.serial, + event_type="Full Waveform", + event_datetime=md.event_datetime, + filename=p.name, + sample_rate=md.sample_rate, + record_time_sec=md.record_time_sec, + ) + + def _peak_ips(ch: str) -> float: + arr = decoded.get(ch, []) + return geo_count_to_ips(max((abs(v) for v in arr), default=0)) + + peaks = IdfPeaks( + transverse_ips=_peak_ips("Tran"), + vertical_ips=_peak_ips("Vert"), + longitudinal_ips=_peak_ips("Long"), + # PVS requires aligned per-sample √(T²+V²+L²); leave None — the + # sidecar carries it and the bridge picks it up if present. + peak_vector_sum_ips=None, + mic_pspl_dbl=None, + ) + + event = IdfEvent( + serial=md.serial or "UNKNOWN", + timestamp=md.event_datetime or datetime.datetime(1970, 1, 1), + kind="Waveform", + filename=p.name, + sample_rate=md.sample_rate, + record_time_sec=md.record_time_sec, + peaks=peaks, + report=rep, + ) + + return IdfReadResult( + event=event, + samples=decoded, + binary_metadata=md, + signature=signature, ) diff --git a/sfm/waveform_store.py b/sfm/waveform_store.py index d982dce..031a9c0 100644 --- a/sfm/waveform_store.py +++ b/sfm/waveform_store.py @@ -467,21 +467,21 @@ class WaveformStore: Ingest a Thor (Micromate Series IV) IDF event file (`.IDFW` or `.IDFH`) produced by Thor's TXT exporter. - Thor binaries are stored as opaque bytes — seismo-relay doesn't - yet decode the proprietary IDF binary format (codec slot lives - at ``micromate/idf_file.py``). Device-authoritative metadata - comes from the paired ``.IDFW.txt`` / ``.IDFH.txt`` sidecar - when supplied. - Workflow: - 1. Parse the paired TXT report (when supplied) via - ``micromate.parse_idf_report`` → dict. - 2. Wrap parsed dict + filename into a typed ``micromate.IdfEvent``. - 3. Copy bytes verbatim into ``//``. - 4. Bridge IdfEvent → ``minimateplus.Event`` (for the existing - sidecar / DB insert machinery) via - ``IdfEvent.to_minimateplus_event(waveform_key)``. - 5. Write the ``.sfm.json`` sidecar with + 1. For sig-A `.IDFW` binaries, decode samples + binary metadata + via ``micromate.idf_file.read_idf_file()``. Failure or + non-IDFW path falls through to the .txt-only flow. + 2. Parse the paired TXT report (when supplied) via + ``micromate.parse_idf_report`` → dict. TXT remains the + source of truth for fields the binary doesn't yet supply + (full peak set with ZC freq / Time of Peak, sensor self-check, + firmware string, project strings). + 3. Wrap parsed dict + filename into a typed ``micromate.IdfEvent``. + 4. Copy bytes verbatim into ``//``. + 5. Bridge IdfEvent → ``minimateplus.Event`` and attach + ``raw_samples`` from the binary decoder (when available). + 6. Write the `.h5` clean-waveform file when samples decoded. + 7. Write the ``.sfm.json`` sidecar with ``source.kind = "idf-import"`` and the full raw IDF report under ``extensions.idf_report``. @@ -490,7 +490,33 @@ class WaveformStore: """ from micromate import IdfEvent, parse_idf_report - # Parse the .txt sidecar (best-effort; non-fatal on failure). + # 1. Binary decode (sig-A IDFW and IDFH). Non-fatal: any failure + # leaves samples / binary metadata unfilled and we proceed with + # the .txt path as before. + idf_samples: Optional[dict] = None + idf_intervals: Optional[list] = None + binary_md = None + binary_peaks = None + is_histogram = False + try: + from micromate.idf_file import read_idf_file + res = read_idf_file(source_path) + idf_samples = res.samples or None + idf_intervals = res.intervals + is_histogram = res.intervals is not None + binary_md = res.binary_metadata + binary_peaks = res.event.peaks + except NotImplementedError: + # sig-B — codec doesn't handle this yet. + pass + except Exception as exc: + log.warning( + "save_imported_idf: binary codec failed for %s: %s — " + "falling back to .txt-only ingest", + source_path.name, exc, + ) + + # 2. Parse the .txt sidecar (best-effort; non-fatal on failure). report_dict: dict = {} if idf_report_text is not None: try: @@ -501,7 +527,38 @@ class WaveformStore: exc, ) - # Build the typed IdfEvent. Filename is authoritative for + # 3. Backfill report_dict with binary metadata for fields the + # .txt didn't supply. Binary takes precedence on tied fields + # where the binary is more reliable (timestamp, sample_rate), + # and fills in fields entirely missing from the .txt. + if binary_md is not None: + if binary_md.serial and not report_dict.get("serial_number"): + report_dict["serial_number"] = binary_md.serial + if binary_md.event_datetime and not report_dict.get("event_datetime"): + report_dict["event_datetime"] = binary_md.event_datetime + if binary_md.sample_rate and not report_dict.get("sample_rate"): + report_dict["sample_rate"] = binary_md.sample_rate + if binary_md.record_time_sec and not report_dict.get("record_time_sec"): + report_dict["record_time_sec"] = binary_md.record_time_sec + # Calibration date (binary) vs calibration text (.txt) cohabit + # under different keys; no overwrite needed. + if binary_md.event_datetime and not report_dict.get("event_type"): + report_dict["event_type"] = ( + "Full Histogram" if is_histogram else "Full Waveform" + ) + + # 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. + 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 + if binary_peaks.vertical_ips and not report_dict.get("vert_ppv"): + report_dict["vert_ppv"] = binary_peaks.vertical_ips + if binary_peaks.longitudinal_ips and not report_dict.get("long_ppv"): + report_dict["long_ppv"] = binary_peaks.longitudinal_ips + + # 4. Build the typed IdfEvent. Filename is authoritative for # (serial, timestamp, kind); the report's event_datetime takes # precedence over the filename timestamp inside from_report(). idf_event = IdfEvent.from_report(report_dict, source_path.name) @@ -511,7 +568,7 @@ class WaveformStore: # serial that overrides a misnamed export). serial = serial_hint or idf_event.serial or "UNKNOWN" - # Filesystem write. + # 5. Filesystem write of binary bytes. filename = source_path.name bw_path = self._serial_dir(serial) / filename bw_path.write_bytes(idf_bytes) @@ -523,13 +580,41 @@ class WaveformStore: # surrogate — every distinct binary maps to a distinct row. waveform_key = bytes.fromhex(sha256)[:16] - # Bridge to minimateplus.Event for the existing sidecar / DB + # 6. Bridge to minimateplus.Event for the existing sidecar / DB # insert paths. See IdfEvent.to_minimateplus_event() for the # caveats of this bridge (mic units, missing fields → sidecar). ev = idf_event.to_minimateplus_event(waveform_key) - # Write the sidecar. Source kind "idf-import" was added to the - # allow-list in event_file_io.event_to_sidecar_dict for this. + # Attach the decoded sample arrays. Thor's decoder counts use + # LSB = 0.0003 in/s for geo (vs BW's 16-count units at 0.005 in/s) + # — the .h5 writer's geo_range="normal" yields LSB = 10/32768 + # ≈ 0.000305 in/s, so plotted samples come out ~1.7% high. + # Acceptable known offset; refine with a Thor-aware h5 path later. + if idf_samples is not None: + 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 + + # 7. Write the .h5 clean-waveform file when we actually have samples. + # Histograms (IDFH) don't have waveform samples — skip h5 for those. + hdf5_filename: Optional[str] = None + if idf_samples is not None and not is_histogram: + hdf5_path = self.hdf5_path_for(serial, filename) + try: + event_hdf5.write_event_hdf5( + hdf5_path, ev, + serial=serial, + geo_range="normal", # Thor's geo full scale is also 10 in/s (Normal) + source_kind="idf-import", + ) + hdf5_filename = hdf5_path.name + except Exception as exc: + log.warning( + "save_imported_idf: HDF5 write failed for %s: %s — continuing without .h5", + hdf5_path, exc, + ) + + # 8. Write the sidecar. Source kind "idf-import" is on the allow-list. sidecar_path = self.sidecar_path_for(serial, filename) existing_review = None if sidecar_path.exists(): @@ -554,19 +639,46 @@ class WaveformStore: # Time of Peak, sensor self-check, calibration, firmware). if report_dict: sidecar["extensions"]["idf_report"] = report_dict + # For histograms, also stash the binary-decoded per-interval + # records so the UI / report layer doesn't need to re-walk the + # IDFH file at render time. + if idf_intervals is not None: + sidecar["extensions"]["idf_intervals"] = [ + { + "offset": iv.offset, + "tran_peak": iv.peak_count("Tran"), + "tran_halfp": iv.tran_halfp, + "tran_freq": iv.freq_hz("Tran"), + "vert_peak": iv.peak_count("Vert"), + "vert_halfp": iv.vert_halfp, + "vert_freq": iv.freq_hz("Vert"), + "long_peak": iv.peak_count("Long"), + "long_halfp": iv.long_halfp, + "long_freq": iv.freq_hz("Long"), + "mic_peak": iv.peak_count("MicL"), + "mic_halfp": iv.micl_halfp, + "mic_freq": iv.freq_hz("MicL"), + } + for iv in idf_intervals + ] event_file_io.write_sidecar(sidecar_path, sidecar) log.info( "WaveformStore.save_imported_idf serial=%s filename=%s filesize=%d " - "report_attached=%s", - serial, filename, filesize, bool(report_dict), + "kind=%s report_attached=%s binary_decoded=%s h5=%s intervals=%d", + serial, filename, filesize, + "histogram" if is_histogram else "waveform", + bool(report_dict), + (idf_samples is not None) or (idf_intervals is not None), + hdf5_filename or "(skipped)", + len(idf_intervals) if idf_intervals else 0, ) return ev, { "filename": filename, "filesize": filesize, "sha256": sha256, "a5_pickle_filename": None, - "hdf5_filename": None, + "hdf5_filename": hdf5_filename, "sidecar_filename": sidecar_path.name, "serial": serial, } From 9fd52ddabb66826693ae46c84bb43131d3a1f24a Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 29 May 2026 19:03:06 +0000 Subject: [PATCH 02/11] feat: add thor report generation, pdf generation. --- analysis_idf/e2e_report.py | 102 ++++++++++ analysis_idf/e2e_report_idfh.py | 91 +++++++++ analysis_idf/test_adapter.py | 47 +++++ analysis_idf/thor_report.pdf | Bin 0 -> 107956 bytes analysis_idf/thor_report_idfh.pdf | Bin 0 -> 53213 bytes micromate/idf_ascii_report.py | 19 +- micromate/idf_to_bw_report.py | 323 ++++++++++++++++++++++++++++++ sfm/waveform_store.py | 21 ++ 8 files changed, 601 insertions(+), 2 deletions(-) create mode 100644 analysis_idf/e2e_report.py create mode 100644 analysis_idf/e2e_report_idfh.py create mode 100644 analysis_idf/test_adapter.py create mode 100644 analysis_idf/thor_report.pdf create mode 100644 analysis_idf/thor_report_idfh.pdf create mode 100644 micromate/idf_to_bw_report.py diff --git a/analysis_idf/e2e_report.py b/analysis_idf/e2e_report.py new file mode 100644 index 0000000..c4cbb04 --- /dev/null +++ b/analysis_idf/e2e_report.py @@ -0,0 +1,102 @@ +"""End-to-end Thor report PDF rendering. + +Ingests an IDFW + .txt via save_imported_idf, runs gather_report_data +(faking a minimal DB row), and renders the PDF to disk. +""" +from __future__ import annotations +import sys +import tempfile +import json +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +from sfm.waveform_store import WaveformStore +from sfm import report_pdf + + +class FakeDb: + """Stand-in for SeismoDb.get_event(); the renderer only needs a few cols.""" + def __init__(self, event): + self.event = event + + def get_event(self, _id): + return self.event + + +def main(): + base = REPO / "tests/fixtures/THORDATA_example/THORDATA_example/UPMC Presby/UM11719" + idfw = base / "UM11719_20231219162723.IDFW" + txt = base / "TXT" / f"{idfw.name}.txt" + + with tempfile.TemporaryDirectory() as td: + store = WaveformStore(Path(td)) + ev, rec = store.save_imported_idf( + idfw.read_bytes(), + idfw, + idf_report_text=txt.read_text(errors="replace"), + ) + print(f"save_imported_idf: h5={rec['hdf5_filename']}, sidecar={rec['sidecar_filename']}") + + # Verify sidecar has bw_report block + sc_path = Path(td) / "UM11719" / f"{idfw.name}.sfm.json" + sc = json.loads(sc_path.read_text()) + bw = sc.get("bw_report", {}) + print(f" bw_report.available: {bw.get('available')}") + print(f" bw_report.peaks.tran.ppv_ips: {bw.get('peaks', {}).get('tran', {}).get('ppv_ips')}") + print(f" bw_report.mic.pspl_dbl: {bw.get('mic', {}).get('pspl_dbl')}") + print(f" bw_report.histogram.n_intervals: {bw.get('histogram', {}).get('n_intervals')}") + + # Build a DB-row-shaped dict from the Event for gather_report_data + import datetime + ts = ev.timestamp + ts_iso = None + if ts is not None: + try: + ts_iso = datetime.datetime(ts.year, ts.month, ts.day, ts.hour, ts.minute, ts.second).isoformat() + except Exception: + pass + fake_row = { + "serial": "UM11719", + "blastware_filename": rec["filename"], + "record_type": "Waveform", + "timestamp": ts_iso, + "sample_rate": ev.sample_rate, + "project": ev.project_info.project if ev.project_info else None, + "client": ev.project_info.client if ev.project_info else None, + "operator": ev.project_info.operator if ev.project_info else None, + "sensor_location": ev.project_info.sensor_location if ev.project_info else None, + "created_at": None, + } + + rd = report_pdf.gather_report_data(FakeDb(fake_row), store, event_id="test-1") + print() + print(f"=== ReportData ===") + print(f" event_id: {rd.event_id}") + print(f" serial: {rd.serial}") + print(f" record_type: {rd.record_type}") + print(f" event_datetime: {rd.event_datetime_str}") + print(f" trigger: {rd.trigger_source}") + print(f" geo_range: {rd.geo_range_str}") + print(f" sample_rate: {rd.sample_rate_str}") + print(f" firmware: {rd.firmware}") + print(f" calibration: {rd.calibration_date} by {rd.calibration_by}") + print(f" battery: {rd.battery_volts}") + print(f" PVS: {rd.peak_vector_sum_ips} in/s at {rd.peak_vector_sum_time_s} sec") + print(f" mic_pspl_dbl: {rd.mic_pspl_dbl}") + print(f" mic_zc_freq_hz: {rd.mic_zc_freq_hz}") + print(f" channel_stats: {len(rd.channel_stats)} rows") + for cs in rd.channel_stats: + print(f" {cs['name']}: PPV={cs['ppv_ips']} ZC={cs['zc_freq_hz']} ToP={cs['time_of_peak_s']} Acc={cs['peak_accel_g']} Disp={cs['peak_disp_in']} Test={cs['sensor_check']}") + + # Render the PDF + out_path = REPO / "analysis_idf" / "thor_report.pdf" + pdf_bytes = report_pdf.render_event_report_pdf(rd) + out_path.write_bytes(pdf_bytes) + print() + print(f" PDF written: {out_path} ({len(pdf_bytes)} bytes)") + + +if __name__ == "__main__": + main() diff --git a/analysis_idf/e2e_report_idfh.py b/analysis_idf/e2e_report_idfh.py new file mode 100644 index 0000000..05e735d --- /dev/null +++ b/analysis_idf/e2e_report_idfh.py @@ -0,0 +1,91 @@ +"""End-to-end Thor IDFH histogram report PDF rendering.""" +from __future__ import annotations +import sys +import tempfile +import json +import datetime +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +from sfm.waveform_store import WaveformStore +from sfm import report_pdf + + +class FakeDb: + def __init__(self, event): + self.event = event + + def get_event(self, _id): + return self.event + + +def main(): + # Use the multi-interval IDFH (81 + trigger row) + idfh = REPO / "tests/fixtures/THORDATA_example/THORDATA_example/UPMC Presby/UM13981/UM13981_20220805075441.IDFH" + txt = idfh.parent / "TXT" / f"{idfh.name}.txt" + + with tempfile.TemporaryDirectory() as td: + store = WaveformStore(Path(td)) + ev, rec = store.save_imported_idf( + idfh.read_bytes(), + idfh, + idf_report_text=txt.read_text(errors="replace"), + ) + print(f"save_imported_idf: h5={rec['hdf5_filename']}, sidecar={rec['sidecar_filename']}") + + sc_path = Path(td) / "UM13981" / f"{idfh.name}.sfm.json" + sc = json.loads(sc_path.read_text()) + bw = sc.get("bw_report", {}) + hist = bw.get("histogram", {}) + print(f" bw_report.histogram.start: {hist.get('start')}") + print(f" bw_report.histogram.stop: {hist.get('stop')}") + print(f" bw_report.histogram.n_intervals: {hist.get('n_intervals')}") + print(f" bw_report.histogram.interval_size: {hist.get('interval_size')}") + print(f" bw_report.histogram.interval_size_s: {hist.get('interval_size_s')}") + print(f" bw_report.peaks.tran.ppv_ips: {bw.get('peaks', {}).get('tran', {}).get('ppv_ips')}") + + ts = ev.timestamp + ts_iso = None + if ts is not None: + try: + ts_iso = datetime.datetime(ts.year, ts.month, ts.day, ts.hour, ts.minute, ts.second).isoformat() + except Exception: + pass + fake_row = { + "serial": "UM13981", + "blastware_filename": rec["filename"], + "record_type": "Histogram", + "timestamp": ts_iso, + "sample_rate": ev.sample_rate, + "project": ev.project_info.project if ev.project_info else None, + "client": ev.project_info.client if ev.project_info else None, + "operator": ev.project_info.operator if ev.project_info else None, + "sensor_location": ev.project_info.sensor_location if ev.project_info else None, + "created_at": None, + } + rd = report_pdf.gather_report_data(FakeDb(fake_row), store, event_id="hist-1") + + print() + print("=== ReportData (histogram) ===") + print(f" is_histogram: {rd.is_histogram}") + print(f" histogram_start: {rd.histogram_start_str}") + print(f" histogram_stop: {rd.histogram_stop_str}") + print(f" histogram_n_intervals: {rd.histogram_n_intervals}") + print(f" histogram_interval_size:{rd.histogram_interval_size}") + print(f" histogram_interval_times[:3]: {rd.histogram_interval_times[:3]}") + print(f" histogram_interval_times[-2:]: {rd.histogram_interval_times[-2:]}") + print(f" channel_stats: {len(rd.channel_stats)} rows") + for cs in rd.channel_stats: + print(f" {cs['name']}: PPV={cs['ppv_ips']} ZC={cs['zc_freq_hz']} peak_date={cs['peak_date']} peak_time={cs['peak_time']}") + + pdf_bytes = report_pdf.render_event_report_pdf(rd) + out_path = REPO / "analysis_idf" / "thor_report_idfh.pdf" + out_path.write_bytes(pdf_bytes) + print() + print(f" PDF written: {out_path} ({len(pdf_bytes)} bytes)") + + +if __name__ == "__main__": + main() diff --git a/analysis_idf/test_adapter.py b/analysis_idf/test_adapter.py new file mode 100644 index 0000000..9b12d12 --- /dev/null +++ b/analysis_idf/test_adapter.py @@ -0,0 +1,47 @@ +"""Verify build_bw_report_from_idf against a known sidecar.""" +from __future__ import annotations +import json +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +from micromate.idf_ascii_report import parse_idf_report +from micromate.idf_to_bw_report import build_bw_report_from_idf +from micromate.idf_file import read_idf_file + + +def show(prefix: str, d: dict, indent: int = 0): + for k, v in d.items(): + if isinstance(v, dict): + print(f"{' '*indent}{prefix}{k}:") + show("", v, indent + 1) + else: + print(f"{' '*indent}{prefix}{k}: {v!r}") + + +def main(): + base = REPO / "tests/fixtures/THORDATA_example/THORDATA_example/UPMC Presby/UM11719" + idfw = base / "UM11719_20231219162723.IDFW" + txt = base / "TXT" / f"{idfw.name}.txt" + + report_dict = parse_idf_report(txt.read_text(errors="replace")) + res = read_idf_file(idfw) + bw = build_bw_report_from_idf(report_dict, binary_md=res.binary_metadata) + + print("=== IDFW → bw_report ===") + show("", bw) + + print() + print("=== IDFH (single trigger row) ===") + idfh = base / "UM11719_20231219162648.IDFH" + txt_h = base / "TXT" / f"{idfh.name}.txt" + rh = parse_idf_report(txt_h.read_text(errors="replace")) + res_h = read_idf_file(idfh) + bw_h = build_bw_report_from_idf(rh, binary_md=res_h.binary_metadata, intervals=res_h.intervals) + show("", bw_h) + + +if __name__ == "__main__": + main() diff --git a/analysis_idf/thor_report.pdf b/analysis_idf/thor_report.pdf new file mode 100644 index 0000000000000000000000000000000000000000..52b3096daef468a7aaaf270e3e341a048646be22 GIT binary patch literal 107956 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Ľ%veflttg;=y^NNS^!xz}fq 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 F{|l*>$qWDh literal 0 HcmV?d00001 diff --git a/micromate/idf_ascii_report.py b/micromate/idf_ascii_report.py index 853478d..d4db2c6 100644 --- a/micromate/idf_ascii_report.py +++ b/micromate/idf_ascii_report.py @@ -210,8 +210,7 @@ def parse_idf_report(text: Union[str, bytes]) -> Dict[str, Any]: "long_peak_acceleration", "tran_peak_displacement", "vert_peak_displacement", "long_peak_displacement", - "tran_time_of_peak", "vert_time_of_peak", "long_time_of_peak", - "mic_time_of_peak", "mic_zc_freq", + "mic_zc_freq", ) for key in float_fields: v = raw.get(key) @@ -223,6 +222,22 @@ def parse_idf_report(text: Union[str, bytes]) -> Dict[str, Any]: else: out.pop(key, None) + # Time-of-peak: Thor labels these "TimeofPeak" (lowercase "of") so the + # normalizer produces "*_timeof_peak". Map them to the canonical + # ``*_time_of_peak`` output keys for downstream consumers. + for raw_key, out_key in ( + ("tran_timeof_peak", "tran_time_of_peak"), + ("vert_timeof_peak", "vert_time_of_peak"), + ("long_timeof_peak", "long_time_of_peak"), + ("mic_timeof_peak", "mic_time_of_peak"), + ): + v = raw.get(raw_key) + if v is None: + continue + fv = _parse_float(v) + if fv is not None: + out[out_key] = fv + # Microphone — Thor reports MicPSPL (dB(L)) which is the closest # analogue to BW's mic_ppv. The raw "99.4 dB(L)" string stays in # `out` under the original `mic_pspl` key for display; the parsed diff --git a/micromate/idf_to_bw_report.py b/micromate/idf_to_bw_report.py new file mode 100644 index 0000000..c5d0a01 --- /dev/null +++ b/micromate/idf_to_bw_report.py @@ -0,0 +1,323 @@ +""" +micromate/idf_to_bw_report.py — adapter that projects a parsed Thor IDF +report (+ binary metadata + decoded IDFH intervals) into the +``bw_report``-shaped dict that :mod:`sfm.report_pdf.gather_report_data` +consumes. + +Lets Thor events flow through the existing Series III Event Report PDF +pipeline without duplicating the renderer. Thor's report content is +~95% the same data shape as BW's; the field names differ but the +underlying metrics map 1:1. + +Caveats +─────── + +- **Mic units** — Thor records ``MicPSPL`` natively in dB(L). This + adapter sets ``bw_report.mic.pspl_dbl`` directly; the report + renderer recomputes the equivalent psi via its dBL→psi formula. +- **Saturation / above-range flags** — Thor doesn't always mark + ``OORANGE`` the way BW does; we set ``zc_freq_above_range`` only + when a `>100` sentinel was preserved in the raw text. +- **Per-interval data** — for IDFH events we build ``interval_times`` + by stepping ``IntervalSize`` from ``HistogramStartTime``; the binary + decoder confirms one record per step (882 / 881 / 881 ... across + the corpus). +- **calibration_by parsing** — Thor's free-form ``Calibration : November + 22, 2023 by Instantel`` is split on ``" by "`` to extract the + calibrator; the date prefix is parsed where possible, otherwise + the binary-extracted ``calibration_date`` from + :class:`micromate.idf_file.IdfBinaryMetadata` wins. +""" + +from __future__ import annotations + +import datetime +import re +from typing import Any, Dict, List, Optional + + +# ─── Helpers ──────────────────────────────────────────────────────────────── + + +_NUM_RE = re.compile(r"-?\d+(?:\.\d+)?") + + +def _parse_first_number(s: Optional[str]) -> Optional[float]: + """Pull the first numeric token from a string like ``"0.1500 in/s"``.""" + if s is None: + return None + m = _NUM_RE.search(str(s)) + if not m: + return None + try: + return float(m.group(0)) + except ValueError: + return None + + +def _parse_interval_size_s(s: Optional[str]) -> Optional[float]: + """``"60 sec"`` → 60.0, ``"5 min"`` → 300.0, ``"1 hour"`` → 3600.""" + if s is None: + return None + num = _parse_first_number(s) + if num is None: + return None + sl = str(s).lower() + if "hour" in sl or "hr" in sl: + return num * 3600.0 + if "min" in sl: + return num * 60.0 + return num # default to seconds + + +def _parse_calibration(text: Optional[str]) -> tuple[Optional[str], Optional[str]]: + """Split ``"November 22, 2023 by Instantel"`` → (ISO date, calibrator). + + Returns ``(None, None)`` if neither half parses. + """ + if not text: + return None, None + parts = str(text).split(" by ", 1) + date_part = parts[0].strip() if parts else None + by_part = parts[1].strip() if len(parts) > 1 else None + iso_date: Optional[str] = None + if date_part: + for fmt in ("%B %d, %Y", "%b %d, %Y", "%Y-%m-%d", "%m/%d/%Y"): + try: + iso_date = datetime.datetime.strptime(date_part, fmt).date().isoformat() + break + except ValueError: + continue + return iso_date, by_part + + +def _channel_peaks(idf: Dict[str, Any], ch_lc: str) -> Dict[str, Any]: + """Map ``tran_ppv`` / ``tran_zc_freq`` / ... → bw_report.peaks.tran shape.""" + out: Dict[str, Any] = {} + for src, dst in ( + (f"{ch_lc}_ppv", "ppv_ips"), + (f"{ch_lc}_zc_freq", "zc_freq_hz"), + (f"{ch_lc}_time_of_peak", "time_of_peak_s"), + (f"{ch_lc}_peak_acceleration", "peak_accel_g"), + (f"{ch_lc}_peak_displacement", "peak_disp_in"), + ): + v = idf.get(src) + if v is not None: + out[dst] = v + # ZC freq ">100" sentinel: the raw text carries it under the un-typed + # key (e.g. ``raw["tran_zc_freq"]`` would be ``">100"``), and our parser + # dropped the typed entry. Detect that case and flag. + raw_zc = idf.get(f"{ch_lc}_zc_freq") + if isinstance(raw_zc, str) and ">" in raw_zc: + out["zc_freq_above_range"] = True + out.pop("zc_freq_hz", None) + return out + + +def _sensor_check(idf: Dict[str, Any], ch_lc: str) -> Dict[str, Any]: + out: Dict[str, Any] = {} + fr = idf.get(f"{ch_lc}_test_freq") + if fr is not None: + out["freq_hz"] = _parse_first_number(fr) + rt = idf.get(f"{ch_lc}_test_ratio") + if rt is not None: + out["ratio"] = _parse_first_number(rt) + am = idf.get(f"{ch_lc}_test_amplitude") + if am is not None: + out["amplitude_mv"] = _parse_first_number(am) + res = idf.get(f"{ch_lc}_test_results") + if res is not None: + out["result"] = str(res).strip() + return {k: v for k, v in out.items() if v is not None} + + +def _interval_times(idf: Dict[str, Any], n_intervals: Optional[int]) -> List[str]: + """Synthesise per-interval timestamps from start + interval_size × k. + + Returns ``[]`` when start time or interval size is unknown. + """ + if not n_intervals: + return [] + start_date = idf.get("histogram_start_date") or idf.get("event_date") + start_time = idf.get("histogram_start_time") or idf.get("event_time") + iv_str = idf.get("interval_size") + iv_s = _parse_interval_size_s(iv_str) + if not (start_date and start_time and iv_s): + return [] + try: + t0 = datetime.datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M:%S") + except ValueError: + return [] + out = [] + for k in range(int(n_intervals)): + t = t0 + datetime.timedelta(seconds=iv_s * (k + 1)) + out.append(t.isoformat()) + return out + + +# ─── Top-level adapter ────────────────────────────────────────────────────── + + +def build_bw_report_from_idf( + idf_report: Dict[str, Any], + *, + binary_md=None, + intervals: Optional[list] = None, + is_histogram: Optional[bool] = None, +) -> Dict[str, Any]: + """Project a parsed IDF report dict (and optional binary metadata + + decoded IDFH intervals) into the BW report sidecar shape. + + The returned dict is structurally identical to what + ``minimateplus.event_file_io._bw_report_to_dict`` produces from a + real BW ASCII report — it can be assigned to + ``sidecar["bw_report"]`` and consumed verbatim by + ``sfm.report_pdf.gather_report_data``. + + ``intervals`` is the list of :class:`micromate.idf_file.IdfhInterval` + objects from :func:`micromate.idf_file.decode_idfh_body`; only used + for histogram events to derive accurate ``interval_times``. + """ + if is_histogram is None: + et = str(idf_report.get("event_type", "")) + is_histogram = et.lower().startswith("full histogram") + + # ── Trigger / recording / device ───────────────────────────────────── + trigger_channel = idf_report.get("trigger") + trigger_level = _parse_first_number(idf_report.get("geo_trigger_level")) + geo_range_ips = _parse_first_number(idf_report.get("geo_range")) + + cal_iso, cal_by = _parse_calibration(idf_report.get("calibration")) + # Prefer the binary-extracted calibration_date when our text parse fell + # through; the binary date is unambiguous. + if cal_iso is None and binary_md is not None and binary_md.calibration_date: + cal_iso = binary_md.calibration_date.isoformat() + + # ── Histogram fields ──────────────────────────────────────────────── + hist_block: Dict[str, Any] = { + "start": None, "stop": None, "n_intervals": None, + "interval_size": None, "interval_size_s": None, + "channel_peak_when": {}, + } + if is_histogram: + sd = idf_report.get("histogram_start_date") + st = idf_report.get("histogram_start_time") + if sd and st: + try: + hist_block["start"] = datetime.datetime.strptime( + f"{sd} {st}", "%Y-%m-%d %H:%M:%S" + ).isoformat() + except ValueError: + pass + ed = idf_report.get("histogram_stop_date") + et_ = idf_report.get("histogram_stop_time") + if ed and et_: + try: + hist_block["stop"] = datetime.datetime.strptime( + f"{ed} {et_}", "%Y-%m-%d %H:%M:%S" + ).isoformat() + except ValueError: + pass + n_raw = idf_report.get("number_of_intervals") + if n_raw is not None: + try: + # Thor reports a float like "81.04"; round to int (the BW + # report uses an int for the column). + hist_block["n_intervals"] = int(float(str(n_raw))) + except ValueError: + pass + # When the binary decoder gave us the actual interval count, prefer it. + if intervals is not None: + hist_block["n_intervals"] = len(intervals) + hist_block["interval_size"] = idf_report.get("interval_size") + hist_block["interval_size_s"] = _parse_interval_size_s(idf_report.get("interval_size")) + # interval_times derived from start+step (the BW report uses the + # exact strings; we match its representation). + times = _interval_times(idf_report, hist_block["n_intervals"]) + # Per-channel peak when (absolute date+time at which the channel's + # peak occurred over the histogram run). Thor splits this into + # ``TranPeakDate`` / ``TranPeakTime`` etc. + peak_when: Dict[str, str] = {} + for ch_label, ch_lc in (("Tran", "tran"), ("Vert", "vert"), ("Long", "long"), ("MicL", "mic")): + d = idf_report.get(f"{ch_lc}_peak_date") + t = idf_report.get(f"{ch_lc}_peak_time") + if d and t: + try: + peak_when[ch_label] = datetime.datetime.strptime( + f"{d} {t}", "%Y-%m-%d %H:%M:%S" + ).isoformat() + except ValueError: + continue + if peak_when: + hist_block["channel_peak_when"] = peak_when + + # ── Mic block ──────────────────────────────────────────────────────── + mic_block = { + "weighting": "L", # Thor mic is ISEE Linear + "pspl_dbl": idf_report.get("mic_ppv"), # the dB(L) float + "pspl_saturated": False, + "zc_freq_hz": idf_report.get("mic_zc_freq"), + "zc_freq_above_range": isinstance(idf_report.get("mic_zc_freq"), str) + and ">" in str(idf_report.get("mic_zc_freq")), + "time_of_peak_s": idf_report.get("mic_time_of_peak"), + } + if mic_block["zc_freq_above_range"]: + mic_block["zc_freq_hz"] = None + + # ── Peaks ──────────────────────────────────────────────────────────── + vs_block = { + "ips": idf_report.get("peak_vector_sum"), + "time_s": _parse_first_number(idf_report.get("peak_vector_sum_time_sum")), + "when": None, + "saturated": False, + } + if is_histogram: + # PVS absolute date+time, when present. + vs_d = idf_report.get("peak_vector_sum_date") + vs_t = idf_report.get("peak_vector_sum_time") + if vs_d and vs_t: + try: + vs_block["when"] = datetime.datetime.strptime( + f"{vs_d} {vs_t}", "%Y-%m-%d %H:%M:%S" + ).isoformat() + except ValueError: + pass + + return { + "available": True, + "event_type": idf_report.get("event_type"), + "version": idf_report.get("version"), + "trigger": { + "channel": trigger_channel, + "geo_level_ips": trigger_level, + }, + "recording": { + "sample_rate_sps": idf_report.get("sample_rate"), + "record_time_s": idf_report.get("record_time_sec"), + "pretrig_s": idf_report.get("pre_trigger_sec"), + "stop_mode": idf_report.get("record_stop_mode"), + "geo_range_ips": geo_range_ips, + "units": idf_report.get("units"), + }, + "device": { + "battery_volts": idf_report.get("battery_volts"), + "calibration_date": cal_iso, + "calibration_by": cal_by, + }, + "peaks": { + "tran": _channel_peaks(idf_report, "tran"), + "vert": _channel_peaks(idf_report, "vert"), + "long": _channel_peaks(idf_report, "long"), + "vector_sum": vs_block, + }, + "mic": mic_block, + "sensor_check": { + "tran": _sensor_check(idf_report, "tran"), + "vert": _sensor_check(idf_report, "vert"), + "long": _sensor_check(idf_report, "long"), + "mic": _sensor_check(idf_report, "mic"), + }, + "histogram": hist_block, + "monitor_log": [], + "pc_sw_version": None, + } diff --git a/sfm/waveform_store.py b/sfm/waveform_store.py index 031a9c0..c4861a1 100644 --- a/sfm/waveform_store.py +++ b/sfm/waveform_store.py @@ -639,6 +639,27 @@ class WaveformStore: # Time of Peak, sensor self-check, calibration, firmware). if report_dict: sidecar["extensions"]["idf_report"] = report_dict + + # Project the IDF report into the BW report sidecar shape so the + # existing Event Report PDF pipeline (sfm/report_pdf.py) can + # render Thor events without needing a separate code path. Thor + # data is 95% the same metric set as BW — the adapter handles + # the field-name mapping. + if report_dict or binary_md is not None: + try: + from micromate.idf_to_bw_report import build_bw_report_from_idf + sidecar["bw_report"] = build_bw_report_from_idf( + report_dict or {}, + binary_md=binary_md, + intervals=idf_intervals, + is_histogram=is_histogram, + ) + except Exception as exc: + log.warning( + "save_imported_idf: idf→bw_report adapter failed for %s: %s — " + "report PDF will fall back to DB-only fields", + filename, exc, + ) # For histograms, also stash the binary-decoded per-interval # records so the UI / report layer doesn't need to re-walk the # IDFH file at render time. From e42956a20b74b1fab62ca3667d6ea6d5e4dda1f6 Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 29 May 2026 19:25:44 +0000 Subject: [PATCH 03/11] =?UTF-8?q?release:=20v0.21.0=20=E2=80=94=20Thor=20/?= =?UTF-8?q?=20Series=20IV=20codec=20+=20Thor=E2=86=92BW=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents two commits that landed on dev since v0.20.0: 9b71ead series 4 codec work, initial decode success micromate/idf_file.read_idf_file() decodes both IDFW (waveform; 87-99% sample fidelity reusing decode_waveform_v2 at offset 0x0f1f) and IDFH (histogram; dedicated segment-based decoder, all 859 corpus files decode, 181,071 intervals total). 9fd52dd feat: add thor report generation, pdf generation micromate/idf_to_bw_report.py adapter projects parsed Thor data into the bw_report sidecar shape so Thor events flow through sfm/report_pdf.py without a separate renderer. Wired into save_imported_idf. Net effect: a Thor event ingested via /db/import/idf_file now lands with the same fidelity as a BW event, gets a per-event PDF on demand, and renders in Terra-View's modal chart using the same plotting code as a BW event. Roadmap items closed: - Binary .IDFW / .IDFH codec (was pending) - Series IV (Thor IDF) binary codec reverse-engineering Companion: Terra-View v0.13.0 ships in parallel and closes Phase 1 of the SFM integration. No API changes in seismo-relay for that piece — Terra-View just consumes existing endpoints better. Bumps: - pyproject.toml 0.20.0 → 0.21.0 - minimateplus.event_file_io.TOOL_VERSION 0.20.0 → 0.21.0 (any subsequent backfill_sidecars.py --force will re-stamp existing sidecars; expected + harmless) Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ CLAUDE.md | 2 +- README.md | 18 ++++++++++++++---- minimateplus/event_file_io.py | 2 +- pyproject.toml | 2 +- 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b92776..decd53e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,40 @@ All notable changes to seismo-relay are documented here. --- +## v0.21.0 — 2026-05-29 + +The "Thor / Series IV codec" release. Two big pieces landed: (1) the IDF binary codec actually decodes now, both IDFW and IDFH, and (2) a Thor→BW adapter lets Thor events flow through the existing Series III Event Report PDF pipeline. Combined effect: a Thor event ingested via `/db/import/idf_file` now lands in the DB with the same fidelity as a Blastware event, gets a per-event PDF on demand, and renders in Terra-View's modal chart with the same plotting code as a BW event. + +### Added — Thor IDF binary codec (`micromate/idf_file.read_idf_file`) + +- **IDFW (waveform)** — body sits at fixed file offset `0x0f1f`; reuses the verified `decode_waveform_v2()` walker from `minimateplus.waveform_codec`. Sample fidelity is **87–99% byte-exact** against the ASCII-sidecar reference values on quiet events; loud events hit the same walker-stops-early limitation as the BW codec on `SP0/SS0/SV0`-style events. +- **IDFH (histogram)** — dedicated segment-based decoder for the Thor histogram body format: `[len_be][0a 00 00 00][00 NN][05 3f]` framing plus N × 72-byte interval records (4 × 16-byte per-channel min/max/halfp). **All 859 Thor IDFH corpus files decode**, totalling **181,071 intervals**; per-channel peaks match the sidecar within **~1.8% (ADC quantization)**. +- **BW-aliased binary detection** — a small number of corpus files (e.g. `BE9439_*.IDFW/IDFH`) are actually Series III Blastware binaries that share the IDF filename convention by accident. `read_idf_file()` detects them via their BW `STRT` signature and raises `NotImplementedError` pointing the caller at `read_blastware_file()` instead of trying to decode them as IDF. +- Full field layouts in `docs/idf_protocol_reference.md`; supporting analysis scripts in `analysis_idf/` (decode validators, per-file detail dumps, corpus accuracy reports). + +### Added — Thor → BW report adapter (`micromate/idf_to_bw_report.py`) + +- **`build_bw_report_from_idf(report_dict, binary_md=, intervals=, is_histogram=)`** projects a parsed Thor `IdfReport` plus binary-extracted metadata plus decoded IDFH intervals into the `bw_report`-shaped dict that `sfm.report_pdf.gather_report_data` consumes. No need to duplicate the renderer — Thor data is ~95% the same metric set as BW; the adapter handles the field-name mapping (`MicPSPL` → `pspl_dbl`, `>100` sentinel → `zc_freq_above_range`, free-form `Calibration : Nov 22, 2023 by Instantel` → `calibration_date` + `calibration_by`, etc.). +- For IDFH events the adapter derives `histogram.interval_times` by stepping `IntervalSize` from `HistogramStartTime`, matching what the BW pipeline expects from a histogram-mode event. +- **Wired into `WaveformStore.save_imported_idf`** — every Thor event ingested via `/db/import/idf_file` now gets a `bw_report` block in its sidecar in addition to the existing `extensions.idf_report` (the raw parsed Thor payload). Falls back gracefully (PDF renders from DB-only fields) if the adapter raises — logged as a warning rather than failing the ingest. + +### Companion releases + +- **Terra-View v0.13.0** ships in parallel — closes Phase 1 of the SFM integration. The shared event-detail modal now renders the SFM event story (Chart.js waveform/histogram chart, inline PDF preview, `.TXT` download, FT/reviewer/notes review form) without operators needing to bounce to the standalone SFM webapp on port 8200. Uses only existing seismo-relay endpoints — no API changes here, just better consumption. + +### Migration / Operations + +No DB migration needed. Existing Thor events already in the store don't automatically pick up the new `bw_report` block — they'd need a re-ingest (post the IDF binary + paired `.TXT` back to `/db/import/idf_file`) for the adapter to run. Alternatively, run `scripts/backfill_sidecars.py --reparse-txt` after a small adapter change (the script currently only re-runs the BW ASCII parser; extending it to handle Thor would be a small follow-up). + +```bash +cd /home/serversdown/terra-view +docker compose build sfm && docker compose up -d sfm +``` + +The bumped `TOOL_VERSION = "0.21.0"` in `minimateplus/event_file_io.py` means any subsequent `backfill_sidecars.py --force` pass will re-write sidecars with the new version stamp; that's expected and harmless. + +--- + ## v0.20.0 — 2026-05-28 The "PDF + parser polish" release. Closes out the Event-Report PDF iteration started in v0.17.x: histogram layouts now render correctly against BW reference PDFs, the ASCII parser handles the real-world edge cases production events were tripping over (OORANGE, `>100 Hz`, histogram timestamps), and the `.TXT` preservation rollout lets parser fixes be applied retroactively to ingested events. Adds server-wide timezone support so operator-visible timestamps no longer drift into UTC. Rolls up the substantial "pre-v0.20" body of work that had accumulated under `[Unreleased]` (PDF generation, histogram codec fix, histogram parser fields, `.TXT` preservation, backfill safety) — see the trailing "pre-v0.20.0 work" section below for the full list. diff --git a/CLAUDE.md b/CLAUDE.md index ba8be79..9198786 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.20.0**. +(Sierra Wireless RV50 / RV55). Current version: **v0.21.0**. When new information about the protocol is discovered, please update the instantel_protocol_reference.md with the findings in addition to this document diff --git a/README.md b/README.md index 7522bb1..8d41f7e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# seismo-relay `v0.20.0` +# seismo-relay `v0.21.0` A ground-up replacement for **Blastware** — Instantel's aging Windows-only software for managing seismographs. Supports both the **MiniMate Plus @@ -45,6 +45,15 @@ over direct RS-232 or cellular modem (Sierra Wireless RV50 / RV55). > `scripts/backfill_sidecars.py --reparse-txt` lets parser fixes be > applied retroactively to existing events without re-forwarding, > using the `.TXT` files preserved at ingest time. +> **v0.21.0 (2026-05-29)** is the Thor / Series IV decoder release — +> `micromate/idf_file.read_idf_file()` now decodes both IDFW +> (waveform) and IDFH (histogram) binaries (87–99% sample fidelity +> on quiet IDFW events; all 859 IDFH corpus files decode cleanly). +> A new `micromate/idf_to_bw_report.py` adapter projects parsed +> Thor reports into the BW-shaped sidecar block, so Thor events +> flow through the existing Event Report PDF pipeline without a +> separate renderer. Terra-View v0.13.0 ships in parallel and +> closes Phase 1 of the SFM integration — see its CHANGELOG. > See [CHANGELOG.md](CHANGELOG.md) for full version history. --- @@ -68,7 +77,8 @@ seismo-relay/ ├── micromate/ ← Series IV (Micromate / Thor) client library (NEW v0.19) │ ├── models.py ← IdfEvent, IdfReport, IdfPeaks, IdfProjectInfo, IdfSensorCheck (mic in native dB(L)) │ ├── idf_ascii_report.py ← Parse Thor .IDFW.txt / .IDFH.txt event sidecars -│ └── idf_file.py ← Stub for the .IDFW / .IDFH binary codec (reverse-engineering pending) +│ ├── idf_file.py ← Binary codec for .IDFW + .IDFH (v0.21.0+) +│ └── idf_to_bw_report.py ← Adapter projecting Thor IDF into the BW report shape (v0.21.0+) │ ├── sfm/ ← SFM REST API server (FastAPI, port 8200) │ ├── server.py ← Live device endpoints + DB query + ingest endpoints + caching @@ -425,7 +435,7 @@ Use **com0com** or **VSPD** to create the virtual COM pair on Windows. - [x] Thor IDF file ingest at `/db/import/idf_file` (paired with `thor-watcher`, v0.18.0+) - [x] Native `IdfEvent` / `IdfReport` typed models — mic in dB(L), full title strings, sensor self-check, calibration, firmware version - [x] Parser verified against 1,014 paired `.txt` sidecars in `thor-watcher/example-data/` -- [ ] Binary `.IDFW` / `.IDFH` codec — pending (see Roadmap + [`docs/idf_protocol_reference.md`](docs/idf_protocol_reference.md)) +- [x] Binary `.IDFW` / `.IDFH` codec — ✅ v0.21.0. IDFW reuses `decode_waveform_v2()` on the body at offset `0x0f1f` (87–99% sample fidelity on quiet events); IDFH has a dedicated segment-based decoder (all 859 corpus files decode, 181,071 intervals total). See `micromate/idf_file.py` + `docs/idf_protocol_reference.md`. - [ ] Live-device protocol — pending codec **Data persistence:** @@ -538,7 +548,7 @@ Implementation steps (concrete): ### High-impact (unblocks product features) - [ ] **Series III waveform body codec reverse-engineering.** The 5A bulk-stream body is some kind of compressed/encoded format (not raw int16 LE as previously assumed — see §7.6.1 retraction in `docs/instantel_protocol_reference.md`). Structural framing is ~50% decoded on branch `claude/codec-re-cBGNe` (tagged-block walker, segment counters); per-byte sample mapping is still open. Until this lands, the in-app waveform viewer renders garbage and BW-import peak values fall back to `_peaks_from_samples()` saturation noise. Workaround: pair every BW-imported event with its `_ASCII.TXT` so the device-authoritative peaks land in the DB regardless of codec. -- [ ] **Series IV (Thor IDF) binary codec reverse-engineering.** `.IDFH` / `.IDFW` files are currently stored opaquely by `WaveformStore.save_imported_idf`, with all metadata sourced from the paired `.txt` sidecar. This works because thor-watcher forwards both files together, but operators who haven't enabled Thor's TXT exporter get rows with NULL peaks. Cracking the binary closes that gap and unlocks waveform display. Starting-point reference at [`docs/idf_protocol_reference.md`](docs/idf_protocol_reference.md) — two observed file signatures (1,012 newer-firmware files + 2 old files whose layout matches the Series III STRT-record format), suggested first-session plan (~2-4 hrs), 1,014 paired binary+txt files available as ground truth in `thor-watcher/example-data/`. Code seam ready at `micromate/idf_file.py`. +- [x] **Series IV (Thor IDF) binary codec reverse-engineering.** ✅ v0.21.0 — `micromate/idf_file.read_idf_file()` decodes both IDFW (waveform body at offset `0x0f1f`, reusing `decode_waveform_v2()`; 87–99% sample fidelity on quiet events) and IDFH (dedicated segment-based decoder: all 859 corpus files decode, 181,071 intervals, peaks within ~1.8% of sidecar values). `WaveformStore.save_imported_idf` now also projects parsed Thor data into a `bw_report` block via `micromate/idf_to_bw_report.py` so Thor events render in the existing Event Report PDF pipeline without a separate renderer. - [ ] **In-app waveform viewer accuracy.** Depends on Series III codec decode. Plot.v1 JSON pipeline + viewer skeleton already exist; will start showing real waveforms automatically once `_decode_a5_waveform` produces correct samples. Series IV waveforms come online when the IDF codec lands. - [ ] **Series IV live-device support.** Once the IDF binary is decoded, extend `micromate/` with `transport.py` / `framing.py` / `protocol.py` / `client.py` mirroring the `minimateplus/` package layout — depends on capturing Thor's wire protocol (TCP / RS-232 captures TBD). - [ ] **Terra-view integration** — seismo-relay router, unit detail page, VISON-style event listing. diff --git a/minimateplus/event_file_io.py b/minimateplus/event_file_io.py index 7dc74c1..6b61f7e 100644 --- a/minimateplus/event_file_io.py +++ b/minimateplus/event_file_io.py @@ -49,7 +49,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.20.0" +TOOL_VERSION = "0.21.0" try: # Best-effort: prefer the installed metadata when it's NEWER than the diff --git a/pyproject.toml b/pyproject.toml index 5151f55..f8e4ae8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "seismo-relay" -version = "0.20.0" +version = "0.21.0" description = "Python client and REST server for MiniMate Plus seismographs" requires-python = ">=3.10" dependencies = [ From defd17d9c2bc3190b895942070890a8b1027895d Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 29 May 2026 19:51:58 +0000 Subject: [PATCH 04/11] =?UTF-8?q?sfm=5Fwebapp:=20harmonize=20"Received=20b?= =?UTF-8?q?y=20server=20at"=20=E2=86=92=20"Time=20received"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches Terra-View's event-modal relabel from the same iteration. Wording was already clearer here than in Terra-View's "Captured at", but using identical text across both surfaces means operators see the same label whether they're in the native modal or the standalone webapp. Co-Authored-By: Claude Opus 4.7 (1M context) --- sfm/sfm_webapp.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sfm/sfm_webapp.html b/sfm/sfm_webapp.html index 7f283a4..d03070e 100644 --- a/sfm/sfm_webapp.html +++ b/sfm/sfm_webapp.html @@ -3287,7 +3287,7 @@ if (currentSection === 'db') {
File size
File sha256
Source kind
-
Received by server at
+
Time received
From bee118506b9b0df5fd18d10670f9cb4ac99d43a4 Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 29 May 2026 20:09:54 +0000 Subject: [PATCH 05/11] fix(idf): decode from in-memory bytes during ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug shipped in v0.21.0: save_imported_idf called read_idf_file() with `source_path` (a bare filename like "UM12947_….IDFW") BEFORE writing the binary to disk. The codec did Path(path).read_bytes() which resolved relative to /app and hit FileNotFoundError. The error was caught + logged as a warning, and ingest fell back to .txt-only — events still landed in the DB but lost the bw_report block + .h5 waveform that the codec was supposed to produce. Observed during a full re-forward from thor-watcher on 2026-05-29: every Thor event logged "binary codec failed for X: [Errno 2] No such file or directory" and got binary_decoded=False. Fix: - read_idf_file() gains a `data: Optional[bytes]` kwarg. When supplied, skips the disk read and decodes the provided bytes directly. `path` stays required (used for filename in error messages + .IDFH vs .IDFW suffix detection); only the read is conditional. Backward compatible — existing positional callers (CLI scripts, tests) continue to work unchanged. - save_imported_idf passes `data=idf_bytes` since the bytes are already in memory from the multipart upload. Filesystem write still happens at step 5 of the existing flow; codec just no longer depends on it. Verified end-to-end against UM11719_20231219162723.IDFW from the example-data corpus: ingest endpoint returns inserted=1, log line shows binary_decoded=True + h5=...IDFW.h5, no warnings. Re-forward existing Thor events from thor-watcher after deploy to backfill the bw_report block — UPSERT preserves review state. Co-Authored-By: Claude Opus 4.7 (1M context) --- micromate/idf_file.py | 13 +++++++++++-- sfm/waveform_store.py | 7 ++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/micromate/idf_file.py b/micromate/idf_file.py index bee7555..203a26e 100644 --- a/micromate/idf_file.py +++ b/micromate/idf_file.py @@ -326,7 +326,11 @@ class IdfReadResult: intervals: Optional[list] = None # list[IdfhInterval] for IDFH; None for IDFW -def read_idf_file(path: Union[str, Path]) -> IdfReadResult: +def read_idf_file( + path: Union[str, Path], + *, + data: Optional[bytes] = None, +) -> IdfReadResult: """Parse a Thor ``.IDFW`` binary into an ``IdfEvent`` + decoded samples. Currently implements signature-A waveforms only. Signature-B @@ -337,9 +341,14 @@ def read_idf_file(path: Union[str, Path]) -> IdfReadResult: Returns an :class:`IdfReadResult`. The caller converts int sample counts to physical units via :func:`geo_count_to_ips` / :func:`mic_count_to_psi`. + + ``path`` is used for filename in error messages and ``.IDFH`` vs + ``.IDFW`` suffix detection. When ``data`` is supplied the disk + read is skipped — useful for ingest paths that already have the + bytes in memory and where the file may not exist on disk yet. """ p = Path(path) - buf = p.read_bytes() + buf = data if data is not None else p.read_bytes() if len(buf) < 16 or buf[6:16] != _INSTANTEL_TAG + b"\x00": raise ValueError(f"{p.name}: not an IDF file (missing Instantel magic)") diff --git a/sfm/waveform_store.py b/sfm/waveform_store.py index c4861a1..3b2ba42 100644 --- a/sfm/waveform_store.py +++ b/sfm/waveform_store.py @@ -500,7 +500,12 @@ class WaveformStore: is_histogram = False try: from micromate.idf_file import read_idf_file - res = read_idf_file(source_path) + # Pass idf_bytes through `data=` — at this point in the flow + # the binary hasn't been written to disk yet, so the codec + # can't read from source_path. We still pass source_path so + # the codec has the filename for error messages + .IDFH + # suffix detection. + res = read_idf_file(source_path, data=idf_bytes) idf_samples = res.samples or None idf_intervals = res.intervals is_histogram = res.intervals is not None From 23e83908c2073c7f11c2e7aa83d1829cc10edb4a Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 29 May 2026 22:17:43 +0000 Subject: [PATCH 06/11] report_pdf: fix PVS overlapping stats table, drop NA caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes to the per-channel stats block: 1. Pin the stats table's position via an explicit bbox= on ax.table() so the bottom edge is at a known axes-fraction Y. The previous loc="upper left" + tbl.scale(1, 1.4) combo let matplotlib choose row heights based on text size, which made the table extend further below the axes than the hard-coded PVS line at y=-0.08 expected. Result was the "Peak Vector Sum X in/s" string landing horizontally inside the Peak Displacement row. With bbox=[0, 1-N*0.12, 0.80, N*0.12] the table is pinned to a precise rectangle (12% axes-fraction per row × N rows tall). _draw_stats_table now stashes the bottom Y on the axes for the PVS helper to reference, so the geometry stays in sync. 2. Center PVS horizontally (ha="center" at x=0.5 instead of ha="left" at x=0). The previous left-edge alignment put PVS at the same X as the label column, which read as "off-center" once the rest of the stats data was column-aligned further right. 3. Drop the "NA: Not Applicable" caption. It existed to explain "—" placeholder cells, but "—" is universally understood and the caption was always visually squished against the PVS line below. Less cruft on the page; one fewer position to manage. Verified against a real BE12599 histogram event (5 data rows) and a real UM12947 IDFW waveform event (6 data rows) — both layouts clear the table cleanly with no overlap. Co-Authored-By: Claude Opus 4.7 (1M context) --- sfm/report_pdf.py | 87 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 63 insertions(+), 24 deletions(-) diff --git a/sfm/report_pdf.py b/sfm/report_pdf.py index 6618d9a..25859d1 100644 --- a/sfm/report_pdf.py +++ b/sfm/report_pdf.py @@ -638,14 +638,7 @@ def _draw_channel_stats_waveform(ax, rd: ReportData) -> None: ("Sensor Check", "sensor_check", ""), ] _draw_stats_table(ax, rd, rows_spec) - if rd.peak_vector_sum_ips is not None: - line = f"Peak Vector Sum {rd.peak_vector_sum_ips:.3f} in/s" - if rd.peak_vector_sum_time_s is not None: - line += f" At {rd.peak_vector_sum_time_s:.3f} sec." - ax.text(0.0, -0.08, line, fontsize=9, weight="bold", - ha="left", va="top", transform=ax.transAxes) - ax.text(0.0, -0.18, "NA: Not Applicable", fontsize=7, color="#888", - ha="left", va="top", transform=ax.transAxes) + _draw_pvs_summary(ax, rd, n_data_rows=len(rows_spec)) def _draw_channel_stats_histogram(ax, rd: ReportData) -> None: @@ -663,20 +656,54 @@ def _draw_channel_stats_histogram(ax, rd: ReportData) -> None: ("Sensor Check", "sensor_check", ""), ] _draw_stats_table(ax, rd, rows_spec) - if rd.peak_vector_sum_ips is not None: - line = f"Peak Vector Sum {rd.peak_vector_sum_ips:.3f} in/s" - # Histograms: "0.091 in/s on May 27, 2026 At 06:06:14" - # The when_str is "HH:MM:SS Month DD, YYYY" — reformat for BW match. - if rd.peak_vector_sum_when_str: - parts = rd.peak_vector_sum_when_str.split(" ", 1) - if len(parts) == 2: - line += f" on {parts[1]} At {parts[0]}" - else: - line += f" on {rd.peak_vector_sum_when_str}" - ax.text(0.0, -0.08, line, fontsize=9, weight="bold", - ha="left", va="top", transform=ax.transAxes) - ax.text(0.0, -0.18, "NA: Not Applicable", fontsize=7, color="#888", - ha="left", va="top", transform=ax.transAxes) + _draw_pvs_summary(ax, rd, n_data_rows=len(rows_spec), histogram_when=True) + + +def _draw_pvs_summary( + ax, + rd: ReportData, + *, + n_data_rows: int, + histogram_when: bool = False, +) -> None: + """Render the Peak Vector Sum + 'NA: Not Applicable' caption below the + stats table. + + Reads ``ax._stats_table_bottom`` (set by ``_draw_stats_table`` when + it pins the table via an explicit ``bbox``) so the PVS line lands + just below the table's known bottom edge instead of guessing at the + geometry. + + Centered horizontally for visual balance (the previous left-aligned + x=0 landed under the label column, not the data, which looked off). + """ + if rd.peak_vector_sum_ips is None: + return + + line = f"Peak Vector Sum {rd.peak_vector_sum_ips:.3f} in/s" + if histogram_when and rd.peak_vector_sum_when_str: + # Histogram absolute date+time. when_str is "HH:MM:SS Month DD, YYYY"; + # reformat to " on At