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
90 lines
3.6 KiB
Python
90 lines
3.6 KiB
Python
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
|