4 Commits

Author SHA1 Message Date
serversdown 3457ed0072 bw_ascii_report: parse OORANGE saturation marker + TimeSum typo
BW writes "OORANGE" (truncation of "Out Of Range") when a channel
exceeds its full-scale, and uses a typo'd label "Peak Vector Sum
TimeSum" for the PVS time field.  Both confirmed against real ASCII
files pulled from a Windows watcher PC 2026-05-27:

  T190LD5Q.LK0W  Vert PPV = OORANGE  (Normal range, 10 in/s exceeded)
  T438L713.RY0W  All three PPVs OORANGE  (Sensitive range, 1.25 in/s)
  K557L3YM.OE0W  Tran+Vert PPV OORANGE + MicL PSPL OORANGE

Previously our _parse_number() returned None for OORANGE → DB columns
ended up NULL → events vanished from filters / sorts / dashboards
despite being legitimate high-amplitude events.

New behavior — substitute a conservative bound + set a saturation flag:
  - Channel PPV       → geo_range_ips + ChannelStats.ppv_saturated
  - Peak Vector Sum   → sqrt(3) * geo_range_ips + peak_vector_sum_saturated
  - MicL PSPL         → 140 dB(L) + MicStats.pspl_saturated

Flags propagate to the sidecar's bw_report block so the SFM UI can
render "> 10 in/s" / "> 140 dBL" rather than treating the substituted
value as exact.

Same commit also accepts "Peak Vector Sum TimeSum" as an alias for
"Peak Vector Sum Time" (BW always writes the typo on OORANGE PVS
lines — every example file confirms it).

Tests: new test_oorange_marker_treated_as_saturation (synthetic) +
test_real_oorange_event_t190_parses (skips if real fixture absent).
177/177 tests pass; 16 pre-existing missing-fixture skips unchanged.

Five events on prod (T190, T438, K557, plus 2 others matching the
same fault pattern) will pick up correct peaks + saturation flags
once watchers re-forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:32:56 +00:00
serversdown d21e3b5298 histogram aggregation + parser extension for BW interval fields
Three layered changes that together make histogram charts visually
match BW's printout (one bar per interval, not per codec block):

1. bw_ascii_report parser captures histogram fields it previously
   dropped:
     - Histogram Start/Stop Time + Date → datetime
     - Number of Intervals + Interval Size (string + parsed seconds)
     - <Channel> Peak Time + Peak Date → datetime (per-channel)
     - Peak Vector Sum Date (combined with PVS Time → datetime;
       clears the bogus seconds parse that interpreted "22:33:52"
       as 22.0)
   New _parse_iso_date() handles BW's ISO format for histograms
   (waveforms use "May 8, 2026" long form).  New _parse_interval_size()
   handles "1 minute" / "5 minutes" / "15 seconds" etc.

2. _bw_report_to_dict() projects the new fields into a new
   bw_report.histogram block in the sidecar.

3. /db/events/{id}/waveform.json wraps the existing path 1 (HDF5)
   output with _maybe_aggregate_histogram(): when the event is a
   histogram AND the sidecar has bw_report.histogram.n_intervals,
   group the codec's per-block samples into N intervals via
   max-per-group and return the aggregated array.  time_axis gains
   histogram_aggregated / n_intervals / interval_size_s / interval_times
   fields.

Frontend (both modal chart in sfm_webapp.html + standalone event
browser) uses interval_times as x-axis labels when provided (BW-style
HH:MM:SS), falls back to interval index.

Defensive: aggregation is no-op when the sidecar lacks the histogram
block (events ingested before this change).  Activates automatically
on prod once a watcher re-forward populates new sidecars.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:23:05 +00:00
serversdown ad2b553c7b ingest: preserve raw BW ASCII report (.TXT) alongside the binary
Previously the .TXT was parsed into the sidecar's bw_report projection
and then discarded at ingest time.  Now save_imported_bw() writes it
to <store>/<serial>/<filename>_ASCII.TXT permanently.

Rationale: with BW Mail / Forwarding Agent being phased out of the
operator workflow, the XML/PDF/WMF those tools produce won't be
available — the binary + .TXT (created by BW ACH itself) are our
only authoritative inputs going forward.  Keeping the raw .TXT
unlocks:

  - Parser bug fixes can be applied RETROACTIVELY by re-parsing the
    stored .TXT, instead of requiring a re-forward from the watcher
    PC (which lost the .TXT after BW ACH cleanup).
  - Audit trail of what BW actually sent us, for debugging.
  - The five known parser-PPV-miss events will be re-parseable once
    the regex fix lands (instead of staying broken indefinitely).

Storage cost: ~15 KB per event × 14k events = ~210 MB on the
existing prod corpus.  Negligible.

