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
75 changes: 71 additions & 4 deletions src/retina_analytics/association.py
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,19 @@ def _has_receiver_position(config: dict) -> bool:
return not (_coord(config, "rx_lat") == 0.0 and _coord(config, "rx_lon") == 0.0)


def _worlds_compatible(world_a, world_b) -> bool:
"""Whether two world tags may be associated with each other.

Fail-open in both directions, which is the rule the ADS-B seed gate already
applies: an unknown world on either side — no provider, a node the provider
does not know, an untagged state — is compatible with everything, and only
two *known* and different tags are refused. A tag that is present but empty
is not a world; it reads as unknown rather than as a third world that
matches nothing.
"""
return not world_a or not world_b or world_a == world_b


def _merge_epochs_multi(histories: list[tuple[str, list[dict]]]) -> list:
"""N-node generalisation of _merge_epochs, same output shape.

Expand Down Expand Up @@ -1230,6 +1243,15 @@ def __init__(
# hex collision, a mis-tag) whatever its residuals say. Optional and
# fail-open in both directions: no provider, or an unknown world on
# either side, changes nothing.
#
# The same reasoning applies one level lower, to the overlap zones
# bottom-up pairing is drawn from: two nodes in different worlds can
# never see the same echo, so a grid between them only ever pairs a
# synthetic tracklet with a real one. On the test fleet that is 400
# of 1653 zones (50 synthetic × 8 hardware nodes), 39 of them with a
# real overlap, and the real node ids duly turned up in a third of
# the synthetic dark solver records. register_node and
# rebuild_zones_for therefore skip cross-world pairs outright.
self.node_world_provider = node_world_provider
# Counters — plain += like the claiming ones above.
self.adsb_seed_rounds: int = 0
Expand All @@ -1239,6 +1261,12 @@ def __init__(
self.adsb_seed_world_rejects: int = 0
self.adsb_tracklets_excluded: int = 0
self.adsb_inputs_emitted: int = 0
# Node pairs registration or a rebuild declined to build a grid for
# because the two nodes are in different worlds. Counted per pair
# considered, so it rises by O(N) on every registration in a mixed
# fleet — the useful reading is that it is non-zero at all, and
# against overlap_zones, how much of the pair space it is removing.
self.assoc_world_skipped_pairs: int = 0

def register_node(self, node_id: str, config: dict):
"""Register a node and pre-compute overlap zones with all existing nodes.
Expand All @@ -1250,7 +1278,9 @@ def register_node(self, node_id: str, config: dict):
as long as their geometry (RX/TX position) hasn't changed.

A node whose config carries no receiver position is registered but takes
no part in overlap — see _has_receiver_position.
no part in overlap — see _has_receiver_position. A pair whose two nodes
are in known and different worlds gets no zone either — see
node_world_provider and _worlds_compatible.
"""
positioned = _has_receiver_position(config)
rx_alt_km = (config.get("rx_alt_ft") or 0) * 0.3048 / 1000.0
Expand Down Expand Up @@ -1310,10 +1340,19 @@ def register_node(self, node_id: str, config: dict):
# Pre-compute overlap zones with existing nodes (serialised to avoid
# RuntimeError: dictionary changed size during iteration when multiple
# nodes register concurrently from a thread-pool executor).
my_world = self._node_world(node_id)
for existing_id, existing_geo in list(self.node_geometries.items()):
if not self._is_positioned(existing_id):
continue
pair_key = tuple(sorted([node_id, existing_id]))
if not _worlds_compatible(my_world, self._node_world(existing_id)):
# Re-registration can be what moved this node between
# worlds, so drop rather than merely skip: a grid built
# while the pair was compatible must not survive the
# change that made it cross-world.
self._drop_pair(pair_key)
self.assoc_world_skipped_pairs += 1
continue
zone = compute_overlap_zone(
geo if pair_key[0] == node_id else existing_geo,
existing_geo if pair_key[0] == node_id else geo,
Expand Down Expand Up @@ -1360,13 +1399,35 @@ def _reset_for_tests(self) -> None:
"adsb_seed_world_rejects",
"adsb_tracklets_excluded",
"adsb_inputs_emitted",
"assoc_world_skipped_pairs",
):
setattr(self, name, 0)

def _is_positioned(self, node_id: str) -> bool:
"""Whether a registered node has a receiver position to pair against."""
return _has_receiver_position(self.node_configs.get(node_id, {}))

def _node_world(self, node_id: str):
"""This node's world, or None when nothing can say.

