Skip to content
Merged
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
77 changes: 77 additions & 0 deletions src/retina_analytics/empirical_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@
neither opens a bin nor clips one, and unobserved bearings collapse to the RX
apex instead of being interpolated across.

Declared publication (declared_wedge_polygon)
---------------------------------------------
The exception, and only for SYNTHETIC nodes: the simulator emits a detection
only inside the node's declared cone, so for those nodes the cone is the
detection area by definition and the bins are the unreliable half (about a
third of the ADS-B binds that feed them are to the wrong aircraft). Real
nodes never take this path. See declared_wedge_polygon.

Learned FOV (FOV_MODE, schema 3)
---------------------------------
The methods above (observed_limit_km / constraint_digest / to_polygon without
Expand Down Expand Up @@ -904,6 +912,75 @@ def _evidence_only_polygon(self) -> list[list[float]] | None:
return None
return polygon

def declared_wedge_polygon(self, step_deg: float = 2.5) -> list[list[float]] | None:
"""The declared cone, drawn as-is — for nodes whose geometry IS truth.

Only ever published for SYNTHETIC (simulator-fleet) nodes; the caller
decides, and for a real node this method is never called. A real
node's beam_azimuth_deg / beam_width_deg are unsurveyed configuration,
so drawing them would claim coverage nobody measured — that is what
_evidence_only_polygon exists to avoid, and it stays the published
shape for real hardware.

A simulated node is the other way round. 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 azimuth ± width/2, range within the declared differential limit
when there is one, else within max_range_km on the RX distance), so
the cone is the node's detection area *by definition* and any evidence
outside it is an error in the evidence. It is: 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 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 node with a 42° beam drawn as a disc.

So this method reads no bins at all. Radius on each bearing is
_reach_at — already the bistatic ellipse when the TX and a
differential limit are known, else the monostatic circle, which is
exactly the simulator's own range rule — with no range_clamp_mult,
because there is no mis-attributed far detection to defend against.

Vertices: for a directional prior, the RX apex, then both wedge edges
exactly plus every *step_deg* between them, then the apex again to
close. For an omni prior (prior_azimuth_deg None) a full ring every
*step_deg*, closed on its first vertex and with no apex — an omni node
has no direction to exclude. None only when the reach is not positive.
"""
half = (self.prior_width_deg if self.prior_width_deg is not None else YAGI_BEAM_WIDTH_DEG) / 2.0
az = self.prior_azimuth_deg

if az is None:
n_steps = max(3, int(round(360.0 / step_deg)))
bearings = [i * (360.0 / n_steps) for i in range(n_steps)]
else:
start, end = az - half, az + half
bearings = [start]
b = start + step_deg
while b < end - 1e-9:
bearings.append(b)
b += step_deg
bearings.append(end)

if not any(self._reach_at(b) > 0.0 for b in bearings):
return None

apex = [round(self.rx_lat, 5), round(self.rx_lon, 5)]
polygon: list[list[float]] = [] if az is None else [apex]
for bearing in bearings:
r_km = max(0.0, self._reach_at(bearing))
bearing_rad = math.radians(bearing)
lat, lon = offset_latlon(
self.rx_lat,
self.rx_lon,
east_km=r_km * math.sin(bearing_rad),
north_km=r_km * math.cos(bearing_rad),
)
polygon.append([round(lat, 5), round(lon, 5)])
polygon.append(apex if az is not None else polygon[0])
return polygon

# ── Shrink-only prior ────────────────────────────────────────────────────

def observed_limit_km(self, bearing_deg_: float) -> float | None:
Expand Down
60 changes: 56 additions & 4 deletions src/retina_analytics/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ def __init__(self, storage_dir: str = "", fov_mode: str = "off"):
self.reputations: dict[str, NodeReputation] = {}
self.coverage_maps: dict[str, HistoricalCoverageMap] = {}
self.empirical_coverages: dict[str, EmpiricalCoverageState] = {}
# Nodes whose DECLARED geometry is ground truth rather than a config
# guess — synthetic/simulator nodes, whose detection cone is what the
# simulator enforces. Set per registration by the backend (see
# register_node's declared_geometry_is_truth); read only by
# get_node_summary, to publish the declared wedge instead of the
# evidence. Membership is not persisted: it is a property of who
# registered the node, re-asserted on every registration.
self._declared_truth: set[str] = set()
self._storage_dir = storage_dir
self._last_save_time = 0.0
self._save_interval_s = 300.0
Expand Down Expand Up @@ -100,18 +108,41 @@ def _reset_for_tests(self) -> None:
self.empirical_coverages,
):
store.clear()
self._declared_truth.clear()
self._cross_node_cache = None
self._cross_node_cache_ts = 0.0
self._summaries_cache = None
self._summaries_cache_ts = 0.0
self._last_save_time = 0.0

