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
11 changes: 11 additions & 0 deletions backend/core/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -1303,6 +1303,17 @@ def _seed_sim_fracs_from_env() -> dict:
# of dark traffic silently unable to test outages. 0.0 = off.
"frac_adsb_outage": 0.0,
#
# Live ADS-B seeding: the fleet pulls real aircraft over its metro from
# adsb.retina.fm into the simulated world and the synthetic nodes echo
# them (retina_simulation.orchestrator._seed_live_adsb). frac_live_dark
# is the share of THOSE aircraft the simulator mirrors without their
# transponder — outside the frac_* sum for the same reason as the outage
# knob: it is a fraction of the live population, orthogonal to how the
# simulator's own spawns are rolled. live_adsb_enabled pauses the pull
# (and removes the live aircraft) without a fleet restart.
"frac_live_dark": 0.15,
"live_adsb_enabled": True,
#
# 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
3 changes: 3 additions & 0 deletions backend/routes/sim_ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ async def push_ground_truth_snapshot(body: dict = Body(...), _key=Depends(_verif
"adsb_silent": ac.get("adsb_silent", False),
"adsb_callsign": ac.get("adsb_callsign"),
"anomaly_event": ac.get("anomaly_event"),
# "live" for an aircraft the simulator mirrored from the ADS-B
# feed, "sim" for its own spawns (older fleets send neither).
"source": ac.get("source") or "sim",
}
# Flag anomalous objects and log events
if ac.get("is_anomalous"):
Expand Down
25 changes: 23 additions & 2 deletions backend/routes/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,12 +517,22 @@ async def get_simulation_config():
# still a commercial aircraft, it just is not broadcasting, so this
# is the one count that overlaps the others.
"adsb_silent": 0,
# Aircraft mirrored from the live ADS-B feed, split by the cast the
# simulator gave them. Like adsb_silent these overlap the type
# buckets above (a live aircraft is also an "aircraft" or "dark"),
# so the frac_live_dark knob is verifiable in one call.
"live": 0,
"live_adsb": 0,
"live_dark": 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("source") == "live":
counts["live"] += 1
counts["live_adsb" if meta.get("has_adsb") else "live_dark"] += 1
if meta.get("is_anomalous"):
counts["anomalous"] += 1
elif meta.get("object_type") == "drone":
Expand All @@ -548,6 +558,9 @@ async def put_simulation_config(body: dict = Body(...), _admin=Depends(require_a
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.
frac_live_dark (0.0–1.0) is likewise outside it: the share of the
aircraft the simulator mirrors from the live ADS-B feed that it casts
as dark. live_adsb_enabled (bool) pauses that feed.
Optional: max_range_km (0 = auto, or 10–400), min_aircraft (1–500),
max_aircraft (1–500).

Expand All @@ -562,6 +575,8 @@ async def put_simulation_config(body: dict = Body(...), _admin=Depends(require_a
"frac_drone",
"frac_dark",
"frac_adsb_outage",
"frac_live_dark",
"live_adsb_enabled",
"max_range_km",
"min_aircraft",
"max_aircraft",
Expand All @@ -572,7 +587,10 @@ async def put_simulation_config(body: dict = Body(...), _admin=Depends(require_a
for k in allowed:
if k in body:
v = body[k]
if k.startswith("frac_"):
if k == "live_adsb_enabled":
if not isinstance(v, bool):
raise HTTPException(400, detail=f"{k} must be true or false")
elif k.startswith("frac_"):
if not isinstance(v, (int, float)) or not (0.0 <= v <= 1.0):
raise HTTPException(400, detail=f"{k} must be 0.0–1.0")
elif k in ("max_range_km",):
Expand All @@ -594,7 +612,8 @@ 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.
# frac_adsb_outage and frac_live_dark are 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 Expand Up @@ -651,6 +670,8 @@ async def get_simulation_ground_truth():
"ts": round(ts, 3),
"object_type": meta.get("object_type", "aircraft"),
"is_anomalous": meta.get("is_anomalous", False),
"has_adsb": meta.get("has_adsb", False),
"source": meta.get("source", "sim"),
}
)

Expand Down
2 changes: 2 additions & 0 deletions backend/services/state_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ def save_snapshot() -> None:
"frac_drone",
"frac_dark",
"frac_adsb_outage",
"frac_live_dark",
"live_adsb_enabled",
"min_aircraft",
"max_aircraft",
"max_range_km",
Expand Down
55 changes: 55 additions & 0 deletions backend/tests/test_sim_ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,64 @@ def test_counts_split_out_dark_aircraft(self, client):
"aircraft": 1,
"dark": 1,
"adsb_silent": 0,
"live": 0,
"live_adsb": 0,
"live_dark": 0,
"total": 4,
}

def test_counts_split_live_feed_aircraft_by_cast(self, client):
# Live-feed aircraft are ALSO counted in their type bucket (an ADS-B
# one under "aircraft", a dark-cast one under "dark"): the live
# counters overlap the type buckets, like adsb_silent does, so the
# frac_live_dark knob can be read off in one call without the type
# counts losing the live population.
state.ground_truth_meta.update(
{
"ab1388": {"object_type": "aircraft", "has_adsb": True, "source": "live"},
"live-a9c2d1": {"object_type": "aircraft", "has_adsb": False, "source": "live"},
"obj-00001": {"object_type": "aircraft", "has_adsb": False, "source": "sim"},
"aaa111": {"object_type": "aircraft", "has_adsb": True}, # pre-source fleet
}
)
counts = client.get("/api/simulation/config").json()["ground_truth_counts"]
assert counts["live"] == 2
assert counts["live_adsb"] == 1
assert counts["live_dark"] == 1
assert counts["aircraft"] == 2
assert counts["dark"] == 2

def test_live_knobs_default_on_with_dark_share(self, client):
cfg = client.get("/api/simulation/config").json()
assert cfg["live_adsb_enabled"] is True
assert cfg["frac_live_dark"] == 0.15

def test_live_knobs_accepted_and_echoed(self, client):
r = client.put("/api/simulation/config", json={"frac_live_dark": 0.6, "live_adsb_enabled": False})
assert r.status_code == 200
assert r.json()["config"]["frac_live_dark"] == 0.6
assert r.json()["config"]["live_adsb_enabled"] is False
echoed = client.get("/api/simulation/config").json()
assert echoed["frac_live_dark"] == 0.6
assert echoed["live_adsb_enabled"] is False
client.put("/api/simulation/config", json={"frac_live_dark": 0.15, "live_adsb_enabled": True})

def test_live_knobs_validated(self, client):
assert client.put("/api/simulation/config", json={"frac_live_dark": 1.5}).status_code == 400
assert client.put("/api/simulation/config", json={"live_adsb_enabled": 1}).status_code == 400
assert client.put("/api/simulation/config", json={"live_adsb_enabled": "yes"}).status_code == 400

def test_frac_live_dark_is_outside_the_frac_sum_constraint(self, client):
# A fraction OF the live population: a scene at the synthetic type-sum
# ceiling must still be able to cast every live aircraft dark.
r = client.put(
"/api/simulation/config",
json={"frac_anomalous": 0.1, "frac_drone": 0.1, "frac_dark": 0.8, "frac_live_dark": 1.0},
)
assert r.status_code == 200
assert r.json()["config"]["frac_live_dark"] == 1.0
client.put("/api/simulation/config", json={"frac_live_dark": 0.15})

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
Expand Down
12 changes: 12 additions & 0 deletions deploy/fleet-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ VALIDATE="${FLEET_VALIDATE:-false}"
# synthetic echoes to — the ghost planes on the map. Only set this once the
# server tags/gates claiming by world (known_claims_world_rejects counter).
REAL_ADSB="${FLEET_REAL_ADSB:-false}"
# Live ADS-B seeding: real aircraft over the metro are pulled from this feed
# INTO the simulated world and echoed by the synthetic nodes (the backend's
# frac_live_dark casts a share of them as dark; live_adsb_enabled pauses it at
# runtime). Any adsb.lol-shaped /v2/point server works. Set it empty to run a
# purely synthetic fleet — production does, because its real hardware nodes
# and the simulated fleet would otherwise both claim the same real hexes.
LIVE_ADSB_URL="${FLEET_LIVE_ADSB_URL-https://adsb.retina.fm}"
LIVE_ADSB_INTERVAL="${FLEET_LIVE_ADSB_INTERVAL:-5}"
N_CLUSTER="${FLEET_N_CLUSTER:-16}"
N_CLUSTERS="${FLEET_N_CLUSTERS:-1}"
# ring | dual | scatter — see generator.py --layout. The orchestrator reads the
Expand All @@ -50,6 +58,7 @@ echo " Server: ${HOST}:${PORT}"
echo " Interval: ${INTERVAL}s"
echo " Time scale: ${TIME_SCALE}x"
echo " Aircraft: ${MIN_AIRCRAFT}-${MAX_AIRCRAFT}"
echo " Live ADS-B: ${LIVE_ADSB_URL:-off}"
echo " Validate: ${VALIDATE}"
echo "═══════════════════════════════════════════════════"

Expand Down Expand Up @@ -156,6 +165,9 @@ fi
if [ "${REAL_ADSB}" = "true" ]; then
ARGS="${ARGS} --real-adsb"
fi
if [ -n "${LIVE_ADSB_URL}" ]; then
ARGS="${ARGS} --live-adsb-url ${LIVE_ADSB_URL} --live-adsb-interval ${LIVE_ADSB_INTERVAL}"
fi

# Launch fleet orchestrator
echo "Launching fleet orchestrator..."
Expand Down
6 changes: 6 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ services:
- FLEET_MAX_AIRCRAFT=30
- FLEET_BEAM_WIDTH_DEG=0
- FLEET_MAX_RANGE_KM=0
# No live ADS-B seeding here (the entrypoint default is adsb.retina.fm):
# production has real hardware nodes whose echoes claim real hexes in the
# "real" world, and a simulated fleet mirroring the same aircraft would
# push those hexes into the same server cache as simulated-world
# traffic — the two writers would take turns owning each entry.
- FLEET_LIVE_ADSB_URL=
- FLEET_CONCURRENCY=20
# `restart: unless-stopped` recovers the fleet if the app restarts, so we
# no longer need the script's 999-retry hack — but keep a healthy margin
Expand Down
53 changes: 43 additions & 10 deletions docs/simulation.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,11 @@ tab (or `PUT /api/simulation/config`, admin-authed) can change at runtime:
|-----|---------|---------|
| `frac_anomalous` | 0.0 | Fraction of spawns with anomalous behaviour |
| `frac_drone` | 0.0 | Drone fraction (off by default; enable for drone scenarios) |
| `frac_dark` | 0.15 | Non-ADS-B ("dark") fraction |
| `min_aircraft` / `max_aircraft` | *(unset)* | Steady-state aircraft bounds |
| `frac_dark` | 0.15 | Non-ADS-B ("dark") fraction of the synthetic spawns |
| `frac_adsb_outage` | 0.0 | Fraction of ADS-B aircraft that go transponder-silent mid-flight (outside the sum above) |
| `frac_live_dark` | 0.15 | Share of the **live-feed** aircraft cast as dark (outside the sum above) — see below |
| `live_adsb_enabled` | true | Pull live aircraft from the feed into the world (off removes them) |
| `min_aircraft` / `max_aircraft` | *(unset)* | Steady-state bounds for the **synthetic** aircraft |
| `n_nodes` / `dual_fraction` / `max_range_km` | *(unset)* | Fleet-scene keys — applying one **restarts the fleet** for regeneration |

The fractions and aircraft counts apply in-process to *new spawns*. The scene
Expand Down Expand Up @@ -133,14 +136,44 @@ under `backend/data/runtime/`.

---

## Real ADS-B Feed (`AdsbLolClient`)

With `--mode adsb`, a background task polls `api.adsb.lol` for the metro's
bounding box every 10 s and merges results into the world: hexes matching a
simulated aircraft update it in place; new hexes join as real aircraft.
Ground truth (positions + per-object metadata) is pushed to the server
(`POST /api/sim/ground-truth`) for accuracy evaluation and the debug-truth
map layer.
## Live ADS-B seeding (`LiveAdsbClient`)

With `--live-adsb-url` (the fleet entrypoint defaults it to
`https://adsb.retina.fm`; `FLEET_LIVE_ADSB_URL=` turns it off), the
orchestrator polls the feed's `/v2/point/{lat}/{lon}/{radius_nm}` for the
`--metro` area every 5 s and merges the result into the world
(`SimulationWorld.ingest_live_aircraft`). Each real aircraft becomes a
`live-<hex>` world aircraft flying its reported position, altitude, ground
speed, track and vertical rate — extrapolated from the fix's capture time,
dead-reckoned between polls, dropped 60 s after the feed last reported it.
The synthetic nodes echo it like any other aircraft (delay/Doppler from its
real kinematics), so the fleet flies real traffic.

The feed owns these aircraft: no waypoints, no separation slowing, no
lifetime, and they never count toward `min_aircraft` / `max_aircraft` —
those, with `frac_dark`, keep governing the synthetic aircraft the world
spawns on top, so the two populations are adjusted independently.

`frac_live_dark` casts a share of the live aircraft as **dark**: the world
mirrors them without their transponder (`has_adsb` off, `adsb_hex` None), so
the node frames carry no tag for them, the 1 Hz ADS-B push omits them and the
ground truth keys them by object id — a synthetic dark spawn's shape with a
real trajectory underneath. The rest keep their real hex and callsign and are
pushed as simulated-world ADS-B. The cast is a stable per-hex hash, so
moving the knob re-partitions the aircraft already in the air (raising it
only ever adds dark aircraft) at the next config poll, not the next fleet
turnover. The Physics tab's "Live ADS-B traffic" card holds the toggle and
the slider; `GET /api/simulation/config` reports `live` / `live_adsb` /
`live_dark` counts.

Production sets `FLEET_LIVE_ADSB_URL=` (off): its real hardware nodes claim
the same real hexes in the "real" world, and a simulated fleet mirroring
those aircraft would push the same keys into the server's one ADS-B cache
as simulated-world traffic.

The older opt-in `--real-adsb` relay (adsb.lol → `POST /api/sim/adsb/push`
tagged `source=real`, display-only for claiming) still exists but is
ignored while seeding runs, for the same one-cache reason.

---

Expand Down
38 changes: 38 additions & 0 deletions frontend/src/components/PhysicsSettings.css
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,44 @@
font-weight: 400;
}

.ps-settings-desc {
margin: 8px 0 0;
}

/* Live ADS-B card — toggle row above the dark-share slider. */
.ps-live-card {
grid-column: 1 / -1;
}

.ps-toggle-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
font-size: 13px;
cursor: pointer;
}

.ps-toggle-row input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}

.ps-toggle-text {
flex: 1;
}

.ps-live-sublabel {
margin-top: 4px;
}

.ps-range:disabled {
opacity: 0.4;
cursor: not-allowed;
}

.ps-count-inputs {
display: flex;
align-items: center;
Expand Down
59 changes: 59 additions & 0 deletions frontend/src/components/PhysicsSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,65 @@ describe("PhysicsSettings", () => {
});
});

