From f6abe3caa00ab456556d4c2bf1243952973baab0 Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 28 May 2026 18:22:20 +0000 Subject: [PATCH] fix(report_pdf): histogram geo channels share nice-quantized y-axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related visual bugs on histogram PDFs: 1. Per-channel auto-scale meant Tran/Vert/Long had different y-axes (e.g. 0-0.015, 0-0.025, 0-0.020) — bars looked taller on the channel that happened to be quietest. Not directly comparable. 2. Footer "Amplitude Geo: X in/s/div" was just amax/5 of the FIRST geo channel with data, with no LSB quantization — producing nonsense like 0.003 in/s/div when the geophone LSB is 0.005. Fix: compute a single shared geo y-axis range from max(Tran,Vert,Long), quantize the per-division step to BW's 1-2-5 sequence rounded to the 0.005 LSB (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, ...), apply the same ylim + ticks to all three geo subplots, and use that same step for the footer label. MicL stays on its own auto-scale (different units). Verified across edge cases including the reported event (geo max 0.025 → 0.005/div, top 0.025), small PVS events, and large blast amplitudes. Co-Authored-By: Claude Opus 4.7 (1M context) --- sfm/report_pdf.py | 52 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/sfm/report_pdf.py b/sfm/report_pdf.py index 2a30939..a051393 100644 --- a/sfm/report_pdf.py +++ b/sfm/report_pdf.py @@ -796,11 +796,31 @@ def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None: ) +def _nice_geo_step(amax: float) -> float: + """Pick a "nice" per-division step for the geo y-axis. + + Geo LSB is 0.005 in/s — sub-LSB steps like 0.003/div are nonsense. + Quantize to the BW-style 1-2-5 sequence (0.005, 0.01, 0.025, 0.05, + …) and return the smallest step where 5 divisions >= amax, so the + top of the chart lands on a tick. + """ + if amax <= 0: + return 0.005 + for step in (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0): + if step * 5 >= amax: + return step + return 10.0 + + def _draw_histogram_subplot(fig, gridspec_cell, rd: ReportData) -> None: """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. + + The three geo channels share a single y-axis scale (a BW-style nice + multiple of the 0.005 in/s LSB) so bar heights are directly + comparable across channels. MicL has its own auto-scale. """ inner = gridspec_cell.subgridspec(4, 1, hspace=0.0) order = ["MicL", "Long", "Vert", "Tran"] @@ -809,6 +829,16 @@ def _draw_histogram_subplot(fig, gridspec_cell, rd: ReportData) -> None: # X-axis: use absolute time labels if we have them, else interval index have_times = bool(rd.histogram_interval_times) + # Shared geo scale: max across Tran/Vert/Long, quantized to a nice + # tick step. Used for ylim + the footer "Amplitude Geo: X in/s/div". + geo_amax = 0.0 + for gch in ("Tran", "Vert", "Long"): + gv = rd.channels.get(gch) or [] + if gv: + geo_amax = max(geo_amax, max(abs(x) for x in gv if x is not None)) + geo_step = _nice_geo_step(geo_amax) + geo_top = geo_step * 5 # 5 divisions — top tick lands at this value + for i, ch in enumerate(order): ax = fig.add_subplot(inner[i]) values = rd.channels.get(ch) or [] @@ -821,9 +851,13 @@ def _draw_histogram_subplot(fig, gridspec_cell, rd: ReportData) -> None: xs = np.arange(len(abs_vals)) color = _channel_axis_color(ch) 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) + if ch in ("Tran", "Vert", "Long"): + ax.set_ylim(0, geo_top) + ax.set_yticks([j * geo_step for j in range(6)]) + else: + 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", color=_channel_axis_color(ch), weight="bold", labelpad=14) ax.text(1.005, 0.02, "0.0", transform=ax.transAxes, @@ -846,15 +880,11 @@ def _draw_histogram_subplot(fig, gridspec_cell, rd: ReportData) -> None: ax.tick_params(axis="x", labelsize=7) ax.tick_params(axis="y", labelsize=6) - # Footer scale info — histograms use minute/div + # Footer scale info — histograms use minute/div. Reuses the shared + # geo_step computed above so the label matches the actual y-axis + # tick spacing on every subplot. 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 + geo_amp_div = f"{geo_step:.3f}" fig.text( 0.11, 0.030, f"Time {interval_str} /div Amplitude Geo: {geo_amp_div} in/s/div Mic: 0.001 psi(L)/div",