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}
|
||||
|
||||
|
||||
|
||||
+27
-4
@@ -34,7 +34,7 @@ import logging
|
||||
import pickle
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, Union
|
||||
|
||||
from minimateplus import event_file_io
|
||||
from minimateplus.blastware_file import blastware_filename, write_blastware_file
|
||||
@@ -258,6 +258,7 @@ class WaveformStore:
|
||||
source_path: Path,
|
||||
*,
|
||||
serial_hint: Optional[str] = None,
|
||||
bw_report_text: Optional[Union[str, bytes]] = None,
|
||||
) -> tuple[Event, dict]:
|
||||
"""
|
||||
Ingest a Blastware event file produced by an external tool
|
||||
@@ -267,10 +268,17 @@ class WaveformStore:
|
||||
Workflow:
|
||||
1. Parse the bytes via event_file_io.read_blastware_file (writes
|
||||
a temp file to do that, since the parser takes a path).
|
||||
2. Resolve serial from BW filename (`<P><serial3>...`) or use
|
||||
2. Optionally parse a paired BW ASCII event report (the .TXT
|
||||
file BW writes alongside the binary). When supplied, its
|
||||
decoded fields land in the sidecar's `bw_report` block AND
|
||||
overlay the device-authoritative peak values into the
|
||||
top-level `peak_values` block. This is the right path for
|
||||
the ACH-forwarder daemon use case where Blastware's own
|
||||
ACH writes both files into the watch folder.
|
||||
3. Resolve serial from BW filename (`<P><serial3>...`) or use
|
||||
serial_hint. Falls back to "UNKNOWN".
|
||||
3. Copy the BW bytes verbatim into <root>/<serial>/<filename>.
|
||||
4. Write the .sfm.json sidecar with source.kind = "bw-import"
|
||||
4. Copy the BW bytes verbatim into <root>/<serial>/<filename>.
|
||||
5. Write the .sfm.json sidecar with source.kind = "bw-import"
|
||||
and a5_pickle_filename = None. Does NOT write a .a5.pkl
|
||||
(no A5 source available; byte-for-byte regeneration not
|
||||
possible — the on-disk BW file IS the byte-for-byte source).
|
||||
@@ -292,6 +300,20 @@ class WaveformStore:
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
# Parse the BW ASCII report if one was supplied. Failures here
|
||||
# are non-fatal: we still write the binary + sidecar without the
|
||||
# rich derived fields.
|
||||
bw_report = None
|
||||
if bw_report_text is not None:
|
||||
try:
|
||||
from minimateplus.bw_ascii_report import parse_report
|
||||
bw_report = parse_report(bw_report_text)
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"save_imported_bw: BW report parse failed: %s — continuing without it",
|
||||
exc,
|
||||
)
|
||||
|
||||
# Resolve serial. blastware_filename derives a 4-char prefix from
|
||||
# the numeric serial (e.g. BE11529 → M529); we go the other way
|
||||
# via the source filename if a hint wasn't given.
|
||||
@@ -345,6 +367,7 @@ class WaveformStore:
|
||||
source_kind="bw-import",
|
||||
a5_pickle_filename=None,
|
||||
review=existing_review,
|
||||
bw_report=bw_report,
|
||||
)
|
||||
event_file_io.write_sidecar(sidecar_path, sidecar)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user