feat(series4): decode sensor self-check waveforms from the IDFW binary
The Thor/Micromate (series-4) IDFW binary carries the sensor self-check in its
fixed-header region (before the waveform body), as up to four records tagged
01 0e 3c/3d/3e/3f — the SAME channel ids as series-3 (Tran/Vert/Long/MicL).
Unlike series-3's delta-coded trailing block, series-4 stores each trace as a
raw int16-BE array after an 18-byte record header (2-byte sample count at
offset +8). Three-channel (mic-disabled) units carry only 3c/3d/3e.
New micromate/sensor_check.py: decode_idf_sensor_check(raw) locates the record
chain (id-ordered marker run, so a stray body match can't chain) and reads each
trace's int16 samples → {Tran,Vert,Long[,MicL]: [counts]}, or {} when absent.
Reverse-engineered + validated against 4 UM oracle events (added as fixtures):
clean geophone ring-downs on all, mic pulse trains on the 4-channel units,
correctly no MicL on the two 3-channel units. Validated by shape + cross-event
consistency (no Thor report strip to exact-match, unlike series-3's BW reports).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
r"""Decode the Thor / Micromate (series-4) sensor self-check waveforms from an
|
||||
IDFW event binary.
|
||||
|
||||
Reverse-engineered 2026-09-15 against 4 UM (Thor) oracle events. The IDFW
|
||||
binary carries the sensor self-check in its fixed-header region (before the
|
||||
waveform body), as up to four records tagged ``01 0e 3c/3d/3e/3f`` — the SAME
|
||||
channel ids as the series-3 MiniMate Plus (Tran / Vert / Long / MicL), which is
|
||||
the physical self-test:
|
||||
|
||||
* 3c / 3d / 3e = Tran / Vert / Long geophone ring-downs (a damped impulse
|
||||
response — resonant frequency + damping).
|
||||
* 3f = MicL pulse train (the mic's known-signal gain check). Absent
|
||||
on three-channel (mic-disabled) units.
|
||||
|
||||
Record framing (per record)::
|
||||
|
||||
01 0e [id:1] [flags:3] [count:2 BE] [pad:10] [int16-BE samples × count]
|
||||
\___ 18-byte header ___/
|
||||
|
||||
Unlike series-3's delta-coded trailing block, series-4 stores each trace as a
|
||||
raw int16 big-endian array. ``count`` (the 2-byte field at header offset +8)
|
||||
is the sample count; the record is padded to a fixed stride after that.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from typing import Dict, List
|
||||
|
||||
# Record id → channel. Same ids/order as series-3 (minimateplus.sensor_check).
|
||||
_ID_TO_CHANNEL = {0x3C: "Tran", 0x3D: "Vert", 0x3E: "Long", 0x3F: "MicL"}
|
||||
_CHAIN_IDS = (0x3C, 0x3D, 0x3E, 0x3F)
|
||||
|
||||
_MARKER = b"\x01\x0e" # precedes the 1-byte channel id
|
||||
_HEADER_LEN = 18 # bytes from the marker start to the first sample
|
||||
_COUNT_OFF = 8 # 2-byte BE sample count, from the marker start
|
||||
_MAX_COUNT = 4000 # sanity cap (traces are ~70-200 samples)
|
||||
|
||||
|
||||
def _find_chain(raw: bytes):
|
||||
"""Locate the sensor-check record chain. Returns a list of
|
||||
``(offset, id, count)`` for the first run of markers whose ids run
|
||||
3c, 3d, 3e[, 3f] in order, or ``[]``.
|
||||
|
||||
Records are padded to a fixed stride, so the next marker is not at
|
||||
``header + count*2``; instead collect every ``01 0e [id]`` marker with a
|
||||
sane count and take the first id-ordered run. Validating the id sequence
|
||||
(not a lone ``01 0e 3c``) keeps a stray marker in the waveform body from
|
||||
matching — the real chain sits in the fixed header, ahead of the body.
|
||||
"""
|
||||
n = len(raw)
|
||||
markers = []
|
||||
for p in range(n - _HEADER_LEN):
|
||||
if raw[p:p + 2] == _MARKER and raw[p + 2] in _ID_TO_CHANNEL:
|
||||
count = int.from_bytes(raw[p + _COUNT_OFF:p + _COUNT_OFF + 2], "big")
|
||||
if 0 < count <= _MAX_COUNT:
|
||||
markers.append((p, raw[p + 2], count))
|
||||
|
||||
for i, (off, rid, _c) in enumerate(markers):
|
||||
if rid != 0x3C:
|
||||
continue
|
||||
run = [markers[i]]
|
||||
for m in markers[i + 1:]:
|
||||
if len(run) < len(_CHAIN_IDS) and m[1] == _CHAIN_IDS[len(run)]:
|
||||
run.append(m)
|
||||
else:
|
||||
break
|
||||
if len(run) >= 3: # 3-channel (mic-disabled) units are valid
|
||||
return run
|
||||
return []
|
||||
|
||||
|
||||
def decode_idf_sensor_check(raw: bytes) -> Dict[str, List[int]]:
|
||||
"""Decode the sensor self-check traces from a Thor/Micromate IDFW binary.
|
||||
|
||||
Returns ``{"Tran": [...], "Vert": [...], "Long": [...], "MicL": [...]}`` in
|
||||
raw int16 ADC counts (MicL omitted on 3-channel units), or ``{}`` if the
|
||||
binary carries no sensor-check chain (a non-IDF file, or an IDFH histogram).
|
||||
"""
|
||||
chain = _find_chain(raw)
|
||||
if not chain:
|
||||
return {}
|
||||
out: Dict[str, List[int]] = {}
|
||||
for off, rid, count in chain:
|
||||
start = off + _HEADER_LEN
|
||||
blob = raw[start:start + count * 2]
|
||||
if len(blob) < count * 2:
|
||||
continue
|
||||
out[_ID_TO_CHANNEL[rid]] = list(struct.unpack(">%dh" % count, blob))
|
||||
return out
|
||||
Reference in New Issue
Block a user