Files
seismo-relay/tests/test_serial_prefix.py
T
serversdownandClaude Opus 5 9ceff65bfb fix(sfm): read the serial family prefix from the file, enabling BlastMates
The BW filename encodes only the serial NUMBER — `<letter><3 digits>`, so
`L895…` is 10895 and nothing more. The two-letter family prefix is not in it:
"BE" is a MiniMate Plus, "BA" a BlastMate. Both are Series III and their
files are byte-identical in every way that matters — all 1,493 BlastMate
binaries in the DL2 archive decode through the existing codec at 100%, same
four channels — so the serial string was the only thing standing between SFM
and BlastMate support.

Two sites synthesised the prefix and got it wrong:

- waveform_store `_serial_from_bw_filename` returned f"BE{num}" on import, so
  a BlastMate event was filed under a unit that does not exist, silently, and
  Terra-View read it straight through. Split into
  `_serial_number_from_bw_filename` (the number, which the filename really
  does carry) and a new `_serial_from_bw_bytes` that reads the serial out of
  the body and accepts it only when its numeric part agrees with the
  filename. save_imported_bw now prefers hint -> body -> filename guess.
  Verified against real archive bytes for BA9229, BA10060, BA10895, BA15957
  and BE9558/BE11529/BE18003.

- client `_decode_0a_partial_header` searched for a literal b"BE" in the
  monitor-log partial record. On a BlastMate that returns -1 and skips the
  whole block, so the geo threshold went missing along with the serial. Now
  matches any two-letter prefix, and requires the NUL terminator — stricter
  than the bare two-byte search it replaces.

Nothing to migrate: no BlastMate events are in prod. The archive's BA units
last recorded 2018-10 (BA9229, BA15957), 2023-08 (BA10895) and 2023-11
(BA10060), and the prod backfill only reaches back to ~May 2025.

21 tests. Suite: 309 passed, same 16 pre-existing failures as at HEAD
(15 missing ASCII fixtures + one peak_values assertion, all untouched here).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgTe8CamXAHcAmaQ6QNcog
2026-09-06 07:58:00 +00:00

102 lines
4.0 KiB
Python

"""The BW filename encodes the serial NUMBER, never the family prefix.
"BE" is a MiniMate Plus; "BA" is a BlastMate. Both are Series III and their
files are byte-compatible — the whole archive's 1,493 BlastMate binaries
decode through the same codec at 100% — so the only thing that distinguishes
them downstream is the serial string, and that lives in the file body.
Synthesising the prefix as "BE" files a BlastMate under a unit that does not
exist. Four units in the DL2 archive are affected: BA9229, BA10060, BA10895
and BA15957.
"""
from __future__ import annotations
import pytest
from minimateplus.client import _decode_0a_partial_header
from sfm.waveform_store import (
_serial_from_bw_bytes,
_serial_from_bw_filename,
_serial_number_from_bw_filename,
)
# ── the filename gives a number, and only a number ──────────────────────────
@pytest.mark.parametrize("name,num", [
("P036L318.C80H", 14036), # BE14036
("H907KWRK.WB0H", 6907), # BE6907
("M529LKIQ.G10", 11529), # BE11529
("T003LQ9K.OE0H", 18003), # BE18003
("L895K63F.GE0W", 10895), # BA10895 — a BlastMate
("K229HGQI.XO0W", 9229), # BA9229 — a BlastMate
])
def test_number_from_filename(name, num):
assert _serial_number_from_bw_filename(name) == num
@pytest.mark.parametrize("name", ["", "not_a_bw_file.bin", "AB12", "1234ABCD.XX0W"])
def test_number_from_filename_rejects_junk(name):
assert _serial_number_from_bw_filename(name) is None
def test_filename_only_decoder_is_a_guess():
"""It still answers "BE" — that is why it must not be the first choice."""
assert _serial_from_bw_filename("L895K63F.GE0W") == "BE10895"
assert _serial_from_bw_filename("M529LKIQ.G10") == "BE11529"
assert _serial_from_bw_filename("nonsense") is None
# ── the body carries the truth ──────────────────────────────────────────────
def _body(serial: bytes) -> bytes:
return b"\x00" * 32 + b"STRT" + b"\xff\xfe" + serial + b"\x00Geo: 0.254 in/s\x00"
def test_body_wins_for_a_blastmate():
assert _serial_from_bw_bytes(_body(b"BA10895"), "L895K63F.GE0W") == "BA10895"
def test_body_wins_for_a_minimate():
assert _serial_from_bw_bytes(_body(b"BE11529"), "M529LKIQ.G10") == "BE11529"
def test_body_candidate_must_match_the_filename_number():
"""A serial-shaped byte run that disagrees with the filename is ignored."""
assert _serial_from_bw_bytes(_body(b"XX99999"), "L895K63F.GE0W") is None
def test_body_tolerates_a_leading_zero():
assert _serial_from_bw_bytes(_body(b"BA09229"), "K229HGQI.XO0W") == "BA09229"
@pytest.mark.parametrize("data,name", [
(b"", "L895K63F.GE0W"), # no bytes
(_body(b"BA10895"), "junk.bin"), # no derivable number
])
def test_body_returns_none_when_it_cannot_decide(data, name):
assert _serial_from_bw_bytes(data, name) is None
# ── the live monitor-log path ───────────────────────────────────────────────
def _partial_record(serial: bytes) -> bytes:
"""0x2C partial record: type, prefix, two 9-byte timestamps, then ASCII."""
ts = bytes([11, 0x10, 4, 0x07, 0xE9, 0, 16, 2, 0]) # 2025-04-11 16:02:00
return (bytes([0x2C]) + b"\x00" * 10 + ts + ts
+ b"\x00\x00\x00\x00" + serial + b"\x00Geo: 0.254 in/s\x00")
@pytest.mark.parametrize("serial", [b"BE11529", b"BA10895", b"UM11719"])
def test_monitor_log_reads_any_family_prefix(serial):
entry = _decode_0a_partial_header(_partial_record(serial), 0, b"\x01\x11\x00\x00")
assert entry is not None
assert entry.serial == serial.decode()
def test_monitor_log_geo_threshold_survives_a_blastmate():
"""The old find(b"BE") skipped the whole block, losing geo too."""
entry = _decode_0a_partial_header(_partial_record(b"BA10895"), 0, b"\x01\x11\x00\x00")
assert entry is not None
assert entry.geo_threshold_ips == pytest.approx(0.254)