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:
@@ -30,6 +30,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import struct
|
import struct
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -2532,10 +2533,17 @@ def _decode_0a_partial_header(raw_data: bytes, index: int, key4: bytes) -> Optio
|
|||||||
ts2 = try_ts(raw_data[ts1_end + 1:ts1_end + 1 + ts_size])
|
ts2 = try_ts(raw_data[ts1_end + 1:ts1_end + 1 + ts_size])
|
||||||
|
|
||||||
# Extract serial and geo threshold from "BE11529\0" and "Geo: X.XXX in/s\0".
|
# Extract serial and geo threshold from "BE11529\0" and "Geo: X.XXX in/s\0".
|
||||||
|
#
|
||||||
|
# Match any two-letter family prefix, not a literal "BE" — a BlastMate
|
||||||
|
# reports "BA10895", and the old `find(b"BE")` returned -1 on one. That
|
||||||
|
# skipped this whole block, so the geo threshold went missing along with
|
||||||
|
# the serial. Requiring the NUL terminator in the pattern also makes the
|
||||||
|
# match stricter than the bare two-byte search it replaces.
|
||||||
serial: Optional[str] = None
|
serial: Optional[str] = None
|
||||||
geo_ips: Optional[float] = None
|
geo_ips: Optional[float] = None
|
||||||
|
|
||||||
serial_pos = raw_data.find(b"BE")
|
serial_match = re.search(rb"[A-Z]{2}\d{3,6}(?=\x00)", raw_data)
|
||||||
|
serial_pos = serial_match.start() if serial_match else -1
|
||||||
if serial_pos >= 0:
|
if serial_pos >= 0:
|
||||||
# Read null-terminated serial starting at serial_pos.
|
# Read null-terminated serial starting at serial_pos.
|
||||||
null_pos = raw_data.find(b"\x00", serial_pos)
|
null_pos = raw_data.find(b"\x00", serial_pos)
|
||||||
|
|||||||
+61
-10
@@ -32,6 +32,7 @@ from __future__ import annotations
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import pickle
|
import pickle
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
@@ -379,8 +380,16 @@ class WaveformStore:
|
|||||||
|
|
||||||
# Resolve serial. blastware_filename derives a 4-char prefix from
|
# Resolve serial. blastware_filename derives a 4-char prefix from
|
||||||
# the numeric serial (e.g. BE11529 → M529); we go the other way
|
# the numeric serial (e.g. BE11529 → M529); we go the other way
|
||||||
# via the source filename if a hint wasn't given.
|
# if a hint wasn't given. The filename carries only the NUMBER,
|
||||||
serial = serial_hint or _serial_from_bw_filename(source_path.name) or "UNKNOWN"
|
# 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
|
# Use the source filename verbatim — it already encodes timestamp
|
||||||
# + record type per BW's AB0T scheme, and we want to preserve it
|
# + record type per BW's AB0T scheme, and we want to preserve it
|
||||||
@@ -840,20 +849,24 @@ class WaveformStore:
|
|||||||
|
|
||||||
# ── helpers ─────────────────────────────────────────────────────────────────────
|
# ── 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>`
|
BW filename format (V10.72): `<P><serial3><stem4>.<ext>`
|
||||||
where P = chr(ord('B') + floor(serial // 1000))
|
where P = chr(ord('B') + floor(serial // 1000))
|
||||||
and serial3 = f"{serial % 1000:03d}".
|
and serial3 = f"{serial % 1000:03d}".
|
||||||
|
|
||||||
Examples (from CLAUDE.md verification archive):
|
Examples (from CLAUDE.md verification archive):
|
||||||
P036... → BE14036 H907... → BE6907
|
P036... → 14036 H907... → 6907
|
||||||
M529... → BE11529 T003... → BE18003
|
M529... → 11529 T003... → 18003
|
||||||
|
L895... → 10895
|
||||||
|
|
||||||
Returns the inferred BE-prefix serial (e.g. "BE11529") or None when
|
⚠ The filename encodes **only the number**. The two-letter family
|
||||||
the filename doesn't match the expected pattern.
|
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:
|
if not name:
|
||||||
return None
|
return None
|
||||||
@@ -866,5 +879,43 @@ def _serial_from_bw_filename(name: str) -> Optional[str]:
|
|||||||
if prefix_letter < "B":
|
if prefix_letter < "B":
|
||||||
return None
|
return None
|
||||||
thousands = ord(prefix_letter) - ord("B")
|
thousands = ord(prefix_letter) - ord("B")
|
||||||
serial_num = thousands * 1000 + int(base[1:4])
|
return thousands * 1000 + int(base[1:4])
|
||||||
return f"BE{serial_num}"
|
|
||||||
|
|
||||||
|
_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}"
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""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)
|
||||||
Reference in New Issue
Block a user