a032fa5451
The four operator-supplied note fields in BW's Compliance Setup →
Notes tab (Project / Client / User Name / Seis Loc) have
USER-EDITABLE LABELS — an operator can rename them in BW's UI to
"Building:", "Site Address:", "Inspector:", or anything else, and
the ASCII export writes those literal labels verbatim. The
previous label-normalisation map approach (just added in commit
6a7e8c6) was fragile: it could only match label spellings we'd
enumerated in advance. An operator using "Site:" instead of
"Seis Loc:" would have their sensor location silently dropped.
What IS reliable: BW always writes the 4 user-notes lines
contiguously, in the same order, between the "Units :" line and
the "Geo Range :" line of the export. So parse them by POSITION:
position 1 → project
position 2 → client
position 3 → operator
position 4 → sensor_location
The original labels BW wrote are preserved in a new
`BwAsciiReport.user_note_labels` dict (canonical slot → literal
label string) so terra-view can render them as the operator named
them.
Removes the `_OPERATOR_LABEL_MAP` / `_normalise_label_for_lookup`
helpers and the elif-by-normalised-label branch in `parse_report`.
Replaces with a small state machine that flips on the "Units" line
and flips off on the "Geo Range" line.
Tests:
- Default-label fixtures (waveform + histogram) still populate
correctly, with operator's labels captured.
- Synthetic custom-labelled exports ("Building:" / "Site Address:" /
etc.) populate the right slots by position.
- Histogram-specific "Seis. Location:" works.
- Lines outside the Units→Geo Range range are ignored even if
they look like user notes (defensive against malformed exports).
- Partial blocks (fewer than 4 lines) leave later slots None.
- Extra lines beyond 4 are dropped (5th slot doesn't exist).
26 tests in test_bw_ascii_report.py (was 33; net drop reflects
parametrised label tests collapsed into 6 focused position tests).
Full SFM suite: 62 passed, 44 skipped.
Pairs with series3-watcher v1.5.0 which fixes the filename pairing
so the report reaches this parser in the first place.
408 lines
15 KiB
Python
408 lines
15 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_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
|