From f03cae0a3fad80c6868125dde1c1ca5525508730 Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Tue, 8 Sep 2026 06:51:23 +0000 Subject: [PATCH] Add transponder outages (frac_adsb_outage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The simulator never stopped ADS-B: every has_adsb aircraft was tagged in every node frame and pushed every second, so the backend's known-track hold (a node track claimed to a hex staying claimed after ADS-B stops) could not be exercised live. SimulatedAircraft now carries an outage window (adsb_outage_start_s / adsb_outage_end_s, world-clock seconds) plus an adsb_silent property. has_adsb and adsb_hex are untouched — the aircraft still HAS a transponder, it is silent. While silent the node frame gets a None ADS-B slot (exactly what a dark aircraft gets), the ADS-B push skips the aircraft entirely, and the summary / ground-truth payload carry adsb_silent so the server can tell a silent transponder apart from a genuinely dark target. SimulationWorld.frac_adsb_outage defaults to 0.0 (off), rolls at spawn, and is applied by the config poll alongside frac_dark. Raising the knob at runtime also rolls aircraft already in the air (schedule_adsb_outages), so verification does not have to wait for a fleet turnover. Co-Authored-By: Claude Fable 5.1 --- retina_simulation/orchestrator.py | 28 +++- retina_simulation/world.py | 81 +++++++++++- tests/test_adsb_outage.py | 205 ++++++++++++++++++++++++++++++ tests/test_dual_fraction.py | 4 + 4 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 tests/test_adsb_outage.py diff --git a/retina_simulation/orchestrator.py b/retina_simulation/orchestrator.py index d62b9c1..5af3af3 100644 --- a/retina_simulation/orchestrator.py +++ b/retina_simulation/orchestrator.py @@ -690,6 +690,10 @@ def build_ground_truth_payload(aircraft_summaries: list[dict]) -> list[dict]: "object_type": ac.get("object_type", "aircraft"), "is_anomalous": ac.get("is_anomalous", False), "has_adsb": ac.get("has_adsb", False), + # Transponder present but currently silent. has_adsb stays + # True — the truth is "this aircraft has a transponder and it + # is off right now", which is what verifies a known-track hold. + "adsb_silent": ac.get("adsb_silent", False), "adsb_callsign": ac.get("adsb_callsign") or None, "anomaly_event": ac.get("anomaly_event") or None, } @@ -717,19 +721,26 @@ def build_real_adsb_body(payload: list[dict]) -> dict: def build_adsb_push_payload(aircraft_summaries: list[dict]) -> list[dict]: """Remap world aircraft summaries to the server ADS-B push schema. - Transponder-equipped aircraft only. This push IS the simulated ADS-B + Transponder-equipped aircraft that are actually broadcasting only. This push IS the simulated ADS-B broadcast, and a dark target by definition emits none — pushing it with its object id standing in for the hex minted a fake transponder per dark aircraft on the server, so every dark solve keyed mn-adsb-* and the dark lane stayed permanently empty. Dark aircraft still reach the server through the ground-truth push, where the object id is the intended key (build_ground_truth_payload above). + + An aircraft inside a transponder outage is skipped for the same reason: + this push IS the broadcast, so a silent aircraft must stop appearing in + it — otherwise the outage is invisible to the server and the known-track + hold it exists to exercise is never entered. """ payload_aircraft = [] for ac in aircraft_summaries: hex_code = ac.get("adsb_hex") or "" if not hex_code: continue + if ac.get("adsb_silent"): + continue speed_ms = ac.get("speed_ms", 0) payload_aircraft.append( { @@ -928,16 +939,29 @@ def _fetch(): orchestrator.world.frac_anomalous = float(cfg.get("frac_anomalous", 0.0)) orchestrator.world.frac_drone = float(cfg.get("frac_drone", 0.0)) orchestrator.world.frac_dark = float(cfg.get("frac_dark", 0.15)) + # Default 0.0 (matching SimulationWorld) so a payload missing + # the key cannot switch outages on. Raising it also re-rolls + # aircraft already in the air — see schedule_adsb_outages. + orchestrator.world.frac_adsb_outage = float(cfg.get("frac_adsb_outage", 0.0)) + if orchestrator.world.frac_adsb_outage > 0.0: + n_sched = orchestrator.world.schedule_adsb_outages() + if n_sched: + log.info( + "ADS-B outages scheduled for %d in-flight aircraft (frac %.2f)", + n_sched, + orchestrator.world.frac_adsb_outage, + ) if "min_aircraft" in cfg: orchestrator.world.min_aircraft = int(cfg["min_aircraft"]) if "max_aircraft" in cfg: orchestrator.world.max_aircraft = int(cfg["max_aircraft"]) last_updated_at = updated_at log.info( - "Simulation config updated: anomalous=%.2f drone=%.2f dark=%.2f aircraft=%d–%d", + "Simulation config updated: anomalous=%.2f drone=%.2f dark=%.2f adsb_outage=%.2f aircraft=%d–%d", orchestrator.world.frac_anomalous, orchestrator.world.frac_drone, orchestrator.world.frac_dark, + orchestrator.world.frac_adsb_outage, orchestrator.world.min_aircraft, orchestrator.world.max_aircraft, ) diff --git a/retina_simulation/world.py b/retina_simulation/world.py index 5366e55..1bab0e3 100644 --- a/retina_simulation/world.py +++ b/retina_simulation/world.py @@ -108,6 +108,18 @@ def waypoints_for_metro(metro: str | None) -> list[tuple[float, float]]: return _REGIONAL_WAYPOINTS.get(metro.strip().lower(), _US_WAYPOINTS) +# Transponder-outage timing. An outage has to outlast the backend's 45 s +# fix-age cap and the 60 s map expiry to be observable as "ADS-B went away" +# rather than as a dropped update, and has to be short enough that a 20-minute +# capture sees several start and end — hence a minute to four minutes, opening +# 20 s to 2 min after spawn (long enough that the track is established first). +_ADSB_OUTAGE_START_S = (20.0, 120.0) +_ADSB_OUTAGE_DURATION_S = (60.0, 240.0) +# Re-roll of already-flying aircraft when the knob is raised at runtime opens +# sooner: verification should not have to wait for a whole fleet turnover. +_ADSB_OUTAGE_RUNTIME_START_S = (5.0, 60.0) + + @dataclass class SimulatedAircraft: """A simulated aircraft in the world with lat/lon/alt position.""" @@ -151,6 +163,22 @@ class SimulatedAircraft: anomaly_fired: bool = False # True once the event has been applied _pre_spoof_lat: float = 0.0 # real position before GPS spoof _pre_spoof_lon: float = 0.0 + # Transponder outage (scheduled window in world-clock seconds). The + # aircraft still HAS a transponder — has_adsb and adsb_hex are untouched — + # it just stops broadcasting for the window, which is what a real failed + # or switched-off transponder looks like to every downstream consumer. + adsb_outage_start_s: float | None = None + adsb_outage_end_s: float | None = None + # World clock as of the last step(), so adsb_silent can be a plain + # property: the aircraft has no back-reference to its world. + sim_now_s: float = 0.0 + + @property + def adsb_silent(self) -> bool: + """True while the world clock sits inside this aircraft's outage.""" + if self.adsb_outage_start_s is None or self.adsb_outage_end_s is None: + return False + return self.adsb_outage_start_s <= self.sim_now_s < self.adsb_outage_end_s @dataclass @@ -367,6 +395,11 @@ def __init__( # stay matched to this value. self.frac_drone: float = 0.0 self.frac_dark: float = 0.15 + # Fraction of ADS-B-equipped aircraft that suffer a transponder + # outage mid-flight. Orthogonal to the frac_* type roll above (it is + # a fraction OF the ADS-B population, not of all spawns), and OFF by + # default so nothing changes for anyone who does not set it. + self.frac_adsb_outage: float = 0.0 # remaining fraction = commercial aircraft with ADS-B # Hub-radial flight planning: when metro_cells is non-empty, this # fraction of spawns is routed through a metro cell; the rest are @@ -586,6 +619,8 @@ def _spawn_aircraft(self, mode: str = "detection") -> SimulatedAircraft: letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" adsb_callsign = f"{''.join(random.choices(letters, k=3))}{random.randint(100, 9999)}" + outage_start, outage_end = self._roll_adsb_outage(has_adsb, _ADSB_OUTAGE_START_S) + # Initial heading toward next waypoint next_wp = route[1] if len(route) > 1 else route[0] heading = _bearing_deg(lat, lon, next_wp[0], next_wp[1]) @@ -611,6 +646,9 @@ def _spawn_aircraft(self, mode: str = "detection") -> SimulatedAircraft: object_type=object_type, adsb_hex=adsb_hex, adsb_callsign=adsb_callsign, + adsb_outage_start_s=outage_start, + adsb_outage_end_s=outage_end, + sim_now_s=self._time, created_at=self._time, lifetime_s=random.uniform(180, 900) if object_type != "drone" else random.uniform(60, 300), waypoints=route, @@ -618,6 +656,41 @@ def _spawn_aircraft(self, mode: str = "detection") -> SimulatedAircraft: **self._maybe_schedule_anomaly(is_anomalous, object_type), ) + def _roll_adsb_outage(self, has_adsb: bool, start_range: tuple[float, float]) -> tuple[float | None, float | None]: + """Roll a transponder-outage window, or (None, None) for no outage. + + Dark aircraft are never candidates: they have no transponder to lose. + """ + if not has_adsb or self.frac_adsb_outage <= 0.0: + return None, None + if random.random() >= self.frac_adsb_outage: + return None, None + start = self._time + random.uniform(*start_range) + return start, start + random.uniform(*_ADSB_OUTAGE_DURATION_S) + + def schedule_adsb_outages(self) -> int: + """Roll outages for live ADS-B aircraft that have none scheduled yet. + + Called when frac_adsb_outage is raised at runtime: without it the knob + only reaches aircraft spawned after the change, so verification would + have to wait for a full fleet turnover. Aircraft that already carry a + window (including one that has already ended) are left alone, so + repeated config polls cannot re-roll the same aircraft every 5 s. + Returns the number newly scheduled. + """ + scheduled = 0 + for ac in self.aircraft: + if ac.adsb_outage_start_s is not None: + continue + start, end = self._roll_adsb_outage(ac.has_adsb, _ADSB_OUTAGE_RUNTIME_START_S) + if start is None: + continue + ac.adsb_outage_start_s = start + ac.adsb_outage_end_s = end + ac.sim_now_s = self._time + scheduled += 1 + return scheduled + # Expired aircraft are retired only once they are at least this far from # the world center — a target vanishing overhead reads as a tracking bug # on the map, so retirement happens off at the edges, beyond the ~60 km @@ -691,6 +764,7 @@ def step(self, dt: float, mode: str = "detection"): # Update each aircraft for ac in self.aircraft: + ac.sim_now_s = self._time self._update_aircraft(ac, dt) self._enforce_separation(dt) @@ -1000,8 +1074,10 @@ def generate_detections_for_node(self, node_id: str, timestamp_ms: int) -> dict: dopplers.append(round(doppler, 2)) snrs.append(round(snr, 2)) - # ADS-B entry - if ac.has_adsb: + # ADS-B entry. A silent transponder gets exactly what a dark + # aircraft gets — a None slot — so the outage is indistinguishable + # downstream from "this echo carries no ADS-B tag". + if ac.has_adsb and not ac.adsb_silent: has_any_adsb = True speed_ms = ac.speed_km_s * 1000 # GPS spoof: ADS-B reports frozen pre-spoof position while @@ -1069,6 +1145,7 @@ def get_aircraft_summary(self) -> list[dict]: "object_type": ac.object_type, "adsb_hex": ac.adsb_hex, "adsb_callsign": ac.adsb_callsign, + "adsb_silent": ac.adsb_silent, "anomaly_event": ac.anomaly_event, } for ac in self.aircraft diff --git a/tests/test_adsb_outage.py b/tests/test_adsb_outage.py new file mode 100644 index 0000000..0f8cf31 --- /dev/null +++ b/tests/test_adsb_outage.py @@ -0,0 +1,205 @@ +"""Transponder outages (frac_adsb_outage). + +An ADS-B aircraft that goes silent mid-flight is the only way to exercise the +backend's known-track hold: the node track stays claimed to a hex after the +broadcast stops. Before this knob the simulator tagged every has_adsb +aircraft in every frame and pushed it every second, so the hold could never be +entered. Default is 0.0 — nothing changes for anyone who does not set it. +""" + +from retina_simulation.orchestrator import build_adsb_push_payload, build_ground_truth_payload +from retina_simulation.world import ( + _ADSB_OUTAGE_DURATION_S, + _ADSB_OUTAGE_START_S, + NodeConfig, + SimulatedAircraft, + SimulationWorld, +) + + +def _world(frac_outage: float) -> SimulationWorld: + w = SimulationWorld(center_lat=33.9, center_lon=-84.6) + w.frac_anomalous = 0.0 + w.frac_drone = 0.0 + w.frac_dark = 0.0 # every spawn is a transponder aircraft + w.frac_adsb_outage = frac_outage + return w + + +def _summary(**overrides) -> dict: + base = { + "id": "obj-0001", + "lat": 34.85, + "lon": -82.4, + "alt_km": 9.5, + "heading": 270.0, + "speed_ms": 230.0, + "has_adsb": True, + "is_anomalous": False, + "object_type": "aircraft", + "adsb_hex": "a1b2c3", + "adsb_callsign": "ABC1234", + "adsb_silent": False, + "anomaly_event": None, + } + base.update(overrides) + return base + + +class TestOutageScheduling: + def test_knob_off_schedules_nothing(self): + w = _world(0.0) + for _ in range(30): + ac = w._spawn_aircraft(mode="adsb") + assert ac.adsb_outage_start_s is None + assert ac.adsb_outage_end_s is None + assert ac.adsb_silent is False + + def test_knob_at_one_schedules_every_adsb_aircraft(self): + w = _world(1.0) + for _ in range(30): + ac = w._spawn_aircraft(mode="adsb") + assert ac.has_adsb + assert ac.adsb_outage_start_s is not None + offset = ac.adsb_outage_start_s - ac.created_at + assert _ADSB_OUTAGE_START_S[0] <= offset <= _ADSB_OUTAGE_START_S[1] + duration = ac.adsb_outage_end_s - ac.adsb_outage_start_s + assert _ADSB_OUTAGE_DURATION_S[0] <= duration <= _ADSB_OUTAGE_DURATION_S[1] + + def test_dark_aircraft_never_get_an_outage(self): + # No transponder means nothing to lose; a dark aircraft with an outage + # window would be a silent no-op that muddies the counts. + w = _world(1.0) + w.frac_dark = 1.0 + for _ in range(20): + ac = w._spawn_aircraft(mode="adsb") + assert ac.has_adsb is False + assert ac.adsb_outage_start_s is None + + def test_silence_window_is_closed_at_the_end(self): + ac = SimulatedAircraft( + object_id="obj-1", + lat=0.0, + lon=0.0, + alt_km=9.0, + vel_east=0.0, + vel_north=0.0, + vel_up=0.0, + heading_deg=0.0, + speed_km_s=0.2, + has_adsb=True, + adsb_hex="a1b2c3", + adsb_outage_start_s=100.0, + adsb_outage_end_s=200.0, + ) + ac.sim_now_s = 99.0 + assert ac.adsb_silent is False + ac.sim_now_s = 100.0 + assert ac.adsb_silent is True + ac.sim_now_s = 199.9 + assert ac.adsb_silent is True + ac.sim_now_s = 200.0 + assert ac.adsb_silent is False + + +class TestRuntimeReRoll: + def test_raising_the_knob_rolls_aircraft_already_in_the_air(self): + w = _world(0.0) + for _ in range(20): + w.aircraft.append(w._spawn_aircraft(mode="adsb")) + assert all(ac.adsb_outage_start_s is None for ac in w.aircraft) + + w.frac_adsb_outage = 1.0 + n = w.schedule_adsb_outages() + assert n == 20 + assert all(ac.adsb_outage_start_s is not None for ac in w.aircraft) + + def test_re_roll_leaves_already_scheduled_aircraft_alone(self): + # The config poll runs every 5 s; re-rolling the same aircraft on each + # poll would keep pushing its outage into the future forever. + w = _world(1.0) + for _ in range(10): + w.aircraft.append(w._spawn_aircraft(mode="adsb")) + before = [(ac.adsb_outage_start_s, ac.adsb_outage_end_s) for ac in w.aircraft] + assert w.schedule_adsb_outages() == 0 + after = [(ac.adsb_outage_start_s, ac.adsb_outage_end_s) for ac in w.aircraft] + assert before == after + + def test_re_roll_is_a_no_op_with_the_knob_off(self): + w = _world(0.0) + for _ in range(10): + w.aircraft.append(w._spawn_aircraft(mode="adsb")) + assert w.schedule_adsb_outages() == 0 + + +class TestFrameTagging: + def _frame_hexes(self, w: SimulationWorld) -> list: + frame = w.generate_detections_for_node("n1", timestamp_ms=0) + return frame.get("adsb", []) + + def test_tag_present_before_and_after_but_none_during(self): + w = _world(0.0) + w.add_node(NodeConfig(node_id="n1", max_range_km=400.0, beam_width_deg=360.0)) + ac = w._spawn_aircraft(mode="adsb") + # Park the aircraft on top of the node so the cone/miss roll cannot + # drop it; retry a few frames to ride out the SNR-dependent miss. + ac.lat, ac.lon, ac.alt_km = 33.94, -84.65, 9.0 + ac.adsb_outage_start_s = 100.0 + ac.adsb_outage_end_s = 200.0 + w.aircraft = [ac] + + def tags_for(now: float) -> list: + ac.sim_now_s = now + seen = [] + for _ in range(40): + seen.extend(self._frame_hexes(w)) + return [t for t in seen if t is not None] + + assert tags_for(50.0), "transponder must be tagged before the outage" + assert tags_for(150.0) == [], "transponder must be untagged during the outage" + assert tags_for(250.0), "transponder must be tagged again after the outage" + + def test_silent_aircraft_still_produces_radar_detections(self): + # The echo is unchanged — only the ADS-B tag goes away. A silent + # aircraft that also vanished from delay/doppler would be a dark + # aircraft, not a transponder outage. + w = _world(0.0) + w.add_node(NodeConfig(node_id="n1", max_range_km=400.0, beam_width_deg=360.0)) + ac = w._spawn_aircraft(mode="adsb") + ac.lat, ac.lon, ac.alt_km = 33.94, -84.65, 9.0 + ac.adsb_outage_start_s, ac.adsb_outage_end_s = 0.0, 200.0 + ac.sim_now_s = 100.0 + w.aircraft = [ac] + assert any(w.generate_detections_for_node("n1", 0)["delay"] for _ in range(40)) + + +class TestSummaryAndPayloads: + def test_summary_carries_the_flag(self): + w = _world(0.0) + ac = w._spawn_aircraft(mode="adsb") + ac.adsb_outage_start_s, ac.adsb_outage_end_s = 10.0, 20.0 + w.aircraft = [ac] + + ac.sim_now_s = 5.0 + assert w.get_aircraft_summary()[0]["adsb_silent"] is False + ac.sim_now_s = 15.0 + assert w.get_aircraft_summary()[0]["adsb_silent"] is True + + def test_ground_truth_payload_passes_the_flag_through(self): + out = build_ground_truth_payload([_summary(adsb_silent=True)]) + assert out[0]["adsb_silent"] is True + # has_adsb stays True: the aircraft HAS a transponder, it is off. + assert out[0]["has_adsb"] is True + + def test_ground_truth_flag_defaults_false_for_older_summaries(self): + summary = _summary() + del summary["adsb_silent"] + assert build_ground_truth_payload([summary])[0]["adsb_silent"] is False + + def test_push_payload_skips_silent_aircraft(self): + out = build_adsb_push_payload([_summary(adsb_silent=True)]) + assert out == [] + + def test_push_payload_keeps_broadcasting_aircraft(self): + out = build_adsb_push_payload([_summary(adsb_silent=False)]) + assert [e["hex"] for e in out] == ["a1b2c3"] diff --git a/tests/test_dual_fraction.py b/tests/test_dual_fraction.py index f01c8f6..08cca92 100644 --- a/tests/test_dual_fraction.py +++ b/tests/test_dual_fraction.py @@ -142,9 +142,13 @@ class _StubWorld: frac_anomalous = 0.0 frac_drone = 0.0 frac_dark = 0.0 + frac_adsb_outage = 0.0 min_aircraft = 1 max_aircraft = 1 + def schedule_adsb_outages(self): + return 0 + class _StubOrchestrator: def __init__(self, max_range_km=0.0):