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
67 changes: 37 additions & 30 deletions src/retina_analytics/association.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@
C_KM_US,
KM_PER_DEG_LAT,
R_EARTH,
_is_real_coordinate,
bistatic_max_radius_km,
has_full_geometry,
km_per_deg_lon,
offset_latlon_m,
resolve_beam_azimuth_deg,
Expand Down Expand Up @@ -1012,28 +1014,21 @@ def _merge_epochs(hist_a: list, node_a_id: str, hist_b: list, node_b_id: str) ->


def _coord(config: dict, key: str) -> float:
"""A latitude/longitude from a config, absent or explicitly null reading 0.0.

`config.get(key, 0)` is not enough: a v1 registration may carry the key with
a null value, and None then reaches the geodesy as a float.
"""A latitude/longitude from a config, defaulting to 0.0 for anything that
is not a real finite number.

`config.get(key, 0)` is not enough: a v1 registration may carry the key
with a null value, and None then reaches the geodesy as a float. Total,
like has_full_geometry (reusing its _is_real_coordinate): this builds a
geometry object for a node has_full_geometry has already ruled
unpositioned, so it must not raise on a non-numeric value either. The
conversion below cannot overflow, because _is_real_coordinate admits an
int only once it has converted one itself.
"""
return float(config.get(key) or 0.0)


def _has_receiver_position(config: dict) -> bool:
"""Whether this config says where the receiver actually is.

Absent coordinates default to (0, 0), a point in the Gulf of Guinea that no
node occupies. Every node registered without a position therefore lands on
one footprint and overlaps every other completely — a pairing that is both
fictitious and, being total, the densest and most expensive grid the pair
can produce. A fleet registered that way makes the neighbour graph
complete, which the multinode solver sees as one enormous candidate.

Only the exact (0, 0) pair reads as absent. The equator and the prime
meridian are each perfectly good coordinates on their own.
"""
return not (_coord(config, "rx_lat") == 0.0 and _coord(config, "rx_lon") == 0.0)
value = config.get(key)
if not _is_real_coordinate(value):
return 0.0
return float(value)


def _worlds_compatible(world_a, world_b) -> bool:
Expand Down Expand Up @@ -1418,12 +1413,12 @@ def register_node(self, node_id: str, config: dict):
Reconnecting nodes skip the expensive O(n²) overlap recomputation
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 overlapsee _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.
A node whose config lacks either end of the bistatic pair is
registered but takes no part in overlap: see has_full_geometry. 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)
positioned = has_full_geometry(config)
rx_alt_km = (config.get("rx_alt_ft") or 0) * 0.3048 / 1000.0
tx_alt_km = (config.get("tx_alt_ft") or 0) * 0.3048 / 1000.0

Expand Down Expand Up @@ -1452,7 +1447,10 @@ def register_node(self, node_id: str, config: dict):
# Recorded before the unchanged-geometry early return: the equality
# check below covers geometry only, so a reconnect that changed fc_hz
# would otherwise leave the fit using the old carrier.
self.node_configs[node_id] = config
# Copied: the caller keeps its dict and may reuse or mutate it,
# and every read below (and _is_positioned's, rounds later) must
# see the config as it was at registration.
self.node_configs[node_id] = dict(config)
existing = self.node_geometries.get(node_id)
if existing is not None and (
abs(existing.rx_lat - geo.rx_lat) < 1e-6
Expand Down Expand Up @@ -1483,7 +1481,10 @@ def register_node(self, node_id: str, config: dict):
# 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):
# node_configs[node_id] already holds the new config (rewritten
# above), so _is_positioned(node_id) cannot be used to skip this
# node's own stale entry here.
if existing_id == node_id or 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)):
Expand All @@ -1507,6 +1508,12 @@ def register_node(self, node_id: str, config: dict):
if zone.delay_pairs: # only real overlaps, not geographic misses
self._neighbors.setdefault(node_id, set()).add(existing_id)
self._neighbors.setdefault(existing_id, set()).add(node_id)
else:
# A relocated node may no longer overlap a former
# neighbour; leaving the adjacency in place would occupy a
# slot in the capped neighbour rotation forever.
self._neighbors.get(node_id, set()).discard(existing_id)
self._neighbors.get(existing_id, set()).discard(node_id)

self.node_geometries[node_id] = geo

Expand Down Expand Up @@ -1547,8 +1554,8 @@ def _reset_for_tests(self) -> None:
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, {}))
"""Whether a registered node has both ends of its geometry to pair against."""
return has_full_geometry(self.node_configs.get(node_id, {}))

def _node_world(self, node_id: str):
"""This node's world, or None when nothing can say.
Expand Down
51 changes: 51 additions & 0 deletions src/retina_analytics/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,57 @@ def resolve_beam_azimuth_deg(config, rx_lat, rx_lon, tx_lat, tx_lon):
return (bearing_deg(rx_lat, rx_lon, tx_lat, tx_lon) + 90.0) % 360.0


