Compare commits
11
Commits
5f1ee5ba91
...
v0.27.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d0d12079b | ||
|
|
8e808b09d4 | ||
|
|
ad84a04404 | ||
|
|
1fdc665675 | ||
|
|
c8c4ec2b9f | ||
|
|
5203aab849 | ||
|
|
5b65718b72 | ||
|
|
483762607e | ||
|
|
d0b66368d5 | ||
|
|
2eb1d25028 | ||
|
|
cc821f9ee3 |
@@ -6,6 +6,16 @@ All notable changes to seismo-relay are documented here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## v0.27.0 — 2026-08-28
|
||||
|
||||
**Per-sample decoder verification at scale, plus the offset investigation.**
|
||||
The series-3 codec is now verified sample-by-sample against **14,338** preserved
|
||||
Blastware ASCII exports — 1,249 waveform and 13,089 histogram, spanning 45 units
|
||||
and files back to 2018. That is 11x the ground truth the production store
|
||||
carried, and it found one real codec bug (below).
|
||||
|
||||
### Fixed
|
||||
- **Sub-minute histograms with a partial final block decoded to nothing**
|
||||
(`histogram_codec.detect_multi_interval_stride`). The stride search confirmed
|
||||
@@ -43,6 +53,37 @@ All notable changes to seismo-relay are documented here.
|
||||
same-serial histogram). `window_seconds` is retained but ignored. Fixes terra-view #102
|
||||
sub-task 2.
|
||||
|
||||
- **`/health` reported a hard-coded `0.1.0`** instead of the real service version.
|
||||
`sfm/server.py` now derives its version from `minimateplus.event_file_io.TOOL_VERSION`,
|
||||
making that constant the single source of truth for the service version and the
|
||||
sidecar stamp alike — one place to bump at release.
|
||||
|
||||
- **`CLAUDE.md` had 793 NUL bytes appended** after its last line, which made `grep`
|
||||
treat the file as binary and silently skip it. Present since at least v0.21.0.
|
||||
Stripped.
|
||||
|
||||
### Added
|
||||
- **`docs/offset_investigation.md`** — a dated journal of the "offset" hardware
|
||||
fault: base rate, detector design, per-unit case files, ruled-out hypotheses
|
||||
(each kept with the evidence that killed it), and Instantel's own autozero
|
||||
procedure with its 2027–2069 acceptance window.
|
||||
- **`scratch/verify_against_ascii.py`** — decodes a corpus of BW binaries and
|
||||
diffs every sample against the paired `_ASCII.TXT`. Includes a saturation
|
||||
carve-out: BW clamps clipped events to the range maximum and writes `OORANGE`,
|
||||
while the decoder faithfully reports counts past nominal full scale.
|
||||
- **`scratch/offset_scan3.py`** — offset detector. Measures the resting floor in
|
||||
the *pre-trigger* window (definitionally quiet) and requires it to hold across
|
||||
pre / middle / end. Result: **5 of 45 units (11%)**, stable across a 2x
|
||||
threshold range. Supersedes `offset_scan.py` and `offset_scan2.py`, both kept
|
||||
as the reasoning trail.
|
||||
|
||||
### Verified
|
||||
- **19,244 healthy channel-events sit at a pre-trigger floor of exactly 0.000
|
||||
(62.7%), 94.5% within ±1 quantisation unit, median +0.0000.** No systematic
|
||||
zero-point bias in the decoder — an independent confirmation of the
|
||||
32000-count geo full scale, arrived at from a different direction than the
|
||||
ASCII sample comparisons.
|
||||
|
||||
---
|
||||
|
||||
## v0.26.0 — 2026-08-27
|
||||
|
||||
@@ -2,21 +2,24 @@
|
||||
|
||||
Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for
|
||||
managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem
|
||||
(Sierra Wireless RV50 / RV55). Current version: **v0.26.0**.
|
||||
(Sierra Wireless RV50 / RV55). Current version: **v0.27.0**.
|
||||
|
||||
---
|
||||
|
||||
## Where things stand (updated 2026-08-27)
|
||||
## Where things stand (updated 2026-08-28)
|
||||
|
||||
Read this first when picking the project back up.
|
||||
|
||||
- **Series-3 decode is correct and verified.** All 11,603 series-3 binaries in
|
||||
the prod snapshot pass every check (channel lengths, peaks vs the device's
|
||||
own reported PPV, nothing above full scale, length vs declared record time).
|
||||
Ground truth: 1,211/1,211 histograms exact per-interval and 75/75 waveform
|
||||
sample counts exact against preserved Blastware ASCII exports.
|
||||
⚠ That is per-sample proof on 11% of files and peak-only consistency on the
|
||||
other 89% — see `docs/instantel_protocol_reference.md` §7.6.1.
|
||||
- **Series-3 decode is verified per-sample at scale (v0.27.0).** The full DL2
|
||||
archive decodes **14,338 / 14,338** paired files exactly against their
|
||||
preserved Blastware ASCII exports — 1,249 waveform + 13,089 histogram, 45
|
||||
units, files back to 2018. That is 11x the ground truth the prod store
|
||||
carried, and it supersedes the old "per-sample on 11%, peak-only on 89%"
|
||||
caveat. Harness: `scratch/verify_against_ascii.py` (note its saturation
|
||||
carve-out — BW clamps clipped events, the decoder reports true counts).
|
||||
Independent corroboration of the 32000-count scale: 19,244 healthy
|
||||
channel-events sit at a pre-trigger floor of exactly 0.000 (62.7%), 94.5%
|
||||
within ±1 quantisation unit, median +0.0000 — no zero-point bias.
|
||||
- **Series-4 (Thor / Micromate) is NOT verified.** UM-series sits at ~48%
|
||||
against device peaks with a ~1.7% systematic bias and a near-zero tail.
|
||||
Thor IDFW is pinned to `decode_waveform_legacy` deliberately.
|
||||
@@ -24,14 +27,23 @@ Read this first when picking the project back up.
|
||||
(= 10.0/1.25) units discrepancy; `scripts/backfill_sidecars.py --force` also
|
||||
inserts DB rows for store files that have none (one-time per store) and the
|
||||
dry-run does not report that count.
|
||||
- **After any codec change, regenerate the store** — `backfill_sidecars.py
|
||||
--force` then `backfill_event_shape.py`, DB backup first. Stored `.h5` files
|
||||
do not update themselves.
|
||||
- **After any codec change, regenerate the store** — `backfill_sidecars.py`
|
||||
then `backfill_event_shape.py`, DB backup first. Stored `.h5` files do not
|
||||
update themselves. No `--force` needed as long as `TOOL_VERSION` was bumped
|
||||
(it gates regeneration). ⚠ On the office NAS this takes **~2 hours**
|
||||
(~1.5 files/sec vs 85/sec on the dev box — gzip-4 in `sfm/event_hdf5.py`
|
||||
against a Synology CPU). Budget it up front.
|
||||
**v0.27.0 owes prod a backfill:** the partial-final-block fix recovers 4
|
||||
histograms that are still empty in the store.
|
||||
- **The "offset" hardware fault has its own journal** --
|
||||
`docs/offset_investigation.md`. Base rate settled at 5-6 of 45 units
|
||||
(11-13%) across the DL2 archive; Instantel's own autozero procedure and
|
||||
its 2027-2069 go/no-go window are recorded there. Best open lead is
|
||||
`SUB 0x0E` (unimplemented), which may carry those numbers.
|
||||
`docs/offset_investigation.md`. **5 of 45 units (11%)**, and the fault is
|
||||
**persistent** — it stays until the geophone is serviced. Detect it with
|
||||
`scratch/offset_scan3.py`: the resting floor in the **pre-trigger** window,
|
||||
required to hold across pre/middle/end. Never score only the dominant-peak
|
||||
axis and never use the mean — both produce false recoveries (see the
|
||||
retraction banner in the journal). Instantel's autozero procedure and its
|
||||
2027-2069 acceptance window are recorded there too. Best open lead is
|
||||
`SUB 0x0E` (unimplemented), which may carry those very numbers.
|
||||
|
||||
|
||||
When new information about the protocol is discovered, please update the instantel_protocol_reference.md with the findings in addition to this document
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# seismo-relay `v0.26.0`
|
||||
# seismo-relay `v0.27.0`
|
||||
|
||||
A ground-up replacement for **Blastware** — Instantel's aging Windows-only
|
||||
software for managing seismographs. Supports both the **MiniMate Plus
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -50,7 +50,7 @@ SIDECAR_KIND = "sfm.event"
|
||||
# bumped without a `pip install` re-run — leading to confusing stale
|
||||
# version stamps in sidecars. Bump this constant and CHANGELOG.md
|
||||
# together at release time.
|
||||
TOOL_VERSION = "0.26.0"
|
||||
TOOL_VERSION = "0.27.0"
|
||||
|
||||
try:
|
||||
# Best-effort: prefer the installed metadata when it's NEWER than the
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "seismo-relay"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
description = "Python client and REST server for MiniMate Plus seismographs"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user