Skip to content

Simulator: aircraft separation, metro scoping, and dual-illuminator sites - #10

Merged
jehanazad merged 19 commits into
mainfrom
fix/aircraft-separation
Aug 24, 2026
Merged

jehanazad merged 19 commits into
mainfrom
fix/aircraft-separation

Conversation

@jehanazad

Copy link
Copy Markdown
Contributor

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:

  • --metro scoping with Greenville, SC as a first-class metro (real FCC illuminator sites, WYFF/WSPA towers, GSP coverage ring)
  • Bistatic detection range on all node layouts, and a dual-illuminator site layout with --dual-aim and a dual_fraction carve
  • Scatter layout for unplanned fleets
  • Fly-out retirement: expired aircraft exit at the region edge instead of vanishing mid-scene
  • Ground-truth push now carries has_adsb, callsign, and the anomaly event
  • Scene-change handling: max_range_km and the scene stamp trigger orchestrator self-restart from the config poll
  • frac_anomalous as a master gate with a derived ADS-B roll floor; fixed beams at 42-degree Yagi

Why

Tower-Finder's fix/sim-separation-and-map-error-lines branch pins this branch as its libs/retina-simulation submodule; it needs this work merged to become mergeable itself. The old tip ee7f2be (the SHA Tower-Finder currently pins) stays fetchable via backup/aircraft-separation-ee7f2be until 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 standard
  • fix: drops an argparse block that got duplicated replaying the --metro commit 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 passed
  • pytest: 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

claude and others added 19 commits August 24, 2026 20:25
…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>
@jehanazad
jehanazad merged commit 55dfdd8 into main Aug 24, 2026
2 checks passed
@jehanazad
jehanazad deleted the fix/aircraft-separation branch August 25, 2026 05:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants