From 4afb1bfa723a4673ace09717491efd64b4e27519 Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Tue, 8 Sep 2026 06:52:24 +0000 Subject: [PATCH 1/3] Simulation config: frac_adsb_outage knob + retina-simulation bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump retina-simulation for the ADS-B outage knob and plumb it through the simulation config API. frac_adsb_outage is the fraction OF the ADS-B aircraft the simulator takes transponder-silent mid-flight, so it is deliberately outside the frac_anomalous + frac_drone + frac_dark <= 1.0 constraint — folding it in would make a dark-heavy scene unable to test outages at all. The ground-truth push now stores adsb_silent in ground_truth_meta (has_adsb stays true, so the dark count is unaffected) and GET /api/simulation/config reports counts["adsb_silent"] beside the type buckets. The snapshot restore whitelist keeps the new key so an operator's setting survives a rebuild. Co-Authored-By: Claude Fable 5.1 --- backend/core/state.py | 8 ++++ backend/routes/sim_ingest.py | 5 +++ backend/routes/test.py | 20 ++++++++- backend/services/state_snapshot.py | 1 + backend/tests/test_sim_ingest.py | 69 +++++++++++++++++++++++++++++- libs/retina-simulation | 2 +- 6 files changed, 102 insertions(+), 3 deletions(-) diff --git a/backend/core/state.py b/backend/core/state.py index bce84437..99a34f98 100644 --- a/backend/core/state.py +++ b/backend/core/state.py @@ -1250,6 +1250,14 @@ def _seed_sim_fracs_from_env() -> dict: **_seed_sim_fracs_from_env(), # aircraft (commercial) fraction = 1 - sum of above # + # Transponder outages: the fraction of ADS-B-equipped aircraft the + # simulator takes silent mid-flight (has_adsb stays true, the broadcast + # stops). Deliberately NOT part of the frac_* sum above and NOT env-seeded + # with them: it is a fraction OF the ADS-B population, orthogonal to the + # spawn-type roll, so folding it into that sum would make a scene with lots + # of dark traffic silently unable to test outages. 0.0 = off. + "frac_adsb_outage": 0.0, + # # Deliberately NO defaults for max_range_km / min_aircraft / max_aircraft: # the fleet orchestrator applies those keys only when present, falling back # to its own deployment env (FLEET_MIN_AIRCRAFT etc.). Defaults here are a diff --git a/backend/routes/sim_ingest.py b/backend/routes/sim_ingest.py index 5aa9e6be..6759a26c 100644 --- a/backend/routes/sim_ingest.py +++ b/backend/routes/sim_ingest.py @@ -83,6 +83,11 @@ async def push_ground_truth_snapshot(body: dict = Body(...), _key=Depends(_verif "speed_ms": ac.get("speed_ms", 0), "heading": ac.get("heading", 0), "has_adsb": ac.get("has_adsb", False), + # Transponder present but silent right now (simulator outage). + # has_adsb stays true, so the dark count is unaffected — this is + # what tells the known-track hold apart from a genuinely dark + # aircraft. + "adsb_silent": ac.get("adsb_silent", False), "adsb_callsign": ac.get("adsb_callsign"), "anomaly_event": ac.get("anomaly_event"), } diff --git a/backend/routes/test.py b/backend/routes/test.py index 174481df..8d5668cc 100644 --- a/backend/routes/test.py +++ b/backend/routes/test.py @@ -473,9 +473,22 @@ async def get_anomaly_log(): @router.get("/api/simulation/config") async def get_simulation_config(): """Return current simulation physics configuration plus live object-type counts.""" - counts: dict[str, int] = {"anomalous": 0, "drone": 0, "aircraft": 0, "dark": 0, "total": 0} + counts: dict[str, int] = { + "anomalous": 0, + "drone": 0, + "aircraft": 0, + "dark": 0, + # Transponder-equipped aircraft currently inside an outage. Counted + # alongside (not instead of) its type bucket: a silent aircraft is + # still a commercial aircraft, it just is not broadcasting, so this + # is the one count that overlaps the others. + "adsb_silent": 0, + "total": 0, + } for meta in list(state.ground_truth_meta.values()): counts["total"] += 1 + if meta.get("adsb_silent"): + counts["adsb_silent"] += 1 if meta.get("is_anomalous"): counts["anomalous"] += 1 elif meta.get("object_type") == "drone": @@ -498,6 +511,9 @@ async def put_simulation_config(body: dict = Body(...), _admin=Depends(require_a Accepted keys: frac_anomalous, frac_drone, frac_dark (0.0–1.0 each). Sum of the three must not exceed 1.0 — the remainder is commercial aircraft. + frac_adsb_outage (0.0–1.0) is deliberately OUTSIDE that sum: it is the + fraction OF the ADS-B aircraft that go transponder-silent mid-flight, + orthogonal to the spawn-type roll. Optional: max_range_km (0 = auto, or 10–400), min_aircraft (1–500), max_aircraft (1–500). @@ -511,6 +527,7 @@ async def put_simulation_config(body: dict = Body(...), _admin=Depends(require_a "frac_anomalous", "frac_drone", "frac_dark", + "frac_adsb_outage", "max_range_km", "min_aircraft", "max_aircraft", @@ -543,6 +560,7 @@ async def put_simulation_config(body: dict = Body(...), _admin=Depends(require_a raise HTTPException(400, detail=f"{k} must be 0.0–1.0") updated[k] = v + # frac_adsb_outage is intentionally absent here — see the docstring. total_frac = ( updated.get("frac_anomalous", state.simulation_config["frac_anomalous"]) + updated.get("frac_drone", state.simulation_config["frac_drone"]) diff --git a/backend/services/state_snapshot.py b/backend/services/state_snapshot.py index 14747127..ac0d515b 100644 --- a/backend/services/state_snapshot.py +++ b/backend/services/state_snapshot.py @@ -106,6 +106,7 @@ def save_snapshot() -> None: "frac_anomalous", "frac_drone", "frac_dark", + "frac_adsb_outage", "min_aircraft", "max_aircraft", "max_range_km", diff --git a/backend/tests/test_sim_ingest.py b/backend/tests/test_sim_ingest.py index 2567b9e3..d4934d92 100644 --- a/backend/tests/test_sim_ingest.py +++ b/backend/tests/test_sim_ingest.py @@ -80,6 +80,25 @@ def test_old_payload_without_new_keys_defaults(self, client): assert meta["adsb_callsign"] is None assert meta["anomaly_event"] is None + def test_stores_adsb_silent_flag(self, client): + # Transponder outage: has_adsb stays true (the aircraft HAS one), the + # silent flag is what the known-track hold is verified against. + r = client.post( + "/api/test/ground-truth/push", + headers=_KEY, + json={"aircraft": [_ac(adsb_silent=True)]}, + ) + assert r.status_code == 200 + meta = state.ground_truth_meta["a1b2c3"] + assert meta["adsb_silent"] is True + assert meta["has_adsb"] is True + + def test_adsb_silent_defaults_false_for_older_fleets(self, client): + legacy = {k: v for k, v in _ac().items() if k != "adsb_silent"} + r = client.post("/api/test/ground-truth/push", headers=_KEY, json={"aircraft": [legacy]}) + assert r.status_code == 200 + assert state.ground_truth_meta["a1b2c3"]["adsb_silent"] is False + def test_anomalous_push_flags_hex_and_logs_event(self, client): r = client.post( "/api/test/ground-truth/push", @@ -170,7 +189,55 @@ def test_counts_split_out_dark_aircraft(self, client): } ) counts = client.get("/api/simulation/config").json()["ground_truth_counts"] - assert counts == {"anomalous": 1, "drone": 1, "aircraft": 1, "dark": 1, "total": 4} + assert counts == { + "anomalous": 1, + "drone": 1, + "aircraft": 1, + "dark": 1, + "adsb_silent": 0, + "total": 4, + } + + def test_adsb_outage_default_is_off(self, client): + # Default 0.0 so nothing changes for a deployment that never sets it. + assert client.get("/api/simulation/config").json()["frac_adsb_outage"] == 0.0 + + def test_adsb_outage_accepted_and_echoed(self, client): + r = client.put("/api/simulation/config", json={"frac_adsb_outage": 0.3}) + assert r.status_code == 200 + assert r.json()["config"]["frac_adsb_outage"] == 0.3 + assert client.get("/api/simulation/config").json()["frac_adsb_outage"] == 0.3 + + def test_adsb_outage_out_of_range_rejected(self, client): + assert client.put("/api/simulation/config", json={"frac_adsb_outage": 1.5}).status_code == 400 + assert client.put("/api/simulation/config", json={"frac_adsb_outage": -0.1}).status_code == 400 + + def test_adsb_outage_is_outside_the_frac_sum_constraint(self, client): + # It is a fraction OF the ADS-B aircraft, orthogonal to the spawn-type + # roll: a scene at the 1.0 type-sum ceiling must still be able to take + # its transponder aircraft silent. + r = client.put( + "/api/simulation/config", + json={"frac_anomalous": 0.1, "frac_drone": 0.1, "frac_dark": 0.8, "frac_adsb_outage": 1.0}, + ) + assert r.status_code == 200 + assert r.json()["config"]["frac_adsb_outage"] == 1.0 + + def test_counts_report_silent_transponders_alongside_their_type(self, client): + # A silent aircraft is still a commercial aircraft (has_adsb stays + # true) — it must NOT be counted as dark, or the outage knob would be + # indistinguishable from frac_dark. + state.ground_truth_meta.update( + { + "aaa111": {"object_type": "aircraft", "has_adsb": True, "adsb_silent": True}, + "bbb222": {"object_type": "aircraft", "has_adsb": True, "adsb_silent": False}, + "obj-00001": {"object_type": "aircraft", "has_adsb": False}, + } + ) + counts = client.get("/api/simulation/config").json()["ground_truth_counts"] + assert counts["adsb_silent"] == 1 + assert counts["aircraft"] == 2 + assert counts["dark"] == 1 def test_scene_keys_absent_by_default(self, client): # Only-if-set pattern (state.py): a fresh backend never ships diff --git a/libs/retina-simulation b/libs/retina-simulation index 754cca11..f03cae0a 160000 --- a/libs/retina-simulation +++ b/libs/retina-simulation @@ -1 +1 @@ -Subproject commit 754cca114367ce4cc5479d56fbd15f155fb5654e +Subproject commit f03cae0a3fad80c6868125dde1c1ca5525508730 From 03bfc8290e97d0f8d633ba35b2a9768e6c5b06f6 Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Tue, 8 Sep 2026 07:06:32 +0000 Subject: [PATCH 2/3] Expose adsb_silent on the v1 ground-truth endpoint The capture tooling that measures the known-track hold reads /api/v1/ground-truth/aircraft, so the silent flag has to be visible there as well as on the simulation config counts. Co-Authored-By: Claude Fable 5.1 --- backend/routes/output.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/routes/output.py b/backend/routes/output.py index e9acb745..f8639ef5 100644 --- a/backend/routes/output.py +++ b/backend/routes/output.py @@ -137,6 +137,10 @@ async def ground_truth_aircraft(): "has_adsb": meta.get("has_adsb", False), "adsb_callsign": meta.get("adsb_callsign"), "anomaly_event": meta.get("anomaly_event"), + # A transponder that is silent right now (simulator outage) — the + # aircraft still has ADS-B (has_adsb stays true), it is just not + # broadcasting, which is what the known-track hold is measured on. + "adsb_silent": meta.get("adsb_silent", False), "trail": list(trail)[-30:], } ) From 67f17004578b52c31f49751ff1ad616588b8aa8a Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Tue, 8 Sep 2026 07:51:03 +0000 Subject: [PATCH 3/3] Pin retina-simulation to main (transponder outages merged) Co-Authored-By: Claude Fable 5.1 --- libs/retina-simulation | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/retina-simulation b/libs/retina-simulation index f03cae0a..f06d5347 160000 --- a/libs/retina-simulation +++ b/libs/retina-simulation @@ -1 +1 @@ -Subproject commit f03cae0a3fad80c6868125dde1c1ca5525508730 +Subproject commit f06d534709c45dac18d1dd24df99e27ec95138a1