114 lines
6.1 KiB
Python
114 lines
6.1 KiB
Python
import numpy as np
|
|
import pytest
|
|
|
|
from defense_sim.simulation.presets import PRESETS, sample_preset
|
|
from defense_sim.simulation.scenarios import basic_intercept
|
|
|
|
|
|
def test_random_selection_reproduces_and_covers_all_classes():
|
|
a, b = np.random.default_rng(19), np.random.default_rng(19)
|
|
samples = [sample_preset("random", a) for _ in range(100)]
|
|
assert samples == [sample_preset("random", b) for _ in range(100)]
|
|
assert {s["preset"] for s in samples} == set(PRESETS)
|
|
assert len({s["entry_speed_m_s"] for s in samples}) == 100
|
|
|
|
|
|
@pytest.mark.parametrize("name", PRESETS)
|
|
def test_preset_parameters_and_initial_velocity(name):
|
|
engine = basic_intercept(seed=7, projectile_preset=name, wind_speed_m_s=0)
|
|
profile = engine.scenario_config["projectile_profile"]
|
|
bounds = PRESETS[name]
|
|
for field in ("mass_kg", "area_m2", "drag_coefficient"):
|
|
low, high = getattr(bounds, field)
|
|
assert low <= profile[field] <= high
|
|
p = engine.projectiles[0]
|
|
# Mid-flight launch: the vehicle starts on the approach ring at its
|
|
# sampled entry altitude, moving toward the strike point (the run models
|
|
# the terminal phase, starting outside the outer detection ring).
|
|
approach_m = engine.scenario_config["approach_range_km"] * 1000.0
|
|
assert np.allclose(p.position_ned_m, [approach_m, 0.0, -profile["entry_altitude_m"]])
|
|
assert np.isclose(np.linalg.norm(p.velocity_ned_m_s), profile["entry_speed_m_s"])
|
|
# The velocity is the straight line from the start position to the strike
|
|
# point (the initial glide-slope aim; terminal guidance re-aims and
|
|
# flattens it into a descent as the flight develops -- the initial line
|
|
# can climb when the target is close and the entry altitude is high).
|
|
target = np.asarray(engine.scenario_config["target_position_ned_m"])
|
|
aim = np.array([target[0] - approach_m, target[1], profile["entry_altitude_m"]])
|
|
expected_dir = aim / np.linalg.norm(aim)
|
|
assert np.allclose(p.velocity_ned_m_s / profile["entry_speed_m_s"], expected_dir, atol=1e-9)
|
|
# The vehicle always closes horizontal distance to the strike point.
|
|
assert np.dot(p.velocity_ned_m_s[:2], target[:2] - p.position_ned_m[:2]) > 0
|
|
# The guided preset carries a terminal guidance law aimed at the target.
|
|
assert p.guidance is not None
|
|
assert np.allclose(p.guidance.target_ned_m, target)
|
|
# The drag term opposes motion through the air at launch.
|
|
acceleration = p.derivative(np.concatenate((p.position_ned_m, p.velocity_ned_m_s)), np.zeros(3))[3:]
|
|
drag = acceleration - np.array([0., 0., 9.80665])
|
|
assert np.dot(drag, p.velocity_ned_m_s) < 0
|
|
|
|
|
|
def test_scenario_ring_geometry():
|
|
engine = basic_intercept(seed=3, projectile_preset="shahed_238",
|
|
protected_radius_km=10, radar_radius_km=12,
|
|
drone_radius_km=28, radar_station_count=8)
|
|
protected = engine.city_circle.radius_m
|
|
radar_ring = engine.scenario_config["radar_radius_km"] * 1000.0
|
|
drone_ring = engine.scenario_config["drone_radius_km"] * 1000.0
|
|
assert protected == 10_000
|
|
assert radar_ring == 12_000
|
|
assert drone_ring == 28_000
|
|
# Defense drones loiter on the drone loiter ring.
|
|
for drone in engine.drones:
|
|
assert np.isclose(np.hypot(*drone.position_ned_m[:2]), drone_ring)
|
|
# Radar stations sit on the radar site ring.
|
|
for radar in engine.radars:
|
|
assert np.isclose(np.hypot(*radar.position_ned_m[:2]), radar_ring)
|
|
# The randomized strike target lands inside the protected circle.
|
|
target = np.asarray(engine.scenario_config["target_position_ned_m"])
|
|
assert np.hypot(*target[:2]) <= protected
|
|
# Snapshot exposes the ring radii for the renderer.
|
|
snapshot = engine.snapshot()
|
|
assert snapshot["environment"]["protected_radius_m"] == protected
|
|
assert snapshot["environment"]["radar_radius_m"] == radar_ring
|
|
assert snapshot["environment"]["drone_radius_m"] == drone_ring
|
|
assert snapshot["environment"]["defense_perimeter_radius_m"] == drone_ring
|
|
|
|
|
|
def test_terminal_guidance_holds_speed_and_reaches_target():
|
|
# A guided loitering munition keeps cruise speed against drag (thrust
|
|
# balance) and steers to the strike point: within the radar range it must
|
|
# be tracked, the defenders must activate, and the impact must land near
|
|
# the target -- it cannot stall and fall short.
|
|
engine = basic_intercept(seed=7, projectile_preset="shahed_238",
|
|
approach_range_km=12, protected_radius_km=8,
|
|
radar_radius_km=10, drone_radius_km=14,
|
|
wind_speed_m_s=0, defense_drone_count=4)
|
|
p = engine.projectiles[0]
|
|
entry_speed = engine.scenario_config["projectile_profile"]["entry_speed_m_s"]
|
|
target = np.asarray(engine.scenario_config["target_position_ned_m"])
|
|
assert p.guidance is not None and np.allclose(p.guidance.target_ned_m, target)
|
|
radar_crossed = False
|
|
for _ in range(20000): # up to 200 s
|
|
engine.step()
|
|
pos = p.position_ned_m
|
|
if not p.active:
|
|
break
|
|
if not radar_crossed and np.hypot(*pos[:2]) < 10_000 and pos[2] < 0:
|
|
radar_crossed = True
|
|
# speed must still be near cruise when the track becomes usable
|
|
assert np.isclose(np.linalg.norm(p.velocity_ned_m_s), entry_speed, rtol=0.15)
|
|
assert radar_crossed, "vehicle never reached the radar ring; guidance stalled"
|
|
assert any(e["type"] == "radar_track_update" for e in engine.events)
|
|
# This sparse layout is not guaranteed feasible: detection and a valid
|
|
# threatening flight do not imply the defenders can launch in time.
|
|
assert any(e["type"] in {"defense_assignment_infeasible", "defenders_assigned"} for e in engine.events)
|
|
impacts = [e for e in engine.events if e["type"] == "ground_impact"]
|
|
assert impacts, "vehicle never reached the ground"
|
|
impact = np.asarray(impacts[0]["position_ned_m"])
|
|
assert np.linalg.norm(impact[:2] - target[:2]) < 2_000, "impact far from strike target"
|
|
|
|
|
|
def test_unknown_preset_is_rejected():
|
|
with pytest.raises(ValueError, match="unknown projectile preset"):
|
|
basic_intercept(seed=1, projectile_preset="unknown")
|