3457ed0072
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>
466 lines
17 KiB
Python
466 lines
17 KiB
Python
"""
|
|
test_bw_ascii_report.py — parser for Blastware's per-event ASCII export.
|
|
|
|
Run:
|
|
python -m pytest tests/test_bw_ascii_report.py -q
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from minimateplus.bw_ascii_report import (
|
|
BwAsciiReport,
|
|
parse_report,
|
|
parse_report_file,
|
|
)
|
|
|
|
|
|
FIXTURES = Path(__file__).parent.parent / "decode-re" / "5-8-26"
|
|
|
|
|
|
def _fixture(event_name: str) -> Path:
|
|
"""Find the .TXT file inside a fixture event folder."""
|
|
for p in (FIXTURES / event_name).iterdir():
|
|
if p.suffix.lower() == ".txt":
|
|
return p
|
|
raise FileNotFoundError(f"no .TXT in {FIXTURES / event_name}")
|
|
|
|
|
|
# ── Identity / config ───────────────────────────────────────────────────────
|
|
|
|
|
|
def test_event_c_identity_and_config():
|
|
r = parse_report_file(_fixture("event-c"))
|
|
|
|
assert r.event_type == "Full Waveform"
|
|
assert r.serial == "BE11529"
|
|
assert r.file_name == "M529LK44.AB0"
|
|
assert r.event_datetime == datetime.datetime(2026, 4, 23, 15, 56, 35)
|
|
|
|
assert r.trigger_channel == "Vert"
|
|
assert r.geo_trigger_level_ips == pytest.approx(0.5)
|
|
assert r.pretrig_s == pytest.approx(-0.25)
|
|
assert r.record_time_s == pytest.approx(1.0)
|
|
assert r.record_stop_mode == "Fixed"
|
|
assert r.sample_rate_sps == 1024
|
|
assert r.battery_volts == pytest.approx(6.8)
|
|
assert r.calibration_date == datetime.date(2025, 4, 29)
|
|
assert r.calibration_by == "Instantel"
|
|
assert r.units == "in/s and dB(L)"
|
|
|
|
|
|
def test_event_c_operator_metadata():
|
|
r = parse_report_file(_fixture("event-c"))
|
|
|
|
# The "Project: : value" pattern (key has its own trailing colon)
|
|
# is handled by stripping the colon at lookup time.
|
|
assert r.project == "Test4-21-26"
|
|
assert r.client == "Test-Client1"
|
|
assert r.operator == "Brian and claude"
|
|
assert r.sensor_location == "catbed"
|
|
|
|
|
|
def test_event_c_geo_range():
|
|
r = parse_report_file(_fixture("event-c"))
|
|
assert r.geo_range_ips == pytest.approx(10.0)
|
|
|
|
|
|
# ── Per-channel derived stats ───────────────────────────────────────────────
|
|
|
|
|
|
def test_event_c_per_channel_stats():
|
|
r = parse_report_file(_fixture("event-c"))
|
|
|
|
tran = r.channels["Tran"]
|
|
assert tran.ppv_ips == pytest.approx(0.065)
|
|
assert tran.zc_freq_hz == pytest.approx(47.0)
|
|
assert tran.time_of_peak_s == pytest.approx(0.007)
|
|
assert tran.peak_accel_g == pytest.approx(0.066)
|
|
assert tran.peak_disp_in == pytest.approx(0.001)
|
|
|
|
vert = r.channels["Vert"]
|
|
assert vert.ppv_ips == pytest.approx(0.610)
|
|
assert vert.zc_freq_hz == pytest.approx(16.0)
|
|
assert vert.time_of_peak_s == pytest.approx(0.024)
|
|
assert vert.peak_accel_g == pytest.approx(0.437)
|
|
assert vert.peak_disp_in == pytest.approx(0.006)
|
|
|
|
long_ = r.channels["Long"]
|
|
assert long_.ppv_ips == pytest.approx(0.070)
|
|
assert long_.zc_freq_hz == pytest.approx(22.0)
|
|
assert long_.time_of_peak_s == pytest.approx(0.019)
|
|
assert long_.peak_accel_g == pytest.approx(0.040)
|
|
assert long_.peak_disp_in == pytest.approx(0.001)
|
|
|
|
|
|
def test_event_c_micl_stats():
|
|
r = parse_report_file(_fixture("event-c"))
|
|
|
|
# MicL specific block
|
|
assert r.mic.weighting == "Linear Weighting"
|
|
assert r.mic.pspl_dbl == pytest.approx(88.0)
|
|
assert r.mic.zc_freq_hz == pytest.approx(57.0)
|
|
assert r.mic.time_of_peak_s == pytest.approx(-0.004)
|
|
|
|
# Mirrored onto channels["MicL"] for uniform per-channel access
|
|
micl_ch = r.channels["MicL"]
|
|
assert micl_ch.zc_freq_hz == pytest.approx(57.0)
|
|
assert micl_ch.time_of_peak_s == pytest.approx(-0.004)
|
|
|
|
|
|
def test_event_c_vector_sum():
|
|
r = parse_report_file(_fixture("event-c"))
|
|
assert r.peak_vector_sum_ips == pytest.approx(0.612)
|
|
assert r.peak_vector_sum_time_s == pytest.approx(0.024)
|
|
|
|
|
|
# ── Sensor self-check ───────────────────────────────────────────────────────
|
|
|
|
|
|
def test_event_c_sensor_check_geo_channels():
|
|
r = parse_report_file(_fixture("event-c"))
|
|
|
|
for ch_name, expected_freq, expected_ratio in [
|
|
("Tran", 7.4, 3.7),
|
|
("Vert", 7.6, 3.5),
|
|
("Long", 7.5, 3.8),
|
|
]:
|
|
sc = r.sensor_check[ch_name]
|
|
assert sc.test_freq_hz == pytest.approx(expected_freq), ch_name
|
|
assert sc.test_ratio == pytest.approx(expected_ratio), ch_name
|
|
assert sc.test_results == "Passed", ch_name
|
|
# Geo channels don't have an Test Amplitude
|
|
assert sc.test_amplitude_mv is None
|
|
|
|
|
|
def test_event_c_sensor_check_micl():
|
|
r = parse_report_file(_fixture("event-c"))
|
|
|
|
sc = r.sensor_check["MicL"]
|
|
assert sc.test_freq_hz == pytest.approx(20.1)
|
|
assert sc.test_amplitude_mv == pytest.approx(533.0)
|
|
assert sc.test_results == "Passed"
|
|
# MicL doesn't have a ratio — it has amplitude instead
|
|
assert sc.test_ratio is None
|
|
|
|
|
|
# ── Monitor log + tooling ───────────────────────────────────────────────────
|
|
|
|
|
|
def test_event_c_monitor_log_and_pc_version():
|
|
r = parse_report_file(_fixture("event-c"))
|
|
|
|
assert len(r.monitor_log) == 1
|
|
e = r.monitor_log[0]
|
|
assert e.start_time == datetime.datetime(2026, 4, 23, 15, 46, 16)
|
|
assert e.stop_time == datetime.datetime(2026, 4, 23, 15, 56, 36)
|
|
assert e.description == "Event recorded."
|
|
|
|
assert r.pc_sw_version == "V 10.74"
|
|
|
|
|
|
# ── Sample table ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_event_c_sample_table_parsed_when_requested():
|
|
r = parse_report_file(_fixture("event-c"), parse_samples=True)
|
|
|
|
# 1 sec event @ 1024 sps + 0.25 sec pretrig = 1280 samples
|
|
assert r.samples is not None
|
|
assert len(r.samples) == 1280, f"expected 1280 samples, got {len(r.samples)}"
|
|
|
|
# First row: "0.000 \t0.005 \t0.005 \t-81.94"
|
|
t, v, l, m = r.samples[0]
|
|
assert t == pytest.approx(0.000)
|
|
assert v == pytest.approx(0.005)
|
|
assert l == pytest.approx(0.005)
|
|
assert m == pytest.approx(-81.94)
|
|
|
|
|
|
def test_event_c_sample_table_skipped_by_default():
|
|
r = parse_report_file(_fixture("event-c"))
|
|
assert r.samples is None
|
|
|
|
|
|
# ── Cross-event smoke ───────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize("event_name", ["event-a", "event-b", "event-c", "event-d"])
|
|
def test_all_fixtures_parse_without_error(event_name):
|
|
"""Every fixture in the bundle must parse cleanly with the same parser."""
|
|
r = parse_report_file(_fixture(event_name))
|
|
|
|
# Common invariants: serial, event_datetime, sample rate, all four
|
|
# channels surfaced.
|
|
assert r.serial == "BE11529"
|
|
assert r.event_datetime is not None
|
|
assert r.sample_rate_sps in (1024, 2048, 4096)
|
|
for ch in ("Tran", "Vert", "Long", "MicL"):
|
|
assert ch in r.channels
|
|
assert ch in r.sensor_check
|
|
|
|
# PVS should be present and positive on triggered events
|
|
if r.peak_vector_sum_ips is not None:
|
|
assert r.peak_vector_sum_ips >= 0
|
|
|
|
|
|
# ── Edge cases / defensive parsing ──────────────────────────────────────────
|
|
|
|
|
|
def test_parse_empty_input():
|
|
r = parse_report("")
|
|
assert r.serial is None
|
|
assert r.event_datetime is None
|
|
assert all(cs.ppv_ips is None for cs in r.channels.values())
|
|
|
|
|
|
def test_parse_unknown_keys_ignored():
|
|
"""Forward-compat: future BW versions may add fields we don't recognise.
|
|
Those should be silently dropped, not raise."""
|
|
text = (
|
|
'"Serial Number : BE99999"\n'
|
|
'"Future Field That Does Not Exist : 42 widgets"\n'
|
|
'"Tran PPV : 0.123 in/s"\n'
|
|
)
|
|
r = parse_report(text)
|
|
assert r.serial == "BE99999"
|
|
assert r.channels["Tran"].ppv_ips == pytest.approx(0.123)
|
|
|
|
|
|
def test_parse_numeric_with_units_strips_unit():
|
|
text = (
|
|
'"Vert PPV : 1.275 in/s"\n'
|
|
'"Vert ZC Freq : 23 Hz"\n'
|
|
'"MicL Test Amplitude : 569 mv"\n'
|
|
)
|
|
r = parse_report(text)
|
|
assert r.channels["Vert"].ppv_ips == pytest.approx(1.275)
|
|
assert r.channels["Vert"].zc_freq_hz == pytest.approx(23.0)
|
|
assert r.sensor_check["MicL"].test_amplitude_mv == pytest.approx(569.0)
|
|
|
|
|
|
def test_parse_handles_micl_double_space_in_key():
|
|
"""BW writes "MicL Time of Peak" with TWO spaces; the parser must
|
|
normalise whitespace before key lookup."""
|
|
text = (
|
|
'"MicL Time of Peak : 0.012 sec"\n'
|
|
'"MicL ZC Freq : 51 Hz"\n'
|
|
)
|
|
r = parse_report(text)
|
|
assert r.mic.time_of_peak_s == pytest.approx(0.012)
|
|
assert r.mic.zc_freq_hz == pytest.approx(51.0)
|
|
|
|
|
|
# ── Position-based user-notes parsing ───────────────────────────────────────
|
|
#
|
|
# The 4 user-supplied note slots (Project / Client / User Name / Seis Loc
|
|
# by default) have OPERATOR-EDITABLE labels in BW's Compliance Setup →
|
|
# Notes tab. An operator could rename them to "Building:", "Site:",
|
|
# "Address:", etc. and the ASCII export would write those labels
|
|
# verbatim. We parse by POSITION between the `Units :` and `Geo Range :`
|
|
# anchors, NOT by matching the label text.
|
|
|
|
|
|
def _wrap_user_notes(*lines: str) -> str:
|
|
"""Helper: wrap N user-note lines in the minimal context the parser
|
|
needs (`Units :` opens the block, `Geo Range :` closes it)."""
|
|
body = ['"Units : in/s and dB(L)"']
|
|
body.extend('"' + l + '"' for l in lines)
|
|
body.append('"Geo Range : 10.000 in/s"')
|
|
return "\n".join(body) + "\n"
|
|
|
|
|
|
def test_user_notes_default_labels_populate_by_position():
|
|
"""The BW-default labels (Project / Client / User Name / Seis Loc)
|
|
populate the four canonical slots in order."""
|
|
r = parse_report(_wrap_user_notes(
|
|
"Project: : Test4-21-26",
|
|
"Client: : Acme Inc",
|
|
"User Name: : Brian",
|
|
"Seis Loc: : Catbed",
|
|
))
|
|
assert r.project == "Test4-21-26"
|
|
assert r.client == "Acme Inc"
|
|
assert r.operator == "Brian"
|
|
assert r.sensor_location == "Catbed"
|
|
assert r.user_note_labels == {
|
|
"project": "Project:",
|
|
"client": "Client:",
|
|
"operator": "User Name:",
|
|
"sensor_location": "Seis Loc:",
|
|
}
|
|
|
|
|
|
def test_user_notes_operator_renamed_labels_still_populate():
|
|
"""If the operator renames the labels in BW's UI (e.g. "Seis Loc:"
|
|
→ "Building:"), the values STILL populate the canonical slots by
|
|
position — and the operator's labels are preserved in
|
|
`user_note_labels` for terra-view to display."""
|
|
r = parse_report(_wrap_user_notes(
|
|
"Building : Main Office",
|
|
"Project Manager : Brian",
|
|
"Inspector : Claude",
|
|
"Site Address : 123 Main St",
|
|
))
|
|
assert r.project == "Main Office"
|
|
assert r.client == "Brian"
|
|
assert r.operator == "Claude"
|
|
assert r.sensor_location == "123 Main St"
|
|
assert r.user_note_labels == {
|
|
"project": "Building",
|
|
"client": "Project Manager",
|
|
"operator": "Inspector",
|
|
"sensor_location": "Site Address",
|
|
}
|
|
|
|
|
|
def test_user_notes_with_histogram_label_spelling():
|
|
"""Histogram exports use 'Seis. Location:' (with period and colon)
|
|
instead of 'Seis Loc:'. Position-based parsing handles both."""
|
|
r = parse_report(_wrap_user_notes(
|
|
"Project: : Plum Cont.- Rainbow Run",
|
|
"Client: : Plum Contracting In.c",
|
|
"User Name: : Terra-Mechanics Inc.",
|
|
"Seis. Location: : Loc #1 - 2652 Hepner",
|
|
))
|
|
assert r.project == "Plum Cont.- Rainbow Run"
|
|
assert r.client == "Plum Contracting In.c"
|
|
assert r.operator == "Terra-Mechanics Inc."
|
|
assert r.sensor_location == "Loc #1 - 2652 Hepner"
|
|
# And the histogram's specific label spelling is preserved
|
|
assert r.user_note_labels["sensor_location"] == "Seis. Location:"
|
|
|
|
|
|
def test_user_notes_outside_block_are_ignored():
|
|
"""Lines that look like user-notes but appear OUTSIDE the
|
|
Units→Geo Range range don't get assigned to user-note slots."""
|
|
# No Units anchor — these lines shouldn't populate user-note slots
|
|
text = (
|
|
'"Serial Number : BE11529"\n'
|
|
'"Project: : SHOULD NOT POPULATE"\n'
|
|
)
|
|
r = parse_report(text)
|
|
assert r.serial == "BE11529"
|
|
assert r.project is None
|
|
|
|
|
|
def test_user_notes_partial_block_only_fills_present_slots():
|
|
"""If BW writes fewer than 4 user-notes (e.g. operator disabled
|
|
Extended Notes mid-block), only the present positions populate;
|
|
later slots stay None."""
|
|
r = parse_report(_wrap_user_notes(
|
|
"Project: : Just-a-project",
|
|
"Client: : Just-a-client",
|
|
))
|
|
assert r.project == "Just-a-project"
|
|
assert r.client == "Just-a-client"
|
|
assert r.operator is None
|
|
assert r.sensor_location is None
|
|
|
|
|
|
def test_user_notes_extra_lines_beyond_four_are_dropped():
|
|
"""If somehow more than 4 lines appear in the user-notes block
|
|
(e.g. BW adds an Extended Notes line), only the first 4 are
|
|
captured — slots 5+ have nowhere to go."""
|
|
r = parse_report(_wrap_user_notes(
|
|
"L1 : v1",
|
|
"L2 : v2",
|
|
"L3 : v3",
|
|
"L4 : v4",
|
|
"L5 : v5", # ignored — no fifth slot
|
|
))
|
|
assert r.project == "v1"
|
|
assert r.client == "v2"
|
|
assert r.operator == "v3"
|
|
assert r.sensor_location == "v4"
|
|
# 5th label not captured
|
|
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."""
|
|
fixture_dir = (
|
|
Path(__file__).parent.parent / "example-events" / "histogram"
|
|
)
|
|
if not fixture_dir.exists():
|
|
pytest.skip("histogram fixtures not present")
|
|
txt = next(fixture_dir.glob("*_ASCII.TXT"), None)
|
|
if txt is None:
|
|
pytest.skip("no histogram TXT in fixture dir")
|
|
|
|
r = parse_report_file(txt)
|
|
assert r.sensor_location is not None
|
|
assert len(r.sensor_location) > 0
|
|
assert r.user_note_labels.get("sensor_location") is not None
|
|
# Sanity: other shared fields still parse correctly
|
|
assert r.serial is not None
|
|
assert r.serial.startswith("BE")
|
|
assert r.geo_range_ips is not None
|