feat(micromate): the event chain -- walk, records, download, and a self-check

Step 4 of docs/micromate_client_spec.md.  MicromateEventRef plus list_events(),
iter_events(), download_event(), get_event() and decode_error().  14 new tests;
112 micromate tests total.

The load-bearing test replays THOR's captured six-event download session through
iter_events() + get_event() and asserts EVERY BYTE WE EMIT MATCHES THOR'S, in
order -- 74 frames -- while decoding all six events and cross-checking each
waveform's peak vector sum against the float the device computed itself.

Two design decisions worth recording:

1. iter_events() EXISTS BECAUSE THOR INTERLEAVES.  Its captured order is
   0x93 -> 1E -> 0C -> 5A*n -> 0x93 -> 1F -> 0C -> 5A*n, downloading each event
   before advancing the chain.  list_events() walks to the end first, which is
   fine for browsing but leaves the device cursor parked past the event a later
   download addresses.  0x5A is key-addressed so it very probably does not care
   -- but nothing observed says either way, so the interleaved path is the one
   offered for downloads, and it is the one the replay test exercises.

2. get_event(verify=True) RECOMPUTES THE PEAK VECTOR SUM from the decoded
   samples and compares it against the device's own 0x0C float.  Two independent
   computations over the same samples, so a disagreement means our decode is
   wrong.  Agreement on the bench events is 0.000%.  Cheap insurance in a
   codebase whose decode failures have historically been silent -- unhandled
   block tags shorten a channel and nothing raises.  It is a decode-correctness
   check, NOT a truncation detector: a channel cut after its peak still yields
   the right PVS, and the docstring says so.  A test corrupts a stored peak to
   prove the check actually fires.

MicromateEventRef.uid is SERIAL:key, because the key alone is ambiguous across
units, and .filename generates THOR's name (<serial>_<YYYYMMDDHHMMSS>.IDFW/H)
-- returning None rather than guessing when the record type is unknown, since
read_idf_file() dispatches on exactly that suffix.

Recorded as a CANDIDATE, not used: 0x06 content[0:4] looks like the EVENT COUNT
-- 6 on a unit holding 6 events, zeros on an empty one, and THOR reads it BEFORE
the walk then downloads exactly six events without ever reading the chain
sentinel.  If it holds it lets a caller decide whether to walk at all, which
over cellular is the useful part.  Two samples on one unit, and content[4:8]
reads 9 unexplained, so list_events() still walks to the sentinel: slower by one
round trip and correct on evidence rather than inference.

Full suite: 465 passed, 16 pre-existing failures unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
This commit is contained in:
2026-09-30 18:29:32 -04:00
co-authored by Claude Opus 5
parent e95d8e3b8c
commit d7d72cadf8
3 changed files with 690 additions and 1 deletions
+73
View File
@@ -478,3 +478,76 @@ class MicromateState:
if frac is not None:
bits.append(f"memory {frac * 100:.1f}% used")
return " ".join(bits)
@dataclass
class MicromateEventRef:
"""One entry in a unit's event chain, from `1E`/`1F` and optionally `0x0C`.
⚠ **`key` is NOT unique across units.** The event counter starts from the
same value on every Micromate — UM12947 and UM20147 both have an event
`055d4a81`, with different sizes and different contents. Use `uid`, or key
on `(serial, key_hex)`, for anything that stores or deduplicates. A store
keyed on the event key alone silently treats one unit's event as a duplicate
of another's, and nothing raises.
"""
index: int
key: bytes # 4-byte event key from the chain walk
size: int # bytes the device will send for this event
serial: Optional[str] = None # the unit, because `key` alone is ambiguous
# From `SUB 0x0C` — one extra round trip per event, so optional.
record_type: Optional[str] = None # "waveform" | "histogram"
timestamp: Optional[datetime.datetime] = None
setup: Optional[str] = None # setup file name, no extension
sensor_location: Optional[str] = None
peak_vector_sum_ips: Optional[float] = None # per-sample PVS, device-computed
peaks_ips: Optional[Dict[str, float]] = None # {"Tran": …, "Vert": …, …}
raw_record: Optional[bytes] = field(default=None, repr=False)
@property
def key_hex(self) -> str:
return self.key.hex()
@property
def uid(self) -> str:
"""`SERIAL:key` — safe to use as a primary key. See the class note."""
return f"{self.serial or '?'}:{self.key_hex}"
@property
def is_histogram(self) -> Optional[bool]:
if self.record_type is None:
return None
return self.record_type == "histogram"
@property
def suffix(self) -> Optional[str]:
return {"waveform": ".IDFW", "histogram": ".IDFH"}.get(self.record_type or "")
@property
def filename(self) -> Optional[str]:
"""The name THOR would have given this event.
`<serial>_<YYYYMMDDHHMMSS>.IDF{W,H}` — e.g.
`UM12947_20260923163319.IDFW`. Verified against the production store
for all five bench events.
⚠ The type comes from the **protocol**, not the payload, so it has to be
carried here from the `0x0C` read. Returns None without it: guessing
the suffix would file a histogram as a waveform, and `read_idf_file()`
dispatches on exactly that.
"""
if not (self.serial and self.timestamp and self.suffix):
return None
return f"{self.serial}_{self.timestamp:%Y%m%d%H%M%S}{self.suffix}"
def __str__(self) -> str:
bits = [self.uid, f"{self.size} B"]
if self.record_type:
bits.append(self.record_type)
if self.timestamp:
bits.append(self.timestamp.strftime("%Y-%m-%d %H:%M:%S"))
if self.peak_vector_sum_ips is not None:
bits.append(f"PVS {self.peak_vector_sum_ips:.4f} in/s")
return " ".join(bits)