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
This commit is contained in:
2026-09-06 07:58:00 +00:00
co-authored by Claude Opus 5
parent 9982938b0b
commit 9ceff65bfb
3 changed files with 171 additions and 11 deletions
+61 -10
View File
@@ -32,6 +32,7 @@ from __future__ import annotations
import datetime
import logging
import pickle
import re
import shutil
from pathlib import Path
from typing import Optional, Union
@@ -379,8 +380,16 @@ class WaveformStore:
# Resolve serial. blastware_filename derives a 4-char prefix from
# the numeric serial (e.g. BE11529 → M529); we go the other way
# via the source filename if a hint wasn't given.
serial = serial_hint or _serial_from_bw_filename(source_path.name) or "UNKNOWN"
# if a hint wasn't given. The filename carries only the NUMBER,
# so read the family prefix out of the body first — a BlastMate
# ("BA") filed as "BE" is a unit that does not exist. The
# filename-only decoder stays as the last resort.
serial = (
serial_hint
or _serial_from_bw_bytes(bw_bytes, source_path.name)
or _serial_from_bw_filename(source_path.name)
or "UNKNOWN"
)
# Use the source filename verbatim — it already encodes timestamp
# + record type per BW's AB0T scheme, and we want to preserve it
@@ -840,20 +849,24 @@ class WaveformStore:
# ── helpers ─────────────────────────────────────────────────────────────────────
def _serial_from_bw_filename(name: str) -> Optional[str]:
def _serial_number_from_bw_filename(name: str) -> Optional[int]:
"""
Reverse of `blastware_filename`'s serial-prefix encoding.
Reverse of `blastware_filename`'s serial-prefix encoding — the NUMBER only.
BW filename format (V10.72): `<P><serial3><stem4>.<ext>`
where P = chr(ord('B') + floor(serial // 1000))
and serial3 = f"{serial % 1000:03d}".
Examples (from CLAUDE.md verification archive):
P036... → BE14036 H907... → BE6907
M529... → BE11529 T003... → BE18003
P036... → 14036 H907... → 6907
M529... → 11529 T003... → 18003
L895... → 10895
Returns the inferred BE-prefix serial (e.g. "BE11529") or None when
the filename doesn't match the expected pattern.
⚠ The filename encodes **only the number**. The two-letter family
prefix is NOT in it — "BE" is a MiniMate Plus, "BA" a BlastMate — so
the prefix has to come from the file body (`_serial_from_bw_bytes`)
or from an explicit hint. Returns None when the filename doesn't
match the expected pattern.
"""
if not name:
return None
@@ -866,5 +879,43 @@ def _serial_from_bw_filename(name: str) -> Optional[str]:
if prefix_letter < "B":
return None
thousands = ord(prefix_letter) - ord("B")
serial_num = thousands * 1000 + int(base[1:4])
return f"BE{serial_num}"
return thousands * 1000 + int(base[1:4])
_BW_SERIAL_RE = re.compile(rb"[A-Z]{2}\d{3,6}")
def _serial_from_bw_bytes(data: bytes, name: str) -> Optional[str]:
"""
Read the real serial — prefix included — out of a BW file body.
The body carries the serial as a plain ASCII string ("BE9558",
"BA10895"). We accept a candidate only when its numeric part matches
the number the filename encodes, which keeps a stray byte sequence in
the sample stream from being mistaken for a serial.
Returns None when the filename number can't be derived or no
candidate in the body agrees with it — the caller then falls back.
"""
num = _serial_number_from_bw_filename(name)
if num is None or not data:
return None
for match in _BW_SERIAL_RE.findall(data):
candidate = match.decode("ascii", errors="replace")
if candidate[2:].lstrip("0") == str(num):
return candidate
return None
def _serial_from_bw_filename(name: str) -> Optional[str]:
"""
Best-effort serial from the filename alone.
⚠ The family prefix is a **guess** — the filename does not carry it.
"BE" is right for every MiniMate Plus but wrong for a BlastMate, whose
serials start "BA". Prefer `_serial_from_bw_bytes` whenever the file
body is at hand; this exists for callers that only have a name
(log lines, dry-run output).
"""
num = _serial_number_from_bw_filename(name)
return None if num is None else f"BE{num}"