Skip to content

Serve a synthetic node's declared cone as its detection area - #367

Open
jehanazad wants to merge 2 commits into
mainfrom
feat/synthetic-nodes-declared-wedge
Open

jehanazad wants to merge 2 commits into
mainfrom
feat/synthetic-nodes-declared-wedge

Conversation

@jehanazad

Copy link
Copy Markdown
Contributor

Summary

Synthetic nodes on the live map were drawing "empirical" detection areas far larger than their beams (every synthetic node near Greenville showed a near-disc for a 42° beam). This makes the backend tell the analytics library which nodes' declared geometry is ground truth — synthetic nodes, by the existing is_synthetic_node prefix rule — so those nodes are served their declared cone, while real nodes keep the evidence-only polygon from #358. The map popup says which it is showing.

Depends on offworldlabs/retina-analytics#32 (submodule pin 6df7c3d), which itself is stacked on retina-analytics#28 — that one is still open although main already pins its head. Merge order: analytics #28, analytics #32, then this.

Why the polygons were too big

The simulator emits a detection only for an aircraft inside the node's declared cone, so for a synthetic node the wedge is the detection area. The calibration feed (track_gatesservices/calibration.py) records the ADS-B fix of whichever hex a track bound to, and a steady share of those binds are the wrong aircraft. From the test container's persisted bins (2026-09-13): one 42° node had 3,037 calibration points, 47 % outside its wedge (34 % excluding the two edge bins), 55 out-of-wedge bins open, polygon on 71 of 72 bearings; an eight-node sample ranged 5–41 % fully-outside points, and even the 5 % node opened 15+ stray bearings because FOV_OPEN_MIN_POINTS is 3. The evidence-only publisher was faithfully drawing mis-attributed evidence.

Changes

  • backend services/node_registration.register_node_blocking passes declared_geometry_is_truth=is_synthetic_node(node_id) (function-local import; tcp_handler imports this module). tests/test_ingest_event_loop.py stubs take **kwargs.
  • library pin → retina-analytics 6df7c3d: declared_wedge_polygon, the flag, and empirical_coverage.polygon_source ("declared" / "evidence" / "learned").
  • frontend NodeInfo.empirical_polygon_source (defaults to "evidence" on an older payload); the popup coverage line moved into a unit-tested coverageLine() in map/nodeSites.ts: "Coverage: declared beam (synthetic node), reach ≤ N km" for declared nodes, the existing measured / not-yet-measured lines otherwise. CoverageLayer draws whatever polygon is served, unchanged.
  • docs docs/pipeline.md §7 (real-node paragraph kept, synthetic-node paragraph added with the measurement); docs/arc-display.md corrected where it said the beam wedge is never drawn.

