feat(histogram): decode multi-interval blocks — recovers 415 files
Sub-minute histogram intervals are packed several to a block so that
every block still covers exactly one minute of data:
interval intervals/block stride
1 minute 1 32 <- the standard big-endian block
15 s 4 92
2 s 30 612
stride = 12 + n * 20
Block = [00][segment][ctr uint16 LE][0a][00], then n x 20-byte records of
8 x uint16 LITTLE-endian values (T_peak, T_halfp, V_peak, V_halfp,
L_peak, L_halfp, M_peak, M_halfp) plus a 2-word tail whose first word is
0000 on every real interval, then a 6-byte block trailer.
The standard 32-byte block is BIG-endian; this variant is LITTLE-endian.
The tail-word check matters: a session ending mid-block leaves buffer
garbage in the remaining interval slots, which decoded as peaks
thousands of times the real value. Stride detection also requires at
least 2 records, since a 1-record block would have stride 32 and
collide with the standard block.
Recovers 415 files that decoded to nothing: 216 on BE18193 (2 s
intervals) and 199 on BE9440 (15 s). Before decoding to nothing they
were being accepted by the WAVEFORM codec, which returned garbage
peaking up to 400x the device-reported PPV.
Ground truth BE9440/K440L3AQ.T70H (5,710 intervals) matches its
Blastware ASCII export exactly: 17,130/17,130 geo peaks, 22,840/22,840
frequencies, 5,710/5,710 mic dB(L). Across all 455 affected files,
1,354/1,365 channel peaks (99.2%) match the device-reported PPV; the 11
that don't are under-reads on BE9440 where the walk stops early.
Fixture (binary + ASCII) saved under tests/fixtures/, which is
gitignored per repo practice — the ground-truth test skips when absent.
Tests: 258 passed, failure list unchanged from baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgTe8CamXAHcAmaQ6QNcog
This commit is contained in:
@@ -263,7 +263,7 @@ def decode_histogram_body(body: bytes) -> Optional[dict]:
|
||||
to get 1-count ADC values, then ``count / 32767 * 10.0`` for in/s)
|
||||
- Mic channel: use ``waveform_codec.mic_count_to_db(count)``
|
||||
"""
|
||||
records = walk_body(body)
|
||||
records = walk_body(body) or walk_multi_interval_blocks(body)
|
||||
if not records:
|
||||
return None
|
||||
return {
|
||||
@@ -285,7 +285,7 @@ def decode_histogram_body_full(body: bytes) -> Optional[List[dict]]:
|
||||
|
||||
Returns ``None`` if the body has no valid blocks.
|
||||
"""
|
||||
records = walk_body(body)
|
||||
records = walk_body(body) or walk_multi_interval_blocks(body)
|
||||
return records if records else None
|
||||
|
||||
|
||||
@@ -305,3 +305,118 @@ def half_period_to_hz(halfp: int) -> Optional[float]:
|
||||
def geo_count_to_ins(count: int) -> float:
|
||||
"""Convert a histogram geo peak count to in/s at Normal range."""
|
||||
return count * _GEO_LSB_INS
|
||||
|
||||
|
||||
# ── Multi-interval block variant (CONFIRMED 2026-08-26) ─────────────────────
|
||||
#
|
||||
# When the histogram interval is SHORTER than one minute, the device packs
|
||||
# several intervals into a single block so that every block still covers
|
||||
# exactly one minute of data:
|
||||
#
|
||||
# interval size intervals/block stride
|
||||
# 1 minute 1 32 <- the standard block above
|
||||
# 15 seconds 4 92
|
||||
# 2 seconds 30 612
|
||||
#
|
||||
# stride = 12 + n_intervals * 20
|
||||
#
|
||||
# Block layout:
|
||||
# [0] 0x00
|
||||
# [1] segment_id (256 blocks per segment, same as the standard block)
|
||||
# [2:4] block_ctr uint16 LE (0x0100.., resets each segment)
|
||||
# [4] 0x0a marker
|
||||
# [5] 0x00
|
||||
# [6 ...] n x 20-byte interval records, each carrying 8 x uint16
|
||||
# LITTLE-endian values:
|
||||
# T_peak, T_halfperiod, V_peak, V_halfperiod,
|
||||
# L_peak, L_halfperiod, M_peak, M_halfperiod
|
||||
# then 2 more words; the first is 0x0000 on every real interval.
|
||||
# [-6:] 6-byte block trailer
|
||||
#
|
||||
# ⚠ ENDIANNESS: the standard 32-byte block is BIG-endian. This variant is
|
||||
# LITTLE-endian. Do not share the accessor.
|
||||
#
|
||||
# These files previously decoded to nothing at all — 415 of them in the
|
||||
# production snapshot, 216 on BE18193 (2 s intervals) and 199 on BE9440
|
||||
# (15 s). Before that they were being accepted by the WAVEFORM codec, which
|
||||
# returned garbage peaking up to 400x the device-reported PPV.
|
||||
#
|
||||
# Ground truth: BE9440/K440L3AQ.T70H (15 s intervals, 5,710 of them) decodes
|
||||
# against its Blastware ASCII export with 17,130/17,130 geo peak counts,
|
||||
# 22,840/22,840 frequencies and 5,710/5,710 mic dB(L) values matching exactly.
|
||||
|
||||
_MULTI_HEADER_LEN = 6
|
||||
_MULTI_RECORD_LEN = 20
|
||||
_MULTI_TRAILER_LEN = 6
|
||||
# At least 2 records: a 1-record block would have stride 12 + 20 = 32, which
|
||||
# collides with the standard big-endian block and mis-decodes it.
|
||||
_MULTI_MIN_RECORDS = 2
|
||||
_MULTI_MAX_RECORDS = 64
|
||||
|
||||
|
||||
def _is_multi_header(body: bytes, off: int) -> bool:
|
||||
return (off + _MULTI_HEADER_LEN <= len(body)
|
||||
and body[off] == 0x00
|
||||
and body[off + 4] == 0x0A
|
||||
and body[off + 5] == 0x00)
|
||||
|
||||
|
||||
def detect_multi_interval_stride(body: bytes) -> Optional[int]:
|
||||
"""Block stride of a multi-interval histogram body, or None.
|
||||
|
||||
Found by locating the second block header; validated against
|
||||
``stride = 12 + n * 20`` and confirmed on a third block where present.
|
||||
"""
|
||||
if not _is_multi_header(body, 0):
|
||||
return None
|
||||
lo = _MULTI_HEADER_LEN + _MULTI_TRAILER_LEN + _MULTI_RECORD_LEN * _MULTI_MIN_RECORDS
|
||||
hi = _MULTI_HEADER_LEN + _MULTI_TRAILER_LEN + _MULTI_RECORD_LEN * _MULTI_MAX_RECORDS
|
||||
for stride in range(lo, min(hi, len(body)) + 1, 2):
|
||||
if (stride - 12) % _MULTI_RECORD_LEN:
|
||||
continue
|
||||
if not _is_multi_header(body, stride):
|
||||
continue
|
||||
# confirm on a third block when the body is long enough
|
||||
if 2 * stride + _MULTI_HEADER_LEN <= len(body) and not _is_multi_header(body, 2 * stride):
|
||||
continue
|
||||
return stride
|
||||
return None
|
||||
|
||||
|
||||
def walk_multi_interval_blocks(body: bytes,
|
||||
stride: Optional[int] = None) -> List[dict]:
|
||||
"""Decode a multi-interval histogram body into per-interval records."""
|
||||
if stride is None:
|
||||
stride = detect_multi_interval_stride(body)
|
||||
if not stride:
|
||||
return []
|
||||
n_per_block = (stride - _MULTI_HEADER_LEN - _MULTI_TRAILER_LEN) // _MULTI_RECORD_LEN
|
||||
if n_per_block < 1:
|
||||
return []
|
||||
|
||||
def u16le(p: int) -> int:
|
||||
return body[p] | (body[p + 1] << 8)
|
||||
|
||||
out: List[dict] = []
|
||||
for off in range(0, len(body) - stride + 1, stride):
|
||||
if not _is_multi_header(body, off):
|
||||
break # end of the block run; trailer follows
|
||||
for k in range(n_per_block):
|
||||
q = off + _MULTI_HEADER_LEN + _MULTI_RECORD_LEN * k
|
||||
# The first word of each record's 2-word tail is 0x0000 on every
|
||||
# real interval. A session ending mid-block leaves the remaining
|
||||
# slots filled with whatever was in the buffer; emitting those
|
||||
# produced peaks thousands of times the device-reported PPV.
|
||||
if u16le(q + 16) != 0:
|
||||
return out
|
||||
out.append({
|
||||
"segment_id": body[off + 1],
|
||||
"block_ctr": u16le(off + 2),
|
||||
"t_peak": u16le(q), "t_halfp": u16le(q + 2),
|
||||
"v_peak": u16le(q + 4), "v_halfp": u16le(q + 6),
|
||||
"l_peak": u16le(q + 8), "l_halfp": u16le(q + 10),
|
||||
"m_peak": u16le(q + 12), "m_halfp": u16le(q + 14),
|
||||
"meta_var": bytes(body[q + 16:q + 20]),
|
||||
"is_terminal": False,
|
||||
})
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user