diff --git a/sfm/report_pdf.py b/sfm/report_pdf.py index 87c556a..2771bba 100644 --- a/sfm/report_pdf.py +++ b/sfm/report_pdf.py @@ -121,6 +121,11 @@ class ReportData: t0_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: Optional[str] = None is_histogram: bool = False @@ -246,6 +251,8 @@ def gather_report_data( "peak_accel_g": ch.get("peak_accel_g"), "peak_disp_in": ch.get("peak_disp_in"), "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_time": peak_time, }) @@ -290,6 +297,19 @@ def gather_report_data( except Exception as 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 ── # Codec emits ~N per-block samples (typically 1/sec); BW reports # 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), ("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 dy = 0.095 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 = 0.95 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 @@ -656,6 +679,10 @@ def _draw_channel_stats_waveform(ax, rd: ReportData) -> None: ("Peak Acceleration", "peak_accel_g", "g"), ("Peak Displacement", "peak_disp_in", "in"), ("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, # right against the page margin) has room — see _COMPLIANCE_BOX. @@ -772,6 +799,8 @@ def _draw_stats_table( if field == "zc_freq_hz": prefix = ">" if ch_rec.get("zc_freq_above_range") else "" 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 str(val) @@ -816,9 +845,22 @@ def _channel_axis_color(ch: str) -> str: def _draw_waveform_subplot(fig, gridspec_cell, rd: ReportData) -> None: """4-channel stacked waveform plot — Instantel printout order (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.""" - inner = gridspec_cell.subgridspec(4, 1, hspace=0.0) + triangle markers at t=0, '0.0' baseline label on right of each. + + 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"] + 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 # Convert ms-based time axis to seconds for the x-axis 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_shared = max(_geo_amax * 1.10, GEO_FLOOR_INS) + main_axes = [] + sc_axes = [] last_idx = len(order) - 1 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 [] 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: ax.tick_params(axis="x", labelsize=7) 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 - 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", 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) # and find peak geo amplitude for the geo amp/div setting. total_s = times[-1] - times[0] if values else 0