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
25 changes: 22 additions & 3 deletions src/retina_analytics/association.py
Original file line number Diff line number Diff line change
Expand Up @@ -778,11 +778,21 @@ def _point_in_beam(lat, lon, geo: NodeGeometry) -> bool:
return True


# Altitude layers the overlap grid is built on when a caller does not say.
# Kept at the historic six so every existing caller — the unit tests, the
# offline bench, any library user — sees exactly the grid it saw before the
# layer set became a parameter. Production overrides it (retina-server's
# ASSOC_ALT_LAYERS_KM): the association altitude is what an n=2 solve's
# position error is made of, so the deployment wants a finer ladder than the
# library's conservative default.
DEFAULT_ALTITUDES_KM: tuple[float, ...] = (1.5, 3.0, 5.0, 7.0, 9.0, 11.0)


def compute_overlap_zone(
geo_a: NodeGeometry,
geo_b: NodeGeometry,
grid_step_km: float = 3.0,
altitudes_km: tuple[float, ...] = (1.5, 3.0, 5.0, 7.0, 9.0, 11.0),
altitudes_km: tuple[float, ...] = DEFAULT_ALTITUDES_KM,
delay_gate_us: float = 5.0,
doppler_gate_hz: float = 30.0,
) -> OverlapZone:
Expand Down Expand Up @@ -832,8 +842,8 @@ def compute_overlap_zone(

# The both-beams test is 2-D — _point_in_beam takes (lat, lon) only, and
# the lat/lon of a column depends on (east, north) alone — so it is
# resolved once here rather than re-derived identically for each of the
# six altitudes. It dominated the rebuild (87% of a node rebuild's time,
# resolved once here rather than re-derived identically for each
# altitude layer. It dominated the rebuild (87% of a node rebuild's time,
# 855k calls where 143k distinct columns exist), and the altitude loop
# below now runs over the survivors, which on this fleet is a small
# fraction of the bounding box. Column order is preserved and altitude
Expand Down Expand Up @@ -1105,6 +1115,7 @@ def __init__(
delay_gate_us: float = 5.0,
doppler_gate_hz: float = 30.0,
grid_step_km: float = 3.0,
altitudes_km: tuple[float, ...] = DEFAULT_ALTITUDES_KM,
assoc_interval_s: float = 30.0,
cv_fit=None,
cv_chi2_max: float = 2.0,
Expand Down Expand Up @@ -1136,6 +1147,12 @@ def __init__(
self.delay_gate_us = delay_gate_us
self.doppler_gate_hz = doppler_gate_hz
self.grid_step_km = grid_step_km
# Threaded through to compute_overlap_zone on every zone build, the
# same way grid_step_km is: the horizontal step is not what limits an
# n=2 solve (the LM converges from a 3 km start), the altitude layer
# it picks is, so the layer ladder has to be tunable per deployment
# rather than frozen in the library.
self.altitudes_km = tuple(altitudes_km)
self.node_geometries: dict[str, NodeGeometry] = {}
# Raw registration configs, kept because the constant-velocity fit wants
# rx/tx lat/lon/alt and fc in the same shape the solver takes them.
Expand Down Expand Up @@ -1481,6 +1498,7 @@ def register_node(self, node_id: str, config: dict):
geo if pair_key[0] == node_id else existing_geo,
existing_geo if pair_key[0] == node_id else geo,
grid_step_km=self.grid_step_km,
altitudes_km=self.altitudes_km,
delay_gate_us=self.delay_gate_us,
doppler_gate_hz=self.doppler_gate_hz,
)
Expand Down Expand Up @@ -1630,6 +1648,7 @@ def rebuild_zones_for(self, node_id: str) -> int:
a,
b,
grid_step_km=self.grid_step_km,
altitudes_km=self.altitudes_km,
delay_gate_us=self.delay_gate_us,
doppler_gate_hz=self.doppler_gate_hz,
)
Expand Down
8 changes: 5 additions & 3 deletions src/retina_analytics/detection_association.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,9 @@ def find_associations(zone: OverlapZone, frame_a: dict, frame_b: dict, timestamp

# ── ADS-B altitude override ──────────────────────────────────────────
# ADS-B altitude is exact (to ~10 m) while the pre-computed grid only
# has discrete layers (e.g. 5, 7, 9, 11 km), introducing up to ±1 km
# altitude error. Prefer frame_a; fall back to frame_b.
# has discrete layers (whatever altitudes_km the zone was built on),
# introducing up to half a layer spacing of altitude error.
# Prefer frame_a; fall back to frame_b.
# Require alt_baro > 100 ft to exclude spurious zero reports.
if isinstance(_ae_a, dict) and (_ae_a.get("alt_baro") or 0) > 100:
g_alt = float(_ae_a["alt_baro"]) * 0.3048 / 1000.0 # ft → km
Expand Down Expand Up @@ -499,7 +500,8 @@ def _union(x: int, y: int) -> None:

# Use the altitude of the best-matching grid point (min delay
# residual) from each candidate, then take the mean across the
# group. Layers default to (1.5, 3, 5, 7, 9, 11) km so low-altitude
# group. The layer set is the associator's altitudes_km (six by
# default, twelve at 1 km spacing in production) so low-altitude
# bistatic ghost solutions (which can map to positions hundreds of
# km away) are never considered.
g_alt_km = sum(c.grid_alt_km for c in group) / len(group)
Expand Down
50 changes: 49 additions & 1 deletion tests/test_association.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,8 @@ def test_true_pairings_are_essentially_never_rejected(self):
class TestOverlapGridCost:
"""The both-beams test is 2-D, so it must not be repeated per altitude.

compute_overlap_zone evaluates six altitude layers over one bounding box,
compute_overlap_zone evaluates a configurable set of altitude layers over
one bounding box (six by default, twelve in production),
and _point_in_beam takes (lat, lon) only. Re-deriving it per layer was 87%
of a node rebuild on the 52-node test deployment (855k calls where 143k
distinct columns exist), which is what put analytics_refresh past its 120 s
Expand Down Expand Up @@ -347,6 +348,53 @@ def test_every_altitude_layer_is_still_emitted(self):
by_alt.setdefault(alt, set()).add((lat, lon))
assert set(by_alt) == set(self.ALTS)

def test_layer_count_scales_the_grid_linearly(self):
"""Twelve 1 km layers cost exactly twice a six-layer grid, not more.

The columns are altitude-independent (they are what the both-beams
test resolves once, above), so doubling the ladder doubles the emitted
points and nothing else — this is the precompute bill production pays
for ASSOC_ALT_LAYERS_KM, and the number the server PR quotes.
"""
geo_a, geo_b = self._pair()
six = compute_overlap_zone(geo_a, geo_b, grid_step_km=5.0, altitudes_km=self.ALTS)
twelve_alts = tuple(float(k) for k in range(1, 13))
twelve = compute_overlap_zone(geo_a, geo_b, grid_step_km=5.0, altitudes_km=twelve_alts)
assert six.grid_points
assert len(twelve.grid_points) == 2 * len(six.grid_points)
assert {a for _, _, a in twelve.grid_points} == set(twelve_alts)

def test_associator_threads_its_layer_set_into_every_zone(self):
"""The layer set is a property of the associator, not a hardcode.

register_node builds a zone per neighbour pair; the ladder the
deployment configured has to reach all of them, or the finer layers
exist only in whichever build path happened to be patched.
"""
geo_a, geo_b = self._pair()
alts = (2.0, 4.0, 6.0, 8.0)
assoc = InterNodeAssociator(grid_step_km=5.0, altitudes_km=alts)
assert assoc.altitudes_km == alts
for geo in (geo_a, geo_b):
assoc.register_node(
geo.node_id,
{
"rx_lat": geo.rx_lat,
"rx_lon": geo.rx_lon,
"rx_alt_km": geo.rx_alt_km,
"tx_lat": geo.tx_lat,
"tx_lon": geo.tx_lon,
"tx_alt_km": geo.tx_alt_km,
"beam_azimuth_deg": geo.beam_azimuth_deg,
"beam_width_deg": geo.beam_width_deg,
"max_range_km": geo.max_range_km,
},
)
zones = [z for z in assoc.overlap_zones.values() if z.grid_points]
assert zones, "the fixture pair overlaps, so a zone must have been built"
for zone in zones:
assert {a for _, _, a in zone.grid_points} == set(alts)

def test_baseline_km_is_memoised_but_follows_a_moved_node(self):
geo_a, _ = self._pair()
first = geo_a.baseline_km
Expand Down
Loading