sfm: Event Report PDF generation (v0.20.0 stub layout)

New endpoint GET /db/events/{id}/report.pdf returns a single-page
letter-portrait PDF for any event with waveform data on disk.

Architecture:
  sfm/report_pdf.py — gather_report_data() assembles fields from
    SeismoDb row + .sfm.json sidecar (bw_report block) + .h5 samples;
    render_event_report_pdf() turns that into PDF bytes via matplotlib.
  sfm/server.py — new endpoint wires them together, streams PDF back
    with Content-Disposition: inline so the browser displays it.
  sfm_webapp.html — new "Download PDF" button in the event modal
    footer that opens the endpoint in a new tab.

Fields surfaced — same coverage as a Blastware Event Report:
  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 (PPV, ZC Freq, Time of Peak, Peak Accel,
                     Peak Disp, Sensor Check) for Tran/Vert/Long
  Peak Vector Sum
  Waveform plot (MicL/Long/Vert/Tran stacked, shared time axis,
                 trigger marker, symmetric Y for geo, zero-anchored
                 mic) — OR per-interval bar chart for histograms.

Rendering pipeline = matplotlib only (vector PDF, no headless-browser
dep).  Adds matplotlib>=3.8 to deps.

Visual layout is approximate until reference PDFs from Instantel land
at docs/reference/instantel/ for iteration.  USBM RI8507 / OSMRE
compliance chart is stubbed (placeholder rectangle) — separate work
item.

Smoke-tested on a K558 waveform event: 77 KB valid PDF, all fields
populated correctly from the snapshot DB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 02:55:58 +00:00
parent ed926de3f4
commit 411ef8139e
6 changed files with 566 additions and 1 deletions
+27 -1
View File
@@ -46,7 +46,7 @@ from typing import Optional
# FastAPI / Pydantic
try:
from fastapi import Body, FastAPI, File, HTTPException, Query, UploadFile
from fastapi import Body, FastAPI, File, HTTPException, Query, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pydantic import BaseModel
@@ -2178,6 +2178,32 @@ def db_event_blastware_file(event_id: str) -> FileResponse:
)
@app.get("/db/events/{event_id}/report.pdf")
def db_event_report_pdf(event_id: str):
"""Render an Instantel-style Event Report as a PDF.
Single-page letter portrait, matches the BW Event Report's data
coverage and layout (header / mic block / per-channel stats /
waveform plot). V0.20.0 stub — exact visual being iterated
against reference PDFs in ``docs/reference/instantel/``.
Returns 404 if the event is unknown or has no waveform data on
disk (same condition as /waveform.json).
"""
from sfm import report_pdf
rd = report_pdf.gather_report_data(_get_db(), _get_store(), event_id)
if rd is None:
raise HTTPException(status_code=404, detail=f"Event {event_id} not found or has no waveform")
pdf_bytes = report_pdf.render_event_report_pdf(rd)
# Suggested download filename based on the BW file basename.
fname = (rd.file_name or event_id).replace(".", "_")
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{fname}_report.pdf"'},
)
@app.get("/db/events/{event_id}/waveform.json")
def db_event_waveform_json(event_id: str) -> dict:
"""