_GEOMETRY_KEYS = ("rx_lat", "rx_lon", "tx_lat", "tx_lon")


def _is_real_coordinate(value) -> bool:
"""Whether *value* is usable as one coordinate: a real, finite number.

Identity-based, not coerced: a numeric string is not a coordinate, and
neither is a ``bool`` (a subclass of ``int`` otherwise indistinguishable
from one). An ``int`` must survive conversion to a float, because every
consumer stores one: calling an unrepresentable int real would hand the
caller a coordinate it then raises ``OverflowError`` trying to use,
aborting a registration partway through. A float is admitted only when
``math.isfinite``, which never raises on an existing float.
"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return False
if isinstance(value, int):
try:
value = float(value)
except OverflowError:
return False
return math.isfinite(value)


def has_full_geometry(config: dict) -> bool:
"""Whether this config places both ends of the bistatic pair.

Both are needed: the beam points broadside to the RX->TX baseline and every
footprint has foci at RX and TX, so one missing side leaves a node
unplaceable rather than approximately placed.

Total: never raises, for any input, including a non-dict config or a
coordinate slot holding something other than a number. Absent and null
read alike, and so does the legacy (0, 0) sentinel that absent
coordinates used to default to. No node occupies the Gulf of Guinea, and
a fleet defaulted there overlaps completely, which makes the neighbour
graph complete and the solver's candidates pure artefact. Only the exact
(0, 0) pair reads as unset, on either end: the equator and the prime
meridian are each perfectly good coordinates on their own, for either
end. See _is_real_coordinate for what counts as a coordinate at all.
"""
if not isinstance(config, dict):
return False
values = {key: config.get(key) for key in _GEOMETRY_KEYS}
if not all(_is_real_coordinate(v) for v in values.values()):
return False
if values["rx_lat"] == 0.0 and values["rx_lon"] == 0.0:
return False
return not (values["tx_lat"] == 0.0 and values["tx_lon"] == 0.0)


def resolve_beam_width_deg(config):
"""Resolve a node's beam width (deg), defaulting to the nominal Yagi spec.

Expand Down
132 changes: 106 additions & 26 deletions src/retina_analytics/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
YAGI_BEAM_WIDTH_DEG,
YAGI_MAX_RANGE_KM,
bearing_deg,
has_full_geometry,
haversine_km,
resolve_beam_azimuth_deg,
resolve_beam_width_deg,
Expand Down Expand Up @@ -113,27 +114,17 @@ def register_node(self, node_id: str, config: dict):
self._register_node_locked(node_id, config)

def _register_node_locked(self, node_id: str, config: dict):
# Whether this call adds anything a summary reads, which is what the
# cache invalidation below turns on. Tracked per store as each is
# written rather than inferred from one of them: a node id can already
# be present in trust_scores without ever having registered, since
# record_adsb_correlation creates entries there too, and its summary
# still gains metrics, reputation and coverage here.
added_to_summary = False

if node_id not in self.trust_scores:
self.trust_scores[node_id] = TrustScoreState(node_id=node_id)

