diff --git a/CHANGELOG.md b/CHANGELOG.md index 03bf500..ed75adf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ All notable changes to seismo-relay are documented here. ### Added +- **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 (PPV / ZC Freq / Time of Peak / Peak Accel / Peak Disp / Sensor Check), Peak Vector Sum, and the 4-channel waveform plot stacked Instantel-style (MicL top → Tran bottom, shared time axis, trigger marker, symmetric Y on geo channels, zero-anchored on mic). Histogram events render as per-interval bar charts instead of waveform plots. USBM RI8507 / OSMRE compliance chart still stubbed — separate work item. Backed by matplotlib (vector PDF output, no headless-browser dep); new `sfm/report_pdf.py` does data assembly + rendering. **Visual layout is approximate** until reference PDFs land at `docs/reference/instantel/` to iterate against. +- **"Download PDF" button** in the event modal's footer — triggers the new endpoint; opens in a new tab so the browser handles save-or-display + surfaces any 404 / server errors visibly. + - **SFM webapp now opens to Database view by default** and the History table is fully interactive. Click any column header to sort ascending / descending (timestamp, serial, per-channel PPV, PVS, mic dB(L), project, client, record type, key — all sortable). Click any event row to open the event modal, which now renders a **4-channel waveform plot inline** (MicL / Long / Vert / Tran stacked, Instantel-printout order) alongside the existing sidecar review fields. Headers are sticky so the columns stay visible while scrolling long event lists. No more "where is the viewer" — pick a unit from the filter dropdown, scan the table, click the event, see the waveform. - **Stored-event browser** — new standalone HTML page at `GET /events` (`sfm/event_browser.html`). Pick a serial from the unit dropdown, scroll through that unit's events (newest-first), click any event to render its decoded waveform via the existing `/db/events/{id}/waveform.json` endpoint. Dark-themed Chart.js viewer, channels stacked vertically (MicL / Long / Vert / Tran — Instantel printout order, designed PDF-export-ready), trigger line at t=0, peak labels, search/filter, false-trigger flag honored. Companion to the existing live-device viewer at `/waveform`; the two routes are now clearly delineated in their docstrings. The webapp's inline plot at `/` is the primary path; `/events` remains a useful diagnostic when you want just a viewer. - **Histogram body codec — uint8 peak count fix.** Per-channel peak fields at `block[6]/[10]/[14]/[18]` are `uint8`, not `uint16 LE` spanning `block[6:8]` etc. The original interpretation was byte-exact on the N844 fixture corpus only because every annotation byte (`block[7]/[11]/[15]/[19]`) in those fixtures was zero. On non-N844 events with non-zero annotation bytes (observed across BE9558 Tran-drift and BE18003 Histogram+Continuous units), the old interpretation produced peaks up to 268 in/s per channel and 35× inflated PVS sums when first deployed to prod (rolled back same day; properly fixed in this release). Cross-correlated against BW's per-interval ASCII export on K558 / T003 / N599 / N844 corpora — 100% byte-exact on T/V/L, 99%+ on M (sub-precision rounding). Annotation byte preserved on each record as `record["annotations"]` for future RE. Verified against ~3,500 blocks across 5 in-repo fixtures + a synthetic K558 interval-12 regression block. diff --git a/pyproject.toml b/pyproject.toml index 7674acc..d5db51f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "python-multipart>=0.0.7", "h5py>=3.10", "numpy>=1.24", + "matplotlib>=3.8", ] [tool.setuptools.packages.find] diff --git a/requirements.txt b/requirements.txt index 8b01960..c77bbf7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ pyserial python-multipart h5py numpy +matplotlib diff --git a/sfm/report_pdf.py b/sfm/report_pdf.py new file mode 100644 index 0000000..2bbcf22 --- /dev/null +++ b/sfm/report_pdf.py @@ -0,0 +1,518 @@ +""" +sfm/report_pdf.py — generate Instantel-style Event Report PDFs. + +Stub layout for v0.20.0 — the exact visual is iterated against actual +Blastware reference PDFs (uploaded to docs/reference/instantel/). +Current output captures all the data fields a real BW Event Report +contains, but the visual hierarchy / spacing is still approximate. + +Architecture +──────────── +1. ``gather_report_data(event_id)`` — assembles a flat dict from three + sources: the SeismoDb events row, the .sfm.json sidecar (bw_report + block), and the .h5 waveform samples. Returns ``None`` when the + event doesn't exist or has no waveform data on disk. + +2. ``render_event_report_pdf(data)`` — takes that dict and produces a + single-page letter-sized PDF as bytes, using matplotlib's PDF + backend (vector output, no rasterization, prints cleanly). + +3. The HTTP endpoint at ``/db/events/{id}/report.pdf`` wires them + together: fetch event → gather → render → stream bytes back with + ``Content-Type: application/pdf``. + +What's in the report (every field BW's printout includes): + + Header (left): Date/Time, Trigger Source, Range, Sample Rate, Notes, + Project, Client, User Name, Seis. Loc + Header (right): Serial + firmware, Battery, Calibration, File Name, + Post Event Notes + Mic block: PSPL (dBL + psi), ZC Freq, Channel Test result + Stats table: per-channel PPV / ZC Freq / Time of Peak / + Peak Acceleration / Peak Displacement / Sensor Check + Peak Vector Sum + Waveform plot: 4 channels stacked (MicL/Long/Vert/Tran), shared + time axis, trigger marker, peak markers + USBM RI8507/OSMRE compliance chart: STUBBED — separate work item + +Histogram events: the layout differs (Number of Intervals header +field, no trigger marker, per-interval bar chart instead of waveform). +Handled via a record_type branch in ``render_event_report_pdf``. +""" + +from __future__ import annotations + +import io +import json +import logging +import math +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +import matplotlib +matplotlib.use("Agg") # headless — no display required +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.backends.backend_pdf import PdfPages + +log = logging.getLogger(__name__) + + +# Reference pressure for dB(L) conversion: 20 µPa expressed in psi. +DBL_REF_PSI = 2.9e-9 + + +# ── Data assembly ──────────────────────────────────────────────────────────── + + +@dataclass +class ReportData: + """All fields needed to render an Instantel-style Event Report. + + Most fields are Optional — BW's printout shows '—' or just omits + sections when source data is missing. The renderer mirrors that. + """ + # Header — left column + event_datetime_str: Optional[str] = None + trigger_source: Optional[str] = None + geo_range_str: Optional[str] = None + sample_rate_str: Optional[str] = None + notes: Optional[str] = None + project: Optional[str] = None + client: Optional[str] = None + operator: Optional[str] = None + sensor_location: Optional[str] = None + + # Header — right column + serial: Optional[str] = None + firmware: Optional[str] = None + battery_volts: Optional[float] = None + calibration_date: Optional[str] = None + calibration_by: Optional[str] = None + file_name: Optional[str] = None + post_event_notes: Optional[str] = None + + # Microphone block + mic_pspl_dbl: Optional[float] = None + mic_pspl_psi: Optional[float] = None + mic_pspl_time_s: Optional[float] = None + mic_zc_freq_hz: Optional[float] = None + mic_channel_test_result: Optional[str] = None + mic_channel_test_freq_hz: Optional[float] = None + mic_channel_test_amp_mv: Optional[float] = None + + # Per-channel stats — list of dicts (one per channel) + # Keys: name, ppv_ips, zc_freq_hz, time_of_peak_s, + # peak_accel_g, peak_disp_in, sensor_check + channel_stats: list[dict] = field(default_factory=list) + + # Peak Vector Sum + peak_vector_sum_ips: Optional[float] = None + peak_vector_sum_time_s: Optional[float] = None + + # Waveform samples — channels[ch] = list of floats in physical units + # Time axis derived from sample_rate + pretrig_samples + channels: dict = field(default_factory=dict) + sample_rate_sps: Optional[int] = None + pretrig_samples: Optional[int] = None + t0_ms: Optional[float] = None + dt_ms: Optional[float] = None + + # Record-type discriminator + record_type: Optional[str] = None + is_histogram: bool = False + + # Bookkeeping + event_id: Optional[str] = None + server_received_at: Optional[str] = None + bw_pc_sw_version: Optional[str] = None + + +def gather_report_data( + db, + store, + event_id: str, +) -> Optional[ReportData]: + """Collect every field needed to render an event report. + + Returns ``None`` if the event is unknown or has no waveform data + on disk (no .h5, no .a5.pkl — same condition the waveform.json + endpoint 404s on). + """ + row = db.get_event(event_id) + if row is None: + return None + serial = row.get("serial") + filename = row.get("blastware_filename") + if not serial or not filename: + return None + + rd = ReportData( + event_id=event_id, + serial=serial, + file_name=filename, + record_type=row.get("record_type"), + is_histogram=str(row.get("record_type", "")).lower().startswith("hist"), + event_datetime_str=row.get("timestamp"), + sample_rate_sps=row.get("sample_rate"), + project=row.get("project"), + client=row.get("client"), + operator=row.get("operator"), + sensor_location=row.get("sensor_location"), + server_received_at=row.get("created_at"), + ) + + # ── Sidecar bw_report — the rich BW-derived fields ── + sidecar_path = store.sidecar_path_for(serial, filename) + if sidecar_path.exists(): + try: + sc = json.loads(sidecar_path.read_text()) + except Exception as exc: + log.warning("gather_report_data: sidecar read failed: %s", exc) + sc = {} + bw = sc.get("bw_report") or {} + + # Trigger / range / sample-rate display + trig = bw.get("trigger") or {} + rd.trigger_source = ( + f"{trig.get('channel','')}: {trig.get('geo_level_ips')} in/s" + if trig.get("channel") or trig.get("geo_level_ips") is not None + else None + ) + rec = bw.get("recording") or {} + rd.geo_range_str = ( + f"Geo: {rec.get('geo_range_ips')} in/s" + if rec.get("geo_range_ips") is not None else None + ) + rt = rec.get("record_time_s") + if rt is not None and rd.sample_rate_sps: + rd.sample_rate_str = f"{rt:.1f} sec At {rd.sample_rate_sps} Sps" + + # Device block + dev = bw.get("device") or {} + rd.battery_volts = dev.get("battery_volts") + rd.calibration_date = dev.get("calibration_date") + rd.calibration_by = dev.get("calibration_by") + rd.firmware = bw.get("version") + rd.bw_pc_sw_version = bw.get("pc_sw_version") + + # Microphone block + mic = bw.get("mic") or {} + rd.mic_pspl_dbl = mic.get("pspl_dbl") + if rd.mic_pspl_dbl is not None and rd.mic_pspl_dbl > 0: + # Inverse of the dBL formula → psi. Mirrors waveform_codec convention. + rd.mic_pspl_psi = DBL_REF_PSI * (10 ** (rd.mic_pspl_dbl / 20)) + rd.mic_pspl_time_s = mic.get("time_of_peak_s") + rd.mic_zc_freq_hz = mic.get("zc_freq_hz") + sc_mic = (bw.get("sensor_check") or {}).get("mic") or {} + rd.mic_channel_test_result = sc_mic.get("result") + rd.mic_channel_test_freq_hz = sc_mic.get("freq_hz") + rd.mic_channel_test_amp_mv = sc_mic.get("amplitude_mv") + + # Per-channel stats (Tran / Vert / Long) + peaks = bw.get("peaks") or {} + sc_block = bw.get("sensor_check") or {} + for ch_lc, ch_label in (("tran", "Tran"), ("vert", "Vert"), ("long", "Long")): + ch = peaks.get(ch_lc) or {} + sc_ch = sc_block.get(ch_lc) or {} + rd.channel_stats.append({ + "name": ch_label, + "ppv_ips": ch.get("ppv_ips"), + "zc_freq_hz": ch.get("zc_freq_hz"), + "time_of_peak_s": ch.get("time_of_peak_s"), + "peak_accel_g": ch.get("peak_accel_g"), + "peak_disp_in": ch.get("peak_disp_in"), + "sensor_check": sc_ch.get("result"), + }) + + # Peak Vector Sum + vs = peaks.get("vector_sum") or {} + rd.peak_vector_sum_ips = vs.get("ips") + rd.peak_vector_sum_time_s = vs.get("time_s") + + # ── Waveform samples — from the .h5 via the existing helper ── + from sfm import event_hdf5 + h5_path = store.hdf5_path_for(serial, filename) + if h5_path.exists(): + try: + wf = event_hdf5.plot_json_from_hdf5(h5_path, event_id=event_id) + rd.channels = { + ch: (chd.get("values") or []) + for ch, chd in (wf.get("channels") or {}).items() + } + ta = wf.get("time_axis") or {} + rd.sample_rate_sps = rd.sample_rate_sps or ta.get("sample_rate") + rd.pretrig_samples = ta.get("pretrig_samples") + rd.t0_ms = ta.get("t0_ms") + rd.dt_ms = ta.get("dt_ms") + except Exception as exc: + log.warning("gather_report_data: hdf5 read failed: %s", exc) + + return rd + + +# ── PDF rendering ──────────────────────────────────────────────────────────── + + +def render_event_report_pdf(rd: ReportData) -> bytes: + """Render an event report dict to a single-page letter PDF. + + Returns the raw PDF bytes — caller streams them back via FastAPI. + + NOTE: this is a v0.20.0 stub layout. The visual hierarchy will be + refined once reference PDFs land at docs/reference/instantel/. All + fields the printout includes are surfaced; spacing and typography + are approximate. + """ + # Letter portrait — 8.5"×11" + fig = plt.figure(figsize=(8.5, 11), dpi=100) + fig.patch.set_facecolor("white") + + # Grid: header rows on top, stats in the middle, waveform plot at bottom + # height_ratios sum doesn't matter, only the relative proportions + gs = fig.add_gridspec( + nrows=4, ncols=1, + left=0.07, right=0.96, top=0.96, bottom=0.04, + height_ratios=[2.2, 1.0, 1.4, 5.0], + hspace=0.35, + ) + + # ── Header area (top) ── + ax_header = fig.add_subplot(gs[0]) + ax_header.axis("off") + _draw_header(ax_header, rd) + + # ── Mic block (left) + USBM chart placeholder (right) ── + ax_mic = fig.add_subplot(gs[1]) + ax_mic.axis("off") + _draw_mic_block(ax_mic, rd) + + # ── Per-channel stats table + Peak Vector Sum ── + ax_stats = fig.add_subplot(gs[2]) + ax_stats.axis("off") + _draw_channel_stats(ax_stats, rd) + + # ── Waveform / histogram plot ── + if rd.is_histogram: + _draw_histogram_subplot(fig, gs[3], rd) + else: + _draw_waveform_subplot(fig, gs[3], rd) + + # Footer + fig.text( + 0.07, 0.015, + f"Generated by seismo-relay • event_id={rd.event_id or '—'}", + fontsize=7, color="#888", ha="left", + ) + + buf = io.BytesIO() + fig.savefig(buf, format="pdf") + plt.close(fig) + return buf.getvalue() + + +def _kv(ax, x, y, label, value, *, label_w=0.18): + """Render a 'Label Value' row at axes-coordinates (x, y).""" + ax.text(x, y, label, fontsize=8, color="#555", ha="left", va="top", + transform=ax.transAxes) + ax.text(x + label_w, y, _fmt(value), fontsize=8, ha="left", va="top", + transform=ax.transAxes, family="monospace") + + +def _fmt(v): + """Format any field for display — '—' for None, str otherwise.""" + if v is None: + return "—" + if isinstance(v, float): + return f"{v:.4f}".rstrip("0").rstrip(".") + return str(v) + + +def _draw_header(ax, rd: ReportData) -> None: + """Two-column metadata header — matches BW printout layout.""" + # Left column + rows_left = [ + ("Date/Time", rd.event_datetime_str), + ("Trigger Source", rd.trigger_source), + ("Range", rd.geo_range_str), + ("Sample Rate", rd.sample_rate_str), + ("Notes", rd.notes), + ("Project:", rd.project), + ("Client:", rd.client), + ("User Name:", rd.operator), + ("Seis. Loc:", rd.sensor_location), + ] + rows_right = [ + ("Serial Number", f"{rd.serial or '—'}" + + (f" {rd.firmware}" if rd.firmware else "")), + ("Battery Level", f"{rd.battery_volts:.1f} Volts" if rd.battery_volts is not None else None), + ("Unit Calibration", (f"{rd.calibration_date}" + + (f" by {rd.calibration_by}" if rd.calibration_by else "")) + if rd.calibration_date else None), + ("File Name", rd.file_name), + ("Post Event Notes", rd.post_event_notes), + ] + y = 0.95 + dy = 0.10 + for label, value in rows_left: + _kv(ax, 0.0, y, label, value, label_w=0.18) + y -= dy + y = 0.95 + for label, value in rows_right: + _kv(ax, 0.55, y, label, value, label_w=0.20) + y -= dy + + +def _draw_mic_block(ax, rd: ReportData) -> None: + """Microphone block — PSPL, ZC Freq, Channel Test. USBM chart + placeholder on the right (filled in a separate work item).""" + ax.text(0.0, 0.95, "Microphone Linear Weighting", fontsize=8, color="#555", + transform=ax.transAxes, va="top") + rows = [] + if rd.mic_pspl_dbl is not None: + line = f"{rd.mic_pspl_dbl:.1f} dB(L)" + if rd.mic_pspl_time_s is not None: + line += f" at {rd.mic_pspl_time_s:.3f} sec." + rows.append(("PSPL", line)) + if rd.mic_zc_freq_hz is not None: + rows.append(("ZC Freq", f"{rd.mic_zc_freq_hz:.0f} Hz")) + if rd.mic_channel_test_result: + line = rd.mic_channel_test_result + if rd.mic_channel_test_freq_hz is not None and rd.mic_channel_test_amp_mv is not None: + line += (f" (Freq = {rd.mic_channel_test_freq_hz:.1f} Hz, " + f"Amp = {rd.mic_channel_test_amp_mv:.0f} mv)") + rows.append(("Channel Test", line)) + + y = 0.70 + for label, value in rows: + _kv(ax, 0.0, y, label, value, label_w=0.18) + y -= 0.22 + + # USBM chart placeholder — upper-right of this row + ax.text(0.75, 0.95, "USBM RI8507 / OSMRE", + fontsize=8, color="#555", ha="center", va="top", + transform=ax.transAxes) + ax.text(0.75, 0.45, "[compliance chart\nrenders here]", + fontsize=8, color="#bbb", ha="center", va="center", + transform=ax.transAxes, style="italic") + + +def _draw_channel_stats(ax, rd: ReportData) -> None: + """Per-channel stats table + Peak Vector Sum row.""" + # Build a 2-D array of strings: header row + 3 channel rows + headers = ["", "Tran", "Vert", "Long", ""] + rows = [ + ["PPV", "ppv_ips", "in/s"], + ["ZC Freq", "zc_freq_hz", "Hz"], + ["Time (Rel. to Trig)", "time_of_peak_s", "sec"], + ["Peak Acceleration", "peak_accel_g", "g"], + ["Peak Displacement", "peak_disp_in", "in"], + ["Sensor Check", "sensor_check", ""], + ] + ch_lookup = {c["name"]: c for c in rd.channel_stats} + + def _cell(field, ch_name): + val = ch_lookup.get(ch_name, {}).get(field) + if val is None: + return "—" + if field == "sensor_check": + return str(val) + if isinstance(val, float): + return f"{val:.3f}" + return str(val) + + table_data = [headers] + for label, field_name, unit in rows: + table_data.append([ + label, + _cell(field_name, "Tran"), + _cell(field_name, "Vert"), + _cell(field_name, "Long"), + unit, + ]) + + tbl = ax.table( + cellText=table_data, loc="upper left", + colWidths=[0.30, 0.13, 0.13, 0.13, 0.10], + cellLoc="left", edges="open", + ) + tbl.auto_set_font_size(False) + tbl.set_fontsize(8) + tbl.scale(1, 1.4) + # Header row styling + for j in range(5): + cell = tbl[(0, j)] + cell.set_text_props(weight="bold", color="#555") + + # Peak Vector Sum + if rd.peak_vector_sum_ips is not None: + line = f"Peak Vector Sum {rd.peak_vector_sum_ips:.3f} in/s" + if rd.peak_vector_sum_time_s is not None: + line += f" At {rd.peak_vector_sum_time_s:.3f} sec." + ax.text(0.0, -0.05, line, fontsize=9, weight="bold", + ha="left", va="top", transform=ax.transAxes) + + +def _channel_axis_color(ch: str) -> str: + return {"MicL": "#cc00cc", "Long": "#0066ff", "Vert": "#009933", "Tran": "#cc0000"}.get(ch, "#444") + + +def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None: + """4-channel stacked waveform plot — Instantel printout order + (MicL on top, Tran on bottom), shared x-axis.""" + inner = gridspec_cell.subgridspec(4, 1, hspace=0.0) + order = ["MicL", "Long", "Vert", "Tran"] + sr = rd.sample_rate_sps or 1024 + dt_ms = rd.dt_ms or (1000.0 / sr) + t0_ms = rd.t0_ms if rd.t0_ms is not None else 0.0 + + last_idx = len(order) - 1 + for i, ch in enumerate(order): + ax = fig.add_subplot(inner[i]) + values = rd.channels.get(ch) or [] + times = [t0_ms + j * dt_ms for j in range(len(values))] + + if values: + color = _channel_axis_color(ch) + ax.plot(times, values, color=color, linewidth=0.6) + # Symmetric y-axis for geo; zero-anchored for mic + if ch != "MicL": + amax = max((abs(v) for v in values), default=0.001) + ax.set_ylim(-amax * 1.1, amax * 1.1) + # Channel label on left + ax.set_ylabel(ch, fontsize=8, rotation=0, ha="right", va="center", + color=_channel_axis_color(ch), weight="bold", labelpad=14) + ax.grid(True, linestyle=":", linewidth=0.4, alpha=0.5) + # Dashed trigger line at t=0 + ax.axvline(0.0, color="#cc0000", linestyle="--", linewidth=0.8, alpha=0.7) + # Zero baseline + ax.axhline(0.0, color="#888", linestyle="-", linewidth=0.4, alpha=0.5) + + if i != last_idx: + ax.set_xticklabels([]) + else: + ax.set_xlabel("Time (ms)", fontsize=8) + ax.tick_params(axis="both", labelsize=7) + + +def _draw_histogram_subplot(fig, gridspec_cell, rd: ReportData) -> None: + """4-channel stacked histogram bar chart — per-interval peaks.""" + inner = gridspec_cell.subgridspec(4, 1, hspace=0.0) + order = ["MicL", "Long", "Vert", "Tran"] + last_idx = len(order) - 1 + for i, ch in enumerate(order): + ax = fig.add_subplot(inner[i]) + values = rd.channels.get(ch) or [] + if values: + xs = np.arange(1, len(values) + 1) + color = _channel_axis_color(ch) + ax.bar(xs, values, color=color, width=1.0, linewidth=0) + ax.set_ylabel(ch, fontsize=8, rotation=0, ha="right", va="center", + color=_channel_axis_color(ch), weight="bold", labelpad=14) + ax.grid(True, axis="y", linestyle=":", linewidth=0.4, alpha=0.5) + if i != last_idx: + ax.set_xticklabels([]) + else: + ax.set_xlabel("Interval", fontsize=8) + ax.tick_params(axis="both", labelsize=7) diff --git a/sfm/server.py b/sfm/server.py index dfc3b45..2474681 100644 --- a/sfm/server.py +++ b/sfm/server.py @@ -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: """ diff --git a/sfm/sfm_webapp.html b/sfm/sfm_webapp.html index ad2b6e9..188f4d5 100644 --- a/sfm/sfm_webapp.html +++ b/sfm/sfm_webapp.html @@ -2892,6 +2892,18 @@ function closeSidecarModal() { _destroyScCharts(); } +// Trigger a PDF download for the currently-open event. The browser +// handles the actual save dialog from the Content-Disposition header +// the server sends. +function downloadEventReport() { + if (!_scCurrentEventId) return; + const url = `${api()}/db/events/${_scCurrentEventId}/report.pdf`; + // Open in a new tab — browser prompts to save or displays inline, + // and a failed fetch (e.g. 404 for events with no waveform) shows + // its JSON error in-page rather than silently failing. + window.open(url, '_blank'); +} + function onSidecarOverlayClick(e) { // Click on the dimmed backdrop (but NOT on the modal itself) closes. if (e.target.id === 'sc-overlay') closeSidecarModal(); @@ -3193,6 +3205,10 @@ if (currentSection === 'db') {