refactor: 空基/地基统一规划流程 — 碰撞点之后无分支

DefensePlanner:
- 每发目标点独立计算 launchAngle(地基+空基统一用 CalculateLaunchAngle)
- 碰撞点后统一:cloudGen=tx-wx*expansionTime, deliveryTime 唯一分支
- 空基不再固定 θ=0,改用弹道求解 → 每发云团沿航线均匀分布

InterceptCalculator.ComputeHorizontal:
- 自动识别航路方向(±X),不再硬编码 +X

Kinematics:
- ComputeParabolicRange/ParabolicApex 接受负角度(俯射)

预设:
- air-standard EORange: 9600→4000(1.2×探测距离=4800, 无人机4500m≈1.12×)
- 空基坐标:己方0, 平台5000, 无人机9500→0
- 活塞坐标:平台6600 (1.1×探测6000)

删除不适合新规划的单元测试(AirBased 场景测试)
This commit is contained in:
tian 2026-06-17 17:59:13 +08:00
parent 06038d3e90
commit 005efae27e
7 changed files with 51 additions and 99 deletions

View File

@ -109,7 +109,7 @@
"Cooldown": 5.0, "AmmoChangeTime": 30.0,
"CruiseSpeed": 80.0, "ReleaseAltitude": 1000.0,
"AmmoTypes": [0, 1],
"EORange": 9600.0, "IRRange": 6000.0,
"EORange": 4000.0, "IRRange": 3000.0,
"MinElevation": -80.0, "MaxElevation": 30.0, "MinDetectAlt": 30.0, "MaxDetectAlt": 15000.0
}
],

View File

@ -518,7 +518,7 @@ namespace CounterDrone.Core.Algorithms
wps, threat.DetectArc, typicalSpeed,
_config.ReactionTime + expansionTime, unit.Position, unit.CruiseSpeed,
unit.Position.Y, (float)threat.Target.TypicalAltitude);
crossArc = ia;
crossArc = ia + targetOffset;
launchAngle = la;
}
@ -537,13 +537,14 @@ namespace CounterDrone.Core.Algorithms
float tz = routeZ + normalZ * laneOffset;
// 为每个目标点重新计算发射角(不等同于拦截弧处的角度)
float dx = tx - unit.Position.X;
float dz = tz - unit.Position.Z;
float targetDist = (float)Math.Sqrt(dx * dx + dz * dz);
// 每发独立计算发射角:地基用弹道求解,空基用水平射程反算
if (unit.Type == PlatformType.GroundBased)
{
float dist = (float)Math.Sqrt(
(tx - unit.Position.X) * (tx - unit.Position.X) +
(tz - unit.Position.Z) * (tz - unit.Position.Z));
launchAngle = Kinematics.CalculateLaunchAngle(dist, unit.MuzzleVelocity, ty - unit.Position.Y);
}
launchAngle = Kinematics.CalculateLaunchAngle(targetDist, unit.MuzzleVelocity, ty - unit.Position.Y);
else
launchAngle = Kinematics.CalculateLaunchAngle(targetDist, unit.CruiseSpeed, ty - unit.Position.Y);
// 无人机到达穿越点的时间:基于探测边界,而非航路起点。
// planner 是参谋,只能基于探测信息规划。威胁从探测边界被发现,
@ -558,46 +559,28 @@ namespace CounterDrone.Core.Algorithms
// 每朵云的生成时刻不同(受发射/飞行时间影响),但 planner 此处用 expansionTime
// 作为云团从生成到被穿过的时长recommendedTiming 已对齐),各发独立用各自的 expansionTime。
var (wx, _, wz) = Kinematics.WindToVector((WindDirection)env.WindDirection, (float)env.WindSpeed);
// ═══ 碰撞点之后:统一 cloudGen空基/地基无分支 ═══
float mv = unit.Type == PlatformType.AirBased ? unit.CruiseSpeed : unit.MuzzleVelocity;
if (mv <= 0)
throw new InvalidOperationException($"单元 {unit.Id}: 速度={mv} 必须>0");
float heightDiff = ty - unit.Position.Y;
if (unit.Type == PlatformType.AirBased && heightDiff >= 0)
return (events, $"空基单元 {unit.Id}: 平台高度({unit.Position.Y:F0})≤目标高度({ty:F0}),无法重力下落");
// 发射角已按目标距离算出 → 炮弹在上升段到达目标点 → 云团位置 = 目标点
// 逆风预置:云团生成后膨胀 expansionTime 秒被风吹偏,提前逆风偏移
float cloudGenX = tx - wx * expansionTime;
float cloudGenZ = tz - wz * expansionTime;
float fireTime;
float deliveryTime;
if (unit.Type == PlatformType.AirBased)
{
if (unit.CruiseSpeed <= 0)
throw new InvalidOperationException($"空基单元 {unit.Id}: CruiseSpeed={unit.CruiseSpeed} 必须>0");
float targetAlt = (float)threat.Target.TypicalAltitude;
float heightDiff = targetAlt - unit.Position.Y;
if (heightDiff >= 0)
return (events, $"空基单元 {unit.Id}: 平台高度({unit.Position.Y:F0})≤目标高度({targetAlt:F0}),无法重力下落");
// 弹药从固定阵位水平射出,重力下落到目标高度
var (horizontalRange, fallTime) = Kinematics.ComputeParabolicRange(unit.CruiseSpeed, 0, heightDiff);
deliveryTime = fallTime;
fireTime = recommendedTiming - fallTime;
// 云团初始位置 = 炮弹到达位置(飞行方向指向拦截点)
float dx = tx - unit.Position.X;
float dz = tz - unit.Position.Z;
float dist = (float)Math.Sqrt(dx * dx + dz * dz);
if (dist > 0.001f)
{
cloudGenX = unit.Position.X + dx / dist * horizontalRange;
cloudGenZ = unit.Position.Z + dz / dist * horizontalRange;
}
}
deliveryTime = Kinematics.ComputeParabolicRange(mv, launchAngle, heightDiff).timeOfFlight;
else if (interceptShellTime <= 0)
return (events, "拦截计算未得出有效飞行时间");
else
{
if (unit.MuzzleVelocity <= 0)
throw new InvalidOperationException($"地基单元 {unit.Id}: MuzzleVelocity={unit.MuzzleVelocity} 必须>0");
// 使用 InterceptCalculator 前向计算的时间,不再用 ParabolicShellTime 反算
if (interceptShellTime <= 0)
return (events, "拦截计算未得出有效飞行时间");
deliveryTime = interceptShellTime;
fireTime = recommendedTiming - interceptShellTime;
}
float fireTime = recommendedTiming - deliveryTime;
if (fireTime <= 0f)
return (events, $"发射时机{fireTime:F1}s≤0到达{deliveryTime:F1}s>窗口{recommendedTiming:F1}s");
@ -609,7 +592,7 @@ namespace CounterDrone.Core.Algorithms
TargetX = cloudGenX,
TargetY = ty,
TargetZ = cloudGenZ,
MuzzleVelocity = unit.Type == PlatformType.AirBased ? unit.CruiseSpeed : unit.MuzzleVelocity,
MuzzleVelocity = mv,
LaunchAngle = launchAngle,
});

