Rename the simulator to Overwatch and organize the repository for presentation.
Some checks failed
AERIS-10 CI / Python Lint + Tests (push) Has been cancelled
AERIS-10 CI / MCU Firmware Tests (push) Has been cancelled
AERIS-10 CI / FPGA Regression (push) Has been cancelled
AERIS-10 CI / Cross-Layer Contract Tests (push) Has been cancelled

The operator interface now uses that name. The radar design, parts list, and simulation sit in separate directories, and the earlier simulator and project license are removed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-21 06:04:46 +00:00
parent 62e09916c8
commit b3608ecfd7
1439 changed files with 23822 additions and 6571 deletions

View File

@@ -46,8 +46,8 @@ jobs:
- name: Unit tests
run: >
uv run pytest
9_Firmware/9_3_GUI/test_GUI_V65_Tk.py
9_Firmware/9_3_GUI/test_v7.py
firmware/9_3_GUI/test_GUI_V65_Tk.py
firmware/9_3_GUI/test_v7.py
-v --tb=short
# ===========================================================================
@@ -66,7 +66,7 @@ jobs:
- name: Build and run MCU tests
run: make test
working-directory: 9_Firmware/9_1_Microcontroller/tests
working-directory: firmware/9_1_Microcontroller/tests
# ===========================================================================
# FPGA RTL Regression (25 testbenches + lint)
@@ -83,7 +83,7 @@ jobs:
- name: Run full FPGA regression
run: bash run_regression.sh
working-directory: 9_Firmware/9_2_FPGA
working-directory: firmware/9_2_FPGA
# ===========================================================================
# Cross-Layer Contract Tests (Python ↔ Verilog ↔ C)
@@ -112,5 +112,5 @@ jobs:
- name: Run cross-layer contract tests
run: >
uv run pytest
9_Firmware/tests/cross_layer/test_cross_layer_contract.py
firmware/tests/cross_layer/test_cross_layer_contract.py
-v --tb=short

60
.gitignore vendored
View File

@@ -3,35 +3,35 @@
*.vcd
# Debug / scratch RTL (not part of the design)
9_Firmware/9_2_FPGA/debug_*.v
9_Firmware/9_2_FPGA/tb/tb_fft_debug*.v
9_Firmware/9_2_FPGA/tb/tb_fft_mini*.v
9_Firmware/9_2_FPGA/tb/tb_bram_debug.v
firmware/9_2_FPGA/debug_*.v
firmware/9_2_FPGA/tb/tb_fft_debug*.v
firmware/9_2_FPGA/tb/tb_fft_mini*.v
firmware/9_2_FPGA/tb/tb_bram_debug.v
# Local simulation artifacts and CSV outputs
9_Firmware/9_2_FPGA/cic_*.csv
9_Firmware/9_2_FPGA/fir_*.csv
9_Firmware/9_2_FPGA/nco_*.csv
9_Firmware/9_2_FPGA/ddc_*.csv
9_Firmware/9_2_FPGA/mf_pipeline_output.csv
9_Firmware/9_2_FPGA/mf_chain_autocorr.csv
9_Firmware/9_2_FPGA/rbd_mode00_ramp.csv
9_Firmware/9_2_FPGA/rbd_mode01_peak.csv
9_Firmware/9_2_FPGA/rbd_mode10_avg.csv
9_Firmware/9_2_FPGA/rbd_mode10_ramp.csv
9_Firmware/9_2_FPGA/rmc_autoscan.csv
9_Firmware/9_2_FPGA/tb/mf_chain_autocorr.csv
9_Firmware/9_2_FPGA/tb/rbd_mode00_ramp.csv
9_Firmware/9_2_FPGA/tb/rbd_mode01_peak.csv
9_Firmware/9_2_FPGA/tb/rbd_mode10_avg.csv
9_Firmware/9_2_FPGA/tb/rbd_mode10_ramp.csv
9_Firmware/9_2_FPGA/tb/rmc_autoscan.csv
9_Firmware/9_2_FPGA/tb_usb_data_interface.csv
firmware/9_2_FPGA/cic_*.csv
firmware/9_2_FPGA/fir_*.csv
firmware/9_2_FPGA/nco_*.csv
firmware/9_2_FPGA/ddc_*.csv
firmware/9_2_FPGA/mf_pipeline_output.csv
firmware/9_2_FPGA/mf_chain_autocorr.csv
firmware/9_2_FPGA/rbd_mode00_ramp.csv
firmware/9_2_FPGA/rbd_mode01_peak.csv
firmware/9_2_FPGA/rbd_mode10_avg.csv
firmware/9_2_FPGA/rbd_mode10_ramp.csv
firmware/9_2_FPGA/rmc_autoscan.csv
firmware/9_2_FPGA/tb/mf_chain_autocorr.csv
firmware/9_2_FPGA/tb/rbd_mode00_ramp.csv
firmware/9_2_FPGA/tb/rbd_mode01_peak.csv
firmware/9_2_FPGA/tb/rbd_mode10_avg.csv
firmware/9_2_FPGA/tb/rbd_mode10_ramp.csv
firmware/9_2_FPGA/tb/rmc_autoscan.csv
firmware/9_2_FPGA/tb_usb_data_interface.csv
# Co-sim intermediate CSVs (regenerated by scripts)
9_Firmware/9_2_FPGA/tb/cosim/rtl_doppler_*.csv
9_Firmware/9_2_FPGA/tb/cosim/compare_doppler_*.csv
9_Firmware/9_2_FPGA/tb/cosim/rtl_multiseg_*.csv
firmware/9_2_FPGA/tb/cosim/rtl_doppler_*.csv
firmware/9_2_FPGA/tb/cosim/compare_doppler_*.csv
firmware/9_2_FPGA/tb/cosim/rtl_multiseg_*.csv
# macOS
.DS_Store
@@ -44,13 +44,13 @@ __pycache__/
10_docs/
# Local simulation workspaces and generated outputs
5_Simulations/generated/
5_Simulations/aeris10_antenna_sim.py
5_Simulations/aeris10_radar_sim.py
hardware/rf-simulations/generated/
hardware/rf-simulations/aeris10_antenna_sim.py
hardware/rf-simulations/aeris10_radar_sim.py
# Local FPGA report dumps and scratch constraints
9_Firmware/9_2_FPGA/reports/
9_Firmware/9_2_FPGA/synth_only.xdc
firmware/9_2_FPGA/reports/
firmware/9_2_FPGA/synth_only.xdc
# Local timing closure report snapshots
build*_reports/

View File

@@ -20,11 +20,13 @@ Never run bare package installation commands without the `sfw` prefix.
| Path | Contents |
|------|----------|
| `4_Schematics and Boards Layout/` | KiCad schematics, Gerbers, BOM/CPL |
| `9_Firmware/9_1_Microcontroller/` | STM32 MCU C/C++ firmware and unit tests |
| `9_Firmware/9_2_FPGA/` | Verilog RTL, constraints, testbenches, build scripts |
| `9_Firmware/9_3_GUI/` | Python radar dashboard (Tkinter/PyQt6) and CLI tools |
| `9_Firmware/tests/cross_layer/` | Python-based system invariant/contract tests |
| `simulation/` | Overwatch |
| `schematics/` | Schematics, Gerbers, BOM/CPL |
| `parts/` | Consolidated parts list and datasheets |
| `firmware/9_1_Microcontroller/` | STM32 MCU C/C++ firmware and unit tests |
| `firmware/9_2_FPGA/` | Verilog RTL, constraints, testbenches, build scripts |
| `firmware/9_3_GUI/` | Python radar dashboard (Tkinter/PyQt6) and CLI tools |
| `firmware/tests/cross_layer/` | Python-based system invariant/contract tests |
| `docs/` | GitHub Pages documentation site |
## Code Standards & Tooling
@@ -57,26 +59,26 @@ We use GitHub Actions for CI, which runs four main jobs on every PR. Run these l
### 1. Python & Linting
```bash
uv run ruff check .
cd 9_Firmware/9_3_GUI
cd firmware/9_3_GUI
uv run pytest test_GUI_V65_Tk.py test_v7.py -v
```
### 2. FPGA Regression
```bash
cd 9_Firmware/9_2_FPGA
cd firmware/9_2_FPGA
bash run_regression.sh
```
This runs five phases (Lint, Changed Modules, Integration, Signal Processing, Infrastructure, and **P0 Adversarial Tests**). All must pass.
### 3. MCU Unit Tests
```bash
cd 9_Firmware/9_1_Microcontroller/tests
cd firmware/9_1_Microcontroller/tests
make clean && make
```
### 4. Cross-Layer Contract Tests
```bash
uv run pytest 9_Firmware/tests/cross_layer/test_cross_layer_contract.py -v
uv run pytest firmware/tests/cross_layer/test_cross_layer_contract.py -v
```
## Before merging: CI checklist
@@ -102,9 +104,9 @@ All PRs must pass CI:
- [ ] `uv run ruff check .` — no lint errors
- [ ] `uv run pytest test_GUI_V65_Tk.py test_v7.py -v` — all pass
- [ ] `cd 9_Firmware/9_2_FPGA && bash run_regression.sh` — all 5 phases pass
- [ ] `cd 9_Firmware/9_1_Microcontroller/tests && make clean && make` — pass
- [ ] `uv run pytest 9_Firmware/tests/cross_layer/test_cross_layer_contract.py` — pass
- [ ] `cd firmware/9_2_FPGA && bash run_regression.sh` — all 5 phases pass
- [ ] `cd firmware/9_1_Microcontroller/tests && make clean && make` — pass
- [ ] `uv run pytest firmware/tests/cross_layer/test_cross_layer_contract.py` — pass
- [ ] `git diff --check` — no whitespace issues
- [ ] PR targets `develop` branch

View File

@@ -1,206 +0,0 @@
# Generalized Projectile Defense Simulation
## Overview
This simulation models a realistic defense scenario where an attack projectile (drone, missile, or ballistic re-entry vehicle) approaches a city defended by a ring of radars and interceptor drones. The simulation uses proper physics, realistic flight profiles, and validated error models.
It is **generalized** — the attack profile is selected via **presets** for different projectile types. Each preset defines the speed, cruise altitude band, descent geometry, and terminal altitude. The default preset is the jet-powered **Shahed-238 (Geran-3)**. All attack parameters are also editable manually for custom scenarios.
## Projectile Presets
| Preset | Type | Speed | Cruise Alt | Descent Start | Terminal Alt | Notes |
|--------|------|-------|-----------|---------------|--------------|-------|
| `shahed-136` | Prop drone (Geran-2) | 50 m/s | 100–300 m | 5 km | 0 m | Slow, low-altitude |
| `shahed-238` | Jet drone (Geran-3) | 150 m/s | 1000–2000 m | 8 km | 200 m | Default; fast, mid-altitude |
| `cruise-missile` | Cruise missile | 300 m/s | 50–150 m | 5 km | 0 m | Fast, sea-skimming |
| `ballistic-reentry` | Ballistic re-entry | 800 m/s | 60000–80000 m | 40 km | 0 m | Very fast, high-altitude entry |
| `prop-drone` | Small UAS | 30 m/s | 100–300 m | 3 km | 0 m | Slow, low-altitude |
### Shahed-238 Specifications (Jet-Powered Variant)
Based on research of open-source intelligence and technical analysis:
| Parameter | Value | Notes |
|-----------|-------|-------|
| Speed (cruise) | ~150 m/s (540 km/h) | 3-4x faster than Shahed-136 |
| Cruise altitude | 1000-2000 m | Typical operational band |
| Terminal altitude | 200 m | Descent target altitude |
| Descent start | ~8 km from target | Gradual descent begins |
| Warhead | 30-50 kg | High explosive |
| Guidance | GPS/INS + terminal seeker | Autonomous |
| Range | ~1500-2000 km | One-way attack |
Sources:
- [CSIS Missile Threat - Shahed-238](https://missilethreat.csis.org/missile/shahed-238/)
- [BattlePolicy - Shahed-238](https://www.battlepolicy.com/shahed-238/)
- [Wikipedia - HESA Shahed 136](https://en.wikipedia.org/wiki/HESA_Shahed_136)
- [drone-warfare.com - Shahed variants](https://drone-warfare.com/research/shahed-136/)
## Flight Profile
Each preset follows a realistic two-phase flight profile:
1. **Cruise phase**: Maintains constant altitude (within the preset's cruise band, randomized per run) at cruise speed
2. **Descent phase**: Begins `descent_start` distance from the target, gradually descends to the preset's terminal altitude
The descent is gradual (not a nosedive), consistent with reported loitering-munition attack patterns. The projectile uses GPS/INS navigation to follow a pre-planned waypoint course, with a terminal seeker for the final approach. For the ballistic re-entry preset, the high cruise altitude and long descent start model a high-altitude entry with a terminal pull-down.
## Defense System
### Radar Network
- **8 AERIS-10 radars** positioned in a ring around the city (2 km outside city radius)
- Each radar has **20 km range**
- Single-measurement accuracy: **±20 m (1σ)**
- Detection requires target to be within radar range
- Multiple radars improve position accuracy: σ_combined = σ_single / √n
### Interceptor Drones
- **24 drones** positioned in a ring around the city (8 km outside city radius)
- Drone speed: **100 m/s** (slower than most presets' cruise speed, e.g. Shahed-238 at 150 m/s)
- IR homing range: **5 km**
- IR tracking accuracy: **±3 m (1σ)**
- Kill radius: **20 m** (3D)
- **3 closest drones** launch per threat (selected by fastest viable intercept time)
## Physics Models
### 1. Radar Detection
Each radar measures:
- Range (with Gaussian noise, σ = 20 m)
- Azimuth (with noise proportional to 1/distance)
- Elevation (with noise proportional to 1/distance)
The noise model reflects real radar behavior: angular accuracy improves with range, but absolute position error grows.
### 2. Position Estimation
The 3D position is estimated by averaging the individual 3D point estimates from each radar (bearing + range). This is a least-squares estimate that properly constrains all three coordinates (x, y, z), including altitude.
**Key insight**: With coplanar radars (all at z=0), range measurements alone cannot determine altitude. The bearing (azimuth + elevation) measurements are required to constrain the z-coordinate.
### 3. Trajectory Estimation
The velocity is estimated by linear regression of position vs. time:
```
pos(t) = pos0 + vel * t
```
The slope (vel) is the estimated velocity. R² measures fit quality. The standard error of the slope decreases as more data points accumulate: σ_vel = σ_pos / √(Sxx), where Sxx = Σ(t - t̄)².
**This is the key mechanism by which error decreases with more data points**: the best-fit line converges as more observations are added.
### 4. Intercept Calculation
The drone intercept solves the classic pursuer-target problem:
```
|drone_pos + v_d * t - target_pos - v_s * t| = 0
```
where v_d = drone_speed * d (d is a unit vector).
This reduces to a quadratic in t:
```
(|v_s|² - v_d²) * t² + 2*(rel_pos · v_s) * t + |rel_pos|² = 0
```
The **smallest positive root** gives the earliest intercept time. The intercept point is where the target will be at that time.
**Critical insight**: A slower pursuer (drone at 100 m/s vs a faster target such as Shahed-238 at 150 m/s) can only intercept if:
- The target is moving TOWARD the pursuer (rel_pos · v_s < 0)
- The geometry is favorable (drones positioned in the approach corridor)
- The intercept happens before the target reaches the city
### 5. Drone Guidance
Drones fly in 3D straight lines toward the computed intercept point. The intercept point is recomputed every 5 seconds as new trajectory estimates become available (proportional navigation). When within 5 km, the drone switches to IR homing for final precision.
### 6. Error Propagation
- Radar position error: σ_pos = 20/√n m (decreases with data points)
- 95% confidence interval: 1.96 * σ_pos
- IR error: σ_IR = 3/√n_drones m (decreases with tracking drones)
- Combined error: σ_combined = √(σ_radar² + σ_IR²)
## Validation Tests
The simulation is validated against known analytical solutions:
| Test | Description | Result |
|------|-------------|--------|
| Trajectory continuity | Target speed is constant, altitude profile correct | PASS |
| 1D intercept | Analytical solution: t = 48 s for specific geometry | PASS (t = 48.0 s) |
| No-solution case | Target moving away, no intercept possible | PASS (graceful fallback) |
| 3D intercept | Intercept altitude matches target altitude | PASS |
| Radar detection | In-range detected, out-of-range not | PASS |
| Position estimation | Error < 30 m with 3 radars | PASS (10.2 m) |
| Trajectory estimation | Velocity error < 10 m/s | PASS (0.00 m/s) |
| Simulation runs | Full simulation completes without errors | PASS |
| Interception rate | Rate is within [0, 100]% for default preset | PASS (~67%) |
| Presets vary rates | Slow/low target >= fast/high target intercept rate | PASS |
| Drone selection | Only viable drones launched | PASS |
**All 11 tests pass.**
## Monte Carlo Results (100 Trials per Preset)
| Preset | Interception Rate | Avg Detect | Avg Collision | Avg Impact |
|--------|-------------------|-----------|---------------|-----------|
| `shahed-136` (prop, low) | **100%** | 7.0 s | 88.5 s | — |
| `shahed-238` (jet, mid) | **68%** | 27.2 s | 85.8 s | 170.0 s |
| `cruise-missile` (fast, low) | **36%** | 64.7 s | 98.9 s | 137.6 s |
| `ballistic-reentry` (very fast, high) | **0%** | 110.9 s | — | 114.6 s |
| `prop-drone` (slow, low) | **100%** | 1.0 s | 44.2 s | — |
(Exact values are regenerated each run via `montecarlo_stats.json`; the table shows representative values.)
## Key Findings
1. **Interception rate scales with target speed and altitude.** Slow, low-altitude projectiles (Shahed-136, small UAS) are nearly always intercepted, while fast, high-altitude ones (ballistic re-entry) are effectively unstoppable by this drone defense. The jet-powered Shahed-238 sits in the middle at ~68%.
2. **Detection happens before the target is in the danger zone.** The target enters radar range (20 km) and is detected after accumulating 3+ observations. Faster targets get detected later in absolute terms but with less margin.
3. **The interception window is the gap between detection and impact.** When interception succeeds, it occurs well before the impact time. When it fails, the target reaches the city. This gap is the decision time available to the defense.
4. **The interception rate depends strongly on geometry.** Approaches from directions where the drones are well-positioned in the approach corridor have higher interception rates. Approaches from directions where the drones are on the opposite side of the city fail.
5. **Altitude matters.** Higher cruise altitudes give the drones more time to climb, but also increase the distance they must cover. The optimal altitude depends on the approach geometry.
## Why the Rates Are Realistic
The per-preset interception rates reflect the fundamental physics of a slower defender (100 m/s drones) vs. faster or equal-speed attackers:
- A slow target (30–50 m/s) is easily caught — the drones have ample time to close the gap, so interception is near 100%.
- A mid-speed jet target (150 m/s) outruns part of the pursuit; only favorable geometries allow a timely intercept, giving ~68%.
- A fast sea-skimming cruise missile (300 m/s) is hard to catch, especially at low altitude where climb time hurts; ~36%.
- A very fast high-altitude ballistic re-entry (800 m/s) is far beyond the drones' capability; 0%.
This is consistent with real-world observations that drone-vs-drone interception is challenging against faster targets, and that the interception rate depends heavily on the target's speed, altitude, approach direction, and the positioning of the defenders.
## Files
| File | Description |
|------|-------------|
| `defense_sim.py` | Python simulation with proper physics and projectile presets |
| `test_defense_sim.py` | Validation test suite (11 tests) |
| `defense_sim.html` | Interactive HTML visualization with preset dropdown |
| `montecarlo_stats.json` | Monte Carlo results (100 trials per preset) |
## Running the Simulation
```bash
# Run the Python simulation (runs all presets)
python3 defense_sim.py
# Run the validation tests
python3 test_defense_sim.py
# Open the HTML viewer in a browser (serve over HTTP for the stats table)
python3 -m http.server 8000
# then open http://localhost:8000/defense_sim.html
```
### Selecting a Projectile Preset
- **In the HTML:** use the **Projectile Preset** dropdown at the top of the Attack Parameters panel. Selecting a preset prefills all attack parameters (start distance, speed, cruise altitude band, descent start, terminal altitude) and highlights that preset's row in the stats table. Choose **Custom** to set all parameters manually.
- **In Python:** pass `preset='shahed-238'` (or any preset name) to `run_simulation()` or `monte_carlo()`. Omit it to use the default (`shahed-238`).
## References
1. CSIS Missile Threat Project - Shahed-238: https://missilethreat.csis.org/missile/shahed-238/
2. BattlePolicy - Shahed-238: https://www.battlepolicy.com/shahed-238/
3. Wikipedia - HESA Shahed 136: https://en.wikipedia.org/wiki/HESA_Shahed_136
4. drone-warfare.com - Shahed-136 and Geran variants: https://drone-warfare.com/research/shahed-136/
5. AERIS-10 radar: 10.5 GHz X-band FMCW phased array, 20 km range

154
Licence
View File

@@ -1,154 +0,0 @@
CERN Open Hardware Licence Version 2 - Permissive
Preamble
CERN has developed this licence to promote collaboration among
hardware designers and to provide a legal tool for the dissemination
of hardware designs. The CERN Open Hardware Licence Version 2 is
available in two variants: Weakly Reciprocal (CERN-OHL-W) and
Permissive (CERN-OHL-P). The CERN-OHL-P is for you if you want to
maximise the ability of people to use your hardware design
including for use in commercial applications with a minimum of
obligations. It is a continuation of the CERN Open Hardware Licence
version 1 (but with a new structure) and is focused more narrowly
on the design itself and what you must do to give proper credit and
to allow others to build and modify the design.
Please see the CERN-OHL version 2 family page (www.ohwr.org/cernohl)
for more information.
Terms and conditions
1. Definitions
In this Licence, the following terms have the following meanings:
“Additive” means something you add to the Design that was not part
of it when it was first published by the Licensor.
“Design” means the design documentation for the hardware that is
licensed under this Licence, in any format or medium. It may
include both the Hardware Description and the Modifications.
“Documentation” means all the materials that are provided by the
Licensor or any Contributor to describe the Design, including
schematics, netlists, PCB layout files, bills of materials,
drawings, specifications, and the like, and also includes the
Modifications.
“Hardware” means any physical device that embodies the Design or
any part thereof, in any form.
“Hardware Description” means the files that specify the Hardware in
a form that can be read by a computer and that, when used
appropriately, can be used to fabricate Hardware, for example in
whole or in part.
“Licence” means this licence (the CERN-OHL-P).
“Licensor” means the person or entity that first publishes the
Design under the terms of this Licence.
“Modification” means any change to the Design, including changes to
the Hardware Description.
“Product” means either an unchanged Design or a Design with
Modifications, that is offered for sale, or otherwise made
available for commercial use, whether as Hardware or in other forms.
“Source” means the format of the Design that is preferred for
making Modifications, including all the files that are provided for
that purpose.
“You” means any person or entity that exercises any right under
this Licence.
2. What this Licence covers
The Licensor grants you a worldwide, royalty-free, non-exclusive,
perpetual licence to:
a) use the Design and any associated Documentation,
b) make Modifications,
c) communicate the Design and any Documentation, including by
making it available for copying and modification,
d) manufacture and distribute Hardware that implements the Design
or any part thereof,
e) sell Products, and
f) otherwise deal in the Design or any part thereof,
all in each case subject to the conditions set out in this Licence.
3. Conditions
3.1. Availability of Source
The Design and any Documentation that is made available under this
Licence must be made available in Source format. If the Design is
available in a format that is not Source, you must also make a
version of the Design in Source format available, and you must
inform others of how to obtain it.
3.2. Retention of copyright and other notices
You must retain all copyright and other notices that appear in the
Design or Documentation.
3.3. Notice of Modifications
If you make Modifications, you must include a notice in any
Distribution of the Design or Documentation, stating that
Modifications have been made, by whom, and in what they consist.
3.4. Distribution of Modifications
If you distribute any Design or Documentation, whether unchanged or
with Modifications, you must distribute it under this Licence, and
you must inform the recipient that the Design or Documentation is
licensed under this Licence.
3.5. No implicit approval
The Licensor shall not be taken to have approved any Modifications
unless the Licessor expressly approves them in writing.
4. Disclaimer of warranty
THE DESIGN AND ANY DOCUMENTATION IS PROVIDED "AS IS" AND THE
LICENSOR MAKES NO WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO WARRANTIES OF MERCHANTABILITY, SATISFACTORY QUALITY,
NON-INFRINGEMENT, TITLE, AND FITNESS FOR A PARTICULAR PURPOSE. THE
DESIGN MAY CONTAIN DESIGN DEFECTS OR ERRORS THAT MAKE IT
UNSUITABLE FOR USE IN CERTAIN APPLICATIONS. THE ENTIRE RISK AS TO
ITS QUALITY AND PERFORMANCE REMAINS WITH YOU.
5. Limitation of liability
TO THE EXTENT NOT PROHIBITED BY LAW, IN NO EVENT WILL THE LICENSOR
BE LIABLE FOR ANY DAMAGES WHATSOEVER, INCLUDING WITHOUT LIMITATION
LOST PROFITS, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, OR ANY
OTHER LOSSES ARISING OUT OF THE USE OF THE DESIGN OR ANY
DOCUMENTATION, HOWEVER CAUSED, WHETHER UNDER THEORY OF CONTRACT,
TORT (INCLUDING NEGLIGENCE), PRODUCTS LIABILITY, OR OTHERWISE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
6. Termination
This Licence is perpetual except that if you fail to comply with
its terms, your rights under this Licence will terminate immediately.
If you bring or defend any litigation or other claim alleging that
the Design or any part thereof infringes any intellectual property
right, all your rights under this Licence granted by the Licensor
will terminate forty-five days after you bring or defend such claim.
7. General
This Licence shall be governed by the laws of the country of the
Licensor, without reference to its conflict of laws provisions.
If any part of this Licence is held to be invalid or unenforceable,
the remaining parts shall remain in full force and effect.
---------------------------------------------------------------------
CERN-OHL-P is a variant of the CERN-OHL family. For more information
see www.ohwr.org/cernohl.

