report_pdf: PDF histogram aggregation + fix footer/x-axis overlap

Two issues spotted on a histogram event PDF:

1. Footer scale ("Time — /div  Amplitude Geo: X in/s/div  Mic: Y
   psi(L)/div") was overlapping horizontally with the x-axis tick
   labels (0, 20, 40, 60...).  Both rendered on the same Y row.
   Fix: bumped gridspec bottom margin from 0.06 → 0.12, moved the
   footer text from y=0.045 → y=0.030 (below the tick labels), moved
   the page-bottom Created/Event line from y=0.015 → y=0.005.
   Trigger legend on waveforms moved 0.030 → 0.018.  Everything
   stacks cleanly now without collision.

2. PDF was showing the raw codec output (~150+ bars per histogram)
   instead of BW's per-interval aggregation.  Why: the aggregation
   I'd added to /db/events/{id}/waveform.json wasn't replicated in
   the PDF gather path.  Now: gather_report_data does the same
   max-per-group aggregation when bw_report.histogram.n_intervals is
   populated, AND derives per-interval HH:MM:SS labels from the
   start time + interval_size_s.  Result: histogram PDFs now match
   BW's display (one bar per BW interval, x-axis labeled with actual
   times) — same fix as the modal chart, applied to the PDF.

For events ingested BEFORE the parser extension (no histogram block
in their sidecar), aggregation is a no-op — they still render with
per-block bars + interval-index x-axis (but the overlap fix applies
to them too).  Re-forwarding repopulates the histogram block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 04:33:53 +00:00
parent b9f8bbb220
commit a5888e1b5c
+55 -11
View File
@@ -285,6 +285,43 @@ def gather_report_data(
except Exception as exc: except Exception as exc:
log.warning("gather_report_data: hdf5 read failed: %s", exc) log.warning("gather_report_data: hdf5 read failed: %s", exc)
# ── Histogram aggregation ──
# Codec emits ~N per-block samples (typically 1/sec); BW reports
# one bar per configured interval (1 min / 5 min / etc.). When
# bw_report.histogram.n_intervals is populated (events ingested
# with the parser extension), group max-per-group to match. Also
# derives per-interval timestamps for the x-axis. No-op for
# waveform events or when n_intervals is missing.
if rd.is_histogram and rd.histogram_n_intervals and rd.histogram_n_intervals >= 1:
n = int(rd.histogram_n_intervals)
for ch, vals in list(rd.channels.items()):
if not vals:
continue
per_group = len(vals) // n
remainder = len(vals) % n
agg: list = []
offset = 0
for i in range(n):
grp_size = per_group + (1 if i < remainder else 0)
if grp_size > 0:
grp = vals[offset:offset + grp_size]
agg.append(max((abs(v) for v in grp if v is not None), default=0))
offset += grp_size
else:
agg.append(0)
rd.channels[ch] = agg
# Derive per-interval HH:MM:SS labels if we have the start time + size
if rd.histogram_start_str and rd.histogram_interval_size_s and not rd.histogram_interval_times:
try:
import datetime as _dt
start = _dt.datetime.fromisoformat(rd.histogram_start_str)
rd.histogram_interval_times = [
(start + _dt.timedelta(seconds=(i + 1) * rd.histogram_interval_size_s)).strftime("%H:%M:%S")
for i in range(n)
]
except Exception:
pass
return rd return rd
@@ -308,16 +345,18 @@ def render_event_report_pdf(rd: ReportData) -> bytes:
else: else:
_render_waveform_layout(fig, rd) _render_waveform_layout(fig, rd)
# Footer (common to both layouts) — Created date + Xmark-like attribution. # Page footer (common to both layouts) — Created date + event id.
# Pushed to the very page bottom so it doesn't collide with the
# waveform footer scale / trigger legend lines just above.
fig.text( fig.text(
0.07, 0.015, 0.07, 0.005,
f"Created: {rd.server_received_at or ''} • seismo-relay", f"Created: {rd.server_received_at or ''} • seismo-relay",
fontsize=7, color="#888", ha="left", fontsize=6, color="#888", ha="left",
) )
fig.text( fig.text(
0.93, 0.015, 0.93, 0.005,
f"Event {rd.event_id[:8] if rd.event_id else ''}", f"Event {rd.event_id[:8] if rd.event_id else ''}",
fontsize=7, color="#888", ha="right", fontsize=6, color="#888", ha="right",
) )
buf = io.BytesIO() buf = io.BytesIO()
@@ -331,10 +370,13 @@ def _render_waveform_layout(fig, rd: ReportData) -> None:
Stats table includes Time (Rel. to Trig), Peak Accel, Peak Disp. Stats table includes Time (Rel. to Trig), Peak Accel, Peak Disp.
Left margin sized to fit the channel labels (MicL/Long/Vert/Tran). Left margin sized to fit the channel labels (MicL/Long/Vert/Tran).
Extra bottom margin reserves space for x-axis tick labels +
"Amplitude Geo: X in/s/div Mic: Y psi(L)/div" footer + trigger
legend without overlap.
""" """
gs = fig.add_gridspec( gs = fig.add_gridspec(
nrows=4, ncols=1, nrows=4, ncols=1,
left=0.11, right=0.94, top=0.97, bottom=0.06, left=0.11, right=0.94, top=0.97, bottom=0.12,
height_ratios=[1.7, 2.0, 1.8, 5.5], height_ratios=[1.7, 2.0, 1.8, 5.5],
hspace=0.35, hspace=0.35,
) )
@@ -355,11 +397,13 @@ def _render_histogram_layout(fig, rd: ReportData) -> None:
No USBM compliance chart (it's a waveform-only concept). Stats table No USBM compliance chart (it's a waveform-only concept). Stats table
uses Date + Time-of-peak instead of relative-time + accel + disp. uses Date + Time-of-peak instead of relative-time + accel + disp.
Left margin sized to fit the channel labels. Left margin sized to fit the channel labels. Extra bottom margin
leaves room for the x-axis time labels + footer scale legend
without overlap.
""" """
gs = fig.add_gridspec( gs = fig.add_gridspec(
nrows=4, ncols=1, nrows=4, ncols=1,
left=0.11, right=0.94, top=0.97, bottom=0.06, left=0.11, right=0.94, top=0.97, bottom=0.12,
height_ratios=[1.8, 0.9, 1.7, 5.6], height_ratios=[1.8, 0.9, 1.7, 5.6],
hspace=0.35, hspace=0.35,
) )
@@ -718,12 +762,12 @@ def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None:
geo_amp_div = f"{(amax * 1.1 * 2) / 10:.3f}" geo_amp_div = f"{(amax * 1.1 * 2) / 10:.3f}"
break break
fig.text( fig.text(
0.07, 0.045, 0.11, 0.030,
f"Time(Seconds) {div_s:.2f} sec/div Amplitude Geo: {geo_amp_div} in/s/div Mic: 0.001 psi(L)/div", 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", fontsize=7, color="#444", ha="left",
) )
fig.text( fig.text(
0.07, 0.030, 0.11, 0.018,
"Trigger = ▶━━━━━ ━━━━━━◀", "Trigger = ▶━━━━━ ━━━━━━◀",
fontsize=7, color="#444", ha="left", fontsize=7, color="#444", ha="left",
) )
@@ -789,7 +833,7 @@ def _draw_histogram_subplot(fig, gridspec_cell, rd: ReportData) -> None:
geo_amp_div = f"{amax / 5:.3f}" geo_amp_div = f"{amax / 5:.3f}"
break break
fig.text( fig.text(
0.07, 0.045, 0.11, 0.030,
f"Time {interval_str} /div Amplitude Geo: {geo_amp_div} in/s/div Mic: 0.001 psi(L)/div", 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", fontsize=7, color="#444", ha="left",
) )