v0.20.0 -- Full s3 event parse and PDF creation. #28

Merged
serversdown merged 46 commits from dev into main 2026-05-28 17:54:34 -04:00
5 changed files with 85 additions and 0 deletions
Showing only changes of commit ad2b553c7b - Show all commits
+2
View File
@@ -8,6 +8,8 @@ All notable changes to seismo-relay are documented here.
### Added ### Added
- **Raw BW ASCII report (.TXT) preservation.** `save_imported_bw` now writes the paired `_ASCII.TXT` to `<store>/<serial>/<filename>_ASCII.TXT` alongside the binary at ingest time. Previously the .TXT was parsed into the sidecar's `bw_report` projection and then discarded — meaning parser bug fixes couldn't be applied retroactively without re-forwarding from the watcher PC. Now the raw .TXT lives in the waveform store permanently (~15 KB per event; ~210 MB total for a 14k-event store; negligible). Sidecar's `source.txt_filename` field records the saved path; backfill_sidecars preserves it across regens. New `GET /db/events/{id}/ascii_report.txt` endpoint serves the raw .TXT for any event ingested after this change. Events ingested before today still return 404 from that endpoint until re-forwarded. Architectural rationale: with BW Mail / Forwarding Agent being phased out of the operator workflow, the XML/PDF/WMF that those tools produced are no longer available — the binary + .TXT (created by BW ACH itself) are our authoritative source for everything going forward.
- **Event Report PDF generation** — `GET /db/events/{id}/report.pdf` returns a single-page letter-portrait PDF for any event with waveform data on disk. Covers every field a Blastware Event Report includes: header metadata (date/time, trigger source, range, sample rate, project/client/operator/location, serial+firmware, battery, calibration, file name), microphone block (PSPL in dB(L) + psi, ZC freq, channel test), per-channel stats table (rows differ for waveform vs histogram), Peak Vector Sum, and the 4-channel plot. Iterated against real Blastware reference PDFs (uploaded to `example-events/pdfsnstuff/`): - **Event Report PDF generation** — `GET /db/events/{id}/report.pdf` returns a single-page letter-portrait PDF for any event with waveform data on disk. Covers every field a Blastware Event Report includes: header metadata (date/time, trigger source, range, sample rate, project/client/operator/location, serial+firmware, battery, calibration, file name), microphone block (PSPL in dB(L) + psi, ZC freq, channel test), per-channel stats table (rows differ for waveform vs histogram), Peak Vector Sum, and the 4-channel plot. Iterated against real Blastware reference PDFs (uploaded to `example-events/pdfsnstuff/`):
- **Waveform layout**: header shows Date/Time, Trigger Source, Range, Sample Rate; stats table has PPV / ZC Freq / Time (Rel. to Trig) / Peak Accel / Peak Disp / Sensor Check; bottom plot is 4-channel line waveform (MicL top → Tran bottom), shared time axis in seconds, dashed trigger line + triangle marker at t=0, symmetric Y on geo channels, zero-anchored on mic, "0.0" baseline label on right per BW convention; footer shows `Time X sec/div Amplitude Geo: Y in/s/div Mic: 0.001 psi(L)/div` and the trigger window `▶━━◀` marker. USBM RI8507/OSMRE compliance chart placeholder upper-right. - **Waveform layout**: header shows Date/Time, Trigger Source, Range, Sample Rate; stats table has PPV / ZC Freq / Time (Rel. to Trig) / Peak Accel / Peak Disp / Sensor Check; bottom plot is 4-channel line waveform (MicL top → Tran bottom), shared time axis in seconds, dashed trigger line + triangle marker at t=0, symmetric Y on geo channels, zero-anchored on mic, "0.0" baseline label on right per BW convention; footer shows `Time X sec/div Amplitude Geo: Y in/s/div Mic: 0.001 psi(L)/div` and the trigger window `▶━━◀` marker. USBM RI8507/OSMRE compliance chart placeholder upper-right.
- **Histogram layout**: header shows Start / Finish / Intervals At Size / Range / Sample Rate (no Trigger Source — histograms aren't triggered); NO USBM chart; stats table has PPV / ZC Freq / Date / Time / Sensor Check; bottom plot is per-interval bar chart, Y-axis 0-to-peak (never negative), 0.0 baseline at the bottom; footer shows `Time INTERVAL_SIZE /div Amplitude Geo: Y in/s/div Mic: 0.001 psi(L)/div`. - **Histogram layout**: header shows Start / Finish / Intervals At Size / Range / Sample Rate (no Trigger Source — histograms aren't triggered); NO USBM chart; stats table has PPV / ZC Freq / Date / Time / Sensor Check; bottom plot is per-interval bar chart, Y-axis 0-to-peak (never negative), 0.0 baseline at the bottom; footer shows `Time INTERVAL_SIZE /div Amplitude Geo: Y in/s/div Mic: 0.001 psi(L)/div`.
+2
View File
@@ -332,6 +332,7 @@ def event_to_sidecar_dict(
blastware_filesize: int, blastware_filesize: int,
blastware_sha256: str, blastware_sha256: str,
source_kind: str = "sfm-live", source_kind: str = "sfm-live",
txt_filename: Optional[str] = None,
a5_pickle_filename: Optional[str] = None, a5_pickle_filename: Optional[str] = None,
tool_version: str = _TOOL_VERSION_DEFAULT, tool_version: str = _TOOL_VERSION_DEFAULT,
captured_at: Optional[datetime.datetime] = None, captured_at: Optional[datetime.datetime] = None,
@@ -448,6 +449,7 @@ def event_to_sidecar_dict(
"captured_at": captured_at.isoformat() + "Z" if captured_at.tzinfo is None else captured_at.isoformat(), "captured_at": captured_at.isoformat() + "Z" if captured_at.tzinfo is None else captured_at.isoformat(),
"tool_version": tool_version, "tool_version": tool_version,
"a5_pickle_filename": a5_pickle_filename, "a5_pickle_filename": a5_pickle_filename,
"txt_filename": txt_filename,
}, },
"review": review or { "review": review or {
+6
View File
@@ -300,12 +300,17 @@ def main(argv=None) -> int:
preserved_review = None preserved_review = None
preserved_ext = None preserved_ext = None
preserved_bw_report = None preserved_bw_report = None
preserved_txt_fn = None
if sidecar_path.exists(): if sidecar_path.exists():
try: try:
_existing = event_file_io.read_sidecar(sidecar_path) _existing = event_file_io.read_sidecar(sidecar_path)
preserved_review = _existing.get("review") preserved_review = _existing.get("review")
preserved_ext = _existing.get("extensions") preserved_ext = _existing.get("extensions")
preserved_bw_report = _existing.get("bw_report") preserved_bw_report = _existing.get("bw_report")
# Preserve txt_filename so backfills don't blank out the
# pointer to the saved raw .TXT (events ingested after
# 2026-05-27 have this).
preserved_txt_fn = (_existing.get("source") or {}).get("txt_filename")
except Exception: except Exception:
pass pass
@@ -334,6 +339,7 @@ def main(argv=None) -> int:
blastware_sha256=bw_sha, blastware_sha256=bw_sha,
source_kind=source_kind, source_kind=source_kind,
a5_pickle_filename=a5_filename, a5_pickle_filename=a5_filename,
txt_filename=preserved_txt_fn,
review=preserved_review, review=preserved_review,
extensions=preserved_ext, extensions=preserved_ext,
) )
+33
View File
@@ -2178,6 +2178,39 @@ def db_event_blastware_file(event_id: str) -> FileResponse:
) )
@app.get("/db/events/{event_id}/ascii_report.txt")
def db_event_ascii_report_txt(event_id: str):
"""Serve the raw BW ASCII report (.TXT) for an event, when preserved.
Returns 404 for events ingested before the .TXT-preservation feature
landed (2026-05-27) those events have only the parsed ``bw_report``
block in the sidecar, not the raw .TXT. Re-forwarding from the
watcher PC will populate the .TXT going forward.
"""
row = _get_db().get_event(event_id)
if row is None:
raise HTTPException(status_code=404, detail=f"Event {event_id} not found")
serial = row.get("serial")
filename = row.get("blastware_filename")
if not serial or not filename:
raise HTTPException(status_code=404, detail="Event has no associated BW file")
txt_path = _get_store().open_txt(serial, filename)
if txt_path is None:
raise HTTPException(
status_code=404,
detail=(
f"Raw .TXT not preserved for {filename}. Events ingested "
"before 2026-05-27 don't have it; re-forward from the "
"watcher PC to populate."
),
)
return FileResponse(
path=str(txt_path),
media_type="text/plain",
filename=txt_path.name,
)
@app.get("/db/events/{event_id}/report.pdf") @app.get("/db/events/{event_id}/report.pdf")
def db_event_report_pdf(event_id: str): def db_event_report_pdf(event_id: str):
"""Render an Instantel-style Event Report as a PDF. """Render an Instantel-style Event Report as a PDF.
+42
View File
@@ -108,11 +108,30 @@ class WaveformStore:
"""Return absolute path to the .h5 clean-waveform file for a given event.""" """Return absolute path to the .h5 clean-waveform file for a given event."""
return self._serial_dir(serial) / f"{filename}.h5" return self._serial_dir(serial) / f"{filename}.h5"
def txt_path_for(self, serial: str, filename: str) -> Path:
"""Return absolute path to the preserved BW ASCII report (.TXT)
for a given event.
We name it ``<filename>_ASCII.TXT`` to match BW's own filename
convention in the ACH folder. Saved at ingest time alongside
the binary so the parser bug fixes can be applied retroactively
by re-parsing without needing to re-forward from the watcher PC.
"""
return self._serial_dir(serial) / f"{filename}_ASCII.TXT"
def open_blastware(self, serial: str, filename: str) -> Optional[Path]: def open_blastware(self, serial: str, filename: str) -> Optional[Path]:
"""Return absolute path to an existing event file or None.""" """Return absolute path to an existing event file or None."""
bw_path, _ = self.paths_for(serial, filename) bw_path, _ = self.paths_for(serial, filename)
return bw_path if bw_path.exists() else None return bw_path if bw_path.exists() else None
def open_txt(self, serial: str, filename: str) -> Optional[Path]:
"""Return absolute path to the preserved BW ASCII report for an
event, or None if the .TXT wasn't saved at ingest time (events
ingested before .TXT preservation landed will show None until
re-forwarded)."""
p = self.txt_path_for(serial, filename)
return p if p.exists() else None
# ── save / load ───────────────────────────────────────────────────────────── # ── save / load ─────────────────────────────────────────────────────────────
def save( def save(
@@ -357,6 +376,28 @@ class WaveformStore:
filesize = bw_path.stat().st_size filesize = bw_path.stat().st_size
sha256 = event_file_io.file_sha256(bw_path) sha256 = event_file_io.file_sha256(bw_path)
# 1b. preserve the raw BW ASCII report (.TXT) alongside the binary.
# Saved at <root>/<serial>/<filename>_ASCII.TXT. Lets us re-parse
# offline after parser fixes without needing to re-forward from
# the watcher PC. Negligible storage cost (~15 KB per event).
# Skipped silently when no report was supplied (live download path,
# manual upload without paired TXT).
txt_filename: Optional[str] = None
if bw_report_text is not None:
try:
txt_path = self.txt_path_for(serial, filename)
if isinstance(bw_report_text, bytes):
txt_path.write_bytes(bw_report_text)
else:
txt_path.write_text(bw_report_text)
txt_filename = txt_path.name
except Exception as exc:
log.warning(
"save_imported_bw: failed to save TXT for %s: %s"
"continuing without it",
filename, exc,
)
# 2. write the .h5 clean-waveform file from the parsed Event. # 2. write the .h5 clean-waveform file from the parsed Event.
# Note: peaks here are computed from raw samples (the BW file # Note: peaks here are computed from raw samples (the BW file
# doesn't carry the device-authoritative 0C peaks). Best-effort. # doesn't carry the device-authoritative 0C peaks). Best-effort.
@@ -393,6 +434,7 @@ class WaveformStore:
blastware_sha256=sha256, blastware_sha256=sha256,
source_kind="bw-import", source_kind="bw-import",
a5_pickle_filename=None, a5_pickle_filename=None,
txt_filename=txt_filename,
review=existing_review, review=existing_review,
bw_report=bw_report, bw_report=bw_report,
) )