From a5888e1b5c0551410cf7f53672f7d2e68d288554 Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 28 May 2026 04:33:53 +0000 Subject: [PATCH] report_pdf: PDF histogram aggregation + fix footer/x-axis overlap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- sfm/report_pdf.py | 66 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/sfm/report_pdf.py b/sfm/report_pdf.py index 1df8699..6635f06 100644 --- a/sfm/report_pdf.py +++ b/sfm/report_pdf.py @@ -285,6 +285,43 @@ def gather_report_data( except Exception as 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 @@ -308,16 +345,18 @@ def render_event_report_pdf(rd: ReportData) -> bytes: else: _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( - 0.07, 0.015, + 0.07, 0.005, f"Created: {rd.server_received_at or '—'} • seismo-relay", - fontsize=7, color="#888", ha="left", + fontsize=6, color="#888", ha="left", ) fig.text( - 0.93, 0.015, + 0.93, 0.005, 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() @@ -331,10 +370,13 @@ def _render_waveform_layout(fig, rd: ReportData) -> None: Stats table includes Time (Rel. to Trig), Peak Accel, Peak Disp. 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( 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], 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 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( 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], 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}" break 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", fontsize=7, color="#444", ha="left", ) fig.text( - 0.07, 0.030, + 0.11, 0.018, "Trigger = ▶━━━━━ ━━━━━━◀", 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}" break 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", fontsize=7, color="#444", ha="left", )