From c1fd181b653aa0e9c5bbb70f6342a0bf2243f3c7 Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Mon, 7 Sep 2026 21:01:17 +0000 Subject: [PATCH] Thread the altitude layer set through the associator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An n=2 solve is exactly determined in (x, y) once the altitude is pinned, so its position error IS its altitude error. On a 20-minute test capture against ground truth, n=2 solves had a median altitude error of 1.46 km (p90 5.25) and a position error of 1.61 km when the altitude landed within 1 km of truth against 2.71 km when it did not (n=3, for contrast: 0.36 vs 2.54 km). That altitude comes from here — compute_overlap_zone precomputes each node pair's overlap on a horizontal lattice at a handful of altitude layers, and detection_association picks the best grid point and a delay-residual-weighted mean altitude across them. The horizontal step is not the limiter (the LM solve converges happily from a 3 km start); the altitude ladder is, and it was frozen at six layers whose widest gap (1.5 -> 3.0 -> 5.0) is 2 km. So make the ladder a parameter rather than a hardcode: altitudes_km on InterNodeAssociator, threaded into compute_overlap_zone on both zone-build paths (register_node and the rebuild), exactly the way grid_step_km already is. The library default is unchanged — DEFAULT_ALTITUDES_KM is the historic (1.5, 3.0, 5.0, 7.0, 9.0, 11.0), so every existing caller, unit test and offline bench sees the identical grid it saw before. The deployment is what overrides it. Cost is linear and nothing else moves: the columns are altitude-independent (that is the property the both-beams restructure rests on), so a twelve-layer ladder emits exactly twice the grid points of a six-layer one over the same geometry and pays twice the precompute. A test pins that ratio, and another pins that the associator's configured ladder actually reaches the zones register_node builds — a layer set that only takes effect on one of the two build paths would be worse than none. Co-Authored-By: Claude Fable 5.1 --- src/retina_analytics/association.py | 25 ++++++++-- src/retina_analytics/detection_association.py | 8 +-- tests/test_association.py | 50 ++++++++++++++++++- 3 files changed, 76 insertions(+), 7 deletions(-) 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