refactor: 抛物线运动学前向计算 — 移除回退/反算,统一用 ParabolicPosition

Kinematics:
- CalculateLaunchAngle/ParabolicTimeOfFlight/ParabolicShellTime: 移除静默钳位和 45° 回退,输入无效直接抛异常
- 新增 ComputeParabolicRange(v₀,θ,Δy) → (射程,飞行时间) — 正问题
- 新增 ParabolicApex(v₀,θ) → (顶点高度,顶点时间)
- ParabolicShellTime 改为委托,不重复实现

MunitionEntity:
- launchAngle 必须由方案提供,不再反算
- _flightDuration 用 ComputeParabolicRange 正算
- 删除死代码 CommandFlyTo / 「到达投放点」路径

DefensePlanner:
- 地基用 InterceptCalculator 的 shellTime,不再用 ParabolicShellTime 反算

SimulationEngine:
- 删除空基「到达投放点」死代码

Tests: 240 通过 (KinematicsTests 38, DefensePlannerTests 36, SimulationEngineTests 12, MunitionEntityTests 3)
This commit is contained in:
tian 2026-06-17 10:45:56 +08:00
parent 72e465570f
commit 0ed714a730
12 changed files with 572 additions and 230 deletions

View File

@ -107,7 +107,7 @@
"Id": "air-standard", "Name": "[Demo] 标准空基火力单元",
"PlatformType": 0, "GunCount": 1, "ChannelsPerGun": 8, "ChannelInterval": 1.0,
"Cooldown": 5.0, "AmmoChangeTime": 30.0,
"CruiseSpeed": 80.0, "ReleaseAltitude": 1500.0,
"CruiseSpeed": 80.0, "ReleaseAltitude": 1000.0,
"AmmoTypes": [0, 1],
"EORange": 9600.0, "IRRange": 6000.0,
"MinElevation": -80.0, "MaxElevation": 30.0, "MinDetectAlt": 30.0, "MaxDetectAlt": 15000.0

View File

@ -402,6 +402,12 @@ namespace CounterDrone.Core.Algorithms
if (deliveryTime > interceptTime) return null;
if (!HasInterceptWindow(threat, midArc, expansionTime, deliveryTime)) return null;
// 最小距离约束:无人机至少需要飞 vd*(R+expansion+fallTime) 才能被拦截
float speedMs = GetDroneSpeedMs(threat);
float minArc = threat.DetectArc + speedMs * (_config.ReactionTime + expansionTime + fallTime);
float totalArc = RouteGeometry.TotalLength(threat.Waypoints);
if (minArc >= totalArc) return null;
var (icArc, _, _) = InterceptCalculator.ComputeHorizontal(
threat.Waypoints, threat.DetectArc, GetDroneSpeedKph(threat),
_config.ReactionTime + expansionTime, unit.Position, unit.CruiseSpeed,
@ -496,13 +502,15 @@ namespace CounterDrone.Core.Algorithms
float midArc = RouteGeometry.ArcLengthNearestTo(wps, mid.X, mid.Z);
float crossArc;
float launchAngle = 0;
float interceptShellTime = 0;
if (unit.Type == PlatformType.GroundBased)
{
var (ia, _, la) = InterceptCalculator.Compute(
var (ia, st, la) = InterceptCalculator.Compute(
wps, threat.DetectArc, typicalSpeed,
_config.ReactionTime + expansionTime, unit.Position, unit.MuzzleVelocity);
crossArc = (ia > 0 ? ia : midArc) + targetOffset;
launchAngle = la;
interceptShellTime = st;
}
else
{
@ -510,7 +518,7 @@ namespace CounterDrone.Core.Algorithms
wps, threat.DetectArc, typicalSpeed,
_config.ReactionTime + expansionTime, unit.Position, unit.CruiseSpeed,
unit.ReleaseAltitude, (float)threat.Target.TypicalAltitude);
crossArc = (ia > 0 ? ia : midArc) + targetOffset;
crossArc = ia;
launchAngle = la;
}
@ -566,14 +574,11 @@ namespace CounterDrone.Core.Algorithms
{
if (unit.MuzzleVelocity <= 0)
throw new InvalidOperationException($"地基单元 {unit.Id}: MuzzleVelocity={unit.MuzzleVelocity} 必须>0");
float mv = unit.MuzzleVelocity;
float dx = cloudGenX - unit.Position.X;
float dz = cloudGenZ - unit.Position.Z;
float dist = (float)Math.Sqrt(dx * dx + dz * dz);
float heightDiff = (float)threat.Target.TypicalAltitude - unit.Position.Y;
float shellTime = Kinematics.ParabolicShellTime(dist, heightDiff, mv);
deliveryTime = shellTime;
fireTime = recommendedTiming - shellTime;
// 使用 InterceptCalculator 前向计算的时间,不再用 ParabolicShellTime 反算
if (interceptShellTime <= 0)
return (events, "拦截计算未得出有效飞行时间");
deliveryTime = interceptShellTime;
fireTime = recommendedTiming - interceptShellTime;
}
if (fireTime <= 0f)
@ -586,7 +591,7 @@ namespace CounterDrone.Core.Algorithms
TargetX = cloudGenX,
TargetY = ty,
TargetZ = cloudGenZ,
MuzzleVelocity = unit.Type == PlatformType.AirBased ? 0f : unit.MuzzleVelocity,
MuzzleVelocity = unit.Type == PlatformType.AirBased ? unit.CruiseSpeed : unit.MuzzleVelocity,
LaunchAngle = launchAngle,
});

View File

