3 Commits
Author SHA1 Message Date
serversdownandClaude Opus 5 ad84a04404 feat(offset): detector v3 — pre-trigger floor with a constant-floor test
Brian's method, and better than v2's whole-record median: the pre-trigger
window is definitionally quiet (the buffer captured before the trigger fired),
whereas a median is merely robust to the event. Requiring the floor to hold
across pre-trigger / middle / end rejects transients that a median cannot.

    per channel:  pre/mid/end medians, spread = max - min
    offset when   |pre| >= floor AND spread <= 0.02 in/s
    real fault    >= 3 consecutive flagged events on that channel

The empirical noise floor justifies the threshold and validates the decoder:
across 19,244 non-flagged channel-events the pre-trigger floor is 62.7% exactly
0.000, 94.5% within +/-1 quantisation unit, median +0.0000, mean -0.0008. There
is no systematic zero-point bias — an independent confirmation of the
32000-count geo scale.

The result is threshold-insensitive across a 2x range (0.020 to 0.040 in/s),
which is what separates a real signal from a tuned one:

  FINAL: 5 of 45 units (11%) — BE9558, BE11529, BE12599, BE13117, BE18438

BE11007 and BE10895 drop out; the spread test identifies them as transients
rather than pedestals. v1's 11% headline was right by luck — it included
BE11007 and named the wrong channel on most units.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgTe8CamXAHcAmaQ6QNcog
2026-08-28 21:18:33 +00:00
serversdownandClaude Opus 5 1fdc665675 fix(offset): retract the v1 detector — per-channel median, not dominant-axis mean
Brian challenged the v1 finding that offsets "come and go", against field
experience that a unit which develops one stays broken until the geophone is
replaced. He was right; v1 had two flaws, both of which manufactured false
recoveries:

1. It scored only the axis with the largest peak, so a real event on one axis
   hid a persistent pedestal on another. BE12599 on 2026-08-21 read "clean"
   because Long had a 1.065 in/s event, while Tran sat at +0.4732 in/s and was
   never examined.
2. It used the mean, which a real transient perturbs. The median is the resting
   baseline and a blast does not move it. Same event, Long channel:
   mean +0.0783 vs median -0.0050.

