2 Commits

Author SHA1 Message Date
serversdown 6a73523e4d ui: surface per-channel ZC Freq (and ">100") in event modals
The PDF report shows per-channel ZC Freq alongside PPV in the stats
block, but neither modal exposed it.  Now that the sidecar projection
carries zc_freq_hz + zc_freq_above_range, plumb them through:

- sfm_webapp.html: inline suffix on existing Peaks cells, e.g.
  "Tran  0.04500 in/s · >100 Hz".  Empty suffix when no ZC is
  available (legacy events without a preserved .TXT).

- event_browser.html: new ZC Freq column on the per-channel stats
  table.  Required adding a parallel sidecar fetch in loadEvent()
  (waveform.json alone doesn't carry bw_report).  Fetch failure is
  non-fatal — falls back to "—" in the new column.

Above-range ZC peaks (BW ">100 Hz") render with a literal ">"
prefix mirroring the PDF, so operators don't have to generate the
PDF to see when a channel hit the zero-crossing ceiling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:47:37 +00:00
serversdown 780b45a371 feat: render ">100" for above-range ZC Freq instead of "—"
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>
2026-05-28 18:38:49 +00:00
6 changed files with 149 additions and 38 deletions
+39 -4
View File
@@ -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 (
+10 -5
View File
@@ -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"),
+26 -11
View File
@@ -499,6 +499,14 @@ async function loadEvent(eventId) {
renderEventList();
setStatus('Loading waveform…');
try {
// Sidecar fetch runs in parallel — its bw_report block carries ZC
// Freq + above-range flags + sensor-check results that the per-
// channel stats table surfaces. Failures are non-fatal (legacy
// events without a preserved .TXT have no sidecar bw_report).
const sidecarP = fetch(`${apiBase}/db/events/${eventId}/sidecar`)
.then(r => r.ok ? r.json() : null)
.catch(() => null);
const r = await fetch(`${apiBase}/db/events/${eventId}/waveform.json`);
if (!r.ok) {
if (r.status === 404) {
@@ -511,7 +519,8 @@ async function loadEvent(eventId) {
renderWaveform(data);
// Also fetch metadata from the events list for richer header
const ev = allEvents.find(e => e.id === eventId);
renderMeta(data, ev);
const sidecar = await sidecarP;
renderMeta(data, ev, sidecar);
setStatus(`Event loaded.`, 'ok');
} catch (e) {
setStatus(`Failed to load event: ${e.message}`, 'error');
@@ -528,7 +537,7 @@ function showEmpty(msg) {
charts = {};
}
function renderMeta(data, ev) {
function renderMeta(data, ev, sidecar) {
const metaDiv = document.getElementById('event-meta');
const fields = [
['Serial', data.serial || ev?.serial || '—'],
@@ -543,14 +552,20 @@ function renderMeta(data, ev) {
];
// Per-channel stats table mirroring the printout's middle block.
// Pulls per-channel PPV from the events row (DB columns) and additional
// details (peak time, peak accel, peak displacement, sensor check) from
// bw_report when present.
// PPV from the events DB row; ZC Freq + saturation flags from the
// sidecar's bw_report block (when a .TXT was preserved on ingest).
const bwrPeaks = (sidecar?.bw_report || {}).peaks || {};
const bwrMic = (sidecar?.bw_report || {}).mic || {};
const fmt = v => (v == null ? '—' : (typeof v === 'number' ? v.toFixed(3) : v));
const fmtZc = bwr => {
if (!bwr || bwr.zc_freq_hz == null) return '—';
const prefix = bwr.zc_freq_above_range ? '>' : '';
return `${prefix}${Math.round(bwr.zc_freq_hz)} Hz`;
};
const rows = [
['Tran', ev?.tran_ppv],
['Vert', ev?.vert_ppv],
['Long', ev?.long_ppv],
['Tran', ev?.tran_ppv, fmtZc(bwrPeaks.tran)],
['Vert', ev?.vert_ppv, fmtZc(bwrPeaks.vert)],
['Long', ev?.long_ppv, fmtZc(bwrPeaks.long)],
];
// Mic display honors the current user preference (dBL default).
// mic_ppv is stored as raw psi on series3 events; convert when needed.
@@ -568,11 +583,11 @@ function renderMeta(data, ev) {
const statsHtml = `
<table class="stats-table">
<thead>
<tr><th>Channel</th><th>PPV (in/s)</th></tr>
<tr><th>Channel</th><th>PPV (in/s)</th><th>ZC Freq</th></tr>
</thead>
<tbody>
${rows.map(([ch, ppv]) => `<tr><td>${ch}</td><td>${fmt(ppv)}</td></tr>`).join('')}
<tr><td>MicL</td><td>${micStr}</td></tr>
${rows.map(([ch, ppv, zc]) => `<tr><td>${ch}</td><td>${fmt(ppv)}</td><td>${zc}</td></tr>`).join('')}
<tr><td>MicL</td><td>${micStr}</td><td>${fmtZc(bwrMic)}</td></tr>
</tbody>
</table>
`;
+22 -14
View File
@@ -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)
+18 -4
View File
@@ -2886,6 +2886,12 @@ function _renderSidecar(data) {
const bw = data.blastware || {};
const src = data.source || {};
const rev = data.review || {};
// bw_report carries the per-channel ASCII-derived stats (ZC Freq,
// saturation flags, peak time, etc.). Only present on events
// ingested with a preserved .TXT (post-2026-05-27); falls back to
// empty for legacy events.
const bwrPeaks = (data.bw_report || {}).peaks || {};
const bwrMic = (data.bw_report || {}).mic || {};
document.getElementById('sc-title').textContent = `Event — ${bw.filename || ev.waveform_key || 'unknown'}`;
@@ -2918,11 +2924,19 @@ function _renderSidecar(data) {
document.getElementById('sc-f-sr').textContent = (ev.sample_rate ?? '—') + (ev.sample_rate ? ' sps' : '');
document.getElementById('sc-f-key').textContent = ev.waveform_key || '—';
document.getElementById('sc-f-tran').textContent = fmtPpv(pv.transverse);
document.getElementById('sc-f-vert').textContent = fmtPpv(pv.vertical);
document.getElementById('sc-f-long').textContent = fmtPpv(pv.longitudinal);
// Suffix with " · {prefix}{N} Hz" when bw_report has a ZC Freq.
// Above-range ZC peaks (BW ">100 Hz") get a literal ">" prefix so
// operators see the same indicator the PDF shows.
const fmtZc = bwr => {
if (!bwr || bwr.zc_freq_hz == null) return '';
const prefix = bwr.zc_freq_above_range ? '>' : '';
return ` · ${prefix}${Math.round(bwr.zc_freq_hz)} Hz`;
};
document.getElementById('sc-f-tran').textContent = fmtPpv(pv.transverse) + fmtZc(bwrPeaks.tran);
document.getElementById('sc-f-vert').textContent = fmtPpv(pv.vertical) + fmtZc(bwrPeaks.vert);
document.getElementById('sc-f-long').textContent = fmtPpv(pv.longitudinal) + fmtZc(bwrPeaks.long);
document.getElementById('sc-f-pvs').textContent = fmtPpv(pv.vector_sum);
document.getElementById('sc-f-mic').textContent = fmtMic(pv.mic_psi);
document.getElementById('sc-f-mic').textContent = fmtMic(pv.mic_psi) + fmtZc(bwrMic);
document.getElementById('sc-f-project').textContent = pi.project || '—';
document.getElementById('sc-f-client').textContent = pi.client || '—';
+34
View File
@@ -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():