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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user