From 5c3120952eeed7fc36efe265f391bb71eae71d90 Mon Sep 17 00:00:00 2001 From: Babissimo Date: Tue, 8 Sep 2026 14:27:35 +0100 Subject: [PATCH 1/6] chore(nodes): drop the blah2 bridge and its two hardcoded nodes radar3-retnode and radar3a-retnode were polled over HTTP once a second each, their geometry pinned in backend/config/blah2_nodes.json. Both are being reflashed and will re-enter through the v1 node API, which reaches the pipeline by the same calls the bridge made, so the bridge has no remaining job. It was kept deliberately as the v1 rollback path (see docs/design-notes/2026-08-06-node-ingest-minimal.md); retiring that rollback is the point of this change, not an oversight. Removed with it: the BLAH2_* constants, blah2_nodes.json's place in the runtime-config overlay and the Dockerfile step that seeded it, the task-registry note about per-node keys, and the two /api/test/radar3/* back-compat aliases. The generic /api/test/node/{node_id}/* endpoints those wrapped are unchanged, and had no callers in the repo. Comments in node_pipeline, frame_processor and node_stream described their contract by pointing at the bridge as the working reference. They now state the contract directly rather than referring to a module that is gone. The running app reads backend/data/runtime/blah2_nodes.json, seeded on first boot and authoritative after, so deployed droplets keep that file until it is cleared by hand. Nothing reads it once this lands. Co-Authored-By: Claude Opus 5 --- Dockerfile | 8 +- backend/config/blah2_nodes.json | 57 ---- backend/config/constants.py | 6 - backend/core/runtime_config.py | 2 +- backend/core/task_registry.py | 3 - backend/main.py | 7 - backend/routes/node_stream.py | 9 +- backend/routes/test.py | 14 - backend/services/blah2_bridge.py | 349 ------------------------ backend/services/frame_processor.py | 8 +- backend/services/node_pipeline.py | 16 +- backend/tests/test_blah2_bridge.py | 316 --------------------- backend/tests/test_ingest_event_loop.py | 56 +--- backend/tests/test_node_pipeline.py | 10 +- docs/runbook.md | 22 +- docs/simulation.md | 5 - docs/solverflow.md | 7 +- 17 files changed, 33 insertions(+), 862 deletions(-) delete mode 100644 backend/config/blah2_nodes.json delete mode 100644 backend/services/blah2_bridge.py delete mode 100644 backend/tests/test_blah2_bridge.py diff --git a/Dockerfile b/Dockerfile index 5a6925da..005274ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -104,11 +104,6 @@ RUN chmod +x /app/deploy/start.sh # nodes_config.json is runtime-editable and stays in the volume; constants.py # is source code and must follow the image. # -# blah2_nodes.json is runtime-editable too, but it also has to be *seedable*: -# on an existing deployment the volume masks backend/config, so a copy shipped -# only there would be invisible and the bridge would poll nothing. The pristine -# copy is what runtime_config.default_source_path() seeds the overlay from. -# # Layout: /app/deploy/config-image/config/constants.py (no __init__.py so # Python treats 'config' as a namespace package and merges all 'config/' # dirs on sys.path). start.sh prepends /app/deploy/config-image to @@ -116,8 +111,7 @@ RUN chmod +x /app/deploy/start.sh # copy at /app/backend/config/constants.py — even when the volume is # root-owned and the cp refresh fails. RUN mkdir -p /app/deploy/config-image/config && \ - cp /app/backend/config/constants.py /app/deploy/config-image/config/constants.py && \ - cp /app/backend/config/blah2_nodes.json /app/deploy/config-image/config/blah2_nodes.json + cp /app/backend/config/constants.py /app/deploy/config-image/config/constants.py # ── Non-root user ──────────────────────────────────────────────────────────── RUN useradd -r -s /usr/sbin/nologin appuser && \ diff --git a/backend/config/blah2_nodes.json b/backend/config/blah2_nodes.json deleted file mode 100644 index 7a583f04..00000000 --- a/backend/config/blah2_nodes.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "_README": [ - "Live blah2 retnodes polled over HTTP by services/blah2_bridge.py.", - "One entry per node; the bridge runs one polling task per entry, so adding a", - "node here is the only change needed to ingest it.", - "", - "Required: node_id, detection_url, rx_lat, rx_lon, tx_lat, tx_lon, fc_hz.", - "Optional (defaults in blah2_bridge.py): rx_alt_ft, tx_alt_ft, fs_hz,", - "doppler_min, doppler_max, min_doppler, beam_width_deg, max_range_km, notes.", - "", - "Geometry must match the real hardware — tx/rx positions and fc feed the", - "bistatic solver directly, so an error here misplaces every target from that", - "node. Verify with GET /api/test/node//verification after a change.", - "", - "This file is the source-controlled default. The running app reads the copy", - "in backend/data/runtime/, seeded from here on first boot; edit that copy (or", - "set BLAH2_NODES_FILE) to change nodes without a rebuild." - ], - "nodes": [ - { - "node_id": "radar3-retnode", - "detection_url": "https://radar3.retnode.com/api/detection", - "notes": "RX Wilderness. Illuminated by WXIA-TV (FCC facility 51163, RF ch 10, Midtown Atlanta) at N 33d45'24.0\" W 84d19'55.0\", RCAMSL 595 m. Geometry reproduces the node's own adsb expected_delay to 7.5 m RMS over 128 samples; expected_doppler fits fc = 194.92 MHz, the ch 10 centre. Previously tx_lat was byte-identical to rx_lat (~20 km north of the real tower, 14.7 km delay RMS) — corrected once the device began publishing ADS-B truth to fit against.", - "rx_lat": 33.939182, - "rx_lon": -84.651910, - "rx_alt_ft": 1050, - "tx_lat": 33.756667, - "tx_lon": -84.331944, - "tx_alt_ft": 1952, - "fc_hz": 195000000, - "fs_hz": 2000000, - "doppler_min": -300, - "doppler_max": 300, - "min_doppler": 15, - "beam_width_deg": 42.0, - "max_range_km": 140 - }, - { - "node_id": "radar3a-retnode", - "detection_url": "https://radar3a.retnode.com/api/detection", - "notes": "Shares radar3's receive site. Illuminated by WGTV (FCC facility 23948, RF ch 7, Stone Mountain) at N 33d48'18.0\" W 84d08'40.0\", RCAMSL 605 m. Geometry reproduces the node's own adsb expected_delay to 21.8 m RMS; expected_doppler fits fc = 177.01 MHz, the ch 7 centre.", - "rx_lat": 33.939182, - "rx_lon": -84.651910, - "rx_alt_ft": 1050, - "tx_lat": 33.805000, - "tx_lon": -84.144444, - "tx_alt_ft": 1985, - "fc_hz": 177000000, - "fs_hz": 2000000, - "doppler_min": -300, - "doppler_max": 300, - "min_doppler": 15, - "beam_width_deg": 42.0, - "max_range_km": 140 - } - ] -} diff --git a/backend/config/constants.py b/backend/config/constants.py index 0643ec46..5bb9cde8 100644 --- a/backend/config/constants.py +++ b/backend/config/constants.py @@ -297,12 +297,6 @@ def as_num(v) -> float: IQ_COMMITMENTS_MAX_PER_NODE = 200 # Max IQ commitments per node (rolling) RATE_BUCKETS_MAX_IPS = 10_000 # Max unique IPs in rate limiter -# ── blah2 bridge ───────────────────────────────────────────────────────────── -BLAH2_POLL_INTERVAL_S = 1.0 # blah2 API poll cadence (s) -BLAH2_STALE_THRESHOLD_S = 10.0 # Ignore frames older than this (s) -BLAH2_RECONNECT_DELAY_S = 5.0 # Backoff after failures (s) -BLAH2_MAX_FAILURES = 5 # Failures before backing off - # ── Node retirement ────────────────────────────────────────────────────────── # Which node ids the admin route will force-retire. Empty, the default, # imposes no restriction, which is what production wants: a decommissioned diff --git a/backend/core/runtime_config.py b/backend/core/runtime_config.py index b497b108..c9c3193e 100644 --- a/backend/core/runtime_config.py +++ b/backend/core/runtime_config.py @@ -28,7 +28,7 @@ # invisible on an existing deployment, because the volume masks the directory. _IMAGE_DEFAULTS_DIR = _BACKEND_DIR.parent / "deploy" / "config-image" / "config" -_RUNTIME_FILES = ("nodes_config.json", "blah2_nodes.json") +_RUNTIME_FILES = ("nodes_config.json",) logger = logging.getLogger(__name__) diff --git a/backend/core/task_registry.py b/backend/core/task_registry.py index 9f4b6764..db8bdd01 100644 --- a/backend/core/task_registry.py +++ b/backend/core/task_registry.py @@ -21,9 +21,6 @@ "storage_refresh": 720, # expected every 300 s; alert if >2× late "track_archive_flush": 180, # flush every 60 s; alert if >3× late "users_db_backup": 86400 * 2, # daily; alert if it hasn't run in 2 days - # The blah2 bridge registers one key per configured live node at startup - # (see services/blah2_bridge.load_nodes) — its node list is config-driven, - # so those keys cannot be enumerated here. } diff --git a/backend/main.py b/backend/main.py index 09248ff3..d3f1569a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -73,8 +73,6 @@ track_flush_task, users_backup_task, ) -from services.blah2_bridge import blah2_bridge_task -from services.blah2_bridge import load_nodes as load_blah2_nodes from services.runtime_coverage import start as _start_coverage from services.runtime_coverage import stop as _stop_coverage from services.state_snapshot import SAVE_INTERVAL_S, restore_snapshot, save_snapshot @@ -143,10 +141,6 @@ async def lifespan(app: FastAPI): migrate_defaults_into_runtime() - # Live blah2 nodes are config-driven — read after the overlay is seeded so - # the runtime copy wins, and before the task list is built below. - blah2_nodes = load_blah2_nodes() - # No-op everywhere except tests (RETINA_SCHEMA_SOURCE guards create_all off # otherwise). The schema comes from Alembic migrations instead: deploy/start.sh # runs them before uvicorn starts, and `just setup` runs them for local dev. @@ -209,7 +203,6 @@ async def _snapshot_loop(): asyncio.create_task(analytics_refresh_task()), asyncio.create_task(coverage_constraints_task()), asyncio.create_task(storage_refresh_task()), - *[asyncio.create_task(blah2_bridge_task(n)) for n in blah2_nodes], asyncio.create_task(health_monitor_task()), asyncio.create_task(heartbeat_task()), asyncio.create_task(_snapshot_loop()), diff --git a/backend/routes/node_stream.py b/backend/routes/node_stream.py index 913ce001..3485488e 100644 --- a/backend/routes/node_stream.py +++ b/backend/routes/node_stream.py @@ -105,13 +105,12 @@ async def _version_was_ever_issued(session: AsyncSession, node_id: str, version: def _pipeline_frame(frame: DetectionFrame) -> dict: - """The wire frame in the shape services/blah2_bridge.py puts on the queue. + """The wire frame in the shape the frame queue's readers expect. - `timestamp` is milliseconds because that is what the queue's readers expect. - `delay` needs no conversion: it is microseconds on the wire, where the bridge - has to convert from kilometres. + `timestamp` is milliseconds, and `delay` needs no conversion: it is + microseconds on the wire and microseconds on the queue. - `adsb_hex` travels under its own key rather than the bridge's `adsb`, which + `adsb_hex` travels under its own key rather than `adsb`, which frame_processor reads as position reports. The contract's array is an association and carries no lat/lon, so filing it there would be filing an empty position for every detection. diff --git a/backend/routes/test.py b/backend/routes/test.py index f575fad6..7980f12d 100644 --- a/backend/routes/test.py +++ b/backend/routes/test.py @@ -659,8 +659,6 @@ def _mlat_verification_summary() -> dict: # ── Per-node solver verification ────────────────────────────────────────────── -_RADAR3_NODE_ID = "radar3-retnode" - @router.get("/api/test/node/{node_id}/verification") async def node_verification(node_id: str): @@ -671,12 +669,6 @@ async def node_verification(node_id: str): ) -@router.get("/api/test/radar3/verification") -async def radar3_verification(): - """Back-compat alias for the radar3 node's verification stats.""" - return await node_verification(_RADAR3_NODE_ID) - - @router.get("/api/test/mlat-verification") async def mlat_verification(): """Return pre-computed multinode (MLAT) solver-vs-ground-truth verification stats.""" @@ -1074,9 +1066,3 @@ async def node_detection_range(node_id: str): ), media_type="application/json", ) - - -@router.get("/api/test/radar3/detection-range") -async def radar3_detection_range(): - """Back-compat alias for the radar3 node's detection range.""" - return await node_detection_range(_RADAR3_NODE_ID) diff --git a/backend/services/blah2_bridge.py b/backend/services/blah2_bridge.py deleted file mode 100644 index ff30f468..00000000 --- a/backend/services/blah2_bridge.py +++ /dev/null @@ -1,349 +0,0 @@ -"""blah2 bridge — polls live retnodes' /api/detection endpoints and injects -real passive-radar detections directly into the RETINA frame pipeline. - -The blah2 delay axis is in km (bistatic range difference). -RETINA expects delay in µs. Conversion: delay_us = delay_km / C_KM_US. - -Nodes are defined in ``blah2_nodes.json`` (see that file's _README for the -schema), loaded through the same runtime-config overlay as nodes_config.json: -``backend/config/`` holds the source-controlled default, the app reads the copy -under ``backend/data/runtime/``. Set ``BLAH2_NODES_FILE`` to point somewhere -else entirely. One polling task runs per configured node, so adding a node is -a config change, not a code change. - -Node geometry is *not* discoverable at runtime — /api/config on these hosts -returns only a truth flag — which is why it lives in the config file and must -match the real hardware: tx/rx positions and fc feed the bistatic solver -directly. -""" - -import asyncio -import hashlib -import json -import logging -import math -import os -import time -from dataclasses import dataclass, field -from pathlib import Path - -import httpx - -from config.constants import ( - BLAH2_MAX_FAILURES as MAX_FAILURES, -) -from config.constants import ( - BLAH2_POLL_INTERVAL_S as POLL_INTERVAL_S, -) -from config.constants import ( - BLAH2_RECONNECT_DELAY_S as RECONNECT_DELAY_S, -) -from config.constants import ( - BLAH2_STALE_THRESHOLD_S as STALE_THRESHOLD_S, -) -from config.constants import ( - C_KM_US, -) -from core import state -from core.runtime_config import default_source_path, runtime_path -from core.task_registry import register_task -from services import node_registration - -log = logging.getLogger("blah2_bridge") - -CONFIG_FILENAME = "blah2_nodes.json" - -# Each node polls every POLL_INTERVAL_S (1 s); flag it stale past this. -BRIDGE_STALE_INTERVAL_S = 10 - -# Fields every node must supply — without these the bistatic solve is undefined. -_REQUIRED = ("node_id", "detection_url", "rx_lat", "rx_lon", "tx_lat", "tx_lon", "fc_hz") - -# Optional fields and the defaults applied when a node omits them. -_OPTIONAL_DEFAULTS = { - "rx_alt_ft": 0.0, - "tx_alt_ft": 0.0, - "fs_hz": 2_000_000, - "doppler_min": -300, - "doppler_max": 300, - "min_doppler": 15, - # 42-degree Yagi per the actual hardware; the old 120 was a stale guess - # that tripled every radar3 beam gate and coverage sector. - "beam_width_deg": 42.0, - "max_range_km": 140, -} - - -@dataclass(frozen=True) -class Blah2Node: - """One live blah2 node: RETINA identity, endpoint, and pipeline config.""" - - node_id: str - detection_url: str - config: dict = field(repr=False) - - @property - def peer(self) -> str: - """Hostname, for display in the node list.""" - return httpx.URL(self.detection_url).host - - -class Blah2ConfigError(ValueError): - """A node entry in the config file is unusable.""" - - -def task_key(node_id: str) -> str: - """Per-node key in state.task_last_success / the staleness registry.""" - return f"blah2_bridge:{node_id}" - - -def config_file_path() -> Path: - """Where the node list is read from. - - BLAH2_NODES_FILE wins; otherwise the runtime overlay copy; otherwise the - source-controlled default that ships in the image. - """ - override = os.getenv("BLAH2_NODES_FILE") - if override: - return Path(override) - overlay = runtime_path(CONFIG_FILENAME) - if overlay.exists(): - return overlay - return default_source_path(CONFIG_FILENAME) or overlay - - -def _build_node(entry: dict) -> Blah2Node: - """Validate one config entry and expand it into a Blah2Node. - - Raises Blah2ConfigError with a message naming the offending field. - """ - if not isinstance(entry, dict): - raise Blah2ConfigError(f"node entry must be an object, got {type(entry).__name__}") - - missing = [k for k in _REQUIRED if entry.get(k) is None] - if missing: - raise Blah2ConfigError(f"missing required field(s): {', '.join(missing)}") - - node_id = str(entry["node_id"]).strip() - if not node_id: - raise Blah2ConfigError("node_id is empty") - - url = str(entry["detection_url"]).strip() - if not url.startswith(("http://", "https://")): - raise Blah2ConfigError(f"{node_id}: detection_url must be http(s), got {url!r}") - - cfg: dict = {"node_id": node_id} - for key in ("rx_lat", "rx_lon", "tx_lat", "tx_lon", "fc_hz"): - try: - cfg[key] = float(entry[key]) - except (TypeError, ValueError) as exc: - raise Blah2ConfigError(f"{node_id}: {key} is not a number: {entry[key]!r}") from exc - for key, default in _OPTIONAL_DEFAULTS.items(): - raw = entry.get(key, default) - try: - cfg[key] = float(raw) - except (TypeError, ValueError) as exc: - raise Blah2ConfigError(f"{node_id}: {key} is not a number: {raw!r}") from exc - - for key, lo, hi in (("rx_lat", -90, 90), ("tx_lat", -90, 90), ("rx_lon", -180, 180), ("tx_lon", -180, 180)): - if not lo <= cfg[key] <= hi: - raise Blah2ConfigError(f"{node_id}: {key}={cfg[key]} out of range [{lo}, {hi}]") - if cfg["fc_hz"] <= 0 or cfg["fs_hz"] <= 0: - raise Blah2ConfigError(f"{node_id}: fc_hz and fs_hz must be positive") - - # The pipeline factory and analytics read both spellings; keep them in step. - cfg["FC"] = cfg["fc_hz"] - cfg["Fs"] = cfg["fs_hz"] - if entry.get("notes"): - cfg["notes"] = str(entry["notes"]) - - return Blah2Node(node_id=node_id, detection_url=url, config=cfg) - - -def load_nodes(path: Path | None = None) -> list[Blah2Node]: - """Read the node list from the config file. - - A malformed individual entry is logged and skipped so one bad node cannot - keep the others off the air; a missing or unreadable file yields an empty - list and the bridge simply runs no pollers. Every failure is logged at - error level — and a node that fails to load is also visibly absent from - /api/radar/nodes. - """ - path = Path(path) if path else config_file_path() - try: - raw = json.loads(path.read_text()) - except FileNotFoundError: - log.error("blah2_bridge: node config %s not found — no live nodes will be polled", path) - return [] - except (OSError, json.JSONDecodeError) as exc: - log.error("blah2_bridge: cannot read node config %s: %s", path, exc) - return [] - - entries = raw.get("nodes") if isinstance(raw, dict) else raw - if not isinstance(entries, list): - log.error("blah2_bridge: %s must contain a 'nodes' array", path) - return [] - - nodes: list[Blah2Node] = [] - seen_ids: set[str] = set() - seen_urls: set[str] = set() - for entry in entries: - try: - node = _build_node(entry) - except Blah2ConfigError as exc: - log.error("blah2_bridge: skipping node in %s — %s", path, exc) - continue - # Duplicates would fight over the same slot in state.connected_nodes. - if node.node_id in seen_ids: - log.error("blah2_bridge: skipping duplicate node_id %s in %s", node.node_id, path) - continue - if node.detection_url in seen_urls: - log.error( - "blah2_bridge: skipping %s — detection_url %s already used by another node", - node.node_id, - node.detection_url, - ) - continue - seen_ids.add(node.node_id) - seen_urls.add(node.detection_url) - nodes.append(node) - - log.info( - "blah2_bridge: loaded %d node(s) from %s: %s", len(nodes), path, ", ".join(n.node_id for n in nodes) or "(none)" - ) - for node in nodes: - register_task(task_key(node.node_id), BRIDGE_STALE_INTERVAL_S) - return nodes - - -async def _register_node(node: Blah2Node): - """Register a node in state as a real (non-synthetic) connected node.""" - cfg_hash = hashlib.sha256(json.dumps(node.config, sort_keys=True).encode()).hexdigest()[:16] - with state.connected_nodes_lock: - state.connected_nodes[node.node_id] = { - "config_hash": cfg_hash, - "config": node.config, - "status": "active", - "last_heartbeat": "", - "peer": node.peer, - "is_synthetic": False, - "capabilities": {"adsb_report": True}, - } - await node_registration.register_node(node.node_id, node.config) - log.info("blah2_bridge: registered node %s", node.node_id) - - -def _convert_frame(raw: dict, node_id: str) -> dict | None: - """Convert a blah2 /api/detection response to a RETINA frame dict.""" - ts_ms = raw.get("timestamp") - delays_km = raw.get("delay", []) - dopplers_hz = raw.get("doppler", []) - snrs = raw.get("snr", []) - - if not ts_ms or not delays_km: - return None - - # Reject stale frames (blah2 sometimes serves cached responses) - age_s = time.time() - ts_ms / 1000.0 - if abs(age_s) > STALE_THRESHOLD_S: - return None - - # Convert delay: km → µs - delays_us = [d / C_KM_US for d in delays_km] - - # Convert blah2 adsb entries to RETINA format - adsb_out = [] - for entry in raw.get("adsb", []): - if not isinstance(entry, dict): - adsb_out.append(None) - continue - lat = entry.get("lat") or entry.get("latitude") - lon = entry.get("lon") or entry.get("longitude") - if lat and lon and math.isfinite(lat) and math.isfinite(lon): - adsb_out.append( - { - "hex": entry.get("hex") or entry.get("icao"), - "lat": lat, - "lon": lon, - "alt_baro": entry.get("alt_baro") or entry.get("altitude", 0), - "gs": entry.get("gs") or entry.get("speed", 0), - "track": entry.get("track") or entry.get("heading", 0), - "flight": entry.get("flight") or entry.get("callsign", ""), - } - ) - else: - adsb_out.append(None) - - frame = { - "timestamp": ts_ms, - "delay": delays_us, - "doppler": dopplers_hz, - "snr": snrs, - "_node_id": node_id, - } - if adsb_out: - frame["adsb"] = adsb_out - return frame - - -async def blah2_bridge_task(node: Blah2Node): - """Long-running background task: poll one blah2 node and inject frames.""" - await _register_node(node) - key = task_key(node.node_id) - failures = 0 - last_ts = 0 - - async with httpx.AsyncClient(timeout=5.0, verify=False) as client: - while True: - try: - resp = await client.get(node.detection_url) - resp.raise_for_status() - raw = resp.json() - - frame = _convert_frame(raw, node.node_id) - if frame is not None: - ts_ms = raw.get("timestamp", 0) - # Forward progress only: a repeat or a backwards clock step - # would hand the tracker a non-positive dt. _convert_frame's - # staleness gate must stay above this one - it bounds a - # regression stall to 2 * STALE_THRESHOLD_S rather than forever. - if ts_ms > last_ts: - last_ts = ts_ms - # Update heartbeat timestamp - if node.node_id in state.connected_nodes: - from datetime import datetime, timezone - - with state.connected_nodes_lock: - state.connected_nodes[node.node_id]["last_heartbeat"] = datetime.now( - timezone.utc - ).isoformat() - try: - state.frame_queue.put_nowait((node.node_id, frame)) - except Exception: - state.bump_counter("frames_dropped") - - failures = 0 - state.task_last_success[key] = time.time() - await asyncio.sleep(POLL_INTERVAL_S) - - except (httpx.HTTPError, Exception) as exc: - failures += 1 - state.task_error_counts[key] += 1 - if failures >= MAX_FAILURES: - log.warning( - "blah2_bridge[%s]: %d consecutive failures (%s), backing off %ds", - node.node_id, - failures, - exc, - RECONNECT_DELAY_S, - ) - # Mark node as degraded but don't remove it - if node.node_id in state.connected_nodes: - with state.connected_nodes_lock: - state.connected_nodes[node.node_id]["status"] = "degraded" - await asyncio.sleep(RECONNECT_DELAY_S) - failures = 0 - # Re-register in case state was reset - await _register_node(node) - else: - await asyncio.sleep(POLL_INTERVAL_S) diff --git a/backend/services/frame_processor.py b/backend/services/frame_processor.py index 7cb65fb2..34e5d349 100644 --- a/backend/services/frame_processor.py +++ b/backend/services/frame_processor.py @@ -445,13 +445,13 @@ def process_one_frame(node_id: str, frame: dict, default_pipeline: PassiveRadarP _d_assoc = time.thread_time() - _t2 # ADS-B extraction: TCP handler runs _apply_synthetic_adsb for synth nodes - # before queuing. For non-TCP sources (e.g. blah2_bridge) the adsb list - # arrives here still unextracted — store those positions now so the - # verification and accuracy pipelines can reference them. + # before queuing. For non-TCP sources the adsb list arrives here still + # unextracted — store those positions now so the verification and accuracy + # pipelines can reference them. _adsb_list = frame.get("adsb") if _adsb_list: _ts_ms = int(time.time() * 1000) - # Same world stamp the TCP fast-path applies — a blah2 node's list is + # Same world stamp the TCP fast-path applies — a real node's list is # real traffic, a test frame's is simulated; claiming keys on it. _world = state.node_world(node_id) for _ae in _adsb_list: diff --git a/backend/services/node_pipeline.py b/backend/services/node_pipeline.py index 9b3cadb6..1d0322cb 100644 --- a/backend/services/node_pipeline.py +++ b/backend/services/node_pipeline.py @@ -1,9 +1,8 @@ -"""Make a v1 node indistinguishable from a blah2_bridge node to the pipeline. +"""Put a v1 node into the pipeline the way every other source does. -The bridge is the working reference: services/blah2_bridge.py puts a node into -connected_nodes, hands it to services/node_registration, and pushes frames onto -one queue. This does the same, so a v1 node reaches the map without anything -downstream knowing the difference. +A node reaches the map by landing in connected_nodes, going through +services/node_registration, and having its frames pushed onto one queue. This +does those three, so nothing downstream needs to know where the node came from. """ import hashlib @@ -19,8 +18,8 @@ logger = logging.getLogger(__name__) -# The pipeline expects three fields the v1 wire config does not carry. -# Values copied from services/blah2_bridge.py rather than invented. +# The pipeline expects three fields the v1 wire config does not carry; these +# are the values the nodes actually run with, not invented ones. _PIPELINE_DEFAULTS = {"doppler_min": -300, "doppler_max": 300, "min_doppler": 15} # beam_azimuth_deg is passed through rather than defaulted: null is broadside @@ -117,8 +116,7 @@ async def prime_pipeline_at_startup() -> int: A failure here costs the v1 fleet its pipeline membership until the next restart, which is bad but recoverable. Raising instead would abort the - lifespan and take the whole API with it, including the blah2_bridge path - this phase deliberately keeps running as its rollback. + lifespan and take the whole API down with it. """ import core.users from services.alerting import send_alert diff --git a/backend/tests/test_blah2_bridge.py b/backend/tests/test_blah2_bridge.py deleted file mode 100644 index 0ff5c83e..00000000 --- a/backend/tests/test_blah2_bridge.py +++ /dev/null @@ -1,316 +0,0 @@ -"""Tests for the config-driven blah2 bridge.""" - -import json -import time - -import pytest - -from core.task_registry import TASK_EXPECTED_INTERVAL_S -from services.blah2_bridge import ( - Blah2ConfigError, - _build_node, - _convert_frame, - config_file_path, - load_nodes, - task_key, -) -from services.tcp_handler import is_synthetic_node - -MINIMAL = { - "node_id": "n1", - "detection_url": "https://example.test/api/detection", - "rx_lat": 33.9, - "rx_lon": -84.6, - "tx_lat": 33.8, - "tx_lon": -84.1, - "fc_hz": 177_000_000, -} - - -async def _noop_register(_node): - """Bypass registration: it touches shared node state the ordering tests do not exercise.""" - - -def _write(tmp_path, payload): - p = tmp_path / "blah2_nodes.json" - p.write_text(json.dumps(payload)) - return p - - -# ── Shipped default ─────────────────────────────────────────────────────────── - - -class TestShippedConfig: - def test_default_config_loads(self): - nodes = load_nodes(config_file_path()) - assert {n.node_id for n in nodes} == {"radar3-retnode", "radar3a-retnode"} - - def test_nodes_are_real_not_synthetic(self): - """Registered with is_synthetic=False, so they must not trip the prefix - classifier that strips synthetic nodes from the public feed.""" - for node in load_nodes(config_file_path()): - assert is_synthetic_node(node.node_id) is False - - def test_radar3a_geometry(self): - """radar3a shares radar3's receiver but is illuminated by WGTV (RF ch 7, - Stone Mountain), not WXIA — reusing radar3's TX/FC misplaces every target.""" - by_id = {n.node_id: n.config for n in load_nodes(config_file_path())} - r3, r3a = by_id["radar3-retnode"], by_id["radar3a-retnode"] - assert (r3a["rx_lat"], r3a["rx_lon"]) == (r3["rx_lat"], r3["rx_lon"]) - assert (r3a["tx_lat"], r3a["tx_lon"]) == (33.805000, -84.144444) - assert r3a["fc_hz"] == 177_000_000 - assert r3a["fc_hz"] != r3["fc_hz"] - - def test_radar3_tx_is_wxia_not_a_copy_of_rx(self): - """Regression: tx_lat was once byte-identical to rx_lat, putting the - illuminator ~20 km north of WXIA-TV and biasing every radar3 solve. - A wrong coordinate is silent — only the delay residual shows it.""" - r3 = next(n.config for n in load_nodes(config_file_path()) if n.node_id == "radar3-retnode") - assert r3["tx_lat"] != r3["rx_lat"] - assert (r3["tx_lat"], r3["tx_lon"]) == (33.756667, -84.331944) - assert r3["fc_hz"] == 195_000_000 - - def test_every_node_tx_differs_from_its_rx(self): - """A bistatic pair with TX on top of RX has no baseline to solve against.""" - for node in load_nodes(config_file_path()): - c = node.config - assert (c["tx_lat"], c["tx_lon"]) != (c["rx_lat"], c["rx_lon"]), node.node_id - - def test_registers_a_staleness_key_per_node(self): - for node in load_nodes(config_file_path()): - assert task_key(node.node_id) in TASK_EXPECTED_INTERVAL_S - - -# ── Loading arbitrary configs ───────────────────────────────────────────────── - - -class TestLoadNodes: - def test_loads_arbitrary_node_count(self, tmp_path): - entries = [] - for i in range(5): - e = dict(MINIMAL) - e["node_id"] = f"n{i}" - e["detection_url"] = f"https://host{i}.test/api/detection" - entries.append(e) - nodes = load_nodes(_write(tmp_path, {"nodes": entries})) - assert [n.node_id for n in nodes] == [f"n{i}" for i in range(5)] - - def test_bare_list_is_accepted(self, tmp_path): - assert len(load_nodes(_write(tmp_path, [MINIMAL]))) == 1 - - def test_missing_file_yields_no_nodes(self, tmp_path): - assert load_nodes(tmp_path / "nope.json") == [] - - def test_malformed_json_yields_no_nodes(self, tmp_path): - p = tmp_path / "blah2_nodes.json" - p.write_text("{not json") - assert load_nodes(p) == [] - - def test_bad_entry_is_skipped_others_survive(self, tmp_path): - """One broken node must not take the rest of the network off the air.""" - good = dict(MINIMAL, node_id="good", detection_url="https://g.test/api/detection") - bad = dict(MINIMAL, node_id="bad", fc_hz="not-a-number") - nodes = load_nodes(_write(tmp_path, {"nodes": [bad, good]})) - assert [n.node_id for n in nodes] == ["good"] - - def test_duplicate_node_id_is_skipped(self, tmp_path): - a = dict(MINIMAL, detection_url="https://a.test/api/detection") - b = dict(MINIMAL, detection_url="https://b.test/api/detection") - nodes = load_nodes(_write(tmp_path, {"nodes": [a, b]})) - assert len(nodes) == 1 - - def test_duplicate_url_is_skipped(self, tmp_path): - a = dict(MINIMAL, node_id="a") - b = dict(MINIMAL, node_id="b") - nodes = load_nodes(_write(tmp_path, {"nodes": [a, b]})) - assert [n.node_id for n in nodes] == ["a"] - - def test_env_override_wins(self, tmp_path, monkeypatch): - p = _write(tmp_path, {"nodes": [MINIMAL]}) - monkeypatch.setenv("BLAH2_NODES_FILE", str(p)) - assert config_file_path() == p - - -# ── Entry validation ────────────────────────────────────────────────────────── - - -class TestBuildNode: - def test_optional_fields_get_defaults(self): - cfg = _build_node(MINIMAL).config - assert cfg["fs_hz"] == 2_000_000 - assert cfg["beam_width_deg"] == 42.0 - assert cfg["max_range_km"] == 140 - - def test_fc_and_fs_aliases_track_each_other(self): - """The pipeline factory reads FC/Fs, analytics reads fc_hz/fs_hz.""" - cfg = _build_node(MINIMAL).config - assert cfg["FC"] == cfg["fc_hz"] - assert cfg["Fs"] == cfg["fs_hz"] - - def test_peer_is_the_hostname(self): - assert _build_node(MINIMAL).peer == "example.test" - - @pytest.mark.parametrize( - "mutation,field", - [ - ({"node_id": None}, "node_id"), - ({"detection_url": None}, "detection_url"), - ({"fc_hz": None}, "fc_hz"), - ({"rx_lat": None}, "rx_lat"), - ], - ) - def test_missing_required_field_rejected(self, mutation, field): - with pytest.raises(Blah2ConfigError, match=field): - _build_node({**MINIMAL, **mutation}) - - @pytest.mark.parametrize("url", ["ftp://h/a", "h/a", "", "file:///etc/passwd"]) - def test_non_http_url_rejected(self, url): - with pytest.raises(Blah2ConfigError, match="http"): - _build_node({**MINIMAL, "detection_url": url}) - - @pytest.mark.parametrize( - "field,value", - [ - ("rx_lat", 91), - ("tx_lat", -91), - ("rx_lon", 181), - ("tx_lon", -181), - ], - ) - def test_out_of_range_coordinates_rejected(self, field, value): - with pytest.raises(Blah2ConfigError, match="out of range"): - _build_node({**MINIMAL, field: value}) - - @pytest.mark.parametrize("field", ["fc_hz", "fs_hz"]) - def test_non_positive_frequency_rejected(self, field): - with pytest.raises(Blah2ConfigError, match="positive"): - _build_node({**MINIMAL, field: 0}) - - def test_non_numeric_rejected(self): - with pytest.raises(Blah2ConfigError, match="not a number"): - _build_node({**MINIMAL, "rx_lon": "west"}) - - def test_non_dict_entry_rejected(self): - with pytest.raises(Blah2ConfigError, match="must be an object"): - _build_node("radar3") - - -# ── Frame conversion ────────────────────────────────────────────────────────── - - -class TestConvertFrame: - def _raw(self, ts_ms): - return { - "timestamp": ts_ms, - "delay": [19.86, 18.27], - "doppler": [-160.62, -111.43], - "snr": [10.06, 5.81], - } - - def test_tags_frame_with_its_own_node_id(self): - """Frames from different nodes must stay attributable in the shared queue.""" - now_ms = int(time.time() * 1000) - for node_id in ("radar3-retnode", "radar3a-retnode"): - assert _convert_frame(self._raw(now_ms), node_id)["_node_id"] == node_id - - def test_delay_converted_km_to_us(self): - from config.constants import C_KM_US - - raw = self._raw(int(time.time() * 1000)) - frame = _convert_frame(raw, "n1") - assert frame["delay"] == [d / C_KM_US for d in raw["delay"]] - - def test_stale_frame_rejected(self): - assert _convert_frame(self._raw(1_000_000), "n1") is None - - def test_empty_frame_rejected(self): - assert _convert_frame({"timestamp": 0, "delay": []}, "n1") is None - - -# ── Frame ordering ──────────────────────────────────────────────────────────── - - -class _StopPolling(BaseException): - """Ends the bridge's infinite loop; BaseException so its `except Exception` misses it.""" - - -class TestFrameOrdering: - """Only forward timestamps reach the queue. - - A repeat is a cached response and a lower one is a clock step backwards; - either would hand the tracker a non-positive dt. Nothing between the queue - and `Tracker.process_frame` re-orders, so this guard is the only defence - against it on the bridge's path. - """ - - async def _enqueued_timestamps(self, monkeypatch, timestamps): - """Run the bridge over a scripted timestamp sequence; return what it queued.""" - import asyncio - - from core import state - from services import blah2_bridge - - base_ms = int(time.time() * 1000) - raw_frames = [ - { - "timestamp": base_ms + offset_ms, - "delay": [19.86], - "doppler": [-160.62], - "snr": [10.06], - } - for offset_ms in timestamps - ] - - class _Response: - def __init__(self, payload): - self._payload = payload - - def raise_for_status(self): - pass - - def json(self): - return self._payload - - class _Client: - def __init__(self, *_, **__): - self._remaining = list(raw_frames) - - async def __aenter__(self): - return self - - async def __aexit__(self, *_): - return False - - async def get(self, _url): - if not self._remaining: - raise _StopPolling - return _Response(self._remaining.pop(0)) - - monkeypatch.setattr(blah2_bridge.httpx, "AsyncClient", _Client) - monkeypatch.setattr(blah2_bridge, "_register_node", _noop_register) - monkeypatch.setattr(blah2_bridge, "POLL_INTERVAL_S", 0) - monkeypatch.setattr(state, "frame_queue", asyncio.Queue()) - - node = _build_node(MINIMAL) - with pytest.raises(_StopPolling): - await blah2_bridge.blah2_bridge_task(node) - - queued = [] - while not state.frame_queue.empty(): - _node_id, frame = state.frame_queue.get_nowait() - queued.append(frame["timestamp"] - base_ms) - return queued - - async def test_first_frame_passes(self, monkeypatch): - """`last_ts` starts at 0, so no special case is needed for the first frame.""" - assert await self._enqueued_timestamps(monkeypatch, [0]) == [0] - - async def test_repeat_dropped(self, monkeypatch): - assert await self._enqueued_timestamps(monkeypatch, [0, 0, 0]) == [0] - - async def test_older_frame_dropped(self, monkeypatch): - assert await self._enqueued_timestamps(monkeypatch, [1000, 500]) == [1000] - - async def test_newer_frame_after_older_still_passes(self, monkeypatch): - """An out-of-order frame must not wedge the node against later good ones.""" - assert await self._enqueued_timestamps(monkeypatch, [1000, 500, 2000]) == [1000, 2000] diff --git a/backend/tests/test_ingest_event_loop.py b/backend/tests/test_ingest_event_loop.py index 1bdc14d2..ec776dde 100644 --- a/backend/tests/test_ingest_event_loop.py +++ b/backend/tests/test_ingest_event_loop.py @@ -10,8 +10,7 @@ `services/tcp_handler.py` already dispatches registration to a dedicated executor. These tests hold every other path to the same rule: both HTTP ingest -endpoints, the v1 pipeline path in `services/node_pipeline.py`, and the blah2 -bridge. +endpoints and the v1 pipeline path in `services/node_pipeline.py`. """ import asyncio @@ -162,12 +161,11 @@ async def test_the_registration_reaches_the_associator(self, client, monkeypatch assert [nid for nid, _ in seen] == ["test-passthrough"] -# ── The paths that do not go through an HTTP handler ────────────────────────── +# ── The path that does not go through an HTTP handler ───────────────────────── # -# The two below reach the same registration from elsewhere: `node_pipeline` on -# the v1 path (POST /v1/nodes/register in-request, and once per node at -# startup), and `blah2_bridge` for the real receivers. Neither can be driven -# through the ASGI client, so they are timed directly. +# `node_pipeline` reaches the same registration from elsewhere on the v1 path +# (POST /v1/nodes/register in-request, and once per node at startup). It cannot +# be driven through the ASGI client, so it is timed directly. # The v1 node's stored configuration. Of these only beam_width_deg is nullable, # and it is given a value here rather than the null a real node carries: a null @@ -253,7 +251,7 @@ async def v1_node(node_session): # a second cleanup path to keep in step with the first. -class TestTheNonHttpPathsDoNotBlockTheLoop: +class TestTheNonHttpPathDoesNotBlockTheLoop: async def test_v1_pipeline_registration_leaves_the_loop_free(self, node_session, v1_node, slow_registration): from services.node_pipeline import register_with_pipeline @@ -261,27 +259,8 @@ async def test_v1_pipeline_registration_leaves_the_loop_free(self, node_session, assert stall < RESPONSIVE_S - async def test_blah2_bridge_registration_leaves_the_loop_free(self, slow_registration): - from services.blah2_bridge import _build_node, _register_node - - node = _build_node( - { - "node_id": "test-loop-blah2", - "detection_url": "https://example.test/api/detection", - "rx_lat": 33.9, - "rx_lon": -84.6, - "tx_lat": 33.8, - "tx_lon": -84.1, - "fc_hz": 177_000_000, - } - ) - - stall = await _longest_stall_during(_register_node(node)) - - assert stall < RESPONSIVE_S - -class TestTheNonHttpRegistrationsStillHappen: +class TestTheNonHttpRegistrationStillHappens: async def test_the_v1_node_reaches_the_registries(self, node_session, v1_node): from core import state from services.node_pipeline import register_with_pipeline @@ -291,24 +270,3 @@ async def test_the_v1_node_reaches_the_registries(self, node_session, v1_node): assert state.connected_nodes[_V1_NODE_ID]["peer"] == "v1" assert state.node_associator.node_geometries[_V1_NODE_ID].rx_lat == _V1_CONFIG["rx_lat"] assert state.node_analytics.detection_areas[_V1_NODE_ID].rx_lat == _V1_CONFIG["rx_lat"] - - async def test_the_blah2_node_reaches_the_registries(self): - from core import state - from services.blah2_bridge import _build_node, _register_node - - node = _build_node( - { - "node_id": "test-registered-blah2", - "detection_url": "https://example.test/api/detection", - "rx_lat": 33.9, - "rx_lon": -84.6, - "tx_lat": 33.8, - "tx_lon": -84.1, - "fc_hz": 177_000_000, - } - ) - - await _register_node(node) - - assert state.connected_nodes["test-registered-blah2"]["peer"] == node.peer - assert state.node_associator.node_geometries["test-registered-blah2"].rx_lat == 33.9 diff --git a/backend/tests/test_node_pipeline.py b/backend/tests/test_node_pipeline.py index a651b81a..1fd516a0 100644 --- a/backend/tests/test_node_pipeline.py +++ b/backend/tests/test_node_pipeline.py @@ -1,4 +1,4 @@ -"""A v1 node has to look to the pipeline exactly like a blah2_bridge node. +"""A v1 node has to look to the pipeline like any other source. The assertions here read the real registries rather than spying on calls: what matters is that analytics and the associator end up knowing the node's @@ -100,7 +100,7 @@ async def test_registration_reaches_analytics_and_the_associator(node_session, n assert state.node_associator.node_geometries[NODE_ID].rx_lat == 51.42 -async def test_the_pipeline_config_carries_the_defaults_blah2_bridge_supplies(node_session, node): +async def test_the_pipeline_config_carries_the_defaults_the_wire_config_omits(node_session, node): await register_with_pipeline(node_session, node) config = state.connected_nodes[NODE_ID]["config"] @@ -278,11 +278,7 @@ async def test_startup_priming_loads_the_fleet_from_the_app_session(tmp_path, no async def test_startup_priming_survives_a_database_failure(monkeypatch): - """A nodes table that is not there yet must not take the whole API down. - - blah2_bridge is this phase's rollback and runs in the same process, so a - priming failure that killed startup would take the fallback with it. - """ + """A nodes table that is not there yet must not take the whole API down.""" def _no_such_table(): raise OperationalError("SELECT nodes.node_id FROM nodes", {}, Exception("no such table: nodes")) diff --git a/docs/runbook.md b/docs/runbook.md index f463999d..7245a037 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -293,9 +293,7 @@ The health check only monitors these three tasks (defined in `_CRITICAL_TASKS` i | `aircraft_flush` | ~5 s | 15 s | | `analytics_refresh` | 30 s | 120 s | -The blah2 bridge tasks and `solver` update `task_last_success` but are **not** checked by `/api/health` — their alerts fire via separate mechanisms (`solver_latency_high`, `solver_queue_drops`). - -The bridge reports one task key per live node — `blah2_bridge:` — so a single node going dark is visible on its own instead of being masked by its neighbours. The keys are registered at startup from the node list, which is config, not code (see below). +`solver` updates `task_last_success` but is **not** checked by `/api/health` — its alerts fire via separate mechanisms (`solver_latency_high`, `solver_queue_drops`). **Check logs for exceptions in the named task:** ```bash @@ -304,28 +302,16 @@ docker compose logs --tail=500 | grep -i "error\|exception\|traceback" | tail -2 `frame_processor` stale is the most serious — it means detection frames are piling up unprocessed or the loop crashed. If the loop crashed, the container needs a restart (tasks are daemon threads and will not restart themselves). -A stale `blah2_bridge:` means that node's `/api/detection` is unreachable or serving only stale frames; other nodes are unaffected. Stale bridge keys are expected wherever there is no upstream retnode access — safe to ignore there. - -### Adding or changing a live blah2 node - -The node list is `blah2_nodes.json` — url, rx, tx, fc and friends per node — read through the runtime-config overlay, so this is a config change with no rebuild: - -```bash -docker compose exec server vi /app/backend/data/runtime/blah2_nodes.json -``` - -Restart the container afterwards; the list is read once, at startup. - -`backend/config/blah2_nodes.json` in the repo is the shipped default that seeds that overlay on first boot. Once the overlay exists it wins, so editing the repo copy will not change a running deployment. `BLAH2_NODES_FILE` overrides the path entirely. +### Checking a node's configured geometry -After a change, confirm the node registered and is solving sensibly: +After a node's geometry changes, confirm it registered and is solving sensibly: ```bash curl -sk https://localhost/api/radar/nodes | jq '.nodes | keys' ``` ```bash -curl -sk https://localhost/api/test/node/radar3a-retnode/verification | jq '{n_tracks, n_matched, position}' +curl -sk https://localhost/api/test/node//verification | jq '{n_tracks, n_matched, position}' ``` A node missing from the first list failed validation — the reason is logged at error level, naming the offending field. diff --git a/docs/simulation.md b/docs/simulation.md index 9a58a975..b088e072 100644 --- a/docs/simulation.md +++ b/docs/simulation.md @@ -123,11 +123,6 @@ changes, not code changes. Current staging scale (`docker-compose.staging.yml`): | `FLEET_MODE` | `adsb` | Merge the real ADS-B feed | | `FLEET_INTERVAL` | 0.5 s | Frame interval per node | -Two real hardware nodes (`radar3*-retnode`, via the blah2 bridge near -Atlanta) connect alongside the synthetic fleet; their geometry lives in -`backend/config/blah2_nodes.json` (42° Yagis) with a runtime overlay copy -under `backend/data/runtime/`. - --- ## Real ADS-B Feed (`AdsbLolClient`) diff --git a/docs/solverflow.md b/docs/solverflow.md index 82f70e37..e3f571af 100644 --- a/docs/solverflow.md +++ b/docs/solverflow.md @@ -42,7 +42,7 @@ labeled on the arrow. ```mermaid flowchart TD - ingest["Ingest: 5 producers"] --> fq[["frame_queue asyncio.Queue"]] + ingest["Ingest: 4 producers"] --> fq[["frame_queue asyncio.Queue"]] fq --> fp["frame_processor_loop: process_one_frame"] fp --> known["Known lane: claiming"] @@ -88,9 +88,8 @@ own. Everything that reaches a solve passes through one gate stack ```mermaid flowchart TD - subgraph producers["Five producers"] + subgraph producers["Four producers"] p1["TCP (primary)
tcp_handler.py:326"] - p2["blah2 bridge
blah2_bridge.py:289"] p3["v1 node HTTP API
node_stream.py:250"] p4["Legacy HTTP radar routes
routes/radar.py:151,202"] p5["Startup priming
node_pipeline.py:139"] @@ -105,7 +104,6 @@ flowchart TD gC -->|"yes"| dropC["frames_dropped counter
+ rate-limited warning"]:::inert gC -->|"no"| fq[["frame_queue"]] - p2 --> fq p3 --> gD{"node in
state.connected_nodes?"} gD -->|"no"| dropD["frames_dropped + refused"]:::inert gD -->|"yes"| fq @@ -149,7 +147,6 @@ one of them. | `process_one_frame` entry | — | `services/frame_processor.py:294` | | Ordering rationale (claim → seed → tracker) | — | `services/frame_processor.py:327-337` | | Gate 2.10: `n_nodes < 2` skip | — | `services/frame_processor.py:409-426` | -| blah2 poll interval | 1.0 s | `config/constants.py:266` | --- From d0302058b031e160f4dee1f7cb241960d2622307 Mon Sep 17 00:00:00 2001 From: Babissimo Date: Tue, 8 Sep 2026 16:35:05 +0100 Subject: [PATCH 2/6] fix(nodes): address review of the blah2 bridge removal Deleting the bridge left `register_task` with no callers, which fails the dead-code hook and so CI. The bridge was the only thing registering task keys that config, not code, decided; nothing else needs the entry point. Restores the two /api/test/radar3/* aliases. They were added for callers that predate the per-node routes, and those callers are on the droplets rather than in this repo, so a grep proving no in-repo use proves nothing about what would start 404ing. They can go with the node itself. DEFAULT_NODE_CONFIG is the retired radar3 geometry. The deleted node list was the only place saying so, and the coordinates outlived it, so the provenance is now recorded where the numbers are. The doppler triple in node_pipeline points at the same place rather than asserting a provenance a reader cannot check. Runbook command took a placeholder, which bash reads as a redirection; uses a variable instead so it survives being pasted. Two solverflow anchors had drifted by a line or two when docstrings shrank. Not changed, recorded here so the next reader does not re-derive it: a frame staleness gate at v1 ingest would contradict the published contract, which says a late frame carries an old `t` and is rejected by the association gate instead. Node geometry is validated by services/node_config.py, which is shared by registration and PUT and is stricter than the bridge's own checks were. Co-Authored-By: Claude Opus 5 --- backend/core/task_registry.py | 9 --------- backend/pipeline/passive_radar.py | 6 ++++++ backend/routes/test.py | 17 +++++++++++++++++ backend/services/node_pipeline.py | 4 ++-- docs/runbook.md | 2 +- docs/solverflow.md | 4 ++-- 6 files changed, 28 insertions(+), 14 deletions(-) diff --git a/backend/core/task_registry.py b/backend/core/task_registry.py index db8bdd01..ce86c039 100644 --- a/backend/core/task_registry.py +++ b/backend/core/task_registry.py @@ -24,15 +24,6 @@ } -def register_task(name: str, expected_interval_s: int) -> None: - """Add a dynamically-discovered task to the staleness registry. - - For tasks whose number is not known until config is read. Idempotent, so - re-reading a config file does not disturb an already-registered task. - """ - TASK_EXPECTED_INTERVAL_S.setdefault(name, expected_interval_s) - - def get_stale_tasks() -> list[str]: """Tasks that have not reported success within 2x their expected interval. diff --git a/backend/pipeline/passive_radar.py b/backend/pipeline/passive_radar.py index 7ef221d4..0c061592 100644 --- a/backend/pipeline/passive_radar.py +++ b/backend/pipeline/passive_radar.py @@ -51,6 +51,12 @@ from services.publication import is_private # ─── Node Configuration ───────────────────────────────────────────── +# Fallback geometry only, for a frame whose node is absent from the registries; +# a registered node is always solved against its own configuration. The numbers +# are the retired radar3 site (RX Wilderness, illuminated by WXIA-TV on ch 10), +# kept because they are a self-consistent bistatic pair rather than because that +# site still exists. Anything solved against them lands near Atlanta, which is +# the tell that a node reached the pipeline unregistered. DEFAULT_NODE_CONFIG = { "node_id": "net13", "Fs": 2_000_000, # Sample rate Hz diff --git a/backend/routes/test.py b/backend/routes/test.py index 7980f12d..dd73ca72 100644 --- a/backend/routes/test.py +++ b/backend/routes/test.py @@ -659,6 +659,11 @@ def _mlat_verification_summary() -> dict: # ── Per-node solver verification ────────────────────────────────────────────── +# The radar3 aliases below predate the per-node routes and are kept until the +# node itself is decommissioned: whatever calls them lives on the droplets, not +# in this repo, so removing them here would 404 something no grep can find. +_RADAR3_NODE_ID = "radar3-retnode" + @router.get("/api/test/node/{node_id}/verification") async def node_verification(node_id: str): @@ -669,6 +674,12 @@ async def node_verification(node_id: str): ) +@router.get("/api/test/radar3/verification") +async def radar3_verification(): + """Back-compat alias for the radar3 node's verification stats.""" + return await node_verification(_RADAR3_NODE_ID) + + @router.get("/api/test/mlat-verification") async def mlat_verification(): """Return pre-computed multinode (MLAT) solver-vs-ground-truth verification stats.""" @@ -1066,3 +1077,9 @@ async def node_detection_range(node_id: str): ), media_type="application/json", ) + + +@router.get("/api/test/radar3/detection-range") +async def radar3_detection_range(): + """Back-compat alias for the radar3 node's detection range.""" + return await node_detection_range(_RADAR3_NODE_ID) diff --git a/backend/services/node_pipeline.py b/backend/services/node_pipeline.py index 1d0322cb..e73e2194 100644 --- a/backend/services/node_pipeline.py +++ b/backend/services/node_pipeline.py @@ -18,8 +18,8 @@ logger = logging.getLogger(__name__) -# The pipeline expects three fields the v1 wire config does not carry; these -# are the values the nodes actually run with, not invented ones. +# The pipeline expects three fields the v1 wire config does not carry. The same +# triple is in pipeline/passive_radar.py's DEFAULT_NODE_CONFIG; keep them equal. _PIPELINE_DEFAULTS = {"doppler_min": -300, "doppler_max": 300, "min_doppler": 15} # beam_azimuth_deg is passed through rather than defaulted: null is broadside diff --git a/docs/runbook.md b/docs/runbook.md index 7245a037..e91c9085 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -311,7 +311,7 @@ curl -sk https://localhost/api/radar/nodes | jq '.nodes | keys' ``` ```bash -curl -sk https://localhost/api/test/node//verification | jq '{n_tracks, n_matched, position}' +NODE_ID=radar3a-retnode; curl -sk "https://localhost/api/test/node/$NODE_ID/verification" | jq '{n_tracks, n_matched, position}' ``` A node missing from the first list failed validation — the reason is logged at error level, naming the offending field. diff --git a/docs/solverflow.md b/docs/solverflow.md index e3f571af..268f9964 100644 --- a/docs/solverflow.md +++ b/docs/solverflow.md @@ -90,9 +90,9 @@ own. Everything that reaches a solve passes through one gate stack flowchart TD subgraph producers["Four producers"] p1["TCP (primary)
tcp_handler.py:326"] - p3["v1 node HTTP API
node_stream.py:250"] + p3["v1 node HTTP API
node_stream.py:284"] p4["Legacy HTTP radar routes
routes/radar.py:151,202"] - p5["Startup priming
node_pipeline.py:139"] + p5["Startup priming
node_pipeline.py:137"] end p1 --> gA{"Gate A: timestamp present?"} From a358a9debdb9e97f96442a0e5342c4c6974a484c Mon Sep 17 00:00:00 2001 From: Babissimo Date: Tue, 8 Sep 2026 17:58:29 +0100 Subject: [PATCH 3/6] docs(nodes): correct what the bridge removal left pointing at itself The comment on DEFAULT_NODE_CONFIG named the receive site and its city right above that site's coordinates. services/public_location.py exists because a receiver sits at someone's home, and this repo is public, so which site it is now lives in the ticket and the comment keeps only the provenance. It also claimed a registered node is always solved against its own config, which __init__ does not guarantee: any falsy node_config takes the fallback. The runbook section was retitled to generic per-node guidance but kept two sentences that only described the bridge. Missing from /api/radar/nodes no longer means an entry was skipped and logged at startup; an invalid config is refused at the PUT. The delay-residual check is still the way to catch bad geometry, but it needs a node publishing adsb[].expected_delay, which a v1 node does not, so it is scoped rather than presented as available. Producer anchors in solverflow now name symbols. Renumbering them by hand was not working: the pass that corrected two missed main.py:164, and one of the two pointed at submit_frame's queue push while labelled startup priming. The adsb branch in frame_processor says which source still reaches it, and the radar3 alias comment cites the commits that establish the ordering it asserts. Co-Authored-By: Claude Opus 5 --- backend/pipeline/passive_radar.py | 14 ++++++++------ backend/routes/test.py | 7 ++++--- backend/services/frame_processor.py | 9 +++++---- docs/runbook.md | 10 ++++++---- docs/solverflow.md | 10 +++++----- 5 files changed, 28 insertions(+), 22 deletions(-) diff --git a/backend/pipeline/passive_radar.py b/backend/pipeline/passive_radar.py index 0c061592..77f85278 100644 --- a/backend/pipeline/passive_radar.py +++ b/backend/pipeline/passive_radar.py @@ -51,12 +51,14 @@ from services.publication import is_private # ─── Node Configuration ───────────────────────────────────────────── -# Fallback geometry only, for a frame whose node is absent from the registries; -# a registered node is always solved against its own configuration. The numbers -# are the retired radar3 site (RX Wilderness, illuminated by WXIA-TV on ch 10), -# kept because they are a self-consistent bistatic pair rather than because that -# site still exists. Anything solved against them lands near Atlanta, which is -# the tell that a node reached the pipeline unregistered. +# Fallback geometry for a frame whose node has no configuration of its own: a +# real receiver and its illuminator, kept because they are a self-consistent +# bistatic pair. Which site, and the fit behind the numbers, is in ClickUp +# 86cb6385b rather than here: a receiver position is someone's home and this +# repo is public. +# +# __init__ takes this branch for any falsy node_config, an empty dict included, +# so being registered is not on its own enough to be solved against your own. DEFAULT_NODE_CONFIG = { "node_id": "net13", "Fs": 2_000_000, # Sample rate Hz diff --git a/backend/routes/test.py b/backend/routes/test.py index dd73ca72..b00098a6 100644 --- a/backend/routes/test.py +++ b/backend/routes/test.py @@ -659,9 +659,10 @@ def _mlat_verification_summary() -> dict: # ── Per-node solver verification ────────────────────────────────────────────── -# The radar3 aliases below predate the per-node routes and are kept until the -# node itself is decommissioned: whatever calls them lives on the droplets, not -# in this repo, so removing them here would 404 something no grep can find. +# The radar3 aliases below predate the per-node routes (eac190a 2026-04-09, vs +# 4906b32 2026-08-05) and are kept until the node itself is decommissioned: +# whatever calls them lives on the droplets, not in this repo, so removing them +# here would 404 something no grep can find. _RADAR3_NODE_ID = "radar3-retnode" diff --git a/backend/services/frame_processor.py b/backend/services/frame_processor.py index 34e5d349..79431aef 100644 --- a/backend/services/frame_processor.py +++ b/backend/services/frame_processor.py @@ -444,10 +444,11 @@ def process_one_frame(node_id: str, frame: dict, default_pipeline: PassiveRadarP ) _d_assoc = time.thread_time() - _t2 - # ADS-B extraction: TCP handler runs _apply_synthetic_adsb for synth nodes - # before queuing. For non-TCP sources the adsb list arrives here still - # unextracted — store those positions now so the verification and accuracy - # pipelines can reference them. + # ADS-B extraction. Only TCP frames still reach here carrying an `adsb` + # list: v1 files its association under `adsb_hex` and the legacy radar routes + # carry no list at all. _apply_synthetic_adsb has already read it for its + # own purposes without consuming it, so these positions are stored again + # here, where the verification and accuracy pipelines can reference them. _adsb_list = frame.get("adsb") if _adsb_list: _ts_ms = int(time.time() * 1000) diff --git a/docs/runbook.md b/docs/runbook.md index e91c9085..a6caea75 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -293,7 +293,7 @@ The health check only monitors these three tasks (defined in `_CRITICAL_TASKS` i | `aircraft_flush` | ~5 s | 15 s | | `analytics_refresh` | 30 s | 120 s | -`solver` updates `task_last_success` but is **not** checked by `/api/health` — its alerts fire via separate mechanisms (`solver_latency_high`, `solver_queue_drops`). +`solver` updates `task_last_success` but is **not** checked by `/api/health`: its alerts fire via separate mechanisms (`solver_latency_high`, `solver_queue_drops`). **Check logs for exceptions in the named task:** ```bash @@ -311,12 +311,14 @@ curl -sk https://localhost/api/radar/nodes | jq '.nodes | keys' ``` ```bash -NODE_ID=radar3a-retnode; curl -sk "https://localhost/api/test/node/$NODE_ID/verification" | jq '{n_tracks, n_matched, position}' +NODE_ID=ret0123abcd; curl -sk "https://localhost/api/test/node/$NODE_ID/verification" | jq '{n_tracks, n_matched, position}' ``` -A node missing from the first list failed validation — the reason is logged at error level, naming the offending field. +A node missing from the first list has not registered, or has no active configuration. An invalid one is refused at the config PUT with a 4xx naming the offending field (`services/node_config.py`), so it never reaches this list to be missing from. -Bad geometry passes validation, and `position.median_km` will *not* reliably catch it: that figure is dominated by the single-node solver's own ~25–35 km uncertainty. A deliberate 20 km TX error moved it by about 5 km, inside the run-to-run spread. To check tx/rx/fc against the hardware, compare the node's published `adsb[].expected_delay` with the bistatic delay computed from the configured geometry — correct config agrees to tens of metres, a 20 km TX error to tens of kilometres. +Bad geometry passes validation, and `position.median_km` will *not* reliably catch it: that figure is dominated by the single-node solver's own ~25–35 km uncertainty. A deliberate 20 km TX error moved it by about 5 km, inside the run-to-run spread. + +What does catch it is the delay residual: compare the node's published `adsb[].expected_delay` with the bistatic delay computed from the configured geometry, which agrees to tens of metres when the config is right and to tens of kilometres under a 20 km TX error. This needs a node that publishes that array. A v1 node sends `adsb_hex` alone, so it has no equivalent check yet (86cb7fdhg). --- diff --git a/docs/solverflow.md b/docs/solverflow.md index 268f9964..f975e65e 100644 --- a/docs/solverflow.md +++ b/docs/solverflow.md @@ -78,7 +78,7 @@ own. Everything that reaches a solve passes through one gate stack |---|---|---| | `frame_queue` size (`FRAME_QUEUE_SIZE`) | 10000 | `core/state.py:358-359` | | `solver_queue` size (`SOLVER_QUEUE_SIZE`) | 200 | `core/state.py:365-366` | -| `FRAME_WORKERS` | 4 (compose sets 6) | `main.py:164`, `docker-compose.yml:54` | +| `FRAME_WORKERS` | 4 (compose sets 6) | `FRAME_WORKERS` in `main.py`, `docker-compose.yml:54` | | `SOLVER_WORKERS` | 2 daemon threads + same-size process pool | `services/tasks/solver.py:31,67` | | `KNOWN_LANE_MODE` default | `binding` | `core/state.py:72-74` | @@ -89,10 +89,10 @@ own. Everything that reaches a solve passes through one gate stack ```mermaid flowchart TD subgraph producers["Four producers"] - p1["TCP (primary)
tcp_handler.py:326"] - p3["v1 node HTTP API
node_stream.py:284"] - p4["Legacy HTTP radar routes
routes/radar.py:151,202"] - p5["Startup priming
node_pipeline.py:137"] + p1["TCP (primary)
tcp_handler.handle_tcp_client"] + p3["v1 node HTTP API
node_stream.post_detection"] + p4["Legacy HTTP radar routes
radar.ingest_detections(_bulk)"] + p5["Startup priming
node_pipeline.prime_pipeline_at_startup"] end p1 --> gA{"Gate A: timestamp present?"} From 400a75f81ca30f1359ca984763c010a709f3fa1d Mon Sep 17 00:00:00 2001 From: Babissimo Date: Wed, 9 Sep 2026 11:53:35 +0100 Subject: [PATCH 4/6] docs(nodes): stop node_sites and pipeline_frame citing the bridge pipeline_frame moved to node_pipeline in #278 and brought its docstring with it, which still described the frame shape as the one blah2_bridge puts on the queue. The merge took main's version of the function and left the text; this finishes that. node_sites reads blah2_nodes.json and keeps doing so: while a droplet still holds a seeded copy, that file is what tells the module radar3 and radar3a share a roof, which is what keeps their published positions fuzzed as one site. Only the comments change, to stop calling it the bridge's list now that nothing writes it. Co-Authored-By: Claude Opus 5 --- backend/services/node_pipeline.py | 9 ++++----- backend/services/node_sites.py | 12 +++++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/backend/services/node_pipeline.py b/backend/services/node_pipeline.py index 2597ff4c..494f7e3a 100644 --- a/backend/services/node_pipeline.py +++ b/backend/services/node_pipeline.py @@ -64,13 +64,12 @@ async def _pipeline_config(session: AsyncSession, node_id: str) -> dict: def pipeline_frame(frame: "DetectionFrame") -> dict: - """The wire frame in the shape services/blah2_bridge.py puts on the queue. + """The wire frame in the shape the frame queue's readers expect. - `timestamp` is milliseconds because that is what the queue's readers expect. - `delay` needs no conversion: it is microseconds on the wire, where the bridge - has to convert from kilometres. + `timestamp` is milliseconds, and `delay` needs no conversion: it is + microseconds on the wire and microseconds on the queue. - `adsb_hex` travels under its own key rather than the bridge's `adsb`, which + `adsb_hex` travels under its own key rather than `adsb`, which frame_processor reads as position reports. The contract's array is an association and carries no lat/lon, so filing it there would be filing an empty position for every detection. diff --git a/backend/services/node_sites.py b/backend/services/node_sites.py index fcd98b4d..cd9fcbce 100644 --- a/backend/services/node_sites.py +++ b/backend/services/node_sites.py @@ -77,8 +77,10 @@ # config files already use. _SITE_DECIMALS = 6 -# The runtime files that define nodes this deployment did not register: the -# blah2 bridge's node list and the synthetic fleet's config. +# The runtime files that define nodes this deployment did not register: a +# legacy geometry list and the synthetic fleet's config. Nothing writes +# blah2_nodes.json any more, but a deployment seeded before the poller was +# removed still has one, and its nodes still share a roof. _NODE_FILES = ("blah2_nodes.json", "nodes_config.json") _lock = threading.Lock() @@ -126,9 +128,9 @@ def _positions_from_live() -> dict[str, tuple[float, float]]: def _positions_from_files() -> dict[str, tuple[float, float]]: """Nodes defined by a runtime file rather than by registration. - The blah2 bridge's nodes and the synthetic fleet's live here and never - reach the database, so a site shared between two of them — which is the - case this module exists for — is invisible without reading the files. + These nodes and the synthetic fleet's never reach the database, so a site + shared between two of them — which is the case this module exists for — is + invisible without reading the files. """ out = {} for name in _NODE_FILES: From e3b964a4fe12d3544c91cc5d8ebd13a73bf6e6fa Mon Sep 17 00:00:00 2001 From: Babissimo Date: Wed, 9 Sep 2026 12:12:35 +0100 Subject: [PATCH 5/6] docs(nodes): name every copy of the pipeline default triple The comment added with the bridge removal named one of the two other copies of doppler_min/doppler_max/min_doppler. frame_processor carries the same values a third time as cfg.get fallbacks, so an editor following the comment and updating passive_radar would have called it synced and left that one behind, which is the drift the comment exists to prevent. Co-Authored-By: Claude Opus 5 --- backend/services/node_pipeline.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/services/node_pipeline.py b/backend/services/node_pipeline.py index 494f7e3a..0013db2c 100644 --- a/backend/services/node_pipeline.py +++ b/backend/services/node_pipeline.py @@ -24,7 +24,8 @@ logger = logging.getLogger(__name__) # The pipeline expects three fields the v1 wire config does not carry. The same -# triple is in pipeline/passive_radar.py's DEFAULT_NODE_CONFIG; keep them equal. +# triple is in pipeline/passive_radar.py's DEFAULT_NODE_CONFIG and again as +# frame_processor's cfg.get fallbacks; all three have to move together. _PIPELINE_DEFAULTS = {"doppler_min": -300, "doppler_max": 300, "min_doppler": 15} # beam_azimuth_deg is passed through rather than defaulted: null is broadside From b0385199247aa12af390c4e5e6734c774d9ab811 Mon Sep 17 00:00:00 2001 From: Babissimo Date: Wed, 9 Sep 2026 13:17:45 +0100 Subject: [PATCH 6/6] chore(nodes): drop the radar3 endpoint aliases /api/test/radar3/verification and /api/test/radar3/detection-range wrapped the per-node routes with the node id fixed. They were kept earlier in this branch on the reasoning that their callers were on the droplets, out of a repo grep's reach. That was never checked, and it is wrong: a read-only sweep of staging and production found no reference outside the route definition, in root's crontab, the systemd timers, /root, /opt, /etc/cron* or /usr/local/bin, and production's nginx log recorded no request for either across the hour it holds, a window in which two other /api/test routes were called. An hour is not proof of a rare external caller's absence, but nothing is known to call these, they name a node that is being reflashed out of existence, and GET /api/test/node//... does the same job for any node. Co-Authored-By: Claude Opus 5 --- backend/routes/test.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/backend/routes/test.py b/backend/routes/test.py index fdfd5e53..11c58817 100644 --- a/backend/routes/test.py +++ b/backend/routes/test.py @@ -724,12 +724,6 @@ def _mlat_verification_summary() -> dict: # ── Per-node solver verification ────────────────────────────────────────────── -# The radar3 aliases below predate the per-node routes (eac190a 2026-04-09, vs -# 4906b32 2026-08-05) and are kept until the node itself is decommissioned: -# whatever calls them lives on the droplets, not in this repo, so removing them -# here would 404 something no grep can find. -_RADAR3_NODE_ID = "radar3-retnode" - @router.get("/api/test/node/{node_id}/verification") async def node_verification(node_id: str): @@ -740,12 +734,6 @@ async def node_verification(node_id: str): ) -@router.get("/api/test/radar3/verification") -async def radar3_verification(): - """Back-compat alias for the radar3 node's verification stats.""" - return await node_verification(_RADAR3_NODE_ID) - - @router.get("/api/test/mlat-verification") async def mlat_verification(): """Return pre-computed multinode (MLAT) solver-vs-ground-truth verification stats.""" @@ -1757,9 +1745,3 @@ async def node_detection_range(node_id: str): ), media_type="application/json", ) - - -@router.get("/api/test/radar3/detection-range") -async def radar3_detection_range(): - """Back-compat alias for the radar3 node's detection range.""" - return await node_detection_range(_RADAR3_NODE_ID)