Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d32dcab
feat(sim): add --metro scoping with Greenville, SC as a first-class m…
claude Aug 1, 2026
2be09a1
feat(sim): make frac_anomalous a master gate; derive ADS-B roll floor
claude Aug 2, 2026
4829b46
feat: bistatic detection range and metro-scoped solo receivers
claude Aug 2, 2026
9f66815
feat: real FCC illuminator sites for the Greenville metro
claude Aug 2, 2026
afa7e2b
feat: dual-illuminator site layout
claude Aug 2, 2026
c30aed5
fix: register --dual-aim on the generator CLI
claude Aug 3, 2026
1acd07e
fix: declare a bistatic limit on the generic region nodes too
claude Aug 3, 2026
cf118a3
feat: add a scatter layout — the fleet nobody designed
claude Aug 4, 2026
8d5d2ff
fix: layout fleet faults, cells that lied, vel_up saturation, seed st…
claude Aug 4, 2026
2ba572f
Stage 4: document the SNR model simplification
claude Aug 5, 2026
63a31a5
Pass the bistatic limit into the world; fix all beams at 42-degree Yagi
claude Aug 6, 2026
8f9db41
feat: carry has_adsb, callsign, and anomaly event in the ground-truth…
claude Aug 6, 2026
b14d8b9
fix: retire expired aircraft at the region edge, not mid-scene
claude Aug 6, 2026
e1878f2
generator: dual_fraction carve + scene stamp; orchestrator: self-rest…
claude Aug 8, 2026
068dfa8
Treat max_range_km as a scene key: restart-on-change in the config poll
claude Aug 9, 2026
80e46e6
Fly-out retirement for expired aircraft; drones truly off by default
claude Aug 9, 2026
a44faa0
world: traffic separation — spawn resampling + in-trail speed modulation
jehanazad Aug 20, 2026
19aad49
style: normalize new files to the shared ruff standard
jehanazad Aug 24, 2026
7c5f6f1
fix: drop CLI block duplicated in the rebase over the ruff standardiz…
jehanazad Aug 24, 2026
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
836 changes: 814 additions & 22 deletions retina_simulation/generator.py

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion retina_simulation/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,14 @@ def _target_detection(self, target: SyntheticTarget) -> dict:
delay += random.gauss(0, 0.1) # ~0.1 μs noise (GPS-disciplined SDR)
doppler += random.gauss(0, 2.0) # ~2 Hz noise

# SNR depends on distance (closer = stronger)
# SNR depends on distance (closer = stronger).
# KNOWN SIMPLIFICATION: this is a one-way 10 dB/decade falloff on the
# RX-relative distance. Real bistatic received power goes as
# 1/(R_tx² · R_rx²) — ~40 dB/decade split across both legs — so
# synthetic SNR falls off far more gently than hardware will. Any
# SNR-derived gate tuned on this model needs re-tuning on real
# captures; changing the model is a measured follow-up, not a
# drive-by (it reshapes every detection threshold downstream).
dist = _norm(pos)
base_snr = 25 - 10 * math.log10(max(dist, 1))
snr = max(base_snr + random.gauss(0, 2), 4.0)
Expand Down Expand Up @@ -871,6 +878,8 @@ def _stream_multi_node_tcp(
fs_hz=nd.get("fs_hz", 2_000_000.0),
beam_width_deg=nd.get("beam_width_deg", 41.0),
max_range_km=nd.get("max_range_km", 50.0),
# Absent → monostatic range rule, so hardware nodes are unaffected.
max_bistatic_range_km=nd.get("max_bistatic_range_km"),
)
world.add_node(wc)
node_configs.append(wc)
Expand Down
212 changes: 159 additions & 53 deletions retina_simulation/orchestrator.py

Large diffs are not rendered by default.

364 changes: 332 additions & 32 deletions retina_simulation/world.py

Large diffs are not rendered by default.

130 changes: 130 additions & 0 deletions tests/test_bistatic_range.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Detection range limited on bistatic range rather than RX distance.

A monostatic limit compares 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 is what sets received power via the bistatic radar equation. The
difference is not cosmetic — 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 transmitter.

