Verified against Thor's own CSV exports, which carry a per-sample four-column block beside every binary (CSV/<name>.IDFW.csv). Those 1,012 paired files were in the corpus all along; the decoder had been pinned to a superseded walker on the stated grounds that "Thor has no ASCII ground truth in the corpus and its geo scaling is separately suspect". Both premises were false. IDFW per-sample exact 39.1% -> 100.000% (1,057,536/1,057,536) IDFW files fully exact 0/153 -> 153/153 IDFW PPV median error -3.32% -> -0.002% IDFH within 2% of Thor PPV 51.1% -> 100.0% (858/858) prod IDFW, 8 units -3.3% -> -0.001% Four independent root causes: - Geo LSB was 0.0003, the 4-dp *display rounding* of the real 0.000310308 mistaken for the LSB, so every series-4 geophone sample read 3.3% low. Pinned to +-6e-11 by intersecting 991,415 rounding constraints; corroborated by the +-full-scale seed (+-32226) left in unwritten IDFH slots. IDFH had a separate, also wrong, 10.0/32768. - IDFH histograms were capped at 250 intervals: the segment validator required the interval counter's high byte to be zero, but the counter is a uint16 cumulative index, so every segment past interval 255 was rejected. Runs over ~4 hours lost their tail, often the peak. 540/858 corpus files affected. - Record mode 00 00 (raw int16, 10-byte header) was unhandled and fell through the dispatch, silently dropping each channel's first 512 samples -- the long-standing "loud events truncate" symptom. MODE_ABSOLUTE is now also accepted as a segment-0 preamble. - The body-offset search matched 00 02 00 *inside* record headers, selecting a candidate part-way down the chain and decoding a rotation-shifted body. It now anchors on record headers and takes the chain head (6 ms/file). Also fixes the separately tracked "UM-series decodes ~1000x low" bug. Series-3 re-verified unchanged at 14,338/14,338 exact after the shared waveform_codec change. Known open: 41/575 prod IDFW files (7%, mostly UM12947/UM20147) decode with unequal channel lengths and also fail metadata extraction -- a different header variant with no Thor export in the store. NOTE: this is a codec change; the Thor store owes a regeneration via scripts/backfill_thor_events.py. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
229 lines
7.9 KiB
Python
229 lines
7.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Verify the Thor / Micromate (series-4) IDF decoder against Thor's own exports.
|
|
|
|
Sister harness to ``scratch/verify_against_ascii.py`` (series-3 / Blastware).
|
|
|
|
Ground truth is the ``.IDFW.csv`` / ``.IDFH.csv`` file Thor writes next to each
|
|
binary, under a sibling ``CSV/`` directory:
|
|
|
|
<dir>/UM13981_20220207084555.IDFW
|
|
<dir>/CSV/UM13981_20220207084555.IDFW.csv
|
|
|
|
For waveforms the CSV carries a per-sample block of four columns
|
|
(Tran, Vert, Long, Mic) in in/s and psi -- i.e. true per-sample ground truth,
|
|
exactly what the BW ASCII exports give us for series-3. The leading 2-column
|
|
rows are the report header (PPV, sample rate, geo range, ...).
|
|
|
|
Usage:
|
|
python scratch/verify_thor_against_csv.py [--root DIR] [--lsb FLOAT]
|
|
[--limit N] [--kind idfw|idfh|both]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import os
|
|
import statistics
|
|
import sys
|
|
from collections import Counter, defaultdict
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from micromate import idf_file as M
|
|
|
|
DEFAULT_ROOT = "/home/serversdown/thor-watcher/example-data"
|
|
GEO = ("Tran", "Vert", "Long")
|
|
|
|
|
|
def parse_export(path):
|
|
"""Return (header_dict, sample_rows) from a Thor CSV export."""
|
|
hdr, rows = {}, []
|
|
with open(path, newline="", encoding="utf-8", errors="replace") as fh:
|
|
for rec in csv.reader(fh):
|
|
if len(rec) == 2:
|
|
hdr[rec[0].strip()] = rec[1].strip()
|
|
elif len(rec) >= 3:
|
|
try:
|
|
rows.append([float(x) for x in rec])
|
|
except ValueError:
|
|
pass
|
|
return hdr, rows
|
|
|
|
|
|
def index_corpus(root):
|
|
"""Map BASENAME.IDFW -> (binary_path, csv_path) for every paired file."""
|
|
exports, binaries = {}, {}
|
|
for dirpath, _dirs, files in os.walk(root):
|
|
for name in files:
|
|
up = name.upper()
|
|
full = os.path.join(dirpath, name)
|
|
if up.endswith(".IDFW.CSV") or up.endswith(".IDFH.CSV"):
|
|
exports.setdefault(name[:-4].upper(), full)
|
|
elif up.endswith(".IDFW") or up.endswith(".IDFH"):
|
|
binaries.setdefault(up, full)
|
|
return {k: (binaries[k], exports[k]) for k in binaries.keys() & exports.keys()}
|
|
|
|
|
|
def hdr_float(hdr, key):
|
|
raw = hdr.get(key)
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return float(raw.split()[0])
|
|
except (ValueError, IndexError):
|
|
return None
|
|
|
|
|
|
def verify_waveform(binpath, csvpath, lsb):
|
|
"""Compare one IDFW against its export. Returns a result dict."""
|
|
out = {"file": os.path.basename(binpath), "status": "ok"}
|
|
try:
|
|
res = M.read_idf_file(binpath)
|
|
except NotImplementedError:
|
|
out["status"] = "not-thor"
|
|
return out
|
|
except Exception as exc: # noqa: BLE001 - harness reports, never raises
|
|
out["status"] = "decode-error"
|
|
out["detail"] = f"{type(exc).__name__}: {exc}"
|
|
return out
|
|
|
|
hdr, rows = parse_export(csvpath)
|
|
if not rows:
|
|
out["status"] = "no-gt-samples"
|
|
return out
|
|
|
|
gt = {ch: [r[i] for r in rows] for i, ch in enumerate(GEO)}
|
|
out["gt_len"] = len(rows)
|
|
out["geo_range"] = hdr.get("GeoRange")
|
|
|
|
exact = total = 0
|
|
lens, chan_status = {}, {}
|
|
ppv_err = {}
|
|
for ch in GEO:
|
|
arr = res.samples.get(ch, [])
|
|
ref = gt[ch]
|
|
lens[ch] = len(arr)
|
|
if len(arr) != len(ref):
|
|
chan_status[ch] = "length"
|
|
continue
|
|
if not arr:
|
|
chan_status[ch] = "empty"
|
|
continue
|
|
hits = sum(1 for c, v in zip(arr, ref) if abs(c * lsb - v) < 5e-5)
|
|
exact += hits
|
|
total += len(arr)
|
|
chan_status[ch] = "exact" if hits == len(arr) else "value"
|
|
gp = hdr_float(hdr, f"{ch}PPV")
|
|
if gp:
|
|
ppv_err[ch] = (max(abs(c) for c in arr) * lsb - gp) / gp
|
|
|
|
out["lens"] = lens
|
|
out["chan_status"] = chan_status
|
|
out["exact"] = exact
|
|
out["total"] = total
|
|
out["ppv_err"] = ppv_err
|
|
if all(v == "exact" for v in chan_status.values()):
|
|
out["status"] = "exact"
|
|
elif any(v == "length" for v in chan_status.values()):
|
|
out["status"] = "length-mismatch"
|
|
else:
|
|
out["status"] = "value-mismatch"
|
|
return out
|
|
|
|
|
|
def verify_histogram(binpath, csvpath, lsb):
|
|
out = {"file": os.path.basename(binpath), "status": "ok"}
|
|
try:
|
|
res = M.read_idf_file(binpath)
|
|
except NotImplementedError:
|
|
out["status"] = "not-thor"
|
|
return out
|
|
except Exception as exc: # noqa: BLE001
|
|
out["status"] = "decode-error"
|
|
out["detail"] = f"{type(exc).__name__}: {exc}"
|
|
return out
|
|
hdr, _rows = parse_export(csvpath)
|
|
out["n_intervals"] = len(res.intervals or [])
|
|
errs = {}
|
|
for ch, attr in (("Tran", "transverse_ips"), ("Vert", "vertical_ips"),
|
|
("Long", "longitudinal_ips")):
|
|
gp = hdr_float(hdr, f"{ch}PPV")
|
|
dv = getattr(res.event.peaks, attr, None)
|
|
if gp and dv:
|
|
errs[ch] = (dv - gp) / gp
|
|
out["ppv_err"] = errs
|
|
out["status"] = "peaks" if errs else "no-gt-peaks"
|
|
return out
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--root", default=DEFAULT_ROOT)
|
|
ap.add_argument("--lsb", type=float, default=M._GEO_LSB_IPS)
|
|
ap.add_argument("--limit", type=int, default=0)
|
|
ap.add_argument("--kind", choices=("idfw", "idfh", "both"), default="both")
|
|
ap.add_argument("--show", type=int, default=15, help="worst-N detail rows")
|
|
args = ap.parse_args()
|
|
|
|
pairs = index_corpus(args.root)
|
|
keys = sorted(pairs)
|
|
if args.kind != "both":
|
|
keys = [k for k in keys if k.endswith(args.kind.upper())]
|
|
if args.limit:
|
|
keys = keys[: args.limit]
|
|
|
|
print(f"root: {args.root}")
|
|
print(f"geo LSB under test: {args.lsb!r} in/s per count")
|
|
print(f"paired files: {len(keys)}\n")
|
|
|
|
wf, hg = [], []
|
|
for k in keys:
|
|
binpath, csvpath = pairs[k]
|
|
if k.endswith(".IDFW"):
|
|
wf.append(verify_waveform(binpath, csvpath, args.lsb))
|
|
else:
|
|
hg.append(verify_histogram(binpath, csvpath, args.lsb))
|
|
|
|
if wf:
|
|
st = Counter(r["status"] for r in wf)
|
|
ex = sum(r.get("exact", 0) for r in wf)
|
|
tot = sum(r.get("total", 0) for r in wf)
|
|
print("=" * 68)
|
|
print(f"WAVEFORM (IDFW): {len(wf)} files")
|
|
for s, n in st.most_common():
|
|
print(f" {s:16} {n:5d} ({100*n/len(wf):5.1f}%)")
|
|
if tot:
|
|
print(f" per-sample exact: {ex}/{tot} = {100*ex/tot:.3f}%")
|
|
errs = [e for r in wf for e in r.get("ppv_err", {}).values()]
|
|
if errs:
|
|
print(f" PPV rel-error: median {statistics.median(errs):+.4%} "
|
|
f"mean {statistics.mean(errs):+.4%} "
|
|
f"max|.| {max(abs(e) for e in errs):.4%}")
|
|
bad = [r for r in wf if r["status"] not in ("exact",)]
|
|
if bad:
|
|
print(f"\n worst {min(args.show, len(bad))} of {len(bad)} non-exact:")
|
|
for r in bad[: args.show]:
|
|
print(f" {r['file']:42} {r['status']:16} "
|
|
f"lens={r.get('lens')} gt={r.get('gt_len')} "
|
|
f"{r.get('detail','')}")
|
|
|
|
if hg:
|
|
st = Counter(r["status"] for r in hg)
|
|
print("=" * 68)
|
|
print(f"HISTOGRAM (IDFH): {len(hg)} files")
|
|
for s, n in st.most_common():
|
|
print(f" {s:16} {n:5d} ({100*n/len(hg):5.1f}%)")
|
|
errs = [e for r in hg for e in r.get("ppv_err", {}).values()]
|
|
if errs:
|
|
print(f" PPV rel-error: median {statistics.median(errs):+.4%} "
|
|
f"mean {statistics.mean(errs):+.4%} "
|
|
f"max|.| {max(abs(e) for e in errs):.4%}")
|
|
within = lambda t: 100*sum(1 for e in errs if abs(e) <= t)/len(errs)
|
|
print(f" within 0.5%: {within(0.005):.1f}% "
|
|
f"within 2%: {within(0.02):.1f}% within 5%: {within(0.05):.1f}%")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|