No provider means no world gate at all, which is what every caller
that never injects one gets.
"""
if self.node_world_provider is None:
return None
return self.node_world_provider(node_id)

def _drop_pair(self, pair_key: tuple[str, str]) -> None:
"""Remove one pair's overlap zone and both adjacency entries.

Caller holds _register_lock. Both nodes stay registered and stay
paired with everyone else; only this edge goes.
"""
a_id, b_id = pair_key
self.overlap_zones.pop(pair_key, None)
self._neighbors.get(a_id, set()).discard(b_id)
self._neighbors.get(b_id, set()).discard(a_id)

def _drop_zones_for(self, node_id: str) -> int:
"""Remove every overlap zone and adjacency entry naming this node.

Expand Down Expand Up @@ -1427,10 +1488,18 @@ def rebuild_zones_for(self, node_id: str) -> int:
geo.fov = self.fov_provider(node_id)
rebuilt = 0
with self._register_lock:
my_world = self._node_world(node_id)
for other_id, other_geo in list(self.node_geometries.items()):
if other_id == node_id or not self._is_positioned(other_id):
continue
pair_key = tuple(sorted([node_id, other_id]))
if not _worlds_compatible(my_world, self._node_world(other_id)):
# Dropped, not skipped: a rebuild is exactly where a node
# that has since changed world sheds the grids it built
# against the world it left.
self._drop_pair(pair_key)
self.assoc_world_skipped_pairs += 1
continue
a, b = (geo, other_geo) if pair_key[0] == node_id else (other_geo, geo)
zone = compute_overlap_zone(
a,
Expand Down Expand Up @@ -1518,9 +1587,7 @@ def _adsb_seed_round(
if st is None or st.get("lat") is None or st.get("lon") is None:
self.adsb_seed_no_state += 1
continue
st_world = st.get("world")
node_world = world_of.get(nid)
if st_world is not None and node_world is not None and st_world != node_world:
if not _worlds_compatible(st.get("world"), world_of.get(nid)):
self.adsb_seed_world_rejects += 1
continue
dt = float(last["t_s"]) - st.get("timestamp_ms", 0) / 1000.0
Expand Down
122 changes: 122 additions & 0 deletions tests/test_world_overlap_gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Bottom-up pairing does not cross worlds.

The ADS-B gate added a world check to seeding and claiming, but the overlap
zones pairing draws its candidates from were still built between every pair of
positioned nodes. A synthetic fleet sharing a footprint with real hardware —
the test droplet's 50 simulated nodes over the same city as 8 receivers —
therefore kept a grid for every sim/real pair, and real node ids turned up
inside synthetic dark solves. Two nodes in different worlds can never see the
same echo, so those grids have no true pairing to find.

Same fail-open rule as the seed gate: only two known, different worlds are
refused.
"""

from retina_analytics.association import InterNodeAssociator

# Two receivers a few km apart on the same illuminator: a real, non-empty
# overlap, so "no zone" below is the world gate and not the geometry.
_CFG_A = dict(rx_lat=34.85, rx_lon=-82.40, tx_lat=34.90, tx_lon=-82.30, max_range_km=50, max_bistatic_range_km=50)
_CFG_B = dict(rx_lat=34.86, rx_lon=-82.36, tx_lat=34.90, tx_lon=-82.30, max_range_km=50, max_bistatic_range_km=50)

_PAIR = ("a", "b")


def _register_pair(**kwargs) -> InterNodeAssociator:
assoc = InterNodeAssociator(grid_step_km=5.0, **kwargs)
assoc.register_node("a", dict(_CFG_A))
assoc.register_node("b", dict(_CFG_B))
return assoc


class TestOverlapZoneWorldGate:
def test_cross_world_pair_gets_no_zone(self):
worlds = {"a": "sim", "b": "real"}
assoc = _register_pair(node_world_provider=worlds.get)

assert _PAIR not in assoc.overlap_zones
assert assoc.overlap_zones == {}
assert assoc._neighbors.get("a", set()) == set()
assert assoc._neighbors.get("b", set()) == set()
assert assoc.assoc_world_skipped_pairs == 1

def test_same_world_pair_is_unchanged(self):
assoc = _register_pair(node_world_provider=lambda nid: "sim")

assert assoc.overlap_zones[_PAIR].delay_pairs
assert assoc._neighbors["a"] == {"b"}
assert assoc.assoc_world_skipped_pairs == 0

def test_one_untagged_node_still_pairs(self):
"""Fail-open: an unknown world is compatible with every world, which is
what a node the provider has not seen yet has."""
worlds = {"a": "real"}
assoc = _register_pair(node_world_provider=worlds.get)

assert assoc.overlap_zones[_PAIR].delay_pairs
assert assoc._neighbors["a"] == {"b"}
assert assoc.assoc_world_skipped_pairs == 0

def test_an_empty_world_tag_is_not_a_world(self):
worlds = {"a": "", "b": "real"}
assoc = _register_pair(node_world_provider=worlds.get)

assert assoc.overlap_zones[_PAIR].delay_pairs
assert assoc.assoc_world_skipped_pairs == 0

def test_no_provider_pairs_everything(self):
assoc = _register_pair()

assert assoc.overlap_zones[_PAIR].delay_pairs
assert assoc._neighbors["a"] == {"b"}
assert assoc.assoc_world_skipped_pairs == 0

def test_a_node_that_changes_world_loses_its_zone_on_rebuild(self):
"""The gate has to be able to take a grid away, not only decline to
build one: registration order and a late handshake both mean a pair can
be compatible when it is first built and cross-world afterwards."""
worlds = {"a": "sim", "b": "sim"}
assoc = _register_pair(node_world_provider=worlds.get)
assert assoc.overlap_zones[_PAIR].delay_pairs

worlds["b"] = "real"
rebuilt = assoc.rebuild_zones_for("b")

assert rebuilt == 0
assert _PAIR not in assoc.overlap_zones
assert assoc._neighbors.get("a", set()) == set()
assert assoc.assoc_world_skipped_pairs == 1

def test_rebuild_keeps_same_world_zones(self):
assoc = _register_pair(node_world_provider=lambda nid: "sim")

rebuilt = assoc.rebuild_zones_for("b")

assert rebuilt == 1
assert assoc.overlap_zones[_PAIR].delay_pairs
assert assoc._neighbors["a"] == {"b"}
assert assoc.assoc_world_skipped_pairs == 0

def test_a_re_registration_that_changes_world_drops_the_zone(self):
"""register_node's own drop path, which rebuild_zones_for cannot cover:
a reconnecting node re-registers with a moved receiver."""
worlds = {"a": "sim", "b": "sim"}
assoc = _register_pair(node_world_provider=worlds.get)
assert _PAIR in assoc.overlap_zones

worlds["b"] = "real"
moved = dict(_CFG_B, rx_lat=34.87)
assoc.register_node("b", moved)

assert _PAIR not in assoc.overlap_zones
assert assoc._neighbors.get("a", set()) == set()
assert assoc.assoc_world_skipped_pairs == 1

def test_the_counter_resets_with_the_others(self):
worlds = {"a": "sim", "b": "real"}
assoc = _register_pair(node_world_provider=worlds.get)
assert assoc.assoc_world_skipped_pairs == 1

assoc._reset_for_tests()

assert assoc.assoc_world_skipped_pairs == 0
Loading