780b45a371
BW writes ">100 Hz" for ZC Freq when the zero-crossing algorithm sees a peak too fast to count — the device's reporting ceiling is 100 Hz on V10.72. Our parser fell back to None via _parse_number (which requires a leading digit), so the PDF rendered "—" where BW shows ">100". Mirrors the OORANGE/saturated pattern already used for PPV and PSPL: parser stores the threshold (100.0) on zc_freq_hz + sets a new zc_freq_above_range flag. Projection carries the flag through to the sidecar; PDF renderer prepends ">" when set. Affects both per-channel stats tables (waveform + histogram variants) and the mic block's ZC Freq row. Verified on the real T190LD5Q.LK0W fixture: Tran zc_freq_hz=100.0 above_range=True; Vert/Long (normal values) above_range=False; "N/A" still produces zc_freq_hz=None which renders as "—" (unchanged). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
500 lines
18 KiB
Python
500 lines
18 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)
|
|
# Same fixture: Tran ZC Freq is ">100 Hz" — must parse as 100 +
|
|
# above_range flag, not None (which would render as "—" on the PDF).
|
|
assert r.channels["Tran"].zc_freq_hz == 100.0
|
|
assert r.channels["Tran"].zc_freq_above_range is True
|
|
# Vert/Long are normal numeric values; flag stays False.
|
|
assert r.channels["Vert"].zc_freq_above_range is False
|
|
assert r.channels["Long"].zc_freq_above_range is False
|
|
|
|
|
|
def test_above_range_marker_treated_as_zc_threshold():
|
|
"""BW writes '>100 Hz' for ZC Freq when the zero-crossing algorithm
|
|
sees a peak too fast to count (cuts off at the device's 100 Hz
|
|
reporting ceiling). Parser must store the threshold + flag, not
|
|
fall back to None.
|
|
"""
|
|
txt = """\
|
|
"Event Type : Full Waveform"
|
|
"Serial Number : BE18190"
|
|
"Tran ZC Freq : >100 Hz"
|
|
"Vert ZC Freq : 73 Hz"
|
|
"Long ZC Freq : N/A Hz"
|
|
"MicL ZC Freq : >100 Hz"
|
|
"""
|
|
r = parse_report(txt)
|
|
assert r.channels["Tran"].zc_freq_hz == 100.0
|
|
assert r.channels["Tran"].zc_freq_above_range is True
|
|
assert r.channels["Vert"].zc_freq_hz == 73.0
|
|
assert r.channels["Vert"].zc_freq_above_range is False
|
|
# N/A → None, flag stays False
|
|
assert r.channels["Long"].zc_freq_hz is None
|
|
assert r.channels["Long"].zc_freq_above_range is False
|
|
# Mic above-range
|
|
assert r.mic.zc_freq_hz == 100.0
|
|
assert r.mic.zc_freq_above_range is True
|
|
|
|
|
|
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
|