Productionizes the validated scratch/offset_scan3.py: a DC offset (baseline shifted off zero — sensor bumped/settled/drifted) is |median(pre-trigger)| >= 5 counts (0.025 in/s) AND flat across pre/mid/end thirds (spread <= 0.02); a transient moves one third and is rejected by the spread test. - shape_metrics: offset_from_samples / offset_from_h5 (reads .h5 samples + pretrig_samples attr; range-aware via the .h5's in/s float samples) - events schema: shape_offset / _axis / _pre / _spread (via _SCHEMA + the _migrate ADD COLUMN loop only; NOT the Migration-1 rebuild), threaded through insert + upsert mirroring shape_* - ingest: computed at all three waveform_store save paths alongside shape - backfill_event_shape: also computes + stores (and stale-clears) offset - exposed via /db/events automatically (SELECT *) Gating to waveforms is done downstream in terra-view ft_suspicion (mirrors how shape is ignored for histograms), not at the SFM call sites. 13 new tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
128 lines
4.7 KiB
Python
128 lines
4.7 KiB
Python
"""Waveform-shape metrics for false-trigger detection.
|
|
|
|
A false trigger is an isolated impulse (quiet → spike → quiet); a real event
|
|
rings for many cycles. Two numbers separate them: crest factor (how far the
|
|
peak stands above the typical sample) and how many samples sit near the peak.
|
|
"""
|
|
from __future__ import annotations
|
|
import numpy as np
|
|
|
|
_GEO_CHANNELS = ("Tran", "Vert", "Long")
|
|
NEAR_PEAK_FRACTION = 0.5 # a sample "near the peak" is >= this * peak amplitude
|
|
|
|
|
|
def channel_shape(x) -> dict | None:
|
|
x = np.asarray(x, dtype=float)
|
|
if x.size < 2:
|
|
return None
|
|
peak = float(np.max(np.abs(x)))
|
|
if peak <= 0:
|
|
return None
|
|
rms = float(np.sqrt(np.mean(x ** 2)))
|
|
if rms <= 0:
|
|
return None
|
|
near = int(np.sum(np.abs(x) >= NEAR_PEAK_FRACTION * peak))
|
|
return {"crest_factor": peak / rms, "near_peak_count": near,
|
|
"sample_count": int(x.size)}
|
|
|
|
|
|
def shape_from_samples(chans: dict) -> dict | None:
|
|
best_axis, best_peak, best_x = None, -1.0, None
|
|
for ax in _GEO_CHANNELS:
|
|
x = chans.get(ax)
|
|
if x is None:
|
|
continue
|
|
x = np.asarray(x, dtype=float)
|
|
if x.size < 2:
|
|
continue
|
|
p = float(np.max(np.abs(x)))
|
|
if p > best_peak:
|
|
best_axis, best_peak, best_x = ax, p, x
|
|
if best_axis is None:
|
|
return None
|
|
s = channel_shape(best_x)
|
|
if s is None:
|
|
return None
|
|
s["axis"] = best_axis
|
|
return s
|
|
|
|
|
|
# ── Offset (DC-baseline) detection ────────────────────────────────────────────
|
|
# A DC offset is a false trigger where the geophone baseline sits at a constant
|
|
# non-zero floor (sensor bumped / settled / drifted) instead of oscillating
|
|
# around zero. Brian's method (validated in scratch/offset_scan3.py): the
|
|
# pre-trigger window is definitionally quiet, so a true offset shows |pre| off
|
|
# zero AND stays flat across the record (pre ≈ mid ≈ end). A transient moves one
|
|
# third relative to the others and is rejected by the spread test.
|
|
# Thresholds are in in/s (the .h5 samples are already range-scaled); validated at
|
|
# Normal range (10 in/s) — the only range in the fleet.
|
|
OFFSET_FLOOR = 0.025 # |pre| at/above this reads as an off-zero baseline (5 A/D counts)
|
|
OFFSET_MAX_SPREAD = 0.02 # max(pre,mid,end) - min(...) at/below this reads as flat/constant
|
|
|
|
|
|
def _channel_offset(x, pretrig_n):
|
|
"""Return (pre, spread, is_offset) for one channel, or None if unusable."""
|
|
x = np.asarray(x, dtype=float)
|
|
n = x.size
|
|
if n < 3:
|
|
return None
|
|
t = n // 3
|
|
pre = x[:pretrig_n] if (pretrig_n and 0 < pretrig_n < n) else x[:t]
|
|
mid, end = x[t:2 * t], x[2 * t:]
|
|
if pre.size == 0 or mid.size == 0 or end.size == 0:
|
|
return None
|
|
vals = [float(np.median(seg)) for seg in (pre, mid, end)]
|
|
spread = max(vals) - min(vals)
|
|
is_offset = abs(vals[0]) >= OFFSET_FLOOR and spread <= OFFSET_MAX_SPREAD
|
|
return vals[0], spread, is_offset
|
|
|
|
|
|
def offset_from_samples(chans: dict, pretrig_n) -> dict | None:
|
|
"""Detect a DC-offset false trigger across the geophone channels.
|
|
|
|
An event is offset if ANY geo channel's pre-trigger baseline is off zero and
|
|
flat across the record. Reports the tripping axis (or, if none trips, the
|
|
most-offset-like axis) with its ``pre``/``spread`` for transparency + tuning.
|
|
Returns None when no geo channel is usable.
|
|
"""
|
|
results = []
|
|
for ax in _GEO_CHANNELS:
|
|
x = chans.get(ax)
|
|
if x is None:
|
|
continue
|
|
r = _channel_offset(x, pretrig_n)
|
|
if r is not None:
|
|
results.append((ax, r[0], r[1], r[2]))
|
|
if not results:
|
|
return None
|
|
offenders = [r for r in results if r[3]]
|
|
ax, pre, spread, _ = max(offenders or results, key=lambda r: abs(r[1]))
|
|
return {"offset": bool(offenders), "axis": ax,
|
|
"pre": round(pre, 6), "spread": round(spread, 6)}
|
|
|
|
|
|
def shape_from_h5(path) -> dict | None:
|
|
import h5py
|
|
try:
|
|
with h5py.File(path, "r") as f:
|
|
chans = {ax: f[f"samples/{ax}"][:] for ax in _GEO_CHANNELS
|
|
if f"samples/{ax}" in f}
|
|
except Exception:
|
|
return None
|
|
return shape_from_samples(chans)
|
|
|
|
|
|
def offset_from_h5(path) -> dict | None:
|
|
"""offset_from_samples fed from an event's .h5 (float32 in/s geo samples +
|
|
the pretrig_samples attribute)."""
|
|
import h5py
|
|
try:
|
|
with h5py.File(path, "r") as f:
|
|
chans = {ax: f[f"samples/{ax}"][:] for ax in _GEO_CHANNELS
|
|
if f"samples/{ax}" in f}
|
|
pretrig_n = f.attrs.get("pretrig_samples")
|
|
except Exception:
|
|
return None
|
|
pretrig_n = int(pretrig_n) if pretrig_n is not None else 0
|
|
return offset_from_samples(chans, pretrig_n)
|