diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7e11df8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: astral-sh/setup-uv@v5 + + # retina-custody is a sibling library on no package index, so pip cannot + # resolve the `retina-custody>=0.1.0` dependency on its own. The pin stays + # generic in pyproject.toml on purpose: Tower-Finder installs these libs + # from local submodule paths, and a direct URL there would collide with + # "conflicting URLs for package retina-custody". + - run: uv pip install --system "retina-custody @ git+https://github.com/offworldlabs/retina-custody@v0.1.0" + + # Pinned deliberately: an unpinned linter picks up new rules on release + # and turns a green branch red without anything in this repo changing. + - run: uv pip install --system -e '.[dev]' pre-commit==4.6.2 vulture==2.14 + + - run: pre-commit run --all-files --show-diff-on-failure + + - run: pytest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 4b0bd85..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: lint - -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - -jobs: - ruff: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - # Pinned deliberately: an unpinned ruff picks up new rules on release and - # turns a green branch red without anything in this repo changing. - - run: pip install ruff==0.16.2 vulture==2.14 pre-commit==4.6.2 - - - run: ruff check . - - - name: Dead code - run: pre-commit run --all-files --show-diff-on-failure diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ff5e4ee..b469494 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,14 @@ -# The dead-code gate lives in offworldlabs/ops and is pinned here. Do not -# vendor it back into this repo. See 86cb417ty. -# -# ruff is deliberately still a separate CI step rather than a hook; aligning -# this repo with claude-shared's ci-python.yml is a separate piece of work. +# ruff and the dead-code gate both run through pre-commit, so `pre-commit run +# --all-files` locally reproduces CI exactly. See 86cb43xya. repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.2 + hooks: + - id: ruff + - id: ruff-format + - repo: https://github.com/offworldlabs/ops - rev: dead-code-v1.0 + rev: hooks-v1.0 hooks: - id: dead-code + - id: ruff-config diff --git a/pyproject.toml b/pyproject.toml index 78eb782..22f66ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,8 +26,10 @@ retina_simulation = ["*.json", "data/*.geojson"] [tool.pytest.ini_options] testpaths = ["tests"] -# Shared ruff standard for offworldlabs Python repos. -# Keep in sync across repos; see offworldlabs/ops for the canonical copy. +# Shared ruff standard for offworldlabs Python repos. The keys below are checked +# against offworldlabs/ops:ruff-shared.toml by the `ruff-config` pre-commit hook +# — edit them there, not here. target-version is deliberately per-repo and +# tracks this package's requires-python. [tool.ruff] line-length = 120 target-version = "py310" diff --git a/retina_simulation/generator.py b/retina_simulation/generator.py index a1081df..ce3bd31 100644 --- a/retina_simulation/generator.py +++ b/retina_simulation/generator.py @@ -22,45 +22,45 @@ _TOWERS_US = [ # East Coast - (33.75667, -84.33184, 1600, 195_000_000, "WSB-TV"), # Atlanta - (35.23064, -80.84313, 1540, 575_000_000, "WBTV"), # Charlotte - (38.93460, -77.07920, 1380, 585_000_000, "WRC-TV"), # Washington DC - (40.74843, -73.98566, 1776, 191_000_000, "WCBS-TV"), # New York - (42.35370, -71.06010, 1200, 575_000_000, "WBZ-TV"), # Boston - (39.95233, -75.16379, 1600, 563_000_000, "KYW-TV"), # Philadelphia - (25.79590, -80.28700, 1000, 191_000_000, "WTVJ"), # Miami - (28.54082, -81.37916, 1350, 551_000_000, "WESH"), # Orlando - (27.97450, -82.45720, 1400, 539_000_000, "WFLA"), # Tampa - (30.33270, -81.65560, 1200, 575_000_000, "WJXT"), # Jacksonville - (36.85260, -75.97820, 1300, 539_000_000, "WAVY"), # Norfolk - (35.78700, -78.78170, 1500, 563_000_000, "WRAL"), # Raleigh + (33.75667, -84.33184, 1600, 195_000_000, "WSB-TV"), # Atlanta + (35.23064, -80.84313, 1540, 575_000_000, "WBTV"), # Charlotte + (38.93460, -77.07920, 1380, 585_000_000, "WRC-TV"), # Washington DC + (40.74843, -73.98566, 1776, 191_000_000, "WCBS-TV"), # New York + (42.35370, -71.06010, 1200, 575_000_000, "WBZ-TV"), # Boston + (39.95233, -75.16379, 1600, 563_000_000, "KYW-TV"), # Philadelphia + (25.79590, -80.28700, 1000, 191_000_000, "WTVJ"), # Miami + (28.54082, -81.37916, 1350, 551_000_000, "WESH"), # Orlando + (27.97450, -82.45720, 1400, 539_000_000, "WFLA"), # Tampa + (30.33270, -81.65560, 1200, 575_000_000, "WJXT"), # Jacksonville + (36.85260, -75.97820, 1300, 539_000_000, "WAVY"), # Norfolk + (35.78700, -78.78170, 1500, 563_000_000, "WRAL"), # Raleigh # Midwest - (41.87150, -87.62440, 1650, 191_000_000, "WBBM-TV"), # Chicago - (42.33140, -83.04580, 1200, 551_000_000, "WXYZ-TV"), # Detroit - (39.96110, -82.99880, 1400, 563_000_000, "WCMH"), # Columbus - (39.76910, -86.15800, 1350, 575_000_000, "WISH-TV"), # Indianapolis - (44.97750, -93.26490, 1500, 585_000_000, "WCCO-TV"), # Minneapolis - (38.62720, -90.19780, 1300, 551_000_000, "KMOV"), # St Louis - (39.09970, -94.57860, 1450, 539_000_000, "KCTV"), # Kansas City - (41.25220, -95.99780, 1350, 575_000_000, "KETV"), # Omaha + (41.87150, -87.62440, 1650, 191_000_000, "WBBM-TV"), # Chicago + (42.33140, -83.04580, 1200, 551_000_000, "WXYZ-TV"), # Detroit + (39.96110, -82.99880, 1400, 563_000_000, "WCMH"), # Columbus + (39.76910, -86.15800, 1350, 575_000_000, "WISH-TV"), # Indianapolis + (44.97750, -93.26490, 1500, 585_000_000, "WCCO-TV"), # Minneapolis + (38.62720, -90.19780, 1300, 551_000_000, "KMOV"), # St Louis + (39.09970, -94.57860, 1450, 539_000_000, "KCTV"), # Kansas City + (41.25220, -95.99780, 1350, 575_000_000, "KETV"), # Omaha # South - (29.76330, -95.36320, 1300, 191_000_000, "KHOU"), # Houston - (32.78060, -96.80060, 1600, 575_000_000, "WFAA"), # Dallas - (29.42410, -98.49360, 1200, 563_000_000, "KENS"), # San Antonio - (30.26710, -97.74310, 1400, 551_000_000, "KVUE"), # Austin - (36.16270, -86.78160, 1350, 539_000_000, "WSMV"), # Nashville - (35.14950, -90.04890, 1200, 575_000_000, "WMC-TV"), # Memphis - (32.29560, -90.18480, 1100, 563_000_000, "WLBT"), # Jackson MS - (30.45080, -91.18720, 1150, 551_000_000, "WAFB"), # Baton Rouge + (29.76330, -95.36320, 1300, 191_000_000, "KHOU"), # Houston + (32.78060, -96.80060, 1600, 575_000_000, "WFAA"), # Dallas + (29.42410, -98.49360, 1200, 563_000_000, "KENS"), # San Antonio + (30.26710, -97.74310, 1400, 551_000_000, "KVUE"), # Austin + (36.16270, -86.78160, 1350, 539_000_000, "WSMV"), # Nashville + (35.14950, -90.04890, 1200, 575_000_000, "WMC-TV"), # Memphis + (32.29560, -90.18480, 1100, 563_000_000, "WLBT"), # Jackson MS + (30.45080, -91.18720, 1150, 551_000_000, "WAFB"), # Baton Rouge # West (34.05220, -118.24370, 1600, 191_000_000, "KABC-TV"), # Los Angeles - (37.77490, -122.41940, 1500, 575_000_000, "KGO-TV"), # San Francisco + (37.77490, -122.41940, 1500, 575_000_000, "KGO-TV"), # San Francisco (47.60620, -122.33210, 1400, 585_000_000, "KOMO-TV"), # Seattle - (45.52350, -122.67620, 1300, 551_000_000, "KGW"), # Portland + (45.52350, -122.67620, 1300, 551_000_000, "KGW"), # Portland (33.44840, -112.07400, 1200, 563_000_000, "KPHO-TV"), # Phoenix (36.17490, -115.13740, 1150, 539_000_000, "KLAS-TV"), # Las Vegas (39.73920, -104.99030, 1400, 575_000_000, "KCNC-TV"), # Denver - (40.76080, -111.89100, 1300, 563_000_000, "KSL-TV"), # Salt Lake City + (40.76080, -111.89100, 1300, 563_000_000, "KSL-TV"), # Salt Lake City (32.71570, -117.16110, 1100, 551_000_000, "KFMB-TV"), # San Diego (36.74770, -119.77260, 1200, 539_000_000, "KFSN-TV"), # Fresno ] @@ -112,33 +112,33 @@ # can never share coverage with a metro node and will always produce solo arcs. _TOWERS_SOLO_US = [ # Great Plains / High Plains - (44.07500, -103.22830, 1100, 551_000_000, "KEVN-Rapid City"), # SD - (46.87190, -113.99300, 1050, 563_000_000, "KPAX-Missoula"), # MT - (43.03540, -108.05270, 1000, 539_000_000, "KCWY-Casper"), # WY - (48.23000, -101.29600, 980, 575_000_000, "KMOT-Minot"), # ND - (38.81130, -99.32640, 960, 551_000_000, "KAYS-Hays"), # KS central - (32.44180, -104.22840, 900, 563_000_000, "KCAV-Carlsbad"), # NM SE + (44.07500, -103.22830, 1100, 551_000_000, "KEVN-Rapid City"), # SD + (46.87190, -113.99300, 1050, 563_000_000, "KPAX-Missoula"), # MT + (43.03540, -108.05270, 1000, 539_000_000, "KCWY-Casper"), # WY + (48.23000, -101.29600, 980, 575_000_000, "KMOT-Minot"), # ND + (38.81130, -99.32640, 960, 551_000_000, "KAYS-Hays"), # KS central + (32.44180, -104.22840, 900, 563_000_000, "KCAV-Carlsbad"), # NM SE # Desert Southwest - (35.19900, -111.65100, 1200, 563_000_000, "KNAZ-Flagstaff"), # AZ - (40.83870, -115.76270, 920, 539_000_000, "KELK-Elko"), # NV - (31.87220, -106.42920, 880, 539_000_000, "KTSM-El Paso"), # TX border - (36.90000, -111.50000, 1100, 539_000_000, "KPGE-Page-AZ"), # AZ NE - (39.50000, -119.80000, 950, 551_000_000, "KRNV-Reno"), # NV + (35.19900, -111.65100, 1200, 563_000_000, "KNAZ-Flagstaff"), # AZ + (40.83870, -115.76270, 920, 539_000_000, "KELK-Elko"), # NV + (31.87220, -106.42920, 880, 539_000_000, "KTSM-El Paso"), # TX border + (36.90000, -111.50000, 1100, 539_000_000, "KPGE-Page-AZ"), # AZ NE + (39.50000, -119.80000, 950, 551_000_000, "KRNV-Reno"), # NV # South / Central - (34.74020, -92.28990, 920, 563_000_000, "KATV-Little Rock"), # AR - (37.68610, -97.33010, 940, 551_000_000, "KWCH-Wichita"), # KS + (34.74020, -92.28990, 920, 563_000_000, "KATV-Little Rock"), # AR + (37.68610, -97.33010, 940, 551_000_000, "KWCH-Wichita"), # KS # Pacific NW interior - (42.55000, -114.46000, 980, 575_000_000, "KXTF-Twin Falls"), # ID + (42.55000, -114.46000, 980, 575_000_000, "KXTF-Twin Falls"), # ID # Rural Midwest / Great Lakes - (46.48730, -84.35670, 900, 563_000_000, "KBSF-Sault Ste Marie"),# MI UP - (46.78650, -92.10350, 940, 551_000_000, "KDLH-Duluth"), # MN - (47.92500, -97.03260, 920, 563_000_000, "WDAY-Fargo"), # ND - (43.54960, -96.72960, 930, 539_000_000, "KSFY-Sioux Falls"), # SD - (46.37000, -94.87000, 920, 539_000_000, "KBRJ-Brainerd"), # MN lakes - (45.00000, -85.50000, 880, 563_000_000, "WPBN-Traverse"), # MI north + (46.48730, -84.35670, 900, 563_000_000, "KBSF-Sault Ste Marie"), # MI UP + (46.78650, -92.10350, 940, 551_000_000, "KDLH-Duluth"), # MN + (47.92500, -97.03260, 920, 563_000_000, "WDAY-Fargo"), # ND + (43.54960, -96.72960, 930, 539_000_000, "KSFY-Sioux Falls"), # SD + (46.37000, -94.87000, 920, 539_000_000, "KBRJ-Brainerd"), # MN lakes + (45.00000, -85.50000, 880, 563_000_000, "WPBN-Traverse"), # MI north # East - (44.06000, -76.15000, 850, 563_000_000, "WWTI-Watertown"), # NY - (37.30000, -79.50000, 880, 563_000_000, "WSLS-Roanoke"), # VA + (44.06000, -76.15000, 850, 563_000_000, "WWTI-Watertown"), # NY + (37.30000, -79.50000, 880, 563_000_000, "WSLS-Roanoke"), # VA ] @@ -149,11 +149,11 @@ # the core for a non-degenerate bistatic angle. Cores reuse world._US_WAYPOINTS. # (tx_lat, tx_lon, tx_alt_ft, fc_hz, callsign, core_lat, core_lon) _RING_TXS = [ - (32.78060, -96.80060, 1600, 195_000_000, "WFAA-RING", 32.8968, -97.0380), # DFW - (41.87810, -87.62980, 1500, 197_000_000, "WMAQ-RING", 41.9742, -87.9073), # ORD Chicago - (33.74900, -84.38800, 1050, 199_000_000, "WSB-RING", 33.6407, -84.4277), # ATL - (39.73920, -104.99030, 5300, 201_000_000, "KCNC-RING", 39.8561, -104.6737), # DEN - (39.09970, -94.57860, 900, 203_000_000, "KMBC-RING", 39.2976, -94.7139), # MCI Kansas City + (32.78060, -96.80060, 1600, 195_000_000, "WFAA-RING", 32.8968, -97.0380), # DFW + (41.87810, -87.62980, 1500, 197_000_000, "WMAQ-RING", 41.9742, -87.9073), # ORD Chicago + (33.74900, -84.38800, 1050, 199_000_000, "WSB-RING", 33.6407, -84.4277), # ATL + (39.73920, -104.99030, 5300, 201_000_000, "KCNC-RING", 39.8561, -104.6737), # DEN + (39.09970, -94.57860, 900, 203_000_000, "KMBC-RING", 39.2976, -94.7139), # MCI Kansas City ] @@ -162,9 +162,7 @@ def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: R = 6371.0 dlat = math.radians(lat2 - lat1) dlon = math.radians(lon2 - lon1) - a = (math.sin(dlat / 2) ** 2 - + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) - * math.sin(dlon / 2) ** 2) + a = math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2 return R * 2 * math.asin(math.sqrt(max(0.0, min(1.0, a)))) @@ -176,20 +174,19 @@ def _bearing_between(lat1: float, lon1: float, lat2: float, lon2: float) -> floa dlon = math.radians(lon2 - lon1) lat1r, lat2r = math.radians(lat1), math.radians(lat2) x = math.sin(dlon) * math.cos(lat2r) - y = (math.cos(lat1r) * math.sin(lat2r) - - math.sin(lat1r) * math.cos(lat2r) * math.cos(dlon)) + y = math.cos(lat1r) * math.sin(lat2r) - math.sin(lat1r) * math.cos(lat2r) * math.cos(dlon) return math.degrees(math.atan2(x, y)) % 360 # Rural US bounding boxes used when the named solo pool needs extending. # Each box: (lat_min, lat_max, lon_min, lon_max) _RURAL_BOXES_US = [ - (38.0, 49.0, -116.0, -96.0), # Northern Great Plains (MT/ND/SD/NE/WY) - (32.0, 42.0, -108.0, -96.0), # Southern Great Plains (KS/OK/TX panhandle/NM) + (38.0, 49.0, -116.0, -96.0), # Northern Great Plains (MT/ND/SD/NE/WY) + (32.0, 42.0, -108.0, -96.0), # Southern Great Plains (KS/OK/TX panhandle/NM) (32.0, 42.0, -117.0, -108.0), # Desert Southwest (AZ/NV/UT/western NM) (40.0, 50.0, -122.0, -111.0), # Pacific NW interior (eastern OR/WA/ID) (35.0, 46.0, -111.0, -103.0), # Rockies (CO/WY/MT eastern slope) - (38.0, 47.0, -99.0, -88.0), # Rural Midwest (IA/MN/WI/IL away from cities) + (38.0, 47.0, -99.0, -88.0), # Rural Midwest (IA/MN/WI/IL away from cities) ] _RURAL_FREQS = [539_000_000, 551_000_000, 563_000_000, 575_000_000, 585_000_000] @@ -211,7 +208,7 @@ def _extend_solo_pool( Returns the (possibly extended) pool. """ pool = [] - busy = list(avoid_positions) # positions already taken + busy = list(avoid_positions) # positions already taken # Gate named entries — apply the same minimum-separation rule so named # towers that are too close to each other or to metro positions are skipped. @@ -245,6 +242,7 @@ def _extend_solo_pool( @dataclass class GeneratedNodeConfig: """A generated node in the fleet.""" + node_id: str rx_lat: float rx_lon: float @@ -258,7 +256,7 @@ class GeneratedNodeConfig: max_range_km: float = 50.0 region: str = "us" tx_callsign: str = "" - beam_azimuth_deg: float | None = None # explicit Yagi aim; None → broadside + beam_azimuth_deg: float | None = None # explicit Yagi aim; None → broadside def _node_dict(node: GeneratedNodeConfig) -> dict: @@ -311,7 +309,7 @@ def _node_display_fuzz(node_id: str) -> tuple[float, float]: _WATER_BOXES: list[tuple[float, float, float, float]] = [ # (lat_min, lat_max, lon_min, lon_max) # Great Lakes - (41.5, 49.0, -92.5, -76.0), # approximate Great Lakes bounding box + (41.5, 49.0, -92.5, -76.0), # approximate Great Lakes bounding box # Gulf of Mexico (open water — broad nearshore strip) (18.0, 30.5, -98.0, -80.0), # Atlantic east of Florida / Florida Straits / Bahamas @@ -356,113 +354,113 @@ def _node_display_fuzz(node_id: str) -> tuple[float, float]: # If an RX position is within 5 km of a land point, it's allowed. _COASTAL_LAND_POINTS: list[tuple[float, float]] = [ # Great Lakes cities - (41.88, -87.63), # Chicago - (42.33, -83.05), # Detroit - (41.50, -81.69), # Cleveland - (43.16, -79.24), # Niagara Falls - (42.89, -78.88), # Buffalo - (44.98, -93.27), # Minneapolis - (43.04, -87.91), # Milwaukee - (42.96, -85.67), # Grand Rapids - (46.79, -92.10), # Duluth - (43.05, -89.40), # Madison WI - (44.51, -88.01), # Green Bay WI - (42.96, -82.45), # Port Huron MI - (44.27, -85.60), # Cadillac MI - (42.26, -85.59), # Kalamazoo MI - (41.66, -83.56), # Toledo OH / Maumee Bay - (43.96, -77.96), # Oswego NY - (44.70, -75.48), # Ogdensburg NY - (43.45, -76.51), # Oswego / Pulaski NY + (41.88, -87.63), # Chicago + (42.33, -83.05), # Detroit + (41.50, -81.69), # Cleveland + (43.16, -79.24), # Niagara Falls + (42.89, -78.88), # Buffalo + (44.98, -93.27), # Minneapolis + (43.04, -87.91), # Milwaukee + (42.96, -85.67), # Grand Rapids + (46.79, -92.10), # Duluth + (43.05, -89.40), # Madison WI + (44.51, -88.01), # Green Bay WI + (42.96, -82.45), # Port Huron MI + (44.27, -85.60), # Cadillac MI + (42.26, -85.59), # Kalamazoo MI + (41.66, -83.56), # Toledo OH / Maumee Bay + (43.96, -77.96), # Oswego NY + (44.70, -75.48), # Ogdensburg NY + (43.45, -76.51), # Oswego / Pulaski NY # Gulf Coast cities - (29.76, -95.36), # Houston - (30.27, -97.74), # Austin - (30.45, -91.19), # Baton Rouge - (30.00, -90.07), # New Orleans - (27.95, -82.46), # Tampa - (25.76, -80.19), # Miami - (28.54, -81.38), # Orlando - (30.33, -81.66), # Jacksonville - (27.77, -82.64), # St. Petersburg - (29.42, -98.49), # San Antonio - (30.39, -87.69), # Pensacola - (30.22, -92.02), # Lafayette - (29.95, -90.07), # New Orleans Lakeshore - (30.69, -88.04), # Mobile AL - (29.70, -95.01), # Pasadena TX - (29.55, -95.13), # League City TX + (29.76, -95.36), # Houston + (30.27, -97.74), # Austin + (30.45, -91.19), # Baton Rouge + (30.00, -90.07), # New Orleans + (27.95, -82.46), # Tampa + (25.76, -80.19), # Miami + (28.54, -81.38), # Orlando + (30.33, -81.66), # Jacksonville + (27.77, -82.64), # St. Petersburg + (29.42, -98.49), # San Antonio + (30.39, -87.69), # Pensacola + (30.22, -92.02), # Lafayette + (29.95, -90.07), # New Orleans Lakeshore + (30.69, -88.04), # Mobile AL + (29.70, -95.01), # Pasadena TX + (29.55, -95.13), # League City TX # Tampa Bay shores (box: 27.35-28.1°N, -82.85 to -82.4°W) # NOTE: only inland / peninsula cities — do NOT add right-on-shore suburbs # (Ruskin, Apollo Beach, Gibsonton) because at any positive radius their # circle extends into the bay and exempts mid-bay positions. - (27.97, -82.80), # Clearwater FL - (28.02, -82.77), # Dunedin FL - (27.99, -82.69), # Safety Harbor FL - (27.94, -82.29), # Brandon FL - (27.87, -82.33), # Riverview FL - (27.52, -82.57), # Palmetto FL - (27.50, -82.57), # Bradenton FL - (27.34, -82.54), # Sarasota FL + (27.97, -82.80), # Clearwater FL + (28.02, -82.77), # Dunedin FL + (27.99, -82.69), # Safety Harbor FL + (27.94, -82.29), # Brandon FL + (27.87, -82.33), # Riverview FL + (27.52, -82.57), # Palmetto FL + (27.50, -82.57), # Bradenton FL + (27.34, -82.54), # Sarasota FL # Charlotte Harbor shores (box: 26.5-27.1°N, -82.35 to -81.85°W) - (27.09, -82.43), # Venice FL (north edge) - (26.93, -82.05), # Port Charlotte FL - (26.63, -81.87), # Cape Coral FL (east) - (26.71, -81.93), # Punta Gorda FL + (27.09, -82.43), # Venice FL (north edge) + (26.93, -82.05), # Port Charlotte FL + (26.63, -81.87), # Cape Coral FL (east) + (26.71, -81.93), # Punta Gorda FL # Sarasota Bay (box: 27.1-27.55°N, -82.75 to -82.5°W) - (27.34, -82.54), # Sarasota FL (already above, reuses) - (27.48, -82.57), # North Port FL + (27.34, -82.54), # Sarasota FL (already above, reuses) + (27.48, -82.57), # North Port FL # Lake Pontchartrain shores (box: 30.05-30.45°N, -90.55 to -89.65°W) - (30.07, -89.93), # Slidell LA (east shore) - (30.43, -90.10), # Mandeville LA (north shore) - (30.20, -90.23), # Metairie / Kenner LA (south shore) - (30.18, -89.75), # Bay St. Louis MS + (30.07, -89.93), # Slidell LA (east shore) + (30.43, -90.10), # Mandeville LA (north shore) + (30.20, -90.23), # Metairie / Kenner LA (south shore) + (30.18, -89.75), # Bay St. Louis MS # Corpus Christi Bay (box: 27.7-27.95°N, -97.5 to -97.05°W) - (27.80, -97.40), # Corpus Christi TX - (27.73, -97.14), # Portland TX - (27.86, -97.08), # Ingleside TX + (27.80, -97.40), # Corpus Christi TX + (27.73, -97.14), # Portland TX + (27.86, -97.08), # Ingleside TX # Matagorda Bay (box: 28.45-28.8°N, -96.75 to -96.15°W) - (28.69, -96.00), # El Campo / Bay City TX - (28.60, -96.10), # Palacios TX - (28.72, -96.67), # Bay City area + (28.69, -96.00), # El Campo / Bay City TX + (28.60, -96.10), # Palacios TX + (28.72, -96.67), # Bay City area # Pamlico / Albemarle Sound (box: 35.0-36.1°N, -76.85 to -75.65°W) - (35.54, -77.07), # Greenville NC (west) - (35.10, -76.89), # New Bern NC (southwest) - (36.07, -76.77), # Elizabeth City NC (north) - (36.00, -75.68), # Kill Devil Hills / OBX NC (east shore) - (35.26, -75.71), # Ocracoke Island NC (southeast) + (35.54, -77.07), # Greenville NC (west) + (35.10, -76.89), # New Bern NC (southwest) + (36.07, -76.77), # Elizabeth City NC (north) + (36.00, -75.68), # Kill Devil Hills / OBX NC (east shore) + (35.26, -75.71), # Ocracoke Island NC (southeast) # Florida Atlantic coast cities (for FL Atlantic water box) - (26.12, -80.14), # Fort Lauderdale FL - (26.36, -80.08), # Boca Raton FL - (26.72, -80.05), # West Palm Beach FL - (27.20, -80.25), # Stuart / Treasure Coast FL - (27.64, -80.40), # Vero Beach FL - (28.08, -80.61), # Melbourne / Brevard County FL - (28.45, -80.79), # Cocoa / Rockledge FL - (28.61, -80.82), # Titusville / Merritt Island FL - (29.03, -80.93), # New Smyrna Beach FL - (29.21, -81.00), # Daytona Beach FL - (29.89, -81.31), # St. Augustine FL - (30.28, -81.39), # Jacksonville Beach FL + (26.12, -80.14), # Fort Lauderdale FL + (26.36, -80.08), # Boca Raton FL + (26.72, -80.05), # West Palm Beach FL + (27.20, -80.25), # Stuart / Treasure Coast FL + (27.64, -80.40), # Vero Beach FL + (28.08, -80.61), # Melbourne / Brevard County FL + (28.45, -80.79), # Cocoa / Rockledge FL + (28.61, -80.82), # Titusville / Merritt Island FL + (29.03, -80.93), # New Smyrna Beach FL + (29.21, -81.00), # Daytona Beach FL + (29.89, -81.31), # St. Augustine FL + (30.28, -81.39), # Jacksonville Beach FL # Eastern seaboard - (38.91, -77.04), # Washington DC - (39.95, -75.16), # Philadelphia - (40.71, -74.01), # New York - (42.36, -71.06), # Boston - (36.85, -75.98), # Norfolk - (32.78, -79.93), # Charleston SC - (34.22, -77.91), # Wilmington NC - (33.45, -75.96), # outer banks NC (off coast; excluded) - (38.32, -75.09), # Ocean City MD - (39.94, -74.07), # Toms River NJ - (40.92, -72.64), # Long Island NY (east) - (41.27, -72.89), # New Haven CT - (41.46, -71.31), # Providence RI - (43.66, -70.25), # Portland ME - (44.80, -68.77), # Bangor ME - (44.42, -73.14), # Burlington VT (Lake Champlain) - (43.09, -76.15), # Syracuse NY - (42.45, -76.51), # Ithaca NY - (44.18, -76.49), # Kingston ON / Wolfe Island + (38.91, -77.04), # Washington DC + (39.95, -75.16), # Philadelphia + (40.71, -74.01), # New York + (42.36, -71.06), # Boston + (36.85, -75.98), # Norfolk + (32.78, -79.93), # Charleston SC + (34.22, -77.91), # Wilmington NC + (33.45, -75.96), # outer banks NC (off coast; excluded) + (38.32, -75.09), # Ocean City MD + (39.94, -74.07), # Toms River NJ + (40.92, -72.64), # Long Island NY (east) + (41.27, -72.89), # New Haven CT + (41.46, -71.31), # Providence RI + (43.66, -70.25), # Portland ME + (44.80, -68.77), # Bangor ME + (44.42, -73.14), # Burlington VT (Lake Champlain) + (43.09, -76.15), # Syracuse NY + (42.45, -76.51), # Ithaca NY + (44.18, -76.49), # Kingston ON / Wolfe Island # Pacific coast (34.05, -118.24), # Los Angeles (37.77, -122.42), # San Francisco @@ -519,15 +517,17 @@ def _get_land_check(): data_dir = Path(__file__).parent / "data" try: + def _load(name): with open(data_dir / name) as f: return [shape(feat["geometry"]) for feat in json.load(f)["features"]] + land = _load("ne_10m_land.geojson") lakes = _load("ne_50m_lakes.geojson") except (OSError, ValueError) as exc: logging.getLogger(__name__).warning( - "Natural Earth data unavailable (%s); water-rejection falls back " - "to bounding boxes.", exc, + "Natural Earth data unavailable (%s); water-rejection falls back to bounding boxes.", + exc, ) return None @@ -584,8 +584,10 @@ def _candidate_is_safe( def _place_rx_on_land( - tx_lat: float, tx_lon: float, - dist_min_km: float = 5.0, dist_max_km: float = 40.0, + tx_lat: float, + tx_lon: float, + dist_min_km: float = 5.0, + dist_max_km: float = 40.0, max_attempts: int = 80, display_node_id: str | None = None, ) -> tuple[float, float]: @@ -595,9 +597,7 @@ def _place_rx_on_land( distance_km = random.uniform(dist_min_km, dist_max_km) bearing_rad = random.uniform(0, 2 * math.pi) dlat = (distance_km * math.cos(bearing_rad)) / R - dlon = (distance_km * math.sin(bearing_rad)) / ( - R * math.cos(math.radians(tx_lat)) - ) + dlon = (distance_km * math.sin(bearing_rad)) / (R * math.cos(math.radians(tx_lat))) rx_lat = tx_lat + math.degrees(dlat) rx_lon = tx_lon + math.degrees(dlon) if _candidate_is_safe(rx_lat, rx_lon, display_node_id): @@ -606,9 +606,7 @@ def _place_rx_on_land( for bearing_deg in range(0, 360, 15): bearing_rad = math.radians(bearing_deg) dlat = (step_km * math.cos(bearing_rad)) / R - dlon = (step_km * math.sin(bearing_rad)) / ( - R * math.cos(math.radians(tx_lat)) - ) + dlon = (step_km * math.sin(bearing_rad)) / (R * math.cos(math.radians(tx_lat))) rx_lat = tx_lat + math.degrees(dlat) rx_lon = tx_lon + math.degrees(dlon) if _candidate_is_safe(rx_lat, rx_lon, display_node_id): @@ -653,16 +651,15 @@ def _generate_coverage_ring( for i in range(n): bearing_rad = math.radians((start_bearing_deg + 360.0 * i / n) % 360.0) dlat = (radius_km * math.cos(bearing_rad)) / R - dlon = (radius_km * math.sin(bearing_rad)) / ( - R * math.cos(math.radians(core_lat)) - ) + dlon = (radius_km * math.sin(bearing_rad)) / (R * math.cos(math.radians(core_lat))) rx_lat = core_lat + math.degrees(dlat) rx_lon = core_lon + math.degrees(dlon) node_id = f"{prefix}-{i + 1:04d}" if not _candidate_is_safe(rx_lat, rx_lon, node_id): rx_lat, rx_lon = _place_rx_on_land( - core_lat, core_lon, + core_lat, + core_lon, dist_min_km=max(5.0, radius_km - 5), dist_max_km=radius_km + 5, display_node_id=node_id, @@ -729,14 +726,16 @@ def coverage_cells( cells = [] for ring_id, spec, size in _active_rings(n_cluster, n_clusters, ring_spec): tx_lat, tx_lon, tx_alt_ft, fc_hz, callsign, core_lat, core_lon = spec - cells.append({ - "ring_id": ring_id, - "core_lat": core_lat, - "core_lon": core_lon, - "radius_km": traffic_radius_km, - "ops_weight": float(size), - "illuminator": callsign, - }) + cells.append( + { + "ring_id": ring_id, + "core_lat": core_lat, + "core_lon": core_lon, + "radius_km": traffic_radius_km, + "ops_weight": float(size), + "illuminator": callsign, + } + ) return cells @@ -820,8 +819,10 @@ def generate_fleet( # Each metro area gets multiple real towers so nodes in the same city # use DIFFERENT transmitters instead of all sharing the same one. metro_api_towers: dict[str, list[dict]] = {} + def _cache_key(lat, lon): return f"{lat:.4f},{lon:.4f}" + if use_tower_api: try: try: @@ -840,6 +841,7 @@ def _cache_key(lat, lon): metro_api_towers[key] = metro_api_towers_raw[key] except Exception as exc: import logging + logging.warning("Tower API lookup failed, using hardcoded towers: %s", exc) # Allocate solo and cluster node counts, carving both from metro allocation @@ -910,9 +912,7 @@ def _cache_key(lat, lon): # also enforced to be at least min_sep_km from each other. # 400 km > 2 × 140 km fleet range → solo coverage circles never # touch each other or any metro cluster, making them visually isolated. - us_metro_occ: list[tuple[float, float]] = [ - (t[0], t[1]) for t in _TOWERS_US - ] + us_metro_occ: list[tuple[float, float]] = [(t[0], t[1]) for t in _TOWERS_US] solo_pool = _extend_solo_pool( list(solo_towers), n_solo, @@ -922,7 +922,7 @@ def _cache_key(lat, lon): random.shuffle(solo_pool) for j in range(min(n_solo, len(solo_pool))): - tower = solo_pool[j] # strict: never re-use a tower index + tower = solo_pool[j] # strict: never re-use a tower index tx_lat, tx_lon, tx_alt_ft, fc_hz, callsign = tower node_id = f"synth-SOLO-{j + 1:04d}" @@ -964,18 +964,20 @@ def _cache_key(lat, lon): ring_nodes = [] for ring_id, spec, size in _active_rings(n_cluster, n_clusters, ring_spec): tx_lat, tx_lon, tx_alt_ft, fc_hz, callsign, core_lat, core_lon = spec - ring_nodes.extend(_generate_coverage_ring( - n=size, - core_lat=core_lat, - core_lon=core_lon, - tx_tower=(tx_lat, tx_lon, tx_alt_ft, fc_hz, callsign), - prefix=ring_id, - radius_km=ring_radius_km, - beam_width_deg=ring_beam_width_deg, - max_range_km=ring_max_range_km, - aim=ring_aim, - )) - nodes = ring_nodes + nodes # prepend so ring IDs are first + ring_nodes.extend( + _generate_coverage_ring( + n=size, + core_lat=core_lat, + core_lon=core_lon, + tx_tower=(tx_lat, tx_lon, tx_alt_ft, fc_hz, callsign), + prefix=ring_id, + radius_km=ring_radius_km, + beam_width_deg=ring_beam_width_deg, + max_range_km=ring_max_range_km, + aim=ring_aim, + ) + ) + nodes = ring_nodes + nodes # prepend so ring IDs are first return nodes @@ -983,6 +985,7 @@ def _cache_key(lat, lon): def fleet_summary(nodes: list[dict]) -> dict: """Compute a summary of the fleet configuration.""" from collections import Counter + regions = Counter(n["region"] for n in nodes) towers = Counter(n["tx_callsign"] for n in nodes) return { @@ -1007,27 +1010,50 @@ def main(): parser.add_argument("--regions", type=str, default="us", help="Comma-separated regions: us,eu,au") parser.add_argument("--output", type=str, default="fleet_config.json", help="Output file path") parser.add_argument("--seed", type=int, default=42, help="Random seed") - parser.add_argument("--n-cluster", "--n-ring", dest="n_cluster", type=int, default=30, - help="Total coverage-ring receivers (split across rings)") - parser.add_argument("--n-clusters", "--n-rings", dest="n_clusters", type=int, default=5, - help="Number of metro coverage rings (more = multinode spread across the map)") - parser.add_argument("--ring-radius-km", type=float, default=18.0, - help="Receiver ring radius around each metro core") - parser.add_argument("--ring-beam-width-deg", type=float, default=50.0, - help="Yagi half-power beamwidth for ring receivers") - parser.add_argument("--ring-max-range-km", type=float, default=60.0, - help="Detection range for ring receivers") - parser.add_argument("--ring-aim", type=str, default="core", choices=["core", "broadside"], - help="Aim ring beams at the metro core or broadside to TX") + parser.add_argument( + "--n-cluster", + "--n-ring", + dest="n_cluster", + type=int, + default=30, + help="Total coverage-ring receivers (split across rings)", + ) + parser.add_argument( + "--n-clusters", + "--n-rings", + dest="n_clusters", + type=int, + default=5, + help="Number of metro coverage rings (more = multinode spread across the map)", + ) + parser.add_argument( + "--ring-radius-km", type=float, default=18.0, help="Receiver ring radius around each metro core" + ) + parser.add_argument( + "--ring-beam-width-deg", type=float, default=50.0, help="Yagi half-power beamwidth for ring receivers" + ) + parser.add_argument("--ring-max-range-km", type=float, default=60.0, help="Detection range for ring receivers") + parser.add_argument( + "--ring-aim", + type=str, + default="core", + choices=["core", "broadside"], + help="Aim ring beams at the metro core or broadside to TX", + ) args = parser.parse_args() regions = [r.strip().lower() for r in args.regions.split(",")] - nodes = generate_fleet(n_nodes=args.nodes, regions=regions, seed=args.seed, - n_cluster=args.n_cluster, n_clusters=args.n_clusters, - ring_radius_km=args.ring_radius_km, - ring_beam_width_deg=args.ring_beam_width_deg, - ring_max_range_km=args.ring_max_range_km, - ring_aim=args.ring_aim) + nodes = generate_fleet( + n_nodes=args.nodes, + regions=regions, + seed=args.seed, + n_cluster=args.n_cluster, + n_clusters=args.n_clusters, + ring_radius_km=args.ring_radius_km, + ring_beam_width_deg=args.ring_beam_width_deg, + ring_max_range_km=args.ring_max_range_km, + ring_aim=args.ring_aim, + ) cells = coverage_cells(n_cluster=args.n_cluster, n_clusters=args.n_clusters) summary = fleet_summary(nodes) diff --git a/retina_simulation/node.py b/retina_simulation/node.py index d9cda98..5356ac6 100644 --- a/retina_simulation/node.py +++ b/retina_simulation/node.py @@ -55,6 +55,7 @@ @dataclass class NodeConfig: """Passive radar node configuration.""" + node_id: str = "synth-node-01" rx_lat: float = 33.939182 rx_lon: float = -84.651910 @@ -79,6 +80,7 @@ def _config_hash(config: NodeConfig) -> str: @dataclass class SyntheticTarget: """A simulated moving target in ENU coordinates (km) relative to RX.""" + target_id: str # Position (ENU km) east: float @@ -167,8 +169,12 @@ def __init__(self, config: NodeConfig, mode: str = "detection"): tx_alt_m = config.tx_alt_ft * 0.3048 self.rx_enu = (0.0, 0.0, 0.0) self.tx_enu = _lla_to_enu( - config.tx_lat, config.tx_lon, tx_alt_m, - config.rx_lat, config.rx_lon, rx_alt_m, + config.tx_lat, + config.tx_lon, + tx_alt_m, + config.rx_lat, + config.rx_lon, + rx_alt_m, ) self.rx_alt_m = rx_alt_m @@ -239,9 +245,7 @@ def _target_detection(self, target: SyntheticTarget) -> dict: vel = (target.vel_east, target.vel_north, target.vel_up) delay = _bistatic_delay(pos, self.tx_enu, self.rx_enu) - doppler = _bistatic_doppler( - pos, vel, self.tx_enu, self.rx_enu, self.config.fc_hz - ) + doppler = _bistatic_doppler(pos, vel, self.tx_enu, self.rx_enu, self.config.fc_hz) # Add measurement noise delay += random.gauss(0, 0.1) # ~0.1 μs noise (GPS-disciplined SDR) @@ -268,8 +272,12 @@ def _make_adsb_entry(self, target: SyntheticTarget) -> dict | None: # Convert ENU to LLA for ADS-B position lat, lon, alt_m = _enu_to_lla( - target.east, target.north, target.up, - self.config.rx_lat, self.config.rx_lon, self.rx_alt_m, + target.east, + target.north, + target.up, + self.config.rx_lat, + self.config.rx_lon, + self.rx_alt_m, ) speed_ms = _norm([target.vel_east * 1000, target.vel_north * 1000, 0]) @@ -294,10 +302,7 @@ def generate_frame(self, timestamp_ms: int) -> dict: # Manage target lifecycle # Remove expired targets - self.targets = [ - t for t in self.targets - if (now - t.created_at) < t.lifetime_s - ] + self.targets = [t for t in self.targets if (now - t.created_at) < t.lifetime_s] # Spawn new targets to maintain 3-8 active while len(self.targets) < 3: @@ -318,14 +323,14 @@ def generate_frame(self, timestamp_ms: int) -> dict: # Add some clutter/noise detections (false alarms) n_clutter = random.randint(0, 5) for _ in range(n_clutter): - detections.append({ - "delay": round(random.uniform(0, 60), 2), - "doppler": round(random.uniform( - self.config.doppler_min, self.config.doppler_max - ), 2), - "snr": round(random.uniform(4, 7), 2), - "_target": None, - }) + detections.append( + { + "delay": round(random.uniform(0, 60), 2), + "doppler": round(random.uniform(self.config.doppler_min, self.config.doppler_max), 2), + "snr": round(random.uniform(4, 7), 2), + "_target": None, + } + ) # Build output frame delays = [d["delay"] for d in detections] @@ -356,8 +361,8 @@ def generate_frame(self, timestamp_ms: int) -> dict: # ── TCP connection helpers ──────────────────────────────────────────────────── -def _connect_tcp(host: str, port: int, max_retries: int = 0, - cloudflare_host: str | None = None) -> socket.socket: + +def _connect_tcp(host: str, port: int, max_retries: int = 0, cloudflare_host: str | None = None) -> socket.socket: """Connect to the tracker server via TCP with retry logic. If cloudflare_host is provided, the connection is made to the Cloudflare @@ -382,10 +387,9 @@ def _connect_tcp(host: str, port: int, max_retries: int = 0, attempt += 1 if 0 < max_retries <= attempt: raise - wait = min(2 ** attempt, 30) + wait = min(2**attempt, 30) print( - f"Connection to {connect_host}:{port} failed ({exc}), " - f"retrying in {wait}s...", + f"Connection to {connect_host}:{port} failed ({exc}), retrying in {wait}s...", file=sys.stderr, ) time.sleep(wait) @@ -416,8 +420,8 @@ def _recv_msg(sock: socket.socket, timeout: float = CONFIG_ACK_TIMEOUT_S) -> dic # ── Protocol handshake ──────────────────────────────────────────────────────── -def _perform_handshake(sock: socket.socket, config: NodeConfig, - crypto_backend=None) -> bool: + +def _perform_handshake(sock: socket.socket, config: NodeConfig, crypto_backend=None) -> bool: """Perform the RETINA TCP handshake: HELLO → CONFIG → REGISTER_KEY → wait ACKs. If crypto_backend is provided, also registers the public key for @@ -430,29 +434,35 @@ def _perform_handshake(sock: socket.socket, config: NodeConfig, is_synthetic = config.node_id.startswith("synth-") or config.node_id.startswith("syn-") # 1. Send HELLO with capabilities - _send_msg(sock, { - "type": "HELLO", - "node_id": config.node_id, - "version": RETINA_VERSION, - "is_synthetic": is_synthetic, - "capabilities": { - "detection": True, - "adsb_correlation": True, - "doppler": True, - "config_hash": True, - "heartbeat": True, - "chain_of_custody": crypto_backend is not None, + _send_msg( + sock, + { + "type": "HELLO", + "node_id": config.node_id, + "version": RETINA_VERSION, + "is_synthetic": is_synthetic, + "capabilities": { + "detection": True, + "adsb_correlation": True, + "doppler": True, + "config_hash": True, + "heartbeat": True, + "chain_of_custody": crypto_backend is not None, + }, }, - }) + ) print(f" → HELLO (version={RETINA_VERSION}, synthetic={is_synthetic})", file=sys.stderr) # 2. Send CONFIG - _send_msg(sock, { - "type": "CONFIG", - "node_id": config.node_id, - "config_hash": cfg_hash, - "config": cfg_payload, - }) + _send_msg( + sock, + { + "type": "CONFIG", + "node_id": config.node_id, + "config_hash": cfg_hash, + "config": cfg_payload, + }, + ) print(f" → CONFIG (hash={cfg_hash})", file=sys.stderr) # 3. Wait for CONFIG_ACK @@ -471,12 +481,15 @@ def _perform_handshake(sock: socket.socket, config: NodeConfig, print(f" ← unexpected message: {ack.get('type', '?')}", file=sys.stderr) else: print(f" ! CONFIG_ACK timeout (attempt {attempt + 1}/3), retransmitting CONFIG...", file=sys.stderr) - _send_msg(sock, { - "type": "CONFIG", - "node_id": config.node_id, - "config_hash": cfg_hash, - "config": cfg_payload, - }) + _send_msg( + sock, + { + "type": "CONFIG", + "node_id": config.node_id, + "config_hash": cfg_hash, + "config": cfg_payload, + }, + ) if not config_acked: print(" ! Handshake failed after 3 attempts", file=sys.stderr) @@ -484,14 +497,17 @@ def _perform_handshake(sock: socket.socket, config: NodeConfig, # 4. Register public key (chain of custody) if crypto_backend is not None: - _send_msg(sock, { - "type": "REGISTER_KEY", - "node_id": config.node_id, - "public_key_pem": crypto_backend.get_public_key_pem(), - "fingerprint": crypto_backend.get_public_key_fingerprint(), - "serial_number": crypto_backend.get_serial_number(), - "signing_mode": crypto_backend.signing_mode, - }) + _send_msg( + sock, + { + "type": "REGISTER_KEY", + "node_id": config.node_id, + "public_key_pem": crypto_backend.get_public_key_pem(), + "fingerprint": crypto_backend.get_public_key_fingerprint(), + "serial_number": crypto_backend.get_serial_number(), + "signing_mode": crypto_backend.signing_mode, + }, + ) print(f" → REGISTER_KEY (fp={crypto_backend.get_public_key_fingerprint()[:12]}...)", file=sys.stderr) # Wait for KEY_ACK (non-blocking — best effort) key_ack = _recv_msg(sock, timeout=5) @@ -505,24 +521,29 @@ def _perform_handshake(sock: socket.socket, config: NodeConfig, # ── Heartbeat thread ────────────────────────────────────────────────────────── + def _heartbeat_loop(sock: socket.socket, config: NodeConfig, stop_event: threading.Event): """Send periodic heartbeats on a background thread.""" cfg_hash = _config_hash(config) while not stop_event.wait(HEARTBEAT_INTERVAL_S): try: - _send_msg(sock, { - "type": "HEARTBEAT", - "node_id": config.node_id, - "timestamp": datetime.now(timezone.utc).isoformat(), - "config_hash": cfg_hash, - "status": "active", - }) + _send_msg( + sock, + { + "type": "HEARTBEAT", + "node_id": config.node_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "config_hash": cfg_hash, + "status": "active", + }, + ) except (BrokenPipeError, ConnectionResetError, OSError): break # main loop will handle reconnection # ── Server message listener ────────────────────────────────────────────────── + def _listener_loop(sock: socket.socket, config: NodeConfig, stop_event: threading.Event): """Listen for server messages (CONFIG_REQUEST, etc.) on a background thread.""" sock.settimeout(1.0) @@ -542,12 +563,15 @@ def _listener_loop(sock: socket.socket, config: NodeConfig, stop_event: threadin if msg.get("type") == "CONFIG_REQUEST": print("\n ← CONFIG_REQUEST — resending config", file=sys.stderr) try: - _send_msg(sock, { - "type": "CONFIG", - "node_id": config.node_id, - "config_hash": _config_hash(config), - "config": asdict(config), - }) + _send_msg( + sock, + { + "type": "CONFIG", + "node_id": config.node_id, + "config_hash": _config_hash(config), + "config": asdict(config), + }, + ) except (BrokenPipeError, ConnectionResetError, OSError): break except TimeoutError: @@ -558,8 +582,10 @@ def _listener_loop(sock: socket.socket, config: NodeConfig, stop_event: threadin # ── Streaming modes ─────────────────────────────────────────────────────────── -def _stream_tcp(generator: SyntheticNodeGenerator, host: str, port: int, - interval_ms: int = 500, cloudflare_host: str | None = None): + +def _stream_tcp( + generator: SyntheticNodeGenerator, host: str, port: int, interval_ms: int = 500, cloudflare_host: str | None = None +): """Stream detection frames to the tracker server over TCP with full protocol. Includes chain of custody: signing, hash chain, TSA timestamping. @@ -582,7 +608,10 @@ def _stream_tcp(generator: SyntheticNodeGenerator, host: str, port: int, iq_buffer = IQCircularBuffer(is_synthetic=True) iq_buffer.start() - print(f" Chain of custody: key_fp={crypto.get_public_key_fingerprint()[:12]}... serial={crypto.get_serial_number()}", file=sys.stderr) + print( + f" Chain of custody: key_fp={crypto.get_public_key_fingerprint()[:12]}... serial={crypto.get_serial_number()}", + file=sys.stderr, + ) while True: sock = _connect_tcp(host, port, cloudflare_host=cloudflare_host) @@ -596,12 +625,8 @@ def _stream_tcp(generator: SyntheticNodeGenerator, host: str, port: int, # Start heartbeat and listener threads stop_event = threading.Event() - hb_thread = threading.Thread( - target=_heartbeat_loop, args=(sock, config, stop_event), daemon=True - ) - listener_thread = threading.Thread( - target=_listener_loop, args=(sock, config, stop_event), daemon=True - ) + hb_thread = threading.Thread(target=_heartbeat_loop, args=(sock, config, stop_event), daemon=True) + listener_thread = threading.Thread(target=_listener_loop, args=(sock, config, stop_event), daemon=True) hb_thread.start() listener_thread.start() @@ -643,27 +668,29 @@ def _stream_tcp(generator: SyntheticNodeGenerator, host: str, port: int, entry.ots_proof = ots_proof # Submit chain entry to server try: - _send_msg(sock, { - "type": "CHAIN_ENTRY", - "node_id": config.node_id, - "entry": entry.to_dict(), - }) + _send_msg( + sock, + { + "type": "CHAIN_ENTRY", + "node_id": config.node_id, + "entry": entry.to_dict(), + }, + ) print(f"\n → CHAIN_ENTRY (hour={entry.hour_utc}, n={entry.n_detections})", file=sys.stderr) except (BrokenPipeError, ConnectionResetError, OSError): pass # Print summary to stderr n_det = len(frame["delay"]) - n_adsb = sum( - 1 for a in frame.get("adsb", []) if a is not None - ) if "adsb" in frame else 0 + n_adsb = sum(1 for a in frame.get("adsb", []) if a is not None) if "adsb" in frame else 0 print( f"\r[{time.strftime('%H:%M:%S')}] " f"Sent signed DETECTION: {n_det} detections" f"{f', {n_adsb} with ADS-B' if n_adsb else ''}" f" | targets: {len(generator.targets)}" f" | chain: {chain_builder.pending_detections} pending", - end="", file=sys.stderr, + end="", + file=sys.stderr, ) time.sleep(interval_ms / 1000.0) @@ -686,8 +713,7 @@ def _stream_tcp(generator: SyntheticNodeGenerator, host: str, port: int, time.sleep(2) -def _stream_http(generator: SyntheticNodeGenerator, url: str, - interval_ms: int = 500, batch_size: int = 10): +def _stream_http(generator: SyntheticNodeGenerator, url: str, interval_ms: int = 500, batch_size: int = 10): """Stream detection frames to the server over HTTP POST.""" import urllib.request @@ -714,7 +740,8 @@ def _stream_http(generator: SyntheticNodeGenerator, url: str, f"\r[{time.strftime('%H:%M:%S')}] " f"Sent {len(frames_buffer)} frames → " f"{result.get('tracks', '?')} tracks", - end="", file=sys.stderr, + end="", + file=sys.stderr, ) except Exception as exc: print(f"\nHTTP POST failed: {exc}", file=sys.stderr) @@ -726,8 +753,9 @@ def _stream_http(generator: SyntheticNodeGenerator, url: str, print("\nStopping synthetic node.", file=sys.stderr) -def _replay_file(filepath: str, host: str, port: int, config: NodeConfig, - speed: float = 1.0, cloudflare_host: str | None = None): +def _replay_file( + filepath: str, host: str, port: int, config: NodeConfig, speed: float = 1.0, cloudflare_host: str | None = None +): """Replay a .detection file over TCP with full protocol.""" with open(filepath) as f: content = f.read().strip() @@ -749,9 +777,7 @@ def _replay_file(filepath: str, host: str, port: int, config: NodeConfig, # Start heartbeat thread stop_event = threading.Event() - hb_thread = threading.Thread( - target=_heartbeat_loop, args=(sock, config, stop_event), daemon=True - ) + hb_thread = threading.Thread(target=_heartbeat_loop, args=(sock, config, stop_event), daemon=True) hb_thread.start() try: @@ -783,9 +809,9 @@ def _replay_file(filepath: str, host: str, port: int, config: NodeConfig, n_det = len(frame.get("delay", [])) print( - f"\r[{i+1}/{len(frames)}] " - f"Replayed frame: {n_det} detections", - end="", file=sys.stderr, + f"\r[{i + 1}/{len(frames)}] Replayed frame: {n_det} detections", + end="", + file=sys.stderr, ) print(f"\nReplayed {len(frames)} frames.", file=sys.stderr) @@ -797,9 +823,14 @@ def _replay_file(filepath: str, host: str, port: int, config: NodeConfig, sock.close() -def _stream_multi_node_tcp(nodes_config_path: str, host: str, port: int, - mode: str = "detection", interval_ms: int = 500, - cloudflare_host: str | None = None): +def _stream_multi_node_tcp( + nodes_config_path: str, + host: str, + port: int, + mode: str = "detection", + interval_ms: int = 500, + cloudflare_host: str | None = None, +): """Run multiple synthetic nodes from a shared simulation world. Each node gets its own TCP connection, protocol handshake, and @@ -829,7 +860,7 @@ def _stream_multi_node_tcp(nodes_config_path: str, host: str, port: int, for nd in nodes_data: wc = WorldNodeConfig( - node_id=nd.get("node_id", f"synth-node-{len(node_configs)+1:02d}"), + node_id=nd.get("node_id", f"synth-node-{len(node_configs) + 1:02d}"), rx_lat=nd.get("rx_lat", 33.939182), rx_lon=nd.get("rx_lon", -84.651910), rx_alt_ft=nd.get("rx_alt_ft", 950.0), @@ -863,9 +894,14 @@ def _stream_multi_node_tcp(nodes_config_path: str, host: str, port: int, for wc in node_configs: local_cfg = NodeConfig( node_id=wc.node_id, - rx_lat=wc.rx_lat, rx_lon=wc.rx_lon, rx_alt_ft=wc.rx_alt_ft, - tx_lat=wc.tx_lat, tx_lon=wc.tx_lon, tx_alt_ft=wc.tx_alt_ft, - fc_hz=wc.fc_hz, fs_hz=wc.fs_hz, + rx_lat=wc.rx_lat, + rx_lon=wc.rx_lon, + rx_alt_ft=wc.rx_alt_ft, + tx_lat=wc.tx_lat, + tx_lon=wc.tx_lon, + tx_alt_ft=wc.tx_alt_ft, + fc_hz=wc.fc_hz, + fs_hz=wc.fs_hz, ) node_local_configs[wc.node_id] = local_cfg @@ -892,7 +928,8 @@ def _stream_multi_node_tcp(nodes_config_path: str, host: str, port: int, stop_event = threading.Event() for nid, sock in node_sockets.items(): t = threading.Thread( - target=_heartbeat_loop, args=(sock, node_local_configs[nid], stop_event), + target=_heartbeat_loop, + args=(sock, node_local_configs[nid], stop_event), daemon=True, ) t.start() @@ -975,7 +1012,8 @@ def _stream_multi_node_tcp(nodes_config_path: str, host: str, port: int, f"\r[{time.strftime('%H:%M:%S')}] " f"aircraft={n_aircraft} (adsb={n_adsb} anom={n_anom}) " f"nodes={len(node_sockets)} det_total={total_det}", - end="", file=sys.stderr, + end="", + file=sys.stderr, ) if not node_sockets: @@ -1000,24 +1038,28 @@ def _stream_multi_node_tcp(nodes_config_path: str, host: str, port: int, def main(): - parser = argparse.ArgumentParser( - description="Synthetic node for Retina passive radar network" - ) + parser = argparse.ArgumentParser(description="Synthetic node for Retina passive radar network") parser.add_argument( - "--host", default="localhost", + "--host", + default="localhost", help="Tracker server host (default: localhost)", ) parser.add_argument( - "--port", type=int, default=3012, + "--port", + type=int, + default=3012, help="Tracker server TCP port (default: 3012)", ) parser.add_argument( - "--mode", choices=["detection", "adsb", "anomalous"], + "--mode", + choices=["detection", "adsb", "anomalous"], default="detection", help="Data mode: detection-only, with ADS-B, or with anomalous objects", ) parser.add_argument( - "--interval", type=int, default=500, + "--interval", + type=int, + default=500, help="Interval between frames in ms (default: 500)", ) parser.add_argument( @@ -1025,7 +1067,9 @@ def main(): help="Replay a .detection file instead of generating synthetic data", ) parser.add_argument( - "--speed", type=float, default=1.0, + "--speed", + type=float, + default=1.0, help="Replay speed multiplier (default: 1.0)", ) parser.add_argument( @@ -1035,7 +1079,7 @@ def main(): parser.add_argument( "--cloudflare-host", help="Route TCP through Cloudflare frontend (e.g. hub.re) so the " - "server sees the node as a real internet client", + "server sees the node as a real internet client", ) parser.add_argument( "--config", @@ -1050,11 +1094,14 @@ def main(): help="Export ML training data to this NDJSON file path (no server needed)", ) parser.add_argument( - "--export-frames", type=int, default=10000, + "--export-frames", + type=int, + default=10000, help="Number of frames to export (default: 10000)", ) parser.add_argument( - "--node-id", default="synth-node-01", + "--node-id", + default="synth-node-01", help="Node identifier — 'synth-' prefix marks synthetic nodes (default: synth-node-01)", ) # Node geometry overrides @@ -1092,16 +1139,14 @@ def main(): print(f" Mode: {args.mode}", file=sys.stderr) print(f" Config hash: {cfg_hash}", file=sys.stderr) print( - f" RX: ({node_config.rx_lat:.6f}, {node_config.rx_lon:.6f}) " - f"@ {node_config.rx_alt_ft:.0f} ft", + f" RX: ({node_config.rx_lat:.6f}, {node_config.rx_lon:.6f}) @ {node_config.rx_alt_ft:.0f} ft", file=sys.stderr, ) print( - f" TX: ({node_config.tx_lat:.6f}, {node_config.tx_lon:.6f}) " - f"@ {node_config.tx_alt_ft:.0f} ft", + f" TX: ({node_config.tx_lat:.6f}, {node_config.tx_lon:.6f}) @ {node_config.tx_alt_ft:.0f} ft", file=sys.stderr, ) - print(f" FC: {node_config.fc_hz/1e6:.1f} MHz", file=sys.stderr) + print(f" FC: {node_config.fc_hz / 1e6:.1f} MHz", file=sys.stderr) cf_host = args.cloudflare_host if cf_host: @@ -1111,8 +1156,11 @@ def main(): if args.nodes_config: print(f" Multi-node config: {args.nodes_config}", file=sys.stderr) _stream_multi_node_tcp( - args.nodes_config, args.host, args.port, - mode=args.mode, interval_ms=args.interval, + args.nodes_config, + args.host, + args.port, + mode=args.mode, + interval_ms=args.interval, cloudflare_host=cf_host, ) return @@ -1120,8 +1168,7 @@ def main(): # ── Single-node modes ────────────────────────────────────────── if args.file: print(f" Replaying: {args.file} @ {args.speed}x speed", file=sys.stderr) - _replay_file(args.file, args.host, args.port, node_config, args.speed, - cloudflare_host=cf_host) + _replay_file(args.file, args.host, args.port, node_config, args.speed, cloudflare_host=cf_host) else: generator = SyntheticNodeGenerator(node_config, mode=args.mode) @@ -1133,8 +1180,7 @@ def main(): f" Streaming to: {args.host}:{args.port} (TCP)", file=sys.stderr, ) - _stream_tcp(generator, args.host, args.port, args.interval, - cloudflare_host=cf_host) + _stream_tcp(generator, args.host, args.port, args.interval, cloudflare_host=cf_host) if __name__ == "__main__": diff --git a/retina_simulation/orchestrator.py b/retina_simulation/orchestrator.py index 2c35ccf..d6790e7 100644 --- a/retina_simulation/orchestrator.py +++ b/retina_simulation/orchestrator.py @@ -55,17 +55,19 @@ def _cells_to_metrocells(cell_dicts: list[dict]) -> list[MetroCell]: cells = [] for c in cell_dicts: if c.get("core_lat") is None or c.get("core_lon") is None: - log.warning("Skipping coverage cell without core_lat/core_lon: %r", - c.get("ring_id", c)) + log.warning("Skipping coverage cell without core_lat/core_lon: %r", c.get("ring_id", c)) continue - cells.append(MetroCell( - core_lat=c["core_lat"], - core_lon=c["core_lon"], - radius_km=c.get("radius_km", 70.0), - ops_weight=c.get("ops_weight", 1.0), - )) + cells.append( + MetroCell( + core_lat=c["core_lat"], + core_lon=c["core_lon"], + radius_km=c.get("radius_km", 70.0), + ops_weight=c.get("ops_weight", 1.0), + ) + ) return cells + RETINA_VERSION = "1.0" HEARTBEAT_INTERVAL_S = 60 CONFIG_ACK_TIMEOUT_S = 10 @@ -73,6 +75,7 @@ def _cells_to_metrocells(cell_dicts: list[dict]) -> list[MetroCell]: def _config_hash(cfg: dict) -> str: import hashlib + cfg_str = json.dumps(cfg, sort_keys=True) return hashlib.sha256(cfg_str.encode()).hexdigest()[:16] @@ -132,27 +135,31 @@ async def _recv(self, timeout: float = CONFIG_ACK_TIMEOUT_S) -> dict | None: async def handshake(self) -> bool: """Perform RETINA protocol handshake.""" # HELLO - await self._send({ - "type": "HELLO", - "node_id": self.node_id, - "version": RETINA_VERSION, - "is_synthetic": True, - "capabilities": { - "detection": True, - "adsb_correlation": True, - "doppler": True, - "config_hash": True, - "heartbeat": True, - }, - }) + await self._send( + { + "type": "HELLO", + "node_id": self.node_id, + "version": RETINA_VERSION, + "is_synthetic": True, + "capabilities": { + "detection": True, + "adsb_correlation": True, + "doppler": True, + "config_hash": True, + "heartbeat": True, + }, + } + ) # CONFIG - await self._send({ - "type": "CONFIG", - "node_id": self.node_id, - "config_hash": self._cfg_hash, - "config": self.cfg, - }) + await self._send( + { + "type": "CONFIG", + "node_id": self.node_id, + "config_hash": self._cfg_hash, + "config": self.cfg, + } + ) # Wait for CONFIG_ACK for _ in range(3): @@ -162,21 +169,25 @@ async def handshake(self) -> bool: self.handshake_ok = True return True # Re-send CONFIG on timeout - await self._send({ - "type": "CONFIG", - "node_id": self.node_id, - "config_hash": self._cfg_hash, - "config": self.cfg, - }) + await self._send( + { + "type": "CONFIG", + "node_id": self.node_id, + "config_hash": self._cfg_hash, + "config": self.cfg, + } + ) return False async def send_detection(self, frame: dict): """Send a detection frame.""" - await self._send({ - "type": "DETECTION", - "node_id": self.node_id, - "data": frame, - }) + await self._send( + { + "type": "DETECTION", + "node_id": self.node_id, + "data": frame, + } + ) self.frames_sent += 1 async def send_heartbeat(self): @@ -184,13 +195,15 @@ async def send_heartbeat(self): now = time.monotonic() if now - self.last_heartbeat < HEARTBEAT_INTERVAL_S: return - await self._send({ - "type": "HEARTBEAT", - "node_id": self.node_id, - "timestamp": datetime.now(timezone.utc).isoformat(), - "config_hash": self._cfg_hash, - "status": "active", - }) + await self._send( + { + "type": "HEARTBEAT", + "node_id": self.node_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "config_hash": self._cfg_hash, + "status": "active", + } + ) self.last_heartbeat = now async def close(self): @@ -308,8 +321,11 @@ def _build_world(self): log.info( "SimulationWorld: center=(%.2f, %.2f), %d nodes, %d-%d aircraft, %d metro cells", - center_lat, center_lon, len(self.node_configs), - self.world.min_aircraft, self.world.max_aircraft, + center_lat, + center_lon, + len(self.node_configs), + self.world.min_aircraft, + self.world.max_aircraft, len(self.world.metro_cells), ) @@ -357,7 +373,7 @@ async def connect_all(self): round_start_connected = len(self.connections) for i in range(0, len(pending), batch_size): - batch = pending[i:i + batch_size] + batch = pending[i : i + batch_size] failed = await self._connect_batch(batch) next_pending.extend(failed) len(self.connections) - round_start_connected @@ -443,9 +459,9 @@ async def _reconnect_loop(self, check_interval_s: float = 15.0): while self._running: now = time.monotonic() due = [ - conn for conn in self.connections.values() - if not conn.connected - and now >= self._reconnect_next.get(conn.node_id, 0.0) + conn + for conn in self.connections.values() + if not conn.connected and now >= self._reconnect_next.get(conn.node_id, 0.0) ] if due: @@ -468,7 +484,8 @@ async def _reconnect_loop(self, check_interval_s: float = 15.0): still_down = sum(1 for c in self.connections.values() if not c.connected) log.info( "Auto-reconnect done: %d recovered, %d still down", - reconnected, still_down, + reconnected, + still_down, ) await asyncio.sleep(check_interval_s) @@ -516,7 +533,10 @@ def _geo_sort_key(nid: str) -> tuple: log.info( "Starting simulation loop (tick=%.1fs, frame_interval=%.1fs, " "time_scale=%.1fx, mode=%s, duration=%s, ~%.1f frames/s)", - tick_dt, self.frame_interval, self.time_scale, self.mode, + tick_dt, + self.frame_interval, + self.time_scale, + self.mode, f"{duration_s}s" if duration_s else "infinite", n_nodes / self.frame_interval, ) @@ -559,8 +579,12 @@ def _geo_sort_key(nid: str) -> tuple: log.info( "STATS: %.0fs elapsed | %d active nodes | %d frames (%.0f/s) | " "%d detections (%.0f/s) | %d errors | %d aircraft", - elapsed, active, self._stats["total_frames"], fps, - self._stats["total_detections"], dps, + elapsed, + active, + self._stats["total_frames"], + fps, + self._stats["total_detections"], + dps, self._stats["errors"], len(self.world.aircraft) if self.world else 0, ) @@ -607,10 +631,14 @@ def get_stats(self) -> dict: def save_ground_truth(self, path: str): """Save ground truth data for offline validation.""" with open(path, "w") as f: - json.dump({ - "fleet_stats": self.get_stats(), - "ground_truth": self.ground_truth[-200:], # last 200 snapshots - }, f, indent=2) + json.dump( + { + "fleet_stats": self.get_stats(), + "ground_truth": self.ground_truth[-200:], # last 200 snapshots + }, + f, + indent=2, + ) log.info("Ground truth saved: %s (%d snapshots)", path, len(self.ground_truth)) @@ -647,24 +675,29 @@ async def _push_ground_truth_live( hex_code = ac.get("adsb_hex") or ac.get("id", "") if not hex_code: continue - payload_aircraft.append({ - "hex": hex_code, - "lat": ac["lat"], - "lon": ac["lon"], - "alt_m": ac["alt_km"] * 1000, - "heading": ac.get("heading", 0), - "speed_ms": ac.get("speed_ms", 0), - "object_type": ac.get("object_type", "aircraft"), - "is_anomalous": ac.get("is_anomalous", False), - }) + payload_aircraft.append( + { + "hex": hex_code, + "lat": ac["lat"], + "lon": ac["lon"], + "alt_m": ac["alt_km"] * 1000, + "heading": ac.get("heading", 0), + "speed_ms": ac.get("speed_ms", 0), + "object_type": ac.get("object_type", "aircraft"), + "is_anomalous": ac.get("is_anomalous", False), + } + ) if payload_aircraft: - body = json.dumps({ - "ts_ms": int(time.time() * 1000), - "aircraft": payload_aircraft, - }).encode() + body = json.dumps( + { + "ts_ms": int(time.time() * 1000), + "aircraft": payload_aircraft, + } + ).encode() req = urllib.request.Request( - url, data=body, + url, + data=body, headers={ "Content-Type": "application/json", **({"X-API-Key": _k} if (_k := os.environ.get("RADAR_API_KEY", "")) else {}), @@ -697,6 +730,7 @@ async def _push_real_adsb( from clients.adsb_lol import AdsbLolClient except ImportError: import importlib.util + _p = os.path.join(os.path.dirname(os.path.dirname(__file__)), "clients", "adsb_lol.py") spec = importlib.util.spec_from_file_location("adsb_lol", _p) mod = importlib.util.module_from_spec(spec) @@ -727,23 +761,28 @@ async def _push_real_adsb( h = ac.get("hex", "") if not h or not ac.get("lat") or not ac.get("lon"): continue - payload.append({ - "hex": h, - "flight": ac.get("flight", ""), - "lat": ac["lat"], - "lon": ac["lon"], - "alt_baro": ac.get("alt_baro", 0), - "gs": ac.get("gs", 0), - "track": ac.get("track", 0), - }) + payload.append( + { + "hex": h, + "flight": ac.get("flight", ""), + "lat": ac["lat"], + "lon": ac["lon"], + "alt_baro": ac.get("alt_baro", 0), + "gs": ac.get("gs", 0), + "track": ac.get("track", 0), + } + ) if payload: - body = json.dumps({ - "ts_ms": int(time.time() * 1000), - "aircraft": payload, - }).encode() + body = json.dumps( + { + "ts_ms": int(time.time() * 1000), + "aircraft": payload, + } + ).encode() req = urllib.request.Request( - url, data=body, + url, + data=body, headers={ "Content-Type": "application/json", **({"X-API-Key": _k} if (_k := os.environ.get("RADAR_API_KEY", "")) else {}), @@ -770,9 +809,9 @@ async def _poll_simulation_config( log.info("Simulation config polling started (url=%s, interval=%.1fs)", base_url, interval_s) url = f"{base_url}/api/simulation/config" loop = asyncio.get_event_loop() - ssl_context = ssl._create_unverified_context() if ( # noqa: S323 — localhost dev path only - "localhost" in base_url or "127.0.0.1" in base_url - ) else None + ssl_context = None + if "localhost" in base_url or "127.0.0.1" in base_url: + ssl_context = ssl._create_unverified_context() # noqa: S323 — unverified context only for the localhost/127.0.0.1 dev path above last_updated_at = 0.0 while orchestrator._running: @@ -782,8 +821,10 @@ async def _poll_simulation_config( continue try: + def _fetch(): import json as _json + with urllib.request.urlopen(url, context=ssl_context, timeout=5) as resp: return _json.loads(resp.read()) @@ -791,16 +832,15 @@ def _fetch(): updated_at = cfg.get("_updated_at", 0.0) if updated_at > last_updated_at: orchestrator.world.frac_anomalous = float(cfg.get("frac_anomalous", 0.05)) - orchestrator.world.frac_drone = float(cfg.get("frac_drone", 0.10)) - orchestrator.world.frac_dark = float(cfg.get("frac_dark", 0.15)) + orchestrator.world.frac_drone = float(cfg.get("frac_drone", 0.10)) + orchestrator.world.frac_dark = float(cfg.get("frac_dark", 0.15)) if "min_aircraft" in cfg: orchestrator.world.min_aircraft = int(cfg["min_aircraft"]) if "max_aircraft" in cfg: orchestrator.world.max_aircraft = int(cfg["max_aircraft"]) last_updated_at = updated_at log.info( - "Simulation config updated: anomalous=%.2f drone=%.2f dark=%.2f " - "aircraft=%d–%d", + "Simulation config updated: anomalous=%.2f drone=%.2f dark=%.2f aircraft=%d–%d", orchestrator.world.frac_anomalous, orchestrator.world.frac_drone, orchestrator.world.frac_dark, @@ -831,6 +871,7 @@ async def _push_adsb_live( ssl_context = None if "localhost" in base_url or "127.0.0.1" in base_url: import ssl as _ssl + ssl_context = _ssl._create_unverified_context() # noqa: S323 — unverified context only for the localhost/127.0.0.1 dev path above while orchestrator._running: @@ -849,25 +890,30 @@ async def _push_adsb_live( if not hex_code: continue speed_ms = ac.get("speed_ms", 0) - payload_aircraft.append({ - "hex": hex_code, - "flight": "", - "lat": round(ac["lat"], 5), - "lon": round(ac["lon"], 5), - "alt_baro": round(ac["alt_km"] * 1000 / 0.3048), - "gs": round(speed_ms * 1.94384, 1), - "track": round(ac.get("heading", 0), 1), - }) + payload_aircraft.append( + { + "hex": hex_code, + "flight": "", + "lat": round(ac["lat"], 5), + "lon": round(ac["lon"], 5), + "alt_baro": round(ac["alt_km"] * 1000 / 0.3048), + "gs": round(speed_ms * 1.94384, 1), + "track": round(ac.get("heading", 0), 1), + } + ) if not payload_aircraft: continue - body = json.dumps({ - "ts_ms": int(time.time() * 1000), - "aircraft": payload_aircraft, - }).encode() + body = json.dumps( + { + "ts_ms": int(time.time() * 1000), + "aircraft": payload_aircraft, + } + ).encode() req = urllib.request.Request( - url, data=body, + url, + data=body, headers={ "Content-Type": "application/json", **({"X-API-Key": _k} if (_k := os.environ.get("RADAR_API_KEY", "")) else {}), @@ -913,16 +959,12 @@ def _get_json(endpoint_url): server_aircraft = server_aircraft_data.get("aircraft", []) try: - analytics = await loop.run_in_executor( - None, _get_json, f"{base_url}/api/radar/analytics" - ) + analytics = await loop.run_in_executor(None, _get_json, f"{base_url}/api/radar/analytics") except Exception: analytics = {} try: - nodes_status = await loop.run_in_executor( - None, _get_json, f"{base_url}/api/radar/nodes" - ) + nodes_status = await loop.run_in_executor(None, _get_json, f"{base_url}/api/radar/nodes") except Exception: nodes_status = {} @@ -931,8 +973,7 @@ def _get_json(endpoint_url): truth_aircraft = truth["aircraft"] log.info( - "VALIDATION: server=%d aircraft, truth=%d aircraft, " - "server_nodes=%d connected, analytics_nodes=%d", + "VALIDATION: server=%d aircraft, truth=%d aircraft, server_nodes=%d connected, analytics_nodes=%d", len(server_aircraft), len(truth_aircraft), nodes_status.get("connected", 0), @@ -985,8 +1026,11 @@ async def main_async(args): log.info("No config file, generating %d nodes...", args.nodes) regions = [r.strip() for r in args.regions.split(",")] all_nodes = generate_fleet( - n_nodes=args.nodes, regions=regions, seed=args.seed, - n_cluster=args.n_cluster, n_clusters=args.n_clusters, + n_nodes=args.nodes, + regions=regions, + seed=args.seed, + n_cluster=args.n_cluster, + n_clusters=args.n_clusters, ) cells = coverage_cells(n_cluster=args.n_cluster, n_clusters=args.n_clusters) @@ -994,6 +1038,7 @@ async def main_async(args): if getattr(args, "metros", "") and args.metros: metro_areas = _parse_metro_areas(args.metros) if metro_areas: + def _near_any_metro(node): for m in metro_areas: dlat = abs(node["rx_lat"] - m["lat"]) @@ -1001,10 +1046,10 @@ def _near_any_metro(node): if dlat < 2.0 and dlon < 2.0: # ~200km box return True return False + before = len(all_nodes) all_nodes = [n for n in all_nodes if _near_any_metro(n)] - log.info("Metro filter (%s): %d → %d nodes", - args.metros, before, len(all_nodes)) + log.info("Metro filter (%s): %d → %d nodes", args.metros, before, len(all_nodes)) # Resolve real TX towers for each node (skip for non-US regions — FCC-only). # Coverage-ring receivers keep their shared illuminator: resolving per-RX @@ -1012,21 +1057,19 @@ def _near_any_metro(node): # association invariant and the metro-cell grouping. if getattr(args, "use_real_towers", False): log.info("Resolving real TX towers via FCC API (cached)…") - resolvable = [n for n in all_nodes - if not str(n.get("node_id", "")).startswith("synth-RING")] + resolvable = [n for n in all_nodes if not str(n.get("node_id", "")).startswith("synth-RING")] assignments = resolve_towers(resolvable) updated = apply_tower_assignments(resolvable, assignments) log.info("Real tower assignments applied to %d / %d nodes.", updated, len(resolvable)) # Limit to requested number if args.nodes and args.nodes < len(all_nodes): - all_nodes = all_nodes[:args.nodes] + all_nodes = all_nodes[: args.nodes] # Keep only cells whose ring survived node filtering (e.g. --metros / --nodes) if cells: node_ids = [n["node_id"] for n in all_nodes] - cells = [c for c in cells - if any(nid.startswith(c["ring_id"]) for nid in node_ids)] + cells = [c for c in cells if any(nid.startswith(c["ring_id"]) for nid in node_ids)] log.info("Fleet: %d nodes, %d metro cells", len(all_nodes), len(cells)) summary = fleet_summary(all_nodes) @@ -1074,36 +1117,56 @@ def _near_any_metro(node): # Always push per-aircraft ADS-B positions every second so every aircraft # has a fresh position in state.adsb_aircraft regardless of node visibility. if args.validation_url: - tasks.append(_push_adsb_live( - orchestrator, args.validation_url, interval_s=1.0, - )) + tasks.append( + _push_adsb_live( + orchestrator, + args.validation_url, + interval_s=1.0, + ) + ) # Always push ground truth when we have a validation_url — needed for # anomaly detection and the frontend map overlay, not just validation. if args.validation_url: - tasks.append(_push_ground_truth_live( - orchestrator, args.validation_url, interval_s=2.0, - )) + tasks.append( + _push_ground_truth_live( + orchestrator, + args.validation_url, + interval_s=2.0, + ) + ) # Poll server for updated simulation physics fractions (set from frontend UI). if args.validation_url: - tasks.append(_poll_simulation_config( - orchestrator, args.validation_url, interval_s=5.0, - )) + tasks.append( + _poll_simulation_config( + orchestrator, + args.validation_url, + interval_s=5.0, + ) + ) # Real ADS-B from adsb.lol — inject real air traffic when metro areas are configured. if args.validation_url and hasattr(args, "metros") and args.metros: metro_areas = _parse_metro_areas(args.metros) if metro_areas: - tasks.append(_push_real_adsb( - orchestrator, args.validation_url, - areas=metro_areas, interval_s=10.0, - )) + tasks.append( + _push_real_adsb( + orchestrator, + args.validation_url, + areas=metro_areas, + interval_s=10.0, + ) + ) if args.validate and args.validation_url: - tasks.append(_validate_against_server( - orchestrator, args.validation_url, interval_s=30.0, - )) + tasks.append( + _validate_against_server( + orchestrator, + args.validation_url, + interval_s=30.0, + ) + ) try: await asyncio.gather(*tasks) @@ -1124,66 +1187,84 @@ def _near_any_metro(node): def main(): - parser = argparse.ArgumentParser( - description="Fleet Orchestrator — run 100-1000 synthetic nodes" + parser = argparse.ArgumentParser(description="Fleet Orchestrator — run 100-1000 synthetic nodes") + parser.add_argument("--config", type=str, default="fleet_config.json", help="Path to fleet_config.json") + parser.add_argument("--nodes", type=int, default=0, help="Number of nodes to use (0 = all from config)") + parser.add_argument("--regions", type=str, default="us", help="Regions for auto-generation: us,eu,au") + parser.add_argument("--seed", type=int, default=42, help="Random seed for fleet generation") + parser.add_argument( + "--n-cluster", + "--n-ring", + dest="n_cluster", + type=int, + default=30, + help="Total metro-ring receiver budget, split across --n-clusters " + "rings (only used when auto-generating, i.e. no --config). " + "Matches the generator default.", + ) + parser.add_argument( + "--n-clusters", + "--n-rings", + dest="n_clusters", + type=int, + default=5, + help="Number of distinct metro rings to fan the --n-cluster budget " + "across (5 = Dallas, Chicago, Atlanta, Denver, Kansas City; " + "1 = single Dallas ring). Matches the generator default.", + ) + parser.add_argument("--host", type=str, default="localhost", help="Server hostname") + parser.add_argument("--port", type=int, default=3012, help="Server TCP port") + parser.add_argument( + "--mode", type=str, default="adsb", choices=["detection", "adsb", "anomalous"], help="Detection mode" + ) + parser.add_argument("--interval", type=float, default=0.5, help="Frame interval in seconds") + parser.add_argument( + "--time-scale", type=float, default=1.0, help="Simulation speed multiplier (for demo visibility)" + ) + parser.add_argument("--duration", type=float, default=0, help="Run duration in seconds (0 = infinite)") + parser.add_argument( + "--min-aircraft", type=int, default=0, help="Minimum aircraft to keep alive (0 = auto demo default)" + ) + parser.add_argument("--max-aircraft", type=int, default=0, help="Maximum aircraft in world (0 = auto demo default)") + parser.add_argument( + "--beam-width-deg", type=float, default=0, help="Override node beam width for demo visibility (0 = use config)" + ) + parser.add_argument( + "--max-range-km", type=float, default=0, help="Override node max range for demo visibility (0 = use config)" + ) + parser.add_argument("--concurrency", type=int, default=50, help="Max concurrent TCP connections during setup") + parser.add_argument( + "--connect-retries", type=int, default=3, help="How many retry rounds to use for failed handshakes" + ) + parser.add_argument( + "--use-real-towers", action="store_true", help="Resolve real TX towers via FCC API (persistent cache; US only)" + ) + parser.add_argument("--validate", action="store_true", help="Enable validation against server API") + parser.add_argument( + "--validation-url", type=str, default="http://localhost:8000", help="Base URL for validation API calls" + ) + parser.add_argument( + "--ground-truth-path", type=str, default="ground_truth.json", help="Path to save ground truth data" + ) + parser.add_argument( + "--metros", + type=str, + default="", + help="Comma-separated metro codes to focus on (e.g. atl,gvl). " + "Filters fleet to these metros and injects real ADS-B from adsb.lol. " + f"Available: {','.join(_KNOWN_METROS.keys())}", + ) + parser.add_argument( + "--no-hub-radial", + action="store_true", + help="Disable hub-radial flight planning (use legacy random-anchor spawn)", + ) + parser.add_argument( + "--metro-traffic-frac", + type=float, + default=0.6, + help="Fraction of spawns routed through metro coverage rings (rest en-route)", ) - parser.add_argument("--config", type=str, default="fleet_config.json", - help="Path to fleet_config.json") - parser.add_argument("--nodes", type=int, default=0, - help="Number of nodes to use (0 = all from config)") - parser.add_argument("--regions", type=str, default="us", - help="Regions for auto-generation: us,eu,au") - parser.add_argument("--seed", type=int, default=42, - help="Random seed for fleet generation") - parser.add_argument("--n-cluster", "--n-ring", dest="n_cluster", type=int, default=30, - help="Total metro-ring receiver budget, split across --n-clusters " - "rings (only used when auto-generating, i.e. no --config). " - "Matches the generator default.") - parser.add_argument("--n-clusters", "--n-rings", dest="n_clusters", type=int, default=5, - help="Number of distinct metro rings to fan the --n-cluster budget " - "across (5 = Dallas, Chicago, Atlanta, Denver, Kansas City; " - "1 = single Dallas ring). Matches the generator default.") - parser.add_argument("--host", type=str, default="localhost", - help="Server hostname") - parser.add_argument("--port", type=int, default=3012, - help="Server TCP port") - parser.add_argument("--mode", type=str, default="adsb", - choices=["detection", "adsb", "anomalous"], - help="Detection mode") - parser.add_argument("--interval", type=float, default=0.5, - help="Frame interval in seconds") - parser.add_argument("--time-scale", type=float, default=1.0, - help="Simulation speed multiplier (for demo visibility)") - parser.add_argument("--duration", type=float, default=0, - help="Run duration in seconds (0 = infinite)") - parser.add_argument("--min-aircraft", type=int, default=0, - help="Minimum aircraft to keep alive (0 = auto demo default)") - parser.add_argument("--max-aircraft", type=int, default=0, - help="Maximum aircraft in world (0 = auto demo default)") - parser.add_argument("--beam-width-deg", type=float, default=0, - help="Override node beam width for demo visibility (0 = use config)") - parser.add_argument("--max-range-km", type=float, default=0, - help="Override node max range for demo visibility (0 = use config)") - parser.add_argument("--concurrency", type=int, default=50, - help="Max concurrent TCP connections during setup") - parser.add_argument("--connect-retries", type=int, default=3, - help="How many retry rounds to use for failed handshakes") - parser.add_argument("--use-real-towers", action="store_true", - help="Resolve real TX towers via FCC API (persistent cache; US only)") - parser.add_argument("--validate", action="store_true", - help="Enable validation against server API") - parser.add_argument("--validation-url", type=str, default="http://localhost:8000", - help="Base URL for validation API calls") - parser.add_argument("--ground-truth-path", type=str, default="ground_truth.json", - help="Path to save ground truth data") - parser.add_argument("--metros", type=str, default="", - help="Comma-separated metro codes to focus on (e.g. atl,gvl). " - "Filters fleet to these metros and injects real ADS-B from adsb.lol. " - f"Available: {','.join(_KNOWN_METROS.keys())}") - parser.add_argument("--no-hub-radial", action="store_true", - help="Disable hub-radial flight planning (use legacy random-anchor spawn)") - parser.add_argument("--metro-traffic-frac", type=float, default=0.6, - help="Fraction of spawns routed through metro coverage rings (rest en-route)") args = parser.parse_args() asyncio.run(main_async(args)) diff --git a/retina_simulation/tower_resolver.py b/retina_simulation/tower_resolver.py index 4943bb2..5e4114f 100644 --- a/retina_simulation/tower_resolver.py +++ b/retina_simulation/tower_resolver.py @@ -63,7 +63,7 @@ _CACHE_PATH = os.path.join(os.path.dirname(__file__), "tower_assignments.json") _METRO_CACHE_PATH = os.path.join(os.path.dirname(__file__), "metro_tower_cache.json") _LOOKUP_RADIUS_KM = 80 -_MIN_FREQ_HZ = 80_000_000 # ignore sub-80 MHz (below FM band — not useful for PR) +_MIN_FREQ_HZ = 80_000_000 # ignore sub-80 MHz (below FM band — not useful for PR) _MAX_FREQ_HZ = 900_000_000 # ignore > 900 MHz (above UHF TV) # ── Tower API base URL ──────────────────────────────────────────────────────── @@ -89,6 +89,7 @@ def _save_cache(cache: dict) -> None: # ── Metro tower cache (per-area multi-tower results from Tower API) ─────────── + def _load_metro_cache() -> dict: if os.path.exists(_METRO_CACHE_PATH): try: @@ -138,13 +139,15 @@ def _query_tower_api(lat: float, lon: float, radius_km: int = 80, limit: int = 5 if tx_lat is None or tx_lon is None: continue alt_m = t.get("altitude_m") or t.get("elevation_m") - results.append({ - "tx_lat": round(float(tx_lat), 6), - "tx_lon": round(float(tx_lon), 6), - "tx_alt_ft": _m_to_ft(alt_m), - "fc_hz": freq_hz, - "tx_callsign": (t.get("callsign") or "").strip(), - }) + results.append( + { + "tx_lat": round(float(tx_lat), 6), + "tx_lon": round(float(tx_lon), 6), + "tx_alt_ft": _m_to_ft(alt_m), + "fc_hz": freq_hz, + "tx_callsign": (t.get("callsign") or "").strip(), + } + ) return results except Exception as exc: log.warning("Tower API query failed for (%.4f, %.4f): %s", lat, lon, exc) @@ -267,9 +270,7 @@ async def _resolve_one(node: dict): if result: cache[node["node_id"]] = result updated += 1 - log.debug(" %s → %s @ %.1f MHz", - node["node_id"], result["tx_callsign"], - result["fc_hz"] / 1e6) + log.debug(" %s → %s @ %.1f MHz", node["node_id"], result["tx_callsign"], result["fc_hz"] / 1e6) else: # No real tower found — leave the generated default in place log.debug(" %s — no real tower found, keeping generated TX", node["node_id"]) @@ -316,10 +317,9 @@ def resolve_towers(fleet_nodes: list[dict], cache_path: str = _CACHE_PATH) -> di # Inside an existing async context — use run_until_complete can't work here; # create a new thread-loop pair import concurrent.futures + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - future = pool.submit( - lambda: asyncio.run(_resolve_batch(fleet_nodes, cache)) - ) + future = pool.submit(lambda: asyncio.run(_resolve_batch(fleet_nodes, cache))) cache = future.result(timeout=300) else: cache = loop.run_until_complete(_resolve_batch(fleet_nodes, cache)) diff --git a/retina_simulation/world.py b/retina_simulation/world.py index be422c8..10a6ec0 100644 --- a/retina_simulation/world.py +++ b/retina_simulation/world.py @@ -27,42 +27,42 @@ from dataclasses import asdict, dataclass, field C_KM_US = 0.299792458 # speed of light km/μs -C_KM_S = 299792.458 # speed of light km/s -R_EARTH = 6371.0 # Earth radius km +C_KM_S = 299792.458 # speed of light km/s +R_EARTH = 6371.0 # Earth radius km # ── US flight corridor waypoints (major airports / airways) ────────────────── _US_WAYPOINTS = [ # East coast - (33.6407, -84.4277), # ATL Atlanta - (35.2144, -80.9473), # CLT Charlotte - (35.8776, -78.7875), # RDU Raleigh - (36.0984, -79.9372), # GSO Greensboro - (37.5054, -77.3197), # RIC Richmond - (38.8512, -77.0402), # DCA Washington - (39.1776, -76.6683), # BWI Baltimore - (39.8744, -75.2424), # PHL Philadelphia - (40.6413, -73.7781), # JFK New York - (42.3656, -71.0096), # BOS Boston + (33.6407, -84.4277), # ATL Atlanta + (35.2144, -80.9473), # CLT Charlotte + (35.8776, -78.7875), # RDU Raleigh + (36.0984, -79.9372), # GSO Greensboro + (37.5054, -77.3197), # RIC Richmond + (38.8512, -77.0402), # DCA Washington + (39.1776, -76.6683), # BWI Baltimore + (39.8744, -75.2424), # PHL Philadelphia + (40.6413, -73.7781), # JFK New York + (42.3656, -71.0096), # BOS Boston # Southeast - (32.1271, -81.2020), # SAV Savannah - (28.4312, -81.3081), # MCO Orlando - (25.7959, -80.2870), # MIA Miami - (30.4941, -81.6879), # JAX Jacksonville - (27.9755, -82.5332), # TPA Tampa + (32.1271, -81.2020), # SAV Savannah + (28.4312, -81.3081), # MCO Orlando + (25.7959, -80.2870), # MIA Miami + (30.4941, -81.6879), # JAX Jacksonville + (27.9755, -82.5332), # TPA Tampa # Central - (36.1245, -86.6782), # BNA Nashville - (38.1744, -85.7360), # SDF Louisville - (39.0489, -84.6678), # CVG Cincinnati - (41.4117, -81.8498), # CLE Cleveland - (42.2125, -83.3534), # DTW Detroit - (41.9742, -87.9073), # ORD Chicago - (44.8848, -93.2223), # MSP Minneapolis - (38.7487, -90.3700), # STL St Louis - (39.2976, -94.7139), # MCI Kansas City - (29.9934, -90.2580), # MSY New Orleans - (29.6454, -95.2789), # IAH Houston - (32.8968, -97.0380), # DFW Dallas - (35.3926, -97.6007), # OKC Oklahoma City + (36.1245, -86.6782), # BNA Nashville + (38.1744, -85.7360), # SDF Louisville + (39.0489, -84.6678), # CVG Cincinnati + (41.4117, -81.8498), # CLE Cleveland + (42.2125, -83.3534), # DTW Detroit + (41.9742, -87.9073), # ORD Chicago + (44.8848, -93.2223), # MSP Minneapolis + (38.7487, -90.3700), # STL St Louis + (39.2976, -94.7139), # MCI Kansas City + (29.9934, -90.2580), # MSY New Orleans + (29.6454, -95.2789), # IAH Houston + (32.8968, -97.0380), # DFW Dallas + (35.3926, -97.6007), # OKC Oklahoma City (39.8561, -104.6737), # DEN Denver # West (33.4373, -112.0078), # PHX Phoenix @@ -77,15 +77,16 @@ @dataclass class SimulatedAircraft: """A simulated aircraft in the world with lat/lon/alt position.""" + object_id: str # Position LLA lat: float lon: float alt_km: float # Velocity (km/s) in ENU-like local frame - vel_east: float # km/s east + vel_east: float # km/s east vel_north: float # km/s north - vel_up: float # km/s vertical + vel_up: float # km/s vertical # Heading (degrees from north, clockwise) heading_deg: float speed_km_s: float @@ -102,16 +103,17 @@ class SimulatedAircraft: waypoints: list = field(default_factory=list) waypoint_idx: int = 0 # Mid-flight anomaly injection (scheduled event) - anomaly_event: str | None = None # None | "hijack" | "spoof" | "orbit" | "altitude_jump" | "id_swap" - anomaly_trigger_at: float = 0.0 # world time when event fires - anomaly_fired: bool = False # True once the event has been applied - _pre_spoof_lat: float = 0.0 # real position before GPS spoof + anomaly_event: str | None = None # None | "hijack" | "spoof" | "orbit" | "altitude_jump" | "id_swap" + anomaly_trigger_at: float = 0.0 # world time when event fires + anomaly_fired: bool = False # True once the event has been applied + _pre_spoof_lat: float = 0.0 # real position before GPS spoof _pre_spoof_lon: float = 0.0 @dataclass class NodeConfig: """Configuration for a synthetic radar node.""" + node_id: str = "synth-node-01" rx_lat: float = 33.939182 rx_lon: float = -84.651910 @@ -125,9 +127,9 @@ class NodeConfig: doppler_max: float = 300.0 min_doppler: float = 15.0 # Detection geometry - beam_azimuth_deg: float | None = None # None → auto broadside in add_node - beam_width_deg: float = 41.0 # Yagi half-power beamwidth (40-42° spec) - max_range_km: float = 50.0 # maximum detection range + beam_azimuth_deg: float | None = None # None → auto broadside in add_node + beam_width_deg: float = 41.0 # Yagi half-power beamwidth (40-42° spec) + max_range_km: float = 50.0 # maximum detection range def config_hash(config: NodeConfig) -> str: @@ -145,16 +147,18 @@ class MetroCell: radial pattern as real STAR/SID procedures, which is where the receiver ring's coverage spokes lie. """ + core_lat: float core_lon: float radius_km: float = 70.0 ops_weight: float = 1.0 - arrival_bearings_deg: list = field(default_factory=list) # empty → uniform + arrival_bearings_deg: list = field(default_factory=list) # empty → uniform departure_bearings_deg: list = field(default_factory=list) # ── Coordinate helpers ──────────────────────────────────────────────────────── + def _lla_to_enu(lat, lon, alt_km, ref_lat, ref_lon, ref_alt_km): """Convert LLA to ENU (km) relative to reference point.""" dlat = math.radians(lat - ref_lat) @@ -221,12 +225,10 @@ def _bistatic_doppler(target_enu, vel_enu, tx_enu, rx_enu, freq_hz): # ── Flight corridor route generation ───────────────────────────────────────── + def _pick_route(center_lat: float, center_lon: float, max_dist_km: float = 300) -> list[tuple[float, float]]: """Pick a sequence of 2-4 waypoints near center forming a realistic route.""" - nearby = [ - wp for wp in _US_WAYPOINTS - if _haversine_km(center_lat, center_lon, wp[0], wp[1]) < max_dist_km - ] + nearby = [wp for wp in _US_WAYPOINTS if _haversine_km(center_lat, center_lon, wp[0], wp[1]) < max_dist_km] if len(nearby) < 2: nearby = sorted(_US_WAYPOINTS, key=lambda wp: _haversine_km(center_lat, center_lon, wp[0], wp[1]))[:6] @@ -241,7 +243,7 @@ def _pick_route(center_lat: float, center_lon: float, max_dist_km: float = 300) last = route[-1] remaining.sort(key=lambda wp: _haversine_km(last[0], last[1], wp[0], wp[1])) # Choose from closest 3, weighted toward closer ones - candidates = remaining[:min(3, len(remaining))] + candidates = remaining[: min(3, len(remaining))] nxt = random.choice(candidates) route.append(nxt) remaining = [wp for wp in remaining if wp != nxt] @@ -250,6 +252,7 @@ def _pick_route(center_lat: float, center_lon: float, max_dist_km: float = 300) # ── SimulationWorld ─────────────────────────────────────────────────────────── + class SimulationWorld: """Shared simulation world with aircraft and multiple observer nodes.""" @@ -284,10 +287,15 @@ def add_node(self, config: NodeConfig): maximises cross-coverage of aircraft transiting the bistatic zone. """ if config.beam_azimuth_deg is None: - config.beam_azimuth_deg = (_bearing_deg( - config.rx_lat, config.rx_lon, - config.tx_lat, config.tx_lon, - ) + 90.0) % 360.0 + config.beam_azimuth_deg = ( + _bearing_deg( + config.rx_lat, + config.rx_lon, + config.tx_lat, + config.tx_lon, + ) + + 90.0 + ) % 360.0 self.nodes[config.node_id] = config def _choose_spawn_pose(self) -> tuple[float, float, list]: @@ -312,8 +320,7 @@ def _nationwide_pose(self) -> tuple[float, float, list]: anywhere along the leg (not just at airports) and independent of node placement — the nationwide background that keeps the map alive.""" start = random.choice(_US_WAYPOINTS) - far = [wp for wp in _US_WAYPOINTS - if _haversine_km(start[0], start[1], wp[0], wp[1]) > 400] + far = [wp for wp in _US_WAYPOINTS if _haversine_km(start[0], start[1], wp[0], wp[1]) > 400] dest = random.choice(far or _US_WAYPOINTS) t = random.uniform(0.0, 1.0) lat = start[0] + t * (dest[0] - start[0]) + random.gauss(0, 0.3) @@ -332,6 +339,7 @@ def _radial_pose(self, cell: MetroCell, kind: str) -> tuple[float, float, list]: """Edge↔core radial (arrival/departure) or a chord near the core (overflight) — the radial geometry of real STAR/SID procedures, which is where the receiver ring's coverage spokes lie.""" + def at(bearing_deg: float, dist_km: float) -> tuple[float, float]: br = math.radians(bearing_deg) lat = cell.core_lat + math.degrees((dist_km * math.cos(br)) / R_EARTH) @@ -340,18 +348,17 @@ def at(bearing_deg: float, dist_km: float) -> tuple[float, float]: ) return lat, lon - core = (cell.core_lat + random.gauss(0, 0.02), - cell.core_lon + random.gauss(0, 0.02)) + core = (cell.core_lat + random.gauss(0, 0.02), cell.core_lon + random.gauss(0, 0.02)) if kind == "arrival": - bearing = (random.choice(cell.arrival_bearings_deg) - if cell.arrival_bearings_deg else random.uniform(0, 360)) + bearing = random.choice(cell.arrival_bearings_deg) if cell.arrival_bearings_deg else random.uniform(0, 360) lat, lon = at(bearing, cell.radius_km) return lat, lon, [(lat, lon), core] if kind == "departure": - bearing = (random.choice(cell.departure_bearings_deg) - if cell.departure_bearings_deg else random.uniform(0, 360)) + bearing = ( + random.choice(cell.departure_bearings_deg) if cell.departure_bearings_deg else random.uniform(0, 360) + ) return core[0], core[1], [core, at(bearing, cell.radius_km)] # overflight: enter one edge, exit near the opposite edge, passing near the core @@ -366,9 +373,7 @@ def _fallback_pose(self) -> tuple[float, float, list]: are configured (keeps training export and solo single-node arcs intact).""" if self.nodes: anchor = random.choice(list(self.nodes.values())) - baseline_bearing = _bearing_deg( - anchor.rx_lat, anchor.rx_lon, anchor.tx_lat, anchor.tx_lon - ) + baseline_bearing = _bearing_deg(anchor.rx_lat, anchor.rx_lon, anchor.tx_lat, anchor.tx_lon) perp_rad = math.radians((baseline_bearing + 90.0) % 360.0) dist_km = random.uniform(5.0, anchor.max_range_km * 0.7) anchor_lat = anchor.rx_lat + (dist_km * math.cos(perp_rad)) / 111.32 @@ -424,10 +429,10 @@ def _spawn_aircraft(self, mode: str = "detection") -> SimulatedAircraft: alt_km = random.uniform(0.3, 15.0) elif object_type == "drone": speed_km_s = random.uniform(0.01, 0.06) # 10-60 m/s — small UAS - alt_km = random.uniform(0.05, 0.5) # 50-500m AGL + alt_km = random.uniform(0.05, 0.5) # 50-500m AGL else: speed_km_s = random.uniform(0.12, 0.27) # 120-270 m/s → typical jet - alt_km = random.uniform(5.0, 12.0) # 16k-40k ft + alt_km = random.uniform(5.0, 12.0) # 16k-40k ft # Anomalous objects also get ADS-B — anomalous means unusual flight # behaviour (speed/altitude/heading changes), NOT transponder absence. @@ -448,12 +453,19 @@ def _spawn_aircraft(self, mode: str = "detection") -> SimulatedAircraft: return SimulatedAircraft( object_id=oid, - lat=lat, lon=lon, alt_km=alt_km, - vel_east=vel_east, vel_north=vel_north, vel_up=vel_up, - heading_deg=heading, speed_km_s=speed_km_s, - has_adsb=has_adsb, is_anomalous=is_anomalous, + lat=lat, + lon=lon, + alt_km=alt_km, + vel_east=vel_east, + vel_north=vel_north, + vel_up=vel_up, + heading_deg=heading, + speed_km_s=speed_km_s, + has_adsb=has_adsb, + is_anomalous=is_anomalous, object_type=object_type, - adsb_hex=adsb_hex, adsb_callsign=adsb_callsign, + adsb_hex=adsb_hex, + adsb_callsign=adsb_callsign, created_at=self._time, lifetime_s=random.uniform(180, 900) if object_type != "drone" else random.uniform(60, 300), waypoints=route, @@ -466,10 +478,7 @@ def step(self, dt: float, mode: str = "detection"): self._time += dt # Remove expired aircraft - self.aircraft = [ - ac for ac in self.aircraft - if (self._time - ac.created_at) < ac.lifetime_s - ] + self.aircraft = [ac for ac in self.aircraft if (self._time - ac.created_at) < ac.lifetime_s] # Spawn to maintain target count while len(self.aircraft) < self.min_aircraft: @@ -579,9 +588,9 @@ def _fire_anomaly_event(self, ac: SimulatedAircraft): if ev == "hijack": # Sudden supersonic acceleration + 180° heading reversal. # Triggers: supersonic, instant_acceleration, instant_direction_change - ac.speed_km_s = random.uniform(0.36, 0.55) # 360-550 m/s (Mach 1.05-1.6) + ac.speed_km_s = random.uniform(0.36, 0.55) # 360-550 m/s (Mach 1.05-1.6) ac.heading_deg = (ac.heading_deg + random.uniform(140, 220)) % 360 - ac.vel_up = random.uniform(-0.02, 0.02) # erratic climb/descent + ac.vel_up = random.uniform(-0.02, 0.02) # erratic climb/descent elif ev == "spoof": # GPS spoofing: freeze the ADS-B reported position at current location. @@ -638,8 +647,12 @@ def generate_detections_for_node(self, node_id: str, timestamp_ms: int) -> dict: tx_alt_km = node.tx_alt_ft * 0.3048 / 1000.0 rx_enu = (0.0, 0.0, 0.0) tx_enu = _lla_to_enu( - node.tx_lat, node.tx_lon, tx_alt_km, - node.rx_lat, node.rx_lon, rx_alt_km, + node.tx_lat, + node.tx_lon, + tx_alt_km, + node.rx_lat, + node.rx_lon, + rx_alt_km, ) delays = [] @@ -654,8 +667,12 @@ def generate_detections_for_node(self, node_id: str, timestamp_ms: int) -> dict: # Convert aircraft to ENU relative to this node's RX target_enu = _lla_to_enu( - ac.lat, ac.lon, ac.alt_km, - node.rx_lat, node.rx_lon, rx_alt_km, + ac.lat, + ac.lon, + ac.alt_km, + node.rx_lat, + node.rx_lon, + rx_alt_km, ) # Velocity already in km/s ENU vel_enu = (ac.vel_east, ac.vel_north, ac.vel_up) @@ -697,15 +714,17 @@ def generate_detections_for_node(self, node_id: str, timestamp_ms: int) -> dict: else: report_lat = ac.lat report_lon = ac.lon - adsb_list.append({ - "hex": ac.adsb_hex, - "flight": ac.adsb_callsign, - "lat": round(report_lat, 5), - "lon": round(report_lon, 5), - "alt_baro": round(ac.alt_km * 1000 / 0.3048), - "gs": round(speed_ms * 1.94384, 1), - "track": round(ac.heading_deg, 1), - }) + adsb_list.append( + { + "hex": ac.adsb_hex, + "flight": ac.adsb_callsign, + "lat": round(report_lat, 5), + "lon": round(report_lon, 5), + "alt_baro": round(ac.alt_km * 1000 / 0.3048), + "gs": round(speed_ms * 1.94384, 1), + "track": round(ac.heading_deg, 1), + } + ) else: adsb_list.append(None) @@ -735,10 +754,7 @@ def generate_detections_for_node(self, node_id: str, timestamp_ms: int) -> dict: def generate_all_frames(self, timestamp_ms: int) -> dict[str, dict]: """Generate detection frames for all registered nodes.""" - return { - node_id: self.generate_detections_for_node(node_id, timestamp_ms) - for node_id in self.nodes - } + return {node_id: self.generate_detections_for_node(node_id, timestamp_ms) for node_id in self.nodes} def get_aircraft_summary(self) -> list[dict]: """Return summary of all current aircraft (for debugging/monitoring).""" @@ -760,8 +776,7 @@ def get_aircraft_summary(self) -> list[dict]: # ── ML Training Data Batch Export ──────────────────────────────────────── - def generate_training_batch(self, n_frames: int, dt: float = 0.5, - mode: str = "adsb") -> list[dict]: + def generate_training_batch(self, n_frames: int, dt: float = 0.5, mode: str = "adsb") -> list[dict]: """Generate a batch of labeled training frames for ML pipelines. Each output record contains: @@ -794,8 +809,12 @@ def generate_training_batch(self, n_frames: int, dt: float = 0.5, tx_alt_km = node.tx_alt_ft * 0.3048 / 1000.0 rx_enu = (0.0, 0.0, 0.0) tx_enu = _lla_to_enu( - node.tx_lat, node.tx_lon, tx_alt_km, - node.rx_lat, node.rx_lon, rx_alt_km, + node.tx_lat, + node.tx_lon, + tx_alt_km, + node.rx_lat, + node.rx_lon, + rx_alt_km, ) delays = [] @@ -809,8 +828,12 @@ def generate_training_batch(self, n_frames: int, dt: float = 0.5, continue target_enu = _lla_to_enu( - ac.lat, ac.lon, ac.alt_km, - node.rx_lat, node.rx_lon, rx_alt_km, + ac.lat, + ac.lon, + ac.alt_km, + node.rx_lat, + node.rx_lon, + rx_alt_km, ) vel_enu = (ac.vel_east, ac.vel_north, ac.vel_up) @@ -828,20 +851,22 @@ def generate_training_batch(self, n_frames: int, dt: float = 0.5, dopplers.append(round(doppler_noisy, 2)) snrs.append(round(snr, 2)) - ground_truth.append({ - "object_id": ac.object_id, - "lat": round(ac.lat, 5), - "lon": round(ac.lon, 5), - "alt_km": round(ac.alt_km, 2), - "heading_deg": round(ac.heading_deg, 1), - "speed_ms": round(ac.speed_km_s * 1000, 1), - "has_adsb": ac.has_adsb, - "is_anomalous": ac.is_anomalous, - "anomaly_event": ac.anomaly_event if ac.anomaly_fired else None, - "delay_true": round(delay, 4), - "doppler_true": round(doppler, 4), - "is_clutter": False, - }) + ground_truth.append( + { + "object_id": ac.object_id, + "lat": round(ac.lat, 5), + "lon": round(ac.lon, 5), + "alt_km": round(ac.alt_km, 2), + "heading_deg": round(ac.heading_deg, 1), + "speed_ms": round(ac.speed_km_s * 1000, 1), + "has_adsb": ac.has_adsb, + "is_anomalous": ac.is_anomalous, + "anomaly_event": ac.anomaly_event if ac.anomaly_fired else None, + "delay_true": round(delay, 4), + "doppler_true": round(doppler, 4), + "is_clutter": False, + } + ) if ac.has_adsb: if ac.anomaly_event == "spoof" and ac.anomaly_fired: @@ -850,15 +875,17 @@ def generate_training_batch(self, n_frames: int, dt: float = 0.5, else: report_lat = ac.lat report_lon = ac.lon - adsb_list.append({ - "hex": ac.adsb_hex, - "flight": ac.adsb_callsign, - "lat": round(report_lat, 5), - "lon": round(report_lon, 5), - "alt_baro": round(ac.alt_km * 1000 / 0.3048), - "gs": round(ac.speed_km_s * 1000 * 1.94384, 1), - "track": round(ac.heading_deg, 1), - }) + adsb_list.append( + { + "hex": ac.adsb_hex, + "flight": ac.adsb_callsign, + "lat": round(report_lat, 5), + "lon": round(report_lon, 5), + "alt_baro": round(ac.alt_km * 1000 / 0.3048), + "gs": round(ac.speed_km_s * 1000 * 1.94384, 1), + "track": round(ac.heading_deg, 1), + } + ) else: adsb_list.append(None) @@ -868,12 +895,14 @@ def generate_training_batch(self, n_frames: int, dt: float = 0.5, delays.append(round(random.uniform(0, 60), 2)) dopplers.append(round(random.uniform(node.doppler_min, node.doppler_max), 2)) snrs.append(round(random.uniform(4, 7), 2)) - ground_truth.append({ - "object_id": None, - "is_clutter": True, - "is_anomalous": False, - "has_adsb": False, - }) + ground_truth.append( + { + "object_id": None, + "is_clutter": True, + "is_anomalous": False, + "has_adsb": False, + } + ) adsb_list.append(None) record = { @@ -891,8 +920,7 @@ def generate_training_batch(self, n_frames: int, dt: float = 0.5, return records - def export_training_ndjson(self, path: str, n_frames: int = 10000, - dt: float = 0.5, mode: str = "adsb"): + def export_training_ndjson(self, path: str, n_frames: int = 10000, dt: float = 0.5, mode: str = "adsb"): """Export training data as newline-delimited JSON file. Fast bulk export for ML training pipelines. diff --git a/tests/test_beam_azimuth_plumbing.py b/tests/test_beam_azimuth_plumbing.py index f21ddc1..517688a 100644 --- a/tests/test_beam_azimuth_plumbing.py +++ b/tests/test_beam_azimuth_plumbing.py @@ -25,23 +25,30 @@ def _broadside_azimuth(): def _aircraft_at_bearing(bearing_deg, dist_km=20.0): br = math.radians(bearing_deg) lat = _RX_LAT + math.degrees((dist_km * math.cos(br)) / 6371.0) - lon = _RX_LON + math.degrees( - (dist_km * math.sin(br)) / (6371.0 * math.cos(math.radians(_RX_LAT))) - ) + lon = _RX_LON + math.degrees((dist_km * math.sin(br)) / (6371.0 * math.cos(math.radians(_RX_LAT)))) return SimulatedAircraft( - object_id="probe", lat=lat, lon=lon, alt_km=8.0, - vel_east=0.0, vel_north=0.0, vel_up=0.0, - heading_deg=0.0, speed_km_s=0.2, + object_id="probe", + lat=lat, + lon=lon, + alt_km=8.0, + vel_east=0.0, + vel_north=0.0, + vel_up=0.0, + heading_deg=0.0, + speed_km_s=0.2, ) def _node(beam_azimuth_deg, beam_width_deg=41.0): return NodeConfig( node_id="plumb-node", - rx_lat=_RX_LAT, rx_lon=_RX_LON, - tx_lat=_TX_LAT, tx_lon=_TX_LON, + rx_lat=_RX_LAT, + rx_lon=_RX_LON, + tx_lat=_TX_LAT, + tx_lon=_TX_LON, beam_azimuth_deg=beam_azimuth_deg, - beam_width_deg=beam_width_deg, max_range_km=50.0, + beam_width_deg=beam_width_deg, + max_range_km=50.0, ) diff --git a/tests/test_cells_to_metrocells.py b/tests/test_cells_to_metrocells.py index 27734df..d24d455 100644 --- a/tests/test_cells_to_metrocells.py +++ b/tests/test_cells_to_metrocells.py @@ -45,10 +45,16 @@ def test_missing_ops_weight_defaults_to_one(self): assert metro[0].ops_weight == 1.0 def test_explicit_keys_override_defaults(self): - metro = _cells_to_metrocells([{ - "core_lat": 12.5, "core_lon": -34.5, - "radius_km": 42.0, "ops_weight": 9.0, - }]) + metro = _cells_to_metrocells( + [ + { + "core_lat": 12.5, + "core_lon": -34.5, + "radius_km": 42.0, + "ops_weight": 9.0, + } + ] + ) assert metro[0].core_lat == 12.5 assert metro[0].core_lon == -34.5 assert metro[0].radius_km == 42.0 diff --git a/tests/test_coverage_cells.py b/tests/test_coverage_cells.py index 5791d09..6ba576b 100644 --- a/tests/test_coverage_cells.py +++ b/tests/test_coverage_cells.py @@ -28,7 +28,11 @@ def test_returns_one_cell_per_active_ring(self): def test_ring_ids_are_numbered(self): cells = coverage_cells(30, 5) assert [c["ring_id"] for c in cells] == [ - "synth-RING1", "synth-RING2", "synth-RING3", "synth-RING4", "synth-RING5", + "synth-RING1", + "synth-RING2", + "synth-RING3", + "synth-RING4", + "synth-RING5", ] def test_cores_come_from_spec_not_node_positions(self): @@ -117,8 +121,11 @@ def test_active_rings_matches_coverage_cells(self): def test_generated_node_counts_match_cell_ops_weight(self): nodes = generate_fleet( - n_nodes=120, n_cluster=30, n_clusters=5, - use_tower_api=False, seed=7, + n_nodes=120, + n_cluster=30, + n_clusters=5, + use_tower_api=False, + seed=7, ) cells = coverage_cells(30, 5) counts = Counter() diff --git a/tests/test_coverage_ring.py b/tests/test_coverage_ring.py index 352b205..4f1d42b 100644 --- a/tests/test_coverage_ring.py +++ b/tests/test_coverage_ring.py @@ -49,22 +49,19 @@ def test_every_node_carries_float_beam_azimuth(self): assert isinstance(node["beam_azimuth_deg"], float) def test_node_ids_use_prefix(self): - ids = [n["node_id"] for n in _generate_coverage_ring( - 3, _CORE_LAT, _CORE_LON, _TX_TUPLE, prefix="synth-RING2")] + ids = [n["node_id"] for n in _generate_coverage_ring(3, _CORE_LAT, _CORE_LON, _TX_TUPLE, prefix="synth-RING2")] assert ids == ["synth-RING2-0001", "synth-RING2-0002", "synth-RING2-0003"] class TestRingAim: def test_core_aim_points_each_node_at_core(self): for node in _ring(n=8, aim="core"): - bearing_to_core = _bearing_between( - node["rx_lat"], node["rx_lon"], _CORE_LAT, _CORE_LON) + bearing_to_core = _bearing_between(node["rx_lat"], node["rx_lon"], _CORE_LAT, _CORE_LON) assert _angular_diff(node["beam_azimuth_deg"], bearing_to_core) < 1.0 def test_broadside_aim_is_baseline_plus_ninety(self): for node in _ring(n=8, aim="broadside"): - expected = (_bearing_between( - node["rx_lat"], node["rx_lon"], _TX_LAT, _TX_LON) + 90.0) % 360.0 + expected = (_bearing_between(node["rx_lat"], node["rx_lon"], _TX_LAT, _TX_LON) + 90.0) % 360.0 assert node["beam_azimuth_deg"] == round(expected, 2) @@ -73,23 +70,31 @@ def test_core_airspace_covered_by_all_ring_beams(self): nodes = _ring(n=6, aim="core") world = SimulationWorld() aircraft = SimulatedAircraft( - object_id="core-target", lat=_CORE_LAT, lon=_CORE_LON, alt_km=8.0, - vel_east=0.0, vel_north=0.0, vel_up=0.0, - heading_deg=0.0, speed_km_s=0.2, + object_id="core-target", + lat=_CORE_LAT, + lon=_CORE_LON, + alt_km=8.0, + vel_east=0.0, + vel_north=0.0, + vel_up=0.0, + heading_deg=0.0, + speed_km_s=0.2, ) for node in nodes: cfg = NodeConfig( node_id=node["node_id"], - rx_lat=node["rx_lat"], rx_lon=node["rx_lon"], - tx_lat=_TX_LAT, tx_lon=_TX_LON, fc_hz=_FC_HZ, + rx_lat=node["rx_lat"], + rx_lon=node["rx_lon"], + tx_lat=_TX_LAT, + tx_lon=_TX_LON, + fc_hz=_FC_HZ, beam_azimuth_deg=node["beam_azimuth_deg"], beam_width_deg=node["beam_width_deg"], max_range_km=node["max_range_km"], ) world.add_node(cfg) assert world._aircraft_in_detection_cone(aircraft, world.nodes[node["node_id"]]), ( - f"core outside beam of {node['node_id']} " - f"(az={node['beam_azimuth_deg']})" + f"core outside beam of {node['node_id']} (az={node['beam_azimuth_deg']})" ) @@ -107,6 +112,5 @@ def _wedge_is_water(lat, lon): assert len(nodes) == 8 for node in nodes: assert not generator._is_on_water(node["rx_lat"], node["rx_lon"]), ( - f"{node['node_id']} placed on water at " - f"({node['rx_lat']}, {node['rx_lon']})" + f"{node['node_id']} placed on water at ({node['rx_lat']}, {node['rx_lon']})" ) diff --git a/tests/test_hub_radial_spawn.py b/tests/test_hub_radial_spawn.py index 5df1052..0242052 100644 --- a/tests/test_hub_radial_spawn.py +++ b/tests/test_hub_radial_spawn.py @@ -21,8 +21,7 @@ def _world_with_cell(radius_km=70.0): world = SimulationWorld() world.add_node(NodeConfig(node_id="anchor")) - world.metro_cells = [MetroCell( - core_lat=_CORE_LAT, core_lon=_CORE_LON, radius_km=radius_km)] + world.metro_cells = [MetroCell(core_lat=_CORE_LAT, core_lon=_CORE_LON, radius_km=radius_km)] world.frac_metro_traffic = 1.0 return world @@ -44,8 +43,7 @@ def test_arrivals_end_at_core(self): cell = world.metro_cells[0] for _ in range(200): _, _, route = world._radial_pose(cell, "arrival") - end_to_core = _haversine_km( - route[-1][0], route[-1][1], _CORE_LAT, _CORE_LON) + end_to_core = _haversine_km(route[-1][0], route[-1][1], _CORE_LAT, _CORE_LON) assert end_to_core < 12.0 def test_departures_start_at_core(self): @@ -54,8 +52,7 @@ def test_departures_start_at_core(self): cell = world.metro_cells[0] for _ in range(200): _, _, route = world._radial_pose(cell, "departure") - start_to_core = _haversine_km( - route[0][0], route[0][1], _CORE_LAT, _CORE_LON) + start_to_core = _haversine_km(route[0][0], route[0][1], _CORE_LAT, _CORE_LON) assert start_to_core < 12.0 def test_overflights_cross_the_cell_edge_to_edge(self): diff --git a/tests/test_metro_and_arcs.py b/tests/test_metro_and_arcs.py index 26580de..8b81b3c 100644 --- a/tests/test_metro_and_arcs.py +++ b/tests/test_metro_and_arcs.py @@ -2,7 +2,6 @@ Unit tests for metro filtering and orchestrator helpers. """ - from retina_simulation.orchestrator import _KNOWN_METROS, _parse_metro_areas diff --git a/tests/test_node_dict_wire.py b/tests/test_node_dict_wire.py index e65986e..639ba7f 100644 --- a/tests/test_node_dict_wire.py +++ b/tests/test_node_dict_wire.py @@ -14,7 +14,11 @@ def _fleet(): return generate_fleet( - n_nodes=60, seed=42, use_tower_api=False, n_cluster=8, n_clusters=5, + n_nodes=60, + seed=42, + use_tower_api=False, + n_cluster=8, + n_clusters=5, ) @@ -29,9 +33,7 @@ def test_metro_and_solo_nodes_omit_beam_azimuth_key(self): _, non_ring = _split(_fleet()) assert non_ring for node in non_ring: - assert "beam_azimuth_deg" not in node, ( - f"{node['node_id']} leaked a beam_azimuth_deg key" - ) + assert "beam_azimuth_deg" not in node, f"{node['node_id']} leaked a beam_azimuth_deg key" def test_ring_nodes_carry_float_beam_azimuth(self): ring, _ = _split(_fleet()) @@ -50,15 +52,27 @@ def test_backend_float_cast_never_hits_none(self): class TestNodeDictSerializer: def test_none_azimuth_dropped(self): node = GeneratedNodeConfig( - node_id="synth-US-0001", rx_lat=33.9, rx_lon=-84.6, rx_alt_ft=900, - tx_lat=33.7, tx_lon=-84.3, tx_alt_ft=1600, fc_hz=195e6, + node_id="synth-US-0001", + rx_lat=33.9, + rx_lon=-84.6, + rx_alt_ft=900, + tx_lat=33.7, + tx_lon=-84.3, + tx_alt_ft=1600, + fc_hz=195e6, ) assert "beam_azimuth_deg" not in _node_dict(node) def test_explicit_azimuth_kept(self): node = GeneratedNodeConfig( - node_id="synth-RING-0001", rx_lat=33.9, rx_lon=-84.6, rx_alt_ft=900, - tx_lat=33.7, tx_lon=-84.3, tx_alt_ft=1600, fc_hz=195e6, + node_id="synth-RING-0001", + rx_lat=33.9, + rx_lon=-84.6, + rx_alt_ft=900, + tx_lat=33.7, + tx_lon=-84.3, + tx_alt_ft=1600, + fc_hz=195e6, beam_azimuth_deg=212.5, ) assert _node_dict(node)["beam_azimuth_deg"] == 212.5 diff --git a/tests/test_simulation.py b/tests/test_simulation.py index 9bd36a4..ed88dc6 100644 --- a/tests/test_simulation.py +++ b/tests/test_simulation.py @@ -12,34 +12,49 @@ class TestSimulationWorld: def test_aircraft_populated(self): world = SimulationWorld(center_lat=34.0, center_lon=-84.0) - world.add_node(SimNodeConfig( - node_id="sim-node-1", - rx_lat=33.939, rx_lon=-84.651, - tx_lat=33.756, tx_lon=-84.331, - beam_width_deg=41, max_range_km=50, - )) + world.add_node( + SimNodeConfig( + node_id="sim-node-1", + rx_lat=33.939, + rx_lon=-84.651, + tx_lat=33.756, + tx_lon=-84.331, + beam_width_deg=41, + max_range_km=50, + ) + ) for _ in range(50): world.step(0.5, mode="anomalous") assert len(world.aircraft) >= 5 def test_beam_width_preserved(self): world = SimulationWorld(center_lat=34.0, center_lon=-84.0) - world.add_node(SimNodeConfig( - node_id="sim-node-1", - rx_lat=33.939, rx_lon=-84.651, - tx_lat=33.756, tx_lon=-84.331, - beam_width_deg=41, max_range_km=50, - )) + world.add_node( + SimNodeConfig( + node_id="sim-node-1", + rx_lat=33.939, + rx_lon=-84.651, + tx_lat=33.756, + tx_lon=-84.331, + beam_width_deg=41, + max_range_km=50, + ) + ) assert world.nodes["sim-node-1"].beam_width_deg == 41.0 def test_generate_frames(self): world = SimulationWorld(center_lat=34.0, center_lon=-84.0) - world.add_node(SimNodeConfig( - node_id="sim-node-1", - rx_lat=33.939, rx_lon=-84.651, - tx_lat=33.756, tx_lon=-84.331, - beam_width_deg=41, max_range_km=50, - )) + world.add_node( + SimNodeConfig( + node_id="sim-node-1", + rx_lat=33.939, + rx_lon=-84.651, + tx_lat=33.756, + tx_lon=-84.331, + beam_width_deg=41, + max_range_km=50, + ) + ) for _ in range(50): world.step(0.5, mode="anomalous") frames = world.generate_all_frames(timestamp_ms=1000) @@ -50,12 +65,17 @@ def test_generate_frames(self): def test_aircraft_summary(self): world = SimulationWorld(center_lat=34.0, center_lon=-84.0) - world.add_node(SimNodeConfig( - node_id="sim-node-1", - rx_lat=33.939, rx_lon=-84.651, - tx_lat=33.756, tx_lon=-84.331, - beam_width_deg=41, max_range_km=50, - )) + world.add_node( + SimNodeConfig( + node_id="sim-node-1", + rx_lat=33.939, + rx_lon=-84.651, + tx_lat=33.756, + tx_lon=-84.331, + beam_width_deg=41, + max_range_km=50, + ) + ) for _ in range(50): world.step(0.5, mode="anomalous") summary = world.get_aircraft_summary() diff --git a/tests/test_water_rejection.py b/tests/test_water_rejection.py index a675fcb..10eaacd 100644 --- a/tests/test_water_rejection.py +++ b/tests/test_water_rejection.py @@ -20,23 +20,29 @@ def _require_shapely_path(): class TestIsOnWater: - @pytest.mark.parametrize("lat,lon", [ - (37.50, -122.70), # Pacific off Pacifica — the synth-US-0008 ocean bug - (-33.85, 151.25), # Sydney Harbour - (50.5, 0.0), # English Channel - (25.7, -79.5), # Atlantic off Miami - (43.5, -87.0), # mid Lake Michigan (inland lake) - ]) + @pytest.mark.parametrize( + "lat,lon", + [ + (37.50, -122.70), # Pacific off Pacifica — the synth-US-0008 ocean bug + (-33.85, 151.25), # Sydney Harbour + (50.5, 0.0), # English Channel + (25.7, -79.5), # Atlantic off Miami + (43.5, -87.0), # mid Lake Michigan (inland lake) + ], + ) def test_water_points(self, lat, lon): assert _is_on_water(lat, lon) - @pytest.mark.parametrize("lat,lon", [ - (37.60, -122.42), # SF peninsula - (37.46, -122.43), # Half Moon Bay - (33.75, -84.39), # Atlanta - (41.88, -87.63), # Chicago lakeshore - (-33.87, 151.21), # Sydney CBD - ]) + @pytest.mark.parametrize( + "lat,lon", + [ + (37.60, -122.42), # SF peninsula + (37.46, -122.43), # Half Moon Bay + (33.75, -84.39), # Atlanta + (41.88, -87.63), # Chicago lakeshore + (-33.87, 151.21), # Sydney CBD + ], + ) def test_land_points(self, lat, lon): assert not _is_on_water(lat, lon) @@ -45,8 +51,12 @@ class TestFleetOffWater: @pytest.mark.parametrize("regions,clusters", [(["us"], 5), (["eu", "au"], 0)]) def test_real_and_display_positions_on_land(self, regions, clusters): nodes = generate_fleet( - n_nodes=80, regions=regions, seed=11, use_tower_api=False, - n_cluster=8 if clusters else 0, n_clusters=clusters, + n_nodes=80, + regions=regions, + seed=11, + use_tower_api=False, + n_cluster=8 if clusters else 0, + n_clusters=clusters, ) assert nodes bad = []