Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3457ed0072 | |||
| d21e3b5298 | |||
| ad2b553c7b | |||
| dfbc8b8520 |
+27
-1
@@ -6,9 +6,35 @@ All notable changes to seismo-relay are documented here.
|
||||
|
||||
## [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
|
||||
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -60,6 +60,13 @@ class ChannelStats:
|
||||
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_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
|
||||
@@ -69,6 +76,11 @@ class MicStats:
|
||||
pspl_dbl: Optional[float] = None # dB(L)
|
||||
zc_freq_hz: 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
|
||||
@@ -92,6 +104,21 @@ class MonitorLogEntry:
|
||||
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
|
||||
class BwAsciiReport:
|
||||
"""Structured representation of one BW per-event ASCII export."""
|
||||
@@ -144,6 +171,29 @@ class BwAsciiReport:
|
||||
# ── Vector sum ──────────────────────────────────────────────────────────
|
||||
peak_vector_sum_ips: 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_check: Dict[str, SensorCheck] = field(default_factory=dict)
|
||||
@@ -223,6 +273,46 @@ def _parse_event_date(s: str) -> Optional[datetime.date]:
|
||||
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]:
|
||||
"""Parse "15:56:35" → time."""
|
||||
s = s.strip()
|
||||
@@ -336,6 +426,15 @@ def parse_report(text: Union[str, bytes], *, parse_samples: bool = False) -> BwA
|
||||
in_user_notes_block = False
|
||||
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:
|
||||
raw_line = lines[i]
|
||||
i += 1
|
||||
@@ -420,24 +519,106 @@ def parse_report(text: Union[str, bytes], *, parse_samples: bool = False) -> BwA
|
||||
):
|
||||
ch_name, stat = key.split(" ", 1)
|
||||
cs = report.channels.setdefault(ch_name, ChannelStats())
|
||||
num = _parse_number(value)
|
||||
if stat == "PPV": cs.ppv_ips = num
|
||||
elif stat == "ZC Freq": cs.zc_freq_hz = num
|
||||
elif stat == "Time of Peak": cs.time_of_peak_s = num
|
||||
elif stat == "Peak Acceleration": cs.peak_accel_g = num
|
||||
elif stat == "Peak Displacement": cs.peak_disp_in = num
|
||||
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)
|
||||
if stat == "ZC Freq": cs.zc_freq_hz = num
|
||||
elif stat == "Time of Peak": cs.time_of_peak_s = num
|
||||
elif stat == "Peak Acceleration": cs.peak_accel_g = 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 ───────────────────────────────────────────────────────
|
||||
elif key == "Peak Vector Sum":
|
||||
report.peak_vector_sum_ips = _parse_number(value)
|
||||
elif key == "Peak Vector Sum Time":
|
||||
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)
|
||||
# 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)
|
||||
_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 ────────────────────────────────────────────────
|
||||
elif key == "Microphone":
|
||||
report.mic.weighting = value
|
||||
elif key == "MicL PSPL":
|
||||
report.mic.pspl_dbl = _parse_number(value)
|
||||
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)
|
||||
# Mirror onto the "MicL" entry in channels so callers querying
|
||||
# `channels["MicL"].ppv_ips` see something — but it's dB(L), not
|
||||
# in/s, so we store as-is in the MicStats and mark the channel.
|
||||
|
||||
@@ -120,7 +120,12 @@ def _bw_report_to_dict(report: BwAsciiReport) -> dict:
|
||||
"peak_disp_in": cs.peak_disp_in,
|
||||
}
|
||||
# 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:
|
||||
sc = report.sensor_check.get(ch_name)
|
||||
@@ -169,13 +174,22 @@ def _bw_report_to_dict(report: BwAsciiReport) -> dict:
|
||||
"vert": _ch("Vert"),
|
||||
"long": _ch("Long"),
|
||||
"vector_sum": {
|
||||
"ips": report.peak_vector_sum_ips,
|
||||
"time_s": report.peak_vector_sum_time_s,
|
||||
"ips": report.peak_vector_sum_ips,
|
||||
"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": {
|
||||
"weighting": report.mic.weighting,
|
||||
"pspl_dbl": report.mic.pspl_dbl,
|
||||
"pspl_saturated": bool(getattr(report.mic, "pspl_saturated", False)),
|
||||
"zc_freq_hz": report.mic.zc_freq_hz,
|
||||
"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"),
|
||||
"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,
|
||||
"pc_sw_version": report.pc_sw_version,
|
||||
}
|
||||
@@ -332,6 +357,7 @@ def event_to_sidecar_dict(
|
||||
blastware_filesize: int,
|
||||
blastware_sha256: str,
|
||||
source_kind: str = "sfm-live",
|
||||
txt_filename: Optional[str] = None,
|
||||
a5_pickle_filename: Optional[str] = None,
|
||||
tool_version: str = _TOOL_VERSION_DEFAULT,
|
||||
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(),
|
||||
"tool_version": tool_version,
|
||||
"a5_pickle_filename": a5_pickle_filename,
|
||||
"txt_filename": txt_filename,
|
||||
},
|
||||
|
||||
"review": review or {
|
||||
|
||||
@@ -300,12 +300,17 @@ def main(argv=None) -> int:
|
||||
preserved_review = None
|
||||
preserved_ext = None
|
||||
preserved_bw_report = None
|
||||
preserved_txt_fn = None
|
||||
if sidecar_path.exists():
|
||||
try:
|
||||
_existing = event_file_io.read_sidecar(sidecar_path)
|
||||
preserved_review = _existing.get("review")
|
||||
preserved_ext = _existing.get("extensions")
|
||||
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:
|
||||
pass
|
||||
|
||||
@@ -334,6 +339,7 @@ def main(argv=None) -> int:
|
||||
blastware_sha256=bw_sha,
|
||||
source_kind=source_kind,
|
||||
a5_pickle_filename=a5_filename,
|
||||
txt_filename=preserved_txt_fn,
|
||||
review=preserved_review,
|
||||
extensions=preserved_ext,
|
||||
)
|
||||
|
||||
+12
-5
@@ -656,11 +656,18 @@ function renderWaveform(data) {
|
||||
chartsDiv.appendChild(wrap);
|
||||
|
||||
// Waveform: per-sample time in ms relative to trigger (negative for pretrig).
|
||||
// Histogram: interval index (1..N); sample_rate-based time math doesn't
|
||||
// apply to per-interval peaks.
|
||||
const times = isHistogram
|
||||
? values.map((_, i) => i + 1)
|
||||
: values.map((_, i) => t0Ms + i * dtMs);
|
||||
// Histogram: when the server has aggregated to BW-reported intervals AND
|
||||
// provides per-interval timestamps, use those as x-axis labels (HH:MM:SS).
|
||||
// Falls back to interval index.
|
||||
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
|
||||
const MAX_POINTS = 4000;
|
||||
|
||||
+335
-111
@@ -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",
|
||||
)
|
||||
|
||||
+118
-1
@@ -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")
|
||||
def db_event_report_pdf(event_id: str):
|
||||
"""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")
|
||||
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)
|
||||
if h5_path.exists():
|
||||
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:
|
||||
log.warning("HDF5 read failed (%s); falling back to A5 path", exc)
|
||||
|
||||
|
||||
+12
-4
@@ -2684,10 +2684,18 @@ function _renderScWaveform(data) {
|
||||
chartsDiv.appendChild(wrap);
|
||||
|
||||
// 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.
|
||||
const times = isHistogram
|
||||
? values.map((_, i) => i + 1)
|
||||
: values.map((_, i) => t0Ms + i * dtMs);
|
||||
// Histogram: when the server has aggregated to BW-reported intervals AND
|
||||
// provides per-interval timestamps, use those as x-axis labels (HH:MM:SS).
|
||||
// Falls back to interval index.
|
||||
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.
|
||||
const MAX = 3000;
|
||||
|
||||
@@ -108,11 +108,30 @@ class WaveformStore:
|
||||
"""Return absolute path to the .h5 clean-waveform file for a given event."""
|
||||
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]:
|
||||
"""Return absolute path to an existing event file or None."""
|
||||
bw_path, _ = self.paths_for(serial, filename)
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
def save(
|
||||
@@ -357,6 +376,28 @@ class WaveformStore:
|
||||
filesize = bw_path.stat().st_size
|
||||
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.
|
||||
# Note: peaks here are computed from raw samples (the BW file
|
||||
# doesn't carry the device-authoritative 0C peaks). Best-effort.
|
||||
@@ -393,6 +434,7 @@ class WaveformStore:
|
||||
blastware_sha256=sha256,
|
||||
source_kind="bw-import",
|
||||
a5_pickle_filename=None,
|
||||
txt_filename=txt_filename,
|
||||
review=existing_review,
|
||||
bw_report=bw_report,
|
||||
)
|
||||
|
||||
@@ -385,6 +385,64 @@ def test_user_notes_extra_lines_beyond_four_are_dropped():
|
||||
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():
|
||||
"""End-to-end: the histogram fixture uses 'Seis. Location:' — must
|
||||
successfully populate sensor_location via position-based parsing."""
|
||||
|
||||
Reference in New Issue
Block a user