Implementation:
  - WaveformStore gains txt_path_for() + open_txt()
  - save_imported_bw() writes the .TXT when bw_report_text is supplied
  - sidecar source block records the txt_filename
  - backfill_sidecars.py preserves txt_filename across regens
  - New GET /db/events/{id}/ascii_report.txt endpoint serves it
  - Returns 404 for events ingested before this change (no .TXT in
    the store yet) — re-forward to populate

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:01:12 +00:00
serversdown dfbc8b8520 report_pdf: split waveform vs histogram layouts (BW PDF iteration)
Reviewed against real Blastware Event Report PDFs (uploaded to
example-events/pdfsnstuff/) for K558LLB7.V20H (histogram) and
K558LLB8.0E0W (waveform).  Each event type has its own layout because
BW's printouts genuinely differ:

  Waveform header:   Date/Time, Trigger Source, Range, Sample Rate
  Histogram header:  Start, Finish, Intervals At Size, Range, Sample Rate
                     (no trigger field — histograms aren't triggered)

  Waveform stats:    PPV, ZC Freq, Time (Rel. to Trig),
                     Peak Acceleration, Peak Displacement, Sensor Check
  Histogram stats:   PPV, ZC Freq, Date, Time (of peak), Sensor Check

  Waveform plot:     4-channel stacked line, x-axis in SECONDS,
                     trigger triangle + window markers, symmetric Y
                     for geo, zero-anchored mic, "0.0" baseline label
                     on right edge per BW convention
  Histogram plot:    4-channel stacked bars, Y-axis 0-to-peak only
                     (never negative — peaks are magnitudes), 0.0
                     baseline at the bottom

  Waveform footer:   USBM chart placeholder upper-right;
                     "Time X sec/div   Amplitude Geo: Y in/s/div   Mic: 0.001 psi(L)/div"
                     "Trigger = ▶━━◀"
  Histogram footer:  No USBM chart; same scale-info footer with
                     interval-size as the time unit

Other fixes from the first-pass screenshot review:
  - Channel labels (MicL/Long/Vert/Tran) no longer cut off (wider
    left margin)
  - Histogram bars rise from zero baseline (abs of any signed values)
  - ISO timestamp "2026-05-16T22:33:50" → "22:33:50 May 16, 2026"
    matching BW's display format

Known gaps (separate work):
  - Histogram codec returns per-block granularity (~200 bars for
    BW's 4-interval display).  XML-driven data source is the planned
    fix; the structured BW XML has the per-interval aggregates.
  - USBM RI8507 / OSMRE compliance chart still placeholder

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:22:03 +00:00
10 changed files with 830 additions and 134 deletions
+27 -1
View File
@@ -6,9 +6,35 @@ All notable changes to seismo-relay are documented here.
## [Unreleased] ## [Unreleased]
### Fixed
- **bw_ascii_report parser now handles `OORANGE` saturation marker.** BW writes `"OORANGE"` (truncation of "Out Of Range") in PPV / PVS / MicL PSPL fields when the underlying measurement exceeded the channel's full-scale. Previously our `_parse_number()` returned None → DB ended up with NULL peaks for legitimate high-amplitude events. Confirmed on real ASCII files pulled 2026-05-27 from the Windows watcher PC: T190LD5Q.LK0W (Vert saturated at Normal range 10 in/s), T438L713.RY0W (all three channels saturated at Sensitive range 1.25 in/s), K557L3YM.OE0W (Tran+Vert saturated + Mic PSPL OORANGE). New behavior:
- Per-channel PPV: substitute `geo_range_ips` as a conservative lower bound + set `ppv_saturated` flag
- Peak Vector Sum: substitute `sqrt(3) * geo_range_ips` (the theoretical max when all 3 channels are simultaneously at full-scale) + `peak_vector_sum_saturated` flag
- MicL PSPL: substitute 140 dB(L) (conservative NL-43 max) + `pspl_saturated` flag
- Saturation flags are propagated into the sidecar's `bw_report` block for downstream UI rendering (`> 10 in/s` or similar)
- Five events on prod (T190 / T438 / K557 + 2 others matching the same fault pattern) will pick up correct DB peaks + saturation flags once re-forwarded
- **bw_ascii_report parser handles `Peak Vector Sum TimeSum` typo'd label.** Real BW output uses this misspelled label (Sum appended twice instead of "Peak Vector Sum Time"). Now accepted as an alias. Confirmed against all three OORANGE example files — every one has the typo.
### Added ### 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. - **Histogram per-interval aggregation in `waveform.json`.** Histogram events now render with one bar per BW-reported interval (matching the Blastware printout) instead of ~200 bars per event (the raw codec output). When the sidecar's `bw_report.histogram.n_intervals` is populated (events ingested with the new parser, see next bullet), the `/db/events/{id}/waveform.json` endpoint groups the codec samples into N intervals via max-per-group and returns the aggregated array. `time_axis` gains `histogram_aggregated: true`, `n_intervals`, `interval_size_s`, and `interval_times` (HH:MM:SS strings). Both the modal chart and the standalone event browser use those interval timestamps as x-axis labels when present. Defensive: no-op for events ingested before the parser extension landed (their sidecars lack `histogram.n_intervals`) — those continue to render with raw codec output.
- **`bw_ascii_report` parser now captures histogram-specific fields.** Previously the parser dropped these fields silently (Roadmap item closed):
- `Histogram Start Time` / `Histogram Start Date` (combined into `histogram_start: datetime`)
- `Histogram Stop Time` / `Histogram Stop Date` (combined into `histogram_stop: datetime`)
- `Number of Intervals` (`histogram_n_intervals: int`)
- `Interval Size` ("1 minute" string + parsed seconds: `histogram_interval_size_str`, `histogram_interval_size_s`)
- `<Channel> Peak Time` + `<Channel> Peak Date` for histogram events (combined into `channel_peak_when: dict`; waveforms continue to use `time_of_peak_s` relative)
- `Peak Vector Sum Date` (combined with PVS Time into `peak_vector_sum_when: datetime`; clears the previous bogus `peak_vector_sum_time_s` parse that interpreted "22:33:52" as 22.0 seconds)
- All new fields land in the sidecar's `bw_report.histogram` block via `_bw_report_to_dict`. Tested against synthetic K558LLB7.V20H-shaped input.
- **Raw BW ASCII report (.TXT) preservation.** `save_imported_bw` now writes the paired `_ASCII.TXT` to `<store>/<serial>/<filename>_ASCII.TXT` alongside the binary at ingest time. Previously the .TXT was parsed into the sidecar's `bw_report` projection and then discarded — meaning parser bug fixes couldn't be applied retroactively without re-forwarding from the watcher PC. Now the raw .TXT lives in the waveform store permanently (~15 KB per event; ~210 MB total for a 14k-event store; negligible). Sidecar's `source.txt_filename` field records the saved path; backfill_sidecars preserves it across regens. New `GET /db/events/{id}/ascii_report.txt` endpoint serves the raw .TXT for any event ingested after this change. Events ingested before today still return 404 from that endpoint until re-forwarded. Architectural rationale: with BW Mail / Forwarding Agent being phased out of the operator workflow, the XML/PDF/WMF that those tools produced are no longer available — the binary + .TXT (created by BW ACH itself) are our authoritative source for everything going forward.
- **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. - **"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. - **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.
+184 -3
View File
@@ -60,6 +60,13 @@ class ChannelStats:
time_of_peak_s: Optional[float] = None # seconds (relative to trigger; can be negative) time_of_peak_s: Optional[float] = None # seconds (relative to trigger; can be negative)
peak_accel_g: Optional[float] = None # g (geo channels only) peak_accel_g: Optional[float] = None # g (geo channels only)
peak_disp_in: Optional[float] = None # in (geo channels only) peak_disp_in: Optional[float] = None # in (geo channels only)
# When BW writes "OORANGE" (Out Of Range — truncated) for a PPV
# value, the true peak exceeded the channel's full-scale range.
# We substitute the range max (e.g. 10.000 in/s for Normal range)
# as a lower bound, and flag here so downstream UI / alerts know
# to render "> 10 in/s" or "saturated" instead of trusting the
# value as an exact measurement.
ppv_saturated: bool = False
@dataclass @dataclass
@@ -69,6 +76,11 @@ class MicStats:
pspl_dbl: Optional[float] = None # dB(L) pspl_dbl: Optional[float] = None # dB(L)
zc_freq_hz: Optional[float] = None zc_freq_hz: Optional[float] = None
time_of_peak_s: Optional[float] = None time_of_peak_s: Optional[float] = None
# Set when BW writes "OORANGE" for PSPL — mic exceeded its
# measurement range. pspl_dbl gets the conservative upper bound
# 140 dBL (typical NL-43 max; some units cap at 148). Consumers
# should render "> 140 dB(L)" or similar when this flag is set.
pspl_saturated: bool = False
@dataclass @dataclass
@@ -92,6 +104,21 @@ class MonitorLogEntry:
description: Optional[str] = None description: Optional[str] = None
# BW saturation marker — appears in PPV / Peak Vector Sum / similar
# numeric fields when the underlying measurement exceeded the
# channel's full-scale range (e.g., a geophone reading > 10 in/s at
# Normal range, or a mic exceeding its sensitivity ceiling). Treated
# as "≥ range_max" + a saturated flag rather than discarded.
# Appears as: ``"Tran PPV : OORANGE in/s"``
_OORANGE_MARKERS = ("OORANGE", "OUT OF RANGE")
def _is_oorange(value: str) -> bool:
"""True when a BW numeric field is an Out-Of-Range saturation marker."""
s = value.strip().upper()
return any(m in s for m in _OORANGE_MARKERS)
@dataclass @dataclass
class BwAsciiReport: class BwAsciiReport:
"""Structured representation of one BW per-event ASCII export.""" """Structured representation of one BW per-event ASCII export."""
@@ -144,6 +171,29 @@ class BwAsciiReport:
# ── Vector sum ────────────────────────────────────────────────────────── # ── Vector sum ──────────────────────────────────────────────────────────
peak_vector_sum_ips: Optional[float] = None peak_vector_sum_ips: Optional[float] = None
peak_vector_sum_time_s: Optional[float] = None peak_vector_sum_time_s: Optional[float] = None
# Saturation flag — set when BW writes "OORANGE" for the PVS. We
# then substitute sqrt(3) * geo_range_ips as a conservative upper
# bound (the theoretical maximum PVS when all 3 geo channels are
# simultaneously at full-scale). Consumers should display this as
# ">{value} in/s" or similar.
peak_vector_sum_saturated: bool = False
# Histograms additionally have an absolute date+time for the PVS
# (it occurred at a specific interval). Waveform reports show
# only the relative-time value above.
peak_vector_sum_when: Optional[datetime.datetime] = None
# ── Histogram-specific fields (populated only when Event Type starts
# with 'Histogram' / 'Full Histogram' / 'Histogram + Continuous') ──
histogram_start: Optional[datetime.datetime] = None
histogram_stop: Optional[datetime.datetime] = None
histogram_n_intervals: Optional[int] = None # e.g. 4, 1436
histogram_interval_size_str: Optional[str] = None # "1 minute" / "5 minutes" / "15 seconds"
histogram_interval_size_s: Optional[float] = None # parsed to seconds
# Per-channel absolute peak time+date (histogram-specific). For
# waveform events these are None — those reports use the channel's
# time_of_peak_s (relative to trigger) instead. Keyed by channel
# name ("Tran", "Vert", "Long", "MicL").
channel_peak_when: Dict[str, datetime.datetime] = field(default_factory=dict)
# ── Sensor self-check (per channel) ───────────────────────────────────── # ── Sensor self-check (per channel) ─────────────────────────────────────
sensor_check: Dict[str, SensorCheck] = field(default_factory=dict) sensor_check: Dict[str, SensorCheck] = field(default_factory=dict)
@@ -223,6 +273,46 @@ def _parse_event_date(s: str) -> Optional[datetime.date]:
return None return None
def _parse_iso_date(s: str) -> Optional[datetime.date]:
"""Parse "2026-05-16" → date. Histograms use ISO format for their
Start Date / Stop Date / Peak Date fields; waveforms use the
"May 8, 2026" long form which `_parse_event_date` handles."""
s = s.strip()
try:
return datetime.date.fromisoformat(s)
except ValueError:
return None
_INTERVAL_UNIT_SECONDS = {
"second": 1, "seconds": 1, "sec": 1, "secs": 1,
"minute": 60, "minutes": 60, "min": 60, "mins": 60,
"hour": 3600, "hours": 3600, "hr": 3600, "hrs": 3600,
}
def _parse_interval_size(s: str) -> Optional[float]:
"""Parse "1 minute" / "5 minutes" / "15 seconds" / "2 seconds" → seconds.
Handles the BW Compliance Setup Histogram Interval values verbatim
("2 seconds", "5 seconds", "15 seconds", "1 minute", "5 minutes",
"15 minutes") plus a few defensive variants.
"""
if not s:
return None
parts = s.strip().split()
if len(parts) < 2:
return None
try:
n = float(parts[0])
except ValueError:
return None
unit_per_s = _INTERVAL_UNIT_SECONDS.get(parts[1].lower())
if unit_per_s is None:
return None
return n * unit_per_s
def _parse_event_time(s: str) -> Optional[datetime.time]: def _parse_event_time(s: str) -> Optional[datetime.time]:
"""Parse "15:56:35" → time.""" """Parse "15:56:35" → time."""
s = s.strip() s = s.strip()
@@ -336,6 +426,15 @@ def parse_report(text: Union[str, bytes], *, parse_samples: bool = False) -> BwA
in_user_notes_block = False in_user_notes_block = False
user_note_position = 0 user_note_position = 0
# Histogram-field staging — BW writes <Channel> Peak Time and
# <Channel> Peak Date on separate lines (and similarly Histogram
# Start Time / Date). We stash the partial value when the time
# line arrives and combine it when the matching date line arrives.
_hist_start_time: Optional[datetime.time] = None
_hist_stop_time: Optional[datetime.time] = None
_pending_peak_time: Dict[str, Optional[datetime.time]] = {}
_pvs_time_raw: Optional[str] = None # last Peak Vector Sum Time value, raw
while i < n: while i < n:
raw_line = lines[i] raw_line = lines[i]
i += 1 i += 1
@@ -420,23 +519,105 @@ def parse_report(text: Union[str, bytes], *, parse_samples: bool = False) -> BwA
): ):
ch_name, stat = key.split(" ", 1) ch_name, stat = key.split(" ", 1)
cs = report.channels.setdefault(ch_name, ChannelStats()) cs = report.channels.setdefault(ch_name, ChannelStats())
if stat == "PPV":
if _is_oorange(value):
# Channel saturated — substitute range max as lower
# bound; flag so downstream UI can render "> 10 in/s".
cs.ppv_ips = report.geo_range_ips
cs.ppv_saturated = True
else:
cs.ppv_ips = _parse_number(value)
else:
num = _parse_number(value) num = _parse_number(value)
if stat == "PPV": cs.ppv_ips = num if stat == "ZC Freq": cs.zc_freq_hz = num
elif stat == "ZC Freq": cs.zc_freq_hz = num
elif stat == "Time of Peak": cs.time_of_peak_s = num elif stat == "Time of Peak": cs.time_of_peak_s = num
elif stat == "Peak Acceleration": cs.peak_accel_g = num elif stat == "Peak Acceleration": cs.peak_accel_g = num
elif stat == "Peak Displacement": cs.peak_disp_in = num elif stat == "Peak Displacement": cs.peak_disp_in = num
# ── Histogram-specific fields ────────────────────────────────────────
# Histograms have Start/Stop time+date pairs + an interval count
# and size, plus per-channel absolute Peak Time/Date instead of
# the waveform's relative Time of Peak.
elif key == "Histogram Start Time":
_hist_start_time = _parse_event_time(value)
elif key == "Histogram Start Date":
_d = _parse_iso_date(value)
if _d and _hist_start_time:
report.histogram_start = datetime.datetime.combine(_d, _hist_start_time)
elif key == "Histogram Stop Time":
_hist_stop_time = _parse_event_time(value)
elif key == "Histogram Stop Date":
_d = _parse_iso_date(value)
if _d and _hist_stop_time:
report.histogram_stop = datetime.datetime.combine(_d, _hist_stop_time)
elif key == "Number of Intervals":
try:
report.histogram_n_intervals = int(float(value.strip()))
except ValueError:
pass
elif key == "Interval Size":
report.histogram_interval_size_str = value.strip()
report.histogram_interval_size_s = _parse_interval_size(value)
# ── Per-channel histogram Peak Date / Peak Time ──
# Lines like "Tran Peak Time : 22:31:38" + "Tran Peak Date : 2026-05-16"
elif key in ("Tran Peak Time", "Vert Peak Time", "Long Peak Time", "MicL Time"):
ch_name = "MicL" if key == "MicL Time" else key.split(" ", 1)[0]
_pending_peak_time[ch_name] = _parse_event_time(value)
elif key in ("Tran Peak Date", "Vert Peak Date", "Long Peak Date", "MicL Date"):
ch_name = "MicL" if key == "MicL Date" else key.split(" ", 1)[0]
_d = _parse_iso_date(value)
_t = _pending_peak_time.get(ch_name)
if _d and _t:
report.channel_peak_when[ch_name] = datetime.datetime.combine(_d, _t)
# ── Vector Sum ─────────────────────────────────────────────────────── # ── Vector Sum ───────────────────────────────────────────────────────
elif key == "Peak Vector Sum": elif key == "Peak Vector Sum":
if _is_oorange(value):
# PVS saturated — conservative upper bound is
# sqrt(3) * geo_range_ips (all 3 channels at full-scale).
# Real PVS could be lower (channels rarely peak
# simultaneously) but never higher within the range.
if report.geo_range_ips is not None:
import math as _math
report.peak_vector_sum_ips = _math.sqrt(3) * report.geo_range_ips
report.peak_vector_sum_saturated = True
else:
report.peak_vector_sum_ips = _parse_number(value) report.peak_vector_sum_ips = _parse_number(value)
elif key == "Peak Vector Sum Time": # BW writes the PVS-time label with a typo: "Peak Vector Sum TimeSum"
# (looks like Sum got appended twice). Accept both forms. Confirmed
# against actual BW output on 2026-05-27 — every PVS-time line in
# the field examples (T190, T438, K557) uses the typo'd label.
elif key in ("Peak Vector Sum Time", "Peak Vector Sum TimeSum"):
report.peak_vector_sum_time_s = _parse_number(value) report.peak_vector_sum_time_s = _parse_number(value)
_pvs_time_raw = value
elif key == "Peak Vector Sum Date":
# Histogram-mode PVS gets paired with a date. We may have
# captured 'Peak Vector Sum Time' as either a relative
# seconds float (waveform) or an HH:MM:SS string we
# interpreted as a number. For histograms, BW writes
# "Peak Vector Sum Time : 22:33:52" which _parse_number
# parses as 22.0 (loses information). When Peak Vector Sum
# Date arrives, re-parse the previous PVS time line as a
# clock time and combine into an absolute datetime.
_d = _parse_iso_date(value)
if _d and _pvs_time_raw is not None:
_t = _parse_event_time(_pvs_time_raw)
if _t:
report.peak_vector_sum_when = datetime.datetime.combine(_d, _t)
# The earlier seconds parse was bogus for histograms;
# clear it so consumers don't think it's a real offset.
report.peak_vector_sum_time_s = None
# ── Microphone block ──────────────────────────────────────────────── # ── Microphone block ────────────────────────────────────────────────
elif key == "Microphone": elif key == "Microphone":
report.mic.weighting = value report.mic.weighting = value
elif key == "MicL PSPL": elif key == "MicL PSPL":
if _is_oorange(value):
# Mic saturated — substitute conservative upper bound 140 dBL.
report.mic.pspl_dbl = 140.0
report.mic.pspl_saturated = True
else:
report.mic.pspl_dbl = _parse_number(value) report.mic.pspl_dbl = _parse_number(value)
# Mirror onto the "MicL" entry in channels so callers querying # Mirror onto the "MicL" entry in channels so callers querying
# `channels["MicL"].ppv_ips` see something — but it's dB(L), not # `channels["MicL"].ppv_ips` see something — but it's dB(L), not
+28 -1
View File
@@ -120,7 +120,12 @@ def _bw_report_to_dict(report: BwAsciiReport) -> dict:
"peak_disp_in": cs.peak_disp_in, "peak_disp_in": cs.peak_disp_in,
} }
# Drop all-None entries — keeps the JSON tidy for partial reports. # Drop all-None entries — keeps the JSON tidy for partial reports.
return {k: v for k, v in out.items() if v is not None} out = {k: v for k, v in out.items() if v is not None}
# Saturation flag (only present when True) — signals that ppv_ips
# is the channel range max (a lower bound), not an exact reading.
if getattr(cs, "ppv_saturated", False):
out["ppv_saturated"] = True
return out
def _sc(ch_name: str) -> dict: def _sc(ch_name: str) -> dict:
sc = report.sensor_check.get(ch_name) sc = report.sensor_check.get(ch_name)
@@ -171,11 +176,20 @@ def _bw_report_to_dict(report: BwAsciiReport) -> dict:
"vector_sum": { "vector_sum": {
"ips": report.peak_vector_sum_ips, "ips": report.peak_vector_sum_ips,
"time_s": report.peak_vector_sum_time_s, "time_s": report.peak_vector_sum_time_s,
# Histogram events have an absolute date+time for the PVS
# (the interval at which it occurred); waveform events
# only have the time_s offset.
"when": report.peak_vector_sum_when.isoformat() if report.peak_vector_sum_when else None,
# Set when BW reported the PVS as OORANGE — value is the
# conservative upper bound sqrt(3) * geo_range_ips, not
# an exact peak.
"saturated": bool(getattr(report, "peak_vector_sum_saturated", False)),
}, },
}, },
"mic": { "mic": {
"weighting": report.mic.weighting, "weighting": report.mic.weighting,
"pspl_dbl": report.mic.pspl_dbl, "pspl_dbl": report.mic.pspl_dbl,
"pspl_saturated": bool(getattr(report.mic, "pspl_saturated", False)),
"zc_freq_hz": report.mic.zc_freq_hz, "zc_freq_hz": report.mic.zc_freq_hz,
"time_of_peak_s": report.mic.time_of_peak_s, "time_of_peak_s": report.mic.time_of_peak_s,
}, },
@@ -185,6 +199,17 @@ def _bw_report_to_dict(report: BwAsciiReport) -> dict:
"long": _sc("Long"), "long": _sc("Long"),
"mic": _sc("MicL"), "mic": _sc("MicL"),
}, },
# Histogram-specific fields (None on waveform-mode events).
# Per-channel absolute peak time/date for histograms — for
# waveforms see channels[ch]["time_of_peak_s"] instead.
"histogram": {
"start": report.histogram_start.isoformat() if report.histogram_start else None,
"stop": report.histogram_stop.isoformat() if report.histogram_stop else None,
"n_intervals": report.histogram_n_intervals,
"interval_size": report.histogram_interval_size_str,
"interval_size_s": report.histogram_interval_size_s,
"channel_peak_when": {ch: dt.isoformat() for ch, dt in report.channel_peak_when.items()},
},
"monitor_log": monitor_log, "monitor_log": monitor_log,
"pc_sw_version": report.pc_sw_version, "pc_sw_version": report.pc_sw_version,
} }
@@ -332,6 +357,7 @@ def event_to_sidecar_dict(
blastware_filesize: int, blastware_filesize: int,
blastware_sha256: str, blastware_sha256: str,
source_kind: str = "sfm-live", source_kind: str = "sfm-live",
txt_filename: Optional[str] = None,
a5_pickle_filename: Optional[str] = None, a5_pickle_filename: Optional[str] = None,
tool_version: str = _TOOL_VERSION_DEFAULT, tool_version: str = _TOOL_VERSION_DEFAULT,
captured_at: Optional[datetime.datetime] = None, captured_at: Optional[datetime.datetime] = None,
@@ -448,6 +474,7 @@ def event_to_sidecar_dict(
"captured_at": captured_at.isoformat() + "Z" if captured_at.tzinfo is None else captured_at.isoformat(), "captured_at": captured_at.isoformat() + "Z" if captured_at.tzinfo is None else captured_at.isoformat(),
"tool_version": tool_version, "tool_version": tool_version,
"a5_pickle_filename": a5_pickle_filename, "a5_pickle_filename": a5_pickle_filename,
"txt_filename": txt_filename,
}, },
"review": review or { "review": review or {
+6
View File
@@ -300,12 +300,17 @@ def main(argv=None) -> int:
preserved_review = None preserved_review = None
preserved_ext = None preserved_ext = None
preserved_bw_report = None preserved_bw_report = None
preserved_txt_fn = None
if sidecar_path.exists(): if sidecar_path.exists():
try: try:
_existing = event_file_io.read_sidecar(sidecar_path) _existing = event_file_io.read_sidecar(sidecar_path)
preserved_review = _existing.get("review") preserved_review = _existing.get("review")
preserved_ext = _existing.get("extensions") preserved_ext = _existing.get("extensions")
preserved_bw_report = _existing.get("bw_report") preserved_bw_report = _existing.get("bw_report")
# Preserve txt_filename so backfills don't blank out the
# pointer to the saved raw .TXT (events ingested after
# 2026-05-27 have this).
preserved_txt_fn = (_existing.get("source") or {}).get("txt_filename")
except Exception: except Exception:
pass pass
@@ -334,6 +339,7 @@ def main(argv=None) -> int:
blastware_sha256=bw_sha, blastware_sha256=bw_sha,
source_kind=source_kind, source_kind=source_kind,
a5_pickle_filename=a5_filename, a5_pickle_filename=a5_filename,
txt_filename=preserved_txt_fn,
review=preserved_review, review=preserved_review,
extensions=preserved_ext, extensions=preserved_ext,
) )
+12 -5
View File
@@ -656,11 +656,18 @@ function renderWaveform(data) {
chartsDiv.appendChild(wrap); chartsDiv.appendChild(wrap);
// Waveform: per-sample time in ms relative to trigger (negative for pretrig). // Waveform: per-sample time in ms relative to trigger (negative for pretrig).
// Histogram: interval index (1..N); sample_rate-based time math doesn't // Histogram: when the server has aggregated to BW-reported intervals AND
// apply to per-interval peaks. // provides per-interval timestamps, use those as x-axis labels (HH:MM:SS).
const times = isHistogram // Falls back to interval index.
? values.map((_, i) => i + 1) let times;
: values.map((_, i) => t0Ms + i * dtMs); if (isHistogram) {
const intervalTimes = ta.interval_times || [];
times = (intervalTimes.length === values.length)
? intervalTimes
: values.map((_, i) => i + 1);
} else {
times = values.map((_, i) => t0Ms + i * dtMs);
}
// Downsample for rendering // Downsample for rendering
const MAX_POINTS = 4000; const MAX_POINTS = 4000;
+332 -108
View File
@@ -123,6 +123,16 @@ class ReportData:
record_type: Optional[str] = None record_type: Optional[str] = None
is_histogram: bool = False 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 # Bookkeeping
event_id: Optional[str] = None event_id: Optional[str] = None
server_received_at: 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_ips = vs.get("ips")
rd.peak_vector_sum_time_s = vs.get("time_s") 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 ── # ── Waveform samples — from the .h5 via the existing helper ──
from sfm import event_hdf5 from sfm import event_hdf5
h5_path = store.hdf5_path_for(serial, filename) h5_path = store.hdf5_path_for(serial, filename)
@@ -258,53 +281,31 @@ def gather_report_data(
def render_event_report_pdf(rd: ReportData) -> bytes: def render_event_report_pdf(rd: ReportData) -> bytes:
"""Render an event report dict to a single-page letter PDF. """Render an event report dict to a single-page letter PDF.
Returns the raw PDF bytes caller streams them back via FastAPI. Branches on ``rd.is_histogram`` waveform and histogram layouts
differ in their header fields, stats-table rows, and bottom plot.
NOTE: this is a v0.20.0 stub layout. The visual hierarchy will be Layout modeled on Blastware's Event Report PDFs (samples in
refined once reference PDFs land at docs/reference/instantel/. All docs/reference/instantel/).
fields the printout includes are surfaced; spacing and typography
are approximate.
""" """
# Letter portrait — 8.5"×11" # Letter portrait — 8.5"×11"
fig = plt.figure(figsize=(8.5, 11), dpi=100) fig = plt.figure(figsize=(8.5, 11), dpi=100)
fig.patch.set_facecolor("white") 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: if rd.is_histogram:
_draw_histogram_subplot(fig, gs[3], rd) _render_histogram_layout(fig, rd)
else: 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( fig.text(
0.07, 0.015, 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", 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() buf = io.BytesIO()
fig.savefig(buf, format="pdf") fig.savefig(buf, format="pdf")
@@ -312,6 +313,69 @@ def render_event_report_pdf(rd: ReportData) -> bytes:
return buf.getvalue() 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): def _kv(ax, x, y, label, value, *, label_w=0.18):
"""Render a 'Label Value' row at axes-coordinates (x, y).""" """Render a 'Label Value' row at axes-coordinates (x, y)."""
ax.text(x, y, label, fontsize=8, color="#555", ha="left", va="top", ax.text(x, y, label, fontsize=8, color="#555", ha="left", va="top",
@@ -329,11 +393,10 @@ def _fmt(v):
return str(v) return str(v)
def _draw_header(ax, rd: ReportData) -> None: def _draw_header_waveform(ax, rd: ReportData) -> None:
"""Two-column metadata header — matches BW printout layout.""" """Two-column metadata header — waveform variant."""
# Left column
rows_left = [ rows_left = [
("Date/Time", rd.event_datetime_str), ("Date/Time", _fmt_iso_to_bw(rd.event_datetime_str)),
("Trigger Source", rd.trigger_source), ("Trigger Source", rd.trigger_source),
("Range", rd.geo_range_str), ("Range", rd.geo_range_str),
("Sample Rate", rd.sample_rate_str), ("Sample Rate", rd.sample_rate_str),
@@ -343,18 +406,45 @@ def _draw_header(ax, rd: ReportData) -> None:
("User Name:", rd.operator), ("User Name:", rd.operator),
("Seis. Loc:", rd.sensor_location), ("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 = [ rows_right = [
("Serial Number", f"{rd.serial or ''}" ("Serial Number", f"{rd.serial or ''}" + (f" {rd.firmware}" if rd.firmware else "")),
+ (f" {rd.firmware}" if rd.firmware else "")),
("Battery Level", f"{rd.battery_volts:.1f} Volts" if rd.battery_volts is not None else None), ("Battery Level", f"{rd.battery_volts:.1f} Volts" if rd.battery_volts is not None else None),
("Unit Calibration", (f"{rd.calibration_date}" ("Unit Calibration", (f"{rd.calibration_date}" + (f" by {rd.calibration_by}" if rd.calibration_by else ""))
+ (f" by {rd.calibration_by}" if rd.calibration_by else ""))
if rd.calibration_date else None), if rd.calibration_date else 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),
] ]
y = 0.95 y = 0.95
dy = 0.10 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)
y -= dy y -= dy
@@ -364,12 +454,43 @@ def _draw_header(ax, rd: ReportData) -> None:
y -= dy y -= dy
def _draw_mic_block(ax, rd: ReportData) -> None: def _draw_mic_only(ax, rd: ReportData) -> None:
"""Microphone block — PSPL, ZC Freq, Channel Test. USBM chart """Mic block (histogram variant — no USBM chart)."""
placeholder on the right (filled in a separate work item)."""
ax.text(0.0, 0.95, "Microphone Linear Weighting", fontsize=8, color="#555", ax.text(0.0, 0.95, "Microphone Linear Weighting", fontsize=8, color="#555",
transform=ax.transAxes, va="top") 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: if rd.mic_pspl_dbl is not None:
line = f"{rd.mic_pspl_dbl:.1f} dB(L)" line = f"{rd.mic_pspl_dbl:.1f} dB(L)"
if rd.mic_pspl_time_s is not None: 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, " line += (f" (Freq = {rd.mic_channel_test_freq_hz:.1f} Hz, "
f"Amp = {rd.mic_channel_test_amp_mv:.0f} mv)") f"Amp = {rd.mic_channel_test_amp_mv:.0f} mv)")
rows.append(("Channel Test", line)) rows.append(("Channel Test", line))
return rows
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")
def _draw_channel_stats(ax, rd: ReportData) -> None: def _draw_channel_stats_waveform(ax, rd: ReportData) -> None:
"""Per-channel stats table + Peak Vector Sum row.""" """Waveform stats table — has Time (Rel. to Trig), Peak Accel, Peak Disp.
# Build a 2-D array of strings: header row + 3 channel rows Followed by Peak Vector Sum line."""
headers = ["", "Tran", "Vert", "Long", ""] rows_spec = [
rows = [ ("PPV", "ppv_ips", "in/s"),
["PPV", "ppv_ips", "in/s"], ("ZC Freq", "zc_freq_hz", "Hz"),
["ZC Freq", "zc_freq_hz", "Hz"], ("Time (Rel. to Trig)", "time_of_peak_s", "sec"),
["Time (Rel. to Trig)", "time_of_peak_s", "sec"], ("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", ""],
] ]
_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} ch_lookup = {c["name"]: c for c in rd.channel_stats}
def _cell(field, ch_name): def _cell(field, ch_name):
val = ch_lookup.get(ch_name, {}).get(field) val = ch_lookup.get(ch_name, {}).get(field)
if val is None: if val is None:
return "" return ""
if field == "sensor_check":
return str(val)
if isinstance(val, float): 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 f"{val:.3f}"
return str(val) return str(val)
table_data = [headers] table_data = [headers]
for label, field_name, unit in rows: for label, field_name, unit in rows_spec:
table_data.append([ table_data.append([
label, label,
_cell(field_name, "Tran"), _cell(field_name, "Tran"),
@@ -431,27 +583,16 @@ def _draw_channel_stats(ax, rd: ReportData) -> None:
_cell(field_name, "Long"), _cell(field_name, "Long"),
unit, unit,
]) ])
tbl = ax.table( tbl = ax.table(
cellText=table_data, loc="upper left", 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", cellLoc="left", edges="open",
) )
tbl.auto_set_font_size(False) tbl.auto_set_font_size(False)
tbl.set_fontsize(8) tbl.set_fontsize(8)
tbl.scale(1, 1.4) tbl.scale(1, 1.4)
# Header row styling
for j in range(5): for j in range(5):
cell = tbl[(0, j)] tbl[(0, j)].set_text_props(weight="bold", color="#555")
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)
def _channel_axis_color(ch: str) -> str: 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: 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.""" (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) inner = gridspec_cell.subgridspec(4, 1, hspace=0.0)
order = ["MicL", "Long", "Vert", "Tran"] order = ["MicL", "Long", "Vert", "Tran"]
sr = rd.sample_rate_sps or 1024 sr = rd.sample_rate_sps or 1024
dt_ms = rd.dt_ms or (1000.0 / sr) # Convert ms-based time axis to seconds for the x-axis
t0_ms = rd.t0_ms if rd.t0_ms is not None else 0.0 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 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])
values = rd.channels.get(ch) or [] 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: if values:
color = _channel_axis_color(ch) color = _channel_axis_color(ch)
ax.plot(times, values, color=color, linewidth=0.6) ax.plot(times, values, color=color, linewidth=0.5)
# Symmetric y-axis for geo; zero-anchored for mic # Symmetric y-axis for geo; zero-anchored for mic.
if ch != "MicL": if ch != "MicL":
amax = max((abs(v) for v in values), default=0.001) amax = max((abs(v) for v in values), default=0.001)
ax.set_ylim(-amax * 1.1, amax * 1.1) ax.set_ylim(-amax * 1.10, amax * 1.10)
# Channel label on left 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", ax.set_ylabel(ch, fontsize=8, rotation=0, ha="right", va="center",
color=_channel_axis_color(ch), weight="bold", labelpad=14) color=_channel_axis_color(ch), weight="bold", labelpad=14)
ax.grid(True, linestyle=":", linewidth=0.4, alpha=0.5) # "0.0" on the RIGHT (BW convention)
# Dashed trigger line at t=0 ax.text(1.005, 0.5, "0.0", transform=ax.transAxes,
ax.axvline(0.0, color="#cc0000", linestyle="--", linewidth=0.8, alpha=0.7) fontsize=7, color="#555", va="center", ha="left")
# Zero baseline
ax.axhline(0.0, color="#888", linestyle="-", linewidth=0.4, alpha=0.5) 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: if i != last_idx:
ax.set_xticklabels([]) ax.set_xticklabels([])
ax.tick_params(axis="x", length=0)
else: else:
ax.set_xlabel("Time (ms)", fontsize=8) ax.tick_params(axis="x", labelsize=7)
ax.tick_params(axis="both", 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: 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) inner = gridspec_cell.subgridspec(4, 1, hspace=0.0)
order = ["MicL", "Long", "Vert", "Tran"] order = ["MicL", "Long", "Vert", "Tran"]
last_idx = len(order) - 1 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): for i, ch in enumerate(order):
ax = fig.add_subplot(inner[i]) ax = fig.add_subplot(inner[i])
values = rd.channels.get(ch) or [] values = rd.channels.get(ch) or []
if values: 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) 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", ax.set_ylabel(ch, fontsize=8, rotation=0, ha="right", va="center",
color=_channel_axis_color(ch), weight="bold", labelpad=14) 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: if i != last_idx:
ax.set_xticklabels([]) ax.set_xticklabels([])
ax.tick_params(axis="x", length=0)
else:
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: else:
ax.set_xlabel("Interval", fontsize=8) ax.set_xlabel("Interval", fontsize=8)
ax.tick_params(axis="both", labelsize=7) 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",
)
+118 -1
View File
@@ -2178,6 +2178,39 @@ def db_event_blastware_file(event_id: str) -> FileResponse:
) )
@app.get("/db/events/{event_id}/ascii_report.txt")
def db_event_ascii_report_txt(event_id: str):
"""Serve the raw BW ASCII report (.TXT) for an event, when preserved.
Returns 404 for events ingested before the .TXT-preservation feature
landed (2026-05-27) those events have only the parsed ``bw_report``
block in the sidecar, not the raw .TXT. Re-forwarding from the
watcher PC will populate the .TXT going forward.
"""
row = _get_db().get_event(event_id)
if row is None:
raise HTTPException(status_code=404, detail=f"Event {event_id} not found")
serial = row.get("serial")
filename = row.get("blastware_filename")
if not serial or not filename:
raise HTTPException(status_code=404, detail="Event has no associated BW file")
txt_path = _get_store().open_txt(serial, filename)
if txt_path is None:
raise HTTPException(
status_code=404,
detail=(
f"Raw .TXT not preserved for {filename}. Events ingested "
"before 2026-05-27 don't have it; re-forward from the "
"watcher PC to populate."
),
)
return FileResponse(
path=str(txt_path),
media_type="text/plain",
filename=txt_path.name,
)
@app.get("/db/events/{event_id}/report.pdf") @app.get("/db/events/{event_id}/report.pdf")
def db_event_report_pdf(event_id: str): def db_event_report_pdf(event_id: str):
"""Render an Instantel-style Event Report as a PDF. """Render an Instantel-style Event Report as a PDF.
@@ -2204,6 +2237,89 @@ def db_event_report_pdf(event_id: str):
) )
def _maybe_aggregate_histogram(plot: dict, store, serial: str, filename: str, row: dict) -> dict:
"""For histogram events, aggregate the codec's per-block samples into
the BW-reported number of intervals. No-op for waveforms or when
we don't have the histogram metadata (interval count + size) in the
sidecar's bw_report block.
Why: the histogram codec emits one value per internal block (~1 per
second), but BW's printout shows one bar per configured interval
(typically 1-15 minutes). For a 1-minute-interval event the codec
gives ~60 blocks per BW bar. Aggregating max-per-group makes the
SFM chart + PDF visually match BW's display.
"""
record_type = row.get("record_type") or ""
if not record_type.lower().startswith("hist"):
return plot
# Read interval count + size from the sidecar's bw_report.histogram block
try:
import json as _json
sidecar_path = store.sidecar_path_for(serial, filename)
if not sidecar_path.exists():
return plot
sc = _json.loads(sidecar_path.read_text())
hist = (sc.get("bw_report") or {}).get("histogram") or {}
n_intervals = hist.get("n_intervals")
interval_size_s = hist.get("interval_size_s")
start_iso = hist.get("start")
except Exception:
return plot
if not n_intervals or n_intervals < 1:
return plot
# Aggregate each channel's values into n_intervals groups, max-per-group
channels = plot.get("channels") or {}
aggregated_channels: dict = {}
for ch, chd in channels.items():
vals = chd.get("values") or []
if not vals:
aggregated_channels[ch] = chd
continue
# Distribute len(vals) samples across n_intervals groups; uneven
# remainders get distributed across the first few groups.
per_group = len(vals) // n_intervals
remainder = len(vals) % n_intervals
agg: list = []
offset = 0
for i in range(n_intervals):
grp_size = per_group + (1 if i < remainder else 0)
if grp_size > 0:
grp = vals[offset:offset + grp_size]
# Max of absolute values (peaks are magnitudes).
agg.append(max((abs(v) for v in grp if v is not None), default=0))
offset += grp_size
else:
agg.append(0)
aggregated_channels[ch] = {**chd, "values": agg}
# Build per-interval timestamp labels for the x-axis if we have start time
interval_times: list = []
if start_iso and interval_size_s:
try:
import datetime as _dt
start = _dt.datetime.fromisoformat(start_iso)
for i in range(int(n_intervals)):
# Show the END of each interval (BW convention — the
# peak reported is for samples taken THROUGH that time)
end = start + _dt.timedelta(seconds=(i + 1) * interval_size_s)
interval_times.append(end.strftime("%H:%M:%S"))
except Exception:
pass
# Override the time_axis to reflect intervals (not samples).
plot_aggr = {**plot, "channels": aggregated_channels}
plot_aggr["time_axis"] = {
**(plot.get("time_axis") or {}),
"histogram_aggregated": True,
"n_intervals": int(n_intervals),
"interval_size_s": interval_size_s,
"interval_times": interval_times,
}
return plot_aggr
@app.get("/db/events/{event_id}/waveform.json") @app.get("/db/events/{event_id}/waveform.json")
def db_event_waveform_json(event_id: str) -> dict: def db_event_waveform_json(event_id: str) -> dict:
""" """
@@ -2235,7 +2351,8 @@ def db_event_waveform_json(event_id: str) -> dict:
h5_path = store.hdf5_path_for(serial, filename) h5_path = store.hdf5_path_for(serial, filename)
if h5_path.exists(): if h5_path.exists():
try: try:
return event_hdf5.plot_json_from_hdf5(h5_path, event_id=event_id) plot = event_hdf5.plot_json_from_hdf5(h5_path, event_id=event_id)
return _maybe_aggregate_histogram(plot, store, serial, filename, row)
except Exception as exc: except Exception as exc:
log.warning("HDF5 read failed (%s); falling back to A5 path", exc) log.warning("HDF5 read failed (%s); falling back to A5 path", exc)
+12 -4
View File
@@ -2684,10 +2684,18 @@ function _renderScWaveform(data) {
chartsDiv.appendChild(wrap); chartsDiv.appendChild(wrap);
// Waveform: per-sample time in ms relative to trigger (negative for pretrig). // Waveform: per-sample time in ms relative to trigger (negative for pretrig).
// Histogram: interval index (1..N); time math doesn't apply to per-interval peaks. // Histogram: when the server has aggregated to BW-reported intervals AND
const times = isHistogram // provides per-interval timestamps, use those as x-axis labels (HH:MM:SS).
? values.map((_, i) => i + 1) // Falls back to interval index.
: values.map((_, i) => t0Ms + i * dtMs); let times;
if (isHistogram) {
const intervalTimes = ta.interval_times || [];
times = (intervalTimes.length === values.length)
? intervalTimes
: values.map((_, i) => i + 1);
} else {
times = values.map((_, i) => t0Ms + i * dtMs);
}
// Downsample for rendering when very long. // Downsample for rendering when very long.
const MAX = 3000; const MAX = 3000;
+42
View File
@@ -108,11 +108,30 @@ class WaveformStore:
"""Return absolute path to the .h5 clean-waveform file for a given event.""" """Return absolute path to the .h5 clean-waveform file for a given event."""
return self._serial_dir(serial) / f"{filename}.h5" return self._serial_dir(serial) / f"{filename}.h5"
def txt_path_for(self, serial: str, filename: str) -> Path:
"""Return absolute path to the preserved BW ASCII report (.TXT)
for a given event.
We name it ``<filename>_ASCII.TXT`` to match BW's own filename
convention in the ACH folder. Saved at ingest time alongside
the binary so the parser bug fixes can be applied retroactively
by re-parsing without needing to re-forward from the watcher PC.
"""
return self._serial_dir(serial) / f"{filename}_ASCII.TXT"
def open_blastware(self, serial: str, filename: str) -> Optional[Path]: def open_blastware(self, serial: str, filename: str) -> Optional[Path]:
"""Return absolute path to an existing event file or None.""" """Return absolute path to an existing event file or None."""
bw_path, _ = self.paths_for(serial, filename) bw_path, _ = self.paths_for(serial, filename)
return bw_path if bw_path.exists() else None return bw_path if bw_path.exists() else None
def open_txt(self, serial: str, filename: str) -> Optional[Path]:
"""Return absolute path to the preserved BW ASCII report for an
event, or None if the .TXT wasn't saved at ingest time (events
ingested before .TXT preservation landed will show None until
re-forwarded)."""
p = self.txt_path_for(serial, filename)
return p if p.exists() else None
# ── save / load ───────────────────────────────────────────────────────────── # ── save / load ─────────────────────────────────────────────────────────────
def save( def save(
@@ -357,6 +376,28 @@ class WaveformStore:
filesize = bw_path.stat().st_size filesize = bw_path.stat().st_size
sha256 = event_file_io.file_sha256(bw_path) sha256 = event_file_io.file_sha256(bw_path)
# 1b. preserve the raw BW ASCII report (.TXT) alongside the binary.
# Saved at <root>/<serial>/<filename>_ASCII.TXT. Lets us re-parse
# offline after parser fixes without needing to re-forward from
# the watcher PC. Negligible storage cost (~15 KB per event).
# Skipped silently when no report was supplied (live download path,
# manual upload without paired TXT).
txt_filename: Optional[str] = None
if bw_report_text is not None:
try:
txt_path = self.txt_path_for(serial, filename)
if isinstance(bw_report_text, bytes):
txt_path.write_bytes(bw_report_text)
else:
txt_path.write_text(bw_report_text)
txt_filename = txt_path.name
except Exception as exc:
log.warning(
"save_imported_bw: failed to save TXT for %s: %s"
"continuing without it",
filename, exc,
)
# 2. write the .h5 clean-waveform file from the parsed Event. # 2. write the .h5 clean-waveform file from the parsed Event.
# Note: peaks here are computed from raw samples (the BW file # Note: peaks here are computed from raw samples (the BW file
# doesn't carry the device-authoritative 0C peaks). Best-effort. # doesn't carry the device-authoritative 0C peaks). Best-effort.
@@ -393,6 +434,7 @@ class WaveformStore:
blastware_sha256=sha256, blastware_sha256=sha256,
source_kind="bw-import", source_kind="bw-import",
a5_pickle_filename=None, a5_pickle_filename=None,
txt_filename=txt_filename,
review=existing_review, review=existing_review,
bw_report=bw_report, bw_report=bw_report,
) )
+58
View File
@@ -385,6 +385,64 @@ def test_user_notes_extra_lines_beyond_four_are_dropped():
assert "L5" not in r.user_note_labels.values() assert "L5" not in r.user_note_labels.values()
def test_oorange_marker_treated_as_saturation():
"""BW writes 'OORANGE' (Out Of Range — truncated) when a channel
exceeds its full-scale. Verify ppv_ips falls back to geo_range_ips
+ saturated flag is set, mirroring the real T190LD5Q.LK0W,
T438L713.RY0W, and K557L3YM.OE0W events from prod 2026-05-27.
"""
txt = """\
"Event Type : Full Waveform"
"Serial Number : BE18190"
"Geo Range : 10.000 in/s"
"Tran PPV : 2.140 in/s"
"Vert PPV : OORANGE in/s"
"Long PPV : 2.830 in/s"
"Peak Vector Sum : OORANGE in/s"
"Peak Vector Sum TimeSum : 0.007 s"
"MicL PSPL : OORANGE "
"""
r = parse_report(txt)
# Tran/Long parse normally
assert r.channels["Tran"].ppv_ips == 2.14
assert r.channels["Tran"].ppv_saturated is False
assert r.channels["Long"].ppv_ips == 2.83
# Vert saturated → range max + flag
assert r.channels["Vert"].ppv_ips == 10.0
assert r.channels["Vert"].ppv_saturated is True
# PVS saturated → sqrt(3) * range_max as upper bound + flag
import math
assert r.peak_vector_sum_ips == pytest.approx(math.sqrt(3) * 10.0)
assert r.peak_vector_sum_saturated is True
# Mic saturated → 140 dBL conservative upper bound + flag
assert r.mic.pspl_dbl == 140.0
assert r.mic.pspl_saturated is True
# PVS time still parses despite the BW typo'd label "TimeSum"
assert r.peak_vector_sum_time_s == pytest.approx(0.007)
def test_real_oorange_event_t190_parses():
"""End-to-end against the real T190LD5Q.LK0W ASCII file pulled from
a Windows watcher PC on 2026-05-27. This is the canonical example
of the parser-PPV-miss bug we fixed in this iteration."""
fixture_path = (
Path(__file__).parent.parent / "example-events" /
"ascii-5-27-26" / "T190LD5Q_LK0W_ASCII.TXT"
)
if not fixture_path.exists():
pytest.skip("real ASCII fixture not present (local-only)")
r = parse_report_file(fixture_path)
assert r.serial == "BE18190"
assert r.geo_range_ips == 10.0
# Tran reads cleanly, Vert was OORANGE
assert r.channels["Tran"].ppv_ips == pytest.approx(2.14)
assert r.channels["Vert"].ppv_ips == 10.0
assert r.channels["Vert"].ppv_saturated is True
assert r.channels["Long"].ppv_ips == pytest.approx(2.83)
assert r.peak_vector_sum_saturated is True
assert r.peak_vector_sum_time_s == pytest.approx(0.007)
def test_real_histogram_fixture_populates_sensor_location(): def test_real_histogram_fixture_populates_sensor_location():
"""End-to-end: the histogram fixture uses 'Seis. Location:' — must """End-to-end: the histogram fixture uses 'Seis. Location:' — must
successfully populate sensor_location via position-based parsing.""" successfully populate sensor_location via position-based parsing."""