2 Commits

Author SHA1 Message Date
serversdown 411ef8139e 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>
2026-05-27 02:55:58 +00:00
serversdown ed926de3f4 viewers: default mic to dB(L) + add Mic-unit toggle (dBL ↔ psi)
The sidecar-modal waveform plot was rendering mic in raw psi, while the
rest of SFM (history table column, peaks block, live-device chart,
event detail modal mic field) had already converted to dB(L) — matching
the BW Event Report convention.  Unifying.

Both viewers now:
  - Default mic chart values + axis title + peak label to dB(L)
  - Provide a header toggle ("Mic: dBL" pill) to flip to psi
  - Persist the preference via localStorage (sfm_mic_unit)
  - Re-render the open chart immediately on toggle

Conversion: dBL = 20 * log10(psi / 2.9e-9), where 2.9e-9 psi is the
20 µPa reference pressure already defined for the rest of the webapp.
Non-positive psi samples (log undefined) render as null; Chart.js
handles them as gaps in line mode and missing bars in histogram mode.

Also fixes event_browser.html's stats table — the MicL row was
hard-coding "<value> psi"; now honors the same toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:30:56 +00:00
7 changed files with 676 additions and 12 deletions
+3
View File
@@ -8,6 +8,9 @@ All notable changes to seismo-relay are documented here.
### Added ### 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. - **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. - **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. - **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.
+1
View File
@@ -15,6 +15,7 @@ dependencies = [
"python-multipart>=0.0.7", "python-multipart>=0.0.7",
"h5py>=3.10", "h5py>=3.10",
"numpy>=1.24", "numpy>=1.24",
"matplotlib>=3.8",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
+1
View File
@@ -5,3 +5,4 @@ pyserial
python-multipart python-multipart
h5py h5py
numpy numpy
matplotlib
+60 -6
View File
@@ -289,7 +289,12 @@
</select> </select>
<input type="search" id="event-filter" placeholder="filter events…" /> <input type="search" id="event-filter" placeholder="filter events…" />
<span class="pill" id="count-pill"></span> <span class="pill" id="count-pill"></span>
<button id="print-btn" onclick="togglePrintView()" style="margin-left:auto;background:#21262d">Print view</button> <button id="mic-unit-toggle" style="margin-left:auto;background:#21262d"
onclick="_setMicUnit(_getMicUnit() === 'dBL' ? 'psi' : 'dBL')"
title="Toggle mic display unit (dBL ↔ psi). Persists across page loads.">
Mic: dBL
</button>
<button id="print-btn" onclick="togglePrintView()" style="background:#21262d">Print view</button>
<button id="reload-btn" onclick="loadSerials()">Reload</button> <button id="reload-btn" onclick="loadSerials()">Reload</button>
</header> </header>
@@ -328,6 +333,29 @@ const CHANNEL_COLORS = {
}; };
const CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran']; const CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran'];
// Reference pressure for dB(L) — 20 µPa expressed in psi (≈ 2.9e-9 psi).
const DBL_REF = 2.9e-9;
// User-toggleable mic display unit: 'dBL' (default, matches BW printout
// + the rest of SFM) or 'psi' (raw sample unit).
function _getMicUnit() {
return localStorage.getItem('sfm_mic_unit') === 'psi' ? 'psi' : 'dBL';
}
function _setMicUnit(u) {
localStorage.setItem('sfm_mic_unit', u === 'psi' ? 'psi' : 'dBL');
_refreshMicUnitToggle();
if (currentEventId) loadEvent(currentEventId);
}
function _refreshMicUnitToggle() {
const b = document.getElementById('mic-unit-toggle');
if (b) b.textContent = `Mic: ${_getMicUnit()}`;
}
// psi → dB(L). Null for non-positive (log undefined; Chart.js renders as a gap).
function _psiToDbl(psi) {
if (psi == null || !(psi > 0)) return null;
return 20 * Math.log10(psi / DBL_REF);
}
// Adaptive decimal formatter — scientific notation only for truly extreme // Adaptive decimal formatter — scientific notation only for truly extreme
// values. Normal-range peaks render as plain decimals with sensible // values. Normal-range peaks render as plain decimals with sensible
// precision (was previously forcing toExponential(3) which produced ugly // precision (was previously forcing toExponential(3) which produced ugly
@@ -502,6 +530,19 @@ function renderMeta(data, ev) {
['Vert', ev?.vert_ppv], ['Vert', ev?.vert_ppv],
['Long', ev?.long_ppv], ['Long', ev?.long_ppv],
]; ];
// Mic display honors the current user preference (dBL default).
// mic_ppv is stored as raw psi on series3 events; convert when needed.
const micPsi = ev?.mic_ppv;
const micUnitDisplay = _getMicUnit();
let micStr;
if (micPsi == null) {
micStr = '—';
} else if (micUnitDisplay === 'dBL') {
const d = _psiToDbl(Number(micPsi));
micStr = (d != null ? d.toFixed(1) : '—') + ' dBL';
} else {
micStr = Number(micPsi).toExponential(2) + ' psi';
}
const statsHtml = ` const statsHtml = `
<table class="stats-table"> <table class="stats-table">
<thead> <thead>
@@ -509,7 +550,7 @@ function renderMeta(data, ev) {
</thead> </thead>
<tbody> <tbody>
${rows.map(([ch, ppv]) => `<tr><td>${ch}</td><td>${fmt(ppv)}</td></tr>`).join('')} ${rows.map(([ch, ppv]) => `<tr><td>${ch}</td><td>${fmt(ppv)}</td></tr>`).join('')}
<tr><td>MicL</td><td>${fmt(ev?.mic_ppv)} psi</td></tr> <tr><td>MicL</td><td>${micStr}</td></tr>
</tbody> </tbody>
</table> </table>
`; `;
@@ -560,11 +601,11 @@ function renderWaveform(data) {
); );
const lastDataCh = channelsWithData[channelsWithData.length - 1]; const lastDataCh = channelsWithData[channelsWithData.length - 1];
const micUnit = _getMicUnit();
for (const ch of CHANNEL_ORDER) { for (const ch of CHANNEL_ORDER) {
const chData = channels[ch]; const chData = channels[ch];
if (!chData) continue; if (!chData) continue;
const values = chData.values || []; if ((chData.values || []).length === 0) {
if (values.length === 0) {
// Render an empty card so user sees the channel exists but is missing // Render an empty card so user sees the channel exists but is missing
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'chart-wrap'; wrap.className = 'chart-wrap';
@@ -579,9 +620,19 @@ function renderWaveform(data) {
continue; continue;
} }
const unit = chData.unit || 'unit'; // Mic channel: convert from raw psi to dB(L) when the user prefers dBL
const peak = chData.peak; // (the default). We mutate `values`, `peak`, and `unit` locally so the
// chart datasets + axis title + tooltip + peak label all stay aligned.
let values = chData.values || [];
let unit = chData.unit || 'unit';
let peak = chData.peak;
const peakT = chData.peak_t_ms; const peakT = chData.peak_t_ms;
if (ch === 'MicL' && unit === 'psi' && micUnit === 'dBL') {
values = values.map(_psiToDbl);
peak = _psiToDbl(peak);
unit = 'dB(L)';
}
const peakLabel = peak != null const peakLabel = peak != null
? `peak ${_fmtPeak(peak, unit)}` ? `peak ${_fmtPeak(peak, unit)}`
+ (!isHistogram && peakT != null ? ` @ ${peakT.toFixed(1)} ms` : '') + (!isHistogram && peakT != null ? ` @ ${peakT.toFixed(1)} ms` : '')
@@ -781,6 +832,9 @@ document.getElementById('serial-select').addEventListener('change', e => {
}); });
document.getElementById('event-filter').addEventListener('input', applyFilter); document.getElementById('event-filter').addEventListener('input', applyFilter);
// Reflect any persisted mic-unit preference in the header pill on load
_refreshMicUnitToggle();
// Initial load // Initial load
loadSerials(); loadSerials();
</script> </script>
+518
View File
@@ -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)
+27 -1
View File
@@ -46,7 +46,7 @@ from typing import Optional
# FastAPI / Pydantic # FastAPI / Pydantic
try: 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.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pydantic import BaseModel 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") @app.get("/db/events/{event_id}/waveform.json")
def db_event_waveform_json(event_id: str) -> dict: def db_event_waveform_json(event_id: str) -> dict:
""" """
+66 -5
View File
@@ -818,6 +818,12 @@
<span class="ft-dot"></span> <span class="ft-dot"></span>
<span>Force refresh</span> <span>Force refresh</span>
</label> </label>
<div class="hdr-sep"></div>
<button id="mic-unit-toggle" class="section-btn"
onclick="_setMicUnit(_getMicUnit() === 'dBL' ? 'psi' : 'dBL')"
title="Toggle microphone display unit (dBL ↔ psi) for waveform plots. Affects all mic charts; persists across page loads.">
Mic: dBL
</button>
</header> </header>
<!-- ════════════════════════════════════════════════════════════════ <!-- ════════════════════════════════════════════════════════════════
@@ -2560,6 +2566,29 @@ const _SC_CHANNEL_COLORS = {
const _SC_CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran']; const _SC_CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran'];
let _scCharts = {}; let _scCharts = {};
// User preference for how mic is displayed in plots — dBL (default,
// matches BW printout convention + the rest of SFM) or psi (the raw
// sample unit). Toggleable via the header pill; persists in localStorage.
function _getMicUnit() {
return localStorage.getItem('sfm_mic_unit') === 'psi' ? 'psi' : 'dBL';
}
function _setMicUnit(u) {
localStorage.setItem('sfm_mic_unit', u === 'psi' ? 'psi' : 'dBL');
_refreshMicUnitToggleLabel();
// Re-render the open modal so the change is immediately visible.
if (_scCurrentEventId) openSidecarModal(_scCurrentEventId);
}
function _refreshMicUnitToggleLabel() {
const b = document.getElementById('mic-unit-toggle');
if (b) b.textContent = `Mic: ${_getMicUnit()}`;
}
// Convert a psi value to dB(L). Returns null for non-positive values
// (log of zero is undefined) — Chart.js handles null as a gap in the line.
function _psiToDbl(psi) {
if (psi == null || !(psi > 0)) return null;
return 20 * Math.log10(psi / DBL_REF);
}
// Adaptive decimal formatter — scientific notation is reserved for truly // Adaptive decimal formatter — scientific notation is reserved for truly
// extreme values (10000+ or sub-0.0001). Normal-range values (most peaks // extreme values (10000+ or sub-0.0001). Normal-range values (most peaks
// fall here) render as decimals with sensible precision. Replaces the // fall here) render as decimals with sensible precision. Replaces the
@@ -2610,17 +2639,30 @@ function _renderScWaveform(data) {
); );
const lastCh = withData[withData.length - 1]; const lastCh = withData[withData.length - 1];
const micUnit = _getMicUnit(); // user preference: 'dBL' or 'psi'
for (const ch of _SC_CHANNEL_ORDER) { for (const ch of _SC_CHANNEL_ORDER) {
const chData = channels[ch]; const chData = channels[ch];
if (!chData) continue; if (!chData) continue;
const values = chData.values || []; let values = chData.values || [];
let chUnit = chData.unit || '';
let chPeak = chData.peak;
// Mic channel: convert from raw psi to dB(L) when user prefers dBL
// (default). Mic samples that are zero/negative become null (Chart.js
// renders them as gaps in line mode, zero-height bars in histogram mode).
if (ch === 'MicL' && chUnit === 'psi' && micUnit === 'dBL') {
values = values.map(_psiToDbl);
chPeak = _psiToDbl(chPeak);
chUnit = 'dB(L)';
}
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.style.cssText = 'background:var(--surface);border:1px solid var(--border2);border-radius:6px;padding:6px 30px 4px 10px'; wrap.style.cssText = 'background:var(--surface);border:1px solid var(--border2);border-radius:6px;padding:6px 30px 4px 10px';
const lbl = document.createElement('div'); const lbl = document.createElement('div');
lbl.style.cssText = `font-size:10px;font-weight:600;letter-spacing:0.05em;text-transform:uppercase;margin-bottom:2px;color:${_SC_CHANNEL_COLORS[ch]};display:flex;justify-content:space-between`; lbl.style.cssText = `font-size:10px;font-weight:600;letter-spacing:0.05em;text-transform:uppercase;margin-bottom:2px;color:${_SC_CHANNEL_COLORS[ch]};display:flex;justify-content:space-between`;
const peakStr = chData.peak != null const peakStr = chPeak != null
? `peak ${_fmtPeak(chData.peak, chData.unit)}` ? `peak ${_fmtPeak(chPeak, chUnit)}`
: ''; : '';
lbl.innerHTML = `<span>${ch}</span><span style="color:var(--text-dim);font-weight:normal">${peakStr}</span>`; lbl.innerHTML = `<span>${ch}</span><span style="color:var(--text-dim);font-weight:normal">${peakStr}</span>`;
wrap.appendChild(lbl); wrap.appendChild(lbl);
@@ -2716,7 +2758,7 @@ function _renderScWaveform(data) {
title: items => isHistogram title: items => isHistogram
? `interval ${items[0].label}` ? `interval ${items[0].label}`
: `t = ${items[0].label} ms`, : `t = ${items[0].label} ms`,
label: item => `${ch}: ${_fmtPeak(item.raw, chData.unit)}`, label: item => `${ch}: ${_fmtPeak(item.raw, chUnit)}`,
}, },
}, },
}, },
@@ -2730,7 +2772,7 @@ function _renderScWaveform(data) {
...yBounds, ...yBounds,
ticks: { color: '#484f58', maxTicksLimit: 4 }, ticks: { color: '#484f58', maxTicksLimit: 4 },
grid: { color: '#21262d' }, grid: { color: '#21262d' },
title: { display: true, text: chData.unit || '', color: '#484f58', font: { size: 9 } }, title: { display: true, text: chUnit, color: '#484f58', font: { size: 9 } },
}, },
}, },
}, },
@@ -2850,6 +2892,18 @@ function closeSidecarModal() {
_destroyScCharts(); _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) { function onSidecarOverlayClick(e) {
// Click on the dimmed backdrop (but NOT on the modal itself) closes. // Click on the dimmed backdrop (but NOT on the modal itself) closes.
if (e.target.id === 'sc-overlay') closeSidecarModal(); if (e.target.id === 'sc-overlay') closeSidecarModal();
@@ -3058,6 +3112,9 @@ document.addEventListener('keydown', e => {
// hit localhost:8200, 10.0.0.44:8200, or anything else. // hit localhost:8200, 10.0.0.44:8200, or anything else.
document.getElementById('api-base').value = window.location.origin; document.getElementById('api-base').value = window.location.origin;
// Reflect any persisted mic-unit preference in the header pill on load
_refreshMicUnitToggleLabel();
// We default to Database view → trigger initial history + units load // We default to Database view → trigger initial history + units load
// (switchSection handles this when clicked, but we never click on first paint). // (switchSection handles this when clicked, but we never click on first paint).
if (currentSection === 'db') { if (currentSection === 'db') {
@@ -3148,6 +3205,10 @@ if (currentSection === 'db') {
</div> </div>
<div class="sc-footer"> <div class="sc-footer">
<span class="sc-status" id="sc-status"></span> <span class="sc-status" id="sc-status"></span>
<button class="btn btn-ghost" id="sc-pdf-btn" onclick="downloadEventReport()"
title="Download an Instantel-style Event Report PDF for this event">
Download PDF
</button>
<button class="btn btn-ghost" onclick="closeSidecarModal()">Cancel</button> <button class="btn btn-ghost" onclick="closeSidecarModal()">Cancel</button>
<button class="btn" id="sc-save-btn" onclick="saveSidecarReview()">Save</button> <button class="btn" id="sc-save-btn" onclick="saveSidecarReview()">Save</button>
</div> </div>