"""Series-4 (Thor / Micromate IDFW) sensor self-check waveform decode. Reverse-engineered 2026-09-15 against 4 UM (Thor) oracle events. The IDFW binary carries the sensor self-check in its fixed-header region (before the waveform body) as up to four records tagged ``01 0e 3c/3d/3e/3f`` — the SAME channel ids as series-3 (Tran/Vert/Long/MicL). Unlike series-3's delta-coded trailing block, series-4 stores each trace as a raw int16-BE array after an 18-byte record header whose sample count is a 2-byte field at offset +8. Three-channel (mic-disabled) Thor units carry only 3c/3d/3e — no MicL record. Validated by shape (geophone ring-down / mic pulse train) and cross-event consistency, since there's no Thor Event-Report strip to exact-match against. """ from pathlib import Path import numpy as np from micromate.sensor_check import decode_idf_sensor_check FIXDIR = Path(__file__).parent / "fixtures" / "thor-idf-sc" EVENTS = sorted(p.name for p in FIXDIR.glob("*.IDFW")) def _decode(name): return decode_idf_sensor_check((FIXDIR / name).read_bytes()) def test_geo_channels_present_and_ringdown_shaped(): # Every IDFW event has the three geophone self-checks; each is a large # one-sided deflection (~15000 raw counts) that rings back — the geophone's # damped impulse response. for name in EVENTS: sc = _decode(name) for ch in ("Tran", "Vert", "Long"): assert ch in sc, f"{name} missing {ch}" tr = np.asarray(sc[ch], dtype=float) tr = tr - tr[:4].mean() # reference to the pre-trigger baseline assert 40 <= len(tr) <= 300, f"{name}:{ch} n={len(tr)}" assert tr.min() < -8000, f"{name}:{ch} min {tr.min()}" # deflects one way and rings back toward / past the baseline assert tr.max() < abs(tr.min()), f"{name}:{ch} not one-sided" def test_mic_present_only_on_four_channel_units(): # UM11719 / UM12947 record a mic; UM13981 / UM20147 are 3-channel # (mic-disabled) units and carry no MicL self-check. got = {name: ("MicL" in _decode(name)) for name in EVENTS} assert any(got.values()), "expected at least one 4-channel unit" assert not all(got.values()), "expected at least one 3-channel unit" for name, has_mic in got.items(): if has_mic: tr = np.asarray(_decode(name)["MicL"], dtype=float) tr = tr - tr[:4].mean() # mic self-check is a bipolar pulse train — swings both ways, wide range assert tr.max() > 5000 and tr.min() < -5000, f"{name} mic not bipolar" def test_channel_ids_and_order(): # ids decode to the canonical channel names, geo always in Tran/Vert/Long order sc = _decode(EVENTS[0]) assert [c for c in ("Tran", "Vert", "Long") if c in sc] == ["Tran", "Vert", "Long"] def test_returns_empty_on_non_idf_input(): assert decode_idf_sensor_check(b"not an IDF file") == {} assert decode_idf_sensor_check(b"") == {}