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:
2026-08-25 08:11:11 +00:00
co-authored by Claude Opus 5
parent 37043a47e9
commit 686ab6e7a6
10 changed files with 645 additions and 68 deletions
+135 -2
View File
@@ -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 [])