offset_scan2.py flags a CHANNEL when |median| >= 0.025 in/s (5 A/D counts,
Instantel's own criterion) and treats >=3 consecutive flagged events as the
real signal. No m/p ratio guard is needed — that existed only to compensate for
the mean.

Corrected results:
  units with any flagged event        6 -> 19 of 45
  units with a sustained pedestal     8 of 45 (18%)
  runs >=3 consecutive                29;  1-2 event runs (noise) 69

Also corrected: the affected channel is most often Vert, not Tran (v1 named
whichever axis had the largest peak, so it was frequently wrong). BE10895 and
BE18003 were invisible to v1. BE12599's fault began 2026-08-14, not 08-17.

The decode itself was never in question and is confirmed against Blastware's
own ASCII export: on K558LJN3.BK0W, BW shows Tran parked at +0.265..+0.375 in/s
for the entire record while Vert and Long sit at ~0.005 — Instantel's "parallel
lines above or below the zero line".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgTe8CamXAHcAmaQ6QNcog
2026-08-28 20:41:20 +00:00
serversdownandClaude Opus 4.8 c8c4ec2b9f fix(sfm): /health reports the real service version, not a stale 0.1.0
terra-view's SFM Admin page (/admin/sfm) displays whatever /health returns for
`version`. That was hardcoded to "0.1.0" and never bumped, so the page showed
0.1.0 while the service was actually 0.26.0. Point both /health and the FastAPI
OpenAPI version at the release-bumped TOOL_VERSION (single source of truth), so
they can't drift again. Adds httpx-free regression tests (call health() directly).

Note: minimateplus.__version__ is separately stale at 0.1.0 — left as-is here
(nothing user-facing reads it; touching the package __init__ risks import order).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
2026-08-28 20:39:51 +00:00
5 changed files with 374 additions and 2 deletions
+151
View File
@@ -1,5 +1,31 @@
# The "offset" fault — investigation journal
> ## ⚠ CORRECTED 2026-08-28 (same day) — the v1 detector was wrong
>
> Brian pushed back on the finding that offsets "come and go": in the field,
> once a unit develops one it stays broken until the geophone is replaced.
> He was right, and the challenge exposed **two real flaws** in the v1 detector:
>
> 1. **It scored only the axis with the largest peak.** A real event on one axis
> hid a persistent pedestal on another. BE12599 on 2026-08-21 read "clean"
> solely because Long had a 1.065 in/s event — Tran was sitting at
> **+0.4732 in/s** at that moment and was never examined.
> 2. **It used the MEAN**, which a real transient perturbs. The **median** is the
> resting baseline — most samples sit at it, so a blast does not move it.
> Same event, Long channel: mean **+0.0783** vs median **-0.0050**.
>
> Both flaws manufactured false recoveries. The corrected detector
> (`scratch/offset_scan2.py`, per-channel median) shows the pedestal is
> **persistent**, exactly as the field experience says. See §2b and §3b.
>
> **Then Brian proposed a better detector still** — measure the floor during
> the *pre-trigger* window, and require it to hold across pre/middle/end.
> That is now the detector of record (§2c). Final answer: **5 of 45 units
> (11%)**, stable across a 2x threshold range.
>
> Sections below that were written against v1 are marked; v1 numbers are kept
> for the reasoning trail, not as current fact.
A running record of the **offset** hardware fault on Instantel Series III
seismographs: a geophone channel whose trace sits displaced from zero rather
than centred on it.
@@ -85,6 +111,119 @@ see offsets large enough to *dominate* the trace. A mild offset on a real blast
is invisible. Instantel's A/D-mode check (§5) is the only thing that sees the
mild end. Our base rate is therefore a **gross-offset** rate.
### 2b. Detector v2 — per-channel median (CURRENT)
`scratch/offset_scan2.py`. Supersedes the above.
```
for each series-3 waveform binary:
for each geo channel independently:
pedestal = median(samples) # resting baseline, robust to blasts
flag the CHANNEL when |pedestal| >= 0.025 in/s (5 A/D counts)
a unit has a real fault when a channel is flagged on >=3 CONSECUTIVE events
```
Why median: a DC pedestal shifts every sample, so it moves the median. A real
event moves only a minority of samples, so it does not. This removes the need
for the `m/p` ratio guard entirely — that guard existed only to compensate for
using the mean.
Why per-channel: the fault is on one geophone axis. Scoring only the dominant
axis means any event with motion elsewhere hides it.
Why "3 consecutive": the 0.025 in/s floor is only ~2x a healthy channel's
resting median (observed 0.010-0.015), so isolated flags are noise. Persistence
is the discriminator — and it is what the field experience predicts.
---
## 3b. Archive results, corrected (v2)
| | v1 (dominant axis, mean) | **v2 (per-channel median)** |
|---|---|---|
| units with any flagged event | 6 of 45 | 19 of 45 |
| **units with a sustained pedestal (>=3 consecutive)** | — | **8 of 45 (18%)** |
| runs of >=3 consecutive | — | 29 |
| runs of 1-2 events (noise) | — | 69 |
Units with a sustained pedestal: **BE9558, BE10895, BE11007, BE11529, BE12599,
BE13117, BE18003, BE18438**. BE10895 and BE18003 were invisible to v1.
**The affected channel is most often Vert**, which v1 got wrong — it named
whichever axis had the largest peak. BE13117 and BE18438 are both Vert faults.
Longest / clearest runs:
| unit | ch | span | events | median in/s |
|---|---|---|---|---|
| BE13117 | Vert | 2023-05-03 → 05-04 | 194 | 0.035 → **1.915** |
| BE18438 | Vert | 2026-02-25 → 02-26 | 75 | 0.180 → 0.370 |
| BE9558 | Vert | 2020-02-11 (6 h) | 33 | 0.065 → 0.090 |
| BE12599 | Tran | 2026-08-14 → 08-23 | 8 | 0.030 → **0.565** |
| BE18003 | Vert | 2021-03-17 → 06-11 | 3 | 0.040 → 0.060 |
BE12599 began **2026-08-14**, not 08-17 as v1 reported, and was still faulting
at the last event in the archive.
### 2c. Detector v3 — PRE-TRIGGER floor + constant-floor test (CURRENT)
`scratch/offset_scan3.py`. Brian's method, and better than v2 for a reason
worth naming: **the pre-trigger window is definitionally quiet** — it is the
buffer captured before the trigger fired — whereas a whole-record median is
merely *robust* to the event. `pretrig_samples` comes from the STRT record.
```
per channel:
pre = median of the first pretrig_samples samples
mid = median of the middle third
end = median of the final third
spread = max(pre,mid,end) - min(pre,mid,end)
offset when |pre| >= floor AND spread <= 0.02 in/s
real fault when a channel is flagged on >=3 CONSECUTIVE events
```
A DC offset is a **constant floor** — present before the trigger, during, and
after. The spread test rejects transients (settling, handling, a long event
tail) that move one segment relative to the others, which is what v2's
whole-record median could not do.
**The empirical noise floor justifies the threshold.** Across 19,244
non-flagged channel-events the pre-trigger floor distributes as:
| floor | share |
|---|---|
| −1 unit (−0.005) | 18.4% |
| **0.000** | **62.7%** |
| +1 unit (+0.005) | 13.4% |
**94.5% within ±1 quantisation unit; median exactly +0.0000, mean −0.0008.**
So there is **no systematic zero-point bias in the decoder** — an independent
confirmation of the 32000-count scale. A healthy channel really does read
0.000, and "any constant floor that is not 0.000" is the right signal, with
±1 unit of slack for quantisation.
**The result is threshold-insensitive**, which is what distinguishes a real
signal from a tuned one:
| floor | units flagged | sustained units |
|---|---|---|
| 2 units (0.010) | 34 | 15 ← into the noise |
| 3 units (0.015) | 26 | 8 |
| **4 units (0.020)** | 17 | **5** |
| **5 units (0.025)** — Instantel's | 12 | **5** |
| **8 units (0.040)** | 8 | **5** |
### FINAL RESULT: 5 of 45 units (11%)
**BE9558, BE11529, BE12599, BE13117, BE18438.**
Unchanged across a 2x threshold range. BE11007 and BE10895 drop out — the
spread test identifies them as transients, not pedestals.
The 11% headline happens to match v1's, but the reasoning and the unit list
differ: v1 included BE11007 and named the wrong *channel* on most units.
---
## 3. Archive results (2026-08-28)
@@ -253,6 +392,16 @@ swing test measures geophone frequency response and damping — it never examine
DC zero. **A grossly offset unit passes its own self-check.** This is why the
fault goes unnoticed until somebody looks at waveforms.
### "Offsets are transient / come and go on their own" — RETRACTED 2026-08-28
v1 reported episodes lasting hours that ended spontaneously. **This was an
artifact of the v1 detector** (see the banner at the top). With the per-channel
median, the pedestal persists. Every clear case reads clean again only after a
multi-day-to-multi-month gap consistent with service: BE13117 6 days, BE18438
24 days, BE9558 63 days **with a confirmed Instantel calibration inside the
gap**. BE12599 never reads clean — it is still faulting at the end of the
archive. This matches the operational experience: once a unit develops an
offset it stays broken until the geophone is replaced.
### "Offsets develop N months after calibration" — CONFOUNDED, NOT A FINDING
Tempting, and it looked strong:
@@ -361,3 +510,5 @@ doubled two reported figures before it was caught.
| 2026-08-28 | Calibration-timing correlation attempted and **rejected as confounded**. |
| 2026-08-28 | Instantel FAQs supplied: autozero procedure, the **2027–2069** window, the **>5 counts** threshold. Explains the ~10% re-zero success rate. |
| 2026-08-28 | Bimodality established; sensor check proven **blind** to offsets; `SUB 0x0E` identified as the best open lead. |
| 2026-08-28 | **v1 detector retracted.** Brian challenged the "come and go" finding against field experience. Two flaws found: dominant-axis-only scoring and mean-instead-of-median. Corrected detector shows persistent pedestals on **8 of 45 units**, and the gaps are service windows. |
| 2026-08-28 | **Detector v3 (Brian's method):** pre-trigger floor + pre/mid/end consistency. Healthy channels proven to sit at 0.000 +/-1 unit (94.5%), confirming no decoder zero-point bias. Final: **5 of 45 units (11%)**, threshold-insensitive. |
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Offset detector v2 — per-channel MEDIAN pedestal.
Supersedes the dominant-axis / mean detector in offset_scan.py, which had two
flaws that manufactured false "recoveries":
1. It scored only the axis with the largest peak, so a real event on one axis
hid a persistent pedestal on another. BE12599 2026-08-21 read "clean"
because Long had a 1.065 in/s event, while Tran sat at +0.47 in/s.
2. It used the MEAN, which a real transient perturbs. The median is the
resting baseline: most samples sit at it, so a blast does not move it.
Same event, Long: mean +0.0783 vs median -0.0050.
Flags a CHANNEL when |median| >= --floor in/s (default 0.025 = 5 A/D counts,
Instantel's own criterion; 1 A/D count = 0.005 in/s).
Emits one row per (event, channel) so persistence can be tracked per channel.
"""
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 # ADC counts -> in/s at the 10 in/s range
_WAVE_RE = re.compile(r"\.[A-Za-z0-9]{2}0[Ww]$")
_STEM_RE = re.compile(r"^([B-Z])(\d{3})")
def serial_from_name(n):
m = _STEM_RE.match(n)
return f"BE{(ord(m.group(1))-ord('B'))*1000+int(m.group(2))}" if m else "?"
def scan_one(ps):
p = Path(ps)
try:
ev = read_blastware_file(p)
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 ""
out = []
for ch in GEO:
a = s[ch]
out.append({
"serial": serial_from_name(p.name), "timestamp": stamp,
"filename": p.name, "channel": ch,
"median_ips": round(statistics.median(a) * K, 4),
"mean_ips": round(statistics.fmean(a) * K, 4),
"peak_ips": round(max(abs(v) for v in a) * K, 4),
})
return out
except Exception:
return None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dir", required=True)
ap.add_argument("--jobs", type=int, default=4)
ap.add_argument("--floor", type=float, default=0.025)
ap.add_argument("--out", required=True)
a = ap.parse_args()
seen, files = set(), []
for q in sorted(Path(a.dir).rglob("*")):
if q.is_file() and _WAVE_RE.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 n, f in enumerate(as_completed([ex.submit(scan_one, p) for p in files]), 1):
r = f.result()
if r: rows.extend(r)
if n % 2000 == 0: print(f" {n}/{len(files)}", flush=True)
for r in rows:
r["offset"] = int(abs(r["median_ips"]) >= a.floor)
cols = ["serial","timestamp","filename","channel","median_ips","mean_ips","peak_ips","offset"]
with open(a.out, "w", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=cols); w.writeheader(); w.writerows(rows)
from collections import defaultdict
ev_flagged = {(r["serial"], r["filename"]) for r in rows if r["offset"]}
ev_all = {(r["serial"], r["filename"]) for r in rows}
per = defaultdict(set)
for r in rows:
if r["offset"]: per[r["serial"]].add(r["filename"])
tot = defaultdict(set)
for r in rows: tot[r["serial"]].add(r["filename"])
print(f"\nfloor = {a.floor} in/s ({a.floor/0.005:.0f} A/D counts)")
print(f"events with >=1 offset channel: {len(ev_flagged)} of {len(ev_all)}")
print(f"units affected: {len(per)} of {len(tot)}")
for s in sorted(per, key=lambda s: -len(per[s])):
print(f" {s:9} {len(per[s]):4} / {len(tot[s]):4} events")
print(f"\nwrote {a.out}")
if __name__ == "__main__":
main()
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Offset detector v3 — pre-trigger floor, with pre/mid/end consistency.
Brian's method, and better than v2's whole-record median for one reason: the
pre-trigger window is *definitionally* quiet (it is the buffer captured before
the trigger fired), whereas a whole-record median is merely robust to the event.
Per channel:
pre = median of the first `pretrig_samples` samples (STRT record)
mid = median of the middle third
end = median of the final third
spread = max(pre,mid,end) - min(pre,mid,end)
A DC offset is a *constant floor*: |pre| at or above the floor AND a small
spread. A transient (settling, handling, a long-tailed event) moves one segment
relative to the others and is rejected by the spread test.
Floor default 0.025 in/s = 5 A/D counts (Instantel's own criterion; 1 count =
0.005 in/s). Quantisation is 0.005 in/s, so `spread` is measured in units of it.
"""
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})")
def serial_of(n):
m=_STEM.match(n)
return f"BE{(ord(m.group(1))-ord('B'))*1000+int(m.group(2))}" if m else "?"
def scan(ps):
p=Path(ps)
try:
ev=read_blastware_file(p); s=ev.raw_samples or {}
if not all(s.get(c) for c in GEO): return None
pre_n=ev.pretrig_samples
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 ""
out=[]
for ch in GEO:
a=s[ch]; n=len(a); t=n//3
pre = a[:pre_n] if (pre_n and 0 < pre_n < n) else a[:t]
mid, end = a[t:2*t], a[2*t:]
if not pre or not mid or not end: continue
v=[statistics.median(x)*K for x in (pre,mid,end)]
out.append({"serial":serial_of(p.name),"timestamp":stamp,
"filename":p.name,"channel":ch,
"pretrig_n": pre_n or 0,
"pre":round(v[0],4),"mid":round(v[1],4),"end":round(v[2],4),
"spread":round(max(v)-min(v),4),
"peak":round(max(abs(x) for x in a)*K,4)})
return out
except Exception:
return None
def main():
ap=argparse.ArgumentParser()
ap.add_argument("--dir",required=True); ap.add_argument("--jobs",type=int,default=4)
ap.add_argument("--floor",type=float,default=0.025)
ap.add_argument("--max-spread",type=float,default=0.02)
ap.add_argument("--out",required=True)
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%2000==0: print(f" {i}/{len(files)}",flush=True)
for r in rows:
r["offset"]=int(abs(r["pre"])>=a.floor and r["spread"]<=a.max_spread)
cols=["serial","timestamp","filename","channel","pretrig_n","pre","mid","end","spread","peak","offset"]
with open(a.out,"w",newline="") as fh:
w=csv.DictWriter(fh,fieldnames=cols); w.writeheader(); w.writerows(rows)
from collections import defaultdict
per=defaultdict(set); tot=defaultdict(set)
for r in rows:
tot[r["serial"]].add(r["filename"])
if r["offset"]: per[r["serial"]].add(r["filename"])
print(f"\nfloor={a.floor} in/s ({a.floor/0.005:.0f} counts) max spread={a.max_spread}")
print(f"units affected: {len(per)} of {len(tot)}")
for s in sorted(per,key=lambda s:-len(per[s])):
print(f" {s:9} {len(per[s]):4} / {len(tot[s]):4} events")
print(f"\nwrote {a.out}")
if __name__=="__main__": main()
+3 -2
View File
@@ -67,6 +67,7 @@ from minimateplus.blastware_file import write_blastware_file, blastware_filename
from minimateplus.client import _decode_a5_metadata_into, _decode_a5_waveform, _decode_event_count
from minimateplus.framing import build_bw_write_frame, SESSION_RESET, POLL_PROBE, POLL_DATA
from minimateplus.protocol import SUB_STOP_MONITORING
from minimateplus.event_file_io import TOOL_VERSION as SFM_VERSION # single source for the service version (release-bumped)
from sfm import event_hdf5
from sfm.cache import SFMCache, get_cache
from sfm.database import SeismoDb
@@ -90,7 +91,7 @@ app = FastAPI(
"Implements the minimateplus RS-232 protocol library.\n"
"Proxied by terra-view at /api/sfm/*."
),
version="0.26.0",
version=SFM_VERSION,
)
# Allow requests from the waveform viewer opened as a local file (file://)
@@ -371,7 +372,7 @@ def _backfill_events(events: list, info: "DeviceInfo") -> None:
@app.get("/health")
def health() -> dict:
"""Service heartbeat. No device I/O."""
return {"status": "ok", "service": "sfm", "version": "0.1.0"}
return {"status": "ok", "service": "sfm", "version": SFM_VERSION}
@app.get("/", response_class=FileResponse)
+17
View File
@@ -0,0 +1,17 @@
"""The /health version must track the release, not a stale literal.
terra-view's SFM Admin page displays whatever `/health` reports. It was
hardcoded to "0.1.0" and never bumped, so the page showed 0.1.0 while the
service was actually 0.26.0. These guard against that regression — and run
without httpx (they call the endpoint function directly, no TestClient).
"""
from minimateplus.event_file_io import TOOL_VERSION
from sfm.server import app, health
def test_health_reports_current_tool_version():
assert health()["version"] == TOOL_VERSION
def test_openapi_version_matches_tool_version():
assert app.version == TOOL_VERSION