Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion backend/services/node_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
6 changes: 4 additions & 2 deletions backend/tests/test_ingest_event_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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",
Expand Down
140 changes: 140 additions & 0 deletions backend/tests/test_node_registration_flag.py
Original file line number Diff line number Diff line change
@@ -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)]
11 changes: 7 additions & 4 deletions docs/arc-display.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
28 changes: 27 additions & 1 deletion docs/pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
19 changes: 10 additions & 9 deletions frontend/src/components/LiveAircraftMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
uncertaintyDiscRadiusM,
nodeLabel,
groupNodesBySite,
polygonMaxReachKm,
coverageLine,
FitBounds,
ViewportTracker,
MapClickClear,
Expand Down Expand Up @@ -172,7 +172,7 @@
// Full cleanup on unmount
useEffect(() => {
return () => {
for (const m of markerMapRef.current.values()) m.remove();

Check warning on line 175 in frontend/src/components/LiveAircraftMap.tsx

View workflow job for this annotation

GitHub Actions / frontend-build

The ref value 'markerMapRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'markerMapRef.current' to a variable inside the effect, and use that variable in the cleanup function
markerMapRef.current.clear();
};
}, [map]);
Expand Down Expand Up @@ -325,7 +325,7 @@
}
markers.clear();
};
}, [map, radarAircraftRef, groundTruthRef, smoothRef, NODE, TRUTH]);

Check warning on line 328 in frontend/src/components/LiveAircraftMap.tsx

View workflow job for this annotation

GitHub Actions / frontend-build

React Hook useEffect has a missing dependency: 'nodesByRefRef'. Either include it or remove the dependency array

return null;
});
Expand Down Expand Up @@ -897,14 +897,15 @@
<React.Fragment key={`site-node-${n.node_ref}`}>
{i > 0 && <br />}
<strong>{nodeLabel(n)}</strong><br />
{/* 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 &le; {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 && (
<>
<br />
Expand Down Expand Up @@ -1589,7 +1590,7 @@
return Object.entries(trailsRef.current).filter(
([hex, positions]) => hex === selectedHex && positions.some((p) => isPointInViewport(p[0], p[1], viewport)),
);
}, [selectedHex, trailTick, viewport]);

Check warning on line 1593 in frontend/src/components/LiveAircraftMap.tsx

View workflow job for this annotation

GitHub Actions / frontend-build

React Hook useMemo has a missing dependency: 'trailsRef'. Either include it or remove the dependency array

const selectedTrailPositions = useMemo(() => {
if (!selectedHex) return [];
Expand Down Expand Up @@ -1797,7 +1798,7 @@
const csv = trailToCsv(ac.hex, ac.flight, rows);
downloadCsv(`trail-${ac.hex}-${Date.now()}.csv`, csv);
toast(`Exported ${rows.length} points`, { tone: "success" });
}, [selectedHex, radarAircraft, trailsRef]);

Check warning on line 1801 in frontend/src/components/LiveAircraftMap.tsx

View workflow job for this annotation

GitHub Actions / frontend-build

React Hook useCallback has a missing dependency: 'groundTruthRef'. Either include it or remove the dependency array

const exportAllTrails = useCallback(() => {
const csv = trailsToBulkCsv(radarAircraft || [], trailsRef.current || {});
Expand Down Expand Up @@ -1862,7 +1863,7 @@
p: () => { if (selectedHex) { togglePinned(selectedHex); toast(pinnedSet.has(selectedHex) ? "Unpinned" : "Pinned"); } },
m: () => locateMe(),
n: () => { setSoundOn((v) => { toast(v ? "Sound off" : "Sound on"); return !v; }); },
}), [showShortcutHelp, searchQuery, exportSelectedTrail, exportAllTrails, locateMe, selectedHex, togglePinned, pinnedSet, setSoundOn, setShowLabels, setShowTrails, setShowCoverage, setShowIlluminators, setShowGroundTruth, setColorByAlt, setShowStats, setShowRangeRings, setShowArcs]);

Check warning on line 1866 in frontend/src/components/LiveAircraftMap.tsx

View workflow job for this annotation

GitHub Actions / frontend-build

React Hook useMemo has a missing dependency: 'handleTogglePause'. Either include it or remove the dependency array
useKeyboardShortcuts(shortcutMap);

function computeError(hex, ac) {
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/map/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/map/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
40 changes: 39 additions & 1 deletion frontend/src/components/map/nodeSites.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): RadarNode => ({
Expand All @@ -17,6 +17,7 @@ const node = (extra: Partial<RadarNode> = {}): RadarNode => ({
max_bistatic_range_km: null,
empirical_polygon: null,
empirical_n_points: 0,
empirical_polygon_source: "evidence",
is_synthetic: false,
...extra,
});
Expand Down Expand Up @@ -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");
});
});
31 changes: 31 additions & 0 deletions frontend/src/components/map/nodeSites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)`;
}
Loading
Loading