Nodes without max_bistatic_range_km keep the monostatic rule so real hardware
(which only ever carries max_range_km) is unaffected.
"""

import math

from retina_simulation.world import (
NodeConfig,
SimulatedAircraft,
SimulationWorld,
_haversine_km,
)

_RX_LAT, _RX_LON = 34.85, -82.39
_BASELINE_KM = 40.0
_TX_LON = _RX_LON + _BASELINE_KM / (111.32 * math.cos(math.radians(_RX_LAT)))


def _node(max_bistatic_range_km):
# Beam aimed west, wide open, so only the range rule can reject.
return NodeConfig(
node_id="bistatic-node",
rx_lat=_RX_LAT,
rx_lon=_RX_LON,
rx_alt_ft=0.0,
tx_lat=_RX_LAT,
tx_lon=_TX_LON,
tx_alt_ft=0.0,
beam_azimuth_deg=270.0,
beam_width_deg=200.0,
max_range_km=50.0,
max_bistatic_range_km=max_bistatic_range_km,
)


def _aircraft(bearing_deg, range_km):
br = math.radians(bearing_deg)
return SimulatedAircraft(
object_id="probe",
lat=_RX_LAT + math.degrees((range_km * math.cos(br)) / 6371.0),
lon=_RX_LON + math.degrees((range_km * math.sin(br)) / (6371.0 * math.cos(math.radians(_RX_LAT)))),
alt_km=0.0,
vel_east=0.0,
vel_north=0.0,
vel_up=0.0,
heading_deg=0.0,
speed_km_s=0.2,
)


def _bistatic_km(ac):
r_rx = _haversine_km(_RX_LAT, _RX_LON, ac.lat, ac.lon)
r_tx = _haversine_km(_RX_LAT, _TX_LON, ac.lat, ac.lon)
return r_rx + r_tx - _BASELINE_KM


class TestBistaticRangeGate:
def test_rejects_on_tx_leg_where_monostatic_accepts(self):
"""The whole point: 35 km from the RX, inside max_range_km=50, but
directly away from the TX so the bistatic range is 70 km."""
world = SimulationWorld()
ac = _aircraft(270.0, 35.0)
assert _bistatic_km(ac) > 60.0
assert world._aircraft_in_detection_cone(ac, _node(None)) is True
assert world._aircraft_in_detection_cone(ac, _node(60.0)) is False

def test_accepts_inside_the_bistatic_budget(self):
world = SimulationWorld()
ac = _aircraft(270.0, 20.0)
assert _bistatic_km(ac) < 60.0
assert world._aircraft_in_detection_cone(ac, _node(60.0)) is True

def test_same_rx_range_different_tx_leg(self):
"""Bistatic range must vary with bearing at a fixed RX distance —
otherwise the gate has silently stayed monostatic."""
near_tx = _aircraft(330.0, 28.0)
away_tx = _aircraft(270.0, 28.0)
r_near = _haversine_km(_RX_LAT, _RX_LON, near_tx.lat, near_tx.lon)
r_away = _haversine_km(_RX_LAT, _RX_LON, away_tx.lat, away_tx.lon)
assert abs(r_near - r_away) < 0.1, "probes must be at equal RX range"
assert _bistatic_km(near_tx) < _bistatic_km(away_tx)

def test_absent_key_keeps_monostatic_behaviour(self):
"""Real hardware carries only max_range_km and must be untouched."""
world = SimulationWorld()
mono = _node(None)
assert mono.max_bistatic_range_km is None
assert world._aircraft_in_detection_cone(_aircraft(270.0, 45.0), mono) is True
assert world._aircraft_in_detection_cone(_aircraft(270.0, 55.0), mono) is False

def test_beam_still_applies_under_the_bistatic_rule(self):
"""Range is not the only gate — an out-of-beam target stays rejected."""
world = SimulationWorld()
node = _node(60.0)
node.beam_width_deg = 40.0
assert world._aircraft_in_detection_cone(_aircraft(90.0, 10.0), node) is False


class TestEveryNodeDeclaresABistaticLimit:
"""A circle on the receiver is never a bistatic node's true footprint.

The ring, solo and dual paths all declared max_bistatic_range_km; the
generic region-node path did not, so those nodes alone kept gating and
rendering as circles — visible on staging as three synthetic nodes
reporting bistatic=None while every other node reported 60.0.
"""

def test_metro_fleet_is_uniformly_bistatic(self):
from retina_simulation.generator import generate_fleet

fleet = generate_fleet(n_nodes=15, metro="gvl", n_cluster=10, n_clusters=1, use_tower_api=False, seed=42)
missing = [n["node_id"] for n in fleet if n.get("max_bistatic_range_km") is None]
assert not missing, f"nodes still monostatic: {missing}"

def test_the_limit_matches_the_declared_range(self):
from retina_simulation.generator import generate_fleet

fleet = generate_fleet(n_nodes=15, metro="gvl", n_cluster=10, n_clusters=1, use_tower_api=False, seed=7)
for n in fleet:
assert n["max_bistatic_range_km"] == n["max_range_km"], n["node_id"]
Loading
Loading