def register_node(self, node_id: str, config: dict):
def register_node(self, node_id: str, config: dict, *, declared_geometry_is_truth: bool = False):
"""Register or re-register a node.

declared_geometry_is_truth marks a node whose declared cone IS its
detection area — a simulator node, where the cone is what the
simulator enforces — so get_node_summary publishes
EmpiricalCoverageState.declared_wedge_polygon rather than the
evidence-only shape. Default False: a real receiver's declared aim is
unsurveyed configuration. Asserted per registration, so a node
re-registered without the flag loses it.
"""
# Locked: save_coverage_maps / _load_coverage_maps iterate these dicts
# from other threads, and an unlocked insert mid-iteration raises
# "dictionary changed size during iteration".
with self._save_lock:
was_declared = node_id in self._declared_truth
if declared_geometry_is_truth:
self._declared_truth.add(node_id)
else:
self._declared_truth.discard(node_id)
self._register_node_locked(node_id, config)
# A flag change swaps the published polygon just as a rebuilt
# detection area does, so it has to drop the 60 s summaries cache
# the same way. Compared against the FINAL membership, not the
# requested one: _register_node_locked drops the flag again for a
# node it cannot place.
if (node_id in self._declared_truth) != was_declared:
self._invalidate_analysis_caches()

def _register_node_locked(self, node_id: str, config: dict):
# Whether this call adds anything a summary reads, which is what the
Expand Down Expand Up @@ -162,6 +193,11 @@ def _register_node_locked(self, node_id: str, config: dict):
# dropped here, or one of trust/metrics/reputation/coverage_map
# was just populated above.
had_area = self.detection_areas.pop(node_id, None) is not None
# A node we cannot place has no declared wedge to publish either:
# declared_wedge_polygon is anchored on the RX this registration
# did not supply. register_node's own comparison covers the cache
# when this drops a flag it had just set.
self._declared_truth.discard(node_id)
if added_to_summary or had_area:
self._invalidate_analysis_caches()
return
Expand Down Expand Up @@ -387,6 +423,7 @@ def retire_node(self, node_id: str) -> dict:
self.empirical_coverages,
):
store.pop(node_id, None)
self._declared_truth.discard(node_id)

files = []
if self._storage_dir:
Expand Down Expand Up @@ -517,7 +554,7 @@ def get_node_summary(self, node_id: str) -> dict:
# polygon anchored on the node's stale receiver.
if ec is not None and da is not None:
fov_mode_active = self.fov_mode != "off"
# FOV off publishes what the node has been SEEN to detect, with no
# A REAL node publishes what it has been SEEN to detect, with no
# theoretical clip. It used to pass the detection area's
# beam_azimuth_deg / beam_width_deg / max_range_km, which are
# declared configuration — most nodes never had their aim surveyed
Expand All @@ -528,13 +565,28 @@ def get_node_summary(self, node_id: str) -> dict:
# publishes the learned wedge instead, which is itself derived
# from evidence. The detection area is still required (above) —
# it is the geometry check, not the clip.
#
# A DECLARED-TRUTH node (synthetic: see register_node) inverts
# that. The simulator only ever emits a detection inside the
# node's declared cone, so the cone is its detection area by
# definition and the accumulated bins are the unreliable half —
# about a third of the ADS-B binds feeding them are to the wrong
# aircraft (synth-GVL-SCAT-0032, 2026-09-13: 47 % of 3,037 points
# outside a 42° beam, 71 of 72 bearings published). So it
# publishes the declared wedge, independent of evidence — which is
# also why the min_points gate and the FOV diagnostics below,
# both evidence-derived, do not apply to it. n_points /
# n_filled_bins stay in the payload: the evidence is still
# accumulated and still worth reporting, it just is not drawn.
declared = node_id in self._declared_truth
poly_kwargs = {"use_learned_wedge": True} if fov_mode_active else {"evidence_only": True}
result["empirical_coverage"] = {
"n_points": ec.n_points,
"n_filled_bins": ec.n_filled_bins,
"polygon": ec.to_polygon(**poly_kwargs),
"polygon": (ec.declared_wedge_polygon() if declared else ec.to_polygon(**poly_kwargs)),
"polygon_source": ("declared" if declared else ("learned" if fov_mode_active else "evidence")),
}
if fov_mode_active:
if fov_mode_active and not declared:
# Flows through /api/radar/analytics automatically — no new
# dashboard endpoint needed for shadow verification.
n_neg_events = sum(len(evs) for evs in ec._neg_events)
Expand Down
Loading
Loading