diff --git a/Dockerfile b/Dockerfile index c463fd65..8ced9ce7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -108,11 +108,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 @@ -120,8 +115,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 c22aabb6..b509e3ce 100644 --- a/backend/config/constants.py +++ b/backend/config/constants.py @@ -456,12 +456,6 @@ def _assoc_alt_layers_km() -> tuple[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 b4e70bea..14d460b8 100644 --- a/backend/core/task_registry.py +++ b/backend/core/task_registry.py @@ -33,21 +33,9 @@ "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. } -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/main.py b/backend/main.py index faf4d38b..a6739ad6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -75,8 +75,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 @@ -145,10 +143,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. @@ -218,7 +212,6 @@ async def _snapshot_loop(): asyncio.create_task(coverage_constraints_task()), asyncio.create_task(storage_refresh_task()), asyncio.create_task(detection_mirror.mirror_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/pipeline/passive_radar.py b/backend/pipeline/passive_radar.py index 7ef221d4..77f85278 100644 --- a/backend/pipeline/passive_radar.py +++ b/backend/pipeline/passive_radar.py @@ -51,6 +51,14 @@ from services.publication import is_private # ─── Node Configuration ───────────────────────────────────────────── +# 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 4eab8e8d..11c58817 100644 --- a/backend/routes/test.py +++ b/backend/routes/test.py @@ -724,8 +724,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): @@ -736,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.""" @@ -1753,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) diff --git a/backend/services/blah2_bridge.py b/backend/services/blah2_bridge.py deleted file mode 100644 index 361a0754..00000000 --- a/backend/services/blah2_bridge.py +++ /dev/null @@ -1,366 +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 -from services.node_config import canonical_config - -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") - -# Altitude is deliberately absent from the defaults below: it is resolved at -# the geometry boundary (node_config.resolve_altitudes) instead, so a node that -# declares none archives and publishes a null rather than a figure nothing -# downstream could later tell apart from a survey. -_OPTIONAL_ALTITUDES = ("rx_alt_ft", "tx_alt_ft") - -# Optional fields and the defaults applied when a node omits them. -_OPTIONAL_DEFAULTS = { - "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 in _OPTIONAL_ALTITUDES: - raw = entry.get(key) - if raw is None: - cfg[key] = None - continue - 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.""" - # Hashed over the file's own config, so a node's hash tracks the file - # rather than the normaliser. - cfg_hash = hashlib.sha256(json.dumps(node.config, sort_keys=True).encode()).hexdigest()[:16] - config = canonical_config(node.config) - with state.connected_nodes_lock: - state.connected_nodes[node.node_id] = { - "config_hash": cfg_hash, - "config": config, - "status": "active", - "last_heartbeat": "", - "peer": node.peer, - "is_synthetic": False, - "capabilities": {"adsb_report": True}, - } - await node_registration.register_node(node.node_id, 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.bump_task_error(key) - 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 00ff0ebe..7fb9fd81 100644 --- a/backend/services/frame_processor.py +++ b/backend/services/frame_processor.py @@ -612,16 +612,17 @@ 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. + # 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: _recv_s = time.time() _ts_ms = adsb_capture_ts_ms(frame, _recv_s) _recv_ms = int(_recv_s * 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 5991b6e1..0013db2c 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 @@ -24,8 +23,9 @@ 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. The same +# 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 @@ -65,13 +65,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. @@ -160,8 +159,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/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: diff --git a/backend/tests/test_adsb_capture_timestamps.py b/backend/tests/test_adsb_capture_timestamps.py index fab04c7f..aa7027a9 100644 --- a/backend/tests/test_adsb_capture_timestamps.py +++ b/backend/tests/test_adsb_capture_timestamps.py @@ -210,7 +210,7 @@ class TestBothIngestPathsUseIt: """The helper is only worth anything if the two store sites call it. They are separate paths: the TCP fast-path stores before queuing, the - frame processor stores for sources that arrive unextracted (blah2_bridge), + frame processor stores for sources that arrive unextracted, and each had its own `int(time.time() * 1000)`. """ diff --git a/backend/tests/test_blah2_bridge.py b/backend/tests/test_blah2_bridge.py deleted file mode 100644 index 22d05aa0..00000000 --- a/backend/tests/test_blah2_bridge.py +++ /dev/null @@ -1,330 +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] - - -def test_an_omitted_altitude_stays_null(): - """No invented altitude on the way in. - - resolve_altitudes supplies the terrain figure at the geometry boundary, so - a node that declares none must reach publication and the Parquet archive - with a null: those rows are permanent, and a fabricated figure there cannot - afterwards be told apart from a survey. - """ - cfg = _build_node(MINIMAL).config - - assert cfg["rx_alt_ft"] is None - assert cfg["tx_alt_ft"] is None 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 d417bda0..a6162e92 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 @@ -104,7 +104,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"] @@ -329,11 +329,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 2629a057..23cc5c2c 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -313,9 +313,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 @@ -324,33 +322,23 @@ 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 +### Checking a node's configured geometry -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. - -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}' +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. -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. +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/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 f1be3565..0e9ce22a 100644 --- a/docs/solverflow.md +++ b/docs/solverflow.md @@ -46,7 +46,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"] @@ -94,9 +94,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._enqueue_detection"] - p2["blah2 bridge
blah2_bridge.blah2_bridge_task"] p3["v1 node HTTP API
node_stream._file_frame"] p4["Legacy HTTP radar routes
radar.ingest_detections(_bulk)"] p5["Startup priming
node_pipeline.prime_pipeline"] @@ -111,7 +110,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 @@ -198,7 +196,6 @@ one of them. | `process_one_frame` entry | — | `services/frame_processor.py` | | Ordering rationale (claim → seed → tracker) | — | `frame_processor.process_one_frame` | | Gate 2.10: `n_nodes < 2` skip | — | `frame_processor.process_one_frame` | -| blah2 poll interval | 1.0 s | `config/constants.py` (`BLAH2_POLL_INTERVAL_S`) | ---