feat(report): render the sensor-check strip + report polish

Wire the decoded sensor self-check waveforms (previous commit) onto the event
report PDF, and fold in two related waveform-panel cleanups.

Sensor-check strip (matches Blastware):
  * ReportData gains sensor_check_waveforms; gather_report_data decodes it from
    the retained raw BW binary (store.paths_for) at report time — no ingest or
    .h5 change, waveform events only.
  * _draw_waveform_subplot now draws a narrow right-hand strip of per-channel
    mini-plots (MicL pulse train + Long/Vert/Tran ring-downs) aligned to the
    lanes, captioned "Sensor Check".
  * stats table gains the "Frequency" / "Overswing Ratio" sub-rows under Sensor
    Check (7.5/7.7/7.3 Hz, 3.6/3.3/3.7), formatted to 1 decimal like BW; values
    come from the already-parsed sensor_check scalars.

Cleanups (pre-existing, in the same panel):
  * fix the stacked-lane y-tick collision — adjacent lanes' -1.0 / 1.0 labels
    overprinted at the shared boundary; prune the extreme ticks (MaxNLocator
    prune="both") so each lane shows clean interior ticks only.
  * fix the header serial+firmware line running off the right page edge —
    tighter right-column indent + BW's slightly smaller 7.5pt header.

