istrain-public: from-scratch build of a passive RF train detector
Public community release. The complete working system — DSP + decoders (scripts/), web dashboard + API (dashboard/), container stack (docker/), config templates (config/) — plus APOCALYPSE-EDITION.md, the full build guide for a human or a coding agent. GPLv3 (PyEOT dependency). Scrubbed of all secrets, internal IPs, hostnames, and location detail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
# correlate.py — multi-band "train activity" correlation engine. READ-ONLY, non-destructive.
|
||||
#
|
||||
# The mission is to *detect a train at the crossing*, not to *decode* any one signal. A train lights
|
||||
# several bands at once; this marries the capture logs we already keep and reports CO-OCCURRING
|
||||
# activity as an EXPLAINED candidate — it shows the evidence and lets a human infer. It is deliberately
|
||||
# NOT a latched verdict: it only ever reflects a sliding time window, so when activity ages out it
|
||||
# self-resets to "quiet" (can't get stuck in a false "blocked forever" state).
|
||||
#
|
||||
# Bands (today): 160.980 road/detector VOICE (1002 -> heard.jsonl) · 457-cluster (1001 -> eot.jsonl) ·
|
||||
# 452-cluster head-end / BOT (-> bot.jsonl) on the hop.
|
||||
#
|
||||
# Mid-train DPU (2026-06-24): the DPU command channels sit ±12.5 kHz off the HOT/EOT centers
|
||||
# (452.925/.950 and 457.925/.950) — INSIDE each dwell's 200 kHz capture. So a DPU burst already trips
|
||||
# the 452/457 leg and is counted here; it just isn't *distinguished* from HOT/EOT (that needs IQ
|
||||
# sub-channel ID — see eot/README "DPU decode plan"). Net: a DPU-emitting train already lights these
|
||||
# legs; we don't need a separate capture for detection, only for identification/decode.
|
||||
#
|
||||
# Touches no radio. CLI: correlate.py [--window MIN] [--json] API: correlate(window_min=20) -> dict
|
||||
import os, json, time, sys
|
||||
|
||||
HEARD = os.path.expanduser(os.environ.get("HEARD_LOG", "~/istrain/heard.jsonl"))
|
||||
EOT = os.path.expanduser(os.environ.get("EOT_LOG", "~/istrain/eot.jsonl"))
|
||||
BOT = os.path.expanduser(os.environ.get("BOT_LOG", "~/istrain/bot.jsonl"))
|
||||
MID = os.path.expanduser(os.environ.get("MID_LOG", "~/istrain/midtrain.jsonl"))
|
||||
WINDOW_MIN = int(os.environ.get("CORRELATE_WINDOW_MIN", "20"))
|
||||
|
||||
def _load(path):
|
||||
rows = []
|
||||
if not os.path.exists(path):
|
||||
return rows
|
||||
try:
|
||||
for line in open(path):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try: rows.append(json.loads(line))
|
||||
except Exception: continue
|
||||
except OSError:
|
||||
pass
|
||||
return rows
|
||||
|
||||
def _ch(r):
|
||||
return str(r.get("ch") or r.get("channel") or r.get("freq") or r.get("label") or "")
|
||||
|
||||
def correlate(window_min=WINDOW_MIN, now=None):
|
||||
now = now if now is not None else time.time()
|
||||
lo = now - window_min * 60
|
||||
|
||||
heard = [r for r in _load(HEARD) if r.get("ts", 0) >= lo]
|
||||
road = [r for r in heard if "160.98" in _ch(r)] # the NS road / detector channel
|
||||
eot = [r for r in _load(EOT) if r.get("ts", 0) >= lo] # 457 EOT-band bursts
|
||||
bot = [r for r in _load(BOT) if r.get("ts", 0) >= lo] # 452 head-end bursts (when present)
|
||||
|
||||
# MID (±12.5 kHz DPU watch) is booster-aware (O-9/O-11): the 457.925 wayside booster paints a flat
|
||||
# continuous carrier for hours, which would make this band read "active" on no train at all. With
|
||||
# enough rows to characterize, only excursions >= 6 dB over the median (= the booster floor) count;
|
||||
# the flat carrier itself is reported but never correlates.
|
||||
mid_all = [r for r in _load(MID) if r.get("ts", 0) >= lo and r.get("peak") is not None]
|
||||
booster = False
|
||||
if len(mid_all) >= 30:
|
||||
pks = sorted(r["peak"] for r in mid_all)
|
||||
floor = pks[len(pks) // 2]
|
||||
mid = [r for r in mid_all if r["peak"] >= floor + 6.0]
|
||||
booster = True
|
||||
else:
|
||||
mid = mid_all
|
||||
|
||||
bands = []
|
||||
def add(key, label, evs, extra=None):
|
||||
last = max((e.get("ts", 0) for e in evs), default=0)
|
||||
b = {"key": key, "label": label, "count": len(evs),
|
||||
"last": last, "last_ago": (round(now - last) if last else None)}
|
||||
if extra: b.update(extra)
|
||||
bands.append(b)
|
||||
add("voice", "160.980 road/detector voice", road)
|
||||
add("eot", "457-cluster (EOT + mid-train DPU)", eot)
|
||||
if bot:
|
||||
add("bot", "452-cluster (head-end + mid-train DPU)", bot)
|
||||
if mid_all:
|
||||
add("mid", "±12.5k DPU watch" + (" (booster-floor subtracted)" if booster else ""), mid)
|
||||
|
||||
# flat event list for the timeline view (panel renders this)
|
||||
events = []
|
||||
for e in road: events.append({"band": "voice", "ts": e.get("ts", 0)})
|
||||
for e in eot: events.append({"band": "eot", "ts": e.get("ts", 0), "dur": e.get("dur"), "peak": e.get("peak")})
|
||||
for e in bot: events.append({"band": "bot", "ts": e.get("ts", 0)})
|
||||
for e in mid: events.append({"band": "mid", "ts": e.get("ts", 0), "peak": e.get("peak")})
|
||||
events.sort(key=lambda x: x["ts"])
|
||||
|
||||
active = [b for b in bands if b["count"] > 0]
|
||||
reasons = []
|
||||
if len(active) == 0:
|
||||
state = "quiet"
|
||||
reasons.append("No activity on any watched band in the last %d min." % window_min)
|
||||
elif len(active) == 1:
|
||||
b = active[0]
|
||||
state = "single-band"
|
||||
reasons.append("Only %s active (%d hit%s) — one band alone can be RFI/noise, NOT confirmed as a train."
|
||||
% (b["label"], b["count"], "" if b["count"] == 1 else "s"))
|
||||
else:
|
||||
state = "candidate"
|
||||
reasons.append("%d bands co-firing in the last %d min — consistent with train activity:"
|
||||
% (len(active), window_min))
|
||||
for b in active:
|
||||
reasons.append(" · %s — %d hit%s, last %ss ago"
|
||||
% (b["label"], b["count"], "" if b["count"] == 1 else "s", b["last_ago"]))
|
||||
if booster:
|
||||
reasons.append("Booster carrier active on the MID band (flat ~median floor) — subtracted; "
|
||||
"only excursions ≥6 dB over it count as MID activity.")
|
||||
return {"now": now, "window_min": window_min, "state": state,
|
||||
"bands": bands, "events": events, "reasons": reasons}
|
||||
|
||||
def _pretty(d):
|
||||
badge = {"quiet": "· QUIET", "single-band": "~ SINGLE-BAND", "candidate": "▸ CANDIDATE"}.get(d["state"], d["state"])
|
||||
print("=== train-activity correlation [%s window] ===" % (str(d["window_min"]) + "m"))
|
||||
print("state: %s" % badge)
|
||||
for b in d["bands"]:
|
||||
ago = ("%ss ago" % b["last_ago"]) if b["last_ago"] is not None else "—"
|
||||
print(" %-30s %4d hit(s) last: %s" % (b["label"], b["count"], ago))
|
||||
print("why:")
|
||||
for r in d["reasons"]:
|
||||
print(" " + r)
|
||||
|
||||
if __name__ == "__main__":
|
||||
win = WINDOW_MIN; as_json = False
|
||||
a = sys.argv[1:]
|
||||
while a:
|
||||
t = a.pop(0)
|
||||
if t == "--window": win = int(a.pop(0))
|
||||
elif t == "--json": as_json = True
|
||||
out = correlate(window_min=win)
|
||||
if as_json:
|
||||
print(json.dumps(out))
|
||||
else:
|
||||
_pretty(out)
|
||||
Reference in New Issue
Block a user