Simulator: aircraft separation, metro scoping, and dual-illuminator sites - #10
Merged
Merged
Conversation
…etro
Adds a selectable metro that constrains an entire synthetic fleet -- nodes,
rings, and aircraft -- to one metro area, instead of spreading it across the
continent. Nothing is deleted; the nationwide path is unchanged.
generator.py
- Two real Greenville-market towers in _TOWERS_US: WYFF (Caesars Head,
RF ch 30) and WSPA-TV (Hogback Mtn, RF ch 11).
- A gvl entry in _RING_TXS cored on GSP, illuminated by WSPA-TV 31 km off
the core -- real VHF, keeps Doppler inside the association gate.
- generate_fleet()/coverage_cells() take metro=; it filters towers and rings
to the metro radius and disables solo/rural placement. Both apply the same
filter so cells can never disagree with the nodes.
- _KNOWN_METROS moved here from orchestrator so the generator's filter and
the orchestrator's --metros filter share one definition.
orchestrator.py
- --metro threaded through generation, world build, and real-ADS-B injection.
- Metro fleets get a 15-30 aircraft floor rather than the nationwide 150-300.
world.py
- _REGIONAL_WAYPOINTS + waypoints_for_metro(); the waypoint net is now a
SimulationWorld attribute. Without this, ~40% of spawns stayed on the
nationwide net and flew coast to coast regardless of node placement.
- The 400 km en-route leg threshold now scales to the net, capped at 400 so
the nationwide path is bit-for-bit unchanged.
Verified on staging: synthetic nodes went from a 3588 km spread to 56 km, and
no aircraft appear outside the region.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Anomalies default off. frac_anomalous now gates BOTH sources of anomalous
traffic, not just the spawn-time roll:
- Default frac_anomalous 0.05 -> 0.0.
- _maybe_schedule_anomaly() returns early at frac_anomalous <= 0. That
scheduler turned ~8% of NORMAL commercial aircraft anomalous 30-120s after
spawn at its own hardcoded rate, independent of the fraction -- so zeroing
frac_anomalous alone left the majority of anomaly traffic running. One gate
keeps 'off' meaning off while still switching everything back on.
- orchestrator._poll_simulation_config falls back to 0.0 rather than 0.05, so
a payload missing the key cannot silently re-enable anomalies.
Also fixes a latent bug found while verifying the spawn distribution: the ADS-B
assignment used a hardcoded 'roll >= 0.30', which silently duplicated the
cumulative boundary of the DEFAULT fractions (0.05 + 0.10 + 0.15). Any other
values -- including anything set through the existing Physics Settings slider
at runtime -- left part of the commercial band below the literal, spawning
those aircraft with no transponder so they registered as dark. Measured at
frac_anomalous=0: dark 0.199 against a configured 0.15. The floor is now
derived from the fractions, and dark tracks its setting at 0.0, 0.05 and 0.20.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two changes to make the simulated fleet behave like real passive radar. Bistatic range. _aircraft_in_detection_cone compared only the RX->target leg against max_range_km, ignoring the transmitter entirely. That is not what a passive radar is limited by: the delay measures (RX->target) + (target->TX) - baseline, and that sum sets received power via the bistatic radar equation. At a fixed RX distance the bistatic range varies by more than the whole budget depending on which way the target lies relative to the TX, so the footprint is an ellipse with foci at RX and TX, not a circle on the RX. Introduced as a new key (max_bistatic_range_km) rather than reinterpreting max_range_km, because that key also feeds the real hardware/SDR defaults and the backend's arc binary-search ceiling. Nodes without the new key keep the monostatic rule, so hardware is untouched. Metro-scoped solo receivers. solo_fraction exists precisely to keep the single-node ellipse-arc path exercised -- "isolated rural towers far from any other nodes" -- but was disabled under --metro, because the nationwide pool separates receivers by 400 km and a metro is 111 km across. The result was that 15 of 16 Greenville nodes overlapped 1-11 neighbours, essentially every detection associated into a multinode solve, and single-node arcs nearly vanished. Metro solo nodes get isolation from beam geometry instead of distance: placed on the rim and aimed away from the core, so their sector cannot intersect the inward-aimed ring beams however the range circles overlap. Overlap zones are computed from beam sectors, so this is the property that actually decides whether detections stay single-node. Verified against InterNodeAssociator: both solo nodes report 0 neighbours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The table carried two invented Greenville entries. Replaced with the eight distinct sites from the Tower Finder illuminator search. Twenty stations serve this market but they share only eight masts -- Paris Mountain alone carries WNTV, WRET-TV, WGGS-TV, W10AJ-D and five LPTVs -- and co-sited transmitters are worthless as a bistatic pair, giving an identical ellipse. So the table lists sites, with the strongest station at each. Adds Fountain Inn (WMYA-TV 599, south) and Spartanburg (BLP01065 195, east), which are the two that break the market out of its northern cluster. EIRP lives in a separate dict keyed by callsign so the 5-tuple unpacking used throughout this module stays valid; the spread is 65 dB, from Caesars Head at 92.2 dBm down to Spartanburg at 27.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--layout dual scatters receivers across the metro, each running two nodes
on two transmitters from one antenna: shared rx position, altitude, beam
azimuth, beam width and range, differing only in which tower they listen
to. This is how a real passive-radar site is built, and the geometric
payoff is that the two bistatic ellipses share a focus (the common RX), so
they intersect in at most two points and the beam almost always excludes
one.
Measured offline against the ring layout, 10 seeds each, position error
for the single-pair case -- which is the whole hypothesis:
layout n=2 solves median p90
ring 21 0.77 km 3.11 km
dual (any band) 30 0.32 km 1.23 km
dual (VHF only) 42 0.37 km 0.77 km
2.1-2.4x better, with a far tighter tail. Two illuminators sharing one
receiver do confine the fix better than two receivers tens of km apart.
Illuminators are chosen per receiver by maximising the *worst* subtended
angle across its beam footprint, not the mean: a pair can condition well at
boresight and collapse at the beam edge, and selecting on one representative
point would bake that blind spot in. Selection is over distinct sites --
_TOWERS_US is already one entry per mast, since co-sited transmitters share
an ellipse and are worthless as a pair. An EIRP floor keeps the weaker leg
usable.
--dual-aim defaults to "core" (aim at the metro core with jitter) rather
than random. Random aiming was the first attempt and it starved the
layout: with 85% of traffic routed through the core, most beams saw
nothing and the solve rate collapsed from 105 to 9. Inter-site overlap is
not the enemy here the way it is for a ring -- each dual site already
self-solves from its own two illuminators, so a second site overlapping it
upgrades the fix to four nodes rather than manufacturing a two-node
ambiguity.
--illuminator-band vhf restricts to VHF, which on this measurement is the
better dual configuration: more solves (101 vs 72), more real tracks, and
a lower ghost rate (24% vs 34%) despite narrower subtended angles. That is
consistent with the Doppler plausibility test being strongest when the two
bisector axes are near-parallel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main() passed args.dual_aim to generate_fleet but the parser never defined it, so any invocation of the generator's command line died with AttributeError. Nothing caught it: the tests and the offline bench call generate_fleet() directly, and the CLI runs only in the fleet container's entrypoint — so it surfaced as a staging deploy coming up with a synthetic fleet of zero nodes, several commits after the dual layout landed. Adds a test that parses main()'s source for args.* reads and asserts each one is a registered dest, so the two cannot drift apart again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ring, solo and dual paths all set max_bistatic_range_km; the generic region-node path set only max_range_km, so those nodes alone kept a monostatic circle for both the association gate and the map. Visible on staging as three synthetic nodes reporting bistatic=None against 60.0 for every other node. Every bistatic receiver is bounded by differential range, so a circle on the RX is never its true footprint. The randomised 35-55 km value carries over unchanged -- it is the same number, read correctly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ring and dual both buy geometry on purpose: a ring surrounds the core with
receivers that share one illuminator and all aim inward, and a dual site pairs
two illuminators on one mast. A community deployment does neither. Sites land
where operators live, on whatever station comes in best, pointed by hand, with
whatever hardware they own — and the solver gets whatever geometry falls out.
Measuring against the ring flatters the solver twice over: the shared TX keeps
every node's Doppler inside one association gate, and the inward aim guarantees
that every beam intersects. Neither holds in the field.
--layout scatter spends the same n-cluster budget on:
- placement clumped around the metro core and the towns its real broadcast
towers serve (an FM/TV tower is sited for population), so near-parallel
range gradients appear the way they will in practice
- a per-site illuminator drawn 1/d^2 from towers within 75 km
- aim at the core with ~25 deg pointing error, a quarter aimed elsewhere
- beamwidth and bistatic reach varying per site, mode at 0.7 of the ceiling:
60 km is what a good setup achieves, not an average one
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ability - scatter/dual without --metro silently emitted a fleet ~n_cluster nodes short; both now raise. scatter's budget was also gated on the ring table surviving the metro filter (n_clusters > 0) — a metro without a _RING_TXS entry zeroed the whole layout. Decoupled. - coverage_cells() emitted ring cells for every layout, so a scatter/dual fleet_config.json described an airspace no generated node was placed around — violating _active_rings' own cells-never-disagree guarantee. Ringless layouts now emit one metro-core cell (or none without a metro). - Nationwide solo nodes never declared max_bistatic_range_km, gating as monostatic circles — the one path meant to exercise single-node ellipse arcs. Now declared, same convention as base/ring/dual. - world: vel_up was set once at spawn and integrated forever, so every aircraft saturated at the 0.1/15 km altitude clamps within ~1000 s. Decays toward level flight (tau 5 min), zeroed when a clamp binds. Also replaced the file's last two 111.32 literals with the R_EARTH conversion everything else uses. - generate_fleet re-seeds after the network tower lookup so lookup retries can't shift the placement RNG stream; reproducibility caveats (API content, shapely availability) documented at the seed site. - fleet_summary([]) no longer crashes on min() of an empty sequence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
node.py models one-way 10 dB/decade falloff where bistatic radar is ~40 dB/decade; mark it as a KNOWN SIMPLIFICATION at the site the detection gate consumes, per the survey. Changing the physics is a measured follow-up, not this program. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- orchestrator: NodeConfig now receives max_bistatic_range_km from the fleet config. Without it the world fell back to the monostatic RX-radius rule while the handshake declared the bistatic limit to the server — nodes "detected" up to 1.6x beyond what the server accepts, and 49% of detectable aircraft-samples on staging sat in that disagreement zone (measured: feed delays at 268 us against a declared 165 us limit). - generator/world: every antenna is the same 42-degree Yagi. Width jitter (gauss sigma=8 clamped 25-75, uniform(35,45) x2) and the 40/41/50-degree path defaults removed; test updated to pin uniform width alongside per-site reach variation. Suite: 120 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… push The server's debug map now shows per-object simulated parameters, but the push payload silently dropped has_adsb, adsb_callsign, and anomaly_event — the world tracked them, the server never learned them, and "dark vs ADS-B" was unrecoverable downstream. - get_aircraft_summary emits adsb_callsign and anomaly_event alongside the existing has_adsb. - The payload build is extracted into a pure build_ground_truth_payload() (previously inline in the push loop, untestable) and adds the three fields, defaulting sanely for older summaries. - New tests/test_ground_truth_push.py guards the schema, the dark-object id fallback, and old-payload tolerance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Staging measurement (1 Hz feed sampling): every ground-truth vanish was a
lifetime expiry, and uniform(180, 900) s lifetimes expire aircraft wherever
they happen to be — including over the metro core, where a blue dot blinking
out reads as a tracking bug ("dots spontaneously disappear in the central
region").
Lifetime expiry now marks the aircraft for retirement; it keeps flying until
it is retire_edge_km (70 km) from the world center, beyond the ~60 km node
coverage of a metro-scoped fleet. A 2x-lifetime hard cap keeps slow or
looping routes (drones especially) turning over even if they never reach the
edge. Nationwide (unscoped) fleets spawn mostly beyond the radius and keep
the old expire-anywhere behaviour.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…art on scene change
dual_fraction (0.0-1.0, requires metro, ignored for layout=dual) carves
round(n_nodes*frac/2) dual-illuminator sites out of the ring/metro budget
and appends them AFTER the existing layout output, so dual_fraction=0.0
reproduces today's scene byte-for-byte at the same seed (regression-
tested). Sites reuse _generate_dual_sites with the dual-layout branch's
own prefix/tower/aim construction — a fraction-carved site is
indistinguishable from a full dual-layout one.
The generator CLI gains --dual-fraction and stamps the scene it actually
generated ({n_nodes, dual_fraction, layout, seed}) into
config["fleet"]["scene"]. _poll_simulation_config reads that stamp and,
when the backend-reported n_nodes/dual_fraction drift from it (the
physics tab PUT a scene change), WARNs and calls orchestrator.stop() —
the process exits 0 and docker's restart policy relaunches into
fleet-entrypoint.sh, which fetches the desired scene before regenerating.
Absent stamp (stale volume) or absent config keys (only-if-set backend
pattern) → no comparison, no restart loop.
Tests: tests/test_dual_fraction.py — rx-sharing pair invariants and site
counts for the scatter carve, ring-layout carve, layout=dual no-op,
determinism regression, dual_fraction=1.0 clamp, missing-metro
ValueError, and one-poll scene-change detection (diff → stop; match /
tolerance / empty scene / missing keys → no stop).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The UI's "Max detection range" slider round-tripped into the backend config but nothing consumed it at runtime — the poll loop only applied fractions and aircraft counts, and max_range_km only ever mattered at boot via FLEET_MAX_RANGE_KM. Applying a range change requires regenerating node configs (every node cfg is built from it at construction), which is exactly the existing scene-restart path, so the poll now compares the polled value against the orchestrator's own running max_range_km — no scene stamp needed — and shuts down for regeneration on drift. fleet-entrypoint fetches the override on reboot alongside n_nodes/dual_fraction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two visible-on-the-map fixes: - The 2x-lifetime hard cap retired aircraft regardless of position, and metro-routed traffic (85% of spawns) rarely crosses retire_edge_km on its own — so the edge gate only ever covered planes that happened to be leaving anyway, and most blue dots still vanished mid-view. Expiry now reroutes the aircraft at the region edge (_route_out: one waypoint past retire_edge_km on the bearing away from center) and non-drones get exit_grace_s (900 s, ~70 km at the slowest commercial speed) to actually get there; the anywhere-backstop fires only for genuinely stuck aircraft. Drones keep the 2x cap — they loop low and slow and are expected to churn. - SimulationWorld's constructor default frac_drone=0.10 disagreed with the backend's drones-off default, so every fleet restart spawned a handful of drones in the window before the first config poll applied the real 0.0 — visible as "a few drones exist even with the slider at 0" until they aged out. World default and the poll's absent-key fallback are now both 0.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hub-radial planner converged every metro spawn on the same ~2 km core with no deconfliction, so the fleet routinely flew pairs inside the solver's association gates (delay gate ~1-3 km) — association ambiguity was a property of the simulator, not of realistic traffic. Spawn poses now resample away from live aircraft (best-effort, never blocks the spawn loop), and in-flight conflicts — horizontal AND vertical proximity — slow the later-created aircraft toward 70% of cruise until clear, speed-only so the waypoint router keeps owning heading. Anomalous aircraft and drones are exempt: erratic close approaches are the anomaly signature the network exists to catch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The separation branch predates the repo-wide ruff standardization (#5); its new test files carried the old formatting. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ation Replaying the --metro commit over main's reformatted argparse block kept both copies; argparse then dies on the second --config with 'conflicting option strings'. Keep the branch's copy (it adds --metro and the updated help text) and drop main's stale one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jehanazad
added a commit
to offworldlabs/retina-server
that referenced
this pull request
Aug 24, 2026
The submodule branch was rebased onto its main and force-pushed; new tip 7c5f6f1 (old ee7f2be preserved at backup/aircraft-separation-ee7f2be). See offworldlabs/retina-simulation#10. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Nineteen commits of simulator work, headlined by traffic separation: spawn resampling plus in-trail speed modulation so simulated aircraft no longer overlap or fly through each other. Supporting work the separation fix builds on:
--metroscoping with Greenville, SC as a first-class metro (real FCC illuminator sites, WYFF/WSPA towers, GSP coverage ring)--dual-aimand adual_fractioncarvehas_adsb, callsign, and the anomaly eventmax_range_kmand the scene stamp trigger orchestrator self-restart from the config pollfrac_anomalousas a master gate with a derived ADS-B roll floor; fixed beams at 42-degree YagiWhy
Tower-Finder's
fix/sim-separation-and-map-error-linesbranch pins this branch as itslibs/retina-simulationsubmodule; it needs this work merged to become mergeable itself. The old tipee7f2be(the SHA Tower-Finder currently pins) stays fetchable viabackup/aircraft-separation-ee7f2beuntil the re-pin lands.Rebase notes
The branch predated the ruff standardization (#5), the vulture dead-code gate (#6/#7), and the CI scaffold (#8), all of which touched the same four modules. It was rebased commit-by-commit onto main, keeping main's formatting/lint conventions and the branch's logic. Two follow-up commits on top of the replayed seventeen:
style:normalizes the branch's new test files to the shared ruff standardfix:drops an argparse block that got duplicated replaying the--metrocommit over main's reformatted CLI section (argparse died on the second--config)A format-normalized diff of the rebased tree against the old tip shows only lint-level rewrites — no behavior change relative to what Tower-Finder has been testing against.
Gates
pre-commit run --all-files(ruff 0.16.2, ruff format, vulture dead-code, ruff-config): all passedpytest: 163 passed (81 from main, 82 from this branch's new suites: separation, retirement, dual sites/fraction, scatter, bistatic range, ground-truth push)🤖 Generated with Claude Code