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")