diff --git a/sfm/idf_ascii_report.py b/sfm/idf_ascii_report.py index ea26293..9775b42 100644 --- a/sfm/idf_ascii_report.py +++ b/sfm/idf_ascii_report.py @@ -65,9 +65,17 @@ def _normalize_key(raw: str) -> str: def _strip_unit_suffix(value: str) -> str: - """Return the numeric part of values like "0.2119 in/s" → "0.2119".""" + """Return the numeric part of values like "0.2119 in/s" → "0.2119". + + Also strips Thor's below/above-threshold prefixes: + "<0.005 in/s" → "0.005" (below-noise-floor reading) + ">100 Hz" → "100" (above-measurement-range reading) + """ parts = value.strip().split() - return parts[0] if parts else value.strip() + token = parts[0] if parts else value.strip() + if token.startswith("<") or token.startswith(">"): + token = token[1:] + return token def _parse_float(value: str) -> Optional[float]: @@ -178,38 +186,54 @@ def parse_idf_report(text: Union[str, bytes]) -> Dict[str, Any]: except ValueError: pass - # Numeric scalars - for key in ("sample_rate",): + # Numeric scalars. For every field we typify here, we MUST drop the + # raw string copy from `out` when parsing fails — Thor writes things + # like "<0.005 in/s" (below threshold) and "N/A" (not measured) that + # would otherwise linger in `out` as strings, sneak into SQLite REAL + # columns via permissive type affinity, and then crash the JS + # frontend on `.toFixed(...)`. + int_fields = ("sample_rate",) + for key in int_fields: v = raw.get(key) - if v is not None: - iv = _parse_int(v) - if iv is not None: - out[key] = iv + if v is None: + continue + iv = _parse_int(v) + if iv is not None: + out[key] = iv + else: + out.pop(key, None) - for key in ("tran_ppv", "vert_ppv", "long_ppv", "peak_vector_sum", - "tran_zc_freq", "vert_zc_freq", "long_zc_freq", - "tran_peak_acceleration", "vert_peak_acceleration", - "long_peak_acceleration", - "tran_peak_displacement", "vert_peak_displacement", - "long_peak_displacement", - "tran_time_of_peak", "vert_time_of_peak", "long_time_of_peak", - "mic_time_of_peak", "mic_zc_freq"): + float_fields = ( + "tran_ppv", "vert_ppv", "long_ppv", "peak_vector_sum", + "tran_zc_freq", "vert_zc_freq", "long_zc_freq", + "tran_peak_acceleration", "vert_peak_acceleration", + "long_peak_acceleration", + "tran_peak_displacement", "vert_peak_displacement", + "long_peak_displacement", + "tran_time_of_peak", "vert_time_of_peak", "long_time_of_peak", + "mic_time_of_peak", "mic_zc_freq", + ) + for key in float_fields: v = raw.get(key) - if v is not None: - fv = _parse_float(v) - if fv is not None: - out[key] = fv + if v is None: + continue + fv = _parse_float(v) + if fv is not None: + out[key] = fv + else: + out.pop(key, None) # Microphone — Thor reports MicPSPL (dB(L)) which is the closest - # analogue to BW's mic_ppv. Stored as a float; units are in the - # original raw field (`mic_pspl` raw entry preserves "99.4 dB(L)"). + # analogue to BW's mic_ppv. The raw "99.4 dB(L)" string stays in + # `out` under the original `mic_pspl` key for display; the parsed + # float goes in `mic_ppv`. mic = raw.get("mic_pspl") if mic is not None: fv = _parse_float(mic) if fv is not None: out["mic_ppv"] = fv - # Record / pre-trigger duration + # Record / pre-trigger duration — same drop-on-failure discipline. rt = raw.get("record_time") if rt is not None: fv = _parse_float(rt) diff --git a/sfm/sfm_webapp.html b/sfm/sfm_webapp.html index 17474b4..7925c83 100644 --- a/sfm/sfm_webapp.html +++ b/sfm/sfm_webapp.html @@ -2285,13 +2285,16 @@ let sessLoaded = false; const _unitSerials = new Set(); function _ppvClass(v) { - if (v == null) return ''; - if (v >= 2.0) return 'ppv-high'; - if (v >= 0.5) return 'ppv-warn'; + const n = (v == null) ? null : Number(v); + if (n == null || !isFinite(n)) return ''; + if (n >= 2.0) return 'ppv-high'; + if (n >= 0.5) return 'ppv-warn'; return 'ppv-ok'; } function _ppvFmt(v) { - return v != null ? v.toFixed(5) : '—'; + if (v == null) return '—'; + const n = typeof v === 'number' ? v : Number(v); + return isFinite(n) ? n.toFixed(5) : String(v); } function _fmtTs(ts) { if (!ts) return '—'; @@ -2386,7 +2389,11 @@ async function loadHistory() {