fix(codec): the waveform body is a record chain, not a tag stream
Supersedes the segment-header model entirely, including the fixes made earlier today. Found via multi-agent structural analysis of the 25 files that stalled the walker, then verified independently. Records are self-delimiting: off+2 is a uint16 BE length, next_record = off + 2 + len, and the chain ends on a record whose chan_id is 0x06. off+8 carries a 3-valued mode enum: 02 00 14-byte header, 2 anchors, then CUMULATIVE delta blocks 01 00 10-byte header, no anchors, blocks are ABSOLUTE values 00 03 10-byte header, NO TAGS AT ALL - raw 12-bit packed absolute `40 NN` is an ordinary int16 BE data block (2*NN + 2), never a header. Reading it as a 2*NN + 16 header is what made walks drift — the "variable-prefix segment descriptors" reported earlier today were not a format feature, just walker drift of exactly 4 - (old_stop - true_record_start), on all 25 affected files. Measured on the production snapshot: all four channels equal length 156/1388 -> 1388/1388 ASCII sample-count exact 72/75 -> 75/75 ASCII fully exact 70/75 -> 73/75 device PPV waveform (live) 1288/1306 -> 1306/1306 (mean err 0.00000) device PPV histogram (live) 4434/4459 -> 4458/4459 Also eliminates the walker-over-read class: 24 of those 35 files were histograms that read_blastware_file fed to the waveform codec first; the old walker accepted them and returned garbage (one yielded 98,923 "intervals"), while the record-chain decoder returns None so they fall through to histogram_codec. 00 03 records are DECODED, not skipped. Skipping them silently shifts the time base of everything after them on that channel — BE9558/ K558LOF2.820W had MicL displaced by exactly 512 samples with nothing marking the gap. Footer detection now prefers the 0e 08 candidate whose body yields a chain terminating on 0x06; the signature can occur inside a sample stream. Blast radius 1 file of 1388. The superseded model survives as decode_waveform_legacy, pinned by micromate/idf_file.py: its Thor IDFW body-offset search trial-decodes candidates and keeps whichever yields the most samples, so the new decoder returning None where the old returned garbage changes that heuristic's winner. Deferred until that search uses the record chain. Tests: 253 passed (+11), failure list unchanged from baseline. The 9 tests pinning the superseded model are retargeted at decode_waveform_legacy, which still implements it. NOTE: stored .h5 files need regenerating — nearly all get longer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgTe8CamXAHcAmaQ6QNcog
This commit is contained in:
@@ -14,6 +14,7 @@ import pytest
|
||||
|
||||
from minimateplus.waveform_codec import (
|
||||
WaveformBlock,
|
||||
decode_waveform_legacy,
|
||||
decode_tran_initial,
|
||||
decode_waveform_v2,
|
||||
decoded_to_adc_counts,
|
||||
@@ -548,9 +549,18 @@ def test_walk_body_wide_rle_block():
|
||||
assert blocks[0].length == 2
|
||||
|
||||
|
||||
# NOTE (2026-08-25): the four tests below assert the SUPERSEDED tag-dispatch
|
||||
# model — `40 NN` as a variable-width segment header, tagless headers, channel
|
||||
# from rotation. The body format is really a chain of self-delimiting records
|
||||
# (see the record-chain section of waveform_codec.py), so `decode_waveform_v2`
|
||||
# no longer behaves this way. They are retargeted at `decode_waveform_legacy`,
|
||||
# which still implements the old model and is pinned by micromate/idf_file.py
|
||||
# for Thor IDFW bodies.
|
||||
|
||||
|
||||
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"))
|
||||
decoded = decode_waveform_legacy(_synth(b"\x01\x0c"))
|
||||
# 2 preamble anchors + 268 repeats
|
||||
assert len(decoded["Tran"]) == 2 + 268
|
||||
assert set(decoded["Tran"]) == {0}
|
||||
@@ -589,7 +599,7 @@ def test_segment_header_anchors_track_header_width(nn, hdr_len):
|
||||
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)))
|
||||
decoded = decode_waveform_legacy(_synth(bytes([0x40, nn]) + bytes(data)))
|
||||
assert decoded["Vert"][:2] == [7, 9]
|
||||
|
||||
|
||||
@@ -629,7 +639,7 @@ def test_tagless_header_carries_full_14_bytes_as_data():
|
||||
|
||||
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)))
|
||||
decoded = decode_waveform_legacy(_synth(_tagless(chan_id=0x48, a0=11, a1=13)))
|
||||
assert decoded["Long"][:2] == [11, 13] # 0x48 → Long, not rotation
|
||||
assert decoded["Vert"] == []
|
||||
|
||||
@@ -642,10 +652,146 @@ def test_segment_channel_comes_from_id_not_rotation(chan_id, name):
|
||||
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)
|
||||
decoded = decode_waveform_legacy(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 [])
|
||||
|
||||
|
||||
# ── Record-chain body model (2026-08-25) ────────────────────────────────────
|
||||
#
|
||||
# The body is a chain of self-delimiting per-channel records, not a flat
|
||||
# tag-dispatch stream. Verified over 1,388 production series-3 waveform
|
||||
# binaries: the chain terminates on a 0x06 record in 1,387 of them and all
|
||||
# four channels come out at identical length in 1,388/1,388 (was 156/1,388).
|
||||
# Against the 75 events with a preserved Blastware ASCII export: sample-count
|
||||
# exact 72/75 -> 75/75, fully exact 70/75 -> 73/75.
|
||||
|
||||
from minimateplus.waveform_codec import ( # noqa: E402
|
||||
CHANNEL_IDS,
|
||||
MODE_ABSOLUTE,
|
||||
MODE_DELTA,
|
||||
MODE_RAW12,
|
||||
STREAM_END_ID,
|
||||
data_block_len,
|
||||
find_first_record,
|
||||
is_record,
|
||||
unpack12,
|
||||
walk_records,
|
||||
)
|
||||
|
||||
|
||||
def _rec(chan_id, mode, payload, seg=0, field2=b"\x00\x00", anchors=None):
|
||||
"""Build one self-delimiting record."""
|
||||
head = bytearray()
|
||||
head += bytes([chan_id, 0x00, 0x00, seg])
|
||||
head += bytes(mode)
|
||||
if anchors is not None:
|
||||
for a in anchors:
|
||||
head += int(a).to_bytes(2, "big", signed=True)
|
||||
body = bytes(head) + payload
|
||||
return field2 + (len(body) + 2).to_bytes(2, "big") + body
|
||||
|
||||
|
||||
def _terminator():
|
||||
return b"\x00\x00" + (8).to_bytes(2, "big") + bytes([STREAM_END_ID, 0, 0, 0, 0, 0])
|
||||
|
||||
|
||||
def _body(*records, preamble=b"\x00\x02\x00", seg0=b"\x00\x00\x00\x00"):
|
||||
return preamble + seg0 + b"".join(records) + _terminator()
|
||||
|
||||
|
||||
def test_forty_nn_is_a_data_block_not_a_segment_header():
|
||||
"""`40 NN` is an int16 BE data block of length 2*NN + 2.
|
||||
|
||||
The superseded model read it as a segment header of length 2*NN + 16,
|
||||
which is what made walks drift and channels come out unequal.
|
||||
"""
|
||||
assert data_block_len(b"\x40\x02\x00\x01\x00\x02", 0) == (6, 2)
|
||||
assert data_block_len(b"\x40\x08" + bytes(16), 0) == (18, 8)
|
||||
# NN > 8 is not a data block
|
||||
assert data_block_len(b"\x40\x0c" + bytes(24), 0) == (None, None)
|
||||
|
||||
|
||||
def test_record_chain_is_followed_by_length_not_by_tag_sniffing():
|
||||
payload = b"\x00\x04" # RLE hold x4
|
||||
body = _body(_rec(0x47, MODE_DELTA, payload, anchors=(3, 5)))
|
||||
recs = walk_records(body)
|
||||
assert len(recs) == 1
|
||||
assert recs[0]["channel"] == "Vert"
|
||||
assert recs[0]["mode"] == MODE_DELTA
|
||||
|
||||
|
||||
def test_chain_terminates_on_channel_id_06():
|
||||
body = _body(_rec(0x47, MODE_DELTA, b"\x00\x04", anchors=(1, 1)),
|
||||
_rec(0x48, MODE_DELTA, b"\x00\x04", anchors=(2, 2)))
|
||||
assert [r["channel"] for r in walk_records(body)] == ["Vert", "Long"]
|
||||
assert not is_record(body, len(body) - 10) # the terminator is not a record
|
||||
|
||||
|
||||
def test_mode_delta_emits_anchors_then_accumulates():
|
||||
# two anchors, then an int8 block of +1,+1,+1,+1
|
||||
body = _body(_rec(0x47, MODE_DELTA, b"\x20\x04\x01\x01\x01\x01",
|
||||
anchors=(10, 11)))
|
||||
d = decode_waveform_v2(body)
|
||||
assert d["Vert"] == [10, 11, 12, 13, 14, 15]
|
||||
|
||||
|
||||
def test_mode_absolute_replaces_rather_than_accumulates():
|
||||
"""mode `01 00`: no anchors, and block values are ABSOLUTE samples."""
|
||||
body = _body(_rec(0x48, MODE_ABSOLUTE, b"\x20\x04\x0a\x0b\x0c\x0d"))
|
||||
d = decode_waveform_v2(body)
|
||||
assert d["Long"] == [10, 11, 12, 13], "01 00 blocks are absolute, not deltas"
|
||||
|
||||
|
||||
def test_mode_absolute_rle_holds_the_previous_value():
|
||||
body = _body(_rec(0x48, MODE_ABSOLUTE, b"\x20\x04\x07\x07\x07\x07\x00\x04"))
|
||||
d = decode_waveform_v2(body)
|
||||
assert d["Long"] == [7, 7, 7, 7, 7, 7, 7, 7]
|
||||
|
||||
|
||||
def test_mode_raw12_has_no_tags_at_all():
|
||||
"""mode `00 03`: the whole data section is raw 12-bit absolute samples.
|
||||
|
||||
Decoding these matters for the time base — skipping the record would
|
||||
displace every later sample on that channel (observed on
|
||||
BE9558/K558LOF2.820W, MicL shifted by exactly 512).
|
||||
"""
|
||||
packed = bytes([0x01, 0x23, 0x04, 0x05, 0x06, 0x07]) # 4 samples
|
||||
body = _body(_rec(0x49, MODE_RAW12, packed))
|
||||
d = decode_waveform_v2(body)
|
||||
assert d["MicL"] == unpack12(packed)
|
||||
assert len(d["MicL"]) == 4
|
||||
|
||||
|
||||
def test_unpack12_sign_extends():
|
||||
assert unpack12(bytes([0x00, 0x00, 0x01, 0x02, 0x03, 0x04])) == [1, 2, 3, 4]
|
||||
# high nibble 0x8 -> negative
|
||||
assert unpack12(bytes([0x80, 0x00, 0x00, 0x00, 0x00, 0x00]))[0] == -2048
|
||||
|
||||
|
||||
def test_channel_comes_from_the_record_id():
|
||||
for cid, name in CHANNEL_IDS.items():
|
||||
body = _body(_rec(cid, MODE_ABSOLUTE, b"\x20\x04\x01\x02\x03\x04"))
|
||||
d = decode_waveform_v2(body)
|
||||
assert d[name][-4:] == [1, 2, 3, 4], f"{name} misrouted"
|
||||
|
||||
|
||||
def test_raw12_preamble_is_scanned_not_block_walked():
|
||||
"""A `00 00 03` preamble carries raw 12-bit data from body[3] with no tags,
|
||||
so find_first_record must scan rather than block-walk. One production file
|
||||
has this (BE13121/O121L4L1.KF0W); block-walking returns None on it."""
|
||||
packed = bytes([0x00, 0x00, 0x01, 0x02, 0x03, 0x04])
|
||||
body = _body(_rec(0x47, MODE_DELTA, b"\x00\x04", anchors=(1, 1)),
|
||||
preamble=b"\x00\x00\x03", seg0=packed)
|
||||
assert find_first_record(body) == 3 + len(packed)
|
||||
d = decode_waveform_v2(body)
|
||||
assert d["Tran"] == unpack12(packed)
|
||||
|
||||
|
||||
def test_returns_none_when_no_record_chain():
|
||||
assert decode_waveform_v2(b"\x00\x02\x00" + bytes(40)) is None
|
||||
assert decode_waveform_v2(b"") is None
|
||||
|
||||
Reference in New Issue
Block a user