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