""" 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