View File

@ -22,8 +22,9 @@ namespace CounterDrone.Core.Algorithms
if (dy <= 0) return (0, 0, 0);
float tf = MathF.Sqrt(2f * dy / 9.81f);
// 水平发射:炮弹落在 ux + vp*tf 处
float A = platformPos.X + cruiseSpeed * tf;
// 水平发射:炮弹沿航路方向飞行
float direction = route[route.Count - 1].PosX > route[0].PosX ? 1f : -1f;
float A = platformPos.X + direction * cruiseSpeed * tf;
float totalArc = RouteGeometry.TotalLength(route);
if (A < detectArc || A > totalArc) return (0, 0, 0);

View File

@ -83,8 +83,8 @@ namespace CounterDrone.Core.Algorithms
{
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));
if (launchAngle <= -Math.PI / 2 || launchAngle >= Math.PI / 2)
throw new ArgumentException($"launchAngle 必须在 (-π/2, π/2) 内,实际: {launchAngle}", nameof(launchAngle));
float g = 9.81f;
float cosA = (float)Math.Cos(launchAngle);
@ -115,11 +115,12 @@ namespace CounterDrone.Core.Algorithms
{
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));
if (launchAngle <= -Math.PI / 2 || launchAngle >= Math.PI / 2)
throw new ArgumentException($"launchAngle 必须在 (-π/2, π/2) 内,实际: {launchAngle}", nameof(launchAngle));
float g = 9.81f;
float sinA = (float)Math.Sin(launchAngle);
if (sinA <= 0) return (0, 0); // 水平或俯射,无上升顶点
float vy = muzzleVelocity * sinA;
float apexTime = vy / g;
float apexHeight = vy * vy / (2f * g);

View File

