From 85f2478208f9f67a2a0eb5eb043eb8f23e4e9b27 Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Thu, 10 Sep 2026 19:58:25 +0000 Subject: [PATCH 1/2] Physics tab: live ADS-B traffic from adsb.retina.fm with its own dark share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins retina-simulation feat/live-adsb-seed (adbc713): the fleet pulls the real aircraft over its metro from an adsb.lol-shaped feed INTO the simulated world, so the synthetic nodes echo real trajectories. - deploy/fleet-entrypoint.sh: FLEET_LIVE_ADSB_URL (default https://adsb.retina.fm; empty = off) and FLEET_LIVE_ADSB_INTERVAL feed --live-adsb-url / --live-adsb-interval. Production sets the URL empty: its real hardware nodes claim the same real hexes in the "real" world, and the simulated fleet would push those keys into the one ADS-B cache as simulated-world traffic. - simulation config: frac_live_dark (share of the LIVE aircraft mirrored without their transponder; outside the frac_* sum like frac_adsb_outage) and live_adsb_enabled (pauses the pull). Both PUT-able, snapshot-restored, and applied in-process at the fleet's next config poll — the simulator re-casts the aircraft already in the air. - ground truth: the push carries source=live|sim, the config counts report live / live_adsb / live_dark alongside the type buckets (overlapping them, like adsb_silent), and /api/simulation/ground-truth exposes has_adsb and source. - Physics tab: a "Live ADS-B traffic" card with the feed toggle, a live/dark badge and a "Dark share of live aircraft" slider, deliberately separate from the composition bar (the feed sets the live headcount, so it is not a share of the synthetic mix); the objects-target card says it governs synthetic aircraft only. GT map: dark aircraft coloured by has_adsb (the "dark" object_type it keyed on never arrives), live aircraft dashed. Co-Authored-By: Claude Fable 5.1 --- backend/core/state.py | 11 +++ backend/routes/sim_ingest.py | 3 + backend/routes/test.py | 25 +++++- backend/services/state_snapshot.py | 2 + backend/tests/test_sim_ingest.py | 55 +++++++++++++ deploy/fleet-entrypoint.sh | 12 +++ docker-compose.prod.yml | 6 ++ docs/simulation.md | 53 +++++++++--- frontend/src/components/PhysicsSettings.css | 38 +++++++++ .../src/components/PhysicsSettings.test.tsx | 59 ++++++++++++++ frontend/src/components/PhysicsSettings.tsx | 81 ++++++++++++++++++- libs/retina-simulation | 2 +- 12 files changed, 331 insertions(+), 16 deletions(-) diff --git a/backend/core/state.py b/backend/core/state.py index 0e3c44ac..bf79c518 100644 --- a/backend/core/state.py +++ b/backend/core/state.py @@ -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 diff --git a/backend/routes/sim_ingest.py b/backend/routes/sim_ingest.py index 6759a26c..a8f2c43a 100644 --- a/backend/routes/sim_ingest.py +++ b/backend/routes/sim_ingest.py @@ -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"): diff --git a/backend/routes/test.py b/backend/routes/test.py index 9d1202cb..410a6596 100644 --- a/backend/routes/test.py +++ b/backend/routes/test.py @@ -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": @@ -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). @@ -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", @@ -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",): @@ -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"]) @@ -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"), } ) diff --git a/backend/services/state_snapshot.py b/backend/services/state_snapshot.py index ac0d515b..cb7e7d9c 100644 --- a/backend/services/state_snapshot.py +++ b/backend/services/state_snapshot.py @@ -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", diff --git a/backend/tests/test_sim_ingest.py b/backend/tests/test_sim_ingest.py index d4934d92..0e044be7 100644 --- a/backend/tests/test_sim_ingest.py +++ b/backend/tests/test_sim_ingest.py @@ -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 diff --git a/deploy/fleet-entrypoint.sh b/deploy/fleet-entrypoint.sh index 80a14bb0..fd726cd6 100644 --- a/deploy/fleet-entrypoint.sh +++ b/deploy/fleet-entrypoint.sh @@ -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 @@ -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 "═══════════════════════════════════════════════════" @@ -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..." diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f60275ff..e8edf713 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/docs/simulation.md b/docs/simulation.md index f0dee670..23ca8399 100644 --- a/docs/simulation.md +++ b/docs/simulation.md @@ -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 @@ -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-` 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. --- diff --git a/frontend/src/components/PhysicsSettings.css b/frontend/src/components/PhysicsSettings.css index 45ba8f44..dacfb350 100644 --- a/frontend/src/components/PhysicsSettings.css +++ b/frontend/src/components/PhysicsSettings.css @@ -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; diff --git a/frontend/src/components/PhysicsSettings.test.tsx b/frontend/src/components/PhysicsSettings.test.tsx index 21bc1ab1..2b0f27c3 100644 --- a/frontend/src/components/PhysicsSettings.test.tsx +++ b/frontend/src/components/PhysicsSettings.test.tsx @@ -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(); + 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(); + 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 diff --git a/frontend/src/components/PhysicsSettings.tsx b/frontend/src/components/PhysicsSettings.tsx index fb95a34d..db33e5ea 100644 --- a/frontend/src/components/PhysicsSettings.tsx +++ b/frontend/src/components/PhysicsSettings.tsx @@ -121,6 +121,10 @@ function serverToDraft(data) { frac_dark: data.frac_dark, min_aircraft: data.min_aircraft ?? 20, max_aircraft: data.max_aircraft ?? 40, + // Live ADS-B seeding — an older backend ships neither key; the + // fallbacks match core/state.py's defaults. + frac_live_dark: data.frac_live_dark ?? 0.15, + live_adsb_enabled: data.live_adsb_enabled ?? true, }; } @@ -346,6 +350,10 @@ export default function PhysicsSettings() { frac_dark: draft.frac_dark, min_aircraft: Number(draft.min_aircraft), max_aircraft: Number(draft.max_aircraft), + // Live-feed knobs: in-process too (the simulator re-casts the live + // aircraft already in the air at its next config poll). + frac_live_dark: draft.frac_live_dark, + live_adsb_enabled: Boolean(draft.live_adsb_enabled), }), }); const body = await res.json().catch(() => ({})); @@ -359,7 +367,7 @@ export default function PhysicsSettings() { lastStampRef.current = typeof stamp === "number" ? stamp : null; dirtyRef.current = false; setDrift(null); - setSaveMsg("Applied — new objects will spawn with updated fractions."); + setSaveMsg("Applied — live aircraft are re-cast within ~5 s; new synthetic objects spawn with the updated fractions."); setTimeout(() => setSaveMsg(null), 4000); await fetchConfig(); } catch (e) { @@ -673,6 +681,63 @@ export default function PhysicsSettings() { {draft.max_aircraft} +

+ Synthetic aircraft only — the live feed below adds its own on top of this. +

+ + + {/* Live ADS-B traffic. Real aircraft over the metro (adsb.retina.fm) + join the simulated world and are echoed by the synthetic nodes; + the slider decides how many of THOSE fly without a transponder. + Deliberately its own card, not a segment of the composition bar: + the feed sets the live headcount, so it is not a share of the + synthetic mix and must not be summed with it. */} +
+
+ Live ADS-B traffic + (real aircraft from adsb.retina.fm, echoed by the synthetic nodes) +
+ +
+ Dark share of live aircraft + — cast without their transponder; the rest keep their real ADS-B +
+
+ handleSlider("frac_live_dark", Number(e.target.value))} + className="ps-range" + aria-label="Dark share of live aircraft" + style={{ + "--thumb-color": SIM_DARK, + "--fill-pct": `${pct(draft.frac_live_dark)}%`, + }} + /> + {pct(draft.frac_live_dark)}% +
+

