#!/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()