283
README.md
View File

@@ -1,260 +1,55 @@
[![GitHub stars](https://img.shields.io/github/stars/NawfalMotii79/PLFM_RADAR?style=social)](https://github.com/NawfalMotii79/PLFM_RADAR/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/NawfalMotii79/PLFM_RADAR?style=social)](https://github.com/NawfalMotii79/PLFM_RADAR/network/members)
[![GitHub watchers](https://img.shields.io/github/watchers/NawfalMotii79/PLFM_RADAR?style=social)](https://github.com/NawfalMotii79/PLFM_RADAR/watchers)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Hardware: CERN-OHL-P](https://img.shields.io/badge/Hardware-CERN--OHL--P-blue.svg)](https://ohwr.org/cern_ohl_p_v2.txt)
# AERIS-10
# AERIS-10: Open Source Pulse Linear Frequency Modulated Phased Array Radar
10.5 GHz pulse linear-FM phased-array radar, with Overwatch beside the hardware design.
[![Hardware: CERN-OHL-P](https://img.shields.io/badge/Hardware-CERN--OHL--P-blue.svg)](https://ohwr.org/cern_ohl_p_v2.txt)
[![Software: MIT](https://img.shields.io/badge/Software-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Status: Alpha](https://img.shields.io/badge/Status-Alpha-orange)](https://github.com/NawfalMotii79/PLFM_RADAR)
[![Features: Work in Progress](https://img.shields.io/badge/Features-Work_in_Progress-yellow)](https://github.com/NawfalMotii79/PLFM_RADAR/issues)
[![Frequency: 10.5GHz](https://img.shields.io/badge/Frequency-10.5GHz-blue)](https://github.com/NawfalMotii79/PLFM_RADAR)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/NawfalMotii79/PLFM_RADAR/pulls)
| | |
|---|---|
| [simulation/](simulation/) | Overwatch. Physics, radar model, and the operator view. |
| [schematics/](schematics/) | Board schematics, stack-up, and production Gerbers. |
| [parts/parts-list.csv](parts/parts-list.csv) | Parts list taken from the production bills of materials. |
| [parts/cost.md](parts/cost.md) | Cost: the bills of materials do not contain prices. |
## Radar
AERIS-10 is an open-source, low-cost 10.5 GHz phased array radar system featuring Pulse Linear Frequency Modulated (LFM) modulation. Available in two versions (3km and 20km range), it's designed for researchers, drone developers, and serious SDR enthusiasts who want to explore and experiment with phased array radar technology.
Two builds share the signal chain.
![AERIS-10 Antenna Array](https://raw.githubusercontent.com/NawfalMotii79/PLFM_RADAR/main/8_Utils/Antenna_Array.jpg)
- **AERIS-10N**, about 3 km, 8×16 patch array.
- **AERIS-10X**, about 20 km, 32×16 dielectric-filled slotted waveguide, plus a 10 W QPA2962 stage on each of 16 amplifier boards.
## 📡 Overview
The array steers elevation electronically over ±45°. Azimuth on the prototype is a stepper. Carrier is 10.5 GHz. The FPGA does chirp generation, pulse compression, Doppler, MTI, and CFAR. An STM32 sequences power, clocks, phase shifters, and the GPS/IMU.
The AERIS-10 project aims to democratize radar technology by providing a fully open-source, modular, and hackable radar system. Whether you're a university researcher, a drone startup, or an advanced maker, AERIS-10 offers a platform for experimenting with beamforming, pulse compression, Doppler processing, and target tracking.
![Antenna array](hardware/mechanical/Antenna_Array.jpg)
## 🔬 Key Features
![System diagram](hardware/diagrams/RADAR_V6_V2.png)
- **Open Source Hardware & Software** - Complete schematics, PCB layouts, firmware, and software available
- **Dual Version Availability**:
- **AERIS-10N (Nexus)**: 3km range with 8x16 patch antenna array
- **AERIS-10E (Extended)**: 20km range with 32x16 dielectric-filled slotted waveguide array
- **Full Electronic Beam Steering** - ±45° electronic steering in elevation and azimuth
- **Advanced Signal Processing** - On-board FPGA handles pulse compression, Doppler FFT, MTI, and CFAR
- **Python GUI** - User-friendly interface with map integration
- **GPS/IMU Integration** - Real-time position and attitude correction
- **Modular Design** - Separate power management, frequency synthesis, and RF boards
### Where the rest of the design lives
## 🏗️ System Architecture
| Path | Contents |
|---|---|
| `schematics/4_6_Schematics/` | Main board, power board, frequency synthesizer, power amplifier, antennas |
| `schematics/4_7_Production Files/` | Gerbers, pick-and-place, and the original board bills of materials |
| `parts/datasheets/` | Component datasheets |
| `hardware/diagrams/` | Functional diagram |
| `hardware/power/` | Power-rail and sequencing sheet |
| `hardware/description/` | Project description |
| `hardware/rf-simulations/` | Antenna, filter, and waveguide studies. These are circuit simulations, not Overwatch. |
| `hardware/mechanical/` | Enclosure and waveguide drawings |
| `hardware/application-notes/` | ADAR1000 user guide |
| `firmware/` | STM32, FPGA, and the radar GUI |
| `docs/` | Architecture notes, bring-up, and reports |
![AERIS-10 System Diagram](https://raw.githubusercontent.com/NawfalMotii79/PLFM_RADAR/main/8_Utils/RADAR_V6_V2.png)
## Overwatch
### Hardware Components
From the repository root:
The AERIS-10 main sub-systems are:
```bash
cd simulation
python -m pip install -e '.[server,dev,flight,performance]'
python -m defense_sim.cli --steps 500 --seed 7
```
- **Power Management Board** - Supplies all necessary voltage levels to the electronics components with proper filtering and sequencing (sequencing ensured by the microcontroller)
The earlier simulator, the standalone scripts and the single-page viewer that used to sit in the repository root, has been removed.
- **Frequency Synthesizer Board** - Uses a high-performance Low Jitter Clock Generator (AD9523-1) that supplies phase-aligned clock references for:
- RX and TX Frequency Synthesizers (ADF4382)
- DAC
- ADC
- FPGA
## Contact
- **Main Board** containing:
- **DAC** - Generates the RADAR Chirps
- **2x Microwave Mixers (LTC5552)** - For up-conversion and IF-down-conversion
- **4x 4-Channel Phase Shifters (ADAR1000)** - For RX and TX chain beamforming
- **16x Front End Chips (ADTR1107)** - Used for both Low Noise Amplifying (RX) and Power Amplifying (TX) stages
- **XC7A50T FPGA** - Handles RADAR Signal Processing on the upstream FTG256 board:
- PLFM Chirps generation via the DAC
- Raw ADC data read
- Hybrid Automatic Gain Control (AGC) — cross-layer FPGA/STM32/GUI loop
- I/Q Baseband Down-Conversion
- Decimation
- Filtering
- Forward FFT
- Pulse Compression
- Doppler, MTI and CFAR processing
- USB Interface
- **STM32F746xx Microcontroller** - Used for:
- Power-up and power-down sequencing (see Power Management Excel File)
- FPGA communication
- Setup and Interface with:
- Clock Generator (AD9523-1)
- 2x Frequency Synthesizers (ADF4382)
- 4x 4-Channel Phase Shifters (ADAR1000) for RADAR pulse sequencing
- 2x ADS7830 8-channel I²C ADCs (Main Board, U88 @ 0x48 / U89 @ 0x4A) for 16x Idq measurement, one per PA channel, each sensed through a 5 mΩ shunt on the PA board and an INA241A3 current-sense amplifier (x50) on the Main Board
- 2x DAC5578 8-channel I²C DACs (Main Board, U7 @ 0x48 / U69 @ 0x49) for 16x Vg control, one per PA channel; closed-loop calibrated at boot to the target Idq
- GPS module (UM982) for GUI map centering and per-detection position tagging
- GY-85 IMU for pitch/roll correction of target coordinates
- BMP180 Barometer
- Stepper Motor
- 1x ADS7830 8-channel I²C ADC (Main Board, U10) reading 8 thermistors for thermal monitoring; a single GPIO (EN_DIS_COOLING) switches the cooling fans on when any channel exceeds the threshold
- RF switches
- **16x Power Amplifier Boards** - Used only for AERIS-10E version, featuring 10Watt QPA2962 GaN amplifier for extended range
- **Antenna Arrays**:
- **AERIS-10N (Nexus)** - 8x16 patch antenna array
- **AERIS-10X (Extended)** - 32x16 dielectric-filled slotted waveguide antenna array
- **Miscellaneous Components**:
- Slip-Ring
- Stepper Motor and drivers
- Cooling Fans
- Enclosure
### Processing Pipeline
1. **Waveform Generation** - DAC creates LFM chirps
2. **Up/Down Conversion** - LTC5552 mixers handle frequency translation
3. **Beam Steering** - ADAR1000 phase shifters control 16 elements
4. **Signal Processing (FPGA)**:
- Raw ADC data capture
- I/Q baseband down-conversion
- Decimation & filtering (CIC/FIR)
- Pulse compression
- Doppler FFT processing
- MTI & CFAR detection
5. **System Management (STM32)**:
- Power sequencing
- Peripheral configuration
- GPS/IMU integration
- Stepper motor control
6. **Visualization (Python GUI)**:
- Real-time target plotting
- Map integration
- Radar control interface
![AERIS-10 Dashboard](https://raw.githubusercontent.com/NawfalMotii79/PLFM_RADAR/main/8_Utils/GUI_V6.gif)
<!-- V6 GIF removed — V6 is deprecated. V65 Tk and V7 PyQt6 are the active GUIs. -->
## 📊 Technical Specifications
| Parameter | AERIS-10N (Nexus) | AERIS-10X (Extended) |
|-----------|-------------------|----------------------|
| **Frequency** | 10.5 GHz | 10.5 GHz |
| **Max Range** | 3 km | 20 km |
| **Antenna** | 8x16 Patch Array | 32x16 Slotted Waveguide |
| **Beam Steering** | Electronic (±45°) | Electronic (±45°) |
| **Mechanical Scan** | 360° (stepper motor) | 360° (stepper motor) |
| **Output Power** | ~1W×16 | 10W×16 (GaN amplifier) |
| **Processing** | FPGA + STM32 | FPGA + STM32 |
## 🚀 Getting Started
### 🧹 Repository File Placement Policy
To keep the repository root clean and make artifacts easy to find, place generated files in the following locations:
- **Published reports (tracked, GitHub Pages):** `docs/`
- Example: `docs/AERIS_Simulation_Report_v2.pdf`
- **Simulation-generated outputs (local, gitignored):** `5_Simulations/generated/`
- Plots, scenario outputs, temporary analysis directories
- **FPGA/Vivado generated artifacts (local, gitignored):** `9_Firmware/9_2_FPGA/reports/`
- VCD/VVP dumps, temporary CSVs, local report snapshots
- **Reusable FPGA automation scripts (tracked):** `9_Firmware/9_2_FPGA/scripts/`
- TCL flows, helper scripts used by build/bring-up
**Do not leave generated artifacts in the repository root.**
### Prerequisites
- Basic understanding of radar principles
- Experience with PCB assembly (for hardware build)
- Python 3.8+ for the GUI software
- FPGA development tools (Vivado) for signal processing modifications
### Hardware Assembly
1. **Order PCBs**: Production outputs are under `/4_Schematics and Boards Layout/4_7_Production Files`
2. **Source Components**: BOM/CPL files are co-located under `/4_Schematics and Boards Layout/4_7_Production Files`
3. **Assembly**: Use the schematics in `/4_Schematics and Boards Layout/4_6_Schematics` together with the production outputs above; a standalone assembly guide is not currently tracked
4. **Antenna**: Choose appropriate array files for your target variant
5. **Enclosure**: Mechanical drawings currently live in `/8_Utils/Mechanical_Drawings`
## 📜 License
This project is open-source but uses **different licenses for hardware and software** to ensure proper legal coverage.
### Hardware Documentation
The hardware design files—including:
- Schematics and PCB layouts (in `/4_Schematics and Boards Layout`)
- Bill of Materials (BOM) files
- Gerber files and manufacturing outputs
- Mechanical drawings and enclosure designs
are licensed under the **CERN Open Hardware Licence Version 2 – Permissive (CERN-OHL-P)** .
This is a hardware-specific license that:
- ✅ Clearly defines "Hardware," "Documentation," and "Products"
- ✅ Includes explicit patent protection for contributors and users
- ✅ Provides stronger liability limitations (important for high-power RF)
- ✅ Aligns with professional open-hardware standards (CERN, OSHWA)
You may use, modify, and sell products based on these designs, provided you:
- Maintain the original copyright notices
- Distribute any modified designs under the same license
- Make your modifications available in Source format
### Software and Firmware
The software components—including:
- FPGA code (VHDL/Verilog in `/9_Firmware`)
- Microcontroller firmware (STM32)
- Python GUI and utilities
remain under the **MIT License** for maximum flexibility.
### Full License Texts
- The complete CERN-OHL-P license text is in the `LICENSE` file
- MIT license terms apply to software where not otherwise specified
### Why This Change?
Originally, the entire project used the MIT license. The community (special thanks to gmaynez!) pointed out that MIT lacks legal protections needed for physical hardware. The switch to CERN-OHL-P ensures the project is properly protected while maintaining the same permissive spirit.
## 📚 Documentation
Comprehensive documentation is available in the `/docs` folder and served via GitHub Pages at [https://NawfalMotii79.github.io/PLFM_RADAR/docs/](https://NawfalMotii79.github.io/PLFM_RADAR/docs/):
- [System Architecture](/docs/architecture.html)
- [Implementation Log](/docs/implementation-log.html)
- [Hardware Bring-Up Guide](/docs/bring-up.html)
- [Test Reports](/docs/reports.html)
- [Release Notes](/docs/release-notes.html)
## 🤝 Contributing
We welcome contributions! Please see our [Contributing Guidelines](/CONTRIBUTING.md) for details on repo layout, branch workflow, and basic PR checks.
Areas where help is especially appreciated:
- **RF Engineers**: Review designs, optimize antenna performance
- **FPGA Developers**: Optimize signal processing pipeline
- **Software Developers**: Enhance Python GUI and SDK
- **Beta Testers**: University researchers, drone startups, advanced makers
## 📞 Contact & Collaboration
I welcome serious inquiries from researchers, engineers, and potential collaborators. However, due to the high volume of interest in this project, please understand that I cannot guarantee a response to every message.
- **Technical questions or bug reports**: Please [open a GitHub issue](https://github.com/NawfalMotii79/PLFM_RADAR/issues) so the whole community can benefit from the discussion.
- **Collaboration, licensing, or business inquiries**: 📧 nawfal.motii.33 [at] gmail [dot] com
## 💰 Sponsors
![PCBWay Sponsor Logo](https://raw.githubusercontent.com/NawfalMotii79/PLFM_RADAR/main/8_Utils/PCBWAY.jpg)
---
**Star ⭐ this repository if you're interested in open-source radar technology!**
*Note: This is an active development project. Some features are still in progress. Check the issues page for known limitations and upcoming features.*
## 19,000 stars – Thank you
This project started in a small workshop in Morocco. Today, 19,000 engineers on GitHub have starred it.
I am genuinely humbled.
**What this tells me:**
- Open source radar matters
- Affordable sensing is needed
- Engineers want to build, not just buy
Thank you to everyone who starred, forked, opened issues, submitted PRs, and shared this project.
The work continues.
**Nawfal Motii**
ABAC INDUSTRY
(http://www.abacindustry.com)
Nawfal Motii, ABAC INDUSTRY. Technical questions go through the project issue tracker. Collaboration and business inquiries: nawfal.motii.33 [at] gmail [dot] com.

View File

@@ -1,57 +0,0 @@
const fs = require('fs');
const html = fs.readFileSync('defense_sim.html', 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
function extractFn(src, name) {
const re = new RegExp('function ' + name + '\\(', 'g');
const m = re.exec(src);
if (!m) return null;
let i = m.index, depth = 0, start = src.indexOf('{', i);
for (let j = start; j < src.length; j++) {
if (src[j] === '{') depth++;
else if (src[j] === '}') { depth--; if (depth === 0) return src.slice(i, j + 1); }
}
return null;
}
const fns = ['targetTrajectory','detectFromRadars','estimatePosition3D','estimateTrajectory','computeInterceptPoint','perpDistToLine','gaussianNoise','kmToPixels'];
eval(fns.map(n => extractFn(script, n)).filter(Boolean).join('\n'));
const MEASUREMENT_ERROR = 20.0, IR_ERROR = 3.0, SCALE = 10;
// Single prop-drone trial, fixed geometry for reproducibility
const P = { startDist:15, speed:30, cruiseMin:200, cruiseMax:200, descentStart:3, terminalAlt:0 };
const numRadars=8, numDrones=24, radarRange=20, droneSpeed=100, irRange=5, cityRadius=10, droneLine=8, dt=0.5;
const startAngle = Math.PI/3; // fixed
const startPos = [P.startDist*Math.cos(startAngle), P.startDist*Math.sin(startAngle)];
const targetAngle = Math.PI/3; // straight radial approach
const targetDist = 2;
const targetPos = [targetDist*Math.cos(targetAngle), targetDist*Math.sin(targetAngle)];
const cruiseAlt = 200;
const trajectory = targetTrajectory(startPos, targetPos, cruiseAlt, P.speed, P.descentStart, P.terminalAlt);
const radarPositions = [];
for (let i=0;i<numRadars;i++){const a=2*Math.PI*i/numRadars,d=cityRadius+2;radarPositions.push([d*Math.cos(a),d*Math.sin(a),0]);}
let time=0, detectionTime=null, observations=[];
function getTruePos(t){let b=trajectory[0],bd=1e18;for(const p of trajectory){const d=Math.abs(p.t-t);if(d<bd){bd=d;b=p;}}return b;}
for (let step=0; step<20 && detectionTime===null; step++) {
time += dt;
const tp = getTruePos(time);
const pos = [tp.x, tp.y, tp.z/1000];
const detections = detectFromRadars(pos, radarPositions, radarRange, gaussianNoise);
if (detections.length>0) observations.push({x:pos[0],y:pos[1],z:pos[2]*1000,t:time});
if (observations.length>=3) {
detectionTime = time;
const posEst = estimatePosition3D(detections, radarPositions);
if (posEst) observations[observations.length-1] = {x:posEst[0]/1000,y:posEst[1]/1000,z:posEst[2],t:time};
console.log(`=== detection at t=${time.toFixed(1)}s, ${observations.length} observations ===`);
console.log('true target pos (km):', pos.map(v=>v.toFixed(3)));
if (posEst) console.log('posEst (km):', posEst.map(v=>v.toFixed(3)));
console.log('observations (x km, y km, z m, t s):');
for (const o of observations) console.log(' ', o.x.toFixed(4), o.y.toFixed(4), o.z.toFixed(2), o.t.toFixed(1));
const traj = estimateTrajectory(observations);
const velMs = [traj.vel[0]*1000, traj.vel[1]*1000, traj.vel[2]];
console.log('vel (raw):', traj.vel.map(v=>v.toExponential(3)));
console.log('velMs:', velMs.map(v=>v.toFixed(2)));
console.log('speedEst:', Math.sqrt(velMs[0]**2+velMs[1]**2+velMs[2]**2).toFixed(1), 'R2:', traj.r2.toFixed(3));
break;
}
}

View File

@@ -1,162 +0,0 @@
const fs = require('fs');
const html = fs.readFileSync('defense_sim.html', 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
function extractFn(src, name) {
const re = new RegExp('function ' + name + '\\(', 'g');
const m = re.exec(src);
if (!m) return null;
let i = m.index, depth = 0, start = src.indexOf('{', i);
for (let j = start; j < src.length; j++) {
if (src[j] === '{') depth++;
else if (src[j] === '}') { depth--; if (depth === 0) return src.slice(i, j + 1); }
}
return null;
}
function extractConst(src, name) {
const m = new RegExp('const ' + name + ' = ([^;\\n]+);').exec(src);
return m ? m[1].trim() : null;
}
const fns = ['targetTrajectory', 'detectFromRadars', 'estimatePosition3D', 'estimateTrajectory',
'computeInterceptPoint', 'perpDistToLine', 'gaussianNoise', 'kmToPixels'];
eval(fns.map(n => extractFn(script, n)).filter(Boolean).join('\n'));
const MEASUREMENT_ERROR = parseFloat(extractConst(script, 'MEASUREMENT_ERROR'));
const IR_ERROR = parseFloat(extractConst(script, 'IR_ERROR'));
const SCALE = parseFloat(extractConst(script, 'SCALE'));
console.log('MEASUREMENT_ERROR', MEASUREMENT_ERROR, 'IR_ERROR', IR_ERROR, 'SCALE', SCALE);
function runTrial(preset, verbose) {
const P = {
'shahed-136': { startDist:30, speed:50, cruiseMin:100, cruiseMax:300, descentStart:5, terminalAlt:0 },
'shahed-238': { startDist:35, speed:150, cruiseMin:1000, cruiseMax:2000, descentStart:8, terminalAlt:200 },
'prop-drone': { startDist:15, speed:30, cruiseMin:100, cruiseMax:300, descentStart:3, terminalAlt:0 },
}[preset];
const numRadars = 8, numDrones = 24, radarRange = 20, droneSpeed = 100, irRange = 5, cityRadius = 10, droneLine = 8;
const dt = 0.5;
const startAngle = Math.random() * 2 * Math.PI;
const startPos = [P.startDist * Math.cos(startAngle), P.startDist * Math.sin(startAngle)];
const targetAngle = Math.random() * 2 * Math.PI;
const targetDist = Math.random() * cityRadius * 0.8;
const targetPos = [targetDist * Math.cos(targetAngle), targetDist * Math.sin(targetAngle)];
const cruiseAlt = P.cruiseMin + Math.random() * (P.cruiseMax - P.cruiseMin);
const trajectory = targetTrajectory(startPos, targetPos, cruiseAlt, P.speed, P.descentStart, P.terminalAlt);
const radarPositions = [];
for (let i = 0; i < numRadars; i++) {
const a = 2 * Math.PI * i / numRadars, d = cityRadius + 2;
radarPositions.push([d * Math.cos(a), d * Math.sin(a), 0]);
}
const dronePositions = [];
for (let i = 0; i < numDrones; i++) {
const a = 2 * Math.PI * i / numDrones, d = cityRadius + droneLine;
dronePositions.push({ x: d * Math.cos(a), y: d * Math.sin(a), z: 0, state: 'waiting', intercept: null });
}
let time = 0, detectionTime = null, launched = [], collision = false, impact = false, observations = [];
function getTruePos(t) {
let best = trajectory[0], bd = Infinity;
for (const p of trajectory) { const d = Math.abs(p.t - t); if (d < bd) { bd = d; best = p; } }
return best;
}
while (time < 1200) {
time += dt;
const tp = getTruePos(time);
const pos = [tp.x, tp.y, tp.z / 1000];
const detections = detectFromRadars(pos, radarPositions, radarRange, gaussianNoise);
if (detections.length > 0) observations.push({ x: pos[0], y: pos[1], z: pos[2] * 1000, t: time });
if (detectionTime === null && observations.length >= 3) {
detectionTime = time;
const posEst = estimatePosition3D(detections, radarPositions);
if (posEst) observations[observations.length - 1] = { x: posEst[0]/1000, y: posEst[1]/1000, z: posEst[2], t: time };
const traj = estimateTrajectory(observations);
if (traj) {
const velMs = [traj.vel[0]*1000, traj.vel[1]*1000, traj.vel[2]];
const speedEst = Math.sqrt(velMs[0]**2 + velMs[1]**2 + velMs[2]**2);
const horizDist = Math.sqrt(pos[0]**2 + pos[1]**2);
const tti = horizDist / (speedEst / 1000);
const velKs = [velMs[0]/1000, velMs[1]/1000, velMs[2]/1000];
const posM = [pos[0]*1000, pos[1]*1000, pos[2]*1000];
const cands = [];
for (let i = 0; i < numDrones; i++) {
const dp = dronePositions[i];
const dpM = [dp.x*1000, dp.y*1000, dp.z*1000];
const r = computeInterceptPoint(dpM, posM, velMs, droneSpeed);
const dist = perpDistToLine(pos, velKs, [dp.x, dp.y, dp.z]);
const viable = (r.t > 0 && r.t < tti);
cands.push({ i, t: r.t, intercept: [r.intercept[0]/1000, r.intercept[1]/1000, r.intercept[2]/1000], dist, viable });
}
const vi = cands.filter(c => c.viable).sort((a,b)=>a.dist-b.dist);
const nv = cands.filter(c=>!c.viable).sort((a,b)=>a.dist-b.dist);
const chosen = vi.slice(0,3);
for (const c of nv) { if (chosen.length >= 3) break; chosen.push(c); }
launched = chosen.map(c=>c.i);
for (const c of chosen) { dronePositions[c.i].intercept = c.intercept; dronePositions[c.i].state = 'flying'; }
if (verbose) console.log(`[detect t=${time.toFixed(1)}s] horiz=${horizDist.toFixed(1)}km speedEst=${speedEst.toFixed(0)} tti=${tti.toFixed(0)}s viable=${vi.length} chosen=${launched.join(',')} R2=${traj.r2.toFixed(3)}`);
}
}
if (detectionTime !== null && !collision && observations.length >= 2) {
const traj = estimateTrajectory(observations);
if (traj) {
const velMs = [traj.vel[0]*1000, traj.vel[1]*1000, traj.vel[2]];
const speedEst = Math.sqrt(velMs[0]**2+velMs[1]**2+velMs[2]**2);
const horizDist = Math.sqrt(pos[0]**2+pos[1]**2);
const tti = horizDist / (speedEst/1000);
if ((time - detectionTime) % 5 < dt) {
const posM = [pos[0]*1000, pos[1]*1000, pos[2]*1000];
for (const i of launched) {
const dr = dronePositions[i];
if (dr.state === 'flying') {
const dpM = [dr.x*1000, dr.y*1000, dr.z*1000];
const r = computeInterceptPoint(dpM, posM, velMs, droneSpeed);
dr.intercept = [r.intercept[0]/1000, r.intercept[1]/1000, r.intercept[2]/1000];
}
}
}
}
}
if (launched.length > 0 && !collision) {
for (const i of launched) {
const dr = dronePositions[i];
if (dr.state === 'flying' || dr.state === 'ir-tracking') {
const it = dr.intercept;
if (it) {
const d = [it[0]-dr.x, it[1]-dr.y, it[2]-dr.z];
const L = Math.sqrt(d[0]**2+d[1]**2+d[2]**2);
if (L > 0.001) { const s = droneSpeed*dt/1000; dr.x += d[0]/L*s; dr.y += d[1]/L*s; dr.z += d[2]/L*s; }
}
const dx = pos[0]-dr.x, dy = pos[1]-dr.y, dz = pos[2]-dr.z;
const dist3D = Math.sqrt(dx*dx+dy*dy+dz*dz);
if (dr.state === 'flying' && dist3D < irRange) dr.state = 'ir-tracking';
if (dist3D*1000 < 20) { collision = true; if (verbose) console.log(`[COLLISION t=${time.toFixed(1)}s drone ${i}]`); }
}
}
}
const horizDist = Math.sqrt(pos[0]**2 + pos[1]**2);
if (horizDist < cityRadius && !collision) { impact = true; if (verbose) console.log(`[IMPACT t=${time.toFixed(1)}s]`); break; }
if (collision) break;
}
return { collision, impact, detectionTime, observations: observations.length, launched: launched.length, time };
}
console.log('\n=== prop-drone verbose trials ===');
for (let k = 0; k < 3; k++) {
console.log(`--- trial ${k} ---`);
const r = runTrial('prop-drone', true);
console.log(` => collision=${r.collision} impact=${r.impact} detect=${r.detectionTime ? r.detectionTime.toFixed(1)+'s' : 'none'} obs=${r.observations} launched=${r.launched}`);
}
console.log('\n=== summary (30 trials each) ===');
for (const preset of ['shahed-136','shahed-238','prop-drone']) {
let c = 0, im = 0;
for (let k = 0; k < 30; k++) { const r = runTrial(preset, false); if (r.collision) c++; else if (r.impact) im++; }
console.log(`${preset.padEnd(14)} collision=${c}/30 (${(c/30*100).toFixed(0)}%) impact=${im}/30`);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,701 +0,0 @@
#!/usr/bin/env python3
"""
Generalized Projectile Defense Simulation
==========================================
Models a projectile/drone attack profile and defender radar/drone
intercept with proper physics and error propagation.
Supports multiple projectile types via presets:
- Shahed-136 (Geran-2): prop, ~50 m/s, low altitude
- Shahed-238 (Geran-3): jet, ~150 m/s, 1-2 km cruise (default)
- Cruise Missile: ~300 m/s, sea-skimming
- Ballistic (Re-entry): ~800 m/s, high altitude entry
- Small UAS / Prop Drone: ~30 m/s, low altitude
Defender:
- 8 AERIS-10 radars (20 km range, ±20 m single-shot accuracy)
- 24 interceptor drones (100 m/s, IR homing within 5 km)
"""
import numpy as np
import json
import sys
# =============================================================================
# Projectile Presets
# =============================================================================
# Each preset defines the attack profile:
# start_dist: km - distance from city center at launch
# speed: m/s - cruise speed
# cruise_alt_min / cruise_alt_max: m - cruise altitude range (randomized within)
# descent_start: km - distance from target at which descent begins
# terminal_alt: m - altitude at impact
PRESETS = {
'shahed-136': {'start_dist': 30, 'speed': 50, 'cruise_alt_min': 100, 'cruise_alt_max': 300, 'descent_start': 5, 'terminal_alt': 0},
'shahed-238': {'start_dist': 35, 'speed': 150, 'cruise_alt_min': 1000, 'cruise_alt_max': 2000, 'descent_start': 8, 'terminal_alt': 200},
'cruise-missile': {'start_dist': 50, 'speed': 300, 'cruise_alt_min': 50, 'cruise_alt_max': 150, 'descent_start': 5, 'terminal_alt': 0},
'ballistic-reentry':{'start_dist': 100, 'speed': 800, 'cruise_alt_min': 60000, 'cruise_alt_max': 80000, 'descent_start': 40, 'terminal_alt': 0},
'prop-drone': {'start_dist': 15, 'speed': 30, 'cruise_alt_min': 100, 'cruise_alt_max': 300, 'descent_start': 3, 'terminal_alt': 0},
}
DEFAULT_PRESET = 'shahed-238'
def get_preset(name=None):
"""Return the preset dict for a given name, or the default."""
if name is None:
name = DEFAULT_PRESET
return PRESETS.get(name, PRESETS[DEFAULT_PRESET])
# =============================================================================
# Defender constants
# =============================================================================
# Radar parameters
RADAR_RANGE = 20000.0 # m
RADAR_ERROR_1SIGMA = 20.0 # m (single measurement, 1 sigma)
NUM_RADARS = 8
# Drone parameters
DRONE_SPEED = 100.0 # m/s
DRONE_MAX_ALT = 4000.0 # m
DRONE_DEFENSE_LINE = 8000.0 # m from city center
IR_RANGE = 5000.0 # m
IR_ERROR_1SIGMA = 3.0 # m
NUM_DRONES = 24
CITY_RADIUS = 10000.0 # m
# Simulation
DT = 0.5 # s (time step, coarser for speed)
SIM_DURATION = 400.0 # s
# =============================================================================
# Flight Profile
# =============================================================================
def target_trajectory(start_pos, target_pos, cruise_alt, speed, descent_start, terminal_alt):
"""
Generate a realistic projectile flight trajectory.
- Cruise at cruise_alt until `descent_start` distance from target
- Gradual descent to `terminal_alt` over the remaining distance
- Straight horizontal path (simplified for 2D projection)
descent_start: distance (m) from target at which descent begins
terminal_alt: altitude (m) at impact
Returns: list of (x, y, z, t) tuples
"""
start_pos = np.array(start_pos)
target_pos = np.array(target_pos)
# Horizontal direction
dx = target_pos[0] - start_pos[0]
dy = target_pos[1] - start_pos[1]
horiz_dist = np.sqrt(dx**2 + dy**2)
if horiz_dist < 1e-6:
return []
dir_x = dx / horiz_dist
dir_y = dy / horiz_dist
# Descent begins at `descent_start` distance from target
# So descent begins at (horiz_dist - descent_start) from start
descent_start_dist = max(0.0, horiz_dist - descent_start)
# Descent distance (clamped to available distance)
descent_dist = max(0.0, min(descent_start, horiz_dist))
# Total duration
total_duration = horiz_dist / speed
trajectory = []
t = 0.0
while t <= total_duration + DT:
# Distance traveled
dist_traveled = speed * t
# Determine altitude
if descent_dist <= 0 or dist_traveled < descent_start_dist:
# Cruise phase
z = cruise_alt
else:
# Descent phase: linear interpolation from cruise_alt to terminal_alt
descent_progress = min(1.0, (dist_traveled - descent_start_dist) / descent_dist)
z = cruise_alt + (terminal_alt - cruise_alt) * descent_progress
# Horizontal position
x = start_pos[0] + dir_x * dist_traveled
y = start_pos[1] + dir_y * dist_traveled
trajectory.append((x, y, z, t))
t += DT
return trajectory
# =============================================================================
# Radar Detection Model
# =============================================================================
def radar_positions(num_radars, city_radius):
"""Generate radar positions on a circle around the city."""
positions = []
for i in range(num_radars):
angle = 2 * np.pi * i / num_radars
dist = city_radius + 2000 # 2 km outside city
x = dist * np.cos(angle)
y = dist * np.sin(angle)
positions.append(np.array([x, y, 0.0]))
return positions
def drone_positions(num_drones, city_radius, defense_line):
"""Generate drone positions on a circle around the city."""
positions = []
for i in range(num_drones):
angle = 2 * np.pi * i / num_drones
dist = city_radius + defense_line
x = dist * np.cos(angle)
y = dist * np.sin(angle)
positions.append({
'pos': np.array([x, y, 0.0]),
'state': 'waiting',
'index': i
})
return positions
def detect_from_radars(pos, radar_pos_list, radar_range, rng):
"""
Detect target position from all radars in range.
Returns list of (range, azimuth, elevation, radar_index) and estimated 3D position.
"""
detections = []
for i, rpos in enumerate(radar_pos_list):
dist = np.linalg.norm(pos - rpos)
if dist < radar_range:
# Add measurement noise
range_meas = dist + rng.normal(0, RADAR_ERROR_1SIGMA)
azimuth_true = np.arctan2(pos[1] - rpos[1], pos[0] - rpos[0])
elevation_true = np.arctan2(pos[2] - rpos[2], np.sqrt((pos[0]-rpos[0])**2 + (pos[1]-rpos[1])**2))
azimuth_meas = azimuth_true + rng.normal(0, RADAR_ERROR_1SIGMA / max(100, dist))
elevation_meas = elevation_true + rng.normal(0, RADAR_ERROR_1SIGMA / max(100, dist))
detections.append({
'range': range_meas,
'azimuth': azimuth_meas,
'elevation': elevation_meas,
'radar_index': i
})
return detections
def estimate_position_3d(detections, radar_pos_list, rng):
"""
Estimate 3D position from multiple radar detections.
Each radar provides azimuth, elevation, and range, which together define a 3D point.
The estimated position is the least-squares fit of these 3D point estimates.
Returns (x, y, z) estimate and covariance.
"""
if len(detections) < 2:
return None, None
# Each radar's bearing + range defines a 3D point estimate
estimates = []
for det in detections:
ri = radar_pos_list[det['radar_index']]
az = det['azimuth']
el = det['elevation']
r = det['range']
# Convert bearing + range to 3D offset from radar
dx = r * np.cos(el) * np.cos(az)
dy = r * np.cos(el) * np.sin(az)
dz = r * np.sin(el)
p_est = ri + np.array([dx, dy, dz])
estimates.append(p_est)
# Least-squares estimate is the mean of the point estimates
p = np.mean(estimates, axis=0)
# Estimate covariance from the spread of estimates
if len(estimates) > 1:
spread = np.std(np.array(estimates), axis=0)
cov = np.diag(spread**2)
else:
sigma_pos = RADAR_ERROR_1SIGMA
cov = np.eye(3) * sigma_pos**2
return p, cov
# =============================================================================
# Trajectory Estimation (Linear Regression)
# =============================================================================
def estimate_trajectory(observations, rng=None):
"""
Estimate trajectory (velocity) from a series of position observations.
Each observation is (x, y, z, t).
Returns (vx, vy, vz) and covariance.
"""
if len(observations) < 2:
return None, None
times = np.array([o[3] for o in observations])
xs = np.array([o[0] for o in observations])
ys = np.array([o[1] for o in observations])
zs = np.array([o[2] for o in observations])
n = len(observations)
# Linear regression for each axis: pos = pos0 + vel * t
vel = np.zeros(3)
pos0 = np.zeros(3)
for axis in range(3):
pos = np.array([o[axis] for o in observations])
if n >= 2:
# Least squares fit: pos = pos0 + vel*t
A = np.vstack([np.ones(n), times]).T
coeffs, _, _, _ = np.linalg.lstsq(A, pos, rcond=None)
pos0[axis] = coeffs[0]
vel[axis] = coeffs[1]
# Covariance estimate
# For linear regression, Var(v) = sigma^2 / Sxx where Sxx = sum((t - t_mean)^2)
t_mean = np.mean(times)
Sxx = np.sum((times - t_mean)**2)
sigma_vel = RADAR_ERROR_1SIGMA / np.sqrt(Sxx) if Sxx > 0 else RADAR_ERROR_1SIGMA
cov = np.eye(3) * sigma_vel**2
return vel, cov
# =============================================================================
# Drone Intercept Logic
# =============================================================================
def compute_intercept_point(drone_pos, target_pos, target_vel, drone_speed):
"""
Compute the intercept point for a drone chasing a moving target in 3D.
Solves the classic pursuer-target problem:
|drone_pos + v_d*t - target_pos - v_s*t| = 0
where v_d = drone_speed * unit_vector(direction to intercept)
This works even when the drone is slower than the target,
as long as the drone is positioned close enough to the intercept path.
Returns: intercept point (3D) and estimated time
"""
drone_pos = np.array(drone_pos, dtype=float)
target_pos = np.array(target_pos, dtype=float)
target_vel = np.array(target_vel, dtype=float)
# Relative position and velocity
rel_pos = target_pos - drone_pos # where target is relative to drone
rel_vel = target_vel # relative velocity (drone direction is variable)
# We need to find a direction d (unit vector) and time t such that:
# drone_speed * t * d = rel_pos + target_vel * t
# => d = (rel_pos + target_vel * t) / (drone_speed * t)
# And |d| = 1, so:
# |rel_pos + target_vel * t| = drone_speed * t
# Let's expand:
# |rel_pos + v_s*t|^2 = v_d^2 * t^2
# |rel_pos|^2 + 2*t*(rel_pos·v_s) + t^2*(v_s·v_s) = v_d^2 * t^2
# |rel_pos|^2 + 2*t*(rel_pos·v_s) + t^2*(|v_s|^2 - v_d^2) = 0
a = np.dot(target_vel, target_vel) - drone_speed**2
b = 2 * np.dot(rel_pos, target_vel)
c = np.dot(rel_pos, rel_pos)
# Solve quadratic: a*t^2 + b*t + c = 0
discriminant = b*b - 4*a*c
if a == 0:
# Linear equation: b*t + c = 0
if abs(b) < 1e-10:
# No solution
t = np.linalg.norm(rel_pos) / drone_speed
intercept = drone_pos + rel_pos
return intercept, t
t = -c / b
if t <= 0:
t = np.linalg.norm(rel_pos) / drone_speed
intercept = drone_pos + rel_pos
return intercept, t
target_future = target_pos + target_vel * t
intercept = target_future
return intercept, t
if discriminant < 0:
# No intercept possible with this geometry
# Fallback: aim at current position
t = np.linalg.norm(rel_pos) / drone_speed
intercept = drone_pos + rel_pos
return intercept, t
# Compute both roots, pick the smallest positive one
sqrt_disc = np.sqrt(discriminant)
t_candidates = [
(-b + sqrt_disc) / (2 * a),
(-b - sqrt_disc) / (2 * a)
]
positive_roots = [t for t in t_candidates if t > 0]
if not positive_roots:
# No valid positive intercept
t = np.linalg.norm(rel_pos) / drone_speed
intercept = drone_pos + rel_pos
return intercept, t
# The smallest positive root is the earliest intercept
t = min(positive_roots)
# Intercept point: where target will be at time t
target_future = target_pos + target_vel * t
intercept = target_future
return intercept, t
def drone_trajectory(drone_start, intercept_point, drone_speed):
"""
Generate a smooth 3D trajectory for the drone from start to intercept.
Simple: linear interpolation with altitude management.
"""
drone_start = np.array(drone_start)
intercept = np.array(intercept_point)
total_dist = np.linalg.norm(intercept - drone_start)
duration = total_dist / drone_speed
# Generate waypoints
num_steps = max(2, int(duration / DT))
trajectory = []
for i in range(num_steps + 1):
frac = i / num_steps
pos = drone_start + frac * (intercept - drone_start)
t = i * DT
trajectory.append((pos[0], pos[1], pos[2], t))
return trajectory
# =============================================================================
# Main Simulation
# =============================================================================
def run_simulation(seed=None, debug=False, preset=None):
"""Run one full simulation and return results.
preset: name of a projectile preset (or None for default).
"""
rng = np.random.default_rng(seed)
preset_name = preset if preset is not None else DEFAULT_PRESET
preset_data = get_preset(preset)
# Attack parameters from preset (with some randomization)
start_dist = preset_data['start_dist'] * 1000.0 * rng.uniform(0.9, 1.1) # m, ±10%
start_angle = rng.uniform(0, 2 * np.pi)
target_angle = rng.uniform(0, 2 * np.pi)
target_dist = rng.uniform(0, CITY_RADIUS * 0.8)
start_pos = np.array([
start_dist * np.cos(start_angle),
start_dist * np.sin(start_angle),
0
])
terminal_alt = preset_data['terminal_alt']
target_pos = np.array([
target_dist * np.cos(target_angle),
target_dist * np.sin(target_angle),
terminal_alt # terminal altitude
])
cruise_alt = rng.uniform(preset_data['cruise_alt_min'], preset_data['cruise_alt_max'])
speed = preset_data['speed']
descent_start = preset_data['descent_start'] * 1000.0 # m
if debug:
print(f" [DEBUG] preset={preset_data} start_dist={start_dist:.0f}m, cruise_alt={cruise_alt:.0f}m")
print(f" [DEBUG] start_pos={start_pos}, target_pos={target_pos}")
# Generate trajectory
trajectory = target_trajectory(start_pos, target_pos, cruise_alt, speed, descent_start, terminal_alt)
if not trajectory:
return None
# Setup radar and drones
radar_pos_list = radar_positions(NUM_RADARS, CITY_RADIUS)
drone_pos_list = drone_positions(NUM_DRONES, CITY_RADIUS, DRONE_DEFENSE_LINE)
# Simulation state
time = 0.0
detection_time = None
launched_drones = []
collision = False
observations = []
# Pre-compute true target position at each time step
true_positions = {t: np.array([x, y, z]) for x, y, z, t in trajectory}
def get_true_pos(t):
# Find closest trajectory point
times = sorted(true_positions.keys())
# Binary search for closest
idx = np.searchsorted(times, t)
if idx >= len(times):
return true_positions[times[-1]]
if idx == 0:
return true_positions[times[0]]
# Choose closer of two adjacent
t1, t2 = times[idx-1], times[idx]
if abs(t - t1) < abs(t - t2):
return true_positions[t1]
return true_positions[t2]
# Main loop
while time < SIM_DURATION and not collision:
true_pos = get_true_pos(time)
# Check radar detection
detections = detect_from_radars(true_pos, radar_pos_list, RADAR_RANGE, rng)
active_radars = [d['radar_index'] for d in detections]
if active_radars:
observations.append((true_pos[0], true_pos[1], true_pos[2], time))
# First detection triggers alert
if detection_time is None and len(observations) >= 3:
detection_time = time
if debug:
print(f" [DEBUG] Detection at t={time:.1f}s, true_pos={true_pos}")
# Estimate 3D position (only once at detection)
pos_est, cov = estimate_position_3d(detections, radar_pos_list, rng)
if pos_est is not None:
observations[-1] = (pos_est[0], pos_est[1], pos_est[2], time)
if debug:
print(f" [DEBUG] pos_est={pos_est}, true={true_pos}, err={np.linalg.norm(pos_est-true_pos):.1f}m")
# Estimate trajectory (only once at detection)
vel_est, vel_cov = estimate_trajectory(observations, rng)
if vel_est is not None:
if debug:
print(f" [DEBUG] vel_est={vel_est}, true_vel approx={speed} m/s")
# Compute time to impact for this trajectory
# Target distance to city center
target_horiz_dist = np.sqrt(true_pos[0]**2 + true_pos[1]**2)
speed_est = np.linalg.norm(vel_est)
t_to_impact = target_horiz_dist / speed_est if speed_est > 0 else float('inf')
if debug:
print(f" [DEBUG] t_to_impact={t_to_impact:.1f}s, target_horiz_dist={target_horiz_dist:.0f}m")
# Launch drones that can intercept before impact
drone_intercepts = []
for i in range(NUM_DRONES):
drone_pos = drone_pos_list[i]['pos']
intercept, t_int = compute_intercept_point(
drone_pos, true_pos, vel_est, DRONE_SPEED
)
# Only viable if intercept happens before impact
if t_int < t_to_impact:
drone_intercepts.append((t_int, i, intercept))
drone_intercepts.sort(key=lambda x: x[0])
launched_drones = [d[1] for d in drone_intercepts[:3]]
if debug:
print(f" [DEBUG] viable drones: {len(drone_intercepts)}, launched: {launched_drones}")
for d in drone_intercepts[:3]:
print(f" [DEBUG] drone {d[1]} t_int={d[0]:.1f}s intercept=({d[2][0]:.0f},{d[2][1]:.0f},{d[2][2]:.0f})")
# Compute intercept points
for d in drone_intercepts[:3]:
i = d[1]
drone_pos_list[i]['intercept'] = d[2]
drone_pos_list[i]['state'] = 'flying'
# Update drones
if launched_drones:
# Re-target drones periodically (proportional navigation)
if detection_time is not None and (time - detection_time) % 5.0 < DT:
vel_est_new, _ = estimate_trajectory(observations, rng)
if vel_est_new is not None:
# Recompute intercept points for launched drones
for i in launched_drones:
drone = drone_pos_list[i]
if drone['state'] in ('flying', 'ir_tracking'):
intercept, t_int = compute_intercept_point(
drone['pos'], true_pos, vel_est_new, DRONE_SPEED
)
drone['intercept'] = intercept
for i in launched_drones:
drone = drone_pos_list[i]
if drone['state'] in ('flying', 'ir_tracking'):
intercept = drone.get('intercept')
if intercept is not None:
delta = intercept - drone['pos']
dist = np.linalg.norm(delta)
if dist > 0.01:
step = DRONE_SPEED * DT
drone['pos'] = drone['pos'] + (delta / dist) * step
else:
drone['pos'] = intercept
# Check IR range
target_dist_local = np.linalg.norm(true_pos - drone['pos'])
if drone['state'] == 'flying' and target_dist_local < IR_RANGE:
drone['state'] = 'ir_tracking'
if debug:
print(f" [DEBUG] t={time:.1f}s drone {i} -> IR tracking, dist={target_dist_local:.0f}m")
# Check collision
if target_dist_local < 20.0: # 20 m kill radius
collision = True
if debug:
print(f" [DEBUG] COLLISION at t={time:.1f}s drone {i}")
break
elif debug and time % 10 < DT:
print(f" [DEBUG] t={time:.1f}s drone {i} pos=({drone['pos'][0]:.0f},{drone['pos'][1]:.0f},{drone['pos'][2]:.0f}), target=({true_pos[0]:.0f},{true_pos[1]:.0f},{true_pos[2]:.0f}), dist={target_dist_local:.0f}m")
# Check if target reached city
horiz_dist = np.sqrt(true_pos[0]**2 + true_pos[1]**2)
if horiz_dist < CITY_RADIUS and not collision:
break
time += DT
# Calculate results
result = {
'preset': preset_name,
'start_dist': start_dist,
'cruise_alt': cruise_alt,
'speed': speed,
'terminal_alt': terminal_alt,
'detection_time': detection_time,
'collision': collision,
'time_of_collision': time if collision else None,
'time_of_impact': time if not collision else None,
'num_observations': len(observations),
'launched_drones': launched_drones,
'seed': seed
}
return result
# =============================================================================
# Monte Carlo Validation
# =============================================================================
def monte_carlo(n_trials=100, seed_base=42, preset=None):
"""Run Monte Carlo simulation and return statistics.
preset: name of a projectile preset (or None for default).
"""
results = []
for i in range(n_trials):
result = run_simulation(seed=seed_base + i, preset=preset)
if result is not None:
results.append(result)
if not results:
return None
# Statistics
collisions = sum(1 for r in results if r['collision'])
detection_times = [r['detection_time'] for r in results if r['detection_time'] is not None]
collision_times = [r['time_of_collision'] for r in results if r['collision']]
impact_times = [r['time_of_impact'] for r in results if not r['collision']]
num_obs = [r['num_observations'] for r in results]
cruise_alts = [r['cruise_alt'] for r in results]
preset_name = results[0]['preset']
stats = {
'preset': preset_name,
'total_trials': len(results),
'collisions': collisions,
'interception_rate': collisions / len(results) * 100,
'avg_detection_time': np.mean(detection_times) if detection_times else None,
'avg_collision_time': np.mean(collision_times) if collision_times else None,
'avg_impact_time': np.mean(impact_times) if impact_times else None,
'avg_observations': np.mean(num_obs),
'min_observations': np.min(num_obs),
'max_observations': np.max(num_obs),
'avg_cruise_alt': np.mean(cruise_alts),
'min_cruise_alt': np.min(cruise_alts),
'max_cruise_alt': np.max(cruise_alts),
}
return stats, results
# =============================================================================
# Main
# =============================================================================
if __name__ == '__main__':
print("=" * 80)
print("Generalized Projectile Defense Simulation")
print("=" * 80)
print()
# Run single simulation for demo (default preset)
print(f"Running single simulation (preset={DEFAULT_PRESET})...")
result = run_simulation(seed=42)
if result:
print(f" Preset: {result['preset']}")
print(f" Start distance: {result['start_dist']/1000:.1f} km")
print(f" Speed: {result['speed']:.0f} m/s")
print(f" Cruise altitude: {result['cruise_alt']:.0f} m")
print(f" Detection time: {result['detection_time']:.1f} s" if result['detection_time'] else " Detection time: N/A")
print(f" Collision: {'YES' if result['collision'] else 'NO'}")
if result['collision']:
print(f" Collision time: {result['time_of_collision']:.1f} s")
else:
print(f" Impact time: {result['time_of_impact']:.1f} s")
print(f" Observations: {result['num_observations']}")
print()
# Run Monte Carlo for each preset
print("Running Monte Carlo (100 trials per preset)...")
all_stats = {}
for preset_name in PRESETS.keys():
stats, results = monte_carlo(n_trials=100, seed_base=100, preset=preset_name)
if stats:
all_stats[preset_name] = stats
print(f" [{preset_name}]")
print(f" Interception rate: {stats['interception_rate']:.1f}%")
if stats['avg_detection_time']:
print(f" Avg detection time: {stats['avg_detection_time']:.1f} s")
if stats['avg_collision_time']:
print(f" Avg collision time: {stats['avg_collision_time']:.1f} s")
if stats['avg_impact_time']:
print(f" Avg impact time: {stats['avg_impact_time']:.1f} s")
print(f" Avg cruise altitude: {stats['avg_cruise_alt']:.0f} m")
print()
# Save stats to JSON for the HTML viewer (per preset)
import json
json_stats = {
preset_name: {
'total_trials': stats['total_trials'],
'collisions': stats['collisions'],
'interception_rate': stats['interception_rate'],
'avg_detection_time': stats['avg_detection_time'],
'avg_collision_time': stats['avg_collision_time'],
'avg_impact_time': stats['avg_impact_time'],
'avg_observations': stats['avg_observations'],
}
for preset_name, stats in all_stats.items()
}
with open('montecarlo_stats.json', 'w') as f:
json.dump(json_stats, f, indent=2)
print()
print("Stats saved to montecarlo_stats.json")
print()
print("=" * 80)
print("Simulation complete")
print("=" * 80)

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2020-PRESENT Anthony Fu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,50 +0,0 @@
export interface SimulationEntity {
id: string
type: string
active: boolean
position_ned_m: number[]
velocity_ned_m_s: number[]
acceleration_ned_m_s2: number[]
orientation_quaternion_wxyz?: number[]
angular_velocity_body_rad_s?: number[]
available?: boolean
activated_for?: string | null
position_error_ned_m?: number[] | null
velocity_error_ned_m_s?: number[] | null
trajectory_ned_m?: number[][]
intended_trajectory_ned_m?: number[][]
intention_kind?: string
standby_altitude_m?: number | null
flight_phase?: string
}
export interface SimulationEvent {
type: string
time_s: number
threat_id?: string
defender_ids?: string[]
projectile_id?: string
target_id?: string
position_ned_m?: number[]
projected_city_crossing_ned_m?: number[]
miss_distance_m?: number
[key: string]: unknown
}
export interface SimulationState {
schema_version: number
run_id: string
sequence: number
simulation_time_s: number
kind: 'simulation_state'
entities: SimulationEntity[]
events: SimulationEvent[]
environment: Record<string, unknown>
devices?: Record<string, unknown>
scenario?: Record<string, unknown>
observations?: Record<string, unknown>
radar_detection_points?: Record<string, number[][]>
radar_tracks?: Record<string, { position_ned_m: number[], velocity_ned_m_s: number[], timestamp_s: number, age_s: number, velocity_observable: boolean }>
inferred_trajectories?: Record<string, number[][]>
statistics: Record<string, unknown>
}

View File

@@ -1,3 +0,0 @@
"""Backend-authoritative defense simulation."""
__version__ = "0.1.0"

View File

@@ -1,325 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
from ..physics.validation import finite_numeric_fields
from ..physics.atmosphere import isa
from ..physics.integrator import NUMBA_AVAILABLE, rk4_fast
if NUMBA_AVAILABLE:
from ..physics.integrator import drone_rk4_kernel
else: # pragma: no cover - numba optional
def drone_rk4_kernel(state, wind, held, drag_k, rho):
rel = state[3:] - wind
speed = float(np.linalg.norm(rel))
return np.concatenate((state[3:], held + np.array([0.0, 0.0, 9.80665])
- 0.5 * rho * drag_k * speed * rel))
@dataclass
class DroneConfig:
mass_kg: float = 2.0
max_acceleration_m_s2: float = 18.0
max_speed_m_s: float = 25.0
max_turn_rate_rad_s: float = 1.2
max_climb_rate_m_s: float = 5.0
max_descent_rate_m_s: float = 4.0
acceleration_time_constant_s: float = 0.25
drag_coefficient_area_m2: float = 0.08
collision_radius_m: float = 0.5
actuator_sigma_m_s2: float = 0.0
# IR seeker model (used once the drone is tasked against a threat).
ir_range_m: float = 8_000.0
ir_bearing_sigma_rad: float = .005
ir_fov_deg: float = 120.0
ir_position_sigma_m: float = 5.0
ir_velocity_sigma_m_s: float = 1.0
ir_update_rate_hz: float = 10.0
ir_processing_latency_s: float = 0.01
ir_lock_time_s: float = 1.0
@property
def ir_acquisition_time_s(self) -> float:
# Lock-on margin: seeker needs the configured lock time plus one
# processing/sample period before the estimate is usable.
return self.ir_lock_time_s + 1.0 / self.ir_update_rate_hz + self.ir_processing_latency_s
@property
def ir_acquisition_range_m(self) -> float:
# Minimum distance at which the seeker can hold a lock long enough to
# produce a usable estimate. This is the standoff the interceptor
# geometry is built around.
return self.ir_acquisition_time_s * self.max_speed_m_s
def __post_init__(self):
finite_numeric_fields(self)
if self.acceleration_time_constant_s <= 0 or self.max_turn_rate_rad_s < 0 or self.max_climb_rate_m_s <= 0 or self.max_descent_rate_m_s <= 0:
raise ValueError("invalid drone response/flight limits")
if self.drag_coefficient_area_m2 < 0 or self.collision_radius_m < 0 or self.actuator_sigma_m_s2 < 0:
raise ValueError("invalid drone drag/contact/noise parameters")
if self.mass_kg <= 0 or self.max_acceleration_m_s2 <= 0 or self.max_speed_m_s <= 0:
raise ValueError("invalid drone physical parameters")
if self.ir_range_m <= 0 or self.ir_position_sigma_m < 0 or self.ir_velocity_sigma_m_s < 0:
raise ValueError("invalid IR seeker parameters")
if self.ir_bearing_sigma_rad < 0 or not 0 < self.ir_fov_deg <= 360:
raise ValueError("invalid IR bearing noise/FOV")
if self.ir_update_rate_hz <= 0 or self.ir_processing_latency_s < 0 or self.ir_lock_time_s < 0:
raise ValueError("invalid IR seeker timing")
@dataclass
class Drone:
entity_id: str
entity_type: str
position_ned_m: np.ndarray
velocity_ned_m_s: np.ndarray = field(default_factory=lambda: np.zeros(3))
config: DroneConfig = field(default_factory=DroneConfig)
acceleration_ned_m_s2: np.ndarray = field(default_factory=lambda: np.zeros(3))
estimated_position_ned_m: np.ndarray | None = None
estimated_velocity_ned_m_s: np.ndarray | None = None
actuator_acceleration_ned_m_s2: np.ndarray = field(default_factory=lambda: np.zeros(3))
requested_thrust_ned_m_s2: np.ndarray = field(default_factory=lambda: np.zeros(3))
orientation_quaternion_wxyz: np.ndarray = field(default_factory=lambda: np.array([1., 0., 0., 0.]))
angular_velocity_body_rad_s: np.ndarray = field(default_factory=lambda: np.zeros(3))
target_position_ned_m: np.ndarray | None = None
active: bool = True
available: bool = True
activated_for: str | None = None
# IR seeker state (threat-side): last estimate, lock age, acquisition flag.
ir_threat_estimate: np.ndarray | None = None # [position(3), velocity(3)]
ir_lock_age_s: float = 0.0
ir_acquired: bool = False
ir_next_sample_s: float = 0.0
ir_rng: np.random.Generator | None = None
flight_dynamics: object | None = None
flight_reference: dict | None = None
standby_altitude_m: float | None = None
ir_pending: list = field(default_factory=list)
ir_observation: dict | None = None
def estimate_threat(self, target, time_s: float, rng: np.random.Generator,
event_sink: list | None = None, radar_prior=None) -> bool:
"""Sample a rear-facing bearing-only IR camera, then deliver after delay.
Visibility uses truth only in measurement generation. Range is never
measured by IR: the EKF retains the radar prior's range uncertainty.
Ideal attitude/gyro knowledge and no terrain occlusion are assumptions.
"""
if self.activated_for is None or self.ir_rng is None or target is None or not target.active:
return False
if time_s + 1e-9 >= self.ir_next_sample_s:
period = 1/self.config.ir_update_rate_hz
while self.ir_next_sample_s <= time_s+1e-9:
self.ir_next_sample_s += period
relative = target.position_ned_m-self.position_ned_m
distance = float(np.linalg.norm(relative))
from scipy.spatial.transform import Rotation
rotation = Rotation.from_quat(self.orientation_quaternion_wxyz, scalar_first=True).as_matrix()
camera_axis = -rotation[:, 0] # rear-looking mount on body x axis
visible = (0 < distance <= self.config.ir_range_m and
float(relative @ camera_axis/distance) >= np.cos(np.deg2rad(self.config.ir_fov_deg/2)))
if not visible:
if self.ir_acquired and event_sink is not None:
event_sink.append({"type": "ir_track_lost", "time_s": time_s, "drone_id": self.entity_id})
self.ir_lock_age_s = 0.
self.ir_acquired = False
self.ir_pending.clear()
self.ir_threat_estimate = None
self.ir_observation = None
else:
self.ir_lock_age_s += period
if self.ir_lock_age_s >= self.config.ir_lock_time_s:
# Elevation is mathematical +down to match Stone Soup's
# NED Cartesian model; physical elevation is its negative.
angles = np.array([np.arctan2(relative[2], np.linalg.norm(relative[:2])),
np.arctan2(relative[1], relative[0])])
angles += self.ir_rng.normal(0, self.config.ir_bearing_sigma_rad, 2)
sensor_position = self.estimated_position_ned_m if self.estimated_position_ned_m is not None else self.position_ned_m
self.ir_pending.append({"sample_time_s": time_s,
"ready_time_s": time_s+self.config.ir_processing_latency_s,
"angles": angles, "sensor_position": sensor_position.copy()})
due = [m for m in self.ir_pending if m["ready_time_s"] <= time_s+1e-9]
self.ir_pending = [m for m in self.ir_pending if m["ready_time_s"] > time_s+1e-9]
if not due or radar_prior is None:
return False
from ..sensors.tracking import refine_bearing
measurement = due[-1]
estimate = refine_bearing(radar_prior, measurement["sensor_position"], measurement["angles"],
measurement["sample_time_s"], self.config.ir_bearing_sigma_rad)
if estimate is None:
return False
acquired = not self.ir_acquired
self.ir_acquired = True
self.ir_threat_estimate = np.r_[estimate.position, estimate.velocity]
self.ir_observation = {"sample_time_s": estimate.timestamp_s, "receive_time_s": time_s,
"measurement_type": "bearing_only", "elevation_down_rad": float(measurement["angles"][0]),
"bearing_rad": float(measurement["angles"][1]), "covariance": estimate.covariance.tolist()}
if self.flight_reference is not None:
# Bearing-informed corrections retain radar range uncertainty.
# Keep along-path progress; smooth the lateral reference instead
# of overwriting the drone's actual position or velocity.
self.flight_reference["target_altitude_m"] = max(0., -estimate.position[2])
direction = self.flight_reference["direction"][:2]
relative_estimate = estimate.position[:2]-measurement["sensor_position"][:2]
lateral = relative_estimate-direction*float(relative_estimate @ direction)
bound = self.config.max_speed_m_s/self.config.ir_update_rate_hz
lateral *= min(1., bound/max(float(np.linalg.norm(lateral)), 1e-9))
self.flight_reference["anchor"][:2] += lateral
if event_sink is not None:
event_sink.append({"type": "ir_acquisition" if acquired else "ir_track_update",
"time_s": time_s, "drone_id": self.entity_id, "threat_id": self.activated_for,
**self.ir_observation})
return True
def control(self, dt: float, rng: np.random.Generator) -> None:
if self.target_position_ned_m is None:
desired_net = np.zeros(3)
else:
est = self.estimated_position_ned_m if self.estimated_position_ned_m is not None else self.position_ned_m
vel = self.estimated_velocity_ned_m_s if self.estimated_velocity_ned_m_s is not None else self.velocity_ned_m_s
desired_net = 1.4 * (self.target_position_ned_m - est) - 1.1 * vel
# Hover requires upward thrust to cancel NED gravity even at zero commanded net acceleration.
desired = desired_net + np.array([0.0, 0.0, -9.80665])
# Preserve vertical support before allocating remaining thrust to
# planar motion. A distant horizontal reference must not silently
# remove hover thrust and make a grounded drone slide indefinitely.
limit = self.config.max_acceleration_m_s2
desired[2] = np.clip(desired[2], -limit, limit)
horizontal = float(np.linalg.norm(desired[:2]))
budget = np.sqrt(max(0., limit*limit-desired[2]*desired[2]))
if horizontal > budget:
desired[:2] *= budget/max(horizontal, 1e-9)
self.requested_thrust_ned_m_s2 = desired + rng.normal(0, self.config.actuator_sigma_m_s2, 3)
n = np.linalg.norm(self.requested_thrust_ned_m_s2)
if n > self.config.max_acceleration_m_s2:
self.requested_thrust_ned_m_s2 *= self.config.max_acceleration_m_s2 / n
def update_flight_reference(self, dt):
"""A bounded gameplay ascent reference, never a position overwrite."""
ref = self.flight_reference
if ref is None:
if self.standby_altitude_m is not None and self.target_position_ned_m is not None:
desired_down = -self.standby_altitude_m
self.target_position_ned_m[2] += float(np.clip(desired_down-self.target_position_ned_m[2],
-self.config.max_climb_rate_m_s*dt, self.config.max_descent_rate_m_s*dt))
return
ref["elapsed_s"] += dt
previous = ref["previous_position"]
ref["travel_m"] += float(np.linalg.norm(self.position_ned_m[:2]-previous[:2]))
ref["previous_position"] = self.position_ned_m.copy()
initial_h = ref["initial_altitude_m"]
from ..guidance.ascent import altitude_reference
height = altitude_reference(ref["travel_m"], initial_h, ref["target_altitude_m"],
curve=ref.get("ascent_curve", "quintic"), length_m=ref.get("ascent_distance_m", 1000.),
k_per_m=ref["ascent_k_per_m"])
# Limit the vertical reference rate; the actual vehicle has its own
# actuator/force dynamics and may fail to track this requested curve.
old_height = -self.target_position_ned_m[2]
height = old_height + float(np.clip(height-old_height,
-self.config.max_descent_rate_m_s*dt, self.config.max_climb_rate_m_s*dt))
target = ref["anchor"].copy()
target[:2] += ref["direction"][:2]*ref["speed_m_s"]*ref["elapsed_s"]
target[2] = -height
# Smooth radar replans and lateral joins at the configured speed.
delta = target[:2]-self.target_position_ned_m[:2]
delta *= min(1., self.config.max_speed_m_s*dt/max(float(np.linalg.norm(delta)), 1e-9))
self.target_position_ned_m[:2] += delta
self.target_position_ned_m[2] = target[2]
def intended_trajectory(self, horizon_s=60., interval_s=1.):
"""Preview the delivered command reference, not predicted physical flight.
Uses onboard estimates, rate limits and the selected altitude curve.
Estimated horizontal travel advances the curve; actual flight may lag.
No controller, actuator, wind or collision prediction is implied.
"""
if not self.active or self.target_position_ned_m is None:
return []
from ..guidance.ascent import altitude_reference
origin = (self.estimated_position_ned_m if self.estimated_position_ned_m is not None
else self.position_ned_m).copy()
point = origin.copy()
rows = [point.tolist()]
ref = self.flight_reference
travel = ref["travel_m"] if ref else 0.
for elapsed in np.arange(interval_s, horizon_s+1e-9, interval_s):
target = self.target_position_ned_m.copy()
if ref is not None:
target[:2] = ref["anchor"][:2] + ref["direction"][:2]*ref["speed_m_s"]*(ref["elapsed_s"]+elapsed)
target[2] = -altitude_reference(travel, ref["initial_altitude_m"], ref["target_altitude_m"],
curve=ref.get("ascent_curve", "quintic"), length_m=ref.get("ascent_distance_m", 1000.),
k_per_m=ref["ascent_k_per_m"])
elif self.standby_altitude_m is not None:
target[2] = -self.standby_altitude_m
delta = target[:2]-point[:2]
delta *= min(1., self.config.max_speed_m_s*interval_s/max(float(np.linalg.norm(delta)), 1e-9))
travel += float(np.linalg.norm(delta))
point[:2] += delta
point[2] += float(np.clip(target[2]-point[2], -self.config.max_climb_rate_m_s*interval_s,
self.config.max_descent_rate_m_s*interval_s))
rows.append(point.tolist())
return rows
def step(self, dt: float, wind: np.ndarray, rng: np.random.Generator,
control_due: bool = True, control_dt: float | None = None) -> None:
if not self.active:
return
self.update_flight_reference(dt)
if self.flight_dynamics is not None:
if self.activated_for is None and self.target_position_ned_m is None and self.position_ned_m[2] >= 0:
return
self.flight_dynamics.step(self, dt, wind, control_due)
return
# A defender that has not been tasked simply holds station on the
# loiter ring: it still responds to its actuator time constant (so a
# gust or a fresh command produces a realistic ramp) but it does not
# run the full guidance/controller/RK4 pipeline. This keeps idle
# units cheap -- the engine should not pay flight-control cost for
# drones that are not fighting -- and matches the doctrine that only
# the tasked interceptors fly.
if self.activated_for is None and self.target_position_ned_m is None:
if self.position_ned_m[2] >= 0 and np.linalg.norm(self.velocity_ned_m_s) < 1e-9:
self.position_ned_m[2] = 0.0
self.acceleration_ned_m_s2 = np.zeros(3)
return # supported by the ground, not an unpowered hover
if not hasattr(self, "_hold_position_ned_m"):
self._hold_position_ned_m = self.position_ned_m.copy()
self.target_position_ned_m = self._hold_position_ned_m
# Tasked flight: controller output sampled at its device cadence; the
# held actuator state and vehicle dynamics continue at physics Hz.
if control_due:
self.control(control_dt if control_dt is not None else dt, rng)
# Motor surrogate evolves at physics cadence, independently of the
# command sample rate. Exact first-order decay avoids Euler clipping.
old_actuator = self.actuator_acceleration_ned_m_s2.copy()
tau = self.config.acceleration_time_constant_s
decay = np.exp(-dt / tau)
self.actuator_acceleration_ned_m_s2 = self.requested_thrust_ned_m_s2 + (old_actuator-self.requested_thrust_ned_m_s2)*decay
wind = np.asarray(wind, dtype=np.float64)
rho = isa(-self.position_ned_m[2]).density_kg_m3
drag_k = self.config.drag_coefficient_area_m2 / self.config.mass_kg
held = self.requested_thrust_ned_m_s2 + (old_actuator-self.requested_thrust_ned_m_s2)*tau*(1-decay)/dt
old_velocity = self.velocity_ned_m_s.copy()
y = rk4_fast(np.concatenate((self.position_ned_m, self.velocity_ned_m_s)), dt,
drone_rk4_kernel, wind, held, drag_k, rho)
self.velocity_ned_m_s = y[3:]
speed = float(np.linalg.norm(self.velocity_ned_m_s))
if speed > self.config.max_speed_m_s:
self.velocity_ned_m_s *= self.config.max_speed_m_s / speed
self.velocity_ned_m_s[2] = float(np.clip(self.velocity_ned_m_s[2], -self.config.max_climb_rate_m_s, self.config.max_descent_rate_m_s))
# The reduced model treats speed bounds as kinematic constraints.
# Integrate realized bounded velocity rather than unclipped endpoint
# velocity; RotorPy is the force/attitude model for higher fidelity.
self.position_ned_m += .5*dt*(old_velocity+self.velocity_ned_m_s)
self.acceleration_ned_m_s2 = (self.velocity_ned_m_s-old_velocity)/dt
if self.position_ned_m[2] > 0:
self.position_ned_m[2] = 0.0
self.velocity_ned_m_s[2] = min(0.0, self.velocity_ned_m_s[2])
horizontal_speed = np.linalg.norm(self.velocity_ned_m_s[:2])
if horizontal_speed > 1e-6:
yaw = float(np.arctan2(self.velocity_ned_m_s[1], self.velocity_ned_m_s[0]))
current_yaw = float(2 * np.arctan2(self.orientation_quaternion_wxyz[3], self.orientation_quaternion_wxyz[0]))
error = (yaw - current_yaw + np.pi) % (2 * np.pi) - np.pi
yaw_step = float(np.clip(error, -self.config.max_turn_rate_rad_s * dt, self.config.max_turn_rate_rad_s * dt))
new_yaw = current_yaw + yaw_step
self.angular_velocity_body_rad_s = np.array([0.0, 0.0, yaw_step / dt])
self.orientation_quaternion_wxyz = np.array([np.cos(new_yaw/2), 0.0, 0.0, np.sin(new_yaw/2)])

View File

@@ -1,30 +0,0 @@
"""Distance-based altitude references; these never prescribe actual motion."""
import numpy as np
def altitude_reference(distance_m, initial_altitude_m, target_altitude_m, *,
curve="quintic", length_m=1000., k_per_m=.002):
values = (distance_m, initial_altitude_m, target_altitude_m, length_m, k_per_m)
if not all(np.isfinite(value) for value in values) or length_m <= 0 or k_per_m <= 0:
raise ValueError("ascent reference parameters must be finite with positive L and k")
x = max(0., distance_m)
u = float(np.clip(x/length_m, 0., 1.))
if curve == "quintic":
fraction = u*u*u*(10+u*(-15+6*u))
elif curve == "cubic":
fraction = u*u*(3-2*u)
elif curve == "exponential":
fraction = -np.expm1(-k_per_m*x)
else:
raise ValueError("ascent curve must be quintic, cubic or exponential")
return float(initial_altitude_m+(target_altitude_m-initial_altitude_m)*fraction)
def automatic_ascent_length(rise_m, reference_speed_m_s, max_climb_rate_m_s, curve="quintic"):
"""Heuristic L with 20% peak climb-rate margin at constant reference speed.
Quintic peak dh/dx is 1.875*rise/L; cubic peak is 1.5*rise/L.
This does not guarantee acceleration feasibility or physical tracking.
"""
peak = 1.5 if curve == "cubic" else 1.875
return float(max(500., peak*abs(rise_m)*reference_speed_m_s/(.8*max_climb_rate_m_s)))

View File

@@ -1,221 +0,0 @@
"""Estimated constant-velocity forecasts and reduced-model reference rollouts.
The live controller uses ``predict_track`` with measured states only. Legacy
point-mass rollout helpers remain available for numerical/reference checks;
they must not be fed hidden attacker parameters by the defense controller.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from ..entities.projectile import Projectile
from ..physics.integrator import NUMBA_AVAILABLE, rk4_fast
from .activation import CityCircle
from ..physics.events import ground_fraction, circle_entry_fraction
if NUMBA_AVAILABLE:
from ..physics.integrator import projectile_rk4_kernel
else: # pragma: no cover - numba optional
from ..entities.projectile import _drag_scalar
from ..physics.atmosphere import isa
def projectile_rk4_kernel(state, wind, drag_k, thrust):
rel = state[3:] - wind
return np.concatenate((state[3:], np.array([0.0, 0.0, 9.80665])
+ isa(-state[2]).density_kg_m3 * _drag_scalar(rel, drag_k) + thrust))
PREDICTION_DT_S = 0.25
PREDICTION_HORIZON_S = 600.0
MIN_LINE_SAMPLES = 6
@dataclass
class TrajectoryPrediction:
"""Best-fit threat trajectory and intercept geometry for one threat."""
track_time_s: float
track_position_ned_m: np.ndarray
track_velocity_ned_m_s: np.ndarray
line_origin_ned_m: np.ndarray
line_direction_ned: np.ndarray # unit vector
crossing_ned_m: np.ndarray | None # primary intercept aim (first feasible crossing)
crossing_time_s: float | None
impact_ned_m: np.ndarray | None # predicted ground contact, if before the crossing
impact_time_s: float | None
samples: np.ndarray # (n, 4) -> time_s, x, y, z
time_to_crossing_s: float | None
def point_at(self, delta_s: float) -> np.ndarray:
"""Point on the threat line delta_s seconds from the crossing aim."""
if self.crossing_ned_m is None:
return self.line_origin_ned_m
return self.crossing_ned_m + self.track_velocity_ned_m_s * delta_s
def predict_track(position, velocity, city, time_s, ceiling_m=3000., horizon_s=600.):
"""Declared constant-velocity estimate; no aircraft truth or true wind.
Forecast uncertainty grows with time. This extrapolation is useful for
displaying estimated motion and is not a certified flight prediction.
"""
position, velocity = np.asarray(position, dtype=float), np.asarray(velocity, dtype=float)
end = position + horizon_s*velocity
ground = ground_fraction(position, end)
entry = circle_entry_fraction(position, end, city.center_ned_m, city.radius_m)
crossing = crossing_t = impact = impact_t = None
if ground is not None:
impact = position + ground*(end-position)
impact_t = time_s+ground*horizon_s
if entry is not None and (ground is None or entry < ground):
point = position+entry*(end-position)
if -ceiling_m <= point[2] < 0:
crossing, crossing_t = point, time_s+entry*horizon_s
speed = float(np.linalg.norm(velocity))
if speed < 1e-6:
return None
duration = min(horizon_s, ground*horizon_s) if ground is not None else horizon_s
times = np.linspace(0, duration, 61)
samples = np.column_stack((times, position + times[:, None]*velocity))
return TrajectoryPrediction(time_s, position.copy(), velocity.copy(), position.copy(), velocity/speed,
crossing, crossing_t, impact, impact_t, samples,
crossing_t-time_s if crossing_t is not None else None)
def roll_out_threat(projectile: Projectile, position_ned_m: np.ndarray,
velocity_ned_m_s: np.ndarray, wind_ned_m_s: np.ndarray,
dt: float = PREDICTION_DT_S, horizon_s: float = PREDICTION_HORIZON_S) -> np.ndarray:
"""Integrate the observed state forward; returns (n, 4) [t, x, y, z] rows.
The whole horizon is integrated step-at-a-time with the same RK4 kernel
the real projectile uses (single-step fast path; the batch variant cannot
hold fixed args across a rollout whose altitude/drag changes each step),
and the rollout truncates at the first ground contact, exactly like the
previous loop.
"""
wind = np.asarray(wind_ned_m_s, dtype=np.float64)
drag_k = projectile.config.drag_coefficient * projectile.config.area_m2 / projectile.config.mass_kg
thrust = projectile._thrust_cmd if projectile.guidance is not None and projectile.guidance_active else np.zeros(3)
count = max(1, int(horizon_s / dt))
state = np.ascontiguousarray(np.concatenate((position_ned_m, velocity_ned_m_s)), dtype=np.float64)
rows = [np.concatenate(([0.0], position_ned_m))]
for i in range(count):
previous = state.copy()
state = rk4_fast(state, dt, projectile_rk4_kernel, wind, drag_k, thrust)
p = state[:3]
ground = ground_fraction(previous[:3], p)
if ground is not None:
p = previous[:3] + ground*(p-previous[:3])
p[2] = 0.0
rows.append(np.concatenate(([(i+ground)*dt], p)))
break
rows.append(np.concatenate(([(i+1)*dt], p)))
return np.asarray(rows, dtype=float)
def fit_line(samples: np.ndarray) -> tuple[np.ndarray, np.ndarray] | None:
"""Least-squares line through the rollout samples.
Model: x(t) = origin + direction * t with |direction| = 1, origin at t=0.
Linear in origin and (unnormalized) direction: solved with lstsq.
Returns (origin, unit_direction) or None if the fit is degenerate.
"""
if samples.shape[0] < MIN_LINE_SAMPLES:
return None
t = samples[:, 0]
x = samples[:, 1:]
t = t - t[0]
# center the times for conditioning
tc = t - float(t.mean())
matrix = np.column_stack((np.ones_like(tc), tc[:, None]))
solution, residuals, rank, _ = np.linalg.lstsq(matrix, x, rcond=None)
origin = solution[0] - solution[1] * float(t.mean())
direction = solution[1]
norm = float(np.linalg.norm(direction))
if norm < 1e-9:
return None
return origin, direction / norm
def predict_trajectory(projectile: Projectile, position_ned_m: np.ndarray,
velocity_ned_m_s: np.ndarray, wind_ned_m_s: np.ndarray,
city: CityCircle, loiter_ceiling_m: float,
track_time_s: float,
dt: float = PREDICTION_DT_S, horizon_s: float = PREDICTION_HORIZON_S
) -> TrajectoryPrediction | None:
"""Full prediction: rollout + line fit + 3D protected-circle crossing."""
samples = roll_out_threat(projectile, position_ned_m, velocity_ned_m_s, wind_ned_m_s, dt, horizon_s)
fit = fit_line(samples)
if fit is None:
return None
origin, direction = fit
center = np.asarray(city.center_ned_m, dtype=float)
radius = city.radius_m
crossing: np.ndarray | None = None
crossing_t: float | None = None
impact: np.ndarray | None = None
impact_t: float | None = None
previous = samples[0, 1:]
previous_t = 0.0
for row in samples[1:]:
t, current = float(row[0]), row[1:]
entry = circle_entry_fraction(previous, current, center, radius)
ground = ground_fraction(previous, current)
if entry is not None and (ground is None or entry < ground):
point = previous + entry*(current-previous)
if -loiter_ceiling_m <= point[2] < 0:
crossing = point
crossing_t = track_time_s + previous_t + entry*(t-previous_t)
break
if ground is not None:
impact = previous + ground*(current-previous)
impact_t = track_time_s + previous_t + ground*(t-previous_t)
break
previous, previous_t = current, t
time_to_crossing = crossing_t-track_time_s if crossing_t is not None else None
return TrajectoryPrediction(
track_time_s=track_time_s,
track_position_ned_m=np.asarray(position_ned_m, dtype=float),
track_velocity_ned_m_s=np.asarray(velocity_ned_m_s, dtype=float),
line_origin_ned_m=origin,
line_direction_ned=direction,
crossing_ned_m=crossing,
crossing_time_s=crossing_t,
impact_ned_m=impact,
impact_time_s=impact_t,
samples=samples,
time_to_crossing_s=time_to_crossing,
)
def intercept_waypoints(prediction: TrajectoryPrediction, count: int,
standoff_m: float, order: list[str]) -> dict[str, np.ndarray]:
"""Waypoint per drone: lead ahead of the aim, middle on it, trail behind.
`order` lists drone IDs from nearest-to-farthest of the aim point; the
nearest (lead) flies to the point standoff_m *before* the crossing along
the threat line, the middle one walks the crossing point itself, and the
trail one covers standoff_m *after* it so the threat cannot slip through.
"""
if prediction.crossing_ned_m is None:
origin = prediction.line_origin_ned_m
return {drone_id: origin.copy() for drone_id in order}
direction = prediction.line_direction_ned
# orient direction so that positive offset = toward the crossing
# (the line was fit over the whole rollout; the crossing is usually the
# latest feasible sample, so the direction points toward it already when
# the origin is the track position). Guard against the reversed case.
vector_to_crossing = prediction.crossing_ned_m - prediction.line_origin_ned_m
if float(np.dot(vector_to_crossing, direction)) < 0.0:
direction = -direction
offsets = []
n = len(order)
for index in range(n):
# nearest drone -> largest standoff before the aim, farthest -> closest
remaining = n - 1 - index
offset = -standoff_m * remaining if n > 1 else 0.0
offsets.append(offset)
return {drone_id: prediction.crossing_ned_m + direction * offset for drone_id, offset in zip(order, offsets)}

View File

@@ -1,165 +0,0 @@
"""Device profiles and timing taken from this repository's AERIS-10 design.
The mechanical/electronic scan cadence is derived from the STM32 firmware's
runRadarPulseSequence() constants. Measurement uncertainty and a 7.1 ms fixed
processing delay follow simulate_trajectory_precision.py and remain explicit
R&D assumptions, not bench-qualified radar performance.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from ..physics.validation import finite_numeric_fields
@dataclass(frozen=True)
class AerisRadarConfig:
model: str = "aeris_10x"
station_count: int = 8
ring_radius_km: float = 12.0
range_sigma_m: float = 2.0
azimuth_sigma_deg: float = 0.5
elevation_sigma_deg: float = 0.5
doppler_sigma_m_s: float = 1.0
wired_processing_latency_s: float = 0.0071
half_sector_deg: float = 3.6
elevation_limit_deg: float = 45.0
elevation_step_deg: float = 3.0
elevation_beam_half_width_deg: float = 1.5
@property
def max_range_m(self) -> float:
return 20_000.0 if self.model == "aeris_10x" else 3_000.0
@property
def azimuth_step_period_s(self) -> float:
# Firmware constants: 16 chirps at each PRI, 175.4 us guard,
# three phase patterns x 15 elevation sweeps, four 1 ms motor steps.
pulse_train_s = 16 * (167e-6 + 175e-6) + 175.4e-6
return 15 * (3 * pulse_train_s) + 4e-3
@property
def full_azimuth_revisit_s(self) -> float:
return 50 * self.azimuth_step_period_s
@property
def full_scan_update_hz(self) -> float:
return 1.0 / self.full_azimuth_revisit_s
def __post_init__(self):
finite_numeric_fields(self)
if any(value < 0 for value in (self.range_sigma_m, self.azimuth_sigma_deg, self.elevation_sigma_deg, self.doppler_sigma_m_s, self.wired_processing_latency_s)):
raise ValueError("radar noise and latency must be nonnegative")
if self.elevation_step_deg <= 0 or self.half_sector_deg <= 0 or self.elevation_beam_half_width_deg <= 0:
raise ValueError("radar beam geometry must be positive")
if self.model not in {"aeris_10n", "aeris_10x"}:
raise ValueError("radar model must be aeris_10n or aeris_10x")
if not 1 <= self.station_count <= 16 or self.ring_radius_km <= 0:
raise ValueError("radar station count/ring radius are outside supported bounds")
@dataclass(frozen=True)
class DroneLinkConfig:
update_rate_hz: float = 20.0
one_way_latency_s: float = 0.050
latency_jitter_sigma_s: float = 0.005
command_compute_latency_s: float = 0.010
packet_loss_probability: float = 0.0
bitrate_bps: float = 250_000.0
packet_overhead_bytes: int = 24
telemetry_payload_bytes: int = 64
command_payload_bytes: int = 48
def __post_init__(self):
finite_numeric_fields(self)
if self.update_rate_hz <= 0 or self.one_way_latency_s < 0 or self.latency_jitter_sigma_s < 0 or self.bitrate_bps <= 0:
raise ValueError("invalid drone datalink timing")
if self.command_compute_latency_s < 0 or min(self.packet_overhead_bytes, self.telemetry_payload_bytes, self.command_payload_bytes) < 0:
raise ValueError("invalid packet sizes/processing delay")
if not 0 <= self.packet_loss_probability <= 1:
raise ValueError("packet loss probability must be in [0, 1]")
class Aeris10Radar:
"""One scanning AERIS-10 site with a quantized 50-position azimuth scan."""
def __init__(self, radar_id: str, position_ned_m: np.ndarray, inward_heading_rad: float,
config: AerisRadarConfig, rng: np.random.Generator, initial_slot: int = 0):
self.radar_id = radar_id
self.position_ned_m = np.asarray(position_ned_m, dtype=float)
self.inward_heading_rad = float(inward_heading_rad)
self.config = config
self.rng = rng
self.azimuth_slot = int(initial_slot) % 50
self.next_sweep_time_s = 0.0
self.last_detection_time_s: float | None = None
def scan(self, target, time_s: float) -> dict | None:
measurements = self.scan_dwell([target], time_s)
return measurements[0] if measurements else None
def is_dwell_due(self, time_s: float) -> bool:
return time_s + 1e-9 >= self.next_sweep_time_s
def scan_dwell(self, targets, time_s: float) -> list[dict]:
"""One dwell pass over every candidate target.
The slot advances once per dwell and each active target is evaluated
with the same beam/time state; a first target that is out of range or
out of beam can no longer consume the opportunity for later ones (F03).
Detection remains deterministic within geometry/range constraints: no
cross-section/SNR/clutter model is claimed here (R&D assumption).
"""
if not self.is_dwell_due(time_s):
return []
period = self.config.azimuth_step_period_s
missed = max(0, int(np.floor((time_s - self.next_sweep_time_s + 1e-9) / period)))
self.azimuth_slot = (self.azimuth_slot + missed) % 50
self.next_sweep_time_s += (missed + 1) * period
slot_heading = self.inward_heading_rad + self.azimuth_slot * (2 * np.pi / 50)
self.azimuth_slot = (self.azimuth_slot + 1) % 50
measurements = []
for target in targets:
measurement = self._evaluate_at_dwell(target, time_s, slot_heading)
if measurement is not None:
measurement["target_id"] = target.entity_id
measurements.append(measurement)
return measurements
def _evaluate_at_dwell(self, target, time_s: float, slot_heading: float) -> dict | None:
relative = target.position_ned_m - self.position_ned_m
distance = float(np.linalg.norm(relative))
if distance <= 1e-6 or distance > self.config.max_range_m:
return None
north, east, down = relative
azimuth = float(np.arctan2(east, north))
azimuth_error = (azimuth - slot_heading + np.pi) % (2 * np.pi) - np.pi
if abs(azimuth_error) > np.deg2rad(self.config.half_sector_deg):
return None
elevation = float(np.arctan2(-down, np.hypot(north, east)))
if abs(elevation) > np.deg2rad(self.config.elevation_limit_deg):
return None
nearest_elevation_center = round(np.rad2deg(elevation) / self.config.elevation_step_deg) * self.config.elevation_step_deg
if abs(np.rad2deg(elevation) - nearest_elevation_center) > self.config.elevation_beam_half_width_deg:
return None
range_measured = distance + self.rng.normal(0, self.config.range_sigma_m)
azimuth_measured = azimuth + self.rng.normal(0, np.deg2rad(self.config.azimuth_sigma_deg))
elevation_measured = elevation + self.rng.normal(0, np.deg2rad(self.config.elevation_sigma_deg))
direction = np.array([np.cos(elevation_measured) * np.cos(azimuth_measured),
np.cos(elevation_measured) * np.sin(azimuth_measured),
-np.sin(elevation_measured)])
position = self.position_ned_m + max(0.0, range_measured) * direction
line_of_sight = direction
radial_speed = float(np.dot(target.velocity_ned_m_s, relative / distance)
+ self.rng.normal(0, self.config.doppler_sigma_m_s))
self.last_detection_time_s = time_s
return {"radar_id": self.radar_id, "measurement_time_s": time_s,
"arrival_time_s": time_s + self.config.wired_processing_latency_s,
"position_ned_m": position, "line_of_sight_ned": line_of_sight,
"radial_velocity_m_s": radial_speed,
"sensor_position_ned_m": self.position_ned_m.copy(),
"range_sigma_m": self.config.range_sigma_m,
"azimuth_sigma_rad": np.deg2rad(self.config.azimuth_sigma_deg),
"elevation_sigma_rad": np.deg2rad(self.config.elevation_sigma_deg),
"range_m": range_measured, "azimuth_rad": azimuth_measured,
"elevation_rad": elevation_measured}

View File

@@ -1,782 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
import uuid
from copy import deepcopy
import math
import numpy as np
from ..entities.drone import Drone
from ..entities.projectile import Projectile
from ..physics.environment import Environment
from ..sensors.tracking import RadarTracker
from ..sensors.models import Observation, Sensor, SensorConfig
from ..guidance.activation import CityCircle, select_defenders, reachable_within
from ..guidance.ascent import automatic_ascent_length
from ..guidance.trajectory import predict_track
from ..physics.atmosphere import isa
from ..physics.events import sphere_entry_fraction, ground_fraction, circle_entry_fraction
from ..sensors.devices import Aeris10Radar, AerisRadarConfig, DroneLinkConfig
@dataclass
class SimulationEngine:
dt: float = 0.01
seed: int | None = None
environment: Environment = field(default_factory=Environment)
city_circle: CityCircle | None = None
sensor_config: SensorConfig = field(default_factory=SensorConfig)
radar_config: AerisRadarConfig = field(default_factory=AerisRadarConfig)
drone_link_config: DroneLinkConfig = field(default_factory=DroneLinkConfig)
drone_control_rate_hz: float = 50.0
projectiles: list[Projectile] = field(default_factory=list)
drones: list[Drone] = field(default_factory=list)
time_s: float = 0.0
run_id: str = field(default_factory=lambda: str(uuid.uuid4()))
events: list[dict] = field(default_factory=list)
trajectory: dict = field(default_factory=dict)
closest_approach_m: dict = field(default_factory=dict)
record_interval_s: float = 0.2
def __post_init__(self):
if not np.isfinite(self.dt) or self.dt <= 0:
raise ValueError("simulation dt must be positive")
if not np.isfinite(self.record_interval_s) or self.record_interval_s <= 0:
raise ValueError("trajectory record interval must be positive")
ids = [e.entity_id for e in [*self.projectiles, *self.drones]]
if len(set(ids)) != len(ids):
raise ValueError("entity ids must be unique")
for entity in [*self.projectiles, *self.drones]:
for name in ("position_ned_m", "velocity_ned_m_s"):
value = np.asarray(getattr(entity, name), dtype=float)
if value.shape != (3,) or not np.isfinite(value).all():
raise ValueError(f"{name} must be a finite 3-vector")
setattr(entity, name, value.copy())
self.rng = np.random.default_rng(self.seed)
if not np.isfinite(self.drone_control_rate_hz) or not 0 < self.drone_control_rate_hz <= 1/self.dt:
raise ValueError("drone control rate must be positive")
self.sensors = {d.entity_id: Sensor(self.sensor_config, np.random.default_rng(self.rng.integers(0, 2**63))) for d in self.drones}
self.latest_observations = {}
self.radar_tracks = {}
self.radar_tracker = RadarTracker()
self.track_predictions = {}
self.radar_arrivals = []
self.latest_detections: dict[str, dict] = {}
self.radar_detection_points = {}
self.pending_drone_commands = []
self.standby_launches = {}
self.pending_drone_telemetry = []
self.wireless_channel_free_time_s = 0.0
self.wireless_airtime_s = 0.0
self.remote_drone_estimates = {}
self.remote_drone_velocities = {}
self.remote_drone_estimate_times = {}
self.remote_drone_receive_times = {}
self.remote_drone_link_latencies = {}
self.remote_drone_telemetry_age_s = {}
self.next_control_time_s = {d.entity_id: 0.0 for d in self.drones}
self.activated_threats: set[str] = set()
self._last_activation_check_s: dict[str, float] = {}
for e in [*self.projectiles, *self.drones]:
self.trajectory[e.entity_id] = [np.asarray(e.position_ned_m, dtype=float).copy()]
self.closest_approach_m = {p.entity_id: float("inf") for p in self.projectiles}
for p in self.projectiles:
p.estimated_position_ned_m = None
p.estimated_velocity_ned_m_s = None
# Per-projectile defense lifecycle bookkeeping (assignment state
# machine: proposed -> sent -> acknowledged -> active -> released).
self.protected_entries: set[str] = set()
self.projectile_outcomes: dict[str, str] = {}
self.defense_assignments: dict[str, dict] = {} # threat_id -> assignment record
self.pending_assignment_commands_by_drone: dict[str, list[dict]] = {}
self.threat_release_events: list[tuple[float, str]] = [] # (time_s, threat_id)
self.radars = []
if self.city_circle is not None:
ring_m = self.radar_config.ring_radius_km * 1000.0
for index in range(self.radar_config.station_count):
angle = 2 * np.pi * index / self.radar_config.station_count
position = self.city_circle.center_ned_m + np.array([ring_m * np.cos(angle), ring_m * np.sin(angle), 0.0])
radar_rng = np.random.default_rng(self.rng.integers(0, 2**63))
self.radars.append(Aeris10Radar(f"aeris10_{index+1:02d}", position, angle + np.pi,
self.radar_config, radar_rng, initial_slot=int(radar_rng.integers(0, 50))))
def step(self) -> "SimulationEngine":
"""Advance one physics step. Never serializes; use ``step_and_snapshot``
when a JSON-able state is needed."""
if not hasattr(self, "record_interval_s"):
self.record_interval_s = self.dt
# Observation -> controller -> actuator/physics; each controller only sees estimates.
moving = [p for p in self.projectiles if p.active] + [d for d in self.drones if d.active and (d.position_ned_m[2] < 0 or d.target_position_ned_m is not None)]
if moving:
altitude = float(np.mean([-e.position_ned_m[2] for e in moving]))
speed = float(np.mean([np.linalg.norm(e.velocity_ned_m_s-self.environment.wind_ned_m_s) for e in moving]))
else:
altitude, speed = 0., 0.
self.environment.update(self.dt, altitude, speed)
for d in self.drones:
if not d.active:
continue
sensor = self.sensors[d.entity_id]
# Nav samples run at their own cadence (default 10 Hz): the truth
# history is cheap to keep every step, but observe() (and with it
# telemetry queuing and RNG draws) only fires when due.
if not sensor.is_update_due(self.time_s) and not sensor.pending:
continue
obs = sensor.observe(d, self.time_s)
if obs is not None:
self.latest_observations[d.entity_id] = obs
self._queue_drone_telemetry(d.entity_id, obs)
# Radar scan cadence: a radar advances its azimuth dwell at most once
# per physics step (its 0.258 s slot period is far longer than dt), and
# one dwell evaluates ALL active targets with the same beam/time state
# before the slot advances -- target list order must not decide which
# threats get a chance to be detected (F03).
self._scan_radars()
self._deliver_radar_tracks()
self._deliver_drone_telemetry()
self._service_standby_launches()
projectile_tracks = dict(self.radar_tracks)
self._service_assignments()
self._activate_defenders(projectile_tracks)
self._deliver_drone_commands()
wind = self.environment.air_velocity_ned_m_s
previous = {e.entity_id: e.position_ned_m.copy() for e in [*self.projectiles, *self.drones]}
previous_velocity = {e.entity_id: e.velocity_ned_m_s.copy() for e in [*self.projectiles, *self.drones]}
previously_active = {e.entity_id: e.active for e in [*self.projectiles, *self.drones]}
for p in self.projectiles:
if p.guidance is not None and not p.guidance_active and p.active:
p.guidance_active = True
self.events.append({"type": "terminal_guidance_active", "time_s": self.time_s,
"projectile_id": p.entity_id,
"strike_target_ned_m": p.guidance.target_ned_m.tolist(),
"note": "terminal glide steering engaged on the strike target"})
p.step(self.dt, wind, self.time_s)
control_period = 1.0 / self.drone_control_rate_hz
record_every = max(1, int(round(self.record_interval_s / self.dt))) if hasattr(self, "record_interval_s") else 1
step_count = int(round(self.time_s / self.dt))
for d in self.drones:
control_due = self.time_s + 1e-9 >= self.next_control_time_s[d.entity_id]
if control_due:
while self.next_control_time_s[d.entity_id] <= self.time_s + 1e-9:
self.next_control_time_s[d.entity_id] += control_period
d.step(self.dt, wind, self.rng, control_due=control_due, control_dt=control_period)
self.time_s += self.dt
self._resolve_physical_events(previous, previous_velocity)
for drone in self.drones:
if drone.active and drone.activated_for is not None:
threat = next((p for p in self.projectiles if p.entity_id == drone.activated_for), None)
drone.estimate_threat(threat, self.time_s, self.rng, self.events,
radar_prior=self.radar_tracks.get(drone.activated_for))
self._release_finished_assignments()
record_now = step_count % record_every == 0
for e in [*self.projectiles, *self.drones]:
if record_now or (previously_active[e.entity_id] and not e.active):
self.trajectory[e.entity_id].append(e.position_ned_m.copy())
del self.trajectory[e.entity_id][:-1800]
return self
def step_and_snapshot(self) -> dict:
"""Advance one physics step and return a serialized snapshot.
``step`` itself never serializes (that was the dominant per-step cost);
callers that need a JSON-able state use this wrapper or ``snapshot``.
"""
self.step()
return self.snapshot()
def _scan_radars(self):
"""One dwell per radar per step, evaluating all active targets together."""
targets = [p for p in self.projectiles if p.active]
if not targets:
return
for radar in self.radars:
for measurement in radar.scan_dwell(targets, self.time_s):
target_id = measurement["target_id"]
self.radar_arrivals.append(measurement)
history = self.radar_detection_points.setdefault(target_id, [])
history.append(measurement["position_ned_m"].tolist())
del history[:-600]
prev = self.latest_detections.get(target_id)
if prev is None or measurement["measurement_time_s"] > prev["measurement_time_s"]:
self.latest_detections[target_id] = measurement
def _deliver_radar_tracks(self):
due = [m for m in self.radar_arrivals if m["arrival_time_s"] <= self.time_s + 1e-9]
self.radar_arrivals = [m for m in self.radar_arrivals if m["arrival_time_s"] > self.time_s + 1e-9]
targets = {p.entity_id: p for p in self.projectiles if p.active}
for measurement in sorted(due, key=lambda m: (m["measurement_time_s"], m["radar_id"])):
target = targets.get(measurement["target_id"])
if target is None:
continue
# One delivered position detection is enough for standby launch;
# velocity evidence and reachability gate only the later assignment.
self._request_standby_launches(target.entity_id)
estimate = self.radar_tracker.update(measurement)
if estimate is None:
continue
# Truth comparison is diagnostic only and never enters the filter.
obs = Observation(target.entity_id, estimate.timestamp_s, estimate.position,
estimate.velocity, target.position_ned_m-estimate.position,
target.velocity_ned_m_s-estimate.velocity, estimate.covariance,
estimate.sample_count, estimate.velocity_observable,
error_reference="truth_at_receive_minus_sample_time_estimate")
if self.city_circle is not None and estimate.velocity_observable:
prediction = predict_track(estimate.position, estimate.velocity, self.city_circle, estimate.timestamp_s)
if prediction is not None:
self.track_predictions[target.entity_id] = prediction.samples
self.radar_tracks[target.entity_id] = obs
self.latest_observations[target.entity_id] = obs
target.estimated_position_ned_m = estimate.position.copy()
target.estimated_velocity_ned_m_s = estimate.velocity.copy()
self.events.append({"type": "radar_track_update", "time_s": self.time_s,
"measurement_time_s": estimate.timestamp_s, "target_id": target.entity_id,
"radar_ids": [measurement["radar_id"]], "position_ned_m": estimate.position.tolist(),
"velocity_ned_m_s": estimate.velocity.tolist(), "covariance": estimate.covariance.tolist(),
"sample_count": estimate.sample_count, "velocity_observable": estimate.velocity_observable,
"velocity_estimation_rule": "stonesoup_constant_velocity_position_filter"})
def _deliver_drone_commands(self):
pending = []
for command in self.pending_drone_commands:
if command["delivery_time_s"] > self.time_s + 1e-9:
pending.append(command)
continue
drone = next((d for d in self.drones if d.entity_id == command["drone_id"]), None)
if drone is None:
continue
target = next((p for p in self.projectiles if p.entity_id == command["threat_id"]), None)
if command.get("kind") == "standby_takeoff":
if not drone.active or drone.activated_for is not None or target is None or not target.active:
continue
drone.standby_altitude_m = max(-drone.position_ned_m[2], command["altitude_m"])
drone.target_position_ned_m = drone.position_ned_m.copy()
self.standby_launches[drone.entity_id]["status"] = "active"
self.events.append({"type": "standby_takeoff_started", "time_s": self.time_s,
"drone_id": drone.entity_id, "threat_id": target.entity_id,
"standby_altitude_m": drone.standby_altitude_m,
"end_to_end_command_delay_s": self.time_s-command["computed_time_s"]})
continue
record = self.defense_assignments.get(command["threat_id"])
if not drone.active or target is None or not target.active or record is None:
continue
if command["computed_time_s"] <= getattr(drone, "_last_command_computed_time_s", -float("inf")):
continue
drone._last_command_computed_time_s = command["computed_time_s"]
drone.activated_for = command["threat_id"]
drone.standby_altitude_m = None
if drone.flight_reference is None:
drone.target_position_ned_m = drone.position_ned_m.copy()
reference = command.get("flight_reference")
if reference is not None:
if drone.flight_reference is None:
drone.flight_reference = {**reference,
"initial_altitude_m": -drone.position_ned_m[2], "travel_m": 0., "elapsed_s": 0.,
"previous_position": drone.position_ned_m.copy()}
else:
# Preserve ascent progress and elapsed phase during replans.
drone.flight_reference["direction"] = reference["direction"].copy()
drone.flight_reference["target_altitude_m"] = reference["target_altitude_m"]
else:
drone.target_position_ned_m = command["target_position_ned_m"].copy()
record["status"][drone.entity_id] = "active"
if all(value == "active" for value in record["status"].values()) and not record.get("activation_recorded"):
record["activation_recorded"] = True
self.events.append({"type": "defenders_activated", "time_s": self.time_s,
"threat_id": target.entity_id, "defender_ids": record["defender_ids"]})
self.events.append({"type": "drone_command_received", "time_s": self.time_s,
"command_sent_time_s": command["sent_time_s"], "drone_id": drone.entity_id,
"threat_id": command["threat_id"],
"end_to_end_command_delay_s": self.time_s - command["computed_time_s"],
"wireless_queue_delay_s": command["queue_delay_s"],
"wireless_airtime_s": command["airtime_s"],
"wireless_one_way_latency_s": self.time_s - command["sent_time_s"]})
self.pending_drone_commands = pending
def _request_standby_launches(self, threat_id):
scenario = getattr(self, "scenario_config", {})
if not scenario.get("early_takeoff_enabled", True):
return
for drone in self.drones:
if drone.entity_type != "defense_drone" or not drone.active or not drone.available or drone.activated_for is not None:
continue
if drone.entity_id in self.standby_launches:
record = self.standby_launches[drone.entity_id]
previous_threat_active = any(p.active and p.entity_id == record["threat_id"] for p in self.projectiles)
if drone.standby_altitude_m is not None or record["threat_id"] == threat_id or previous_threat_active:
continue
# An alert for a terminated threat must not permanently block
# a later incident if its command never reached this drone.
self.pending_drone_commands = [c for c in self.pending_drone_commands
if not (c["drone_id"] == drone.entity_id and c.get("kind") == "standby_takeoff")]
self.standby_launches[drone.entity_id] = {"threat_id": threat_id,
"altitude_m": scenario.get("standby_altitude_m", 150.), "attempts": 0,
"last_send": -float("inf"), "status": "proposed"}
self._send_standby_command(drone.entity_id)
def _send_standby_command(self, drone_id):
record = self.standby_launches[drone_id]
record["attempts"] += 1
record["last_send"] = self.time_s
computed = self.time_s+self.drone_link_config.command_compute_latency_s
_, sent = self._wireless_transmit(computed, self.drone_link_config.command_payload_bytes)
if self.rng.random() < self.drone_link_config.packet_loss_probability:
record["status"] = "dropped"
self.events.append({"type": "standby_command_dropped", "time_s": self.time_s,
"drone_id": drone_id, "threat_id": record["threat_id"]})
return
latency = max(0., self.drone_link_config.one_way_latency_s+self.rng.normal(
0., self.drone_link_config.latency_jitter_sigma_s))
record["status"] = "sent"
self.pending_drone_commands.append({"kind": "standby_takeoff", "drone_id": drone_id,
"threat_id": record["threat_id"], "altitude_m": record["altitude_m"],
"computed_time_s": computed, "delivery_time_s": sent+latency})
self.events.append({"type": "standby_takeoff_requested", "time_s": self.time_s,
"drone_id": drone_id, "threat_id": record["threat_id"], "altitude_m": record["altitude_m"]})
def _service_standby_launches(self):
active_threats = {p.entity_id for p in self.projectiles if p.active}
for did, record in self.standby_launches.items():
drone = next(d for d in self.drones if d.entity_id == did)
if not drone.active or drone.activated_for is not None or record["threat_id"] not in active_threats:
if record["status"] != "active":
record["status"] = "cancelled"
continue
if record["status"] in {"active", "failed", "cancelled"}:
continue
if any(c["drone_id"] == did and c.get("kind") == "standby_takeoff" for c in self.pending_drone_commands):
continue
if self.time_s-record["last_send"] < 1.:
continue
if record["attempts"] >= 3:
record["status"] = "failed"
self.events.append({"type": "standby_takeoff_failed", "time_s": self.time_s,
"drone_id": did, "threat_id": record["threat_id"]})
else:
self._send_standby_command(did)
def _queue_drone_telemetry(self, drone_id, observation):
if self.rng.random() < self.drone_link_config.packet_loss_probability:
self.events.append({"type": "drone_telemetry_dropped", "time_s": self.time_s,
"drone_id": drone_id})
return
computed_at = self.time_s
request_time = computed_at
tx_start, transmitted_at = self._wireless_transmit(request_time, self.drone_link_config.telemetry_payload_bytes)
latency = max(0.0, self.drone_link_config.one_way_latency_s
+ self.rng.normal(0.0, self.drone_link_config.latency_jitter_sigma_s))
self.pending_drone_telemetry.append({"drone_id": drone_id,
"measurement_time_s": observation.timestamp_s, "position_ned_m": observation.position_ned_m.copy(),
"velocity_ned_m_s": observation.velocity_ned_m_s.copy(), "sent_time_s": float(transmitted_at),
"delivery_time_s": float(transmitted_at + latency),
"queue_delay_s": max(0.0, tx_start - request_time),
"airtime_s": transmitted_at - tx_start, "wireless_latency_s": latency})
def _wireless_transmit(self, request_time_s: float, payload_bytes: int):
"""Serialize packets through one shared R&D half-duplex radio channel."""
period = 1.0 / self.drone_link_config.update_rate_hz
slot = np.ceil((request_time_s - 1e-9) / period) * period
start = max(float(slot), self.wireless_channel_free_time_s)
airtime = (payload_bytes + self.drone_link_config.packet_overhead_bytes) * 8.0 / self.drone_link_config.bitrate_bps
end = start + airtime
self.wireless_channel_free_time_s = end
self.wireless_airtime_s += airtime
return start, end
def _deliver_drone_telemetry(self):
pending = []
for packet in self.pending_drone_telemetry:
if packet["delivery_time_s"] > self.time_s + 1e-9:
pending.append(packet)
continue
if packet["measurement_time_s"] <= self.remote_drone_estimate_times.get(packet["drone_id"], -float("inf")):
continue
self.remote_drone_estimates[packet["drone_id"]] = packet["position_ned_m"]
self.remote_drone_velocities[packet["drone_id"]] = packet.get("velocity_ned_m_s", np.zeros(3))
self.remote_drone_estimate_times[packet["drone_id"]] = packet["measurement_time_s"]
self.remote_drone_receive_times[packet["drone_id"]] = self.time_s
self.remote_drone_link_latencies[packet["drone_id"]] = packet["wireless_latency_s"]
self.remote_drone_telemetry_age_s[packet["drone_id"]] = self.time_s - packet["measurement_time_s"]
self.pending_drone_telemetry = pending
def _send_assignment_command(self, threat_id, drone_id):
record = self.defense_assignments[threat_id]
record["attempts"][drone_id] += 1
record["last_send"][drone_id] = self.time_s
computed = self.time_s+self.drone_link_config.command_compute_latency_s
start, sent = self._wireless_transmit(computed, self.drone_link_config.command_payload_bytes)
if self.rng.random() < self.drone_link_config.packet_loss_probability:
if record["status"][drone_id] != "active":
record["status"][drone_id] = "dropped"
self.events.append({"type": "drone_command_dropped", "time_s": self.time_s,
"drone_id": drone_id, "threat_id": threat_id})
return
latency = max(0., self.drone_link_config.one_way_latency_s + self.rng.normal(0., self.drone_link_config.latency_jitter_sigma_s))
if record["status"][drone_id] != "active":
record["status"][drone_id] = "sent"
self.pending_drone_commands.append({"drone_id": drone_id, "threat_id": threat_id,
"target_position_ned_m": record["waypoints"][drone_id].copy(),
"flight_reference": deepcopy(record.get("references", {}).get(drone_id)),
"sent_time_s": sent, "computed_time_s": computed,
"queue_delay_s": max(0., start-computed), "airtime_s": sent-start,
"delivery_time_s": sent+latency})
def _service_assignments(self):
active_threats = {p.entity_id for p in self.projectiles if p.active}
for threat_id, record in list(self.defense_assignments.items()):
if threat_id not in active_threats:
continue
failed = False
for did in record["defender_ids"]:
if record["status"][did] == "active":
continue
# A queued packet may have long modeled serialization latency;
# do not retransmit it while it is still awaiting delivery.
if any(c["drone_id"] == did and c["threat_id"] == threat_id for c in self.pending_drone_commands):
continue
if self.time_s-record["last_send"][did] < 1.:
continue
if record["attempts"][did] >= 3:
failed = True
break
self._send_assignment_command(threat_id, did)
if failed:
self._release_assignment(threat_id, "command_delivery_failed")
continue
track = self.radar_tracks.get(threat_id)
if track is not None and track.velocity_observable and self.time_s-track.timestamp_s <= self.radar_config.full_azimuth_revisit_s*1.1 and self.time_s-record["last_replan_s"] >= 1.:
velocity = track.velocity_ned_m_s.copy()
velocity[2] = 0.
norm = float(np.linalg.norm(velocity))
if norm > 1e-6:
for did, ref in record.get("references", {}).items():
ref["direction"] = velocity/norm
ref["target_altitude_m"] = max(0., -(track.position_ned_m[2]+track.velocity_ned_m_s[2]*(self.time_s-track.timestamp_s)))
if record["status"][did] == "active" and not any(c["drone_id"] == did for c in self.pending_drone_commands):
self._send_assignment_command(threat_id, did)
record["last_replan_s"] = self.time_s
def _release_assignment(self, threat_id, reason):
record = self.defense_assignments.pop(threat_id)
self.pending_drone_commands = [c for c in self.pending_drone_commands if c["threat_id"] != threat_id]
for did in record["defender_ids"]:
drone = next(d for d in self.drones if d.entity_id == did)
drone.activated_for = None
drone.flight_reference = None
drone.standby_altitude_m = None
drone.available = drone.active
drone.target_position_ned_m = drone.position_ned_m.copy() if drone.active and drone.position_ned_m[2] < 0 else None
self.events.append({"type": "defenders_released", "time_s": self.time_s,
"threat_id": threat_id, "defender_ids": record["defender_ids"], "reason": reason})
def _release_finished_assignments(self):
still_active = {p.entity_id for p in self.projectiles if p.active}
for threat_id in list(self.defense_assignments):
if threat_id not in still_active:
self._release_assignment(threat_id, "threat_terminated")
def _activate_defenders(self, tracks):
"""Reserve exactly three nearest defenders after sufficient evidence.
Uses timestamped estimates and a declared constant-velocity forecast.
Reachability is a necessary kinematic screen, not a flight guarantee.
Commands, including replans, take effect only after link delivery.
"""
if self.city_circle is None:
return
self._release_finished_assignments()
for p in self.projectiles:
if not p.active or p.entity_id in self.activated_threats:
continue
if self.time_s - self._last_activation_check_s.get(p.entity_id, -1e9) < 1.0:
continue
self._last_activation_check_s[p.entity_id] = self.time_s
track = tracks.get(p.entity_id)
if track is None or not track.velocity_observable:
continue
if self.time_s - track.timestamp_s > self.radar_config.full_azimuth_revisit_s * 1.1:
continue
track_time = self.time_s
track_velocity = track.velocity_ned_m_s.copy()
track_position = track.position_ned_m + track_velocity * (self.time_s-track.timestamp_s)
prediction = predict_track(track_position, track_velocity, self.city_circle,
self.time_s, ceiling_m=3000.)
if prediction is not None:
self.track_predictions[p.entity_id] = prediction.samples
if prediction is None or prediction.crossing_ned_m is None:
continue
aim = prediction.crossing_ned_m
max_age_s = 2.0 / self.sensor_config.update_rate_hz + self.drone_link_config.one_way_latency_s + 0.05
remote_positions = {drone_id: position+self.remote_drone_velocities.get(drone_id, np.zeros(3))*(self.time_s-self.remote_drone_estimate_times[drone_id])
for drone_id, position in self.remote_drone_estimates.items()
if self.time_s - self.remote_drone_estimate_times.get(drone_id, -1e9) <= max_age_s}
if len(remote_positions) < 3:
continue
chosen = select_defenders(self.drones, aim, count=3, position_estimates=remote_positions,
path_segment=(track_position, aim))
if len(chosen) < 3:
continue
# Finite future path: current estimate through first protected entry.
delta = aim-track_position
length2 = float(delta @ delta)
waypoints = {}
references = {}
direction = track_velocity.copy()
direction[2] = 0.
direction /= max(float(np.linalg.norm(direction)), 1e-9)
for drone in chosen:
position = remote_positions[drone.entity_id]
fraction = float(np.clip((position-track_position) @ delta/max(length2, 1e-9), 0, 1))
waypoint = track_position+fraction*delta
waypoints[drone.entity_id] = waypoint
scenario = getattr(self, "scenario_config", {})
curve = scenario.get("ascent_curve", "quintic")
speed = min(drone.config.max_speed_m_s*.8, float(np.linalg.norm(track_velocity[:2]))*.5)
length = scenario.get("ascent_distance_m")
if length is None:
length = automatic_ascent_length(-waypoint[2]+position[2], speed, drone.config.max_climb_rate_m_s, curve)
references[drone.entity_id] = {"anchor": waypoint.copy(), "direction": direction.copy(),
"target_altitude_m": max(0., -waypoint[2]), "speed_m_s": speed,
"ascent_curve": curve, "ascent_distance_m": length,
"ascent_k_per_m": scenario.get("ascent_k_per_m", .002)}
# Feasibility gate (F05): each defender must be able to reach its
# waypoint before the threat is predicted to pass it, using only
# estimates + declared vehicle limits. Infeasible assignments are
# reported rather than silently committed; the outcome always comes
# from simulated motion, never from this test declaring success.
crossing_time = prediction.crossing_time_s
velocity2 = float(track_velocity @ track_velocity)
airtime = 3*(self.drone_link_config.command_payload_bytes+self.drone_link_config.packet_overhead_bytes)*8/self.drone_link_config.bitrate_bps
delay = (self.drone_link_config.command_compute_latency_s + self.drone_link_config.one_way_latency_s
+ 1/self.drone_link_config.update_rate_hz + airtime
+ max(0., self.wireless_channel_free_time_s-self.time_s))
available_times = {d.entity_id: float((waypoints[d.entity_id]-track_position) @ track_velocity/max(velocity2, 1e-9))-delay
for d in chosen}
feasible = [d for d in chosen if reachable_within(
remote_positions[d.entity_id], waypoints[d.entity_id], available_times[d.entity_id],
d.config.max_speed_m_s, d.config.max_climb_rate_m_s, d.config.max_descent_rate_m_s)]
if len(feasible) < 3:
self.events.append({"type": "defense_assignment_infeasible", "time_s": self.time_s,
"threat_id": p.entity_id, "nearest_defender_ids": [d.entity_id for d in chosen],
"reason": "reachability_gate_failed",
"projected_city_crossing_ned_m": aim.tolist(),
"crossing_time_s": crossing_time, "available_time_by_drone_s": available_times})
continue
for drone in chosen:
drone.available = False
# Reservation is not a delivered launch command.
# Targets become executable only after command delivery.
# Seed the IR seeker RNG so the tasked drone can acquire the
# threat once inside seeker range.
if drone.ir_rng is None:
drone.ir_rng = np.random.default_rng(self.rng.integers(0, 2**63))
self.defense_assignments[p.entity_id] = {"defender_ids": [d.entity_id for d in chosen],
"waypoints": waypoints, "references": references, "last_replan_s": self.time_s, "status": {d.entity_id: "proposed" for d in chosen},
"attempts": {d.entity_id: 0 for d in chosen}, "last_send": {d.entity_id: -1e9 for d in chosen}}
for drone in chosen:
self._send_assignment_command(p.entity_id, drone.entity_id)
self.activated_threats.add(p.entity_id)
self.events.append({"type": "defenders_assigned", "time_s": self.time_s,
"threat_id": p.entity_id, "defender_ids": [d.entity_id for d in chosen],
"projected_city_crossing_ned_m": aim.tolist(),
"projected_threat_line_ned_m": prediction.line_origin_ned_m.tolist(),
"projected_threat_direction_ned": prediction.line_direction_ned.tolist(),
"waypoints_ned_m": {did: wp.tolist() for did, wp in waypoints.items()},
"ascent_references": {did: {k: ref[k] for k in ("ascent_curve", "ascent_distance_m", "ascent_k_per_m")} for did, ref in references.items()},
"ground_track_time_s": track.timestamp_s,
"radar_to_control_latency_s": self.time_s - track.timestamp_s,
"command_compute_latency_s": self.drone_link_config.command_compute_latency_s,
"wireless_update_rate_hz": self.drone_link_config.update_rate_hz,
"wireless_one_way_latency_s": self.drone_link_config.one_way_latency_s,
"selection_rule": "three nearest available defenders by 3D distance to finite estimated future path, ties by entity id"})
def _resolve_physical_events(self, previous, previous_velocity):
"""Apply earliest events globally; ground wins an exact contact tie.
Collision participants are consumed. Collision-as-neutralization is
an explicit game assumption, without blast/debris modeling.
"""
candidates = []
for p in self.projectiles:
if not p.active:
continue
ground = ground_fraction(previous[p.entity_id], p.position_ned_m)
limit = ground if ground is not None else 1.0
if ground is not None:
candidates.append((ground, 0, p.entity_id, "", "ground_impact"))
if self.city_circle is not None and p.entity_id not in self.protected_entries:
entry = circle_entry_fraction(previous[p.entity_id], p.position_ned_m,
self.city_circle.center_ned_m, self.city_circle.radius_m)
if entry is not None and (ground is None or entry < ground):
candidates.append((entry, 2, p.entity_id, "", "protected_area_entry"))
for d in self.drones:
if not d.active:
continue
r0 = previous[d.entity_id] - previous[p.entity_id]
r1 = d.position_ned_m - p.position_ned_m
delta = r1 - r0
denom = float(delta @ delta)
nearest = float(np.clip(-float(r0 @ delta) / denom, 0, limit)) if denom else 0.0
distance = float(np.linalg.norm(r0 + nearest * delta))
self.closest_approach_m[p.entity_id] = min(self.closest_approach_m[p.entity_id], distance)
contact = sphere_entry_fraction(r0, r1,
p.config.collision_radius_m + d.config.collision_radius_m)
if contact is not None and contact <= limit:
candidates.append((contact, 1, p.entity_id, d.entity_id, "contact"))
by_projectile = {p.entity_id: p for p in self.projectiles}
by_drone = {d.entity_id: d for d in self.drones}
endpoints = {e.entity_id: e.position_ned_m.copy() for e in [*self.projectiles, *self.drones]}
endvel = {e.entity_id: e.velocity_ned_m_s.copy() for e in [*self.projectiles, *self.drones]}
for fraction, _, pid, did, kind in sorted(candidates):
p = by_projectile[pid]
if not p.active:
continue
position = previous[pid] + fraction * (endpoints[pid] - previous[pid])
event_time = self.time_s - self.dt + fraction * self.dt
if kind == "protected_area_entry":
self.protected_entries.add(pid)
self.events.append({"type": kind, "time_s": event_time,
"projectile_id": pid, "position_ned_m": position.tolist()})
continue
if kind == "contact":
d = by_drone[did]
if not d.active:
continue
d.position_ned_m = previous[did] + fraction * (endpoints[did] - previous[did])
d.velocity_ned_m_s = previous_velocity[did] + fraction * (endvel[did] - previous_velocity[did])
d.active, d.available = False, False
d.target_position_ned_m = None
kind = "interception" if d.entity_type == "defense_drone" else "collision"
outcome = "intercepted" if kind == "interception" else "collision"
event = {"type": kind, "time_s": event_time, "projectile_id": pid,
"target_id": did, "position_ned_m": position.tolist(),
"miss_distance_m": float(np.linalg.norm(d.position_ned_m - position)),
"intercept_error_ned_m": (d.position_ned_m - position).tolist(),
"neutralization_assumption": "contact_consumes_both"}
else:
position[2] = 0.0
inside = self.city_circle is not None and float(np.linalg.norm(
position[:2] - self.city_circle.center_ned_m[:2])) <= self.city_circle.radius_m
outcome = "protected_impact" if inside else "safe_ground_impact"
event = {"type": "ground_impact", "time_s": event_time,
"projectile_id": pid, "position_ned_m": position.tolist(), "outcome": outcome}
p.position_ned_m = position
p.velocity_ned_m_s = previous_velocity[pid] + fraction * (endvel[pid] - previous_velocity[pid])
p.active = False
event["velocity_ned_m_s"] = p.velocity_ned_m_s.tolist()
self.projectile_outcomes[pid] = outcome
self.events.append(event)
def snapshot(self) -> dict:
entities = []
for e in [*self.projectiles, *self.drones]:
kind = "projectile" if isinstance(e, Projectile) else e.entity_type
row = {"id": e.entity_id, "type": kind, "active": e.active,
"position_ned_m": e.position_ned_m.tolist(), "velocity_ned_m_s": e.velocity_ned_m_s.tolist(),
"acceleration_ned_m_s2": e.acceleration_ned_m_s2.tolist()}
if isinstance(e, Drone):
row.update({"orientation_quaternion_wxyz": e.orientation_quaternion_wxyz.tolist(),
"angular_velocity_body_rad_s": e.angular_velocity_body_rad_s.tolist(),
"position_error_ned_m": (e.position_ned_m - e.estimated_position_ned_m).tolist() if e.estimated_position_ned_m is not None else None,
"velocity_error_ned_m_s": (e.velocity_ned_m_s - e.estimated_velocity_ned_m_s).tolist() if e.estimated_velocity_ned_m_s is not None else None,
"actuator_acceleration_ned_m_s2": e.actuator_acceleration_ned_m_s2.tolist()})
row.update({"available": e.available, "activated_for": e.activated_for,
"standby_altitude_m": e.standby_altitude_m,
"flight_phase": ("inactive" if not e.active else "assigned" if e.activated_for is not None
else ("standby_climb" if -e.position_ned_m[2] < e.standby_altitude_m-2 else "standby_hold")
if e.standby_altitude_m is not None else "takeoff_pending"
if self.standby_launches.get(e.entity_id, {}).get("status") == "sent" else "idle"),
"intended_trajectory_ned_m": e.intended_trajectory(),
"intention_kind": "delivered_command_reference",
"flight_dynamics": e.flight_dynamics.model_name if e.flight_dynamics is not None else "reduced_kinematic_surrogate",
"ir_acquired": e.ir_acquired, "ir_observation": e.ir_observation})
elif e.estimated_position_ned_m is not None:
row.update({"estimated_position_ned_m": e.estimated_position_ned_m.tolist(),
"estimated_velocity_ned_m_s": e.estimated_velocity_ned_m_s.tolist(),
"position_error_ned_m": (e.position_ned_m - e.estimated_position_ned_m).tolist(),
"velocity_error_ned_m_s": (e.velocity_ned_m_s - e.estimated_velocity_ned_m_s).tolist()})
if isinstance(e, Projectile) and e.guidance is not None:
row.update({"strike_target_ned_m": e.guidance.target_ned_m.tolist(),
"guidance_active": e.guidance_active,
"terminal_guidance": {"max_thrust_m_s2": e.guidance.max_thrust_m_s2,
"command_rate_hz": e.guidance.command_rate_hz}})
row["trajectory_ned_m"] = np.asarray(self.trajectory[e.entity_id][-1800:]).tolist()
entities.append(row)
if entities:
reference_altitude = -float(np.mean([e["position_ned_m"][2] for e in entities]))
else:
reference_altitude = 0.0
air = isa(reference_altitude)
observations = {key: {"timestamp_s": value.timestamp_s,
"position_ned_m": value.position_ned_m.tolist(),
"velocity_ned_m_s": value.velocity_ned_m_s.tolist(),
"position_error_ned_m": value.position_error_ned_m.tolist(),
"velocity_error_ned_m_s": value.velocity_error_ned_m_s.tolist(),
"error_reference": value.error_reference,
**({"control_station_receive_time_s": self.remote_drone_receive_times[key],
"wireless_one_way_latency_s": self.remote_drone_link_latencies[key],
"remote_measurement_time_s": self.remote_drone_estimate_times[key],
"remote_position_ned_m": self.remote_drone_estimates[key].tolist(),
"data_age_at_control_station_s": self.time_s - self.remote_drone_estimate_times[key]}
if key in self.remote_drone_receive_times else {})}
for key, value in self.latest_observations.items()}
closest = min(self.closest_approach_m.values(), default=float("inf"))
closest = closest if math.isfinite(closest) else None
return {"schema_version": 1, "run_id": self.run_id, "sequence": int(round(self.time_s / self.dt)),
"simulation_time_s": self.time_s, "kind": "simulation_state", "entities": entities,
"events": list(self.events[-250:]), "environment": {"wind_ned_m_s": self.environment.wind_ned_m_s.tolist(),
"gust_ned_m_s": self.environment.gust_ned_m_s.tolist(),
"altitude_m": reference_altitude, "temperature_k": air.temperature_k, "pressure_pa": air.pressure_pa,
"density_kg_m3": air.density_kg_m3, "speed_of_sound_m_s": air.speed_of_sound_m_s,
"viscosity_pa_s": air.viscosity_pa_s,
"city_center_ned_m": self.city_circle.center_ned_m.tolist() if self.city_circle else [0.0, 0.0, 0.0],
"city_radius_m": self.city_circle.radius_m if self.city_circle else 0.0,
"protected_radius_m": self.city_circle.radius_m if self.city_circle else 0.0,
"radar_radius_m": self.radar_config.ring_radius_km * 1000.0,
"drone_radius_m": (getattr(self, "scenario_config", {}).get("drone_radius_km", 0.0)
or getattr(self, "scenario_config", {}).get("defense_perimeter_km", 0.0)
or getattr(self, "scenario_config", {}).get("defense_ring_km", 0.0)) * 1000.0,
"defense_perimeter_radius_m": (getattr(self, "scenario_config", {}).get("drone_radius_km", 0.0)
or getattr(self, "scenario_config", {}).get("defense_perimeter_km", 0.0)
or getattr(self, "scenario_config", {}).get("defense_ring_km", 0.0)) * 1000.0,
"defense_ring_radius_m": (getattr(self, "scenario_config", {}).get("drone_radius_km", 0.0)
or getattr(self, "scenario_config", {}).get("defense_perimeter_km", 0.0)
or getattr(self, "scenario_config", {}).get("defense_ring_km", 0.0)) * 1000.0},
"devices": {"radars": [{"id": r.radar_id, "position_ned_m": r.position_ned_m.tolist(),
"azimuth_slot": r.azimuth_slot, "last_detection_time_s": r.last_detection_time_s}
for r in self.radars],
"radar_profile": {"model": self.radar_config.model, "max_range_m": self.radar_config.max_range_m,
"azimuth_step_period_s": self.radar_config.azimuth_step_period_s,
"full_azimuth_revisit_s": self.radar_config.full_azimuth_revisit_s,
"wired_processing_latency_s": self.radar_config.wired_processing_latency_s},
"wireless_link_profile": {"update_rate_hz": self.drone_link_config.update_rate_hz,
"one_way_latency_s": self.drone_link_config.one_way_latency_s,
"latency_jitter_sigma_s": self.drone_link_config.latency_jitter_sigma_s,
"command_compute_latency_s": self.drone_link_config.command_compute_latency_s,
"bitrate_bps": self.drone_link_config.bitrate_bps,
"packet_loss_probability": self.drone_link_config.packet_loss_probability,
"channel_airtime_s": self.wireless_airtime_s},
"drone_control_rate_hz": self.drone_control_rate_hz},
"scenario": getattr(self, "scenario_config", {}), "observations": observations,
"radar_tracks": {key: {"timestamp_s": value.timestamp_s,
"position_ned_m": value.position_ned_m.tolist(),
"velocity_ned_m_s": value.velocity_ned_m_s.tolist(),
"covariance": value.covariance.tolist() if value.covariance is not None else None,
"sample_count": value.sample_count, "velocity_observable": value.velocity_observable,
"age_s": self.time_s - value.timestamp_s}
for key, value in self.radar_tracks.items()},
"radar_detection_points": self.radar_detection_points,
"inferred_trajectories": {key: value[:, 1:].tolist() for key, value in self.track_predictions.items()},
"latest_detections": {key: {"radar_id": value["radar_id"],
"measurement_time_s": value["measurement_time_s"],
"position_ned_m": value["position_ned_m"].tolist(),
"line_of_sight_ned": value["line_of_sight_ned"].tolist(),
"radial_velocity_m_s": value["radial_velocity_m_s"]}
for key, value in self.latest_detections.items()},
"statistics": {"elapsed_time_s": self.time_s,
"projectile_outcomes": {p.entity_id: self.projectile_outcomes.get(p.entity_id, "unresolved") for p in self.projectiles},
"neutralization_assumption": "contact_consumes_both",
"protected_area_entry_count": len(self.protected_entries),
"interception_count": sum(e["type"] == "interception" for e in self.events),
"closest_approach_m": closest}}
def run(self, steps: int) -> list[dict]:
return [self.step_and_snapshot() for _ in range(steps)]

View File

@@ -1,271 +0,0 @@
import json
import numpy as np
import pytest
from defense_sim.entities.drone import Drone, DroneConfig
from defense_sim.entities.projectile import Projectile, ProjectileConfig
from defense_sim.physics.events import sphere_entry_fraction, circle_entry_fraction
from defense_sim.physics.reference import integrate_reference
from defense_sim.sensors.tracking import RadarTracker
from defense_sim.simulation.engine import SimulationEngine
from defense_sim.statistics.monte_carlo import run_many
from defense_sim.sensors.models import Observation, SensorConfig
from defense_sim.guidance.activation import CityCircle
def projectile(position, velocity, radius=0.1, name="p"):
return Projectile(name, np.array(position, dtype=float), np.array(velocity, dtype=float),
ProjectileConfig(area_m2=0, collision_radius_m=radius))
def drone(position, name="d", kind="defense_drone"):
return Drone(name, kind, np.array(position, dtype=float), config=DroneConfig(collision_radius_m=0.1))
def test_contact_root_and_shallow_circle_chord():
assert sphere_entry_fraction([-1, 0, 0], [1, 0, 0], 0.2) == pytest.approx(0.4)
assert circle_entry_fraction([-2, .9, -1], [2, .9, -1], [0, 0, 0], 1) is not None
def test_ground_precedes_underground_collision():
p = projectile([0, 0, -1], [0, 0, 20], radius=.01)
sim = SimulationEngine(dt=.1, drone_control_rate_hz=10, seed=1, projectiles=[p], drones=[drone([0, 0, 1])])
sim.step()
assert [e["type"] for e in sim.events] == ["ground_impact"]
assert p.position_ned_m[2] == 0
assert p.velocity_ned_m_s[2] == pytest.approx(sim.events[0]["velocity_ned_m_s"][2])
@pytest.mark.parametrize("reverse", [False, True])
def test_first_contact_is_order_independent_and_consumes_drone(reverse):
p = projectile([-1, 0, -10], [200, 0, 0])
drones = [drone([.5, 0, -10], "later"), drone([0, 0, -10], "earlier")]
if reverse:
drones.reverse()
sim = SimulationEngine(dt=.01, seed=1, projectiles=[p], drones=drones)
sim.step()
event = sim.events[0]
assert event["type"] == "interception" and event["target_id"] == "earlier"
assert event["time_s"] == pytest.approx(.004, abs=1e-6)
assert p.position_ned_m[0] == pytest.approx(-.2, abs=1e-5)
assert not next(d for d in drones if d.entity_id == "earlier").active
assert sim.trajectory["p"][-1][0] == pytest.approx(p.position_ned_m[0])
def test_idle_ground_support_under_wind():
d = drone([1, 2, 0])
sim = SimulationEngine(seed=1, drones=[d])
sim.environment.wind_ned_m_s = np.array([20., 3., 0.])
for _ in range(500):
sim.step()
assert np.array_equal(d.position_ned_m, [1, 2, 0])
assert np.array_equal(d.velocity_ned_m_s, np.zeros(3))
def test_scipy_reference_matches_vacuum_ground_time_and_rk4():
p = projectile([0, 0, -10], [20, 0, 0])
state = np.r_[p.position_ned_m, p.velocity_ned_m_s]
reference = integrate_reference(lambda y: p.derivative(y, np.zeros(3)), state, 2, ground_event=True)
ground_time = np.sqrt(20/9.80665)
assert reference.t_events[0][0] == pytest.approx(ground_time, abs=1e-9)
p.step(.1, np.zeros(3))
assert np.allclose(np.r_[p.position_ned_m, p.velocity_ned_m_s], reference.sol(.1), atol=1e-9)
def measurement(t, radar="r"):
position = np.array([1000.+10*t, 0, -10.])
return {"target_id": "p", "radar_id": radar, "measurement_time_s": t,
"position_ned_m": position, "range_m": np.linalg.norm(position),
"azimuth_rad": 0., "elevation_rad": np.arctan2(10., position[0]),
"range_sigma_m": 1., "azimuth_sigma_rad": .001, "elevation_sigma_rad": .001}
def test_tracking_timestamp_covariance_and_velocity_evidence():
tracker = RadarTracker(acceleration_diffusion=.01)
first = tracker.update(measurement(0))
assert not first.velocity_observable
for t in (1., 2., 3.):
result = tracker.update(measurement(t))
assert result.velocity_observable
assert result.velocity[0] == pytest.approx(10., abs=.1)
assert np.linalg.eigvalsh(result.covariance).min() > 0
assert tracker.update(measurement(3)) is None
assert tracker.update(measurement(1, "other")) is None
def test_mature_tracks_and_event_covariance_serialize_as_strict_json():
sim = SimulationEngine(seed=1, projectiles=[projectile([1000, 0, -10], [10, 0, 0])])
for t in (0., 1., 2., 3.):
sim.time_s = t
sim.radar_arrivals.append({**measurement(t), 'arrival_time_s': t})
sim._deliver_radar_tracks()
assert sim.radar_tracks['p'].velocity_observable
json.dumps(sim.snapshot(), allow_nan=False)
def test_monte_carlo_outcomes_are_per_projectile_and_cutoffs_censored():
def factory(seed):
return SimulationEngine(seed=seed, projectiles=[
projectile([-1, 0, -10], [200, 0, 0], name="hit"),
projectile([100, 0, -100], [0, 0, 0], name="unfinished")],
drones=[drone([0, 0, -10])])
result = run_many(factory, runs=2, steps=1)
groups = result.aggregate["interception_by_projectile"]
assert groups["hit"]["interception_probability"] == 1
assert groups["unfinished"]["interception_probability"] is None
assert groups["unfinished"]["unresolved_count"] == 2
json.dumps(result.aggregate, allow_nan=False)
def launch_fixture(loss=0):
from defense_sim.sensors.devices import DroneLinkConfig
p = projectile([1000, 0, -20], [-50, 0, 0])
from defense_sim.entities.projectile import ProjectileGuidanceConfig
p.guidance = ProjectileGuidanceConfig(np.array([0., 0., 0.]), max_thrust_m_s2=20.)
p.guidance_active = True
drones = [drone([500, i*5, 0], f'd{i}') for i in range(4)]
sim = SimulationEngine(seed=3, projectiles=[p], drones=drones,
city_circle=CityCircle(np.zeros(3), 500),
sensor_config=SensorConfig(position_sigma_m=0, velocity_sigma_m_s=0),
drone_link_config=DroneLinkConfig(packet_loss_probability=loss))
track = Observation('p', 0., p.position_ned_m.copy(), p.velocity_ned_m_s.copy(),
np.zeros(3), np.zeros(3), np.eye(6), 5, True)
sim.radars = [] # this fixture isolates explicitly injected tracking evidence
sim.radar_tracks['p'] = track
sim.remote_drone_estimates = {d.entity_id: d.position_ned_m.copy() for d in drones}
sim.remote_drone_estimate_times = {d.entity_id: 0. for d in drones}
return sim
def test_launch_requires_evidence_and_delivered_commands():
sim = launch_fixture()
sim.radar_tracks['p'].velocity_observable = False
sim._activate_defenders(sim.radar_tracks)
assert all(d.available for d in sim.drones)
sim.radar_tracks['p'].velocity_observable = True
sim.time_s = 1.
sim.remote_drone_estimate_times = {d.entity_id: 1. for d in sim.drones}
sim._activate_defenders(sim.radar_tracks)
assert sum(not d.available for d in sim.drones) == 3
assert all(d.activated_for is None and d.target_position_ned_m is None for d in sim.drones)
sim.time_s = max(c['delivery_time_s'] for c in sim.pending_drone_commands)+.01
sim._deliver_drone_commands()
assert sum(d.activated_for == 'p' for d in sim.drones) == 3
for _ in range(500):
sim.step()
assert any(d.position_ned_m[2] < -.01 for d in sim.drones)
assert all(d.position_ned_m[2] <= 0 for d in sim.drones)
def test_total_packet_loss_retries_and_releases_reservations():
sim = launch_fixture(loss=1.)
sim._activate_defenders(sim.radar_tracks)
for t in (1., 2., 3.):
sim.time_s = t
sim._service_assignments()
assert not sim.defense_assignments
assert all(d.available and d.activated_for is None for d in sim.drones)
assert len([e for e in sim.events if e['type'] == 'drone_command_dropped']) == 9
def test_ir_bearing_only_processing_delay_and_loss():
d = drone([0, 0, -10])
d.activated_for = 'p'
d.ir_rng = np.random.default_rng(1)
d.config.ir_lock_time_s = 0.
d.config.ir_processing_latency_s = .05
p = projectile([-100, 0, -10], [0, 0, 0])
prior = Observation('p', 0., p.position_ned_m.copy(), np.zeros(3), np.zeros(3),
np.zeros(3), np.eye(6)*100, 5, True)
events = []
assert not d.estimate_threat(p, 0., d.ir_rng, events, prior)
assert d.estimate_threat(p, .05, d.ir_rng, events, prior)
assert d.ir_observation['measurement_type'] == 'bearing_only'
assert 'range_m' not in d.ir_observation
p.position_ned_m = np.array([100., 0., -10.]) # outside rear-facing FOV
assert not d.estimate_threat(p, .1, d.ir_rng, events, prior)
assert not d.ir_acquired and d.ir_threat_estimate is None
@pytest.mark.parametrize('dt', [.1, .05, .025])
def test_ground_event_converges_to_scipy_reference(dt):
p = projectile([0, 0, -10], [0, 0, 0])
sim = SimulationEngine(dt=dt, drone_control_rate_hz=min(50, 1/dt), seed=1, projectiles=[p])
while p.active:
sim.step()
analytic = np.sqrt(20/9.80665)
assert abs(sim.events[-1]['time_s']-analytic) < dt*dt
def test_atmosphere_high_altitude_and_nonfinite_rejected():
from defense_sim.physics.atmosphere import isa
assert 1e-5 < isa(80000).density_kg_m3 < 3e-5
assert isa(60000).density_kg_m3 < isa(20000).density_kg_m3/100
with pytest.raises(ValueError):
DroneConfig(max_speed_m_s=float('nan'))
with pytest.raises(ValueError):
isa(90000)
def test_radar_dwell_evaluates_all_targets_and_preserves_clock_phase():
from defense_sim.sensors.devices import Aeris10Radar, AerisRadarConfig
config = AerisRadarConfig(range_sigma_m=0, azimuth_sigma_deg=0, elevation_sigma_deg=0, doppler_sigma_m_s=0)
visible = projectile([1000, 0, -10], [-10, 0, 0], name='visible')
outside = projectile([30000, 0, -10], [-10, 0, 0], name='outside')
for targets in ([outside, visible], [visible, outside]):
radar = Aeris10Radar('r', np.zeros(3), 0, config, np.random.default_rng(3))
assert [m['target_id'] for m in radar.scan_dwell(targets, 0)] == ['visible']
for t in np.arange(.01, 13.01, .01):
radar.scan_dwell([], float(t))
assert radar.next_sweep_time_s == pytest.approx(51*config.azimuth_step_period_s)
def test_telemetry_out_of_order_cannot_overwrite_newer_sample():
sim = SimulationEngine(seed=1)
sim.time_s = 2.
base = {'drone_id': 'd', 'delivery_time_s': 1., 'wireless_latency_s': .1}
sim.pending_drone_telemetry = [
{**base, 'measurement_time_s': 1., 'position_ned_m': np.ones(3)},
{**base, 'measurement_time_s': .5, 'position_ned_m': np.zeros(3)}]
sim._deliver_drone_telemetry()
assert np.array_equal(sim.remote_drone_estimates['d'], np.ones(3))
assert sim.remote_drone_estimate_times['d'] == 1.
def test_guidance_does_not_overwrite_velocity_or_create_zero_thrust():
from defense_sim.entities.projectile import ProjectileGuidanceConfig
p = projectile([1000, 0, -100], [-50, 0, 0])
p.guidance = ProjectileGuidanceConfig(np.zeros(3), max_thrust_m_s2=5.)
p.guidance_active = True
initial = p.velocity_ned_m_s.copy()
p.update_guidance(0., np.zeros(3))
assert np.array_equal(p.velocity_ned_m_s, initial)
assert np.linalg.norm(p._thrust_cmd) <= 5.+1e-12
p.guidance.max_thrust_m_s2 = 0.
p.update_guidance(1., np.zeros(3))
assert np.array_equal(p._thrust_cmd, np.zeros(3))
def test_ground_wins_tie_and_other_drone_contact_is_not_defense_success():
p = projectile([0, 0, 0], [0, 0, 0])
sim = SimulationEngine(seed=1, projectiles=[p], drones=[drone([0, 0, 0])])
sim.step()
assert sim.events[0]['type'] == 'ground_impact'
q = projectile([-1, 0, -10], [200, 0, 0])
sim = SimulationEngine(seed=1, projectiles=[q], drones=[drone([0, 0, -10], kind='attack_drone')])
sim.step()
assert sim.events[0]['type'] == 'collision'
assert sim.projectile_outcomes['p'] == 'collision'
@pytest.mark.parametrize('reverse', [False, True])
def test_consumed_drone_cannot_contact_later_threat(reverse):
first = projectile([-.8, 0, -10], [200, 0, 0], name='first')
later = projectile([-1., 0, -10], [200, 0, 0], name='later')
threats = [later, first]
if reverse:
threats.reverse()
sim = SimulationEngine(seed=1, projectiles=threats, drones=[drone([0, 0, -10])])
sim.step()
assert not first.active and later.active
assert [e['projectile_id'] for e in sim.events if e['type'] == 'interception'] == ['first']

View File

@@ -1,101 +0,0 @@
import json
import numpy as np
import pytest
from defense_sim.simulation.scenarios import basic_intercept
from test_audit_regressions import launch_fixture, measurement
def detect_once(sim):
sim.radar_tracks.clear()
sim.radar_arrivals.append({**measurement(sim.time_s), 'arrival_time_s': sim.time_s})
sim._deliver_radar_tracks()
def deliver_standby(sim):
sim.time_s = max(c['delivery_time_s'] for c in sim.pending_drone_commands)+.01
sim._deliver_drone_commands()
def test_first_delivered_detection_launches_before_velocity_or_assignment():
sim = launch_fixture()
sim.scenario_config = {'standby_altitude_m': 42., 'early_takeoff_enabled': True}
detect_once(sim)
assert not sim.radar_tracks['p'].velocity_observable
assert len(sim.pending_drone_commands) == 4
assert all(d.target_position_ned_m is None for d in sim.drones)
assert not sim.defense_assignments
deliver_standby(sim)
assert all(d.standby_altitude_m == 42. and d.available and d.activated_for is None for d in sim.drones)
for _ in range(300):
sim.step()
assert all(d.position_ned_m[2] < -1 for d in sim.drones)
snapshot = sim.snapshot()
row = next(r for r in snapshot['entities'] if r['id'] == 'd0')
assert row['flight_phase'] == 'standby_climb'
intention = np.array(row['intended_trajectory_ned_m'])
assert intention[-1, 2] == pytest.approx(-42.)
assert np.allclose(intention[:, :2], intention[0, :2])
assert not np.array_equal(intention, row['trajectory_ned_m'])
json.dumps(snapshot, allow_nan=False)
def test_early_takeoff_can_be_disabled_and_altitude_is_validated():
sim = launch_fixture()
sim.scenario_config = {'early_takeoff_enabled': False}
detect_once(sim)
assert not sim.pending_drone_commands
for altitude in (0, -1, float('nan'), 3001):
with pytest.raises(ValueError):
basic_intercept(standby_altitude_m=altitude)
assert basic_intercept(standby_altitude_m=250.).scenario_config['standby_altitude_m'] == 250.
def test_standby_packets_retry_and_total_loss_does_not_launch():
sim = launch_fixture(loss=1.)
detect_once(sim)
for t in (1., 2., 3.):
sim.time_s = t
sim._service_standby_launches()
assert not sim.pending_drone_commands
assert all(d.target_position_ned_m is None for d in sim.drones)
assert all(r['status'] == 'failed' and r['attempts'] == 3 for r in sim.standby_launches.values())
def test_late_standby_packet_cannot_override_assignment():
sim = launch_fixture()
detect_once(sim)
d = sim.drones[0]
d.activated_for = 'p'
d.target_position_ned_m = np.array([123., 456., -80.])
deliver_standby(sim)
assert d.standby_altitude_m is None
assert np.array_equal(d.target_position_ned_m, [123., 456., -80.])
def test_traces_persist_longer_than_old_six_second_window_and_samples_persist():
sim = launch_fixture()
sim.scenario_config = {'early_takeoff_enabled': False}
sim.radar_detection_points['p'] = [[1000., 0., -20.]]
for _ in range(1000):
sim.step()
snapshot = sim.snapshot()
path = next(e['trajectory_ned_m'] for e in snapshot['entities'] if e['id'] == 'p')
assert path[0] == [1000., 0., -20.]
assert len(path) >= 50
assert snapshot['radar_detection_points']['p'] == [[1000., 0., -20.]]
def test_intention_preview_does_not_advance_controller_or_truth():
sim = launch_fixture()
sim._activate_defenders(sim.radar_tracks)
deliver_standby(sim) # helper delivers all queued packets, including assignment
d = next(d for d in sim.drones if d.flight_reference is not None)
position, target = d.position_ned_m.copy(), d.target_position_ned_m.copy()
travel, elapsed = d.flight_reference['travel_m'], d.flight_reference['elapsed_s']
path = np.array(d.intended_trajectory())
assert len(path) == 61 and np.isfinite(path).all()
assert np.linalg.norm(path[-1, :2]-path[0, :2]) > 0
assert np.array_equal(position, d.position_ned_m)
assert np.array_equal(target, d.target_position_ned_m)
assert (travel, elapsed) == (d.flight_reference['travel_m'], d.flight_reference['elapsed_s'])

View File

@@ -5,7 +5,7 @@
- Date: 2026-03-21
- Target: Trenz `TE0713-03-82C46-A` on `TE0701-06`
- Top module: `radar_system_top_te0713_dev`
- Constraint file: `9_Firmware/9_2_FPGA/constraints/te0713_te0701_minimal.xdc`
- Constraint file: `firmware/9_2_FPGA/constraints/te0713_te0701_minimal.xdc`
- Bitstream: `docs/artifacts/te0713-te0701-heartbeat-2026-03-21.bit`
Build result:

View File

@@ -72,7 +72,7 @@ Previous builds failed setup timing due to source-synchronous clock skew:
## Source Files
- `9_Firmware/9_2_FPGA/radar_system_top_te0713_umft601x_dev.v` — wrapper top
- `9_Firmware/9_2_FPGA/usb_data_interface.v` — FT601 USB data interface
- `9_Firmware/9_2_FPGA/constraints/te0713_te0701_umft601x.xdc` — FMC LPC constraints
- `9_Firmware/9_2_FPGA/scripts/build_te0713_umft601x_dev.tcl` — build script
- `firmware/9_2_FPGA/radar_system_top_te0713_umft601x_dev.v` — wrapper top
- `firmware/9_2_FPGA/usb_data_interface.v` — FT601 USB data interface
- `firmware/9_2_FPGA/constraints/te0713_te0701_umft601x.xdc` — FMC LPC constraints
- `firmware/9_2_FPGA/scripts/build_te0713_umft601x_dev.tcl` — build script

View File

@@ -3,7 +3,7 @@ Copyright 1986-2022 Xilinx, Inc. All Rights Reserved. Copyright 2022-2025 Advanc
| Tool Version : Vivado v.2025.2 (lin64) Build 6299465 Fri Nov 14 12:34:56 MST 2025
| Date : Sat Mar 21 20:41:16 2026
| Host : jason-pc running 64-bit Ubuntu 24.04.4 LTS
| Command : report_timing_summary -report_unconstrained -max_paths 100 -file /home/jason-stone/PLFM_RADAR_work/PLFM_RADAR/9_Firmware/9_2_FPGA/vivado_te0713_umft601x_dev/reports/timing_summary.rpt
| Command : report_timing_summary -report_unconstrained -max_paths 100 -file /home/jason-stone/PLFM_RADAR_work/PLFM_RADAR/firmware/9_2_FPGA/vivado_te0713_umft601x_dev/reports/timing_summary.rpt
| Design : radar_system_top_te0713_umft601x_dev
| Device : 7a200t-fbg484
| Speed File : -2 PRODUCTION 1.23 2018-06-13

View File

@@ -493,12 +493,12 @@
</tr>
</thead>
<tbody>
<tr><td>Production-target XDC</td><td><code>9_Firmware/9_2_FPGA/constraints/xc7a200t_fbg484.xdc</code></td><td>Constraint source of truth for the production FPGA target</td><td>Tracked and validated after Build 16 cleanup port</td></tr>
<tr><td>FPGA programming flow</td><td><code>9_Firmware/9_2_FPGA/scripts/program_fpga.tcl</code></td><td>Programs the device and reports DONE / INIT_COMPLETE / probes presence</td><td>Primary operator-facing programming script</td></tr>
<tr><td>Debug probe insertion flow</td><td><code>9_Firmware/9_2_FPGA/scripts/insert_ila_probes.tcl</code></td><td>Used when generating or refreshing debug-capable images</td><td>Keep matched with the selected debug bitstream</td></tr>
<tr><td>FPGA regression runner</td><td><code>9_Firmware/9_2_FPGA/run_regression.sh</code></td><td>Pre-arrival regression evidence for the tracked FPGA baseline</td><td>23 / 23 passing on the current tracked branch (includes CFAR + MTI + E2E tests)</td></tr>
<tr><td>MCU regression harness</td><td><code>9_Firmware/9_1_Microcontroller/tests/Makefile</code></td><td>Pre-arrival firmware regression evidence before flashing hardware</td><td>20 / 20 passing on the current tracked branch</td></tr>
<tr><td>Bring-up logging macros</td><td><code>9_Firmware/9_1_Microcontroller/9_1_1_C_Cpp_Libraries/diag_log.h</code></td><td>Defines the main first-power-on log vocabulary used over USART3</td><td>Observation-only instrumentation layer</td></tr>
<tr><td>Production-target XDC</td><td><code>firmware/9_2_FPGA/constraints/xc7a200t_fbg484.xdc</code></td><td>Constraint source of truth for the production FPGA target</td><td>Tracked and validated after Build 16 cleanup port</td></tr>
<tr><td>FPGA programming flow</td><td><code>firmware/9_2_FPGA/scripts/program_fpga.tcl</code></td><td>Programs the device and reports DONE / INIT_COMPLETE / probes presence</td><td>Primary operator-facing programming script</td></tr>
<tr><td>Debug probe insertion flow</td><td><code>firmware/9_2_FPGA/scripts/insert_ila_probes.tcl</code></td><td>Used when generating or refreshing debug-capable images</td><td>Keep matched with the selected debug bitstream</td></tr>
<tr><td>FPGA regression runner</td><td><code>firmware/9_2_FPGA/run_regression.sh</code></td><td>Pre-arrival regression evidence for the tracked FPGA baseline</td><td>23 / 23 passing on the current tracked branch (includes CFAR + MTI + E2E tests)</td></tr>
<tr><td>MCU regression harness</td><td><code>firmware/9_1_Microcontroller/tests/Makefile</code></td><td>Pre-arrival firmware regression evidence before flashing hardware</td><td>20 / 20 passing on the current tracked branch</td></tr>
<tr><td>Bring-up logging macros</td><td><code>firmware/9_1_Microcontroller/9_1_1_C_Cpp_Libraries/diag_log.h</code></td><td>Defines the main first-power-on log vocabulary used over USART3</td><td>Observation-only instrumentation layer</td></tr>
<tr><td>Board-day worksheet</td><td><code>docs/board-day-worksheet.html</code></td><td>Record pass/fail, measurements, and blockers during first sessions</td><td>Use with this page and the bring-up plan</td></tr>
<tr><td>Bring-up execution plan</td><td><code>docs/bring-up.html</code></td><td>Operator checklist, abort criteria, observability targets, and open risks</td><td>Primary readiness document</td></tr>
</tbody>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

View File

@@ -10,15 +10,15 @@ extern SPI_HandleTypeDef hspi1;
extern UART_HandleTypeDef huart3;
// Chip Select GPIO definitions
static const struct {
GPIO_TypeDef* port;
uint16_t pin;
} CHIP_SELECTS[4] = {
{ADAR_1_CS_3V3_GPIO_Port, ADAR_1_CS_3V3_Pin}, // ADAR1000 #1
{ADAR_2_CS_3V3_GPIO_Port, ADAR_2_CS_3V3_Pin}, // ADAR1000 #2
{ADAR_3_CS_3V3_GPIO_Port, ADAR_3_CS_3V3_Pin}, // ADAR1000 #3
{ADAR_4_CS_3V3_GPIO_Port, ADAR_4_CS_3V3_Pin} // ADAR1000 #4
};
static const struct {
GPIO_TypeDef* port;
uint16_t pin;
} CHIP_SELECTS[4] = {
{ADAR_1_CS_3V3_GPIO_Port, ADAR_1_CS_3V3_Pin}, // ADAR1000 #1
{ADAR_2_CS_3V3_GPIO_Port, ADAR_2_CS_3V3_Pin}, // ADAR1000 #2
{ADAR_3_CS_3V3_GPIO_Port, ADAR_3_CS_3V3_Pin}, // ADAR1000 #3
{ADAR_4_CS_3V3_GPIO_Port, ADAR_4_CS_3V3_Pin} // ADAR1000 #4
};
// ADAR1000 Vector Modulator lookup tables (128-state phase grid, 2.8125 deg step).
//
@@ -255,15 +255,15 @@ bool ADAR1000Manager::setBeamAngle(float angle_degrees, BeamDirection direction)
for (uint8_t dev = 0; dev < devices_.size(); ++dev) {
for (uint8_t ch = 0; ch < 4; ++ch) {
if (direction == BeamDirection::TX) {
adarSetTxPhase(dev, ch + 1, phase_settings[ch], BROADCAST_OFF);
adarSetTxVgaGain(dev, ch + 1, kDefaultTxVgaGain, BROADCAST_OFF);
} else {
adarSetRxPhase(dev, ch + 1, phase_settings[ch], BROADCAST_OFF);
adarSetRxVgaGain(dev, ch + 1, kDefaultRxVgaGain, BROADCAST_OFF);
}
}
}
if (direction == BeamDirection::TX) {
adarSetTxPhase(dev, ch + 1, phase_settings[ch], BROADCAST_OFF);
adarSetTxVgaGain(dev, ch + 1, kDefaultTxVgaGain, BROADCAST_OFF);
} else {
adarSetRxPhase(dev, ch + 1, phase_settings[ch], BROADCAST_OFF);
adarSetRxVgaGain(dev, ch + 1, kDefaultRxVgaGain, BROADCAST_OFF);
}
}
}
return true;
}
@@ -522,7 +522,7 @@ bool ADAR1000Manager::initializeADTR1107Sequence() {
HAL_UART_Transmit(&huart3, success, sizeof(success) - 1, 1000);
return true;
}
}
bool ADAR1000Manager::setAllDevicesTXMode() {
DIAG("BF", "setAllDevicesTXMode(): ADTR1107 -> TX, then configure ADAR1000s");
@@ -648,7 +648,7 @@ void ADAR1000Manager::setADTR1107Mode(BeamDirection direction) {
}
DIAG("BF", " ADTR1107 RX mode complete");
}
}
}
void ADAR1000Manager::setADTR1107Control(bool tx_mode) {
DIAG("BF", "setADTR1107Control(%s): setting TR switch on all %u devices, settling %lu us",
@@ -727,7 +727,7 @@ void ADAR1000Manager::setLNABias(bool enable) {
for (uint8_t dev = 0; dev < devices_.size(); ++dev) {
adarWrite(dev, REG_LNA_BIAS_ON, lna_bias, BROADCAST_OFF);
}
}
}
void ADAR1000Manager::delayUs(uint32_t microseconds) {
// Simple implementation - for F7 @ 216MHz, each loop ~7 cycles ≈ 0.032us
@@ -903,11 +903,11 @@ void ADAR1000Manager::adarSetTxVgaGain(uint8_t deviceIndex, uint8_t channel, uin
adarWrite(deviceIndex, REG_LOAD_WORKING, LD_WRK_REGS_LDTX_OVERRIDE, broadcast);
}
void ADAR1000Manager::adarSetTxBias(uint8_t deviceIndex, uint8_t broadcast) {
adarWrite(deviceIndex, REG_BIAS_CURRENT_TX, kTxBiasCurrent, broadcast);
adarWrite(deviceIndex, REG_BIAS_CURRENT_TX_DRV, kTxDriverBiasCurrent, broadcast);
adarWrite(deviceIndex, REG_LOAD_WORKING, 0x2, broadcast);
}
void ADAR1000Manager::adarSetTxBias(uint8_t deviceIndex, uint8_t broadcast) {
adarWrite(deviceIndex, REG_BIAS_CURRENT_TX, kTxBiasCurrent, broadcast);
adarWrite(deviceIndex, REG_BIAS_CURRENT_TX_DRV, kTxDriverBiasCurrent, broadcast);
adarWrite(deviceIndex, REG_LOAD_WORKING, 0x2, broadcast);
}
uint8_t ADAR1000Manager::adarAdcRead(uint8_t deviceIndex, uint8_t broadcast) {
adarWrite(deviceIndex, REG_ADC_CONTROL, ADAR1000_ADC_ST_CONV, broadcast);

Some files were not shown because too many files have changed in this diff Show More