feat(offset): the non-motion test, and BE12599 diagnosed as a connector
Brian noticed BE12599's 2026-08-09 event reports no ZC frequency because the trace never crosses zero. That is the best detector in this investigation. A geophone has no DC response, so its output must integrate to ~zero over a record. |mean|/peak is therefore ~0 for real motion and ~1 for anything electrical. Across 12,068 channel-events with peak >= 0.05 in/s the statistic is bimodal with a 1.09% dead zone, and at mp >= 0.8 it returns exactly the five confirmed units -- from physics rather than a tuned threshold. Two detectors on different principles agreeing is the strongest corroboration the list has had. It also settles BE11007 as NOT an offset: mp 0.75-0.89 but frac_neg 0.99 at peaks of 7.4-9.4 in/s, i.e. a one-sided near-full-scale blast. Journal 8e diagnoses BE12599 specifically. Its August waveforms are unipolar impulses with an RC tail (26 ms -> 118 ms -> never recovers over 14 days), and the fault MOVES between Long and Tran while the sensor self-check passes on every event. A failing element cannot hop channels; a connector can -- which also explains why the swing test never fails and why an autozero rarely helps. Corrects 8c's claim that the spread gate is blind to onsets: of 87 BE18438|Vert events it rejected one, the transitional record. Narrower than stated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgTe8CamXAHcAmaQ6QNcog
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Detect NON-MOTION on a geophone channel: |mean| / peak.
|
||||
|
||||
A geophone is a velocity sensor with no DC response, so its output over a
|
||||
record must integrate to ~zero — the ground does not relocate. Real motion
|
||||
therefore sits roughly half above and half below zero. Anything electrical —
|
||||
a charge-injection spike, a step, a parked pedestal — is one-sided.
|
||||
|
||||
mp = |mean| / peak ~0 for motion, ~1 for a pedestal
|
||||
frac_neg = share of samples < 0 ~0.3-0.5 for motion, ~0 for a fault
|
||||
|
||||
Why this beats the pre-trigger floor (`offset_scan3.py`): that detector's
|
||||
`spread <= 0.02` gate rejects any record whose floor is MOVING, which is
|
||||
exactly what an onset is — it discarded the one BE18438 record in which the
|
||||
ramp was visible. This test is indifferent to whether the fault is a spike,
|
||||
a ramp or a flat pedestal; none of them cross zero.
|
||||
|
||||
⚠ Not a rediscovery of the retracted v1 detector. v1 scored only the
|
||||
largest-peak axis and used the mean as a BASELINE estimator, where the median
|
||||
was required. Here the mean is the signal itself, per channel, and that is
|
||||
what the physics licenses.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse, csv, re, statistics, sys
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from minimateplus.event_file_io import read_blastware_file
|
||||
|
||||
GEO=("Tran","Vert","Long"); K=10.0/32000.0
|
||||
_WAVE=re.compile(r"\.[A-Za-z0-9]{2}0[Ww]$"); _STEM=re.compile(r"^([B-Z])(\d{3})")
|
||||
_SER=re.compile(rb"[A-Z]{2}\d{3,6}")
|
||||
|
||||
def serial_of(name, path=None):
|
||||
m=_STEM.match(name)
|
||||
if not m: return "?"
|
||||
num=(ord(m.group(1))-ord("B"))*1000+int(m.group(2))
|
||||
if path is not None:
|
||||
try:
|
||||
for s in _SER.findall(Path(path).read_bytes()):
|
||||
s=s.decode()
|
||||
if s[2:].lstrip("0")==str(num): return s
|
||||
except Exception: pass
|
||||
return f"BE{num}"
|
||||
|
||||
def scan(ps):
|
||||
import logging; logging.disable(logging.WARNING)
|
||||
p=Path(ps)
|
||||
try: ev=read_blastware_file(p)
|
||||
except Exception: return None
|
||||
s=ev.raw_samples or {}
|
||||
if not all(s.get(c) for c in GEO): return None
|
||||
ts=ev.timestamp
|
||||
stamp=(f"{ts.year:04d}-{ts.month:02d}-{ts.day:02d}T"
|
||||
f"{ts.hour:02d}:{ts.minute:02d}:{ts.second:02d}") if ts else ""
|
||||
ser=serial_of(p.name,p); out=[]
|
||||
for ch in GEO:
|
||||
a=[x*K for x in s[ch]]
|
||||
pk=max(abs(x) for x in a)
|
||||
if pk<=0: continue
|
||||
out.append({"serial":ser,"timestamp":stamp,"filename":p.name,"channel":ch,
|
||||
"peak":round(pk,4),
|
||||
"mean":round(statistics.fmean(a),4),
|
||||
"mp":round(abs(statistics.fmean(a))/pk,4),
|
||||
"frac_neg":round(sum(1 for x in a if x<0)/len(a),4),
|
||||
"n":len(a)})
|
||||
return out
|
||||
|
||||
COLS=["serial","timestamp","filename","channel","peak","mean","mp","frac_neg","n"]
|
||||
|
||||
def main():
|
||||
ap=argparse.ArgumentParser()
|
||||
ap.add_argument("--dir",required=True); ap.add_argument("--out",required=True)
|
||||
ap.add_argument("--jobs",type=int,default=4)
|
||||
a=ap.parse_args()
|
||||
seen=set(); files=[]
|
||||
for q in sorted(Path(a.dir).rglob("*")):
|
||||
if q.is_file() and _WAVE.search(q.name) and q.name not in seen:
|
||||
seen.add(q.name); files.append(str(q))
|
||||
print(f"unique waveform binaries: {len(files)}",flush=True)
|
||||
rows=[]
|
||||
with ProcessPoolExecutor(max_workers=a.jobs) as ex:
|
||||
for i,f in enumerate(as_completed([ex.submit(scan,p) for p in files]),1):
|
||||
r=f.result()
|
||||
if r: rows.extend(r)
|
||||
if i%1000==0: print(f" {i}/{len(files)}",flush=True)
|
||||
with open(a.out,"w",newline="") as fh:
|
||||
w=csv.DictWriter(fh,fieldnames=COLS); w.writeheader(); w.writerows(rows)
|
||||
print(f"\nwrote {a.out} ({len(rows)} channel-rows)")
|
||||
|
||||
if __name__=="__main__": main()
|
||||
Reference in New Issue
Block a user