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
19 changes: 16 additions & 3 deletions backend/services/track_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions backend/tests/test_track_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions frontend/src/components/LiveAircraftMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@
// Full cleanup on unmount
useEffect(() => {
return () => {
for (const m of markerMapRef.current.values()) m.remove();

Check warning on line 151 in frontend/src/components/LiveAircraftMap.tsx

View workflow job for this annotation

GitHub Actions / frontend-build

The ref value 'markerMapRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'markerMapRef.current' to a variable inside the effect, and use that variable in the cleanup function
markerMapRef.current.clear();
};
}, [map]);
Expand Down Expand Up @@ -300,7 +300,7 @@
}
markers.clear();
};
}, [map, radarAircraftRef, groundTruthRef, smoothRef]);

Check warning on line 303 in frontend/src/components/LiveAircraftMap.tsx

View workflow job for this annotation

GitHub Actions / frontend-build

React Hook useEffect has a missing dependency: 'nodesByIdRef'. Either include it or remove the dependency array

return null;
});
Expand Down Expand Up @@ -1316,7 +1316,7 @@
return Object.entries(trailsRef.current).filter(
([hex, positions]) => hex === selectedHex && positions.some((p) => isPointInViewport(p[0], p[1], viewport)),
);
}, [selectedHex, trailTick, viewport]);

Check warning on line 1319 in frontend/src/components/LiveAircraftMap.tsx

View workflow job for this annotation

GitHub Actions / frontend-build

React Hook useMemo has a missing dependency: 'trailsRef'. Either include it or remove the dependency array

const selectedTrailPositions = useMemo(() => {
if (!selectedHex) return [];
Expand Down Expand Up @@ -1506,7 +1506,7 @@
const csv = trailToCsv(ac.hex, ac.flight, rows);
downloadCsv(`trail-${ac.hex}-${Date.now()}.csv`, csv);
toast(`Exported ${rows.length} points`, { tone: "success" });
}, [selectedHex, radarAircraft, trailsRef]);

Check warning on line 1509 in frontend/src/components/LiveAircraftMap.tsx

View workflow job for this annotation

GitHub Actions / frontend-build

React Hook useCallback has a missing dependency: 'groundTruthRef'. Either include it or remove the dependency array

const exportAllTrails = useCallback(() => {
const csv = trailsToBulkCsv(radarAircraft || [], trailsRef.current || {});
Expand Down Expand Up @@ -1571,7 +1571,7 @@
p: () => { if (selectedHex) { togglePinned(selectedHex); toast(pinnedSet.has(selectedHex) ? "Unpinned" : "Pinned"); } },
m: () => locateMe(),
n: () => { setSoundOn((v) => { toast(v ? "Sound off" : "Sound on"); return !v; }); },
}), [showShortcutHelp, searchQuery, exportSelectedTrail, exportAllTrails, locateMe, selectedHex, togglePinned, pinnedSet, setSoundOn, setShowLabels, setShowTrails, setShowCoverage, setShowIlluminators, setShowGroundTruth, setColorByAlt, setShowStats, setShowRangeRings, setShowArcs]);

Check warning on line 1574 in frontend/src/components/LiveAircraftMap.tsx

View workflow job for this annotation

GitHub Actions / frontend-build

React Hook useMemo has a missing dependency: 'handleTogglePause'. Either include it or remove the dependency array
useKeyboardShortcuts(shortcutMap);

function computeError(hex, ac) {
Expand Down Expand Up @@ -2019,13 +2019,19 @@
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
brings the icon straight back. */}
{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 (
Expand Down
Loading