rx_lat = config.get("rx_lat", 0)
rx_lon = config.get("rx_lon", 0)
tx_lat = config.get("tx_lat", 0)
tx_lon = config.get("tx_lon", 0)
# Honour an explicit aim if the config supplies one; else broadside.
beam_az = resolve_beam_azimuth_deg(config, rx_lat, rx_lon, tx_lat, tx_lon)
self.detection_areas[node_id] = DetectionAreaState(
node_id=node_id,
rx_lat=rx_lat,
rx_lon=rx_lon,
tx_lat=tx_lat,
tx_lon=tx_lon,
fc_hz=config.get("fc_hz", config.get("FC", 195e6)),
beam_azimuth_deg=beam_az,
beam_width_deg=resolve_beam_width_deg(config),
max_range_km=config.get("max_range_km", YAGI_MAX_RANGE_KM),
max_bistatic_range_km=config.get("max_bistatic_range_km"),
)
added_to_summary = True

# Preserve accumulated metrics across reconnects. Every other
# per-node store here is conditional, but this one was replaced
Expand All @@ -145,21 +136,97 @@ def _register_node_locked(self, node_id: str, config: dict):
node_id=node_id,
connected_at=time.time(),
)
added_to_summary = True
else:
existing_metrics.connected_at = time.time()

if node_id not in self.reputations:
self.reputations[node_id] = NodeReputation(node_id=node_id)
added_to_summary = True

if node_id not in self.coverage_maps:
self.coverage_maps[node_id] = HistoricalCoverageMap(node_id=node_id)
added_to_summary = True

# Identity, metrics and reputation are above and unconditional: a node
# we cannot place is still a node that is working, and its frames are
# counted through `metrics` membership in record_detection_frame.
#
# Geometry is undefined without both coordinate pairs, so everything
# below is skipped. No detection area is what keeps such a node off the
# map, since get_node_summary omits the key and the map only draws a
# marker for a node that has one.
if not has_full_geometry(config):
# Invalidate exactly when this changes what a summary holds: a
# detection area from a previous positioned registration is
# 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
if added_to_summary or had_area:
self._invalidate_analysis_caches()
return

rx_lat = config["rx_lat"]
rx_lon = config["rx_lon"]
tx_lat = config["tx_lat"]
tx_lon = config["tx_lon"]
# Honour an explicit aim if the config supplies one; else broadside.
beam_az = resolve_beam_azimuth_deg(config, rx_lat, rx_lon, tx_lat, tx_lon)
beam_width = resolve_beam_width_deg(config)
# Keyed on None, not on absence: a config carrying an explicit null
# reaches here unchanged, and a .get default does not substitute for
# one, so the comparison below would subtract None from None.
max_range_km = config.get("max_range_km")
if max_range_km is None:
max_range_km = YAGI_MAX_RANGE_KM
max_bistatic_range_km = config.get("max_bistatic_range_km")
fc_hz = config.get("fc_hz", config.get("FC", 195e6))

# Every field DetectionAreaState is built from, compared against what
# it already holds. Unchanged means a reconnect would rebuild a
# byte-identical object, so the rebuild (and the reset of its
# accumulated n_detections/delay/doppler bounds/furthest_detections
# back to defaults) is skipped, the same way the metrics store above
# is preserved across a reconnect rather than replaced. Mirrors
# InterNodeAssociator.register_node's unchanged-geometry check, but
# that check omits fc_hz deliberately, because it always records the
# fresh config in node_configs regardless of the early return, so a
# changed fc_hz is never lost there even when NodeGeometry keeps its
# old one. Nothing here plays that role for detection_areas, so fc_hz
# has to be part of the comparison instead.
existing_da = self.detection_areas.get(node_id)
detection_area_unchanged = existing_da is not None and (
abs(existing_da.rx_lat - rx_lat) < 1e-6
and abs(existing_da.rx_lon - rx_lon) < 1e-6
and abs(existing_da.tx_lat - tx_lat) < 1e-6
and abs(existing_da.tx_lon - tx_lon) < 1e-6
and abs(existing_da.max_range_km - max_range_km) < 1e-4
and abs(existing_da.beam_azimuth_deg - beam_az) < 1e-4
and abs(existing_da.beam_width_deg - beam_width) < 1e-4
and existing_da.max_bistatic_range_km == max_bistatic_range_km
and existing_da.fc_hz == fc_hz
)