Test coverage

  • tests/test_node_registration_flag.py (8 new): synth-*polygon_source == "declared" with a polygon at zero points; allow-listed ret… id → "evidence", no polygon until points; the payload carries polygon_source; public_location.translate_polygon shifts the declared wedge rigidly and leaves no vertex at the true receiver.
  • Backend full suite run with the new library on PYTHONPATH: coverage gate reached at 85.4 %. Two pre-existing, load-sensitive flakes appeared across five runs and pass in isolation: test_mlat_history::test_nearest_gt_survives_concurrent_appends (hang under coverage's collector lock, reproduced on the unmodified tree) and test_known_lane::TestMaybeRunPass::test_interval_gate_holds_between_passes (a leaked solver worker holds _PASS_LOCK). Neither file is touched here.
  • Frontend: tsc clean, eslint 0 errors, vite build, 249 vitest tests (5 new for coverageLine).
  • pre-commit run --all-files and tests/test_no_real_identities.py pass.
  • Independent cross-check outside the repo: the declared wedge rebuilt from the 42° node's real persisted bins agrees with SimulationWorld._aircraft_in_detection_cone on 25,919 / 25,921 grid points; both misses are on the boundary.

Review notes

  • Real nodes are deliberately untouched: the user's decision is that empirical data defines their area. But the same wrong-aircraft bind rate inflates their polygons too; fixing calibration attribution is a follow-up.
  • Raw node_id, beam_* fields, the solver beam gate and arc clipping remain as in map: one marker per site, node_ref labels, measured coverage only #358's follow-up list.
  • Live check on the test map owed after deploy: every synthetic node should show a 42° wedge sized by its bistatic ellipse, popup line "declared beam (synthetic node)"; real nodes unchanged.

🤖 Generated with Claude Code

jehanazad and others added 2 commits September 13, 2026 22:09
Every node's published `empirical_coverage.polygon` is evidence-only, built
from calibration points that come from ADS-B hexes bound to tracks. That is
right for real hardware — a real node's declared beam azimuth and width are
unsurveyed configuration, and drawing them would claim coverage nobody
measured — but wrong for the simulator fleet. The simulator emits a detection
only for an aircraft inside the node's declared cone, so for a synthetic node
the cone IS the detection area by definition, and the evidence is the
unreliable half: roughly a third of those hex binds are to the wrong aircraft.

Measured on test 2026-09-13, synth-GVL-SCAT-0032 (42 deg 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. Every synthetic node on the test map draws as a disc.

So register_node_blocking passes declared_geometry_is_truth=is_synthetic_node
to the analytics registration, and the library publishes
declared_wedge_polygon() for those nodes instead (retina-analytics 6df7c3d,
pinned here). Real nodes are untouched. Both kinds now name their rule in
`empirical_coverage.polygon_source`, so no consumer has to guess which shape
it was served.

is_synthetic_node is imported inside the function: tcp_handler imports this
module at import time, the same cycle analytics_refresh and core.state already
step around the same way.

test_ingest_event_loop's registration stubs take **kwargs — they stand in for
the analytics registry, which now receives one more keyword.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The site popup quotes measured coverage and nothing else — the declared beam
azimuth, width and range were deliberately removed from it, because most
nodes' aim was never surveyed and beside a calibration-point count they read
as measurements.

That stays true for real nodes. A synthetic node is now served its declared
cone as its detection area (the simulator emits a detection only inside it, so
it is the detection area by definition), and the backend says which rule it
used in `empirical_coverage.polygon_source`. NodeInfo carries it through as
`empirical_polygon_source`, and the popup line moves into a pure
`coverageLine(node)` helper in nodeSites.ts so the wording is unit-tested
rather than buried in JSX: the declared beam is quoted only when the source
reads "declared", and "learned" is treated as measured, because it is.

Missing on a payload from an older server, where it defaults to "evidence" —
the conservative reading, and what every node published then.

CoverageLayer needs no change: it draws whatever polygon it is served.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown

Claude finished @jehanazad's task in 6m 18s —— View job


Review of PR #367

  • Gather context (PR body, changed files list)
  • Run /code-review on the PR
  • Post findings

Reviewed the diff between origin/main and HEAD (commits 1509cfa, 4703200). One correctness issue and one stale-comment issue, both verified against the current code.


1. declared_geometry_is_truth can diverge from the synthetic flag the rest of the system usesbackend/services/node_registration.py:44,50

register_node_blocking decides truth-vs-evidence with a bare is_synthetic_node(node_id) prefix check:

from services.tcp_handler import is_synthetic_node
...
declared_geometry_is_truth=is_synthetic_node(node_id),

But tcp_handler.py:258 already resolves the authoritative flag for a connecting node as msg.get("is_synthetic", is_synthetic_node(node_id)) — i.e. a node's own CONFIG.is_synthetic claim overrides the prefix guess — and stores it in state.connected_nodes[node_id]["is_synthetic"]. Every other consumer of node synthetic-ness (e.g. tcp_handler.py:594, and backend/tests/test_publication.py's TestNodeFlagConsistency, which exists specifically to guard against re-deriving this from the id) treats that stored flag as ground truth, not a fresh prefix re-check.

register_node is called right after (tcp_handler.py:317) without passing the resolved is_synth through, so:

  • A node with a non-synth--prefixed id that declares "is_synthetic": true in its CONFIG message is treated as synthetic everywhere else, but register_node_blocking computes False and serves it the evidence-only polygon instead of its declared cone.
  • Symmetrically, a synth-/test--prefixed id that declares "is_synthetic": false wrongly gets its declared cone published as ground truth.

The new tests in backend/tests/test_node_registration_flag.py all call register_node_blocking directly with prefix-consistent ids, so this divergence isn't exercised. Passing the already-resolved flag through (or otherwise consulting state.connected_nodes[node_id]["is_synthetic"]) instead of re-deriving it would close the gap.

Fix this →

2. Stale comments now contradict the code they documentfrontend/src/components/LiveAircraftMap.tsx:973-977 (and the similar note around line 2084)

The CoverageLayer comment still says a node's polygon is "measured coverage or nothing" and contrasts it with a theoretical wedge that's "never...drawn." After this PR, that's no longer true for synthetic nodes: empirical_polygon itself now carries the declared/theoretical cone when polygon_source === "declared", and this exact code path (positions={n.empirical_polygon}) renders it. Worth a follow-up comment update so a future reader doesn't assume this renderer can't show a declared wedge and duplicate the logic elsewhere, or strip out what's now load-bearing for synthetic nodes.


Everything else — the declared_geometry_is_truth plumbing itself, the coverageLine() extraction and its tests, NodeInfo.empirical_polygon_source typing, and the docs updates — looked correct and well-covered by the new tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant