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
7 changes: 7 additions & 0 deletions backend/core/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,11 @@ def _adsb_for_seeding() -> dict[str, dict]:
# tracks anomalous — but a rising rate here still means association regressed.
position_jump_events: int = 0

# Sim ADS-B pushes dropped for a non-transponder hex (e.g. a simulator object
# id standing in for a transponder). A nonzero value means an outdated fleet
# is still pushing dark aircraft into the ADS-B path — see routes/sim_ingest.
sim_adsb_push_rejected_hex: int = 0

# Solver end-to-end latency (seconds from queue submission to solve completion)
solver_last_latency_s: float = 0.0
solver_total_latency_s: float = 0.0
Expand Down Expand Up @@ -641,6 +646,7 @@ def _reset_for_tests() -> None:
global solver_fail_exception, solver_fail_unconverged, solver_fail_rms_delay
global solver_fail_rms_doppler, solver_fail_beam, solver_fail_displacement
global position_jump_events
global sim_adsb_push_rejected_hex
global solver_last_latency_s, solver_total_latency_s, solver_total_solved
global peak_connected_nodes

Expand Down Expand Up @@ -725,6 +731,7 @@ def _reset_for_tests() -> None:
solver_fail_exception = solver_fail_unconverged = solver_fail_rms_delay = 0
solver_fail_rms_doppler = solver_fail_beam = solver_fail_displacement = 0
position_jump_events = 0
sim_adsb_push_rejected_hex = 0
solver_total_solved = 0
solver_last_latency_s = solver_total_latency_s = 0.0
peak_connected_nodes = 0
Expand Down
15 changes: 13 additions & 2 deletions backend/routes/sim_ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
# consumers in routes.test.
from routes.test import _verify_sim_key
from services.geo import valid_latlon
from services.id_utils import normalize_hex_key
from services.id_utils import is_transponder_hex, normalize_hex_key

router = APIRouter()

