Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a96a34b02 | ||
|
|
154186a6cd | ||
|
|
1765b3300d | ||
|
|
1e76d08b37 | ||
|
|
a84a46e9d4 |
@@ -6,6 +6,22 @@ All notable changes to seismo-relay are documented here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Waveform event times were the monitoring-session start, not the trigger
|
||||
(~hours off).** `read_blastware_file` stamped events with footer `ts1`, which
|
||||
for a waveform is the session start a unit shares across every event that day
|
||||
(a unit arming at 06:00 stamped 06:00 on all of them — the modal and PDF both
|
||||
showed it, since it's the stored value). The event time is footer `ts2` (the
|
||||
recording stop), and Blastware's trigger = `ts2 - record time`. The record
|
||||
time is a big-endian float32 in the recording-setup config block (30 bytes
|
||||
before the `Standard Recording Setup` marker), so the **exact trigger is now
|
||||
recovered from the binary alone** — all 7 BE12844 oracle events decode to
|
||||
their exact Blastware time (e.g. N844LQHB 10:33:29), no paired `.TXT` needed.
|
||||
Histograms keep `ts1` (the ~24 h window start). A paired report's
|
||||
`event_datetime` stays authoritative (unit-clock drift).
|
||||
⚠ **Needs a re-decode backfill** to correct existing stored events' timestamps.
|
||||
|
||||
### Added
|
||||
|
||||
- **Diagnostics tab in the SFM standalone webapp.** Surfaces the device
|
||||
|
||||
@@ -934,6 +934,120 @@ which looks like a different failure mode wearing the same signature.
|
||||
|
||||
---
|
||||
|
||||
### 8f. BE12599 — CONFIRMED on the bench: the geophone assembly, not the recorder (2026-09-23)
|
||||
|
||||
**The first mechanism in this investigation established physically rather than
|
||||
inferred.** Brian pulled BE12599 from I-80 Abut 1 West on 2026-09-21 and bench-
|
||||
tested it. A geophone swap settles it.
|
||||
|
||||
#### The swap test
|
||||
|
||||
Same recorder, twenty minutes apart, sensor self-check:
|
||||
|
||||
| geophone fitted | Tran | **Vert** | Long |
|
||||
|---|---|---|---|
|
||||
| BE12599's own (2026-09-22 21:10) | 4.2 | **2026.0** | 4.8 |
|
||||
| BE9888's known-good (2026-09-22 22:48) | 3.6 | **3.4** | 3.6 |
|
||||
|
||||
Vert frequency likewise **2.2 Hz -> 7.4 Hz**. Fleet reference: overswing 3.3–5.1
|
||||
(median 3.8, max 5.1 across 42,979 passing records), frequency 7.4–7.8 Hz.
|
||||
|
||||
With the good geophone the unit shows **no offsets, no self-triggering, all three
|
||||
channels Passed**, and records a clean damped impulse with broadband 5–100 Hz
|
||||
content. **BE12599 the recorder is functional** — it is only out of calibration
|
||||
(due 2025-08-12).
|
||||
|
||||
**The fault is in the geophone assembly: element, cable, or its connector.**
|
||||
The swap does not separate those three, because the cable is permanently glanded
|
||||
into the geophone.
|
||||
|
||||
#### The bench evidence, with ground motion removed
|
||||
|
||||
17 events recorded on a workbench with nothing touching the sensor.
|
||||
**The control** — `N599LRY0.4B0`, trigger forced by slapping the *microphone*,
|
||||
geophone untouched:
|
||||
|
||||
| channel | peak in/s | frac < 0 | mean/peak |
|
||||
|---|---|---|---|
|
||||
| Tran | 0.070 | **0.00** | 0.87 |
|
||||
| **Vert** | **0.190** | **0.35** | 0.13 |
|
||||
| Long | 0.070 | **0.00** | 0.84 |
|
||||
|
||||
Vert produces 0.19 in/s of bipolar wander with **no mechanical input at all**.
|
||||
The fault is *continuous*, not episodic — every field event was simply the
|
||||
moment it crossed the trigger.
|
||||
|
||||
**All three channels are affected**, consistently across all 17 events:
|
||||
Tran and Long parked at a small positive DC (`frac<0 = 0.00`, mp 0.64–0.95);
|
||||
Vert wandering at 0.89–1.78 Hz, peaks 0.14–0.54. Yet the swing test calls Tran
|
||||
and Long **Passed** (4.2 / 4.8). So their *coils* are healthy and merely sitting
|
||||
on an offset, while Vert's circuit has lost damping entirely.
|
||||
|
||||
That reads as **leakage on several contacts plus one gone open** — which is what
|
||||
progressive corrosion at a shared connection does. Three independent geophone
|
||||
elements do not fail in sequence.
|
||||
|
||||
**It is still progressing.** Tran's DC offset was ~0.011 in/s on 2026-09-14 and
|
||||
~0.064 on 2026-09-22 — 6x in eight days. Vert's overswing went 7.5 -> 2026.0 over
|
||||
the same window.
|
||||
|
||||
#### ⚠ This explains the autozero's 10% success rate
|
||||
|
||||
The journal has framed this fault around the **recorder** throughout: Instantel's
|
||||
autozero, the X1/X8 gains, the 2027–2069 window, `SUB 0x0E` as the best lead.
|
||||
All of that adjusts the recorder's zero reference.
|
||||
|
||||
**An autozero cannot fix a geophone.** If most offsets are geophone-assembly
|
||||
faults, a re-zero would succeed only on the minority where the recorder really is
|
||||
at fault — which is exactly the ~10% Brian's father reported and which has sat
|
||||
unexplained in §5 since 2026-08-28.
|
||||
|
||||
**Procedure change: swap the geophone first.** It is a five-minute field test
|
||||
costing nothing. On this unit it would have answered the question in August.
|
||||
|
||||
⚠ Scoped to one unit. The other four confirmed units are the obvious check.
|
||||
|
||||
#### Correction to §8e
|
||||
|
||||
§8e is titled "a connector, not a geophone" and concluded the element was fine
|
||||
because the fault hopped channels. The channel-hopping observation stands and the
|
||||
swap test supports the general shape, but **that title overstated what was known**:
|
||||
the swap narrows the fault to the *assembly*, and element / cable / connector are
|
||||
still open within it. §8e's reasoning was sound; its confidence was not.
|
||||
|
||||
#### Cause — road salt now outranks the mice
|
||||
|
||||
The enclosure held a mouse nest on the battery ([[§8e]]), and that framed the
|
||||
2026-09-22 analysis. But **the geophone is buried in the ground, not in the
|
||||
enclosure** — they are two independent contamination paths, and the fault is in
|
||||
the buried half.
|
||||
|
||||
The unit sat **~6 feet from I-80 westbound**. Winter road salt spray and highway
|
||||
runoff put a buried geophone in chloride-rich water for months a year. Chloride
|
||||
drives exactly this failure: corrosion at terminations, leakage across
|
||||
insulation, eventually an open circuit.
|
||||
|
||||
The mice remain a real finding for the *enclosure* and the recorder end. They are
|
||||
probably not the cause of this fault.
|
||||
|
||||
**Testable consequence:** road salt is a property of the **site**, not the unit.
|
||||
Units deployed near salted highways should carry higher geophone-assembly failure
|
||||
rates than units on quiet sites. Nothing in the archive records proximity to a
|
||||
road, so this needs the deployment records rather than the event data.
|
||||
|
||||
#### Open — to be settled by opening the geophone
|
||||
|
||||
1. **Meter at the geophone plug** before disassembly: three coil pairs, and each
|
||||
to shell. Prediction: **Vert open or very high** (that is the 2026 overswing);
|
||||
Tran and Long matched and normal; **insulation to shell finite on all three**,
|
||||
Vert worst (that is the leakage putting DC on healthy coils).
|
||||
2. **Then open it.** Look for a water line, white/green corrosion at the coil
|
||||
terminations, the state of the damping shunt, and the cable gland from the
|
||||
inside. Measure the coils again **at the internal terminals** — if Vert reads
|
||||
fine there, the fault is in the internal wiring or the gland, not the element.
|
||||
|
||||
---
|
||||
|
||||
### ⚠ Serial prefixes — four of these units are BlastMates, not MiniMates
|
||||
|
||||
Corrected 2026-09-06, after Brian queried "BA10895?" against a report that
|
||||
@@ -985,3 +1099,7 @@ decoder lost the geo threshold along with the serial. See commit `9ceff65`.
|
||||
| 2026-09-06 | The spread gate is **blind to onsets by construction** — it rejects a moving floor, which is what an onset is. It rejected the very record in which the ramp is visible. |
|
||||
| 2026-09-07 | **The non-motion test** (Brian: "it doesn't cross zero"). `\|mean\|/peak` is bimodal with a 1.09% dead zone and returns exactly the 5 confirmed units from physics, not a threshold. Independent corroboration of the unit list. **BE11007 settled as NOT an offset** — a one-sided 9 in/s blast. |
|
||||
| 2026-09-07 | **BE12599 is a connector fault, not a geophone fault.** Unipolar spikes with a 26→118 ms RC tail progressing to a flat pedestal, and the fault MOVES between Long and Tran while the sensor self-check passes on every event. An element cannot hop channels; a connector can. Inspect before reseating. |
|
||||
| 2026-09-23 | **CONFIRMED: BE12599's fault is the geophone assembly, not the recorder.** Geophone swap on the same unit: Vert overswing **2026.0 -> 3.4**, frequency 2.2 -> 7.4 Hz, all three channels Passed, no self-triggering. First physically established mechanism in this investigation. |
|
||||
| 2026-09-23 | **This explains the autozero's ~10% success rate** (§5): an autozero adjusts the *recorder's* zero reference and cannot fix a geophone. **Procedure change: swap the geophone first.** |
|
||||
| 2026-09-23 | Bench control event (mic-slap trigger, geophone untouched) shows Vert wandering **0.19 in/s with no mechanical input** — the fault is continuous, not episodic. All three channels affected; Tran/Long parked on DC while their coils pass the swing test. |
|
||||
| 2026-09-23 | **Cause: road salt now outranks the mice.** The geophone is buried, not in the enclosure — two independent contamination paths, and the fault is in the buried half. The unit sat ~6 ft from I-80 WB. Salt is a *site* property, so this predicts higher failure rates near salted highways. |
|
||||
|
||||
@@ -296,6 +296,16 @@ def apply_report_to_event(event: Event, report: BwAsciiReport) -> None:
|
||||
event.sample_rate = report.sample_rate_sps
|
||||
if report.record_time_s is not None:
|
||||
event.rectime_seconds = report.record_time_s
|
||||
# The report's event_datetime is Blastware's exact trigger time (parsed
|
||||
# from Event Time + Event Date). Prefer it over the binary footer's stop
|
||||
# time so a report-paired import matches BW to the second.
|
||||
edt = report.event_datetime
|
||||
if edt is not None:
|
||||
event.timestamp = Timestamp(
|
||||
raw=b"", flag=0x10,
|
||||
year=edt.year, unknown_byte=0, month=edt.month, day=edt.day,
|
||||
hour=edt.hour, minute=edt.minute, second=edt.second,
|
||||
)
|
||||
|
||||
|
||||
def apply_bw_report_dict_to_event(event: Event, bw_report: dict) -> None:
|
||||
@@ -808,6 +818,30 @@ def derive_record_type_from_filename(filename, default: str = "Waveform") -> str
|
||||
return _RECORD_TYPE_BY_EXT_SUFFIX.get(ext[-1].upper(), default)
|
||||
|
||||
|
||||
# Marker for the recording-setup config block, and the offset of the record-time
|
||||
# float32 within it. The configured post-trigger record time (seconds) is a
|
||||
# big-endian float32 exactly 30 bytes before the "Standard Recording Setup"
|
||||
# label. Verified across the corpus reading 1.0 / 2.0 / 3.0 s on different
|
||||
# setups — and ts2 - record_time reproduces Blastware's trigger to the second
|
||||
# (N844LQHB: stop 10:33:32 - 3.0 = 10:33:29).
|
||||
_RECSETUP_MARKER = b"Standard Recording Setup"
|
||||
_RECTIME_OFFSET_BEFORE_MARKER = 30
|
||||
|
||||
|
||||
def _parse_record_time_seconds(raw: bytes) -> Optional[float]:
|
||||
"""The configured post-trigger record time in seconds, from the recording-
|
||||
setup config block, or None when absent / implausible."""
|
||||
a = raw.find(_RECSETUP_MARKER)
|
||||
if a < _RECTIME_OFFSET_BEFORE_MARKER:
|
||||
return None
|
||||
off = a - _RECTIME_OFFSET_BEFORE_MARKER
|
||||
try:
|
||||
rt = struct.unpack(">f", raw[off:off + 4])[0]
|
||||
except struct.error:
|
||||
return None
|
||||
return rt if 0.05 <= rt <= 600.0 else None
|
||||
|
||||
|
||||
def read_blastware_file(path: Union[str, Path]) -> Event:
|
||||
"""
|
||||
Parse a Blastware waveform file into an Event.
|
||||
@@ -917,6 +951,10 @@ def read_blastware_file(path: Union[str, Path]) -> Event:
|
||||
# rest of the event (timestamp, waveform_key, project strings) is
|
||||
# still recoverable and useful.
|
||||
decoded = decode_waveform_v2(body)
|
||||
# Discriminator for the timestamp logic below: a waveform (trigger) event
|
||||
# vs a histogram window. Keyed on the codec, not the filename — the
|
||||
# save_imported_bw path passes a tmp ".bw" name whose extension lies.
|
||||
is_waveform_body = decoded is not None
|
||||
if decoded is None:
|
||||
decoded = decode_histogram_body(body)
|
||||
if decoded is None:
|
||||
@@ -948,7 +986,31 @@ def read_blastware_file(path: Union[str, Path]) -> Event:
|
||||
ev.total_samples = strt_fields.get("total_samples")
|
||||
ev.pretrig_samples = strt_fields.get("pretrig_samples")
|
||||
|
||||
if ts1 is not None:
|
||||
# Event timestamp. The footer's two timestamps mean different things by
|
||||
# record type:
|
||||
# * Waveform: ts1 = the monitoring-SESSION start (shared across every
|
||||
# event that day — a unit arming at 06:00 stamps 06:00 on all of them),
|
||||
# ts2 = THIS event's recording STOP. Blastware's Date/Time is the
|
||||
# TRIGGER = ts2 - record time, and the record time is a float32 in the
|
||||
# recording-setup config block (see _parse_record_time_seconds), so the
|
||||
# exact trigger is recoverable from the binary alone. Falls back to ts2
|
||||
# (the stop, within the record duration) if the config block is absent.
|
||||
# (Stamping ts1 showed the session start, hours off.)
|
||||
# * Histogram / undecodable: ts1 = the window start, which IS the event
|
||||
# time — keep it.
|
||||
# Discriminate by ``is_waveform_body`` (the codec), not the filename.
|
||||
if is_waveform_body and ts2 is not None:
|
||||
_stop = datetime.datetime(ts2.year, ts2.month, ts2.day,
|
||||
ts2.hour, ts2.minute, ts2.second)
|
||||
_rt = _parse_record_time_seconds(raw)
|
||||
_trig = _stop - datetime.timedelta(seconds=_rt) if _rt is not None else _stop
|
||||
ev.timestamp = Timestamp(
|
||||
raw=footer[10:18],
|
||||
flag=0x10,
|
||||
year=_trig.year, unknown_byte=0, month=_trig.month, day=_trig.day,
|
||||
hour=_trig.hour, minute=_trig.minute, second=_trig.second,
|
||||
)
|
||||
elif ts1 is not None:
|
||||
ev.timestamp = Timestamp(
|
||||
raw=footer[2:10],
|
||||
flag=0x10,
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,54 @@
|
||||
"""Event timestamp decode — waveform trigger/stop vs histogram window start.
|
||||
|
||||
The Blastware footer holds two timestamps: ts1 = footer[2:10], ts2 = footer[10:18].
|
||||
Their meaning depends on record type:
|
||||
|
||||
* Waveform: ts1 is the monitoring-SESSION start (e.g. 06:00 for a unit that
|
||||
arms at 06:00 daily — shared across every event that day), and ts2 is THIS
|
||||
event's recording STOP. read_blastware_file used to stamp events with ts1 →
|
||||
every waveform showed the session start (~4.5 h off). Binary-only, the best
|
||||
estimate is ts2 (the stop); the exact trigger BW displays (= ts2 - record
|
||||
duration) comes from the paired report's event_datetime, since the binary
|
||||
STRT record-time byte is a misparsed record-type marker.
|
||||
* Histogram: ts1/ts2 are the ~24 h window [start, stop]; the event time is the
|
||||
window start = ts1 (unchanged).
|
||||
"""
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from minimateplus.event_file_io import read_blastware_file, apply_report_to_event
|
||||
from minimateplus.bw_ascii_report import BwAsciiReport
|
||||
from minimateplus.models import Event
|
||||
|
||||
FIX = Path(__file__).parent / "fixtures"
|
||||
WAVEFORM = FIX / "fft-oracle-2026-09-14" / "N844LQHB.ZT0W" # footer ts2 = 2026-08-25 10:33:32
|
||||
HISTOGRAM = FIX / "ts-fix" / "K441LKZU.C30H" # window start 2026-05-10 19:04:50
|
||||
|
||||
|
||||
def _tuple(ts):
|
||||
return (ts.year, ts.month, ts.day, ts.hour, ts.minute, ts.second)
|
||||
|
||||
|
||||
def test_waveform_timestamp_is_exact_trigger_from_binary():
|
||||
ev = read_blastware_file(WAVEFORM)
|
||||
# The EXACT Blastware trigger, from the binary alone: ts2 (stop 10:33:32)
|
||||
# minus the config record time (3.0 s) = 10:33:29 — NOT the 06:00:13
|
||||
# monitoring-session start the old decode used.
|
||||
assert _tuple(ev.timestamp) == (2026, 8, 25, 10, 33, 29), _tuple(ev.timestamp)
|
||||
|
||||
|
||||
def test_histogram_timestamp_is_window_start_unchanged():
|
||||
ev = read_blastware_file(HISTOGRAM)
|
||||
# Histogram event time = the window start (ts1); must NOT get the waveform
|
||||
# ts2 treatment (that would land ~24 h off).
|
||||
assert _tuple(ev.timestamp) == (2026, 5, 10, 19, 4, 50), _tuple(ev.timestamp)
|
||||
|
||||
|
||||
def test_report_event_datetime_is_authoritative_over_binary():
|
||||
# The binary already yields the exact trigger, but a paired report stays
|
||||
# authoritative (e.g. if the unit clock had drifted) — applying it wins.
|
||||
ev = read_blastware_file(WAVEFORM)
|
||||
assert _tuple(ev.timestamp) == (2026, 8, 25, 10, 33, 29) # exact, from binary
|
||||
apply_report_to_event(ev, BwAsciiReport(
|
||||
event_datetime=datetime.datetime(2026, 8, 25, 10, 35, 0)))
|
||||
assert _tuple(ev.timestamp) == (2026, 8, 25, 10, 35, 0) # report wins
|
||||
Reference in New Issue
Block a user