if not detection_area_unchanged:
self.detection_areas[node_id] = DetectionAreaState(
node_id=node_id,
rx_lat=rx_lat,
rx_lon=rx_lon,
tx_lat=tx_lat,
tx_lon=tx_lon,
fc_hz=fc_hz,
beam_azimuth_deg=beam_az,
beam_width_deg=beam_width,
max_range_km=max_range_km,
max_bistatic_range_km=max_bistatic_range_km,
)

# Recreate empirical coverage when the node is new OR its RX moved — node
# IDs are reused across fleet regenerations at different positions, so a
# persisted polygon from the old location would otherwise be served for
# the new one (stale, beam-mismatched, collapsed).
ec = self.empirical_coverages.get(node_id)
cfg_max_range = config.get("max_range_km", YAGI_MAX_RANGE_KM)
cfg_max_range = max_range_km
moved = ec is not None and haversine_km(ec.rx_lat, ec.rx_lon, rx_lat, rx_lon) > _RX_RELOCATE_THRESHOLD_KM
# A change in the *range rule* invalidates the accumulated polygon just
# as surely as the RX physically moving: switching a node from a
Expand All @@ -173,7 +240,7 @@ def _register_node_locked(self, node_id: str, config: dict):
# *keeps* accumulated calibration (see the else branch) because it only
# moves the clamp; switching range rules changes the footprint's shape,
# which is a different thing.
cfg_bistatic = config.get("max_bistatic_range_km")
cfg_bistatic = max_bistatic_range_km
rule_changed = ec is not None and getattr(ec, "max_bistatic_range_km", None) != cfg_bistatic
# A polygon accumulated under an older calibration input is discarded on
# the same footing. The bistatic key cannot catch this one: switching
Expand Down Expand Up @@ -222,6 +289,12 @@ def _register_node_locked(self, node_id: str, config: dict):
# not the evidence.
ec.prior_azimuth_deg, ec.prior_width_deg = prior_az, prior_width

# detection_areas[node_id] was only just rebuilt above when it was not
# already unchanged, and a rebuild is the only way this path alters
# what a summary holds, so the two conditions coincide.
if not detection_area_unchanged:
self._invalidate_analysis_caches()

def coverage_limit_for(self, node_id: str):
"""A bearing → observed-limit-km callable for one node, or None.

Expand Down Expand Up @@ -269,6 +342,11 @@ def coverage_digest(self, node_id: str):
return None
return ec.fov_digest() if self.fov_mode != "off" else ec.constraint_digest()

def _invalidate_analysis_caches(self) -> None:
"""Drop the memoised get_all_summaries/get_cross_node_analysis results."""
self._summaries_cache = None
self._cross_node_cache = None

def retire_node(self, node_id: str) -> dict:
"""Forget a node entirely — in-memory state and its files on disk.

Expand Down Expand Up @@ -322,8 +400,7 @@ def retire_node(self, node_id: str) -> dict:
logging.warning("could not remove %s during retirement", path, exc_info=True)

# Any summary cached before this call still names the node.
self._summaries_cache = None
self._cross_node_cache = None
self._invalidate_analysis_caches()

return {"node_id": node_id, "dropped": dropped, "files_removed": files}

Expand Down Expand Up @@ -434,13 +511,16 @@ def get_node_summary(self, node_id: str) -> dict:
if node_id in self.coverage_maps:
result["coverage_map"] = self.coverage_maps[node_id].summary()
ec = self.empirical_coverages.get(node_id)
if ec is not None:
da = self.detection_areas.get(node_id)
da = self.detection_areas.get(node_id)
# Also gated on da: empirical_coverages outlives a lost geometry (see
# _register_node_locked), so ec alone would publish an unconstrained
# 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"
poly_kwargs = {}
if fov_mode_active:
poly_kwargs["use_learned_wedge"] = True
elif da is not None:
else:
poly_kwargs["beam_azimuth_deg"] = da.beam_azimuth_deg
poly_kwargs["beam_width_deg"] = da.beam_width_deg
poly_kwargs["max_range_km"] = da.max_range_km
Expand Down
Loading
Loading