Expand Down Expand Up @@ -122,10 +122,19 @@ async def sim_push_adsb_positions(body: dict = Body(...), _key=Depends(_verify_s
raise HTTPException(status_code=400, detail="aircraft list required")

updated = 0
rejected = 0
for ac in aircraft_list:
hex_code = normalize_hex_key(ac.get("hex") or "")
if not hex_code:
continue
# A dark object has no transponder, so nothing about it belongs in
# state.adsb_aircraft. Older simulators push every aircraft here with
# the object id standing in for the hex; accepting those minted a fake
# transponder per dark target, every dark solve then keyed mn-adsb-*
# and the dark lane was permanently empty.
if not is_transponder_hex(hex_code):
rejected += 1
continue
lat = ac.get("lat")
lon = ac.get("lon")
if not valid_latlon(lat, lon):
Expand All @@ -148,5 +157,7 @@ async def sim_push_adsb_positions(body: dict = Body(...), _key=Depends(_verify_s

if updated:
state.aircraft_dirty = True
if rejected:
state.bump_counter("sim_adsb_push_rejected_hex", rejected)

return {"status": "ok", "updated": updated}
return {"status": "ok", "updated": updated, "rejected_hex": rejected}
17 changes: 17 additions & 0 deletions backend/services/id_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Identifier utilities shared across service modules."""

import hashlib
import re


def multinode_hex_from_key(key: str) -> str:
Expand Down Expand Up @@ -28,3 +29,19 @@ def normalize_hex_key(hex_code) -> str:
uppercase key is a silent lookup miss, not an error.
"""
return str(hex_code or "").strip().lower()


_TRANSPONDER_HEX_RE = re.compile(r"~?[0-9a-f]{6}")


def is_transponder_hex(hex_code) -> bool:
"""True if ``hex_code`` (already normalized) is a plausible transponder id.

Six hex digits, with tar1090's ``~`` prefix allowed for non-ICAO TIS-B
addresses. Anything else — notably a simulator object id like
``obj-01373`` — must never enter the ADS-B world: a non-transponder id in
``state.adsb_aircraft`` (or in a solve's ``adsb_hex``) puts a dark target
in the ADS-B lane, which starves the mn-dark-* track store and with it the
entire dark-claiming path.
"""
return bool(_TRANSPONDER_HEX_RE.fullmatch(str(hex_code or "")))
9 changes: 7 additions & 2 deletions backend/services/tasks/solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
# were consolidated into services.geo.
from services.geo import bearing_deg, bistatic_differential_km, node_beam_params, offset_latlon_m
from services.geo import haversine_km as _haversine_km
from services.id_utils import multinode_hex_from_key, normalize_hex_key
from services.id_utils import is_transponder_hex, multinode_hex_from_key, normalize_hex_key

_N_SOLVER_WORKERS = int(os.getenv("SOLVER_WORKERS", "2"))

Expand Down Expand Up @@ -650,7 +650,12 @@ def multinode_key_decision(
associates to it above (by proximity, or by anchor once a claim
forms), so it stays stable.
"""
if adsb_hex:
# Transponder-shaped ids only. adsb_hex is whatever upstream association
# produced, and a non-transponder id here (a simulator object id, a claim
# against a poisoned adsb_aircraft entry) would put a dark target in the
# ADS-B lane — adsb_assisted=true on the feed, and the mn-dark-* store
# (so the anchor and proximity branches below) starved forever.
if adsb_hex and is_transponder_hex(adsb_hex):
return f"mn-adsb-{adsb_hex}", "adsb"

lat, lon = result["lat"], result["lon"]
Expand Down
36 changes: 36 additions & 0 deletions backend/tests/test_id_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""services/id_utils.py — the transponder-hex shape rule.

is_transponder_hex is the single gate that keeps non-transponder ids (above
all the simulator's ``obj-NNNNN`` object ids) out of the ADS-B world: it
guards the sim adsb push (routes/sim_ingest.py) and the mn-adsb-* keying rule
(services/tasks/solver.py:multinode_key_decision). Callers pass values that
already went through normalize_hex_key, so the rule is defined over stripped
lowercase input.
"""

import pytest

from services.id_utils import is_transponder_hex


class TestIsTransponderHex:
@pytest.mark.parametrize("value", ["a1b2c3", "abcdef", "000001", "~a1b2c3"])
def test_transponder_shapes_pass(self, value):
assert is_transponder_hex(value) is True

@pytest.mark.parametrize(
"value",
[
"obj-01373", # the simulator object id that poisoned the lane
"",
None,
"a1b2c", # too short
"a1b2c3d", # too long
"A1B2C3", # not normalized — callers must normalize first
"g1b2c3", # not hex
"~~a1b2c3",
"mn-adsb-a1b2c3",
],
)
def test_everything_else_is_refused(self, value):
assert is_transponder_hex(value) is False
47 changes: 47 additions & 0 deletions backend/tests/test_sim_ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,50 @@ def test_max_range_km_boundary_values_accepted(self, client):
assert r.status_code == 200
r = client.put("/api/simulation/config", json={"max_range_km": 400})
assert r.status_code == 200


class TestAdsbPush:
"""The transponder-hex gate on /api/sim/adsb/push.

Older fleets push every aircraft here with the object id standing in for
the hex (orchestrator._push_adsb_live). Accepting those minted a fake
transponder per dark target: every dark solve then claimed against it,
keyed mn-adsb-obj-*, and the dark lane (mn-dark-* store, violet icons,
the whole claiming path) was permanently empty — measured live 2026-08-26
as 15008/15008 multinode samples adsb_assisted with zero dark tracks.
"""

@pytest.fixture(autouse=True)
def _clean_adsb(self):
state.adsb_aircraft.clear()
yield
state.adsb_aircraft.clear()

def _push(self, client, hex_code):
return client.post(
"/api/sim/adsb/push",
headers=_KEY,
json={
"ts_ms": int(time.time() * 1000),
"aircraft": [{"hex": hex_code, "lat": 34.85, "lon": -82.4, "alt_baro": 31000}],
},
)

def test_icao_hex_accepted(self, client):
r = self._push(client, "A1B2C3")
assert r.status_code == 200
assert r.json() == {"status": "ok", "updated": 1, "rejected_hex": 0}
assert "a1b2c3" in state.adsb_aircraft

def test_tisb_tilde_hex_accepted(self, client):
"""tar1090's non-ICAO TIS-B addresses (~ prefix) are real transponder
traffic and must keep working."""
r = self._push(client, "~a1b2c3")
assert r.json()["updated"] == 1
assert "~a1b2c3" in state.adsb_aircraft

def test_object_id_rejected(self, client):
r = self._push(client, "obj-01373")
assert r.status_code == 200
assert r.json() == {"status": "ok", "updated": 0, "rejected_hex": 1}
assert state.adsb_aircraft == {}
24 changes: 24 additions & 0 deletions backend/tests/test_solver_anchor.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,30 @@ def test_mints_a_new_key_with_no_anchor_and_no_claimant(self):
assert how == "minted"
assert key.startswith("mn-dark-1000-")

def test_non_transponder_adsb_hex_cannot_take_the_adsb_branch(self):
"""A simulator object id (or any non-transponder string) reaching
adsb_hex must fall through to the dark branches — keying mn-adsb-obj-*
put dark targets in the ADS-B lane and starved mn-dark-* entirely
(observed live 2026-08-26)."""
tracks = {"mn-dark-1": _anchor_track()}
key, how = solver_mod.multinode_key_decision(
tracks,
{"lat": LAT, "lon": LON, "timestamp_ms": int(time.time() * 1000)},
"obj-01373",
None,
)
assert how == "proximity"
assert key == "mn-dark-1"

def test_tisb_tilde_adsb_hex_still_takes_the_adsb_branch(self):
key, how = solver_mod.multinode_key_decision(
{},
{"lat": LAT, "lon": LON, "timestamp_ms": 1000},
"~abc123",
None,
)
assert (key, how) == ("mn-adsb-~abc123", "adsb")


class TestProcessSolverItemAnchorHonoring:
def setup_method(self):
Expand Down
4 changes: 4 additions & 0 deletions backend/vulture_whitelist.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@
known_claims_bound
known_claims_visibility_rejects

# Same string-keyed bump_counter shape, from routes/sim_ingest.py's
# transponder-hex gate on /api/sim/adsb/push.
sim_adsb_push_rejected_hex


# ── Framework attributes (previously CI --ignore-names) ───────────────────────
# Moved out of the vulture invocation so the reason lives with the name.
Expand Down
Loading