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:
+46
-6
@@ -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}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user