diff --git a/CHANGELOG.md b/CHANGELOG.md index ed75adf..6c79802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,12 @@ All notable changes to seismo-relay are documented here. ### Added -- **Event Report PDF generation** — `GET /db/events/{id}/report.pdf` returns a single-page letter-portrait PDF for any event with waveform data on disk. Covers every field a Blastware Event Report includes: header metadata (date/time, trigger source, range, sample rate, project/client/operator/location, serial+firmware, battery, calibration, file name), microphone block (PSPL in dB(L) + psi, ZC freq, channel test), per-channel stats table (PPV / ZC Freq / Time of Peak / Peak Accel / Peak Disp / Sensor Check), Peak Vector Sum, and the 4-channel waveform plot stacked Instantel-style (MicL top → Tran bottom, shared time axis, trigger marker, symmetric Y on geo channels, zero-anchored on mic). Histogram events render as per-interval bar charts instead of waveform plots. USBM RI8507 / OSMRE compliance chart still stubbed — separate work item. Backed by matplotlib (vector PDF output, no headless-browser dep); new `sfm/report_pdf.py` does data assembly + rendering. **Visual layout is approximate** until reference PDFs land at `docs/reference/instantel/` to iterate against. +- **Event Report PDF generation** — `GET /db/events/{id}/report.pdf` returns a single-page letter-portrait PDF for any event with waveform data on disk. Covers every field a Blastware Event Report includes: header metadata (date/time, trigger source, range, sample rate, project/client/operator/location, serial+firmware, battery, calibration, file name), microphone block (PSPL in dB(L) + psi, ZC freq, channel test), per-channel stats table (rows differ for waveform vs histogram), Peak Vector Sum, and the 4-channel plot. Iterated against real Blastware reference PDFs (uploaded to `example-events/pdfsnstuff/`): + - **Waveform layout**: header shows Date/Time, Trigger Source, Range, Sample Rate; stats table has PPV / ZC Freq / Time (Rel. to Trig) / Peak Accel / Peak Disp / Sensor Check; bottom plot is 4-channel line waveform (MicL top → Tran bottom), shared time axis in seconds, dashed trigger line + triangle marker at t=0, symmetric Y on geo channels, zero-anchored on mic, "0.0" baseline label on right per BW convention; footer shows `Time X sec/div Amplitude Geo: Y in/s/div Mic: 0.001 psi(L)/div` and the trigger window `▶━━◀` marker. USBM RI8507/OSMRE compliance chart placeholder upper-right. + - **Histogram layout**: header shows Start / Finish / Intervals At Size / Range / Sample Rate (no Trigger Source — histograms aren't triggered); NO USBM chart; stats table has PPV / ZC Freq / Date / Time / Sensor Check; bottom plot is per-interval bar chart, Y-axis 0-to-peak (never negative), 0.0 baseline at the bottom; footer shows `Time INTERVAL_SIZE /div Amplitude Geo: Y in/s/div Mic: 0.001 psi(L)/div`. + - Backed by matplotlib (vector PDF, no headless-browser dep). Adds matplotlib>=3.8 to deps. + - **Known gap**: histogram codec returns per-block granularity (~200 bars for a 4-interval event) instead of BW's per-interval aggregation. Visual difference vs BW's 4-bar display. XML-driven data source (parsing the structured `_XML.XML` files BW also exports) is the planned fix; that route also resolves the bw_ascii_report PPV-miss bug. + - **Stubbed**: USBM RI8507 / OSMRE compliance chart curves (separate work item; requires coding the regulatory piecewise functions). - **"Download PDF" button** in the event modal's footer — triggers the new endpoint; opens in a new tab so the browser handles save-or-display + surfaces any 404 / server errors visibly. - **SFM webapp now opens to Database view by default** and the History table is fully interactive. Click any column header to sort ascending / descending (timestamp, serial, per-channel PPV, PVS, mic dB(L), project, client, record type, key — all sortable). Click any event row to open the event modal, which now renders a **4-channel waveform plot inline** (MicL / Long / Vert / Tran stacked, Instantel-printout order) alongside the existing sidecar review fields. Headers are sticky so the columns stay visible while scrolling long event lists. No more "where is the viewer" — pick a unit from the filter dropdown, scan the table, click the event, see the waveform. diff --git a/sfm/report_pdf.py b/sfm/report_pdf.py index 2bbcf22..9a256a0 100644 --- a/sfm/report_pdf.py +++ b/sfm/report_pdf.py @@ -123,6 +123,16 @@ class ReportData: record_type: Optional[str] = None is_histogram: bool = False + # Histogram-only fields — only populated for record_type starts with 'Hist' + histogram_start_str: Optional[str] = None # "22:30:38 May 16, 2026" + histogram_stop_str: Optional[str] = None + histogram_n_intervals: Optional[float] = None # 4.00 + histogram_interval_size: Optional[str] = None # "1 minute" + histogram_interval_times: list[str] = field(default_factory=list) # per-interval timestamps for x-axis + + # Peak Vector Sum metadata (histograms show absolute date+time) + peak_vector_sum_when_str: Optional[str] = None + # Bookkeeping event_id: Optional[str] = None server_received_at: Optional[str] = None @@ -231,6 +241,19 @@ def gather_report_data( rd.peak_vector_sum_ips = vs.get("ips") rd.peak_vector_sum_time_s = vs.get("time_s") + # Histogram-specific header fields. These come from the BW XML + # at ingest time (when present); the parsed bw_report dict + # carries them under the 'histogram' sub-block (added by the + # BW XML parser once that lands). For now, derive from the + # event timestamp + recording config as a best-effort. + if rd.is_histogram: + hist = bw.get("histogram") or {} + rd.histogram_start_str = hist.get("start_str") or rd.event_datetime_str + rd.histogram_stop_str = hist.get("stop_str") + rd.histogram_n_intervals = hist.get("n_intervals") + rd.histogram_interval_size = hist.get("interval_size") + rd.histogram_interval_times = hist.get("interval_times") or [] + # ── Waveform samples — from the .h5 via the existing helper ── from sfm import event_hdf5 h5_path = store.hdf5_path_for(serial, filename) @@ -258,53 +281,31 @@ def gather_report_data( def render_event_report_pdf(rd: ReportData) -> bytes: """Render an event report dict to a single-page letter PDF. - Returns the raw PDF bytes — caller streams them back via FastAPI. - - NOTE: this is a v0.20.0 stub layout. The visual hierarchy will be - refined once reference PDFs land at docs/reference/instantel/. All - fields the printout includes are surfaced; spacing and typography - are approximate. + Branches on ``rd.is_histogram`` — waveform and histogram layouts + differ in their header fields, stats-table rows, and bottom plot. + Layout modeled on Blastware's Event Report PDFs (samples in + docs/reference/instantel/). """ # Letter portrait — 8.5"×11" fig = plt.figure(figsize=(8.5, 11), dpi=100) fig.patch.set_facecolor("white") - # Grid: header rows on top, stats in the middle, waveform plot at bottom - # height_ratios sum doesn't matter, only the relative proportions - gs = fig.add_gridspec( - nrows=4, ncols=1, - left=0.07, right=0.96, top=0.96, bottom=0.04, - height_ratios=[2.2, 1.0, 1.4, 5.0], - hspace=0.35, - ) - - # ── Header area (top) ── - ax_header = fig.add_subplot(gs[0]) - ax_header.axis("off") - _draw_header(ax_header, rd) - - # ── Mic block (left) + USBM chart placeholder (right) ── - ax_mic = fig.add_subplot(gs[1]) - ax_mic.axis("off") - _draw_mic_block(ax_mic, rd) - - # ── Per-channel stats table + Peak Vector Sum ── - ax_stats = fig.add_subplot(gs[2]) - ax_stats.axis("off") - _draw_channel_stats(ax_stats, rd) - - # ── Waveform / histogram plot ── if rd.is_histogram: - _draw_histogram_subplot(fig, gs[3], rd) + _render_histogram_layout(fig, rd) else: - _draw_waveform_subplot(fig, gs[3], rd) + _render_waveform_layout(fig, rd) - # Footer + # Footer (common to both layouts) — Created date + Xmark-like attribution. fig.text( 0.07, 0.015, - f"Generated by seismo-relay • event_id={rd.event_id or '—'}", + f"Created: {rd.server_received_at or '—'} • seismo-relay", fontsize=7, color="#888", ha="left", ) + fig.text( + 0.93, 0.015, + f"Event {rd.event_id[:8] if rd.event_id else '—'}", + fontsize=7, color="#888", ha="right", + ) buf = io.BytesIO() fig.savefig(buf, format="pdf") @@ -312,6 +313,69 @@ def render_event_report_pdf(rd: ReportData) -> bytes: return buf.getvalue() +def _render_waveform_layout(fig, rd: ReportData) -> None: + """Waveform layout: header / mic+USBM / per-channel stats / waveform plot. + + Stats table includes Time (Rel. to Trig), Peak Accel, Peak Disp. + Left margin sized to fit the channel labels (MicL/Long/Vert/Tran). + """ + gs = fig.add_gridspec( + nrows=4, ncols=1, + left=0.11, right=0.94, top=0.97, bottom=0.06, + height_ratios=[1.7, 2.0, 1.8, 5.5], + hspace=0.35, + ) + ax_header = fig.add_subplot(gs[0]); ax_header.axis("off") + _draw_header_waveform(ax_header, rd) + + ax_mid = fig.add_subplot(gs[1]); ax_mid.axis("off") + _draw_mic_and_usbm(ax_mid, rd) + + ax_stats = fig.add_subplot(gs[2]); ax_stats.axis("off") + _draw_channel_stats_waveform(ax_stats, rd) + + _draw_waveform_subplot(fig, gs[3], rd) + + +def _render_histogram_layout(fig, rd: ReportData) -> None: + """Histogram layout: header / mic-only / per-channel stats / bar plot. + + 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. + """ + gs = fig.add_gridspec( + nrows=4, ncols=1, + left=0.11, right=0.94, top=0.97, bottom=0.06, + height_ratios=[1.8, 0.9, 1.7, 5.6], + hspace=0.35, + ) + ax_header = fig.add_subplot(gs[0]); ax_header.axis("off") + _draw_header_histogram(ax_header, rd) + + ax_mic = fig.add_subplot(gs[1]); ax_mic.axis("off") + _draw_mic_only(ax_mic, rd) + + ax_stats = fig.add_subplot(gs[2]); ax_stats.axis("off") + _draw_channel_stats_histogram(ax_stats, rd) + + _draw_histogram_subplot(fig, gs[3], rd) + + +def _fmt_iso_to_bw(iso: Optional[str]) -> Optional[str]: + """Convert a ISO-8601 timestamp like '2026-05-16T22:30:37' to BW's + display format '22:30:37 May 16, 2026'. Returns input unchanged if + it doesn't look like ISO.""" + if not iso or "T" not in iso: + return iso + try: + import datetime as _dt + dt = _dt.datetime.fromisoformat(iso.replace("Z", "+00:00")) + return dt.strftime("%H:%M:%S %B %d, %Y").replace(" 0", " ") + except Exception: + return iso + + def _kv(ax, x, y, label, value, *, label_w=0.18): """Render a 'Label Value' row at axes-coordinates (x, y).""" ax.text(x, y, label, fontsize=8, color="#555", ha="left", va="top", @@ -329,11 +393,10 @@ def _fmt(v): return str(v) -def _draw_header(ax, rd: ReportData) -> None: - """Two-column metadata header — matches BW printout layout.""" - # Left column +def _draw_header_waveform(ax, rd: ReportData) -> None: + """Two-column metadata header — waveform variant.""" rows_left = [ - ("Date/Time", rd.event_datetime_str), + ("Date/Time", _fmt_iso_to_bw(rd.event_datetime_str)), ("Trigger Source", rd.trigger_source), ("Range", rd.geo_range_str), ("Sample Rate", rd.sample_rate_str), @@ -343,18 +406,45 @@ def _draw_header(ax, rd: ReportData) -> None: ("User Name:", rd.operator), ("Seis. Loc:", rd.sensor_location), ] + _draw_header_columns(ax, rows_left, rd) + + +def _draw_header_histogram(ax, rd: ReportData) -> None: + """Two-column metadata header — histogram variant. + + Histograms have Start / Finish / Intervals fields instead of + Trigger Source (there's no trigger event for a histogram capture). + """ + intervals_str = None + if rd.histogram_n_intervals is not None and rd.histogram_interval_size: + intervals_str = f"{rd.histogram_n_intervals} At {rd.histogram_interval_size}" + rows_left = [ + ("Start", _fmt_iso_to_bw(rd.histogram_start_str or rd.event_datetime_str)), + ("Finish", _fmt_iso_to_bw(rd.histogram_stop_str)), + ("Intervals", intervals_str), + ("Range", rd.geo_range_str), + ("Sample Rate", (f"{rd.sample_rate_sps} Sps" if rd.sample_rate_sps else None)), + ("Notes", rd.notes), + ("Project:", rd.project), + ("Client:", rd.client), + ("User Name:", rd.operator), + ("Seis. Loc:", rd.sensor_location), + ] + _draw_header_columns(ax, rows_left, rd) + + +def _draw_header_columns(ax, rows_left, rd: ReportData) -> None: + """Shared 2-column header rendering used by both layouts.""" rows_right = [ - ("Serial Number", f"{rd.serial or '—'}" - + (f" {rd.firmware}" if rd.firmware else "")), - ("Battery Level", f"{rd.battery_volts:.1f} Volts" if rd.battery_volts is not None else None), - ("Unit Calibration", (f"{rd.calibration_date}" - + (f" by {rd.calibration_by}" if rd.calibration_by else "")) + ("Serial Number", f"{rd.serial or '—'}" + (f" {rd.firmware}" if rd.firmware else "")), + ("Battery Level", f"{rd.battery_volts:.1f} Volts" if rd.battery_volts is not None else None), + ("Unit Calibration", (f"{rd.calibration_date}" + (f" by {rd.calibration_by}" if rd.calibration_by else "")) if rd.calibration_date else None), - ("File Name", rd.file_name), + ("File Name", rd.file_name), ("Post Event Notes", rd.post_event_notes), ] y = 0.95 - dy = 0.10 + dy = 0.095 for label, value in rows_left: _kv(ax, 0.0, y, label, value, label_w=0.18) y -= dy @@ -364,12 +454,43 @@ def _draw_header(ax, rd: ReportData) -> None: y -= dy -def _draw_mic_block(ax, rd: ReportData) -> None: - """Microphone block — PSPL, ZC Freq, Channel Test. USBM chart - placeholder on the right (filled in a separate work item).""" +def _draw_mic_only(ax, rd: ReportData) -> None: + """Mic block (histogram variant — no USBM chart).""" ax.text(0.0, 0.95, "Microphone Linear Weighting", fontsize=8, color="#555", transform=ax.transAxes, va="top") - rows = [] + rows = _mic_rows(rd) + y = 0.70 + for label, value in rows: + _kv(ax, 0.0, y, label, value, label_w=0.18) + y -= 0.22 + + +def _draw_mic_and_usbm(ax, rd: ReportData) -> None: + """Mic block on the left + USBM compliance chart placeholder on right. + (Waveform variant — USBM is a velocity-vs-frequency compliance plot + that doesn't apply to histograms.)""" + ax.text(0.0, 0.95, "Microphone Linear Weighting", fontsize=8, color="#555", + transform=ax.transAxes, va="top") + rows = _mic_rows(rd) + y = 0.80 + for label, value in rows: + _kv(ax, 0.0, y, label, value, label_w=0.18) + y -= 0.15 + + # USBM chart placeholder — upper-right. Real piecewise compliance + # curves are a separate work item; for now this just shows the title + # + a "see report" message so the layout is correct. + ax.text(0.72, 0.97, "USBM RI8507 And OSMRE", + fontsize=9, weight="bold", color="#333", ha="center", va="top", + transform=ax.transAxes) + ax.text(0.72, 0.50, "[compliance chart\ncoming soon]", + fontsize=8, color="#bbb", ha="center", va="center", + transform=ax.transAxes, style="italic") + + +def _mic_rows(rd: ReportData) -> list[tuple[str, Optional[str]]]: + """Build the mic-section value rows (shared by both layouts).""" + rows: list[tuple[str, Optional[str]]] = [] if rd.mic_pspl_dbl is not None: line = f"{rd.mic_pspl_dbl:.1f} dB(L)" if rd.mic_pspl_time_s is not None: @@ -383,47 +504,78 @@ def _draw_mic_block(ax, rd: ReportData) -> None: line += (f" (Freq = {rd.mic_channel_test_freq_hz:.1f} Hz, " f"Amp = {rd.mic_channel_test_amp_mv:.0f} mv)") rows.append(("Channel Test", line)) - - y = 0.70 - for label, value in rows: - _kv(ax, 0.0, y, label, value, label_w=0.18) - y -= 0.22 - - # USBM chart placeholder — upper-right of this row - ax.text(0.75, 0.95, "USBM RI8507 / OSMRE", - fontsize=8, color="#555", ha="center", va="top", - transform=ax.transAxes) - ax.text(0.75, 0.45, "[compliance chart\nrenders here]", - fontsize=8, color="#bbb", ha="center", va="center", - transform=ax.transAxes, style="italic") + return rows -def _draw_channel_stats(ax, rd: ReportData) -> None: - """Per-channel stats table + Peak Vector Sum row.""" - # Build a 2-D array of strings: header row + 3 channel rows - headers = ["", "Tran", "Vert", "Long", ""] - rows = [ - ["PPV", "ppv_ips", "in/s"], - ["ZC Freq", "zc_freq_hz", "Hz"], - ["Time (Rel. to Trig)", "time_of_peak_s", "sec"], - ["Peak Acceleration", "peak_accel_g", "g"], - ["Peak Displacement", "peak_disp_in", "in"], - ["Sensor Check", "sensor_check", ""], +def _draw_channel_stats_waveform(ax, rd: ReportData) -> None: + """Waveform stats table — has Time (Rel. to Trig), Peak Accel, Peak Disp. + Followed by Peak Vector Sum line.""" + rows_spec = [ + ("PPV", "ppv_ips", "in/s"), + ("ZC Freq", "zc_freq_hz", "Hz"), + ("Time (Rel. to Trig)", "time_of_peak_s", "sec"), + ("Peak Acceleration", "peak_accel_g", "g"), + ("Peak Displacement", "peak_disp_in", "in"), + ("Sensor Check", "sensor_check", ""), ] + _draw_stats_table(ax, rd, rows_spec) + if rd.peak_vector_sum_ips is not None: + line = f"Peak Vector Sum {rd.peak_vector_sum_ips:.3f} in/s" + if rd.peak_vector_sum_time_s is not None: + line += f" At {rd.peak_vector_sum_time_s:.3f} sec." + ax.text(0.0, -0.08, line, fontsize=9, weight="bold", + ha="left", va="top", transform=ax.transAxes) + ax.text(0.0, -0.18, "NA: Not Applicable", fontsize=7, color="#888", + ha="left", va="top", transform=ax.transAxes) + + +def _draw_channel_stats_histogram(ax, rd: ReportData) -> None: + """Histogram stats table — PPV, ZC Freq, Date, Time of peak, Sensor Check. + Followed by Peak Vector Sum line.""" + # Date / Time of peak are per-channel timestamps for the interval at peak. + # bw_report stores time_of_peak_s as relative seconds, but for histograms + # BW shows them as absolute date+time. We populate from rd.channel_stats + # if those absolute fields are present; otherwise fall back to relative. + rows_spec = [ + ("PPV", "ppv_ips", "in/s"), + ("ZC Freq", "zc_freq_hz", "Hz"), + ("Date", "peak_date", ""), + ("Time", "peak_time", ""), + ("Sensor Check", "sensor_check", ""), + ] + _draw_stats_table(ax, rd, rows_spec) + if rd.peak_vector_sum_ips is not None: + when = rd.peak_vector_sum_when_str or "" + line = f"Peak Vector Sum {rd.peak_vector_sum_ips:.3f} in/s" + if when: + line += f" on {when}" + ax.text(0.0, -0.08, line, fontsize=9, weight="bold", + ha="left", va="top", transform=ax.transAxes) + ax.text(0.0, -0.18, "NA: Not Applicable", fontsize=7, color="#888", + ha="left", va="top", transform=ax.transAxes) + + +def _draw_stats_table(ax, rd: ReportData, rows_spec: list[tuple[str, str, str]]) -> None: + """Render a per-channel stats table (Tran/Vert/Long). + + rows_spec: list of (label, field_name_in_channel_stats, unit_string) + """ + headers = ["", "Tran", "Vert", "Long", ""] ch_lookup = {c["name"]: c for c in rd.channel_stats} def _cell(field, ch_name): val = ch_lookup.get(ch_name, {}).get(field) if val is None: return "—" - if field == "sensor_check": - return str(val) if isinstance(val, float): + # ZC Freq is integer-formatted in BW; everything else with 3 decimals + if field == "zc_freq_hz": + return f"{val:.0f}" return f"{val:.3f}" return str(val) table_data = [headers] - for label, field_name, unit in rows: + for label, field_name, unit in rows_spec: table_data.append([ label, _cell(field_name, "Tran"), @@ -431,27 +583,16 @@ def _draw_channel_stats(ax, rd: ReportData) -> None: _cell(field_name, "Long"), unit, ]) - tbl = ax.table( cellText=table_data, loc="upper left", - colWidths=[0.30, 0.13, 0.13, 0.13, 0.10], + colWidths=[0.28, 0.14, 0.14, 0.14, 0.10], cellLoc="left", edges="open", ) tbl.auto_set_font_size(False) tbl.set_fontsize(8) tbl.scale(1, 1.4) - # Header row styling for j in range(5): - cell = tbl[(0, j)] - cell.set_text_props(weight="bold", color="#555") - - # Peak Vector Sum - if rd.peak_vector_sum_ips is not None: - line = f"Peak Vector Sum {rd.peak_vector_sum_ips:.3f} in/s" - if rd.peak_vector_sum_time_s is not None: - line += f" At {rd.peak_vector_sum_time_s:.3f} sec." - ax.text(0.0, -0.05, line, fontsize=9, weight="bold", - ha="left", va="top", transform=ax.transAxes) + tbl[(0, j)].set_text_props(weight="bold", color="#555") def _channel_axis_color(ch: str) -> str: @@ -460,59 +601,142 @@ 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.""" + (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) order = ["MicL", "Long", "Vert", "Tran"] sr = rd.sample_rate_sps or 1024 - dt_ms = rd.dt_ms or (1000.0 / sr) - t0_ms = rd.t0_ms if rd.t0_ms is not None else 0.0 + # Convert ms-based time axis to seconds for the x-axis + 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 last_idx = len(order) - 1 for i, ch in enumerate(order): ax = fig.add_subplot(inner[i]) values = rd.channels.get(ch) or [] - times = [t0_ms + j * dt_ms for j in range(len(values))] + times = [t0_s + j * dt_s for j in range(len(values))] if values: color = _channel_axis_color(ch) - ax.plot(times, values, color=color, linewidth=0.6) - # Symmetric y-axis for geo; zero-anchored for mic + ax.plot(times, values, color=color, linewidth=0.5) + # Symmetric y-axis for geo; zero-anchored for mic. if ch != "MicL": amax = max((abs(v) for v in values), default=0.001) - ax.set_ylim(-amax * 1.1, amax * 1.1) - # Channel label on left + ax.set_ylim(-amax * 1.10, amax * 1.10) + else: + amax = max((abs(v) for v in values), default=0.001) + ax.set_ylim(-amax * 1.10, amax * 1.10) + + # Channel label on the LEFT (matches BW) ax.set_ylabel(ch, fontsize=8, rotation=0, ha="right", va="center", color=_channel_axis_color(ch), weight="bold", labelpad=14) - ax.grid(True, linestyle=":", linewidth=0.4, alpha=0.5) - # Dashed trigger line at t=0 - ax.axvline(0.0, color="#cc0000", linestyle="--", linewidth=0.8, alpha=0.7) - # Zero baseline - ax.axhline(0.0, color="#888", linestyle="-", linewidth=0.4, alpha=0.5) + # "0.0" on the RIGHT (BW convention) + ax.text(1.005, 0.5, "0.0", transform=ax.transAxes, + fontsize=7, color="#555", va="center", ha="left") + + ax.grid(True, linestyle="--", linewidth=0.3, color="#bbb", alpha=0.6) + # Vertical dashed trigger line at t=0 + ax.axvline(0.0, color="#cc0000", linestyle="--", linewidth=0.6, alpha=0.7) + # Zero baseline horizontal + ax.axhline(0.0, color=_channel_axis_color(ch), linestyle="-", + linewidth=0.4, alpha=0.5) if i != last_idx: ax.set_xticklabels([]) + ax.tick_params(axis="x", length=0) else: - ax.set_xlabel("Time (ms)", fontsize=8) - ax.tick_params(axis="both", labelsize=7) + ax.tick_params(axis="x", labelsize=7) + ax.tick_params(axis="y", labelsize=6) + + # 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.plot([0], [top_ax.get_ylim()[1]], marker="v", color="black", + markersize=8, clip_on=False, zorder=10) + + # 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 + 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 + fig.text( + 0.07, 0.045, + 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, + "Trigger = ▶━━━━━ ━━━━━━◀", + fontsize=7, color="#444", ha="left", + ) def _draw_histogram_subplot(fig, gridspec_cell, rd: ReportData) -> None: - """4-channel stacked histogram bar chart — per-interval peaks.""" + """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. + """ inner = gridspec_cell.subgridspec(4, 1, hspace=0.0) order = ["MicL", "Long", "Vert", "Tran"] last_idx = len(order) - 1 + + # X-axis: use absolute time labels if we have them, else interval index + have_times = bool(rd.histogram_interval_times) + for i, ch in enumerate(order): ax = fig.add_subplot(inner[i]) values = rd.channels.get(ch) or [] if values: - xs = np.arange(1, len(values) + 1) + # Histograms record per-interval PEAK magnitudes — always + # non-negative. Codec output occasionally includes signed + # values when the underlying .h5 was scaled like a waveform; + # take the absolute value so the bars rise from zero. + abs_vals = [abs(v) if v is not None else 0 for v in values] + xs = np.arange(len(abs_vals)) color = _channel_axis_color(ch) - ax.bar(xs, values, color=color, width=1.0, linewidth=0) + 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) ax.set_ylabel(ch, fontsize=8, rotation=0, ha="right", va="center", color=_channel_axis_color(ch), weight="bold", labelpad=14) - ax.grid(True, axis="y", linestyle=":", linewidth=0.4, alpha=0.5) + ax.text(1.005, 0.02, "0.0", transform=ax.transAxes, + fontsize=7, color="#555", va="bottom", ha="left") + ax.grid(True, axis="y", linestyle="--", linewidth=0.3, color="#bbb", alpha=0.6) if i != last_idx: ax.set_xticklabels([]) + ax.tick_params(axis="x", length=0) else: - ax.set_xlabel("Interval", fontsize=8) - ax.tick_params(axis="both", labelsize=7) + if have_times and len(rd.histogram_interval_times) == len(values): + # Show 2-4 labels evenly spaced + n = len(values) + step = max(1, n // 4) + tick_positions = list(range(0, n, step)) + ax.set_xticks(tick_positions) + ax.set_xticklabels([rd.histogram_interval_times[t] for t in tick_positions], + rotation=0, fontsize=6) + else: + ax.set_xlabel("Interval", fontsize=8) + ax.tick_params(axis="x", labelsize=7) + ax.tick_params(axis="y", labelsize=6) + + # Footer scale info — histograms use minute/div + 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 + fig.text( + 0.07, 0.045, + 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", + )