Files
istrain 559caead36 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>
2026-07-24 23:19:16 -04:00

137 lines
6.4 KiB
Python

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