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
8 changes: 8 additions & 0 deletions backend/core/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions backend/routes/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:],
}
)
Expand Down
5 changes: 5 additions & 0 deletions backend/routes/sim_ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}
Expand Down
20 changes: 19 additions & 1 deletion backend/routes/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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).

Expand All @@ -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",
Expand Down Expand Up @@ -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"])
Expand Down
1 change: 1 addition & 0 deletions backend/services/state_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ def save_snapshot() -> None:
"frac_anomalous",
"frac_drone",
"frac_dark",
"frac_adsb_outage",
"min_aircraft",
"max_aircraft",
"max_range_km",
Expand Down
69 changes: 68 additions & 1 deletion backend/tests/test_sim_ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
Loading