+ Independent of the synthetic mix above: the dark slider and the objects target only govern + aircraft the simulator spawns itself. Moving this re-casts the live aircraft already in the air. +

@@ -824,7 +889,9 @@ export default function PhysicsSettings() { function acColor(a) { if (a.is_anomalous) return SIM_ANOMALOUS; if (a.object_type === "drone") return SIM_DRONE; - if (a.object_type === "dark") return SIM_DARK; + // The backend reports dark aircraft as object_type "aircraft" with + // has_adsb false (the "dark" literal is kept for older payloads). + if (a.object_type === "dark" || a.has_adsb === false) return SIM_DARK; return SIM_COMMERCIAL; } @@ -863,9 +930,16 @@ export default function PhysicsSettings() { fillColor: acColor(a), fillOpacity: 0.85, weight: a.is_anomalous ? 2 : 1, + // Live-feed aircraft get a dashed ring so real and + // synthetic trajectories can be told apart at a glance. + dashArray: a.source === "live" ? "2 2" : undefined, }} > - {a.object_type}{a.is_anomalous ? " ⚠ anomalous" : ""} · {Math.round(a.alt_m)} m + + {a.object_type}{a.has_adsb === false && a.object_type === "aircraft" ? " (dark)" : ""} + {a.is_anomalous ? " ⚠ anomalous" : ""}{a.source === "live" ? " · live feed" : ""} + {" · "}{Math.round(a.alt_m)} m + ))} @@ -875,6 +949,7 @@ export default function PhysicsSettings() { ● Dark ● Drone ● Anomalous + ◌ dashed = live feed ); diff --git a/libs/retina-simulation b/libs/retina-simulation index f06d5347..adbc7136 160000 --- a/libs/retina-simulation +++ b/libs/retina-simulation @@ -1 +1 @@ -Subproject commit f06d534709c45dac18d1dd24df99e27ec95138a1 +Subproject commit adbc7136c8a6ebb2af721100ce8a0487163e82ac From 973ee039751fd6fa1ca586591c47537645068aaf Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Thu, 10 Sep 2026 21:20:02 +0000 Subject: [PATCH 2/2] Pin retina-simulation to the merged live-ADS-B seeding (6ffb678) 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 adbc7136..6ffb6781 160000 --- a/libs/retina-simulation +++ b/libs/retina-simulation @@ -1 +1 @@ -Subproject commit adbc7136c8a6ebb2af721100ce8a0487163e82ac +Subproject commit 6ffb6781cae1c3fbde3972faca1c7d2d24ff6d2f