fix(codec): geo full scale is 32000 counts; 4 walker framing cases; channel-id from header
Two independent bugs, both found by diffing 75 production events against
their preserved Blastware ASCII exports (<store>/<serial>/<file>_ASCII.TXT).
1. Geo full scale was wrong — every geophone reading was 2.34% low.
The codec emits geo samples in 16-count units with a documented LSB of
exactly 0.005 in/s, and decoded_to_adc_counts multiplies by 16, so one
ADC count is 0.005/16 in/s and 10.000 in/s is 10.0/(0.005/16) = 32000
counts. sfm/event_hdf5.py and minimateplus/event_file_io.py both
divided by 32768 (2^15), scaling every sample and derived peak down by
1 - 32000/32768. The error scales with amplitude, so it was invisible
on quiet events and worst on the loud ones that matter for compliance.
Mic is unaffected (it back-solves its scale from the device peak).
216 per-channel comparisons: 32768 -> 151/216 exact, worst error 0.238
in/s on a 10 in/s event; 32000 -> 216/216 exact, worst 0.005 = 1 LSB.
2. walk_body silently truncated channels on four unhandled framing cases.
An unrecognised tag ends the walk and decode_waveform_v2 returns
whatever it got, so this surfaced as short channels, never an error:
- wide-NN RLE `0X NN` (runs longer than 252 samples)
- `30 NN` with NN > 0x10 (the old cap was arbitrary)
- variable-width `40 NN` headers: NN counts previous-channel
continuation deltas, so the header is 2*NN + 16 bytes; `40 01`
and `40 03` occur alongside `40 02`
- tagless segment headers: no `40 NN` tag at all, just the 14-byte
tail [field2:2][len:2][channel_id:4][marker:2][anchors:4]
Also: the header field documented as a "monotonic uint32 LE counter" is
really [channel_id][00][00][segment_index], with 0x46=Tran 0x47=Vert
0x48=Long 0x49=MicL — verified on 1697/1697 segment headers, zero
disagreements. decode_waveform_v2 now takes the channel from that field
instead of rotation position, which was fragile: one missed header
desynced every channel after it.
parse_segment_header now returns n_prev_deltas/prev_deltas/marker/
anchors/channel/segment_index; the old fixed_pattern (02 00 00 01)
conflated the 2-byte marker with the first anchor.
Ground-truth corpus, end to end through the production path:
exact 37 -> 72, truncated 23 -> 3, full-length value errors 15 -> 0.
Store-wide, 729 of 1388 series-3 waveform events decode differently and
728 gain samples; the scale fix changes float values on all of them, so
stored .h5 files need regenerating.
Still open: 3 events truncate at a header variant with a variable-width
prefix (2/4/6 bytes) before the channel id and an `01 00` marker.
Documented in docs/instantel_protocol_reference.md with byte offsets.
+20 tests. No regressions: the byte-exact fixture suite still passes and
the full-suite failure list is unchanged from baseline (16 pre-existing
failures from gitignored fixtures).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgTe8CamXAHcAmaQ6QNcog
This commit is contained in:
@@ -210,9 +210,11 @@ def test_parse_segment_header_decodes_fields():
|
||||
)
|
||||
decoded = parse_segment_header(block)
|
||||
assert decoded is not None
|
||||
assert decoded["n_prev_deltas"] == 2
|
||||
assert decoded["prev_deltas"] == [0, 0]
|
||||
assert decoded["counter"] == 0x47 # uint32 LE
|
||||
assert decoded["fixed_pattern"] == b"\x02\x00\x00\x01"
|
||||
assert decoded["anchor_bytes"] == b"\x00\x00\x00\x00"
|
||||
assert decoded["marker"] == b"\x02\x00"
|
||||
assert decoded["anchors"] == [1, 1]
|
||||
|
||||
|
||||
def test_segment_counter_increments():
|
||||
@@ -516,3 +518,134 @@ def test_decode_a5_frames_empty():
|
||||
from minimateplus.waveform_codec import decode_a5_frames
|
||||
assert decode_a5_frames([]) is None
|
||||
assert decode_a5_frames(None) is None
|
||||
|
||||
|
||||
# ── Wide-NN RLE, wide 30 NN, and variable-width segment headers ──────────────
|
||||
#
|
||||
# Three framing cases discovered 2026-08-25 by diffing 75 production events
|
||||
# against their preserved Blastware ASCII exports. Each caused ``walk_body``
|
||||
# to hit its ``else: break`` mid-stream, truncating every channel decoded
|
||||
# after that point (see CHANGELOG v0.25.1).
|
||||
|
||||
_PREAMBLE = b"\x00\x02\x00\x00\x00\x00\x00" # magic + Tran[0]=0, Tran[1]=0
|
||||
_STOP = b"\xff\xff" # unrecognised tag → walker stops
|
||||
|
||||
|
||||
def _synth(*chunks: bytes) -> bytes:
|
||||
return _PREAMBLE + b"".join(chunks) + _STOP
|
||||
|
||||
|
||||
def test_walk_body_wide_rle_block():
|
||||
"""``0X NN`` is a 12-bit-NN RLE run (NN = ((t0 & 0x0F) << 8) | t1).
|
||||
|
||||
Observed as ``01 0c`` (NN=268) in BE9558/K558LKOF.460W and five other
|
||||
production events. A narrow ``00 NN`` maxes out at NN=0xFC, so runs
|
||||
longer than 252 samples must use the wide form.
|
||||
"""
|
||||
blocks = walk_body(_synth(b"\x01\x0c"))
|
||||
assert len(blocks) == 1
|
||||
assert (blocks[0].tag_hi, blocks[0].tag_lo) == (0x01, 0x0C)
|
||||
assert blocks[0].length == 2
|
||||
|
||||
|
||||
def test_decode_wide_rle_repeats_full_run():
|
||||
"""A wide RLE run repeats the running value NN times, not NN & 0xFF."""
|
||||
decoded = decode_waveform_v2(_synth(b"\x01\x0c"))
|
||||
# 2 preamble anchors + 268 repeats
|
||||
assert len(decoded["Tran"]) == 2 + 268
|
||||
assert set(decoded["Tran"]) == {0}
|
||||
|
||||
|
||||
def test_walk_body_30_block_nn_above_16():
|
||||
"""``30 NN`` data blocks are not capped at NN=0x10.
|
||||
|
||||
``30 18`` (NN=24) appears in BE18193/T193LQ45.NN0W; the old
|
||||
``0 < t1 <= 0x10`` guard rejected it and stopped the walk 1033 bytes
|
||||
into a 4877-byte body. Length is still NN * 1.5 + 2.
|
||||
"""
|
||||
payload = bytes(36) # 24 deltas × 1.5 bytes
|
||||
blocks = walk_body(_synth(b"\x30\x18" + payload, b"\x00\x04"))
|
||||
assert [b.length for b in blocks] == [38, 2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nn,hdr_len", [(1, 18), (2, 20), (3, 22)])
|
||||
def test_walk_body_segment_header_width_follows_tag_lo(nn, hdr_len):
|
||||
"""``40 NN``: NN is the count of previous-channel continuation deltas.
|
||||
|
||||
Header length = 2 * NN + 16. ``40 02`` (the only form previously
|
||||
handled) is the NN=2 case at 20 bytes; ``40 01`` (18) and ``40 03``
|
||||
(22) both occur in production files.
|
||||
"""
|
||||
data = bytearray(hdr_len - 2)
|
||||
data[2 * nn + 8 : 2 * nn + 10] = b"\x02\x00" # constant marker
|
||||
blocks = walk_body(_synth(bytes([0x40, nn]) + bytes(data), b"\x00\x04"))
|
||||
assert [b.length for b in blocks] == [hdr_len, 2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nn,hdr_len", [(1, 18), (2, 20), (3, 22)])
|
||||
def test_segment_header_anchors_track_header_width(nn, hdr_len):
|
||||
"""Anchor pair sits at data[2*NN+10 : 2*NN+14] regardless of width."""
|
||||
data = bytearray(hdr_len - 2)
|
||||
data[2 * nn + 8 : 2 * nn + 10] = b"\x02\x00"
|
||||
data[2 * nn + 10 : 2 * nn + 12] = (7).to_bytes(2, "big") # anchor 0
|
||||
data[2 * nn + 12 : 2 * nn + 14] = (9).to_bytes(2, "big") # anchor 1
|
||||
decoded = decode_waveform_v2(_synth(bytes([0x40, nn]) + bytes(data)))
|
||||
assert decoded["Vert"][:2] == [7, 9]
|
||||
|
||||
|
||||
# ── Tagless segment headers ─────────────────────────────────────────────────
|
||||
#
|
||||
# A segment header can appear WITHOUT its ``40 NN`` tag: just the 14-byte tail
|
||||
# ``[field2:2][len:2][channel_id:4][marker:2][anchors:4]``. This is the NN=0
|
||||
# case — no continuation deltas for the previous channel, so no tag and no
|
||||
# delta bytes. Found 2026-08-25: it is where the walk stopped in 7 of the 8
|
||||
# remaining truncating production events.
|
||||
#
|
||||
# The channel_id field (previously mis-labelled a "monotonic counter") is
|
||||
# ``[channel][00][00][segment_index]`` with 0x46=Tran 0x47=Vert 0x48=Long
|
||||
# 0x49=MicL — verified on 1697 of 1697 segment headers across the ground-truth
|
||||
# corpus, zero disagreements.
|
||||
|
||||
def _tagless(chan_id=0x47, seg=2, marker=b"\x02\x00", a0=0, a1=0):
|
||||
return (b"\x5d\xee" + b"\x00\xd0" + bytes([chan_id, 0, 0, seg]) + marker
|
||||
+ a0.to_bytes(2, "big", signed=True) + a1.to_bytes(2, "big", signed=True))
|
||||
|
||||
|
||||
def test_walk_body_accepts_tagless_segment_header():
|
||||
"""A bare 14-byte header is walked as a segment block, not a stop."""
|
||||
blocks = walk_body(_synth(b"\x10\x04\x00\x00", _tagless(), b"\x00\x04"))
|
||||
kinds = [(b.tag_hi, b.tag_lo, b.length) for b in blocks]
|
||||
assert kinds == [(0x10, 0x04, 4), (0x40, 0x00, 14), (0x00, 0x04, 2)]
|
||||
|
||||
|
||||
def test_tagless_header_carries_full_14_bytes_as_data():
|
||||
"""The synthetic block's data includes the leading bytes (there is no tag
|
||||
to strip), so decode_waveform_v2's ``2*nd + k`` offsets line up at nd=0."""
|
||||
blocks = walk_body(_synth(_tagless()))
|
||||
hdr = next(b for b in blocks if b.tag_hi == 0x40)
|
||||
assert len(hdr.data) == 14
|
||||
assert hdr.data[8:10] == b"\x02\x00" # marker at 2*0 + 8
|
||||
|
||||
|
||||
def test_tagless_header_anchors_and_channel_id():
|
||||
"""Anchors decode from data[10:14]; the channel comes from the id byte."""
|
||||
decoded = decode_waveform_v2(_synth(_tagless(chan_id=0x48, a0=11, a1=13)))
|
||||
assert decoded["Long"][:2] == [11, 13] # 0x48 → Long, not rotation
|
||||
assert decoded["Vert"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chan_id,name",
|
||||
[(0x46, "Tran"), (0x47, "Vert"), (0x48, "Long"), (0x49, "MicL")])
|
||||
def test_segment_channel_comes_from_id_not_rotation(chan_id, name):
|
||||
"""Channel is taken from the header's id byte. Two headers in a row for
|
||||
the SAME channel must both land on that channel — rotation-by-position
|
||||
would put the second one on the next channel and corrupt both."""
|
||||
body = _synth(_tagless(chan_id=chan_id, seg=1, a0=5, a1=6),
|
||||
_tagless(chan_id=chan_id, seg=2, a0=7, a1=8))
|
||||
decoded = decode_waveform_v2(body)
|
||||
# Tran additionally carries the body preamble's 2 anchors (both 0 here).
|
||||
expected = [0, 0, 5, 6, 7, 8] if name == "Tran" else [5, 6, 7, 8]
|
||||
assert decoded[name] == expected
|
||||
for other in ("Tran", "Vert", "Long", "MicL"):
|
||||
if other != name:
|
||||
assert decoded[other] == ([0, 0] if other == "Tran" else [])
|
||||
|
||||
Reference in New Issue
Block a user