diff --git a/minimateplus/bw_ascii_report.py b/minimateplus/bw_ascii_report.py index 2d85b97..2f919c4 100644 --- a/minimateplus/bw_ascii_report.py +++ b/minimateplus/bw_ascii_report.py @@ -67,6 +67,11 @@ class ChannelStats: # to render "> 10 in/s" or "saturated" instead of trusting the # value as an exact measurement. 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 @@ -81,6 +86,9 @@ class MicStats: # 140 dBL (typical NL-43 max; some units cap at 148). Consumers # should render "> 140 dB(L)" or similar when this flag is set. 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 @@ -119,6 +127,20 @@ def _is_oorange(value: str) -> bool: 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 class BwAsciiReport: """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 else: 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: num = _parse_number(value) - if stat == "ZC Freq": cs.zc_freq_hz = num - elif stat == "Time of Peak": cs.time_of_peak_s = num + if stat == "Time of Peak": cs.time_of_peak_s = num elif stat == "Peak Acceleration": cs.peak_accel_g = 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.time_of_peak_s = report.mic.time_of_peak_s elif key == "MicL ZC Freq": - report.mic.zc_freq_hz = _parse_number(value) + 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) 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 ──────────────────────────────────────────────── elif key in ( diff --git a/minimateplus/event_file_io.py b/minimateplus/event_file_io.py index 36bf56d..7dc74c1 100644 --- a/minimateplus/event_file_io.py +++ b/minimateplus/event_file_io.py @@ -125,6 +125,10 @@ def _bw_report_to_dict(report: BwAsciiReport) -> dict: # is the channel range max (a lower bound), not an exact reading. if getattr(cs, "ppv_saturated", False): 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 def _sc(ch_name: str) -> dict: @@ -187,11 +191,12 @@ def _bw_report_to_dict(report: BwAsciiReport) -> dict: }, }, "mic": { - "weighting": report.mic.weighting, - "pspl_dbl": report.mic.pspl_dbl, - "pspl_saturated": bool(getattr(report.mic, "pspl_saturated", False)), - "zc_freq_hz": report.mic.zc_freq_hz, - "time_of_peak_s": report.mic.time_of_peak_s, + "weighting": report.mic.weighting, + "pspl_dbl": report.mic.pspl_dbl, + "pspl_saturated": bool(getattr(report.mic, "pspl_saturated", False)), + "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, }, "sensor_check": { "tran": _sc("Tran"), diff --git a/sfm/report_pdf.py b/sfm/report_pdf.py index a051393..6618d9a 100644 --- a/sfm/report_pdf.py +++ b/sfm/report_pdf.py @@ -99,6 +99,7 @@ class ReportData: mic_pspl_time_s: Optional[float] = None mic_pspl_when_str: Optional[str] = None # histogram absolute date+time, BW-formatted mic_zc_freq_hz: Optional[float] = None + mic_zc_freq_above_range: bool = False mic_channel_test_result: Optional[str] = None mic_channel_test_freq_hz: Optional[float] = None mic_channel_test_amp_mv: Optional[float] = None @@ -216,7 +217,8 @@ def gather_report_data( # Inverse of the dBL formula → psi. Mirrors waveform_codec convention. 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_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 {} rd.mic_channel_test_result = sc_mic.get("result") rd.mic_channel_test_freq_hz = sc_mic.get("freq_hz") @@ -236,15 +238,16 @@ def gather_report_data( ch_when_iso = peak_when.get(ch_label) peak_date, peak_time = _split_iso_to_date_time(ch_when_iso) rd.channel_stats.append({ - "name": ch_label, - "ppv_ips": ch.get("ppv_ips"), - "zc_freq_hz": ch.get("zc_freq_hz"), - "time_of_peak_s": ch.get("time_of_peak_s"), - "peak_accel_g": ch.get("peak_accel_g"), - "peak_disp_in": ch.get("peak_disp_in"), - "sensor_check": sc_ch.get("result"), - "peak_date": peak_date, - "peak_time": peak_time, + "name": ch_label, + "ppv_ips": ch.get("ppv_ips"), + "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"), + "peak_accel_g": ch.get("peak_accel_g"), + "peak_disp_in": ch.get("peak_disp_in"), + "sensor_check": sc_ch.get("result"), + "peak_date": peak_date, + "peak_time": peak_time, }) # MicL peak time (used in the mic block — "PSPL ... on DATE at TIME") @@ -612,7 +615,8 @@ def _mic_rows(rd: ReportData) -> list[tuple[str, Optional[str]]]: line += f" at {rd.mic_pspl_time_s:.3f} sec." rows.append(("PSPL", line)) 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: 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: @@ -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} 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: return "—" 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": - return f"{val:.0f}" + prefix = ">" if ch_rec.get("zc_freq_above_range") else "" + return f"{prefix}{val:.0f}" return f"{val:.3f}" return str(val) diff --git a/tests/test_bw_ascii_report.py b/tests/test_bw_ascii_report.py index ddfae70..5756fb2 100644 --- a/tests/test_bw_ascii_report.py +++ b/tests/test_bw_ascii_report.py @@ -441,6 +441,40 @@ def test_real_oorange_event_t190_parses(): 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():