Tests: sensor-check + compliance + geo-scale + fft all green (15). The
test_bw_ascii_report failures are pre-existing (gitignored decode-re fixtures
absent in this worktree), unrelated to this change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
This commit is contained in:
2026-09-15 05:33:38 +00:00
co-authored by Claude Opus 4.8
parent 6341432524
commit ab9d84fde6
+75 -6
View File
@@ -121,6 +121,11 @@ class ReportData:
t0_ms: Optional[float] = None t0_ms: Optional[float] = None
dt_ms: Optional[float] = None dt_ms: Optional[float] = None
# Sensor self-check traces — {ch: [samples]} in raw decode units, decoded
# from the binary's trailing block (see minimateplus.sensor_check). The
# little waveforms BW draws in its "Sensor Check" strip. Empty when absent.
sensor_check_waveforms: dict = field(default_factory=dict)
# Record-type discriminator # Record-type discriminator
record_type: Optional[str] = None record_type: Optional[str] = None
is_histogram: bool = False is_histogram: bool = False
@@ -246,6 +251,8 @@ def gather_report_data(
"peak_accel_g": ch.get("peak_accel_g"), "peak_accel_g": ch.get("peak_accel_g"),
"peak_disp_in": ch.get("peak_disp_in"), "peak_disp_in": ch.get("peak_disp_in"),
"sensor_check": sc_ch.get("result"), "sensor_check": sc_ch.get("result"),
"sc_freq_hz": sc_ch.get("freq_hz"),
"sc_ratio": sc_ch.get("ratio"),
"peak_date": peak_date, "peak_date": peak_date,
"peak_time": peak_time, "peak_time": peak_time,
}) })
@@ -290,6 +297,19 @@ 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)
# ── Sensor self-check traces — decoded from the retained raw binary ──
# The .h5 holds only the main waveform; the sensor-check traces live in the
# binary's trailing block, so decode them straight from the kept BW file.
# Waveform events only (histograms have no sensor-check strip).
if not rd.is_histogram:
try:
from minimateplus.sensor_check import decode_sensor_check
bw_path, _a5 = store.paths_for(serial, filename)
if bw_path.exists():
rd.sensor_check_waveforms = decode_sensor_check(bw_path.read_bytes())
except Exception as exc:
log.warning("gather_report_data: sensor-check decode failed: %s", exc)
# ── Histogram aggregation ── # ── Histogram aggregation ──
# Codec emits ~N per-block samples (typically 1/sec); BW reports # Codec emits ~N per-block samples (typically 1/sec); BW reports
# one bar per configured interval (1 min / 5 min / etc.). When # one bar per configured interval (1 min / 5 min / etc.). When
@@ -569,14 +589,17 @@ def _draw_header_columns(ax, rows_left, rd: ReportData) -> 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),
] ]
# fontsize 7.5 (BW's header is a touch smaller than our body text) + a
# tighter right-column value indent so the long serial+firmware line
# ("BE##### V ##.##-#.## MiniMate Plus") fits without running off the page.
y = 0.95 y = 0.95
dy = 0.095 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, fontsize=7.5)
y -= dy y -= dy
y = 0.95 y = 0.95
for label, value in rows_right: for label, value in rows_right:
_kv(ax, 0.55, y, label, value, label_w=0.20) _kv(ax, 0.55, y, label, value, label_w=0.14, fontsize=7.5)
y -= dy y -= dy
@@ -656,6 +679,10 @@ def _draw_channel_stats_waveform(ax, rd: ReportData) -> None:
("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", ""),
# Sensor-check sub-rows (indented under "Sensor Check", like BW): the
# geophone ring-down frequency + overswing ratio from the self-check.
(" Frequency", "sc_freq_hz", "Hz"),
(" Overswing Ratio", "sc_ratio", ""),
] ]
# Compacted to the left half so the enlarged compliance chart (BW-sized, # Compacted to the left half so the enlarged compliance chart (BW-sized,
# right against the page margin) has room — see _COMPLIANCE_BOX. # right against the page margin) has room — see _COMPLIANCE_BOX.
@@ -772,6 +799,8 @@ def _draw_stats_table(
if field == "zc_freq_hz": if field == "zc_freq_hz":
prefix = ">" if ch_rec.get("zc_freq_above_range") else "" prefix = ">" if ch_rec.get("zc_freq_above_range") else ""
return f"{prefix}{val:.0f}" return f"{prefix}{val:.0f}"
if field in ("sc_freq_hz", "sc_ratio"):
return f"{val:.1f}" # BW shows 1 decimal (7.5 Hz, 3.6)
return f"{val:.3f}" return f"{val:.3f}"
return str(val) return str(val)
@@ -816,9 +845,22 @@ 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 in SECONDS, trigger (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.""" triangle markers at t=0, '0.0' baseline label on right of each.
inner = gridspec_cell.subgridspec(4, 1, hspace=0.0)
When sensor self-check traces are present (rd.sensor_check_waveforms), a
narrow "Sensor Check" strip of per-channel mini-plots is drawn to the right,
aligned to the lanes — matching Blastware's Event Report.
"""
from matplotlib.ticker import MaxNLocator
order = ["MicL", "Long", "Vert", "Tran"] order = ["MicL", "Long", "Vert", "Tran"]
has_sc = bool(rd.sensor_check_waveforms)
if has_sc:
# main lanes + a narrow sensor-check strip column on the right
inner = gridspec_cell.subgridspec(4, 2, width_ratios=[1.0, 0.15],
wspace=0.04, hspace=0.0)
else:
inner = gridspec_cell.subgridspec(4, 1, hspace=0.0)
sr = rd.sample_rate_sps or 1024 sr = rd.sample_rate_sps or 1024
# Convert ms-based time axis to seconds for the x-axis # Convert ms-based time axis to seconds for the x-axis
dt_s = (rd.dt_ms or (1000.0 / sr)) / 1000.0 dt_s = (rd.dt_ms or (1000.0 / sr)) / 1000.0
@@ -837,9 +879,12 @@ def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None:
_geo_amax = _a _geo_amax = _a
geo_shared = max(_geo_amax * 1.10, GEO_FLOOR_INS) geo_shared = max(_geo_amax * 1.10, GEO_FLOOR_INS)
main_axes = []
sc_axes = []
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, 0] if has_sc else inner[i])
main_axes.append(ax)
values = rd.channels.get(ch) or [] values = rd.channels.get(ch) or []
times = [t0_s + j * dt_s for j in range(len(values))] times = [t0_s + j * dt_s for j in range(len(values))]
@@ -874,12 +919,36 @@ def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None:
else: else:
ax.tick_params(axis="x", labelsize=7) ax.tick_params(axis="x", labelsize=7)
ax.tick_params(axis="y", labelsize=6) ax.tick_params(axis="y", labelsize=6)
# Stacked lanes touch, so the top/bottom y-tick labels of adjacent lanes
# would overprint at the shared boundary. Prune the extreme ticks so
# each boundary shows clean interior ticks (0.5 / 0.0 / -0.5) only.
ax.yaxis.set_major_locator(MaxNLocator(nbins=4, prune="both"))
# Sensor self-check mini-plot in the right strip (aligned to this lane).
if has_sc:
scx = fig.add_subplot(inner[i, 1])
sc_axes.append(scx)
sc_vals = rd.sensor_check_waveforms.get(ch) or []
if sc_vals:
scx.plot(range(len(sc_vals)), sc_vals,
color=_channel_axis_color(ch), linewidth=0.5)
_amx = max((abs(v) for v in sc_vals), default=1.0) or 1.0
scx.set_ylim(-_amx * 1.15, _amx * 1.15)
scx.set_xticks([]); scx.set_yticks([])
for _s in scx.spines.values():
_s.set_linewidth(0.4); _s.set_color("#999")
# Trigger triangle marker ▼ above the top channel at t=0 # 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 = main_axes[0] # MicL
top_ax.plot([0], [top_ax.get_ylim()[1]], marker="v", color="black", top_ax.plot([0], [top_ax.get_ylim()[1]], marker="v", color="black",
markersize=8, clip_on=False, zorder=10) markersize=8, clip_on=False, zorder=10)
# "Sensor Check" caption under the strip (BW convention)
if has_sc and sc_axes:
pos = sc_axes[-1].get_position()
fig.text((pos.x0 + pos.x1) / 2, pos.y0 - 0.012, "Sensor Check",
fontsize=7, color="#555", ha="center", va="top")
# Compute scale-per-division for the footer (10 divs across the chart) # Compute scale-per-division for the footer (10 divs across the chart)
# and find peak geo amplitude for the geo amp/div setting. # and find peak geo amplitude for the geo amp/div setting.
total_s = times[-1] - times[0] if values else 0 total_s = times[-1] - times[0] if values else 0