// ── Live ADS-B knobs ──────────────────────────────────────────────────
// Real aircraft pulled from adsb.retina.fm into the simulated world; the
// dark share of THOSE is its own slider, outside the synthetic mix.

it("falls back to the live-feed defaults when the payload omits them, and sends both on Apply", async () => {
const fetchMock = installFetchMock();
render(<PhysicsSettings />);
await waitFor(() => {
expect(screen.getByText(/spawns 20–40/)).toBeInTheDocument();
});

const liveSlider = screen.getByLabelText("Dark share of live aircraft") as HTMLInputElement;
expect(liveSlider.value).toBe("15");
expect(liveSlider.disabled).toBe(false);
const toggle = screen.getByRole("checkbox") as HTMLInputElement;
expect(toggle.checked).toBe(true);

fireEvent.change(liveSlider, { target: { value: "60" } });
expect(liveSlider.value).toBe("60");

fireEvent.click(screen.getByRole("button", { name: /Apply to Simulator/i }));
await waitFor(() => {
const call = fetchMock.mock.calls.find(
([, opts]: any) => opts?.method === "PUT" && "frac_live_dark" in JSON.parse(opts.body),
);
expect(call).toBeTruthy();
const body = JSON.parse((call as any)[1].body);
expect(body.frac_live_dark).toBe(0.6);
expect(body.live_adsb_enabled).toBe(true);
// Still the synthetic knobs alongside — one PUT, never a scene restart.
expect(body).toHaveProperty("frac_dark");
expect(body).not.toHaveProperty("n_nodes");
});
});

it("disables the live dark-share slider when the feed is switched off, and sends the flag", async () => {
const fetchMock = installFetchMock({
current: { ...BARE_CONFIG, frac_live_dark: 0.3, live_adsb_enabled: true },
});
render(<PhysicsSettings />);
await waitFor(() => {
expect(screen.getByText(/spawns 20–40/)).toBeInTheDocument();
});
const liveSlider = screen.getByLabelText("Dark share of live aircraft") as HTMLInputElement;
expect(liveSlider.value).toBe("30");

fireEvent.click(screen.getByRole("checkbox"));
expect(liveSlider.disabled).toBe(true);

fireEvent.click(screen.getByRole("button", { name: /Apply to Simulator/i }));
await waitFor(() => {
const call = fetchMock.mock.calls.find(
([, opts]: any) => opts?.method === "PUT" && "live_adsb_enabled" in JSON.parse(opts.body),
);
expect(call).toBeTruthy();
expect(JSON.parse((call as any)[1].body).live_adsb_enabled).toBe(false);
});
});

// ── Drift detection ───────────────────────────────────────────────────
// A backend restart drops simulation_config back to boot state and
// re-stamps _updated_at at import. The drafts used to seed once and never
Expand Down
Loading
Loading