diff --git a/src/retina_analytics/association.py b/src/retina_analytics/association.py index 6a8e172..6e49f5f 100644 --- a/src/retina_analytics/association.py +++ b/src/retina_analytics/association.py @@ -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: @@ -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 @@ -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, @@ -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. @@ -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, ) @@ -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, ) diff --git a/src/retina_analytics/detection_association.py b/src/retina_analytics/detection_association.py index 3a5f775..8cbf59d 100644 --- a/src/retina_analytics/detection_association.py +++ b/src/retina_analytics/detection_association.py @@ -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 @@ -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) diff --git a/tests/test_association.py b/tests/test_association.py index 464517c..28c06f5 100644 --- a/tests/test_association.py +++ b/tests/test_association.py @@ -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 @@ -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