feat: add thor/micromate compatibility v0.18.0

This commit is contained in:
2026-05-19 04:32:43 +00:00
parent 512d82c720
commit cd20be2eff
7 changed files with 839 additions and 2 deletions
+173
View File
@@ -413,6 +413,179 @@ class WaveformStore:
"serial": serial,
}
def save_imported_idf(
self,
idf_bytes: bytes,
source_path: Path,
*,
serial_hint: Optional[str] = None,
idf_report_text: Optional[Union[str, bytes]] = None,
) -> tuple[Optional["Event"], dict]:
"""
Ingest a Thor (Micromate Series IV) IDF event file (`.IDFW` or
`.IDFH`) produced by Thor's TXT exporter.
Thor binaries are stored as opaque bytes — seismo-relay doesn't
decode the proprietary IDF binary format. Device-authoritative
metadata comes from the paired `.IDFW.txt` / `.IDFH.txt` sidecar
when supplied; we parse that text and surface its fields onto
the returned Event so the SFM database row has real PPV/project
values instead of NULLs.
Workflow:
1. Parse the paired TXT report (when supplied) via
`sfm.idf_ascii_report.parse_idf_report`.
2. Build a minimal `Event` populated from the report fields
(timestamp, peaks, project info, sample_rate, record_type).
3. Resolve serial from filename prefix or `serial_hint`.
4. Copy bytes verbatim into <root>/<serial>/<filename>.
5. Write the `.sfm.json` sidecar with source.kind = "idf-import".
Returns (event, record_dict) so the endpoint can both insert
into SeismoDb and surface the parsed event.
"""
from sfm.idf_ascii_report import (
parse_idf_report,
parse_event_filename,
serial_from_filename as _idf_serial_from_filename,
)
from minimateplus.models import (
Event, PeakValues, ProjectInfo, Timestamp,
)
# Parse the .txt sidecar (best-effort; non-fatal on failure).
report: dict = {}
if idf_report_text is not None:
try:
report = parse_idf_report(idf_report_text)
except Exception as exc:
log.warning(
"save_imported_idf: report parse failed: %s — continuing without it",
exc,
)
# Resolve serial: prefer the explicit hint, fall back to filename prefix.
serial = (
serial_hint
or report.get("serial_number")
or _idf_serial_from_filename(source_path.name)
or "UNKNOWN"
)
# Resolve event timestamp + kind from the filename (always present).
parsed_name = parse_event_filename(source_path.name)
kind = "Waveform"
ts_dt: Optional[datetime.datetime] = None
if parsed_name is not None:
_, ts_dt, kind_token = parsed_name
kind = "Histogram" if kind_token == "IDFH" else "Waveform"
# Report's event_datetime is the device-authoritative value; prefer it.
if "event_datetime" in report:
try:
ts_dt = datetime.datetime.fromisoformat(report["event_datetime"])
except (TypeError, ValueError):
pass
ts_obj: Optional[Timestamp] = None
if ts_dt is not None:
ts_obj = Timestamp(
raw=bytes(9),
flag=0,
year=ts_dt.year,
unknown_byte=0,
month=ts_dt.month,
day=ts_dt.day,
hour=ts_dt.hour,
minute=ts_dt.minute,
second=ts_dt.second,
)
# Build PeakValues from the report (fields are None when absent).
pv = PeakValues(
tran=report.get("tran_ppv"),
vert=report.get("vert_ppv"),
long=report.get("long_ppv"),
micl=report.get("mic_ppv"),
peak_vector_sum=report.get("peak_vector_sum"),
)
# Build ProjectInfo. See idf_ascii_report — Thor's title strings
# carry project / client / company / notes in TitleString1..4.
pi = ProjectInfo(
setup_name=report.get("setup"),
project=report.get("project"),
client=report.get("client"),
operator=report.get("operator"),
sensor_location=None, # Thor folds location into TitleString1 = project
notes=report.get("notes"),
)
# Filesystem write.
filename = source_path.name
bw_path = self._serial_dir(serial) / filename
bw_path.write_bytes(idf_bytes)
filesize = bw_path.stat().st_size
sha256 = event_file_io.file_sha256(bw_path)
# _waveform_key dedups (serial, timestamp) rows in the events
# table. Use the binary's sha256 (first 16 bytes) as a stable
# surrogate — every distinct binary maps to a distinct row.
waveform_key = bytes.fromhex(sha256)[:16]
ev = Event(
index=0,
timestamp=ts_obj,
sample_rate=report.get("sample_rate"),
peak_values=pv,
project_info=pi,
record_type=kind,
rectime_seconds=report.get("record_time_sec"),
)
ev._waveform_key = waveform_key
# Write the sidecar. Source kind "idf-import" was added to the
# allow-list in event_file_io.event_to_sidecar_dict for this.
sidecar_path = self.sidecar_path_for(serial, filename)
existing_review = None
if sidecar_path.exists():
try:
existing_review = event_file_io.read_sidecar(sidecar_path).get("review")
except Exception:
pass
sidecar = event_file_io.event_to_sidecar_dict(
ev,
serial=serial,
blastware_filename=filename,
blastware_filesize=filesize,
blastware_sha256=sha256,
source_kind="idf-import",
a5_pickle_filename=None,
review=existing_review,
)
# Stash the full parsed IDF report under extensions so downstream
# consumers can recover the rich derived fields that don't fit
# the BW-shaped event model (Peak Acceleration / Displacement,
# Time of Peak, sensor self-check, calibration, firmware).
if report:
sidecar["extensions"]["idf_report"] = report
event_file_io.write_sidecar(sidecar_path, sidecar)
log.info(
"WaveformStore.save_imported_idf serial=%s filename=%s filesize=%d "
"report_attached=%s",
serial, filename, filesize, bool(report),
)
return ev, {
"filename": filename,
"filesize": filesize,
"sha256": sha256,
"a5_pickle_filename": None,
"hdf5_filename": None,
"sidecar_filename": sidecar_path.name,
"serial": serial,
}
def load_a5(self, serial: str, filename: str) -> Optional[list[S3Frame]]:
"""
Re-hydrate the pickled A5 frame stream for a stored event.