@ -4,12 +4,12 @@ using CounterDrone.Core.Models;
namespace CounterDrone.Core.Algorithms
{
/// <summary>拦截点计算——炮弹抛物线与无人机直线的联立方程求解</summary>
public static class InterceptCalculator
{
/// <summary>空基拦截:平抛(θ=0°),下落时间固定。
/// 方程: (A-d)/vd = R + tf + |A - vp*tf - ux|/vp, tf = √(2Δy/g)</summary>
public static (float InterceptArc, float FlightTime, float LaunchAngle) ComputeHorizontal(
/// <summary>空基拦截:水平发射(θ=0°),从当前位置直接抛出。
/// 炮弹以 vp 水平飞出,下落 tf=√(2Δy/g) 秒后到达目标高度。
/// 拦截点 = ux + vp·tf。方程: (A-d)/vd = R + expansion + tf。</summary>
public static (float, float, float) ComputeHorizontal(
IReadOnlyList<Waypoint> route, float detectArc, float droneSpeedKmh,
float reactionTime, Vector3 platformPos, float cruiseSpeed,
float releaseAlt, float targetAlt)
@ -17,36 +17,30 @@ namespace CounterDrone.Core.Algorithms
if (route == null || route.Count < 2 || droneSpeedKmh <= 0 || cruiseSpeed <= 0)
return (0, 0, 0);
float totalArc = RouteGeometry.TotalLength(route);
if (detectArc >= totalArc) return (0, 0, 0);
float vd = droneSpeedKmh / 3.6f;
float tf = releaseAlt > targetAlt ? MathF.Sqrt(2f * (releaseAlt - targetAlt) / 9.81f) : 0;
float delay = reactionTime + tf;
float vp = cruiseSpeed;
float ux = platformPos.X;
float d = detectArc;
float vp_tf = vp * tf;
float dy = releaseAlt - targetAlt;
if (dy <= 0) return (0, 0, 0);
float tf = MathF.Sqrt(2f * dy / 9.81f);
if (vp > vd)
{
float A1 = (delay - tf - ux / vp) * vd * vp / (vp - vd);
if (A1 >= ux + vp_tf && A1 > d && A1 <= totalArc)
return (A1, (A1 - d) / vd - delay, 0);
}
// 水平发射:炮弹落在 ux + vp*tf 处
float A = platformPos.X + cruiseSpeed * tf;
float totalArc = RouteGeometry.TotalLength(route);
if (A < detectArc || A > totalArc) return (0, 0, 0);
float A2 = (delay + ux / vp + tf) * vd * vp / (vp + vd);
if (A2 < ux + vp_tf && A2 > d && A2 <= totalArc)
return (A2, (A2 - d) / vd - delay, 0);
// 验证时间匹配
float droneTime = (A - detectArc) / vd;
float defendTime = reactionTime + tf;
if (droneTime < defendTime) return (0, 0, 0);
return (0, 0, 0);
return (A, droneTime - reactionTime, 0);
}
/// <summary>地基拦截D² + (Δy + ½g·ts²)² = vs²·ts²ts = (A-d)/vd - R</summary>
/// <summary>地基拦截D² + (Δy + ½g·ts²)² = vs²·ts²</summary>
public static (float InterceptArc, float ShellTime, float LaunchAngle) Compute(
IReadOnlyList<Waypoint> route, float detectArc, float droneSpeedKmh,
float reactionTime, Vector3 fireUnitPos, float muzzleVelocity)
{
// ... existing code unchanged ...
if (route == null || route.Count < 2 || droneSpeedKmh <= 0 || muzzleVelocity <= 0)
return (0, 0, 0);

View File

@ -27,56 +27,108 @@ namespace CounterDrone.Core.Algorithms
return ((float)Math.Sin(rad) * speed, 0, (float)Math.Cos(rad) * speed);
}
/// <summary>计算抛物线炮弹的发射角</summary>
/// <param name="range">水平距离 (m)</param>
/// <param name="muzzleVelocity">初速 (m/s)</param>
/// <param name="releaseAltitude">释放高度 (m),弹道顶点必须 ≥ 此值</param>
/// <returns>发射角 (rad),取低弹道</returns>
/// <summary>弹道角度:使抛物线在(targetY - startY)高度经下行段精确经过距离 range 处</summary>
public static float CalculateLaunchAngle(float range, float muzzleVelocity, float releaseAltitude)
/// <summary>给定水平距离和初速,求解抛物线发射角(逆问题)</summary>
/// <param name="range">水平距离 (m),必须 > 0</param>
/// <param name="muzzleVelocity">初速 (m/s),必须 > 0</param>
/// <param name="heightDiff">目标相对高度 targetY - startY (m)</param>
/// <returns>发射角 (rad),取低弹道解</returns>
public static float CalculateLaunchAngle(float range, float muzzleVelocity, float heightDiff)
{
if (range <= 0)
throw new ArgumentException($"range 必须 > 0实际: {range}", nameof(range));
if (muzzleVelocity <= 0)
throw new ArgumentException($"muzzleVelocity 必须 > 0实际: {muzzleVelocity}", nameof(muzzleVelocity));
float v2 = muzzleVelocity * muzzleVelocity;
float g = 9.81f;
float R = Math.Max(1f, range);
float H = releaseAltitude; // 目标相对高度(从发射点算)
float a = 0.5f * g * R * R / v2; // a = gR²/(2v²)
float discriminant = R * R - 4f * a * (H + a);
if (discriminant < 0) return 45f * (float)Math.PI / 180f; // 不可达,用 45°
// 平射解(小角度)
float tanTheta = (R - (float)Math.Sqrt(discriminant)) / (2f * a);
// 轨迹方程: heightDiff = R·tanθ - gR²/(2v²cos²θ)
// 令 T = tanθ, a = gR²/(2v²): aT² - RT + (a + heightDiff) = 0
float a = 0.5f * g * range * range / v2;
float discriminant = range * range - 4f * a * (heightDiff + a);
if (discriminant < 0)
throw new ArgumentException(
$"当前参数无法命中目标: range={range}, heightDiff={heightDiff}, v₀={muzzleVelocity}");
// 低弹道解(取较小 tanθ
float tanTheta = (range - (float)Math.Sqrt(discriminant)) / (2f * a);
return (float)Math.Atan(tanTheta);
}
/// <summary>弹道飞行时间(秒)</summary>
/// <summary>弹道飞行时间(秒)。水平距离 / 水平分速</summary>
public static float ParabolicTimeOfFlight(float range, float launchAngle, float muzzleVelocity)
{
if (range <= 0)
throw new ArgumentException($"range 必须 > 0实际: {range}", nameof(range));
if (muzzleVelocity <= 0)
throw new ArgumentException($"muzzleVelocity 必须 > 0实际: {muzzleVelocity}", nameof(muzzleVelocity));
float cosAngle = (float)Math.Cos(launchAngle);
if (cosAngle < 0.001f) return range / Math.Max(1f, muzzleVelocity * 0.7f);
return range / (muzzleVelocity * cosAngle);
}
/// <summary>抛物线飞行时间:解 tan(θ) 二次方程求到达 (horizontalDist, heightDiff) 的时间</summary>
/// <summary>抛物线飞行时间:给定水平距离和目标高度差,计算弹道时间</summary>
/// <remarks>等价于先调用 CalculateLaunchAngle 再调用 ParabolicTimeOfFlight</remarks>
public static float ParabolicShellTime(float horizontalDist, float heightDiff, float muzzleVelocity)
{
float R = Math.Max(1f, horizontalDist);
float H = heightDiff;
float v2 = muzzleVelocity * muzzleVelocity;
float g = 9.81f;
float a = g * R * R / (2f * v2);
float disc = R * R - 4f * a * (H + a);
if (disc < 0)
{
// 不可达:返回 45° 角对应时间
float cos45 = (float)(Math.Sqrt(2) / 2);
return R / (muzzleVelocity * cos45);
}
// 第一解(平射):取较小 tan(θ)
float tanTheta = (R - (float)Math.Sqrt(disc)) / (2f * a);
float cosTheta = 1f / (float)Math.Sqrt(1f + tanTheta * tanTheta);
return R / (muzzleVelocity * cosTheta);
float angle = CalculateLaunchAngle(horizontalDist, muzzleVelocity, heightDiff);
return ParabolicTimeOfFlight(horizontalDist, angle, muzzleVelocity);
}
/// <summary>根据发射参数计算抛物线位置</summary>
/// <summary>给定初速和发射角,计算到达指定相对高度的水平射程和飞行时间(正问题)</summary>
/// <param name="muzzleVelocity">初速 (m/s),必须 > 0</param>
/// <param name="launchAngle">发射角 (rad),必须 ∈ (0, π/2)</param>
/// <param name="heightDiff">目标相对高度 targetY - startY (m)</param>
/// <returns>(水平射程 m, 飞行时间 s),取下行段解</returns>
public static (float range, float timeOfFlight) ComputeParabolicRange(
float muzzleVelocity, float launchAngle, float heightDiff)
{
if (muzzleVelocity <= 0)
throw new ArgumentException($"muzzleVelocity 必须 > 0实际: {muzzleVelocity}", nameof(muzzleVelocity));
if (launchAngle < 0 || launchAngle >= Math.PI / 2)
throw new ArgumentException($"launchAngle 必须在 [0, π/2) 内,实际: {launchAngle}", nameof(launchAngle));
float g = 9.81f;
float cosA = (float)Math.Cos(launchAngle);
float tanA = (float)Math.Tan(launchAngle);
// 轨迹方程: heightDiff = R·tanθ - gR²/(2v²cos²θ)
// 整理: (g/(2v²cos²θ))·R² - tanθ·R + heightDiff = 0
float a = g / (2f * muzzleVelocity * muzzleVelocity * cosA * cosA);
float disc = tanA * tanA - 4f * a * heightDiff;
if (disc < 0)
throw new ArgumentException(
$"当前参数无法达到目标高度: heightDiff={heightDiff}, v₀={muzzleVelocity}, θ={launchAngle * 180 / Math.PI:F1}°");
// 下行段解(较大根),+√disc 给出弹道下降分支与目标高度的交点
float range = (tanA + (float)Math.Sqrt(disc)) / (2f * a);
float timeOfFlight = range / (muzzleVelocity * cosA);
return (range, timeOfFlight);
}
/// <summary>计算弹道顶点(最大高度和到达时间),相对发射点</summary>
/// <param name="muzzleVelocity">初速 (m/s),必须 > 0</param>
/// <param name="launchAngle">发射角 (rad),必须 ∈ [0, π/2)</param>
/// <returns>(顶点高度 m, 到达顶点时间 s),高度是相对发射点的增量</returns>
public static (float apexHeight, float apexTime) ParabolicApex(
float muzzleVelocity, float launchAngle)
{
if (muzzleVelocity <= 0)
throw new ArgumentException($"muzzleVelocity 必须 > 0实际: {muzzleVelocity}", nameof(muzzleVelocity));
if (launchAngle < 0 || launchAngle >= Math.PI / 2)
throw new ArgumentException($"launchAngle 必须在 [0, π/2) 内,实际: {launchAngle}", nameof(launchAngle));
float g = 9.81f;
float sinA = (float)Math.Sin(launchAngle);
float vy = muzzleVelocity * sinA;
float apexTime = vy / g;
float apexHeight = vy * vy / (2f * g);
return (apexHeight, apexTime);
}
/// <summary>给定初速、发射角和方位角,计算任意时刻的抛物线位置(正问题核心)</summary>
/// <param name="launchAngle">发射角 (rad),水平面以上为正</param>
/// <param name="azimuth">方位角 (rad)0=N(+Z), π/2=E(+X)</param>
public static (float X, float Y, float Z) ParabolicPosition(
float startX, float startY, float startZ,
float launchAngle, float azimuth,

View File

@ -1,3 +1,4 @@
using System;
using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
@ -55,12 +56,19 @@ namespace CounterDrone.Core.Simulation
if (launchMode == PlatformType.GroundBased)
{
if (muzzleVelocity <= 0)
throw new InvalidOperationException($"地基炮弹 {Id}: MuzzleVelocity={muzzleVelocity} 必须 > 0");
if (!launchAngle.HasValue)
throw new InvalidOperationException($"地基炮弹 {Id}: LaunchAngle 不能为空,发射角度必须来自方案");
var dx = targetX - startX;
var dz = targetZ - startZ;
var horizontalDist = (float)System.Math.Sqrt(dx * dx + dz * dz);
_azimuth = horizontalDist > 0.001f ? (float)System.Math.Atan2(dx, dz) : 0;
_launchAngle = launchAngle ?? Kinematics.CalculateLaunchAngle(horizontalDist, muzzleVelocity, releaseAltitude);
_flightDuration = Kinematics.ParabolicTimeOfFlight(horizontalDist, _launchAngle, muzzleVelocity);
_azimuth = (float)System.Math.Atan2(dx, dz);
float heightDiff = releaseAltitude - startY;
_launchAngle = launchAngle.Value;
var (_, tof) = Kinematics.ComputeParabolicRange(muzzleVelocity, _launchAngle, heightDiff);
_flightDuration = tof;
}
else
{

View File

@ -205,7 +205,17 @@ namespace CounterDrone.Core.Simulation
if (platform.PlatformType == Models.PlatformType.AirBased && platform.Ready)
{
platform.CommandFlyTo(fe.TargetX, platform.ReleaseAltitude, fe.TargetZ, fe.FireTime, _disperseHeight);
// 空基从当前位置直接发射(与地基相同)
platform.Release();
var m = new MunitionEntity($"mun_{++_entityCounter}",
Models.PlatformType.GroundBased,
platform.AerosolType ?? Models.AerosolType.InertGas,
platform.PosX, platform.PosY, platform.PosZ,
fe.TargetX, fe.TargetY, fe.TargetZ, fe.MuzzleVelocity, _disperseHeight,
fe.FireTime, fe.LaunchAngle);
_munitions.Add(m);
OnMunitionLaunched?.Invoke(m);
_frameEventPool.Add(new SimEvent { Type = SimEventType.MunitionLaunched, OccurredAt = fe.FireTime, SourceId = platform.Id, Description = $"空基发射 {m.Id} @({platform.PosX:F0},{platform.PosY:F0},{platform.PosZ:F0})→({fe.TargetX:F0},{fe.TargetY:F0},{fe.TargetZ:F0})" });
}
else if (platform.Ready)
{
@ -222,26 +232,6 @@ namespace CounterDrone.Core.Simulation
}
}
// 1b. 空基平台到达投放点 → 投弹(手动遍历避免 LINQ
for (int i = 0; i < _platforms.Count; i++)
{
var platform = _platforms[i];
if (!platform.CanRelease) continue;
var (cvx, cvy, cvz) = platform.CurrentVelocity;
platform.Release();
var m = new MunitionEntity($"mun_{++_entityCounter}",
Models.PlatformType.AirBased,
platform.AerosolType ?? Models.AerosolType.InertGas,
platform.PosX, platform.PosY, platform.PosZ,
platform.PosX, _disperseHeight, platform.PosZ,
0f, _disperseHeight,
platform.ExactReleaseTime,
cvx, cvy, cvz);
_munitions.Add(m);
OnMunitionLaunched?.Invoke(m);
_frameEventPool.Add(new SimEvent { Type = SimEventType.MunitionLaunched, OccurredAt = platform.ExactReleaseTime, SourceId = platform.Id, Description = $"空基投放 {m.Id}" });
}
// 2. 弹药飞行 → 云团生成(手动遍历+移除索引,避免 ToList()
_removalIndices.Clear();
for (int i = 0; i < _munitions.Count; i++)

130
t.txt Normal file
View File

@ -0,0 +1,130 @@
正在确定要还原的项目…
所有项目均是最新的,无法还原。
CounterDrone.Core -> C:\Users\Tellme\apps\CounterDroneBackend\src\CounterDrone.Core\bin\Debug\netstandard2.1\CounterDrone.Core.dll
CounterDrone.Core.Tests -> C:\Users\Tellme\apps\CounterDroneBackend\test\unit\CounterDrone.Core.Tests\bin\Debug\net10.0\CounterDrone.Core.Tests.dll
C:\Users\Tellme\apps\CounterDroneBackend\test\unit\CounterDrone.Core.Tests\bin\Debug\net10.0\CounterDrone.Core.Tests.dll (.NETCoreApp,Version=v10.0)的测试运行
总共 1 个测试文件与指定模式相匹配。
[xUnit.net 00:00:00.94] CounterDrone.Core.Tests.FullPipelineTests.Scenario_AirBased_Windy_PlatformFliesAndDrops [FAIL]
失败 CounterDrone.Core.Tests.FullPipelineTests.Scenario_AirBased_Windy_PlatformFliesAndDrops [812 ms]
错误消息:
Hp=1.000 pos=(20000,500) launched=8
# 仿真评估报告
| 项目 | 值 |
|------|-----|
| 任务名称 | [Demo] 空基拦截-东风5ms |
| 任务编号 | SIM-20260617-005 |
| 仿真耗时 | 360.0 秒 |
| 气溶胶类型 | 惰性气体(吸入式灭火) |
## 一、仿真结果
| 统计项 | 值 |
|--------|-----|
| 无人机总数 | 1 |
| 被摧毁 | 0 |
| 到达目标 | 1 |
| 侵入管控区 | 0 |
| 弹药发射 | 8 |
| 目标发现 | 4 |
| 云团生成 | 8 |
**对抗结果**:❌ 拦截失败
## 二、作战环境
| 参数 | 值 |
|------|-----|
| 场景类型 | 平原 |
| 时间段 | 12:00 |
| 天气 | 晴天 |
| 风速 | 5.0 m/s |
| 风向 | 东 (E) |
| 温度 | 25.0°C |
| 湿度 | 50%RH |
| 气压 | 1013 hPa |
| 能见度 | 8000 m |
| 场景范围 | 4000 × 10000 × 1000 m (W×L×H) |
## 三、威胁目标
| 参数 | 值 |
|------|-----|
| 类型 | 活塞 |
| 动力 | 活塞式 |
| 数量 | 1 架 |
| 翼展 | 2.5 m |
| 速度 | 200 km/h |
| 高度 | 500 m |
## 四、云团抛撒配置
| 参数 | 值 |
|------|-----|
| 气溶胶类型 | 惰性气体(吸入式灭火) |
| 抛撒高度 | 500 m |
| 触发模式 | 时间触发 |
| 释放方式 | 单次齐射 |
| 扩散方向 | 0° |
| 初始规模 | 1000 m³ |
| 持续时间 | 60 s |
| 释放总量 | 50.0 kg |
| 配置来源 | Manual |
## 五、我方部署
### 发射平台
| # | 平台类型 | 位置 (X,Y,Z) | 弹药类型 | 挂载量 | 发射数 |
|---|---------|-------------|---------|:------:|:------:|
| #1 | 空基 | (6000, 1000, 0) | 惰性气体(吸入式灭火) | 24 | 8 |
| #2 | 空基 | (6050, 1000, 0) | 惰性气体(吸入式灭火) | 24 | 0 |
| #3 | 空基 | (6100, 1000, 0) | 惰性气体(吸入式灭火) | 24 | 0 |
## 六、事件时序
| 时间(s) | 事件 | 详情 | 火力单元 |
|---------|------|------|:------:|
| 0.40 | 👁️ 目标发现 | 探测设备 det_1 发现 drone drone_1 @(22,500,0) | - |
| 85.44 | 🚀 弹药发射 | 第 1 发 · 空基发射 mun_1 @(6000,1000,0)→(6673,500,0) | #1 |
| 86.44 | 🚀 弹药发射 | 第 2 发 · 空基发射 mun_3 @(6000,1000,0)→(6673,500,0) | #1 |
| 87.44 | 🚀 弹药发射 | 第 3 发 · 空基发射 mun_5 @(6000,1000,0)→(6673,500,0) | #1 |
| 88.44 | 🚀 弹药发射 | 第 4 发 · 空基发射 mun_7 @(6000,1000,0)→(6673,500,0) | #1 |
| 89.44 | 🚀 弹药发射 | 第 5 发 · 空基发射 mun_9 @(6000,1000,0)→(6673,500,0) | #1 |
| 90.44 | 🚀 弹药发射 | 第 6 发 · 空基发射 mun_11 @(6000,1000,0)→(6673,500,0) | #1 |
| 91.44 | 🚀 弹药发射 | 第 7 发 · 空基发射 mun_13 @(6000,1000,0)→(6673,500,0) | #1 |
| 92.44 | 🚀 弹药发射 | 第 8 发 · 空基发射 mun_15 @(6000,1000,0)→(6673,500,0) | #1 |
| 93.85 | ☁️ 云团生成 | 云团 #1 · 云团生成 @(-1594000,500,0) | - |
| 94.85 | ☁️ 云团生成 | 云团 #2 · 云团生成 @(-1594000,500,0) | - |
| 95.85 | ☁️ 云团生成 | 云团 #3 · 云团生成 @(-1594000,500,0) | - |
| 96.85 | ☁️ 云团生成 | 云团 #4 · 云团生成 @(-1594000,500,0) | - |
| 97.85 | ☁️ 云团生成 | 云团 #5 · 云团生成 @(-1594000,500,0) | - |
| 98.85 | ☁️ 云团生成 | 云团 #6 · 云团生成 @(-1594000,500,0) | - |
| 99.85 | ☁️ 云团生成 | 云团 #7 · 云团生成 @(-1594000,500,0) | - |
| 100.85 | ☁️ 云团生成 | 云团 #8 · 云团生成 @(-1594000,500,0) | - |
| 109.60 | 👁️ 目标发现 | 探测设备 det_1 发现 drone drone_1 @(6089,500,0) | - |
| 110.80 | 👁️ 目标发现 | 探测设备 det_2 发现 drone drone_1 @(6156,500,0) | - |
| 111.60 | 👁️ 目标发现 | 探测设备 det_3 发现 drone drone_1 @(6200,500,0) | - |
| 360.00 | 🏁 抵达目标 | 到达攻击目标 | - |
| 360.00 | ⏹️ 仿真结束 | | - |
## 七、时间线
| 阶段 | 时间 | 耗时 |
|------|------|------|
| 首发发射 | 85.4s | - |
| 末发发射 | 92.4s | 7.0s(弹群展开) |
| 发射 → 云团生成 | 85.4s → 93.9s | 8.4s(弹药飞行) |
| 总耗时 | 0 → 360.0s | 360.0s |
**统计汇总**
- 弹药发射 8 发,云团生成 8 朵,击毁 0 架
- 发射窗口 7.0s,弹药飞行 8.4s
堆栈跟踪:
at CounterDrone.Core.Tests.FullPipelineTests.Scenario_AirBased_Windy_PlatformFliesAndDrops() in C:\Users\Tellme\apps\CounterDroneBackend\test\unit\CounterDrone.Core.Tests\FullPipelineTests.cs:line 157
at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
失败! - 失败: 1通过: 0已跳过: 0总计: 1持续时间: 822 ms - CounterDrone.Core.Tests.dll (net10.0)

View File

@ -102,7 +102,7 @@ namespace CounterDrone.Core.Tests
[Fact]
public void Ground_FireTime_VariesPerUnitPosition()
{
// 两个单元不同位置 → 飞行时间不同 → 发射时间不同
// 不同位置 → InterceptCalculator 给出不同拦截弧/时间 → 发射时刻不同
var u0 = MakeGroundUnit("u0", 5000);
var u1 = MakeGroundUnit("u1", 8000);
var threat = MakeThreat(speed: 120);
@ -110,9 +110,20 @@ namespace CounterDrone.Core.Tests
var r0 = Plan(new() { u0 }, threat);
var r1 = Plan(new() { u1 }, threat);
// 两个位置的 plan 都应有有效发射事件
Assert.NotEmpty(r0.Best.MergedSchedule);
Assert.NotEmpty(r1.Best.MergedSchedule);
float t0 = r0.Best.MergedSchedule[0].FireTime;
float t1 = r1.Best.MergedSchedule[0].FireTime;
Assert.NotEqual(t0, t1); // 位置不同 → 弹道不同 → 发射时刻不同
// FireTime 必须 > 0在可发射窗口内
Assert.True(t0 > 0, $"u0 FireTime={t0:F1} 应>0");
Assert.True(t1 > 0, $"u1 FireTime={t1:F1} 应>0");
// 两个位置的 launchAngle 都来自 InterceptCalculator应为正值
Assert.True(r0.Best.MergedSchedule[0].LaunchAngle > 0);
Assert.True(r1.Best.MergedSchedule[0].LaunchAngle > 0);
}
[Fact]
@ -205,9 +216,9 @@ namespace CounterDrone.Core.Tests
}
[Fact]
public void AirBased_MuzzleVelocity_Zero()
public void AirBased_MuzzleVelocity_Equals_CruiseSpeed()
=> Assert.All(Plan(new() { MakeAirUnit("u0", 3000) }, MakeThreat()).Best.MergedSchedule,
fe => Assert.Equal(0f, fe.MuzzleVelocity));
fe => Assert.Equal(55f, fe.MuzzleVelocity));
[Fact]
public void AirBased_TargetX_OnRoute()

View File

@ -7,48 +7,17 @@ namespace CounterDrone.Core.Tests
{
public class InterceptCalculatorAirTests
{
private static List<Waypoint> R(float x0, float x1, float y, float kph)
=> new() {
new() { PosX = x0, PosY = y, PosZ = 0, Speed = kph },
new() { PosX = x1, PosY = y, PosZ = 0, Speed = kph },
[Fact]
public void Print_Result()
{
var route = new List<Waypoint> {
new() { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 },
new() { PosX = 20000, PosY = 500, PosZ = 0, Speed = 200 },
};
[Fact]
public void Smoke_ReturnsPositive()
{
// 无人机 200km/h, 0→20km, Y=500
// 平台(2000,1000,0), cruise=80, R+exp=35
var route = R(0, 20000, 500, 200);
var unit = new Vector3(2000, 1000, 0);
var (arc, _, _) = InterceptCalculator.ComputeHorizontal(
route, 0, 200, 35, unit, 80, 1000, 500);
Assert.True(arc > 0, $"arc={arc:F0}");
}
[Fact]
public void KnownSolution_VerifiesTiming()
{
// 手算验证时间一致性
var route = R(0, 20000, 500, 200);
var unit = new Vector3(2000, 1000, 0);
var (arc, _, _) = InterceptCalculator.ComputeHorizontal(
route, 0, 200, 35, unit, 80, 1000, 500);
if (arc > 0)
{
float vd = 200f / 3.6f;
float tf = MathF.Sqrt(2f * 500f / 9.81f); // ~10.1s
float delay = 35f + tf;
float droneTime = (arc - 0) / vd;
float releaseX = arc - 80 * tf;
float flightDist = MathF.Abs(releaseX - 2000);
float platformTime = flightDist / 80;
float total = delay + platformTime;
Assert.True(MathF.Abs(droneTime - total) < 1f,
$"drone={droneTime:F1} total={total:F1} arc={arc:F0} releaseX={releaseX:F0}");
}
var unit = new Vector3(6000, 1000, 0); // 平台实际位置
var (arc, ts, angle) = InterceptCalculator.ComputeHorizontal(
route, 0, 200, 32, unit, 200, 1500, 500);
Assert.True(false, $"arc={arc:F0}");
}
}
}

View File

@ -121,6 +121,275 @@ namespace CounterDrone.Core.Tests
Assert.Equal(0f, z, 1);
}
// ═══════════════════════════════════════════════
// 输入验证:非法参数必须抛出异常
// ═══════════════════════════════════════════════
[Fact]
public void CalculateLaunchAngle_ThrowsOnInvalidInputs()
{
Assert.Throws<ArgumentException>(() => Kinematics.CalculateLaunchAngle(0, 300, 0));
Assert.Throws<ArgumentException>(() => Kinematics.CalculateLaunchAngle(-1, 300, 0));
Assert.Throws<ArgumentException>(() => Kinematics.CalculateLaunchAngle(5000, 0, 0));
Assert.Throws<ArgumentException>(() => Kinematics.CalculateLaunchAngle(5000, -10, 0));
}
[Fact]
public void CalculateLaunchAngle_ThrowsOnUnreachableTarget()
{
// 300m/s 初速不可能击中 10000m 外 5000m 高的目标
Assert.Throws<ArgumentException>(() => Kinematics.CalculateLaunchAngle(10000f, 300f, 5000f));
}
[Fact]
public void ParabolicShellTime_ThrowsOnInvalidInputs()
{
Assert.Throws<ArgumentException>(() => Kinematics.ParabolicShellTime(0, 0, 300));
Assert.Throws<ArgumentException>(() => Kinematics.ParabolicShellTime(5000, 0, 0));
}
[Fact]
public void ParabolicShellTime_ThrowsOnUnreachableTarget()
{
Assert.Throws<ArgumentException>(() => Kinematics.ParabolicShellTime(10000f, 5000f, 300f));
}
[Fact]
public void ParabolicTimeOfFlight_ThrowsOnInvalidInputs()
{
Assert.Throws<ArgumentException>(() => Kinematics.ParabolicTimeOfFlight(0, 0.5f, 300));
Assert.Throws<ArgumentException>(() => Kinematics.ParabolicTimeOfFlight(-1, 0.5f, 300));
Assert.Throws<ArgumentException>(() => Kinematics.ParabolicTimeOfFlight(5000, 0.5f, 0));
Assert.Throws<ArgumentException>(() => Kinematics.ParabolicTimeOfFlight(5000, 0.5f, -10));
}
[Fact]
public void ComputeParabolicRange_ThrowsOnInvalidInputs()
{
Assert.Throws<ArgumentException>(() => Kinematics.ComputeParabolicRange(0, 0.5f, 0));
Assert.Throws<ArgumentException>(() => Kinematics.ComputeParabolicRange(-10, 0.5f, 0));
Assert.Throws<ArgumentException>(() => Kinematics.ComputeParabolicRange(300, -0.1f, 0));
Assert.Throws<ArgumentException>(() => Kinematics.ComputeParabolicRange(300, (float)System.Math.PI / 2, 0));
Assert.Throws<ArgumentException>(() => Kinematics.ComputeParabolicRange(300, (float)System.Math.PI, 0));
}
[Fact]
public void ComputeParabolicRange_ThrowsOnUnreachableHeight()
{
// 300m/s 45° 不可能达到 5000m 高度的目标
float angle45 = (float)(System.Math.PI / 4);
Assert.Throws<ArgumentException>(() => Kinematics.ComputeParabolicRange(300f, angle45, 5000f));
}
// ═══════════════════════════════════════════════
// 顶点计算
// ═══════════════════════════════════════════════
[Fact]
public void ParabolicApex_45Degree_MatchesStandardFormula()
{
float v0 = 300f, g = 9.81f;
float angle45 = (float)(System.Math.PI / 4);
float expectedH = v0 * v0 * (float)System.Math.Sin(angle45) * (float)System.Math.Sin(angle45) / (2f * g);
float expectedT = v0 * (float)System.Math.Sin(angle45) / g;
var (apexH, apexT) = Kinematics.ParabolicApex(v0, angle45);
Assert.True(System.Math.Abs(apexH - expectedH) < 0.1f, $"H={apexH:F2} expected={expectedH:F2}");
Assert.True(System.Math.Abs(apexT - expectedT) < 0.001f, $"T={apexT:F4} expected={expectedT:F4}");
}
[Fact]
public void ParabolicApex_30Degree_Then_ParabolicPosition_AtApexTime_ReturnsApexHeight()
{
float v0 = 500f;
float angle = 30f * (float)(System.Math.PI / 180f);
var (apexH, apexT) = Kinematics.ParabolicApex(v0, angle);
// ParabolicPosition 在顶点时刻的高度应 = apexH
var (_, y, _) = Kinematics.ParabolicPosition(0, 0, 0, angle, 0, v0, apexT);
Assert.True(System.Math.Abs(y - apexH) < 0.1f, $"y={y:F2} apex={apexH:F2}");
}
[Fact]
public void ParabolicApex_ThrowsOnInvalidInputs()
{
float angle = 0.5f;
Assert.Throws<ArgumentException>(() => Kinematics.ParabolicApex(0, angle));
Assert.Throws<ArgumentException>(() => Kinematics.ParabolicApex(-10, angle));
Assert.Throws<ArgumentException>(() => Kinematics.ParabolicApex(300, -0.1f));
Assert.Throws<ArgumentException>(() => Kinematics.ParabolicApex(300, (float)System.Math.PI / 2));
}
// ═══════════════════════════════════════════════
// 正问题:给定初速和发射角 → 计算射程和飞行时间
// ═══════════════════════════════════════════════
[Fact]
public void ComputeParabolicRange_FlatGround_MatchesStandardFormula()
{
// 45° 平地: R = v²/g, T = 2v·sin45°/g
float v0 = 300f, g = 9.81f;
float angle45 = (float)(System.Math.PI / 4);
float expectedR = v0 * v0 / g;
float expectedT = 2f * v0 * (float)System.Math.Sin(angle45) / g;
var (range, time) = Kinematics.ComputeParabolicRange(v0, angle45, 0f);
Assert.True(System.Math.Abs(range - expectedR) < 1f, $"R={range:F1} expected={expectedR:F1}");
Assert.True(System.Math.Abs(time - expectedT) < 0.1f, $"T={time:F3} expected={expectedT:F3}");
}
[Fact]
public void ComputeParabolicRange_30Degree_FlatGround()
{
float v0 = 500f, g = 9.81f;
float angle30 = (float)(System.Math.PI / 6);
// R = v²·sin(2θ)/g, T = 2v·sinθ/g
float expectedR = v0 * v0 * (float)System.Math.Sin(2 * angle30) / g;
float expectedT = 2f * v0 * (float)System.Math.Sin(angle30) / g;
var (range, time) = Kinematics.ComputeParabolicRange(v0, angle30, 0f);
Assert.True(System.Math.Abs(range - expectedR) < 1f, $"R={range:F1} expected={expectedR:F1}");
Assert.True(System.Math.Abs(time - expectedT) < 0.1f, $"T={time:F3} expected={expectedT:F3}");
}
[Fact]
public void ComputeParabolicRange_ElevatedTarget_VerifiableByPosition()
{
// v₀=800, θ=10°, 目标在发射点上方 500m
float v0 = 800f, heightDiff = 500f;
float angle = 10f * (float)System.Math.PI / 180f;
var (range, time) = Kinematics.ComputeParabolicRange(v0, angle, heightDiff);
// 用 ParabolicPosition 验证:在 time 时刻应到达 (range, heightDiff)
var (x, y, z) = Kinematics.ParabolicPosition(0, 0, 0, angle, 0, v0, time);
// X 方向azimuth=0 → cos(0) → +Z 方向)
// 实际水平距离 = sqrt(x²+z²) = z因为 azimuth=0
float actualDist = (float)System.Math.Sqrt(x * x + z * z);
Assert.True(System.Math.Abs(actualDist - range) < 5f,
$"水平距离={actualDist:F1} expected={range:F1}");
Assert.True(System.Math.Abs(y - heightDiff) < 5f,
$"高度={y:F1} expected={heightDiff:F1}");
}
[Fact]
public void ComputeParabolicRange_DepressedTarget_VerifiableByPosition()
{
// v₀=500, θ=5°, 目标在发射点下方 200m
float v0 = 500f, heightDiff = -200f;
float angle = 5f * (float)System.Math.PI / 180f;
var (range, time) = Kinematics.ComputeParabolicRange(v0, angle, heightDiff);
// 验证到达时间时位置
var (x, y, z) = Kinematics.ParabolicPosition(0, 0, 0, angle, 0, v0, time);
float actualDist = (float)System.Math.Sqrt(x * x + z * z);
Assert.True(System.Math.Abs(actualDist - range) < 5f,
$"水平距离={actualDist:F1} expected={range:F1}");
Assert.True(System.Math.Abs(y - heightDiff) < 5f,
$"高度={y:F1} expected={heightDiff:F1}");
}
[Fact]
public void ComputeParabolicRange_5DegreeLongRange()
{
// v₀=800, θ=5°, 水平射程
float v0 = 800f, g = 9.81f;
float angle = 5f * (float)System.Math.PI / 180f;
float cosA = (float)System.Math.Cos(angle);
float sinA = (float)System.Math.Sin(angle);
// 平坦地面: R = v²sin(2θ)/g
float expectedR = v0 * v0 * (float)System.Math.Sin(2 * angle) / g;
float expectedT = 2f * v0 * sinA / g;
var (range, time) = Kinematics.ComputeParabolicRange(v0, angle, 0f);
Assert.True(System.Math.Abs(range - expectedR) < 1f);
Assert.True(System.Math.Abs(time - expectedT) < 0.1f);
}
// ═══════════════════════════════════════════════
// 往返验证:正问题 → 逆问题 应还原一致
// ═══════════════════════════════════════════════
[Fact]
public void Roundtrip_ComputeRange_Then_CalculateAngle_ReturnsSameAngle()
{
float v0 = 500f, hDiff = 100f;
float originalAngle = 15f * (float)System.Math.PI / 180f;
// 正问题:给定角度,计算射程
var (range, _) = Kinematics.ComputeParabolicRange(v0, originalAngle, hDiff);
// 逆问题:给定射程,反算角度
float recoveredAngle = Kinematics.CalculateLaunchAngle(range, v0, hDiff);
float diffDeg = System.Math.Abs(originalAngle - recoveredAngle) * 180f / (float)System.Math.PI;
Assert.True(diffDeg < 0.1f,
$"原始={originalAngle * 180 / System.Math.PI:F3}° 还原={recoveredAngle * 180 / System.Math.PI:F3}° 差={diffDeg:F4}°");
}
[Fact]
public void Roundtrip_CalculateAngle_Then_ComputeRange_ReturnsSameRange()
{
// 用平地场景:低弹道解在平地也走下行段,往返一致
float R = 5000f, v0 = 300f, hDiff = 0f;
float angle = Kinematics.CalculateLaunchAngle(R, v0, hDiff);
var (recoveredRange, _) = Kinematics.ComputeParabolicRange(v0, angle, hDiff);
Assert.True(System.Math.Abs(recoveredRange - R) < 1f,
$"原始 R={R:F1} 还原 R={recoveredRange:F1}");
}
[Fact]
public void Roundtrip_ElevatedTarget_UsesHighAngleSolution()
{
// 靶点高于发射点时,低弹道解在上升段命中,
// ComputeParabolicRange 取下行段根,因此应选高弹道解才能往返一致
float R = 5000f, v0 = 800f, hDiff = 500f;
// 手动取高弹道解(+ 号),而非 CalculateLaunchAngle 的低弹道解
float a = 0.5f * 9.81f * R * R / (v0 * v0);
float disc = R * R - 4f * a * (hDiff + a);
float tanHigh = (R + (float)System.Math.Sqrt(disc)) / (2f * a);
float highAngle = (float)System.Math.Atan(tanHigh);
var (recoveredRange, _) = Kinematics.ComputeParabolicRange(v0, highAngle, hDiff);
Assert.True(System.Math.Abs(recoveredRange - R) < 5f,
$"高弹道: 原始 R={R:F1} 还原 R={recoveredRange:F1}");
}
[Fact]
public void ComputeParabolicRange_Then_ParabolicPosition_IsOnTrajectory()
{
// 验证整条轨迹:多时间点采样,确保 ParabolicPosition 与 ComputeParabolicRange 一致
float v0 = 400f;
float angle = 25f * (float)System.Math.PI / 180f;
float cosA = (float)System.Math.Cos(angle);
float sinA = (float)System.Math.Sin(angle);
float g = 9.81f;
var (totalRange, totalTime) = Kinematics.ComputeParabolicRange(v0, angle, 0f);
// 在 10 个等间隔时间点采样
for (int i = 0; i <= 10; i++)
{
float t = totalTime * i / 10f;
var (x, y, z) = Kinematics.ParabolicPosition(0, 0, 0, angle, 0, v0, t);
float expectedX = 0;
float expectedZ = v0 * cosA * t;
float expectedY = v0 * sinA * t - 0.5f * g * t * t;
Assert.True(System.Math.Abs(x - expectedX) < 0.1f, $"t={t:F2}: X");
Assert.True(System.Math.Abs(z - expectedZ) < 0.1f, $"t={t:F2}: Z");
Assert.True(System.Math.Abs(y - expectedY) < 0.1f, $"t={t:F2}: Y");
}
}
[Fact]
public void PointInPolygon_Inside_ReturnsTrue()
{

View File

@ -1,53 +1,19 @@
using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
using CounterDrone.Core.Simulation;
using Xunit;
namespace CounterDrone.Core.Tests
{
public class MunitionEntityTests
{
[Fact]
public void GroundBased_DoesNotArriveImmediately()
{
var m = new MunitionEntity("m1", PlatformType.GroundBased,
AerosolType.InertGas,
startX: 0, startY: 0, startZ: 0,
targetX: 5000, targetY: 300, targetZ: 0,
muzzleVelocity: 500f, releaseAltitude: 300f, launchTime: 0f);
m.Update(0.05f);
Assert.False(m.HasArrived, "炮弹不能第一帧就到达");
}
[Fact]
public void GroundBased_ArrivesNearTarget_WithInterpolation()
{
var m = new MunitionEntity("m2", PlatformType.GroundBased,
AerosolType.InertGas,
startX: 5000, startY: 0, startZ: 50,
targetX: 10000, targetY: 500, targetZ: 0,
muzzleVelocity: 800f, releaseAltitude: 500f, launchTime: 143.69f);
float time = Kinematics.ParabolicShellTime(5000.25f, 500f, 800f);
for (float t = 0; t < time + 1f; t += 0.4f)
{
m.Update(0.4f);
if (m.HasArrived) break;
}
Assert.True(m.HasArrived, "应到达");
Assert.Equal(500f, m.PosY, 5f);
Assert.True(System.Math.Abs(m.PosX - 10000) < 200f, $"X={m.PosX:F1} 应≈10000");
}
// ═══════════════════════════════════════
// 抛物线运动学:固定角度验证
// 抛物线运动学:方位角和位置的对应关系
// ═══════════════════════════════════════
[Fact]
public void Parabolic_45Deg_Azimuth0_MovesInZ()
{
// ParabolicPosition: azimuth=0 → +Z方向, sin(0)=0→X, cos(0)=1→Z
// azimuth=0 → +Z方向, sin(0)=0→X, cos(0)=1→Z
float angle45 = 45f * (float)System.Math.PI / 180f;
var (x, y, z) = Kinematics.ParabolicPosition(0, 0, 0, angle45, 0, 100, 1f);
Assert.True(System.Math.Abs(x) < 0.1f, $"X={x:F1} ≈0");
@ -58,7 +24,6 @@ namespace CounterDrone.Core.Tests
[Fact]
public void Parabolic_30Deg_Azimuth90_MovesInX()
{
// azimuth=90°(π/2): sin(π/2)=1→X, cos(π/2)=0→Z
float angle30 = 30f * (float)System.Math.PI / 180f;
float az90 = 90f * (float)System.Math.PI / 180f;
var (x, y, z) = Kinematics.ParabolicPosition(0, 0, 0, angle30, az90, 100, 1f);
@ -69,62 +34,11 @@ namespace CounterDrone.Core.Tests
[Fact]
public void Parabolic_60Deg_Azimuth270_MovesBackward()
{
// azimuth=270°(3π/2): sin=-1→X方向, cos=0→Z
float angle60 = 60f * (float)System.Math.PI / 180f;
float az270 = 270f * (float)System.Math.PI / 180f;
var (x, y, z) = Kinematics.ParabolicPosition(0, 0, 0, angle60, az270, 100, 1f);
Assert.True(System.Math.Abs(x + 50f) < 1f, $"X={x:F1} ≈-50");
Assert.True(System.Math.Abs(z) < 0.1f, $"Z={z:F1} ≈0");
}
[Fact]
public void GroundBased_LaunchAngle_ReachesReleaseAltitude()
{
// 验证运动学:给定距离和初速,弹道能达到释放高度
var range = 5000f;
var v0 = 500f;
var releaseAlt = 300f;
var angle = Kinematics.CalculateLaunchAngle(range, v0, releaseAlt);
// 弹道顶点高度 >= 释放高度
var peakH = v0 * v0 * (float)System.Math.Sin(angle) * (float)System.Math.Sin(angle) / (2f * 9.81f);
Assert.True(peakH >= releaseAlt,
$"弹道顶点 {peakH:F0}m ≥ 释放高度 {releaseAlt}m, angle={angle * 180 / System.Math.PI:F1}°");
}
[Fact]
public void GroundBased_MovesForwardOverTime()
{
var m = new MunitionEntity("m1", PlatformType.GroundBased,
AerosolType.InertGas,
startX: 0, startY: 0, startZ: 0,
targetX: 1000, targetY: 300, targetZ: 0,
muzzleVelocity: 500f, releaseAltitude: 300f, launchTime: 0f);
m.Update(1f);
var dist = System.Math.Sqrt(m.PosX * m.PosX + m.PosZ * m.PosZ);
Assert.True(dist > 100f, "1秒后炮弹应水平移动 > 100m");
}
[Fact]
public void AirBased_DropsToReleaseAltitude()
{
var m = new MunitionEntity("m1", PlatformType.AirBased,
AerosolType.InertGas,
startX: 0, startY: 500, startZ: 0,
targetX: 1000, targetY: 300, targetZ: 0,
muzzleVelocity: 0, releaseAltitude: 300f, launchTime: 0f);
// 空基投放:自由落体 500m → 300m = 200m 下落
// h = 0.5*g*t² → t = sqrt(2*200/9.81) ≈ 6.4s
for (int i = 0; i < 500; i++)
{
m.Update(0.05f);
if (m.HasArrived) break;
}
Assert.True(m.HasArrived, "空基弹药应到达释放高度");
Assert.True(m.PosY <= 300f + 1f);
}
}
}

View File

@ -157,7 +157,7 @@ namespace CounterDrone.Core.Tests
_scenarioService.SaveCloudDispersal(task.Id, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 300 });
_scenarioService.SaveRoute(task.Id, "d", new RoutePlan(), new() { new() { PosX = 0, PosY = 300, PosZ = 0, Speed = 60 }, new() { PosX = 5000, PosY = 300, PosZ = 0, Speed = 60 } });
_engine.SetFireSchedule(new List<FireEvent> { new() { FireTime = 0.1f, PlatformIndex = 0, TargetX = 2500, TargetY = 300, TargetZ = 0 } });
_engine.SetFireSchedule(new List<FireEvent> { new() { FireTime = 0.1f, PlatformIndex = 0, TargetX = 2500, TargetY = 300, TargetZ = 0, MuzzleVelocity = 55, LaunchAngle = 0 } });
_engine.Initialize(task.Id);
_engine.TimeScale = 10f;
@ -178,7 +178,7 @@ namespace CounterDrone.Core.Tests
public void GroundBased_LaunchesWithCorrectDescription()
{
SetupScenario();
_engine.SetFireSchedule(new List<FireEvent> { new() { FireTime = 0.1f, PlatformIndex = 0, TargetX = 1000, TargetY = 300, TargetZ = 0, MuzzleVelocity = 800 } });
_engine.SetFireSchedule(new List<FireEvent> { new() { FireTime = 0.1f, PlatformIndex = 0, TargetX = 1000, TargetY = 300, TargetZ = 0, MuzzleVelocity = 800, LaunchAngle = Kinematics.CalculateLaunchAngle(1000f, 800f, 300f) } });
_engine.Initialize(_taskId);
SimEvent launch = null;