diff --git a/backend/services/track_gates.py b/backend/services/track_gates.py index bc18b477..5ab2e492 100644 --- a/backend/services/track_gates.py +++ b/backend/services/track_gates.py @@ -737,9 +737,22 @@ def _num(v, fallback=0.0): "hex": ac_hex, "ground_truth_hex": resolve_ground_truth_hex(ac_hex, lat, lon), "type": "tisb_other", - "flight": (track.adsb_hex or f"PR{abs(hash(track.track_id)) % 10000:04d}").strip(), - "alt_baro": round(alt_ft), - "alt_geom": round(alt_ft), + # No fabricated identity for an unassociated track: the synthetic + # PR#### callsign made a radar-only return render as a plausible + # aircraft, so clutter promotions read as real low-level traffic. + # An empty flight falls back to the hex everywhere downstream. + "flight": (track.adsb_hex or "").strip(), + # Altitude only when some ADS-B identity vouches for it: a fresh fix + # supplies it directly, and an ADS-B-hexed track with a stale/partial + # fix keeps the solver fallback (test_fresh_adsb_fallback pins that). + # For a track with no ADS-B identity at all, single-node bistatic + # geometry is underdetermined in altitude (passive_radar.py, solver + # notes), so track.alt_ft is essentially free — observed live from + # 165 ft to 50,768 ft on one node — and publishing it painted ghost + # aircraft on the deck. Every consumer null-guards alt_baro (the + # tar1090 schema omits it when unknown). + "alt_baro": round(alt_ft) if (adsb or track.adsb_hex) else None, + "alt_geom": round(alt_ft) if (adsb or track.adsb_hex) else None, "gs": gs, "track": heading, "lat": lat, diff --git a/backend/tests/test_track_gates.py b/backend/tests/test_track_gates.py index 4a042189..e0034787 100644 --- a/backend/tests/test_track_gates.py +++ b/backend/tests/test_track_gates.py @@ -287,3 +287,37 @@ def _spy(node_ids, lat, lon, age_s, fix_ts, detection_ts): assert captured["fix_ts"] == pytest.approx(now - 1.0, abs=0.01) assert captured["detection_ts"] == pytest.approx(now - 1.0, abs=0.01) + + +class TestUnassociatedEntryHonesty: + """No fabricated aircraft attributes for a radar-only track. + + Single-node bistatic geometry is underdetermined in altitude, so + track.alt_ft is essentially free (observed live 2026-08-26: 165 ft to + 50,768 ft on one node), and the synthetic PR#### callsign made clutter + promotions render as plausible low-level traffic. Altitude and callsign + are published only when ADS-B vouches for them. + """ + + def test_unassociated_track_publishes_no_altitude_or_callsign(self, node): + now = time.time() + track = _make_track(n_detections=3, last_detection_age_s=1.0, now=now) + track.adsb_hex = None + entry = track_gates.track_entry("pr1234", track, dict(_NODE_CFG), now, set()) + + assert entry is not None + assert entry["position_source"] == "solver_single_node" + assert entry["alt_baro"] is None + assert entry["alt_geom"] is None + # Empty, not a synthetic PR#### — the frontend falls back to the hex. + assert entry["flight"] == "" + + def test_adsb_backed_track_keeps_altitude_and_identity(self, node): + now = time.time() + _adsb_fix(now) + track = _make_track(n_detections=3, last_detection_age_s=1.0, now=now) + entry = track_gates.track_entry(HEX, track, dict(_NODE_CFG), now, set()) + + assert entry["alt_baro"] == 30000 + assert entry["alt_geom"] == 30000 + assert entry["flight"] == HEX diff --git a/frontend/src/components/LiveAircraftMap.tsx b/frontend/src/components/LiveAircraftMap.tsx index 25ea79cc..5ade4a50 100644 --- a/frontend/src/components/LiveAircraftMap.tsx +++ b/frontend/src/components/LiveAircraftMap.tsx @@ -2019,6 +2019,11 @@ export default function LiveAircraftMap() { users mistook the icon position for the actual location. The detection arc rendered by DetectionArcs is their only map presence; selecting them from the list still highlights the arc and centers the map on the midpoint. + Unassociated solver_single_node tracks (an arc-less frame of the same + single-node geometry) are hidden for the same reason: a lone node's LM solve + is underdetermined, and drawing it as a plane painted short-lived ghost + aircraft wherever clutter promoted a track. They stay in the list as + Solver·1N rows. A track that has dead-reckoned past DR_ICON_HIDE_DISTANCE_M loses its icon for the same reason — the drawn position is no longer evidence of where the aircraft is — but stays tracked everywhere else, so the next real solve @@ -2026,6 +2031,7 @@ export default function LiveAircraftMap() { {visibleAircraft.map((ac) => { if (!validLatLon(ac.lat, ac.lon)) return null; if (ac.position_source === POSITION_SOURCE_ARC_ONLY) return null; + if (ac.position_source === "solver_single_node") return null; if (hideDrIcon(ac, markerNow)) return null; const isSelected = ac.hex === selectedHex; return (