diff --git a/backend/services/node_registration.py b/backend/services/node_registration.py index 78b24fa0..30a6f2df 100644 --- a/backend/services/node_registration.py +++ b/backend/services/node_registration.py @@ -32,9 +32,23 @@ def register_node_blocking(node_id: str, config: dict) -> None: unsurveyed receiver at sea level while the pipeline and the solver put the same node at 900 ft. Neither registry publishes an altitude, so resolving here cannot leak a working figure into a payload. + + Also where a node's declared geometry is judged trustworthy. A synthetic + node's declared cone is exactly what the simulator enforces before it emits + a detection, so the analytics library publishes that cone as the node's + detection area rather than the accumulated evidence (see + NodeAnalyticsManager.get_node_summary). A real receiver's declared aim was + never surveyed, so it keeps the evidence-only polygon. """ + # Function-local: tcp_handler imports this module at import time. + from services.tcp_handler import is_synthetic_node + config = resolve_altitudes(canonical_config(config)) - state.node_analytics.register_node(node_id, config) + state.node_analytics.register_node( + node_id, + config, + declared_geometry_is_truth=is_synthetic_node(node_id), + ) state.node_associator.register_node(node_id, config) diff --git a/backend/tests/test_ingest_event_loop.py b/backend/tests/test_ingest_event_loop.py index ec776dde..183936f8 100644 --- a/backend/tests/test_ingest_event_loop.py +++ b/backend/tests/test_ingest_event_loop.py @@ -48,10 +48,12 @@ def slow_registration(monkeypatch): """ from core import state - def slow(node_id, config): + def slow(node_id, config, **kwargs): time.sleep(BLOCK_S) monkeypatch.setattr(state.node_associator, "register_node", slow) + # **kwargs: the analytics registration also carries + # declared_geometry_is_truth (see services/node_registration.py). monkeypatch.setattr(state.node_analytics, "register_node", slow) @@ -150,7 +152,7 @@ async def test_the_registration_reaches_the_associator(self, client, monkeypatch seen = [] monkeypatch.setattr(state.node_associator, "register_node", lambda nid, cfg: seen.append((nid, cfg))) - monkeypatch.setattr(state.node_analytics, "register_node", lambda nid, cfg: None) + monkeypatch.setattr(state.node_analytics, "register_node", lambda nid, cfg, **kw: None) await client.post( "/api/radar/detections", diff --git a/backend/tests/test_node_registration_flag.py b/backend/tests/test_node_registration_flag.py new file mode 100644 index 00000000..808e433e --- /dev/null +++ b/backend/tests/test_node_registration_flag.py @@ -0,0 +1,140 @@ +"""Which nodes get their DECLARED cone published as their detection area. + +A synthetic node's cone is what the simulator enforces before it emits a +detection, so the cone is its detection area by definition; a real receiver's +declared aim was never surveyed, so it keeps the evidence-only polygon it has +always had. register_node_blocking is the one door both go through, and the +verdict is services.tcp_handler.is_synthetic_node's. + +Node ids here are prefix-legal for tests/test_no_real_identities.py: synth-* +and test-* are synthetic, and ret1a2b3c4d is the allow-listed stand-in for a +real board. +""" + +import math +import os + +import pytest + +os.environ.setdefault("RETINA_ENV", "test") + +from core import state # noqa: E402 +from services import public_location as pl # noqa: E402 +from services.geo import haversine_km # noqa: E402 +from services.node_registration import register_node_blocking # noqa: E402 + +_SYNTH_ID = "synth-GVL-0001" +_REAL_ID = "ret1a2b3c4d" + +_RX_LAT, _RX_LON = 34.851234, -82.401234 +_TX_LAT, _TX_LON = 34.901234, -82.301234 + +_CFG = { + "rx_lat": _RX_LAT, + "rx_lon": _RX_LON, + "rx_alt_ft": 950.0, + "tx_lat": _TX_LAT, + "tx_lon": _TX_LON, + "tx_alt_ft": 1600.0, + "max_range_km": 50, + "max_bistatic_range_km": 60, + "beam_azimuth_deg": 45.0, + "beam_width_deg": 42.0, +} + + +def _coverage(node_id): + return state.node_analytics.get_node_summary(node_id)["empirical_coverage"] + + +@pytest.fixture() +def registered(): + """Both kinds of node, registered the way an entry point registers them.""" + for node_id in (_SYNTH_ID, _REAL_ID): + register_node_blocking(node_id, dict(_CFG, node_id=node_id)) + yield + for node_id in (_SYNTH_ID, _REAL_ID): + state.node_analytics.retire_node(node_id) + + +class TestWhoseGeometryIsTruth: + def test_a_synthetic_node_publishes_its_declared_cone(self, registered): + cov = _coverage(_SYNTH_ID) + assert cov["polygon_source"] == "declared" + assert cov["polygon"] + + def test_it_is_published_before_any_traffic_has_flown(self, registered): + """The declared cone is known at registration; the evidence gate that + holds a real node's polygon back does not apply to it.""" + cov = _coverage(_SYNTH_ID) + assert cov["n_points"] == 0 + assert len(cov["polygon"]) > 3 + + def test_the_cone_is_the_declared_one(self, registered): + """Every vertex inside the declared half-width — the bug this fixes + was a 42 deg beam published across 71 of 72 bearings.""" + polygon = _coverage(_SYNTH_ID)["polygon"] + for lat, lon in polygon[1:-1]: + bearing = math.degrees( + math.atan2( + (lon - _RX_LON) * math.cos(math.radians(_RX_LAT)), + lat - _RX_LAT, + ) + ) + assert abs((bearing - 45.0 + 180.0) % 360.0 - 180.0) <= 21.0 + 0.25 + + def test_a_real_node_still_publishes_evidence_only(self, registered): + cov = _coverage(_REAL_ID) + assert cov["polygon_source"] == "evidence" + # Nothing measured yet, so there is nothing to draw — deliberately, + # rather than a theoretical sector nobody surveyed. + assert cov["polygon"] is None + + def test_the_two_registries_still_agree_on_the_node(self, registered): + """The flag is an extra argument to one of the two registrations; the + associator must still have been told about the node.""" + assert _SYNTH_ID in state.node_associator.node_configs + + +# ── What a stranger fetches ────────────────────────────────────────────────── + +_SALT = "test-salt-for-declared-wedge" +_ROUNDING_SLACK_KM = 0.02 + + +@pytest.fixture() +def _fuzz_on(monkeypatch): + monkeypatch.setenv("NODE_FUZZ_MODE", "on") + monkeypatch.setenv("NODE_FUZZ_SALT", _SALT) + monkeypatch.delenv("NODE_FUZZ_MIN_KM", raising=False) + monkeypatch.delenv("NODE_FUZZ_MAX_KM", raising=False) + pl._reset_for_tests() + yield + pl._reset_for_tests() + + +class TestPublishedPayload: + def test_the_source_survives_the_public_rewrite(self, registered, _fuzz_on): + public = pl.public_node_summaries({_SYNTH_ID: state.node_analytics.get_node_summary(_SYNTH_ID)}) + assert public[_SYNTH_ID]["empirical_coverage"]["polygon_source"] == "declared" + + def test_the_declared_polygon_is_translated_rigidly(self, registered, _fuzz_on): + """public_location keys on empirical_coverage.polygon, so a declared + wedge is fuzzed like any other polygon — same offset on every vertex, + shape untouched, no vertex left on the true receiver.""" + truth = state.node_analytics.get_node_summary(_SYNTH_ID)["empirical_coverage"]["polygon"] + published = pl.public_node_summary(_SYNTH_ID, state.node_analytics.get_node_summary(_SYNTH_ID)) + moved = published["empirical_coverage"]["polygon"] + + assert len(moved) == len(truth) + offsets = {(round(m[0] - t[0], 6), round(m[1] - t[1], 6)) for m, t in zip(moved, truth)} + assert len(offsets) == 1, f"vertices moved by different offsets: {offsets}" + (dlat, dlon) = offsets.pop() + assert (dlat, dlon) != (0.0, 0.0) + assert min(haversine_km(_RX_LAT, _RX_LON, lat, lon) for lat, lon in moved) > _ROUNDING_SLACK_KM + + def test_the_manager_keeps_the_true_wedge(self, registered, _fuzz_on): + """The rewrite is a copy: the solver still gates on the real cone.""" + pl.public_node_summary(_SYNTH_ID, state.node_analytics.get_node_summary(_SYNTH_ID)) + apex = state.node_analytics.get_node_summary(_SYNTH_ID)["empirical_coverage"]["polygon"][0] + assert apex == [round(_RX_LAT, 5), round(_RX_LON, 5)] diff --git a/docs/arc-display.md b/docs/arc-display.md index c58055bc..292dfb25 100644 --- a/docs/arc-display.md +++ b/docs/arc-display.md @@ -95,7 +95,10 @@ pins their position to the arc midpoint, so a long glide walks the reference off the measured locus. Selecting an arc track (from the list panel or by clicking the arc) highlights -its arcs in amber, draws the detecting node's measured coverage polygon (the -empirical detection area — nothing at all for a node that has none yet; the -theoretical beam wedge is never drawn), and centers the map on the arc -midpoint. +its arcs in amber, draws the detecting node's published coverage polygon, and +centers the map on the arc midpoint. That polygon is whatever +`empirical_coverage.polygon` carries: for a real node its measured detection +area, and nothing at all for one that has none yet — a real node's declared +beam wedge is never drawn. A synthetic node is the documented exception, and +is served its declared cone because that is what the simulator enforces (see +`docs/pipeline.md` §7, `polygon_source`). diff --git a/docs/pipeline.md b/docs/pipeline.md index 58b33042..029241ce 100644 --- a/docs/pipeline.md +++ b/docs/pipeline.md @@ -290,7 +290,7 @@ evidence independent of both, and only from *detections*: active FOV gate it once formed a ghost → positive → wider-gate feedback loop. -**What is published as the detection area is evidence only.** Under +**What is published as a REAL node's detection area is evidence only.** Under `FOV_MODE=off` — the default, and what production and test run — `empirical_coverage.polygon` in `/api/radar/analytics` is built by `EmpiricalCoverageState.to_polygon(evidence_only=True)`: a bin is drawn only @@ -306,6 +306,32 @@ detection area, and the map draws nothing for a node with no polygon rather than a sector nobody measured. Under `FOV_MODE=shadow|active` the published polygon is the learned wedge instead, which is itself evidence-derived. +**A SYNTHETIC node publishes its declared wedge instead.** The simulator emits +a detection only for an aircraft inside the node's declared cone +(`retina_simulation/world.py::_aircraft_in_detection_cone`: bearing within +`beam_azimuth_deg ± beam_width_deg/2`, range within `max_bistatic_range_km` on +the differential when declared, else within `max_range_km` on the RX +distance), so for a simulated node the cone *is* the detection area by +definition — and the evidence is the unreliable half, because the calibration +points come from ADS-B hexes bound to tracks and roughly a third of those +binds are to the wrong aircraft. Measured on test 2026-09-13, +`synth-GVL-SCAT-0032` (42° beam) held 3,037 calibration points of which 47 % +lay outside its wedge (34 % ignoring the two edge bins), 55 out-of-wedge bins +had opened, and the published polygon covered 71 of 72 bearings: a 42° beam +drawn as a disc. So `services/node_registration.py::register_node_blocking` +passes `declared_geometry_is_truth=is_synthetic_node(node_id)` to +`NodeAnalyticsManager.register_node`, and those nodes publish +`EmpiricalCoverageState.declared_wedge_polygon()` — the prior azimuth and +width at `_reach_at` on each bearing, with no clamp, no bins and no +minimum-points gate, so it is served from the moment the node registers. +Real nodes never take this path: their declared aim is unsurveyed +configuration, which is exactly what the paragraph above exists to keep off +the map. Every summary names its rule in `empirical_coverage.polygon_source` +(`declared` / `evidence` / `learned`), and the map quotes a declared beam only +when it reads `declared` (`frontend/src/components/map/nodeSites.ts::coverageLine`). +The fuzz rewrite is unchanged: `public_location.translate_polygon` shifts a +declared wedge rigidly like any other polygon. + **Every public node payload carries a `node_ref`.** `/api/radar/analytics` (both variants), `/api/radar/analytics/{node_id}` and `/api/radar/nodes` each carry one per node: the registry's `Node.node_ref` for a node registered diff --git a/frontend/src/components/LiveAircraftMap.tsx b/frontend/src/components/LiveAircraftMap.tsx index a58acaeb..5222ab81 100644 --- a/frontend/src/components/LiveAircraftMap.tsx +++ b/frontend/src/components/LiveAircraftMap.tsx @@ -45,7 +45,7 @@ import { uncertaintyDiscRadiusM, nodeLabel, groupNodesBySite, - polygonMaxReachKm, + coverageLine, FitBounds, ViewportTracker, MapClickClear, @@ -897,14 +897,15 @@ const NodeMarkersLayer = memo(function NodeMarkersLayer({ visibleNodes, onSelect {i > 0 &&
} {nodeLabel(n)}
- {/* Only measured coverage is quoted. The declared beam azimuth, - width and range used to be printed here; they are - configuration, most nodes' aim was never surveyed, and beside - a calibration-point count they read as measurements. */} - {n.empirical_polygon && n.empirical_polygon.length >= 3 - ? <>Coverage: measured from {n.empirical_n_points} calibration pts, - reach ≤ {polygonMaxReachKm(n.rx_lat, n.rx_lon, n.empirical_polygon)} km - : <>Coverage: not yet measured ({n.empirical_n_points || 0} calibration pts)} + {/* A declared beam is quoted ONLY for a synthetic node, whose + declared cone is what the simulator enforces before it emits a + detection and so is its detection area by definition. For a + real node only measured coverage is quoted: its declared + azimuth, width and range are configuration, most nodes' aim + was never surveyed, and beside a calibration-point count they + read as measurements. The backend decides which is which and + says so in empirical_polygon_source; see nodeSites.ts. */} + {coverageLine(n)} {multi && ( <>
diff --git a/frontend/src/components/map/hooks.ts b/frontend/src/components/map/hooks.ts index d4abcb34..3442ced4 100644 --- a/frontend/src/components/map/hooks.ts +++ b/frontend/src/components/map/hooks.ts @@ -398,6 +398,10 @@ export function useNodes() { max_bistatic_range_km: da.max_bistatic_range_km ?? null, empirical_polygon: ec?.polygon ?? null, empirical_n_points: ec?.n_points ?? 0, + // Absent on a payload from a server older than the declared + // wedge: evidence-only is what every node published then, and + // is the conservative reading either way. + empirical_polygon_source: ec?.polygon_source ?? "evidence", is_synthetic: isSyntheticNode(info as { is_synthetic?: boolean }, ref), }); } diff --git a/frontend/src/components/map/index.ts b/frontend/src/components/map/index.ts index efa53dc9..fd2150ba 100644 --- a/frontend/src/components/map/index.ts +++ b/frontend/src/components/map/index.ts @@ -13,7 +13,7 @@ export { uncertaintyDiscRadiusM, pointInPolygon, } from "./geo"; -export { nodeLabel, groupNodesBySite, polygonMaxReachKm } from "./nodeSites"; +export { nodeLabel, groupNodesBySite, polygonMaxReachKm, coverageLine } from "./nodeSites"; export { UNCERTAINTY_K68, UNCERTAINTY_K95, diff --git a/frontend/src/components/map/nodeSites.test.ts b/frontend/src/components/map/nodeSites.test.ts index 172bdb6e..03f7426e 100644 --- a/frontend/src/components/map/nodeSites.test.ts +++ b/frontend/src/components/map/nodeSites.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { nodeLabel, groupNodesBySite, polygonMaxReachKm } from "./nodeSites"; +import { nodeLabel, groupNodesBySite, polygonMaxReachKm, coverageLine } from "./nodeSites"; import type { RadarNode } from "../../types"; const node = (extra: Partial = {}): RadarNode => ({ @@ -17,6 +17,7 @@ const node = (extra: Partial = {}): RadarNode => ({ max_bistatic_range_km: null, empirical_polygon: null, empirical_n_points: 0, + empirical_polygon_source: "evidence", is_synthetic: false, ...extra, }); @@ -96,3 +97,40 @@ describe("polygonMaxReachKm", () => { expect(polygonMaxReachKm(0, 0, [])).toBeNull(); }); }); + +describe("coverageLine", () => { + // ~1° of latitude from the receiver, so reach rounds to 111 km. + const lobe: [number, number][] = [[34.85, -82.4], [35.85, -82.4], [34.85, -82.3]]; + + it("quotes the declared beam only for a node the backend marked declared", () => { + expect( + coverageLine(node({ empirical_polygon_source: "declared", empirical_polygon: lobe })), + ).toBe("Coverage: declared beam (synthetic node), reach ≤ 111 km"); + }); + + it("quotes measured coverage for a real node, whatever it declares", () => { + // Same polygon, same declared beam fields — only the source differs, and + // it is the source that decides what the map is allowed to call it. + expect( + coverageLine(node({ empirical_polygon: lobe, empirical_n_points: 240 })), + ).toBe("Coverage: measured from 240 calibration pts, reach ≤ 111 km"); + }); + + it("says nothing is measured yet when there is no polygon", () => { + expect(coverageLine(node({ empirical_n_points: 7 }))).toBe( + "Coverage: not yet measured (7 calibration pts)", + ); + }); + + it("does not quote a reach it was not given", () => { + expect(coverageLine(node({ empirical_polygon_source: "declared" }))).toBe( + "Coverage: declared beam (synthetic node)", + ); + }); + + it("treats the learned wedge as measured, not declared", () => { + expect( + coverageLine(node({ empirical_polygon_source: "learned", empirical_polygon: lobe })), + ).toBe("Coverage: measured from 0 calibration pts, reach ≤ 111 km"); + }); +}); diff --git a/frontend/src/components/map/nodeSites.ts b/frontend/src/components/map/nodeSites.ts index 981b33be..3e2e01e0 100644 --- a/frontend/src/components/map/nodeSites.ts +++ b/frontend/src/components/map/nodeSites.ts @@ -114,3 +114,34 @@ export function polygonMaxReachKm( } return Math.round(max); } + +/** + * The one line the site popup prints about a node's coverage. + * + * Which shape the backend published is its decision, not the map's, and it + * says so in `empirical_polygon_source`: + * + * - `declared` — a SYNTHETIC node. The simulator emits a detection only for + * an aircraft inside the node's declared cone, so for those nodes the cone + * is the detection area by definition and is served as-is. This is the one + * case where the map may call a declared beam coverage. + * - `evidence` / `learned` — a real receiver, whose declared aim was never + * surveyed. Only what it has been seen to detect is quoted, with the + * calibration-point count that backs it. + * + * Reach comes from the served polygon either way (polygonMaxReachKm), never + * from the node's declared `max_range_km`. + */ +export function coverageLine(node: RadarNode): string { + const reach = polygonMaxReachKm(node.rx_lat, node.rx_lon, node.empirical_polygon); + const drawable = Array.isArray(node.empirical_polygon) && node.empirical_polygon.length >= 3; + if (node.empirical_polygon_source === "declared") { + return drawable + ? `Coverage: declared beam (synthetic node), reach ≤ ${reach} km` + : "Coverage: declared beam (synthetic node)"; + } + if (drawable) { + return `Coverage: measured from ${node.empirical_n_points} calibration pts, reach ≤ ${reach} km`; + } + return `Coverage: not yet measured (${node.empirical_n_points || 0} calibration pts)`; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 78afd3d3..a12840cf 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -183,6 +183,16 @@ export interface RadarNode { max_bistatic_range_km: number | null; empirical_polygon: [number, number][] | null; empirical_n_points: number; + /** + * Which rule produced `empirical_polygon`, decided by the backend: + * `declared` for a synthetic node, whose declared cone is what the + * simulator enforces and so IS its detection area; `evidence` for a real + * node under FOV_MODE=off, drawn only from what it has been seen to + * detect; `learned` for the FOV_MODE shadow/active wedge, itself derived + * from evidence. Only `declared` lets the map call a declared beam + * coverage — see components/map/nodeSites.ts::coverageLine. + */ + empirical_polygon_source: "declared" | "evidence" | "learned"; /** * Server-derived, not parsed from the identifier: see utils/nodeKind.ts. * Identities publish as node_ref, so no prefix survives to match on. diff --git a/libs/retina-analytics b/libs/retina-analytics index 5056b35d..6df7c3df 160000 --- a/libs/retina-analytics +++ b/libs/retina-analytics @@ -1 +1 @@ -Subproject commit 5056b35dbbfc97ab826e32240174d047a6d20b3c +Subproject commit 6df7c3df2e42271b5bccfda053c01835b0f41006