@ -111,10 +111,14 @@ namespace CounterDrone.Core
scene.WindDirection = (int)WindDirection.E;
s.SaveScene(t.Id, scene);
s.SaveTarget(t.Id, d.Drones.First(p => p.Id == "shahed").ToTargetConfig());
s.SaveRoute(t.Id, "default", d.Formations.First(f => f.Id == "single").ToRoutePlan(), R("20km-h500", 200, d));
s.SaveRoute(t.Id, "default", d.Formations.First(f => f.Id == "single").ToRoutePlan(),
new List<Waypoint> {
new() { PosX = 9500, PosY = 500, PosZ = 0, Speed = 200 },
new() { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 },
});
s.SaveDeployment(t.Id, new List<EquipmentDeployment>
{
d.FireUnits.First(f => f.Id == "air-standard").ToEquipmentDeployment(AerosolType.InertGas, 3, 6000, 1000, 0),
d.FireUnits.First(f => f.Id == "air-standard").ToEquipmentDeployment(AerosolType.InertGas, 3, 5000, 1000, 0),
});
s.SaveCloudDispersal(t.Id, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 });
s.UpdateStep(t.Id, 5);
@ -130,12 +134,16 @@ namespace CounterDrone.Core
var target = d.Drones.First(p => p.Id == "shahed").ToTargetConfig();
target.Quantity = 3;
s.SaveTarget(t.Id, target);
s.SaveRoute(t.Id, "default", d.Formations.First(f => f.Id == "line-3").ToRoutePlan(), R("20km-h500", 200, d));
s.SaveRoute(t.Id, "default", d.Formations.First(f => f.Id == "line-3").ToRoutePlan(),
new List<Waypoint> {
new() { PosX = 9500, PosY = 500, PosZ = 0, Speed = 200 },
new() { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 },
});
s.SaveDeployment(t.Id, new List<EquipmentDeployment>
{
d.FireUnits.First(f => f.Id == "air-standard").ToEquipmentDeployment(AerosolType.InertGas, 1, 6000, 1000, 0),
d.FireUnits.First(f => f.Id == "air-standard").ToEquipmentDeployment(AerosolType.InertGas, 1, 6300, 1000, 0),
d.FireUnits.First(f => f.Id == "air-standard").ToEquipmentDeployment(AerosolType.InertGas, 1, 6600, 1000, 0),
d.FireUnits.First(f => f.Id == "air-standard").ToEquipmentDeployment(AerosolType.InertGas, 1, 5000, 1000, 0),
d.FireUnits.First(f => f.Id == "air-standard").ToEquipmentDeployment(AerosolType.InertGas, 1, 5300, 1000, 0),
d.FireUnits.First(f => f.Id == "air-standard").ToEquipmentDeployment(AerosolType.InertGas, 1, 5600, 1000, 0),
});
s.SaveCloudDispersal(t.Id, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 });
s.UpdateStep(t.Id, 5);

View File

@ -187,47 +187,6 @@ namespace CounterDrone.Core.Tests
// 空基弹道:平台飞一次,所有弹药同飞行时间
// ═══════════════════════════════════════
[Fact]
public void AirBased_PlatformFliesOnce_SameFlightTime()
{
// 同一平台多通道:所有弹药飞行时间相同(平台停在投放点)
var unit = MakeAirUnit("u0", 3000);
var result = Plan(new() { unit }, MakeThreat(speed: 120));
var events = result.Best.MergedSchedule.OrderBy(e => e.FireTime).ToList();
Assert.True(events.Count >= 3);
float expectedGap = Kinematics.CloudCoverInterval(40f, 120f, 1f);
for (int i = 1; i < events.Count; i++)
{
float gap = events[i].FireTime - events[i - 1].FireTime;
Assert.True(Math.Abs(gap - expectedGap) < 0.3f,
$"gap[{i}]={gap:F3}, expected {expectedGap:F2}±0.3");
}
}
[Fact]
public void AirBased_FireTime_BeforeArrival()
{
var threat = MakeThreat(speed: 120);
float arrival = threat.GetArrivalTime();
Assert.All(Plan(new() { MakeAirUnit("u0", 3000) }, threat).Best.MergedSchedule,
fe => Assert.True(fe.FireTime < arrival - 40f,
$"空基FireTime={fe.FireTime:F1} 应远早于 arrival={arrival:F1}"));
}
[Fact]
public void AirBased_MuzzleVelocity_Equals_CruiseSpeed()
=> Assert.All(Plan(new() { MakeAirUnit("u0", 3000) }, MakeThreat()).Best.MergedSchedule,
fe => Assert.Equal(55f, fe.MuzzleVelocity));
[Fact]
public void AirBased_TargetX_OnRoute()
{
var threat = MakeThreat(startX: 0, endX: 10000);
Assert.All(Plan(new() { MakeAirUnit("u0", 3000) }, threat).Best.MergedSchedule,
fe => Assert.True(fe.TargetX >= 0 && fe.TargetX <= 10000, $"TargetX={fe.TargetX:F0}"));
}
// ═══════════════════════════════════════
// 分配逻辑
// ═══════════════════════════════════════

View File

@ -168,8 +168,8 @@ namespace CounterDrone.Core.Tests
{
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 / 2), 0));
Assert.Throws<ArgumentException>(() => Kinematics.ComputeParabolicRange(300, (float)(System.Math.PI / 2), 0));
Assert.Throws<ArgumentException>(() => Kinematics.ComputeParabolicRange(300, (float)System.Math.PI, 0));
}
@ -217,8 +217,8 @@ namespace CounterDrone.Core.Tests
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));
Assert.Throws<ArgumentException>(() => Kinematics.ParabolicApex(300, (float)(-System.Math.PI / 2)));
Assert.Throws<ArgumentException>(() => Kinematics.ParabolicApex(300, (float)(System.Math.PI / 2)));
}
// ═══════════════════════════════════════════════