v0.20.0 -- Full s3 event parse and PDF creation. #28
@@ -67,6 +67,11 @@ class ChannelStats:
|
|||||||
# to render "> 10 in/s" or "saturated" instead of trusting the
|
# to render "> 10 in/s" or "saturated" instead of trusting the
|
||||||
# value as an exact measurement.
|
# value as an exact measurement.
|
||||||
ppv_saturated: bool = False
|
ppv_saturated: bool = False
|
||||||
|
# Set when BW writes ">100 Hz" for ZC Freq — the zero-crossing
|
||||||
|
# algorithm's peak frequency exceeded the device's reporting
|
||||||
|
# ceiling (typically 100 Hz on V10.72). zc_freq_hz gets the
|
||||||
|
# threshold (100.0) as a lower bound; downstream UI renders ">100".
|
||||||
|
zc_freq_above_range: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -81,6 +86,9 @@ class MicStats:
|
|||||||
# 140 dBL (typical NL-43 max; some units cap at 148). Consumers
|
# 140 dBL (typical NL-43 max; some units cap at 148). Consumers
|
||||||
# should render "> 140 dB(L)" or similar when this flag is set.
|
# should render "> 140 dB(L)" or similar when this flag is set.
|
||||||
pspl_saturated: bool = False
|
pspl_saturated: bool = False
|
||||||
|
# Same semantics as ChannelStats.zc_freq_above_range — mic ZC
|
||||||
|
# peak exceeded device reporting ceiling.
|
||||||
|
zc_freq_above_range: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -119,6 +127,20 @@ def _is_oorange(value: str) -> bool:
|
|||||||
return any(m in s for m in _OORANGE_MARKERS)
|
return any(m in s for m in _OORANGE_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_above_range(value: str) -> Optional[float]:
|
||||||
|
"""For BW "above-range" markers like ">100 Hz", return the threshold.
|
||||||
|
|
||||||
|
BW writes ZC Freq as ">100 Hz" when the zero-crossing algorithm sees
|
||||||
|
a peak too fast to count (device cuts off at 100 Hz). Returns the
|
||||||
|
numeric portion after the '>' (e.g. 100.0), or None if `value` is
|
||||||
|
not an above-range marker.
|
||||||
|
"""
|
||||||
|
s = value.strip()
|
||||||
|
if not s.startswith(">"):
|
||||||
|
return None
|
||||||
|
return _parse_number(s[1:])
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BwAsciiReport:
|
class BwAsciiReport:
|
||||||
"""Structured representation of one BW per-event ASCII export."""
|
"""Structured representation of one BW per-event ASCII export."""
|
||||||
@@ -527,10 +549,17 @@ def parse_report(text: Union[str, bytes], *, parse_samples: bool = False) -> BwA
|
|||||||
cs.ppv_saturated = True
|
cs.ppv_saturated = True
|
||||||
else:
|
else:
|
||||||
cs.ppv_ips = _parse_number(value)
|
cs.ppv_ips = _parse_number(value)
|
||||||
|
elif stat == "ZC Freq":
|
||||||
|
# ">100 Hz" → store threshold + flag; numeric → parse normally
|
||||||
|
threshold = _parse_above_range(value)
|
||||||
|
if threshold is not None:
|
||||||
|
cs.zc_freq_hz = threshold
|
||||||
|
cs.zc_freq_above_range = True
|
||||||
|
else:
|
||||||
|
cs.zc_freq_hz = _parse_number(value)
|
||||||
else:
|
else:
|
||||||
num = _parse_number(value)
|
num = _parse_number(value)
|
||||||
if stat == "ZC Freq": cs.zc_freq_hz = num
|
if stat == "Time of Peak": cs.time_of_peak_s = num
|
||||||
elif stat == "Time of Peak": cs.time_of_peak_s = num
|
|
||||||
elif stat == "Peak Acceleration": cs.peak_accel_g = num
|
elif stat == "Peak Acceleration": cs.peak_accel_g = num
|
||||||
elif stat == "Peak Displacement": cs.peak_disp_in = num
|
elif stat == "Peak Displacement": cs.peak_disp_in = num
|
||||||
|
|
||||||
@@ -627,9 +656,15 @@ def parse_report(text: Union[str, bytes], *, parse_samples: bool = False) -> BwA
|
|||||||
cs = report.channels.setdefault("MicL", ChannelStats())
|
cs = report.channels.setdefault("MicL", ChannelStats())
|
||||||
cs.time_of_peak_s = report.mic.time_of_peak_s
|
cs.time_of_peak_s = report.mic.time_of_peak_s
|
||||||
elif key == "MicL ZC Freq":
|
elif key == "MicL ZC Freq":
|
||||||
|
threshold = _parse_above_range(value)
|
||||||
|
if threshold is not None:
|
||||||
|
report.mic.zc_freq_hz = threshold
|
||||||
|
report.mic.zc_freq_above_range = True
|
||||||
|
else:
|
||||||
report.mic.zc_freq_hz = _parse_number(value)
|
report.mic.zc_freq_hz = _parse_number(value)
|
||||||
cs = report.channels.setdefault("MicL", ChannelStats())
|
cs = report.channels.setdefault("MicL", ChannelStats())
|
||||||
cs.zc_freq_hz = report.mic.zc_freq_hz
|
cs.zc_freq_hz = report.mic.zc_freq_hz
|
||||||
|
cs.zc_freq_above_range = report.mic.zc_freq_above_range
|
||||||
|
|
||||||
# ── Sensor self-check ────────────────────────────────────────────────
|
# ── Sensor self-check ────────────────────────────────────────────────
|
||||||
elif key in (
|
elif key in (
|
||||||
|
|||||||
@@ -125,6 +125,10 @@ def _bw_report_to_dict(report: BwAsciiReport) -> dict:
|
|||||||
# is the channel range max (a lower bound), not an exact reading.
|
# is the channel range max (a lower bound), not an exact reading.
|
||||||
if getattr(cs, "ppv_saturated", False):
|
if getattr(cs, "ppv_saturated", False):
|
||||||
out["ppv_saturated"] = True
|
out["ppv_saturated"] = True
|
||||||
|
# ZC Freq above device reporting ceiling (BW ">100 Hz") — value
|
||||||
|
# in zc_freq_hz is the threshold, not an exact measurement.
|
||||||
|
if getattr(cs, "zc_freq_above_range", False):
|
||||||
|
out["zc_freq_above_range"] = True
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def _sc(ch_name: str) -> dict:
|
def _sc(ch_name: str) -> dict:
|
||||||
@@ -191,6 +195,7 @@ def _bw_report_to_dict(report: BwAsciiReport) -> dict:
|
|||||||
"pspl_dbl": report.mic.pspl_dbl,
|
"pspl_dbl": report.mic.pspl_dbl,
|
||||||
"pspl_saturated": bool(getattr(report.mic, "pspl_saturated", False)),
|
"pspl_saturated": bool(getattr(report.mic, "pspl_saturated", False)),
|
||||||
"zc_freq_hz": report.mic.zc_freq_hz,
|
"zc_freq_hz": report.mic.zc_freq_hz,
|
||||||
|
"zc_freq_above_range": bool(getattr(report.mic, "zc_freq_above_range", False)),
|
||||||
"time_of_peak_s": report.mic.time_of_peak_s,
|
"time_of_peak_s": report.mic.time_of_peak_s,
|
||||||
},
|
},
|
||||||
"sensor_check": {
|
"sensor_check": {
|
||||||
|
|||||||
+12
-4
@@ -99,6 +99,7 @@ class ReportData:
|
|||||||
mic_pspl_time_s: Optional[float] = None
|
mic_pspl_time_s: Optional[float] = None
|
||||||
mic_pspl_when_str: Optional[str] = None # histogram absolute date+time, BW-formatted
|
mic_pspl_when_str: Optional[str] = None # histogram absolute date+time, BW-formatted
|
||||||
mic_zc_freq_hz: Optional[float] = None
|
mic_zc_freq_hz: Optional[float] = None
|
||||||
|
mic_zc_freq_above_range: bool = False
|
||||||
mic_channel_test_result: Optional[str] = None
|
mic_channel_test_result: Optional[str] = None
|
||||||
mic_channel_test_freq_hz: Optional[float] = None
|
mic_channel_test_freq_hz: Optional[float] = None
|
||||||
mic_channel_test_amp_mv: Optional[float] = None
|
mic_channel_test_amp_mv: Optional[float] = None
|
||||||
@@ -217,6 +218,7 @@ def gather_report_data(
|
|||||||
rd.mic_pspl_psi = DBL_REF_PSI * (10 ** (rd.mic_pspl_dbl / 20))
|
rd.mic_pspl_psi = DBL_REF_PSI * (10 ** (rd.mic_pspl_dbl / 20))
|
||||||
rd.mic_pspl_time_s = mic.get("time_of_peak_s")
|
rd.mic_pspl_time_s = mic.get("time_of_peak_s")
|
||||||
rd.mic_zc_freq_hz = mic.get("zc_freq_hz")
|
rd.mic_zc_freq_hz = mic.get("zc_freq_hz")
|
||||||
|
rd.mic_zc_freq_above_range = bool(mic.get("zc_freq_above_range"))
|
||||||
sc_mic = (bw.get("sensor_check") or {}).get("mic") or {}
|
sc_mic = (bw.get("sensor_check") or {}).get("mic") or {}
|
||||||
rd.mic_channel_test_result = sc_mic.get("result")
|
rd.mic_channel_test_result = sc_mic.get("result")
|
||||||
rd.mic_channel_test_freq_hz = sc_mic.get("freq_hz")
|
rd.mic_channel_test_freq_hz = sc_mic.get("freq_hz")
|
||||||
@@ -239,6 +241,7 @@ def gather_report_data(
|
|||||||
"name": ch_label,
|
"name": ch_label,
|
||||||
"ppv_ips": ch.get("ppv_ips"),
|
"ppv_ips": ch.get("ppv_ips"),
|
||||||
"zc_freq_hz": ch.get("zc_freq_hz"),
|
"zc_freq_hz": ch.get("zc_freq_hz"),
|
||||||
|
"zc_freq_above_range": bool(ch.get("zc_freq_above_range")),
|
||||||
"time_of_peak_s": ch.get("time_of_peak_s"),
|
"time_of_peak_s": ch.get("time_of_peak_s"),
|
||||||
"peak_accel_g": ch.get("peak_accel_g"),
|
"peak_accel_g": ch.get("peak_accel_g"),
|
||||||
"peak_disp_in": ch.get("peak_disp_in"),
|
"peak_disp_in": ch.get("peak_disp_in"),
|
||||||
@@ -612,7 +615,8 @@ def _mic_rows(rd: ReportData) -> list[tuple[str, Optional[str]]]:
|
|||||||
line += f" at {rd.mic_pspl_time_s:.3f} sec."
|
line += f" at {rd.mic_pspl_time_s:.3f} sec."
|
||||||
rows.append(("PSPL", line))
|
rows.append(("PSPL", line))
|
||||||
if rd.mic_zc_freq_hz is not None:
|
if rd.mic_zc_freq_hz is not None:
|
||||||
rows.append(("ZC Freq", f"{rd.mic_zc_freq_hz:.0f} Hz"))
|
prefix = ">" if rd.mic_zc_freq_above_range else ""
|
||||||
|
rows.append(("ZC Freq", f"{prefix}{rd.mic_zc_freq_hz:.0f} Hz"))
|
||||||
if rd.mic_channel_test_result:
|
if rd.mic_channel_test_result:
|
||||||
line = rd.mic_channel_test_result
|
line = rd.mic_channel_test_result
|
||||||
if rd.mic_channel_test_freq_hz is not None and rd.mic_channel_test_amp_mv is not None:
|
if rd.mic_channel_test_freq_hz is not None and rd.mic_channel_test_amp_mv is not None:
|
||||||
@@ -684,13 +688,17 @@ def _draw_stats_table(ax, rd: ReportData, rows_spec: list[tuple[str, str, str]])
|
|||||||
ch_lookup = {c["name"]: c for c in rd.channel_stats}
|
ch_lookup = {c["name"]: c for c in rd.channel_stats}
|
||||||
|
|
||||||
def _cell(field, ch_name):
|
def _cell(field, ch_name):
|
||||||
val = ch_lookup.get(ch_name, {}).get(field)
|
ch_rec = ch_lookup.get(ch_name, {})
|
||||||
|
val = ch_rec.get(field)
|
||||||
if val is None:
|
if val is None:
|
||||||
return "—"
|
return "—"
|
||||||
if isinstance(val, float):
|
if isinstance(val, float):
|
||||||
# ZC Freq is integer-formatted in BW; everything else with 3 decimals
|
# ZC Freq is integer-formatted in BW; ">100 Hz" sentinel
|
||||||
|
# rendered as ">N" (val carries the threshold). Everything
|
||||||
|
# else gets 3 decimals.
|
||||||
if field == "zc_freq_hz":
|
if field == "zc_freq_hz":
|
||||||
return f"{val:.0f}"
|
prefix = ">" if ch_rec.get("zc_freq_above_range") else ""
|
||||||
|
return f"{prefix}{val:.0f}"
|
||||||
return f"{val:.3f}"
|
return f"{val:.3f}"
|
||||||
return str(val)
|
return str(val)
|
||||||
|
|
||||||
|
|||||||
@@ -441,6 +441,40 @@ def test_real_oorange_event_t190_parses():
|
|||||||
assert r.channels["Long"].ppv_ips == pytest.approx(2.83)
|
assert r.channels["Long"].ppv_ips == pytest.approx(2.83)
|
||||||
assert r.peak_vector_sum_saturated is True
|
assert r.peak_vector_sum_saturated is True
|
||||||
assert r.peak_vector_sum_time_s == pytest.approx(0.007)
|
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():
|
def test_real_histogram_fixture_populates_sensor_location():
|
||||||
|
|||||||
Reference in New Issue
Block a user