Step 3 of docs/micromate_client_spec.md: micromate/client.py, two models in micromate/models.py, 26 offline tests. Every response constant in the tests is a real captured data section from UM12947. Field offsets were measured rather than taken from the spec, which turned up one general rule and one trap: THE RESPONSE SHAPE. Every response carries an 11-byte prefix and content starts at data[11]. One rule, every command. THE TRAP: data[0] is the content length & 0xFF, with no high byte anywhere in the prefix. It is therefore correct for every response under 256 bytes -- most of them -- and then reports 44 for a 2,092-byte setup block, 30 for a 286-byte monitor-log record, and 0 for a 1,024-byte download chunk. 182 of 251 captured responses agree with a naive read; the 69 that disagree are exactly the ones >= 256 bytes. That is the third length in this protocol read too narrow, after payload[9]-vs-payload[8:10] in the probe response. The client takes content as data[11:] and lets the frame's own length bound it -- nothing needs the declared length, since the frame already knows how long it is. Also measured: - POLL content[3] is 0x50, printable as "P", immediately before "Instantel". A generic printable-run scan therefore returns "PInstantel" -- it caught a test, not a unit. Vendor comes from a fixed offset; the model is found by searching for "MM/", which is structural rather than positional and so survives the Thor line's shorter "MM/ISEE/S". - The setup walk terminates on an EMPTY NAME, not an error: 23 responses, 22 names, factory.MMB first through TEST1.mmb last. - The 0x1C clock has an unidentified byte at content[6]; the hour is at content[7]. The protocol reference's 0x1C section already had this right and names the byte -- its one-line summary in the divergences list reads as six contiguous fields and is the version not to trust. Re-verified against three captures: 19:12:25, 19:13:34 and 01:14:05 against filenames stamped 19:12:14, 19:12:14 and 01:14:03. - Battery and memory are read FORWARD from content start, never backward from the end. This block is 4 bytes longer on the Thor line; the Series III from-the-end offsets give a 11.0BD unit 577.92 V. A test appends the four trailing bytes and asserts the forward offsets survive. connect() is narrower than the spec asked. The spec said to mirror Thor's POLL -> SERIAL -> 0x49 -> POLL "because it is known-good"; measurement showed that is Thor's connection check (3 of 8 sessions) and its fourth frame repeats its first. So connect() sends the three reads that gather something, and 0x01 is not read at all -- Thor never reads it, its layout is unmapped, and firmware_line comes free from any response's flags byte. If a unit ever refuses the next command after a cold connect, put the fourth POLL back and record it. A dead clock battery yields device_time=None rather than failing the whole state read; an unreadable active setup yields active_setup=None rather than failing connect. Both are real device states. Still verified only against 11.0CB and only over USB. The BD offsets follow from the extra bytes being trailing, which is documented but not something this code has seen. Full suite unchanged at 16 pre-existing failures; 445 passed, up 26. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
481 lines
19 KiB
Python
481 lines
19 KiB
Python
"""
|
||
Micromate (Series IV / Thor) native data models.
|
||
|
||
These are the right-shaped dataclasses for Thor data — Thor measures
|
||
the microphone in dB(L) directly, so this model carries
|
||
``mic_pspl_dbl`` rather than the pseudo-``psi`` shoehorn that
|
||
``minimateplus.PeakValues`` uses for Series III BW data.
|
||
|
||
The ingest pipeline today goes:
|
||
|
||
.IDFW.txt → parse_idf_report() → dict
|
||
dict → IdfEvent.from_report() → IdfEvent (typed)
|
||
IdfEvent → IdfEvent.to_minimateplus_event() → shape DB / sidecar
|
||
machinery expects
|
||
|
||
The ``to_minimateplus_event()`` bridge is a temporary boundary — when we
|
||
crack the binary IDF codec and have richer per-event data to store, the
|
||
DB schema will grow Series-IV-specific columns and the bridge will
|
||
shrink or disappear.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import datetime
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Dict, Optional, Tuple
|
||
|
||
|
||
# ── IdfReport ─────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@dataclass
|
||
class IdfReport:
|
||
"""Typed wrapper around the dict returned by ``parse_idf_report``.
|
||
|
||
All fields optional — Thor's exporter is permissive and some IDF .txt
|
||
files (especially histograms) omit fields that waveform sidecars
|
||
include. Use ``.raw`` for any field this dataclass hasn't surfaced
|
||
yet (the parser keeps every recognised key in the raw dict).
|
||
"""
|
||
|
||
# Identity / kind
|
||
serial_number: Optional[str] = None
|
||
event_type: Optional[str] = None # "Full Waveform" | "Full Histogram"
|
||
event_datetime: Optional[datetime.datetime] = None
|
||
filename: Optional[str] = None # echoed by Thor's exporter
|
||
|
||
# Sampling / timing
|
||
sample_rate: Optional[int] = None # samples/sec
|
||
record_time_sec: Optional[float] = None
|
||
pre_trigger_sec: Optional[float] = None
|
||
|
||
# Geophone peaks (in/s)
|
||
tran_ppv: Optional[float] = None
|
||
vert_ppv: Optional[float] = None
|
||
long_ppv: Optional[float] = None
|
||
peak_vector_sum: Optional[float] = None
|
||
|
||
# Microphone — Thor's native unit is dB(L), NOT psi.
|
||
mic_pspl_dbl: Optional[float] = None
|
||
|
||
# Zero-crossing frequencies (Hz)
|
||
tran_zc_freq: Optional[float] = None
|
||
vert_zc_freq: Optional[float] = None
|
||
long_zc_freq: Optional[float] = None
|
||
mic_zc_freq: Optional[float] = None
|
||
|
||
# Per-channel time of peak (sec, since event start)
|
||
tran_time_of_peak: Optional[float] = None
|
||
vert_time_of_peak: Optional[float] = None
|
||
long_time_of_peak: Optional[float] = None
|
||
mic_time_of_peak: Optional[float] = None
|
||
|
||
# Derived per-channel motion
|
||
tran_peak_acceleration: Optional[float] = None # g
|
||
vert_peak_acceleration: Optional[float] = None
|
||
long_peak_acceleration: Optional[float] = None
|
||
tran_peak_displacement: Optional[float] = None # in
|
||
vert_peak_displacement: Optional[float] = None
|
||
long_peak_displacement: Optional[float] = None
|
||
|
||
# Operator-supplied strings (Thor's TitleString1..4 → semantic slots)
|
||
project: Optional[str] = None # TitleString1
|
||
client: Optional[str] = None # TitleString2
|
||
operator: Optional[str] = None # TitleString3
|
||
notes: Optional[str] = None # TitleString4 / PostEventNote
|
||
setup: Optional[str] = None # setup file name
|
||
|
||
# Sensor self-check results
|
||
tran_test_passed: Optional[bool] = None
|
||
vert_test_passed: Optional[bool] = None
|
||
long_test_passed: Optional[bool] = None
|
||
mic_test_passed: Optional[bool] = None
|
||
|
||
# Device-fixed metadata
|
||
firmware_version: Optional[str] = None
|
||
calibration_text: Optional[str] = None
|
||
battery_volts: Optional[float] = None
|
||
|
||
# Original parser dict — preserves every recognised key (including
|
||
# raw unit-suffixed strings) for forward-compatible field access.
|
||
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
||
|
||
@classmethod
|
||
def from_dict(cls, d: Dict[str, Any]) -> "IdfReport":
|
||
"""Build an IdfReport from the dict returned by ``parse_idf_report``."""
|
||
ed = d.get("event_datetime")
|
||
if isinstance(ed, str):
|
||
try:
|
||
ed = datetime.datetime.fromisoformat(ed)
|
||
except ValueError:
|
||
ed = None
|
||
|
||
return cls(
|
||
serial_number = d.get("serial_number"),
|
||
event_type = d.get("event_type"),
|
||
event_datetime = ed if isinstance(ed, datetime.datetime) else None,
|
||
filename = d.get("filename"),
|
||
sample_rate = d.get("sample_rate"),
|
||
record_time_sec = d.get("record_time_sec"),
|
||
pre_trigger_sec = d.get("pre_trigger_sec"),
|
||
tran_ppv = d.get("tran_ppv"),
|
||
vert_ppv = d.get("vert_ppv"),
|
||
long_ppv = d.get("long_ppv"),
|
||
peak_vector_sum = d.get("peak_vector_sum"),
|
||
mic_pspl_dbl = d.get("mic_ppv"), # parser names it mic_ppv (legacy)
|
||
tran_zc_freq = d.get("tran_zc_freq"),
|
||
vert_zc_freq = d.get("vert_zc_freq"),
|
||
long_zc_freq = d.get("long_zc_freq"),
|
||
mic_zc_freq = d.get("mic_zc_freq"),
|
||
tran_time_of_peak = d.get("tran_time_of_peak"),
|
||
vert_time_of_peak = d.get("vert_time_of_peak"),
|
||
long_time_of_peak = d.get("long_time_of_peak"),
|
||
mic_time_of_peak = d.get("mic_time_of_peak"),
|
||
tran_peak_acceleration = d.get("tran_peak_acceleration"),
|
||
vert_peak_acceleration = d.get("vert_peak_acceleration"),
|
||
long_peak_acceleration = d.get("long_peak_acceleration"),
|
||
tran_peak_displacement = d.get("tran_peak_displacement"),
|
||
vert_peak_displacement = d.get("vert_peak_displacement"),
|
||
long_peak_displacement = d.get("long_peak_displacement"),
|
||
project = d.get("project"),
|
||
client = d.get("client"),
|
||
operator = d.get("operator"),
|
||
notes = d.get("notes"),
|
||
setup = d.get("setup"),
|
||
tran_test_passed = d.get("tran_test_passed"),
|
||
vert_test_passed = d.get("vert_test_passed"),
|
||
long_test_passed = d.get("long_test_passed"),
|
||
mic_test_passed = d.get("mic_test_passed"),
|
||
firmware_version = d.get("version"),
|
||
calibration_text = d.get("calibration_text"),
|
||
battery_volts = d.get("battery_volts"),
|
||
raw = d,
|
||
)
|
||
|
||
|
||
# ── IdfPeaks / IdfProjectInfo / IdfSensorCheck (narrow grouping types) ───────
|
||
|
||
|
||
@dataclass
|
||
class IdfPeaks:
|
||
"""Geophone + mic peak values for one Thor event. Native Thor units.
|
||
|
||
Thor stores the mic peak in two parallel forms — ``mic_pspl_dbl`` is
|
||
what the sidecar's top-level ``MicPSPL`` header field carries (dB(L)),
|
||
used in the report header. ``mic_pspl_psi`` is the psi value derived
|
||
either from the IDFW sample table / IDFH interval column 9, or from
|
||
the binary mic counts (~2.14e-6 psi/count). Needed because the
|
||
BW-shaped ``PeakValues.micl`` consumed by ``event_hdf5.write_event_hdf5``
|
||
expects psi — feeding it dB(L) makes the h5 mic-chart scale factor
|
||
blow up.
|
||
"""
|
||
transverse_ips: Optional[float] = None # in/s
|
||
vertical_ips: Optional[float] = None # in/s
|
||
longitudinal_ips: Optional[float] = None # in/s
|
||
peak_vector_sum_ips: Optional[float] = None # in/s
|
||
mic_pspl_dbl: Optional[float] = None # dB(L)
|
||
mic_pspl_psi: Optional[float] = None # psi
|
||
|
||
|
||
@dataclass
|
||
class IdfProjectInfo:
|
||
"""Operator-supplied strings from Thor's TitleString1..4."""
|
||
project: Optional[str] = None
|
||
client: Optional[str] = None
|
||
operator: Optional[str] = None
|
||
notes: Optional[str] = None
|
||
setup: Optional[str] = None
|
||
|
||
|
||
@dataclass
|
||
class IdfSensorCheck:
|
||
"""Per-channel pass/fail from Thor's self-test."""
|
||
tran: Optional[bool] = None
|
||
vert: Optional[bool] = None
|
||
long: Optional[bool] = None
|
||
mic: Optional[bool] = None
|
||
|
||
|
||
# ── IdfEvent ─────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@dataclass
|
||
class IdfEvent:
|
||
"""A single Thor / Micromate Series IV event.
|
||
|
||
Built from a parsed .IDFW.txt or .IDFH.txt sidecar via
|
||
``IdfEvent.from_report()``. The filename is the authoritative
|
||
source for serial + timestamp + kind; the .txt provides
|
||
device-authoritative peak values, frequencies, project strings,
|
||
sensor self-check, firmware, calibration.
|
||
"""
|
||
|
||
# Identity
|
||
serial: str
|
||
timestamp: datetime.datetime
|
||
kind: str # "Waveform" | "Histogram"
|
||
filename: str # device-native binary filename, e.g. "UM11719_20231219163444.IDFW"
|
||
|
||
# Sampling / timing
|
||
sample_rate: Optional[int] = None
|
||
record_time_sec: Optional[float] = None
|
||
pre_trigger_sec: Optional[float] = None
|
||
|
||
# Peaks
|
||
peaks: IdfPeaks = field(default_factory=IdfPeaks)
|
||
|
||
# Per-channel frequencies (Hz)
|
||
tran_zc_freq: Optional[float] = None
|
||
vert_zc_freq: Optional[float] = None
|
||
long_zc_freq: Optional[float] = None
|
||
mic_zc_freq: Optional[float] = None
|
||
|
||
# Project strings
|
||
project_info: IdfProjectInfo = field(default_factory=IdfProjectInfo)
|
||
|
||
# Sensor self-check
|
||
sensor_check: IdfSensorCheck = field(default_factory=IdfSensorCheck)
|
||
|
||
# Device-fixed
|
||
firmware_version: Optional[str] = None
|
||
calibration_text: Optional[str] = None
|
||
battery_volts: Optional[float] = None
|
||
|
||
# The full parsed report — preserves anything not surfaced as a typed field
|
||
report: IdfReport = field(default_factory=IdfReport)
|
||
|
||
@classmethod
|
||
def from_report(
|
||
cls,
|
||
report: Any,
|
||
filename: str,
|
||
) -> "IdfEvent":
|
||
"""Build an IdfEvent from a parsed report (dict or IdfReport) and
|
||
the device-native binary filename.
|
||
|
||
The filename is authoritative for serial + timestamp + kind:
|
||
Thor's filenames are literal ``<SERIAL>_<YYYYMMDDHHMMSS>.<KIND>``
|
||
and the device's own clock is the canonical event timestamp.
|
||
If the report carries an ``event_datetime`` that differs from
|
||
what's in the filename, the report wins (it has finer-grained
|
||
device-reported time-of-trigger semantics).
|
||
"""
|
||
from .idf_ascii_report import parse_event_filename
|
||
|
||
# Normalise input to IdfReport
|
||
if isinstance(report, IdfReport):
|
||
rep = report
|
||
elif isinstance(report, dict):
|
||
rep = IdfReport.from_dict(report)
|
||
else:
|
||
raise TypeError(
|
||
f"report must be IdfReport or dict; got {type(report).__name__}"
|
||
)
|
||
|
||
# Filename → (serial, timestamp, kind). Required — fall back to
|
||
# report-supplied values only if filename parsing fails.
|
||
parsed = parse_event_filename(filename)
|
||
if parsed is not None:
|
||
fn_serial, fn_ts, fn_kind = parsed
|
||
kind = "Histogram" if fn_kind == "IDFH" else "Waveform"
|
||
else:
|
||
fn_serial = rep.serial_number or "UNKNOWN"
|
||
fn_ts = rep.event_datetime or datetime.datetime(1970, 1, 1)
|
||
kind = "Waveform" if (rep.event_type or "").lower().startswith("full waveform") else "Histogram"
|
||
|
||
# Prefer report's event_datetime (device-authoritative) over the filename.
|
||
ts = rep.event_datetime or fn_ts
|
||
serial = rep.serial_number or fn_serial
|
||
|
||
return cls(
|
||
serial=serial,
|
||
timestamp=ts,
|
||
kind=kind,
|
||
filename=filename,
|
||
sample_rate=rep.sample_rate,
|
||
record_time_sec=rep.record_time_sec,
|
||
pre_trigger_sec=rep.pre_trigger_sec,
|
||
peaks=IdfPeaks(
|
||
transverse_ips = rep.tran_ppv,
|
||
vertical_ips = rep.vert_ppv,
|
||
longitudinal_ips = rep.long_ppv,
|
||
peak_vector_sum_ips = rep.peak_vector_sum,
|
||
mic_pspl_dbl = rep.mic_pspl_dbl,
|
||
),
|
||
tran_zc_freq=rep.tran_zc_freq,
|
||
vert_zc_freq=rep.vert_zc_freq,
|
||
long_zc_freq=rep.long_zc_freq,
|
||
mic_zc_freq=rep.mic_zc_freq,
|
||
project_info=IdfProjectInfo(
|
||
project=rep.project,
|
||
client=rep.client,
|
||
operator=rep.operator,
|
||
notes=rep.notes,
|
||
setup=rep.setup,
|
||
),
|
||
sensor_check=IdfSensorCheck(
|
||
tran=rep.tran_test_passed,
|
||
vert=rep.vert_test_passed,
|
||
long=rep.long_test_passed,
|
||
mic=rep.mic_test_passed,
|
||
),
|
||
firmware_version=rep.firmware_version,
|
||
calibration_text=rep.calibration_text,
|
||
battery_volts=rep.battery_volts,
|
||
report=rep,
|
||
)
|
||
|
||
# ── Bridge to minimateplus shape (for the existing DB / sidecar paths) ──
|
||
|
||
def to_minimateplus_event(self, waveform_key: bytes) -> Any:
|
||
"""Project this Thor event into the shape ``minimateplus.Event``
|
||
carries, so it can flow through the existing
|
||
``SeismoDb.insert_events()`` and ``event_to_sidecar_dict()``
|
||
machinery without those code paths needing to know about Thor.
|
||
|
||
Caveats of the bridge:
|
||
- ``PeakValues.micl`` carries the mic peak in **psi** (matching
|
||
BW's convention) — set from :attr:`IdfPeaks.mic_pspl_psi`,
|
||
with a dB(L)→psi fallback when only the dB(L) value is
|
||
available. This is what the h5 writer's mic-scale-factor
|
||
logic needs. The dB(L) value still flows through
|
||
``bw_report.mic.pspl_dbl`` (set by the
|
||
``idf_to_bw_report`` adapter) and the renderer reads it
|
||
from there for the report header.
|
||
- Many Thor-specific fields (Peak Acceleration / Displacement,
|
||
sensor self-check, calibration) don't have a slot in
|
||
``Event``. The full IdfReport is preserved on the
|
||
``.sfm.json`` sidecar under ``extensions.idf_report`` via
|
||
``save_imported_idf`` — that's the source of truth for them.
|
||
"""
|
||
from minimateplus.models import (
|
||
Event, PeakValues, ProjectInfo, Timestamp,
|
||
)
|
||
|
||
ts_obj = Timestamp(
|
||
raw=bytes(9),
|
||
flag=0,
|
||
year=self.timestamp.year,
|
||
unknown_byte=0,
|
||
month=self.timestamp.month,
|
||
day=self.timestamp.day,
|
||
hour=self.timestamp.hour,
|
||
minute=self.timestamp.minute,
|
||
second=self.timestamp.second,
|
||
)
|
||
# Resolve mic peak as psi. Priority: binary-derived mic_pspl_psi
|
||
# (set by read_idf_file) > dB(L)→psi fallback via standard formula
|
||
# (psi = 2.9e-9 × 10^(dBL/20)) > None.
|
||
mic_psi = self.peaks.mic_pspl_psi
|
||
if mic_psi is None and self.peaks.mic_pspl_dbl is not None:
|
||
mic_psi = 2.9e-9 * (10.0 ** (self.peaks.mic_pspl_dbl / 20.0))
|
||
pv = PeakValues(
|
||
tran=self.peaks.transverse_ips,
|
||
vert=self.peaks.vertical_ips,
|
||
long=self.peaks.longitudinal_ips,
|
||
micl=mic_psi, # psi, matching BW's convention (h5 scaling depends on this)
|
||
peak_vector_sum=self.peaks.peak_vector_sum_ips,
|
||
)
|
||
pi = ProjectInfo(
|
||
setup_name=self.project_info.setup,
|
||
project=self.project_info.project,
|
||
client=self.project_info.client,
|
||
operator=self.project_info.operator,
|
||
sensor_location=None, # Thor folds location into project string
|
||
notes=self.project_info.notes,
|
||
)
|
||
ev = Event(
|
||
index=0,
|
||
timestamp=ts_obj,
|
||
sample_rate=self.sample_rate,
|
||
peak_values=pv,
|
||
project_info=pi,
|
||
record_type=self.kind,
|
||
rectime_seconds=self.record_time_sec,
|
||
)
|
||
ev._waveform_key = waveform_key
|
||
return ev
|
||
|
||
|
||
# ── Live-device models (2026-09-27) ───────────────────────────────────────────
|
||
#
|
||
# These describe what a unit reports over the wire, not what Thor wrote to a
|
||
# file. Everything above this line came out of Thor's exports; everything below
|
||
# came out of Thor's *traffic*. Field offsets are recorded in
|
||
# ``micromate/client.py`` next to the code that reads them.
|
||
|
||
|
||
@dataclass
|
||
class MicromateDeviceInfo:
|
||
"""Identity gathered by ``MicromateClient.connect()``.
|
||
|
||
Sourced from three reads:
|
||
``0x5B`` POLL → manufacturer, model
|
||
``0x15`` SERIAL → serial
|
||
``0x49`` STATE → monitoring
|
||
plus ``firmware_line``, which comes free from the flags byte of any
|
||
response and needs no read of its own.
|
||
"""
|
||
|
||
serial: str
|
||
manufacturer: Optional[str] = None # "Instantel"
|
||
model: Optional[str] = None # "MM/ISEE/S/IO" (CB) / "MM/ISEE/S" (BD)
|
||
firmware_line: Optional[str] = None # "blastware" | "thor" | "unknown"
|
||
monitoring: Optional[bool] = None
|
||
active_setup: Optional[str] = None # e.g. "TEST1.mmb"
|
||
|
||
def __str__(self) -> str:
|
||
bits = [self.serial]
|
||
if self.model:
|
||
bits.append(self.model)
|
||
if self.firmware_line:
|
||
bits.append(f"{self.firmware_line} fw")
|
||
if self.monitoring is not None:
|
||
bits.append("MONITORING" if self.monitoring else "idle")
|
||
if self.active_setup:
|
||
bits.append(f"setup={self.active_setup}")
|
||
return " ".join(bits)
|
||
|
||
|
||
@dataclass
|
||
class MicromateState:
|
||
"""A unit's live state, from ``SUB 0x1C``.
|
||
|
||
``device_time`` is the unit's own clock, in its own local timezone — it is
|
||
NOT converted. Nothing else this protocol exposes reports the unit's time,
|
||
which makes it the only way to detect a drifted clock before it lands in
|
||
event timestamps.
|
||
"""
|
||
|
||
monitoring: bool
|
||
device_time: Optional[datetime.datetime] = None
|
||
battery_volts: Optional[float] = None
|
||
memory_total_bytes: Optional[int] = None
|
||
memory_free_bytes: Optional[int] = None
|
||
raw: Optional[bytes] = field(default=None, repr=False)
|
||
|
||
@property
|
||
def memory_used_bytes(self) -> Optional[int]:
|
||
if self.memory_total_bytes is None or self.memory_free_bytes is None:
|
||
return None
|
||
return self.memory_total_bytes - self.memory_free_bytes
|
||
|
||
@property
|
||
def memory_used_fraction(self) -> Optional[float]:
|
||
used = self.memory_used_bytes
|
||
if used is None or not self.memory_total_bytes:
|
||
return None
|
||
return used / self.memory_total_bytes
|
||
|
||
def __str__(self) -> str:
|
||
bits = ["MONITORING" if self.monitoring else "idle"]
|
||
if self.device_time:
|
||
bits.append(self.device_time.strftime("%Y-%m-%d %H:%M:%S"))
|
||
if self.battery_volts is not None:
|
||
bits.append(f"{self.battery_volts:.2f} V")
|
||
frac = self.memory_used_fraction
|
||
if frac is not None:
|
||
bits.append(f"memory {frac * 100:.1f}% used")
|
||
return " ".join(bits)
|