feat(import): parse paired BW ASCII reports on /db/import/blastware_file

Blastware's ACH writes a per-event ASCII report (.TXT) alongside each
event binary, containing the rich derived per-channel fields BW
computes (PPV, ZC Freq, Time of Peak, Peak Acceleration, Peak
Displacement, Peak Vector Sum + time, sensor self-check Pass/Fail,
monitor-log timestamps).  None of this lives in the BW binary itself.

When the watcher daemon forwards both files to /db/import/blastware_file
in one multipart POST, we now:

  - Pair binaries with their .TXT partners by filename match
  - Parse the report into a structured BwAsciiReport
  - Land the rich fields in a new top-level `bw_report` block of the
    sidecar JSON
  - Overlay the report's peaks/project_info/timestamp/sample_rate/
    record_time/total_samples/pretrig_samples onto the canonical
    sidecar fields (the report values are device-authoritative; the
    BW-binary STRT-derived values had bugs like reading the 0x46
    record-type marker as rectime)

This unblocks the monthly-summary review workflow — events become
sortable/filterable by peak, location, project, etc. — without
depending on the still-undecoded waveform body codec.
This commit is contained in:
2026-05-08 23:56:43 +00:00
parent 510cec8395
commit cdfe4ad3c8
6 changed files with 1097 additions and 24 deletions
+46 -6
View File
@@ -1619,6 +1619,21 @@ async def db_import_blastware_file(
writes a .sfm.json sidecar with source.kind = "bw-import".
2. Upsert a row into `events` (dedup'd on serial+timestamp).
**Paired BW ASCII reports.** When Blastware's ACH writes events,
it also emits a per-event report alongside each binary as
``<binary>.TXT`` (e.g. ``M529LK44.AB0`` + ``M529LK44.AB0.TXT``).
If a request includes ``.TXT`` files matching a binary's filename,
the report is parsed and its decoded fields land in the sidecar's
``bw_report`` block — including device-authoritative peaks, ZC
Freq, Peak Acceleration, Peak Displacement, Time of Peak, sensor
self-check results, and monitor-log timestamps. The daemon-
forwarded ACH workflow should always send both files together
so the SFM database has the rich metadata for sort/filter/report.
Pairing is by exact filename match (case-insensitive on the
extension): a binary named ``foo.AB0`` is paired with a report
named ``foo.AB0.TXT`` or ``foo.AB0.txt``.
Response includes per-file outcomes so the caller can see which
landed cleanly and which failed (e.g. malformed file, unknown
serial, etc.).
@@ -1627,21 +1642,36 @@ async def db_import_blastware_file(
db = _get_db()
results: list[dict] = []
# Read every upload up front (UploadFile.read() is one-shot under
# FastAPI's spooled-tempfile backing) and split into binaries vs
# paired ASCII reports.
binaries: list[tuple[str, bytes]] = []
reports: dict[str, bytes] = {} # keyed by lower-cased stem (without .txt)
for upload in files:
name = upload.filename or ""
try:
content = await upload.read()
except Exception as exc:
results.append({
"filename": upload.filename, "status": "error",
"detail": f"read failed: {exc}",
"filename": name or "<unnamed>", "status": "error",
"detail": f"read failed: {exc}",
})
continue
if name.lower().endswith(".txt"):
# Strip the ".txt" suffix to get the binary's filename.
reports[name[:-4].lower()] = content
else:
binaries.append((name, content))
for filename, content in binaries:
report_bytes = reports.get(filename.lower())
try:
ev, rec = store.save_imported_bw(
content,
source_path=Path(upload.filename or "imported.bw"),
source_path=Path(filename or "imported.bw"),
serial_hint=serial,
bw_report_text=report_bytes,
)
inserted, skipped = db.insert_events(
[ev],
@@ -1652,21 +1682,31 @@ async def db_import_blastware_file(
} if ev._waveform_key else None,
)
results.append({
"filename": upload.filename,
"filename": filename,
"status": "ok",
"stored_filename": rec["filename"],
"filesize": rec["filesize"],
"sha256": rec["sha256"],
"report_attached": report_bytes is not None,
"inserted": inserted,
"skipped": skipped,
})
except Exception as exc:
log.error("import failed for %s: %s", upload.filename, exc, exc_info=True)
log.error("import failed for %s: %s", filename, exc, exc_info=True)
results.append({
"filename": upload.filename, "status": "error",
"filename": filename, "status": "error",
"detail": str(exc),
})
# Surface unmatched .txt uploads so the daemon can detect mis-pairings.
used_report_keys = {fn.lower() for fn, _ in binaries}
for stem in reports.keys() - used_report_keys:
results.append({
"filename": stem + ".txt",
"status": "warning",
"detail": "BW ASCII report supplied but no matching binary in this upload",
})
return {"count": len(results), "results": results}