From 91b9b4578c7c521cbde6b0a2684eabfe19817756 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 7 Sep 2026 23:26:42 +0000 Subject: [PATCH] fix(pdf): shared geo Y scale across Long/Vert/Tran (was per-trace) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event-report waveform plot scaled each geo lane to its own peak, so a small channel filled its lane looking as big as a large one — and the "Geo: X in/s/div" footer only reflected whichever channel was checked first, so its div value was wrong for the other two. Now all three geo lanes share ONE symmetric scale = max |sample| across them (padded, 0.05 in/s floor), matching the event modal and BW's single amp/div; the footer reflects that shared scale. Mic keeps its own psi scale. Big events are unchanged (e.g. BE12844 stays 0.185 in/s/div). Test-first: tests/test_report_pdf_geo_scale.py (shared scale + floor), 2 tests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf --- sfm/report_pdf.py | 29 +++++++++----- tests/test_report_pdf_geo_scale.py | 61 ++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 10 deletions(-) create mode 100644 tests/test_report_pdf_geo_scale.py diff --git a/sfm/report_pdf.py b/sfm/report_pdf.py index 25859d1..60dca98 100644 --- a/sfm/report_pdf.py +++ b/sfm/report_pdf.py @@ -777,6 +777,19 @@ def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None: 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 + # Shared geo scale across Long/Vert/Tran (matches the event modal + BW's + # single amp/div): all three geo lanes use ONE Y scale = the max |sample| + # across them (padded, floored), so relative amplitudes stay honest instead + # of each lane auto-zooming to its own peak. Mic keeps its own (psi) scale. + GEO_FLOOR_INS = 0.05 + _geo_amax = 0.0 + for _gch in ("Long", "Vert", "Tran"): + for _x in (rd.channels.get(_gch) or []): + _a = abs(_x) + if _a > _geo_amax: + _geo_amax = _a + geo_shared = max(_geo_amax * 1.10, GEO_FLOOR_INS) + last_idx = len(order) - 1 for i, ch in enumerate(order): ax = fig.add_subplot(inner[i]) @@ -786,10 +799,10 @@ def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None: if values: color = _channel_axis_color(ch) ax.plot(times, values, color=color, linewidth=0.5) - # Symmetric y-axis for geo; zero-anchored for mic. + # Geo: one shared symmetric scale (honest relative amplitudes). + # Mic: symmetric on its own psi scale (different unit). if ch != "MicL": - amax = max((abs(v) for v in values), default=0.001) - ax.set_ylim(-amax * 1.10, amax * 1.10) + ax.set_ylim(-geo_shared, geo_shared) else: amax = max((abs(v) for v in values), default=0.001) ax.set_ylim(-amax * 1.10, amax * 1.10) @@ -824,13 +837,9 @@ def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None: # 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 + # Footer div value reflects the SHARED geo scale (so it's correct for all + # three lanes, not just whichever one happened to be checked first). + geo_amp_div = f"{(geo_shared * 2) / 10:.3f}" if _geo_amax > 0 else "—" fig.text( 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", diff --git a/tests/test_report_pdf_geo_scale.py b/tests/test_report_pdf_geo_scale.py new file mode 100644 index 0000000..6511f03 --- /dev/null +++ b/tests/test_report_pdf_geo_scale.py @@ -0,0 +1,61 @@ +"""The event-report PDF must draw the three geo channels on ONE shared Y scale +(max |sample| across Long/Vert/Tran, floored), not each trace auto-zoomed to its +own peak — so relative amplitudes are honest and a small channel doesn't fill its +lane looking as big as a large one. Mirrors the event-modal waveform behaviour. +""" +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import pytest + +from sfm.report_pdf import ReportData, _draw_waveform_subplot + + +def _draw(channels): + rd = ReportData( + channels=channels, + sample_rate_sps=1024, + dt_ms=1000.0 / 1024, + t0_ms=0.0, + ) + fig = plt.figure() + cell = fig.add_gridspec(1, 1)[0, 0] + _draw_waveform_subplot(fig, cell, rd) + by_label = {ax.get_ylabel(): ax for ax in fig.axes} + try: + yield_ = {k: by_label[k].get_ylim() for k in ("Long", "Vert", "Tran", "MicL")} + finally: + plt.close(fig) + return yield_ + + +def test_geo_traces_share_one_y_scale(): + # Tran is the biggest geo channel (0.35); Long 0.10, Vert 0.02. + ylims = _draw({ + "Long": [0.10, -0.10, 0.0], + "Vert": [0.02, -0.02, 0.0], + "Tran": [0.35, -0.35, 0.0], + "MicL": [0.0005, -0.0005, 0.0], + }) + # Shared scale = max(0.35 * 1.10, floor 0.05) = 0.385, symmetric. + expected = pytest.approx(0.385, rel=1e-6) + for ch in ("Long", "Vert", "Tran"): + lo, hi = ylims[ch] + assert hi == expected, f"{ch} top ylim {hi} != shared 0.385" + assert lo == pytest.approx(-0.385, rel=1e-6), f"{ch} bottom ylim {lo}" + # All three geo lanes identical. + assert ylims["Long"] == ylims["Vert"] == ylims["Tran"] + # Mic keeps its own (much smaller) scale — not lumped into the geo max. + assert ylims["MicL"][1] < 0.01 + + +def test_geo_shared_scale_has_floor(): + # A tiny event (all geo well under the floor) clamps to the 0.05 floor. + ylims = _draw({ + "Long": [0.008, -0.008, 0.0], + "Vert": [0.006, -0.006, 0.0], + "Tran": [0.010, -0.010, 0.0], + "MicL": [0.0001, -0.0001, 0.0], + }) + for ch in ("Long", "Vert", "Tran"): + assert ylims[ch][1] == pytest.approx(0.05, rel=1e-6), f"{ch} not floored"