Skip to content

No fabricated identity or altitude for unassociated single-node tracks - #253

Merged
jehanazad merged 1 commit into
mainfrom
fix/single-node-ghost-honesty
Aug 26, 2026
Merged

jehanazad merged 1 commit into
mainfrom
fix/single-node-ghost-honesty

Conversation

@jehanazad

Copy link
Copy Markdown
Contributor

Summary

Confirmed live 2026-08-26: periodic "low-altitude ghost aircraft" and orphan noise arcs are one phenomenon — waves of unassociated single-node detections promoted to pr* tracks. Each got a fabricated identity (synthetic PR#### callsign, category A3) and an altitude straight from the single-node LM solve, which is underdetermined in altitude (observed 165 ft to 50,768 ft on one node; 48 ghosts in 8.5 min, none matching any ground truth, lifetimes quantized at the 15 s display TTL). This PR stops publishing what the geometry cannot know, so clutter reads as an unidentified detection instead of a plausible aircraft on the deck.

Changes

  • services/track_gates.pytrack_entry publishes alt_baro/alt_geom only when some ADS-B identity vouches for them: a fresh fix directly, or the solver fallback for an ADS-B-hexed track with a stale/partial fix (test_fresh_adsb_fallback pins that fallback and still passes). A track with no ADS-B identity ships null altitude. The synthetic PR#### callsign is gone; an empty flight falls back to the hex everywhere downstream.
  • frontend/src/components/LiveAircraftMap.tsx — no plane icon for unassociated solver_single_node tracks (the arc-less frame of the same single-node geometry), extending the precedent Three-lane map icon colors, immediate icon handoff, drift-based icon hiding #243 set for arc-midpoint tracks. They stay in the list as Solver·1N rows; error-line and detail-panel handling is unchanged.

Test coverage

  • New TestUnassociatedEntryHonesty in tests/test_track_gates.py: unassociated track publishes null altitude and empty callsign; ADS-B-backed track keeps both.
  • Full backend suite: 2497 passed, 2 skipped. Frontend: tsc --noEmit, vite build, and vitest (121 passed) in the node:20 container. Pre-commit gate clean.

Review notes

  • Judgment call to scrutinize: hiding the solver_single_node icon removes the only map presence of an arc-less unassociated track (arc-midpoint tracks at least keep their arc). In the measured window these were 100% clutter (zero ground-truth matches), and their drawn position is a weakly-determined solve — but a real dark target caught by exactly one node with no buildable arc would now appear only in the list.
  • Deliberately not addressed: the volume of orphan detection arcs themselves (bursty ~30–60 s waves, anti-correlated with real traffic; synthetic nodes 100% orphan). Those arcs honestly represent measurements; reducing them means tracker promotion tuning (M=3/N=6, min_snr=4.0 in retina-tracker) — a sensitivity trade-off that deserves its own change.
  • Track-history/trail altitude still records the solver value internally; only the published wire fields changed.
  • Pairs with Reject non-transponder hexes from the ADS-B world #252 (dark/ADS-B lane): together they were the three map bugs confirmed in the 2026-08-26 live capture.

🤖 Generated with Claude Code

Clutter promotions were rendering as plausible low-level aircraft.  A
radar-only track got a synthetic PR#### callsign, tar1090 category, and
alt_baro straight from the single-node LM solve — but single-node
bistatic geometry is underdetermined in altitude, so that number is
essentially free: observed live 2026-08-26 from 165 ft to 50,768 ft on
one node, with 48 unassociated pr* tracks in 8.5 min (none matching any
ground truth, lifetimes quantized at the 15 s display TTL), 25 of them
"below 6,000 ft".  On the map they read as periodic low-altitude ghost
aircraft.

- track_entry publishes alt_baro/alt_geom only when some ADS-B identity
  vouches for them: a fresh fix directly, or the solver fallback for an
  ADS-B-hexed track with a stale/partial fix (the behaviour
  test_fresh_adsb_fallback pins).  A track with no ADS-B identity ships
  null altitude — every consumer already null-guards (list shows "—").
- The synthetic PR#### callsign is gone; an empty flight falls back to
  the hex everywhere downstream.
- The map draws no plane icon for unassociated solver_single_node
  tracks (the arc-less frame of the same single-node geometry) — the
  precedent set for arc-midpoint tracks, whose lat/lon convention
  already gets no marker.  They stay in the list as Solver·1N rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 26, 2026

Copy link
Copy Markdown

Claude finished @jehanazad's task in 4m 39s —— View job


Claude finished the review

Tasks

  • Gather context (PR diff, changed files, CLAUDE.md)
  • Review backend/services/track_gates.py altitude/identity gating
  • Review backend/tests/test_track_gates.py coverage
  • Review frontend/src/components/LiveAircraftMap.tsx icon-hiding change
  • Post findings

Summary

The core fix is sound and well-tested for the case it was measured against (a track that never had ADS-B identity). Two things worth a look before/after merge:


1. The new altitude gate trusts track.adsb_hex, which this same file documents as capable of going stale (backend/services/track_gates.py:744-755)

"flight": (track.adsb_hex or "").strip(),
...
"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,

track.adsb_hex is the tracker's association hex, not a per-detection freshness signal. Later in this exact function (lines 576-580 in pipeline/passive_radar.py, and again at track_gates.py:647-656), the codebase explicitly documents that this field goes stale: "a track that re-associates onto an untagged (dark) target keeps its old hex indefinitely (the swap debounce only advances on TAGGED mismatches)." The calibration-recording code a few dozen lines above this change already accounts for that — it additionally requires last_detection_adsb_hex to match the current hex before trusting ADS-B provenance (track_gates.py:669-677, and TestDetectionIdentityGate in the new test file's neighboring test class). The altitude/flight gate added here does not reuse that check.

