report_pdf: split waveform vs histogram layouts (BW PDF iteration)
Reviewed against real Blastware Event Report PDFs (uploaded to
example-events/pdfsnstuff/) for K558LLB7.V20H (histogram) and
K558LLB8.0E0W (waveform). Each event type has its own layout because
BW's printouts genuinely differ:
Waveform header: Date/Time, Trigger Source, Range, Sample Rate
Histogram header: Start, Finish, Intervals At Size, Range, Sample Rate
(no trigger field — histograms aren't triggered)
Waveform stats: PPV, ZC Freq, Time (Rel. to Trig),
Peak Acceleration, Peak Displacement, Sensor Check
Histogram stats: PPV, ZC Freq, Date, Time (of peak), Sensor Check
Waveform plot: 4-channel stacked line, x-axis in SECONDS,
trigger triangle + window markers, symmetric Y
for geo, zero-anchored mic, "0.0" baseline label
on right edge per BW convention
Histogram plot: 4-channel stacked bars, Y-axis 0-to-peak only
(never negative — peaks are magnitudes), 0.0
baseline at the bottom
Waveform footer: USBM chart placeholder upper-right;
"Time X sec/div Amplitude Geo: Y in/s/div Mic: 0.001 psi(L)/div"
"Trigger = ▶━━◀"
Histogram footer: No USBM chart; same scale-info footer with
interval-size as the time unit
Other fixes from the first-pass screenshot review:
- Channel labels (MicL/Long/Vert/Tran) no longer cut off (wider
left margin)
- Histogram bars rise from zero baseline (abs of any signed values)
- ISO timestamp "2026-05-16T22:33:50" → "22:33:50 May 16, 2026"
matching BW's display format
Known gaps (separate work):
- Histogram codec returns per-block granularity (~200 bars for
BW's 4-interval display). XML-driven data source is the planned
fix; the structured BW XML has the per-interval aggregates.
- USBM RI8507 / OSMRE compliance chart still placeholder
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+6
-1
@@ -8,7 +8,12 @@ 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.
|
- **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.
|
||||||
|
- **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`.
|
||||||
|
- Backed by matplotlib (vector PDF, no headless-browser dep). Adds matplotlib>=3.8 to deps.
|
||||||
|
- **Known gap**: histogram codec returns per-block granularity (~200 bars for a 4-interval event) instead of BW's per-interval aggregation. Visual difference vs BW's 4-bar display. XML-driven data source (parsing the structured `_XML.XML` files BW also exports) is the planned fix; that route also resolves the bw_ascii_report PPV-miss bug.
|
||||||
|
- **Stubbed**: USBM RI8507 / OSMRE compliance chart curves (separate work item; requires coding the regulatory piecewise functions).
|
||||||
- **"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.
|
- **"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.
|
||||||
|
|||||||
+332
-108
@@ -123,6 +123,16 @@ class ReportData:
|
|||||||
record_type: Optional[str] = None
|
record_type: Optional[str] = None
|
||||||
is_histogram: bool = False
|
is_histogram: bool = False
|
||||||
|
|
||||||
|
# Histogram-only fields — only populated for record_type starts with 'Hist'
|
||||||
|
histogram_start_str: Optional[str] = None # "22:30:38 May 16, 2026"
|
||||||
|
histogram_stop_str: Optional[str] = None
|
||||||
|
histogram_n_intervals: Optional[float] = None # 4.00
|
||||||
|
histogram_interval_size: Optional[str] = None # "1 minute"
|
||||||
|
histogram_interval_times: list[str] = field(default_factory=list) # per-interval timestamps for x-axis
|
||||||
|
|
||||||
|
# Peak Vector Sum metadata (histograms show absolute date+time)
|
||||||
|
peak_vector_sum_when_str: Optional[str] = None
|
||||||
|
|
||||||
# Bookkeeping
|
# Bookkeeping
|
||||||
event_id: Optional[str] = None
|
event_id: Optional[str] = None
|
||||||
server_received_at: Optional[str] = None
|
server_received_at: Optional[str] = None
|
||||||
@@ -231,6 +241,19 @@ def gather_report_data(
|
|||||||
rd.peak_vector_sum_ips = vs.get("ips")
|
rd.peak_vector_sum_ips = vs.get("ips")
|
||||||
rd.peak_vector_sum_time_s = vs.get("time_s")
|
rd.peak_vector_sum_time_s = vs.get("time_s")
|
||||||
|
|
||||||
|
# Histogram-specific header fields. These come from the BW XML
|
||||||
|
# at ingest time (when present); the parsed bw_report dict
|
||||||
|
# carries them under the 'histogram' sub-block (added by the
|
||||||
|
# BW XML parser once that lands). For now, derive from the
|
||||||
|
# event timestamp + recording config as a best-effort.
|
||||||
|
if rd.is_histogram:
|
||||||
|
hist = bw.get("histogram") or {}
|
||||||
|
rd.histogram_start_str = hist.get("start_str") or rd.event_datetime_str
|
||||||
|
rd.histogram_stop_str = hist.get("stop_str")
|
||||||
|
rd.histogram_n_intervals = hist.get("n_intervals")
|
||||||
|
rd.histogram_interval_size = hist.get("interval_size")
|
||||||
|
rd.histogram_interval_times = hist.get("interval_times") or []
|
||||||
|
|
||||||
# ── Waveform samples — from the .h5 via the existing helper ──
|
# ── Waveform samples — from the .h5 via the existing helper ──
|
||||||
from sfm import event_hdf5
|
from sfm import event_hdf5
|
||||||
h5_path = store.hdf5_path_for(serial, filename)
|
h5_path = store.hdf5_path_for(serial, filename)
|
||||||
@@ -258,53 +281,31 @@ def gather_report_data(
|
|||||||
def render_event_report_pdf(rd: ReportData) -> bytes:
|
def render_event_report_pdf(rd: ReportData) -> bytes:
|
||||||
"""Render an event report dict to a single-page letter PDF.
|
"""Render an event report dict to a single-page letter PDF.
|
||||||
|
|
||||||
Returns the raw PDF bytes — caller streams them back via FastAPI.
|
Branches on ``rd.is_histogram`` — waveform and histogram layouts
|
||||||
|
differ in their header fields, stats-table rows, and bottom plot.
|
||||||
NOTE: this is a v0.20.0 stub layout. The visual hierarchy will be
|
Layout modeled on Blastware's Event Report PDFs (samples in
|
||||||
refined once reference PDFs land at docs/reference/instantel/. All
|
docs/reference/instantel/).
|
||||||
fields the printout includes are surfaced; spacing and typography
|
|
||||||
are approximate.
|
|
||||||
"""
|
"""
|
||||||
# Letter portrait — 8.5"×11"
|
# Letter portrait — 8.5"×11"
|
||||||
fig = plt.figure(figsize=(8.5, 11), dpi=100)
|
fig = plt.figure(figsize=(8.5, 11), dpi=100)
|
||||||
fig.patch.set_facecolor("white")
|
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:
|
if rd.is_histogram:
|
||||||
_draw_histogram_subplot(fig, gs[3], rd)
|
_render_histogram_layout(fig, rd)
|
||||||
else:
|
else:
|
||||||
_draw_waveform_subplot(fig, gs[3], rd)
|
_render_waveform_layout(fig, rd)
|
||||||
|
|
||||||
# Footer
|
# Footer (common to both layouts) — Created date + Xmark-like attribution.
|
||||||
fig.text(
|
fig.text(
|
||||||
0.07, 0.015,
|
0.07, 0.015,
|
||||||
f"Generated by seismo-relay • event_id={rd.event_id or '—'}",
|
f"Created: {rd.server_received_at or '—'} • seismo-relay",
|
||||||
fontsize=7, color="#888", ha="left",
|
fontsize=7, color="#888", ha="left",
|
||||||
)
|
)
|
||||||
|
fig.text(
|
||||||
|
0.93, 0.015,
|
||||||
|
f"Event {rd.event_id[:8] if rd.event_id else '—'}",
|
||||||
|
fontsize=7, color="#888", ha="right",
|
||||||
|
)
|
||||||
|
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
fig.savefig(buf, format="pdf")
|
fig.savefig(buf, format="pdf")
|
||||||
@@ -312,6 +313,69 @@ def render_event_report_pdf(rd: ReportData) -> bytes:
|
|||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _render_waveform_layout(fig, rd: ReportData) -> None:
|
||||||
|
"""Waveform layout: header / mic+USBM / per-channel stats / waveform plot.
|
||||||
|
|
||||||
|
Stats table includes Time (Rel. to Trig), Peak Accel, Peak Disp.
|
||||||
|
Left margin sized to fit the channel labels (MicL/Long/Vert/Tran).
|
||||||
|
"""
|
||||||
|
gs = fig.add_gridspec(
|
||||||
|
nrows=4, ncols=1,
|
||||||
|
left=0.11, right=0.94, top=0.97, bottom=0.06,
|
||||||
|
height_ratios=[1.7, 2.0, 1.8, 5.5],
|
||||||
|
hspace=0.35,
|
||||||
|
)
|
||||||
|
ax_header = fig.add_subplot(gs[0]); ax_header.axis("off")
|
||||||
|
_draw_header_waveform(ax_header, rd)
|
||||||
|
|
||||||
|
ax_mid = fig.add_subplot(gs[1]); ax_mid.axis("off")
|
||||||
|
_draw_mic_and_usbm(ax_mid, rd)
|
||||||
|
|
||||||
|
ax_stats = fig.add_subplot(gs[2]); ax_stats.axis("off")
|
||||||
|
_draw_channel_stats_waveform(ax_stats, rd)
|
||||||
|
|
||||||
|
_draw_waveform_subplot(fig, gs[3], rd)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_histogram_layout(fig, rd: ReportData) -> None:
|
||||||
|
"""Histogram layout: header / mic-only / per-channel stats / bar plot.
|
||||||
|
|
||||||
|
No USBM compliance chart (it's a waveform-only concept). Stats table
|
||||||
|
uses Date + Time-of-peak instead of relative-time + accel + disp.
|
||||||
|
Left margin sized to fit the channel labels.
|
||||||
|
"""
|
||||||
|
gs = fig.add_gridspec(
|
||||||
|
nrows=4, ncols=1,
|
||||||
|
left=0.11, right=0.94, top=0.97, bottom=0.06,
|
||||||
|
height_ratios=[1.8, 0.9, 1.7, 5.6],
|
||||||
|
hspace=0.35,
|
||||||
|
)
|
||||||
|
ax_header = fig.add_subplot(gs[0]); ax_header.axis("off")
|
||||||
|
_draw_header_histogram(ax_header, rd)
|
||||||
|
|
||||||
|
ax_mic = fig.add_subplot(gs[1]); ax_mic.axis("off")
|
||||||
|
_draw_mic_only(ax_mic, rd)
|
||||||
|
|
||||||
|
ax_stats = fig.add_subplot(gs[2]); ax_stats.axis("off")
|
||||||
|
_draw_channel_stats_histogram(ax_stats, rd)
|
||||||
|
|
||||||
|
_draw_histogram_subplot(fig, gs[3], rd)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_iso_to_bw(iso: Optional[str]) -> Optional[str]:
|
||||||
|
"""Convert a ISO-8601 timestamp like '2026-05-16T22:30:37' to BW's
|
||||||
|
display format '22:30:37 May 16, 2026'. Returns input unchanged if
|
||||||
|
it doesn't look like ISO."""
|
||||||
|
if not iso or "T" not in iso:
|
||||||
|
return iso
|
||||||
|
try:
|
||||||
|
import datetime as _dt
|
||||||
|
dt = _dt.datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||||
|
return dt.strftime("%H:%M:%S %B %d, %Y").replace(" 0", " ")
|
||||||
|
except Exception:
|
||||||
|
return iso
|
||||||
|
|
||||||
|
|
||||||
def _kv(ax, x, y, label, value, *, label_w=0.18):
|
def _kv(ax, x, y, label, value, *, label_w=0.18):
|
||||||
"""Render a 'Label Value' row at axes-coordinates (x, y)."""
|
"""Render a 'Label Value' row at axes-coordinates (x, y)."""
|
||||||
ax.text(x, y, label, fontsize=8, color="#555", ha="left", va="top",
|
ax.text(x, y, label, fontsize=8, color="#555", ha="left", va="top",
|
||||||
@@ -329,11 +393,10 @@ def _fmt(v):
|
|||||||
return str(v)
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
def _draw_header(ax, rd: ReportData) -> None:
|
def _draw_header_waveform(ax, rd: ReportData) -> None:
|
||||||
"""Two-column metadata header — matches BW printout layout."""
|
"""Two-column metadata header — waveform variant."""
|
||||||
# Left column
|
|
||||||
rows_left = [
|
rows_left = [
|
||||||
("Date/Time", rd.event_datetime_str),
|
("Date/Time", _fmt_iso_to_bw(rd.event_datetime_str)),
|
||||||
("Trigger Source", rd.trigger_source),
|
("Trigger Source", rd.trigger_source),
|
||||||
("Range", rd.geo_range_str),
|
("Range", rd.geo_range_str),
|
||||||
("Sample Rate", rd.sample_rate_str),
|
("Sample Rate", rd.sample_rate_str),
|
||||||
@@ -343,18 +406,45 @@ def _draw_header(ax, rd: ReportData) -> None:
|
|||||||
("User Name:", rd.operator),
|
("User Name:", rd.operator),
|
||||||
("Seis. Loc:", rd.sensor_location),
|
("Seis. Loc:", rd.sensor_location),
|
||||||
]
|
]
|
||||||
|
_draw_header_columns(ax, rows_left, rd)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_header_histogram(ax, rd: ReportData) -> None:
|
||||||
|
"""Two-column metadata header — histogram variant.
|
||||||
|
|
||||||
|
Histograms have Start / Finish / Intervals fields instead of
|
||||||
|
Trigger Source (there's no trigger event for a histogram capture).
|
||||||
|
"""
|
||||||
|
intervals_str = None
|
||||||
|
if rd.histogram_n_intervals is not None and rd.histogram_interval_size:
|
||||||
|
intervals_str = f"{rd.histogram_n_intervals} At {rd.histogram_interval_size}"
|
||||||
|
rows_left = [
|
||||||
|
("Start", _fmt_iso_to_bw(rd.histogram_start_str or rd.event_datetime_str)),
|
||||||
|
("Finish", _fmt_iso_to_bw(rd.histogram_stop_str)),
|
||||||
|
("Intervals", intervals_str),
|
||||||
|
("Range", rd.geo_range_str),
|
||||||
|
("Sample Rate", (f"{rd.sample_rate_sps} Sps" if rd.sample_rate_sps else None)),
|
||||||
|
("Notes", rd.notes),
|
||||||
|
("Project:", rd.project),
|
||||||
|
("Client:", rd.client),
|
||||||
|
("User Name:", rd.operator),
|
||||||
|
("Seis. Loc:", rd.sensor_location),
|
||||||
|
]
|
||||||
|
_draw_header_columns(ax, rows_left, rd)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_header_columns(ax, rows_left, rd: ReportData) -> None:
|
||||||
|
"""Shared 2-column header rendering used by both layouts."""
|
||||||
rows_right = [
|
rows_right = [
|
||||||
("Serial Number", f"{rd.serial or '—'}"
|
("Serial Number", f"{rd.serial or '—'}" + (f" {rd.firmware}" if rd.firmware else "")),
|
||||||
+ (f" {rd.firmware}" if rd.firmware else "")),
|
|
||||||
("Battery Level", f"{rd.battery_volts:.1f} Volts" if rd.battery_volts is not None else None),
|
("Battery Level", f"{rd.battery_volts:.1f} Volts" if rd.battery_volts is not None else None),
|
||||||
("Unit Calibration", (f"{rd.calibration_date}"
|
("Unit Calibration", (f"{rd.calibration_date}" + (f" by {rd.calibration_by}" if rd.calibration_by else ""))
|
||||||
+ (f" by {rd.calibration_by}" if rd.calibration_by else ""))
|
|
||||||
if rd.calibration_date else None),
|
if rd.calibration_date else None),
|
||||||
("File Name", rd.file_name),
|
("File Name", rd.file_name),
|
||||||
("Post Event Notes", rd.post_event_notes),
|
("Post Event Notes", rd.post_event_notes),
|
||||||
]
|
]
|
||||||
y = 0.95
|
y = 0.95
|
||||||
dy = 0.10
|
dy = 0.095
|
||||||
for label, value in rows_left:
|
for label, value in rows_left:
|
||||||
_kv(ax, 0.0, y, label, value, label_w=0.18)
|
_kv(ax, 0.0, y, label, value, label_w=0.18)
|
||||||
y -= dy
|
y -= dy
|
||||||
@@ -364,12 +454,43 @@ def _draw_header(ax, rd: ReportData) -> None:
|
|||||||
y -= dy
|
y -= dy
|
||||||
|
|
||||||
|
|
||||||
def _draw_mic_block(ax, rd: ReportData) -> None:
|
def _draw_mic_only(ax, rd: ReportData) -> None:
|
||||||
"""Microphone block — PSPL, ZC Freq, Channel Test. USBM chart
|
"""Mic block (histogram variant — no 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",
|
ax.text(0.0, 0.95, "Microphone Linear Weighting", fontsize=8, color="#555",
|
||||||
transform=ax.transAxes, va="top")
|
transform=ax.transAxes, va="top")
|
||||||
rows = []
|
rows = _mic_rows(rd)
|
||||||
|
y = 0.70
|
||||||
|
for label, value in rows:
|
||||||
|
_kv(ax, 0.0, y, label, value, label_w=0.18)
|
||||||
|
y -= 0.22
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_mic_and_usbm(ax, rd: ReportData) -> None:
|
||||||
|
"""Mic block on the left + USBM compliance chart placeholder on right.
|
||||||
|
(Waveform variant — USBM is a velocity-vs-frequency compliance plot
|
||||||
|
that doesn't apply to histograms.)"""
|
||||||
|
ax.text(0.0, 0.95, "Microphone Linear Weighting", fontsize=8, color="#555",
|
||||||
|
transform=ax.transAxes, va="top")
|
||||||
|
rows = _mic_rows(rd)
|
||||||
|
y = 0.80
|
||||||
|
for label, value in rows:
|
||||||
|
_kv(ax, 0.0, y, label, value, label_w=0.18)
|
||||||
|
y -= 0.15
|
||||||
|
|
||||||
|
# USBM chart placeholder — upper-right. Real piecewise compliance
|
||||||
|
# curves are a separate work item; for now this just shows the title
|
||||||
|
# + a "see report" message so the layout is correct.
|
||||||
|
ax.text(0.72, 0.97, "USBM RI8507 And OSMRE",
|
||||||
|
fontsize=9, weight="bold", color="#333", ha="center", va="top",
|
||||||
|
transform=ax.transAxes)
|
||||||
|
ax.text(0.72, 0.50, "[compliance chart\ncoming soon]",
|
||||||
|
fontsize=8, color="#bbb", ha="center", va="center",
|
||||||
|
transform=ax.transAxes, style="italic")
|
||||||
|
|
||||||
|
|
||||||
|
def _mic_rows(rd: ReportData) -> list[tuple[str, Optional[str]]]:
|
||||||
|
"""Build the mic-section value rows (shared by both layouts)."""
|
||||||
|
rows: list[tuple[str, Optional[str]]] = []
|
||||||
if rd.mic_pspl_dbl is not None:
|
if rd.mic_pspl_dbl is not None:
|
||||||
line = f"{rd.mic_pspl_dbl:.1f} dB(L)"
|
line = f"{rd.mic_pspl_dbl:.1f} dB(L)"
|
||||||
if rd.mic_pspl_time_s is not None:
|
if rd.mic_pspl_time_s is not None:
|
||||||
@@ -383,47 +504,78 @@ def _draw_mic_block(ax, rd: ReportData) -> None:
|
|||||||
line += (f" (Freq = {rd.mic_channel_test_freq_hz:.1f} Hz, "
|
line += (f" (Freq = {rd.mic_channel_test_freq_hz:.1f} Hz, "
|
||||||
f"Amp = {rd.mic_channel_test_amp_mv:.0f} mv)")
|
f"Amp = {rd.mic_channel_test_amp_mv:.0f} mv)")
|
||||||
rows.append(("Channel Test", line))
|
rows.append(("Channel Test", line))
|
||||||
|
return rows
|
||||||
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:
|
def _draw_channel_stats_waveform(ax, rd: ReportData) -> None:
|
||||||
"""Per-channel stats table + Peak Vector Sum row."""
|
"""Waveform stats table — has Time (Rel. to Trig), Peak Accel, Peak Disp.
|
||||||
# Build a 2-D array of strings: header row + 3 channel rows
|
Followed by Peak Vector Sum line."""
|
||||||
headers = ["", "Tran", "Vert", "Long", ""]
|
rows_spec = [
|
||||||
rows = [
|
("PPV", "ppv_ips", "in/s"),
|
||||||
["PPV", "ppv_ips", "in/s"],
|
("ZC Freq", "zc_freq_hz", "Hz"),
|
||||||
["ZC Freq", "zc_freq_hz", "Hz"],
|
("Time (Rel. to Trig)", "time_of_peak_s", "sec"),
|
||||||
["Time (Rel. to Trig)", "time_of_peak_s", "sec"],
|
("Peak Acceleration", "peak_accel_g", "g"),
|
||||||
["Peak Acceleration", "peak_accel_g", "g"],
|
("Peak Displacement", "peak_disp_in", "in"),
|
||||||
["Peak Displacement", "peak_disp_in", "in"],
|
("Sensor Check", "sensor_check", ""),
|
||||||
["Sensor Check", "sensor_check", ""],
|
|
||||||
]
|
]
|
||||||
|
_draw_stats_table(ax, rd, rows_spec)
|
||||||
|
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.08, line, fontsize=9, weight="bold",
|
||||||
|
ha="left", va="top", transform=ax.transAxes)
|
||||||
|
ax.text(0.0, -0.18, "NA: Not Applicable", fontsize=7, color="#888",
|
||||||
|
ha="left", va="top", transform=ax.transAxes)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_channel_stats_histogram(ax, rd: ReportData) -> None:
|
||||||
|
"""Histogram stats table — PPV, ZC Freq, Date, Time of peak, Sensor Check.
|
||||||
|
Followed by Peak Vector Sum line."""
|
||||||
|
# Date / Time of peak are per-channel timestamps for the interval at peak.
|
||||||
|
# bw_report stores time_of_peak_s as relative seconds, but for histograms
|
||||||
|
# BW shows them as absolute date+time. We populate from rd.channel_stats
|
||||||
|
# if those absolute fields are present; otherwise fall back to relative.
|
||||||
|
rows_spec = [
|
||||||
|
("PPV", "ppv_ips", "in/s"),
|
||||||
|
("ZC Freq", "zc_freq_hz", "Hz"),
|
||||||
|
("Date", "peak_date", ""),
|
||||||
|
("Time", "peak_time", ""),
|
||||||
|
("Sensor Check", "sensor_check", ""),
|
||||||
|
]
|
||||||
|
_draw_stats_table(ax, rd, rows_spec)
|
||||||
|
if rd.peak_vector_sum_ips is not None:
|
||||||
|
when = rd.peak_vector_sum_when_str or ""
|
||||||
|
line = f"Peak Vector Sum {rd.peak_vector_sum_ips:.3f} in/s"
|
||||||
|
if when:
|
||||||
|
line += f" on {when}"
|
||||||
|
ax.text(0.0, -0.08, line, fontsize=9, weight="bold",
|
||||||
|
ha="left", va="top", transform=ax.transAxes)
|
||||||
|
ax.text(0.0, -0.18, "NA: Not Applicable", fontsize=7, color="#888",
|
||||||
|
ha="left", va="top", transform=ax.transAxes)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_stats_table(ax, rd: ReportData, rows_spec: list[tuple[str, str, str]]) -> None:
|
||||||
|
"""Render a per-channel stats table (Tran/Vert/Long).
|
||||||
|
|
||||||
|
rows_spec: list of (label, field_name_in_channel_stats, unit_string)
|
||||||
|
"""
|
||||||
|
headers = ["", "Tran", "Vert", "Long", ""]
|
||||||
ch_lookup = {c["name"]: c for c in rd.channel_stats}
|
ch_lookup = {c["name"]: c for c in rd.channel_stats}
|
||||||
|
|
||||||
def _cell(field, ch_name):
|
def _cell(field, ch_name):
|
||||||
val = ch_lookup.get(ch_name, {}).get(field)
|
val = ch_lookup.get(ch_name, {}).get(field)
|
||||||
if val is None:
|
if val is None:
|
||||||
return "—"
|
return "—"
|
||||||
if field == "sensor_check":
|
|
||||||
return str(val)
|
|
||||||
if isinstance(val, float):
|
if isinstance(val, float):
|
||||||
|
# ZC Freq is integer-formatted in BW; everything else with 3 decimals
|
||||||
|
if field == "zc_freq_hz":
|
||||||
|
return f"{val:.0f}"
|
||||||
return f"{val:.3f}"
|
return f"{val:.3f}"
|
||||||
return str(val)
|
return str(val)
|
||||||
|
|
||||||
table_data = [headers]
|
table_data = [headers]
|
||||||
for label, field_name, unit in rows:
|
for label, field_name, unit in rows_spec:
|
||||||
table_data.append([
|
table_data.append([
|
||||||
label,
|
label,
|
||||||
_cell(field_name, "Tran"),
|
_cell(field_name, "Tran"),
|
||||||
@@ -431,27 +583,16 @@ def _draw_channel_stats(ax, rd: ReportData) -> None:
|
|||||||
_cell(field_name, "Long"),
|
_cell(field_name, "Long"),
|
||||||
unit,
|
unit,
|
||||||
])
|
])
|
||||||
|
|
||||||
tbl = ax.table(
|
tbl = ax.table(
|
||||||
cellText=table_data, loc="upper left",
|
cellText=table_data, loc="upper left",
|
||||||
colWidths=[0.30, 0.13, 0.13, 0.13, 0.10],
|
colWidths=[0.28, 0.14, 0.14, 0.14, 0.10],
|
||||||
cellLoc="left", edges="open",
|
cellLoc="left", edges="open",
|
||||||
)
|
)
|
||||||
tbl.auto_set_font_size(False)
|
tbl.auto_set_font_size(False)
|
||||||
tbl.set_fontsize(8)
|
tbl.set_fontsize(8)
|
||||||
tbl.scale(1, 1.4)
|
tbl.scale(1, 1.4)
|
||||||
# Header row styling
|
|
||||||
for j in range(5):
|
for j in range(5):
|
||||||
cell = tbl[(0, j)]
|
tbl[(0, j)].set_text_props(weight="bold", color="#555")
|
||||||
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:
|
def _channel_axis_color(ch: str) -> str:
|
||||||
@@ -460,59 +601,142 @@ def _channel_axis_color(ch: str) -> str:
|
|||||||
|
|
||||||
def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None:
|
def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None:
|
||||||
"""4-channel stacked waveform plot — Instantel printout order
|
"""4-channel stacked waveform plot — Instantel printout order
|
||||||
(MicL on top, Tran on bottom), shared x-axis."""
|
(MicL on top, Tran on bottom), shared x-axis in SECONDS, trigger
|
||||||
|
triangle markers at t=0, '0.0' baseline label on right of each."""
|
||||||
inner = gridspec_cell.subgridspec(4, 1, hspace=0.0)
|
inner = gridspec_cell.subgridspec(4, 1, hspace=0.0)
|
||||||
order = ["MicL", "Long", "Vert", "Tran"]
|
order = ["MicL", "Long", "Vert", "Tran"]
|
||||||
sr = rd.sample_rate_sps or 1024
|
sr = rd.sample_rate_sps or 1024
|
||||||
dt_ms = rd.dt_ms or (1000.0 / sr)
|
# Convert ms-based time axis to seconds for the x-axis
|
||||||
t0_ms = rd.t0_ms if rd.t0_ms is not None else 0.0
|
dt_s = (rd.dt_ms or (1000.0 / sr)) / 1000.0
|
||||||
|
t0_s = (rd.t0_ms if rd.t0_ms is not None else 0.0) / 1000.0
|
||||||
|
|
||||||
last_idx = len(order) - 1
|
last_idx = len(order) - 1
|
||||||
for i, ch in enumerate(order):
|
for i, ch in enumerate(order):
|
||||||
ax = fig.add_subplot(inner[i])
|
ax = fig.add_subplot(inner[i])
|
||||||
values = rd.channels.get(ch) or []
|
values = rd.channels.get(ch) or []
|
||||||
times = [t0_ms + j * dt_ms for j in range(len(values))]
|
times = [t0_s + j * dt_s for j in range(len(values))]
|
||||||
|
|
||||||
if values:
|
if values:
|
||||||
color = _channel_axis_color(ch)
|
color = _channel_axis_color(ch)
|
||||||
ax.plot(times, values, color=color, linewidth=0.6)
|
ax.plot(times, values, color=color, linewidth=0.5)
|
||||||
# Symmetric y-axis for geo; zero-anchored for mic
|
# Symmetric y-axis for geo; zero-anchored for mic.
|
||||||
if ch != "MicL":
|
if ch != "MicL":
|
||||||
amax = max((abs(v) for v in values), default=0.001)
|
amax = max((abs(v) for v in values), default=0.001)
|
||||||
ax.set_ylim(-amax * 1.1, amax * 1.1)
|
ax.set_ylim(-amax * 1.10, amax * 1.10)
|
||||||
# Channel label on left
|
else:
|
||||||
|
amax = max((abs(v) for v in values), default=0.001)
|
||||||
|
ax.set_ylim(-amax * 1.10, amax * 1.10)
|
||||||
|
|
||||||
|
# Channel label on the LEFT (matches BW)
|
||||||
ax.set_ylabel(ch, fontsize=8, rotation=0, ha="right", va="center",
|
ax.set_ylabel(ch, fontsize=8, rotation=0, ha="right", va="center",
|
||||||
color=_channel_axis_color(ch), weight="bold", labelpad=14)
|
color=_channel_axis_color(ch), weight="bold", labelpad=14)
|
||||||
ax.grid(True, linestyle=":", linewidth=0.4, alpha=0.5)
|
# "0.0" on the RIGHT (BW convention)
|
||||||
# Dashed trigger line at t=0
|
ax.text(1.005, 0.5, "0.0", transform=ax.transAxes,
|
||||||
ax.axvline(0.0, color="#cc0000", linestyle="--", linewidth=0.8, alpha=0.7)
|
fontsize=7, color="#555", va="center", ha="left")
|
||||||
# Zero baseline
|
|
||||||
ax.axhline(0.0, color="#888", linestyle="-", linewidth=0.4, alpha=0.5)
|
ax.grid(True, linestyle="--", linewidth=0.3, color="#bbb", alpha=0.6)
|
||||||
|
# Vertical dashed trigger line at t=0
|
||||||
|
ax.axvline(0.0, color="#cc0000", linestyle="--", linewidth=0.6, alpha=0.7)
|
||||||
|
# Zero baseline horizontal
|
||||||
|
ax.axhline(0.0, color=_channel_axis_color(ch), linestyle="-",
|
||||||
|
linewidth=0.4, alpha=0.5)
|
||||||
|
|
||||||
if i != last_idx:
|
if i != last_idx:
|
||||||
ax.set_xticklabels([])
|
ax.set_xticklabels([])
|
||||||
|
ax.tick_params(axis="x", length=0)
|
||||||
else:
|
else:
|
||||||
ax.set_xlabel("Time (ms)", fontsize=8)
|
ax.tick_params(axis="x", labelsize=7)
|
||||||
ax.tick_params(axis="both", labelsize=7)
|
ax.tick_params(axis="y", labelsize=6)
|
||||||
|
|
||||||
|
# Trigger triangle marker ▼ above the top channel at t=0
|
||||||
|
top_ax = fig.axes[-4] # MicL is the first added in this gridspec
|
||||||
|
top_ax.plot([0], [top_ax.get_ylim()[1]], marker="v", color="black",
|
||||||
|
markersize=8, clip_on=False, zorder=10)
|
||||||
|
|
||||||
|
# Compute scale-per-division for the footer (10 divs across the chart)
|
||||||
|
# and find peak geo amplitude for the geo amp/div setting.
|
||||||
|
total_s = times[-1] - times[0] if values else 0
|
||||||
|
div_s = total_s / 10 if total_s > 0 else 0
|
||||||
|
geo_amp_div = "—"
|
||||||
|
for ch in ("Tran", "Vert", "Long"):
|
||||||
|
v = rd.channels.get(ch) or []
|
||||||
|
if v:
|
||||||
|
amax = max(abs(x) for x in v)
|
||||||
|
geo_amp_div = f"{(amax * 1.1 * 2) / 10:.3f}"
|
||||||
|
break
|
||||||
|
fig.text(
|
||||||
|
0.07, 0.045,
|
||||||
|
f"Time(Seconds) {div_s:.2f} sec/div Amplitude Geo: {geo_amp_div} in/s/div Mic: 0.001 psi(L)/div",
|
||||||
|
fontsize=7, color="#444", ha="left",
|
||||||
|
)
|
||||||
|
fig.text(
|
||||||
|
0.07, 0.030,
|
||||||
|
"Trigger = ▶━━━━━ ━━━━━━◀",
|
||||||
|
fontsize=7, color="#444", ha="left",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _draw_histogram_subplot(fig, gridspec_cell, rd: ReportData) -> None:
|
def _draw_histogram_subplot(fig, gridspec_cell, rd: ReportData) -> None:
|
||||||
"""4-channel stacked histogram bar chart — per-interval peaks."""
|
"""4-channel stacked histogram bar chart — per-interval peaks.
|
||||||
|
|
||||||
|
X-axis labeled with the actual times from rd.histogram_interval_times
|
||||||
|
when available; otherwise interval index.
|
||||||
|
"""
|
||||||
inner = gridspec_cell.subgridspec(4, 1, hspace=0.0)
|
inner = gridspec_cell.subgridspec(4, 1, hspace=0.0)
|
||||||
order = ["MicL", "Long", "Vert", "Tran"]
|
order = ["MicL", "Long", "Vert", "Tran"]
|
||||||
last_idx = len(order) - 1
|
last_idx = len(order) - 1
|
||||||
|
|
||||||
|
# X-axis: use absolute time labels if we have them, else interval index
|
||||||
|
have_times = bool(rd.histogram_interval_times)
|
||||||
|
|
||||||
for i, ch in enumerate(order):
|
for i, ch in enumerate(order):
|
||||||
ax = fig.add_subplot(inner[i])
|
ax = fig.add_subplot(inner[i])
|
||||||
values = rd.channels.get(ch) or []
|
values = rd.channels.get(ch) or []
|
||||||
if values:
|
if values:
|
||||||
xs = np.arange(1, len(values) + 1)
|
# Histograms record per-interval PEAK magnitudes — always
|
||||||
|
# non-negative. Codec output occasionally includes signed
|
||||||
|
# values when the underlying .h5 was scaled like a waveform;
|
||||||
|
# take the absolute value so the bars rise from zero.
|
||||||
|
abs_vals = [abs(v) if v is not None else 0 for v in values]
|
||||||
|
xs = np.arange(len(abs_vals))
|
||||||
color = _channel_axis_color(ch)
|
color = _channel_axis_color(ch)
|
||||||
ax.bar(xs, values, color=color, width=1.0, linewidth=0)
|
ax.bar(xs, abs_vals, color=color, width=0.85, linewidth=0)
|
||||||
|
amax = max(abs_vals, default=0)
|
||||||
|
if amax > 0:
|
||||||
|
ax.set_ylim(0, amax * 1.10)
|
||||||
ax.set_ylabel(ch, fontsize=8, rotation=0, ha="right", va="center",
|
ax.set_ylabel(ch, fontsize=8, rotation=0, ha="right", va="center",
|
||||||
color=_channel_axis_color(ch), weight="bold", labelpad=14)
|
color=_channel_axis_color(ch), weight="bold", labelpad=14)
|
||||||
ax.grid(True, axis="y", linestyle=":", linewidth=0.4, alpha=0.5)
|
ax.text(1.005, 0.02, "0.0", transform=ax.transAxes,
|
||||||
|
fontsize=7, color="#555", va="bottom", ha="left")
|
||||||
|
ax.grid(True, axis="y", linestyle="--", linewidth=0.3, color="#bbb", alpha=0.6)
|
||||||
if i != last_idx:
|
if i != last_idx:
|
||||||
ax.set_xticklabels([])
|
ax.set_xticklabels([])
|
||||||
|
ax.tick_params(axis="x", length=0)
|
||||||
|
else:
|
||||||
|
if have_times and len(rd.histogram_interval_times) == len(values):
|
||||||
|
# Show 2-4 labels evenly spaced
|
||||||
|
n = len(values)
|
||||||
|
step = max(1, n // 4)
|
||||||
|
tick_positions = list(range(0, n, step))
|
||||||
|
ax.set_xticks(tick_positions)
|
||||||
|
ax.set_xticklabels([rd.histogram_interval_times[t] for t in tick_positions],
|
||||||
|
rotation=0, fontsize=6)
|
||||||
else:
|
else:
|
||||||
ax.set_xlabel("Interval", fontsize=8)
|
ax.set_xlabel("Interval", fontsize=8)
|
||||||
ax.tick_params(axis="both", labelsize=7)
|
ax.tick_params(axis="x", labelsize=7)
|
||||||
|
ax.tick_params(axis="y", labelsize=6)
|
||||||
|
|
||||||
|
# Footer scale info — histograms use minute/div
|
||||||
|
interval_str = rd.histogram_interval_size or "—"
|
||||||
|
geo_amp_div = "—"
|
||||||
|
for ch in ("Tran", "Vert", "Long"):
|
||||||
|
v = rd.channels.get(ch) or []
|
||||||
|
if v:
|
||||||
|
amax = max(abs(x) for x in v)
|
||||||
|
geo_amp_div = f"{amax / 5:.3f}"
|
||||||
|
break
|
||||||
|
fig.text(
|
||||||
|
0.07, 0.045,
|
||||||
|
f"Time {interval_str} /div Amplitude Geo: {geo_amp_div} in/s/div Mic: 0.001 psi(L)/div",
|
||||||
|
fontsize=7, color="#444", ha="left",
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user