Concretely: a track that briefly associated with a real aircraft (adsb_hex = "ABC123"), then re-associated onto an unrelated dark/clutter detection without a new tag, keeps publishing flight: "ABC123" and a solver-derived alt_baro/alt_geom for whatever it's now detecting — the same "165 ft to 50,768 ft, underdetermined single-node solve" fabrication this PR sets out to stop, just gated behind a different trigger (stale identity from an earlier tagged association) than the one measured in the 2026-08-26 capture (never-tagged pr* tracks). Since ac_hex passed into track_entry is itself track.adsb_hex or track.hex_id (services/aircraft_feed.py:317, and the equivalent in active_geo_aircraft's key), fresh_adsb(ac_hex, ...) looks up the stale hex too, so this isn't a rare edge case where adsb would independently save it — if the original aircraft has simply gone out of range/landed, adsb is None and the stale-hex branch fires.

Given the PR's stated goal is "publish altitude only when some ADS-B identity vouches for it," this seems worth tightening — e.g. requiring last_detection_adsb_hex to match ac_hex (mirroring the calibration gate) rather than trusting track.adsb_hex alone. Not necessarily a blocker (the flight-field staleness on track.adsb_hex predates this PR — it was already track.adsb_hex or "PR####" before), but the altitude gate is new in this diff and inherits the same weak signal for a use case the PR explicitly cares about.

Fix this →


2. A parallel code path still fabricates the exact same identity/altitude, untouched by this PR (backend/pipeline/passive_radar.py:745-769, generate_aircraft_json)

"flight": f"PR{abs(hash(track.track_id)) % 10000:04d} ",
"alt_baro": round(track.alt_ft),
"alt_geom": round(track.alt_ft),

This is the tar1090-dict builder used by the admin-only /api/radar/load-file batch-replay endpoint (routes/radar.py:222), a sibling to track_gates.track_entry. It still emits the synthetic PR#### callsign and unconditional solver altitude for every track, unconditionally — the identical fabrication pattern this PR removes from the live feed path. test_generate_aircraft_json (tests/test_pipeline.py:281) doesn't exercise flight/altitude, so this wasn't caught by the suite. Possibly out of scope (it's a batch/debug path, not the live map), but since it returns aircraft_data["aircraft"] directly in the HTTP response and can also feed the flushed aircraft.json, it seems worth the same treatment for consistency, or at least a note that it's deliberately excluded.


Other observations (no action needed)

  • The frontend change (LiveAircraftMap.tsx:2033-2034) correctly targets only arc-less, unassociated solver_single_node entries — position_source only lands there when has_adsb is false and no arc was built, so ADS-B-backed and arc-bearing tracks are unaffected. Verified against position_source derivation in track_gates.py:372420 and the Solver·1N list-row fallback in AircraftListPanel.tsx:212-213, which already handles an empty flight by falling back to hex.
  • New test coverage (TestUnassociatedEntryHonesty) correctly pins the no-identity case and the ADS-B-backed case, but doesn't cover the stale-identity scenario in finding 1.
  • Note: I could not run the backend/frontend test suites or pre-commit gate in this sandbox (no fetch/build tooling access beyond static review), so the PR's own stated "2497 passed, 2 skipped" / vitest results are taken at face value rather than independently re-verified.
    · Branch: fix/single-node-ghost-honesty

@jehanazad
jehanazad merged commit afb2e61 into main Aug 26, 2026
13 of 14 checks passed
@jehanazad
jehanazad deleted the fix/single-node-ghost-honesty branch August 26, 2026 21:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant