refactor: DefensePlanner 替代 DefenseAdvisor

- 新增 IDefensePlanner 接口 + DefaultDefensePlanner 实现
- FireUnit 重构为统一平台模型(Type/Position/CruiseSpeed/ReleaseAltitude/MuzzleVelocity)
- DroneGroup 加入威胁指数(类型系数×速度系数)和优先级排序
- 五步流水线:威胁排序→弹药匹配→候选生成→贪心分配→时序生成
- 弹药精确匹配,不降级;临界方案基于50%概率阈值
- 删除 IDefenseAdvisor/DefaultDefenseAdvisor/RecommendMultiGroup
- AlgorithmFactory 注册 IDefensePlanner
- 测试更新:DefensePlannerTests 替代旧测试,FullPipelineTests 适配新 API
- Unity Managers 更新调用
- 125 测试全通过
This commit is contained in:
tian 2026-06-12 18:20:25 +08:00
parent 1e07bcc535
commit 317f47280b
15 changed files with 796 additions and 687 deletions

View File

@ -10,7 +10,7 @@ namespace CounterDrone.Core.Algorithms
{
[typeof(ICloudDispersionModel)] = () => new GaussianPuffDispersion(),
[typeof(IDamageModel)] = () => new DamageModelRouter(),
[typeof(IDefenseAdvisor)] = () => new DefaultDefenseAdvisor(null),
[typeof(IDefensePlanner)] = () => new DefaultDefensePlanner(null),
};
public static void Register<TInterface>(Func<object> factory)

View File

@ -50,21 +50,31 @@ namespace CounterDrone.Core.Algorithms
public List<FireEvent> MergedFireSchedule { get; set; } = new();
}
/// <summary>火力单元(装备编组</summary>
/// <summary>火力单元(单个平台,统一表达空基/地基</summary>
public class FireUnit
{
public string GroupId { get; set; } = string.Empty;
public AerosolType LoadedAmmo { get; set; }
public int PlatformCount { get; set; }
public float PositionX, PositionY, PositionZ;
public string Id { get; set; } = string.Empty;
/// <summary>平台类型AirBased / GroundBased</summary>
public PlatformType Type { get; set; }
/// <summary>待命位置(空基含巡航高度)</summary>
public Vector3 Position { get; set; }
/// <summary>空基巡航速度 m/s</summary>
public float CruiseSpeed { get; set; }
/// <summary>空基投放高度 m</summary>
public float ReleaseAltitude { get; set; }
/// <summary>地基初速 m/s</summary>
public float MuzzleVelocity { get; set; } = 800f;
/// <summary>总载弹量</summary>
public int TotalMunitions { get; set; }
/// <summary>可装填的弹药类型</summary>
public List<AerosolType> AmmoTypes { get; set; } = new();
/// <summary>同弹种连发冷却 s</summary>
public float Cooldown { get; set; } = 5f;
/// <summary>更换弹种时间 s</summary>
public float AmmoChangeTime { get; set; } = 300f;
// 弹药库:可以装填的弹药类型
public List<AerosolType> AvailableAmmo { get; set; } = new();
}
/// <summary>无人机编队(多编队推荐输入)</summary>
/// <summary>无人机编队(规划器输入)</summary>
public class DroneGroup
{
public string GroupId { get; set; } = string.Empty;
@ -72,6 +82,13 @@ namespace CounterDrone.Core.Algorithms
public RoutePlan Route { get; set; } = new();
public List<Waypoint> Waypoints { get; set; } = new();
/// <summary>到达防御区域中点的时间(秒),由规划器预计算</summary>
public float ArrivalTime { get; set; }
/// <summary>威胁指数(速度系数 × 类型系数)</summary>
public float ThreatIndex { get; set; }
/// <summary>综合优先级 = 威胁指数 / (到达时间 + 1)</summary>
public float Priority => ArrivalTime > -1 ? ThreatIndex / (ArrivalTime + 1f) : ThreatIndex;
/// <summary>预计到达航路中点的时间(秒)</summary>
public float GetArrivalTime()
{
@ -135,4 +152,49 @@ namespace CounterDrone.Core.Algorithms
public float TargetX, TargetY, TargetZ;
public float MuzzleVelocity;
}
// ═══════════════════════════════════════════════
// DefensePlanner 类型
// ═══════════════════════════════════════════════
/// <summary>拦截候选:一个火力单元对一个威胁的可行性评估</summary>
public class InterceptCandidate
{
public FireUnit Unit { get; set; } = null!;
public AerosolType AmmoType { get; set; }
public float EarliestInterceptTime { get; set; }
public float CoverageDuration { get; set; }
public float KillProbability { get; set; }
public float FlightTime { get; set; } // 弹药/平台飞行时间
public float ShellFlightTime { get; set; } // 仅地基用
}
/// <summary>单元分配:一个火力单元被分配给一个威胁的配置</summary>
public class UnitAssignment
{
public string FireUnitId { get; set; } = string.Empty;
public string DroneGroupId { get; set; } = string.Empty;
public AerosolType AmmoType { get; set; }
public int RoundsFired { get; set; }
public float FirstFireTime { get; set; }
public List<FireEvent> FireEvents { get; set; } = new();
}
/// <summary>规划方案</summary>
public class DefensePlan
{
public List<UnitAssignment> Assignments { get; set; } = new();
public List<FireEvent> MergedSchedule { get; set; } = new();
public float OverallProbability { get; set; }
public int ThreatsEngaged { get; set; }
public int ThreatsUnengaged { get; set; }
public string Summary { get; set; } = string.Empty;
}
/// <summary>规划器输出</summary>
public class PlannerResult
{
public DefensePlan Best { get; set; } = new();
public DefensePlan Critical { get; set; } = new();
}
}

View File

@ -1,302 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CounterDrone.Core.Models;
namespace CounterDrone.Core.Algorithms
{
public class DefaultDefenseAdvisor : IDefenseAdvisor
{
private readonly List<AmmunitionSpec> _ammoCatalog;
public DefaultDefenseAdvisor(List<AmmunitionSpec> ammoCatalog)
{
_ammoCatalog = ammoCatalog ?? new List<AmmunitionSpec>();
}
private static readonly Dictionary<PowerType, AerosolType> MatchTable = new()
{
{ PowerType.Electric, AerosolType.InertGas },
{ PowerType.Piston, AerosolType.InertGas },
{ PowerType.Jet, AerosolType.ActiveMaterial },
};
public DefenseRecommendation Recommend(ThreatProfile threat)
{
var result = new DefenseRecommendation
{
Best = new DefenseSolution(),
Critical = new DefenseSolution(),
};
// ===== 输入校验 =====
if (threat.Targets.Count == 0)
{
result.Best.SummaryRationale = "失败:未配置威胁目标";
return result;
}
if (threat.Waypoints.Count < 2)
{
result.Best.SummaryRationale = "失败:需要至少两个航路点";
return result;
}
var target = threat.Targets[0];
if (target.TypicalSpeed <= 0)
{
result.Best.SummaryRationale = "失败:目标速度为 0";
return result;
}
// ===== Step A气溶胶选型 =====
var powerType = (PowerType)target.PowerType;
var aerosolType = MatchTable.TryGetValue(powerType, out var match)
? match : AerosolType.InertGas;
var ammo = _ammoCatalog.FirstOrDefault(a => a.AerosolType == (int)aerosolType);
if (ammo == null)
{
result.Best.SummaryRationale = $"失败:弹药库中未找到 {aerosolType} 类型的弹药规格";
return result;
}
var rationale = powerType switch
{
PowerType.Electric or PowerType.Piston =>
$"{powerType}发动机依赖氧气,推荐惰性气体窒息方案",
PowerType.Jet =>
$"{powerType}发动机高温表面可触发活性材料爆燃反应",
_ => "默认推荐惰性气体方案",
};
// ===== Step B时空交汇优化 =====
var start = ToV3(threat.Waypoints[0]);
var end = ToV3(threat.Waypoints[^1]);
var mid = new Vector3((start.X + end.X) / 2f, (start.Y + end.Y) / 2f, (start.Z + end.Z) / 2f);
var routeLength = start.DistanceTo(end);
var avgSpeed = (float)target.TypicalSpeed / 3.6f;
var totalFlightTime = routeLength / avgSpeed;
if (routeLength < 100)
{
result.Best.SummaryRationale = "失败:航路太短(<100m";
return result;
}
// 弹药参数Phase 1 爆轰 + Phase 2 膨胀后的有效半径
var r0 = 3.3f * (float)Math.Pow(Math.Max(0.01, (float)ammo.BurstChargeKg), 0.32);
var k = (float)ammo.TurbulentExpansionK;
var maxDur = (float)ammo.MaxDuration;
var halfTime = totalFlightTime / 2f;
var windSpeed = (float)threat.Environment.WindSpeed;
var weather = (WeatherType)threat.Environment.WeatherType;
var effectiveR = ComputeEffectiveRadius(r0, k, halfTime, windSpeed, weather, ammo);
var crossTime = (2f * effectiveR) / avgSpeed;
var neededExposure = aerosolType == AerosolType.ActiveMaterial ? 2f : 6f;
var spacing = effectiveR * 1.5f;
var requiredCoverage = neededExposure * avgSpeed;
var roundsNeeded = Math.Max(1,
(int)Math.Ceiling((requiredCoverage - 2f * effectiveR) / spacing) + 1);
var actualCoverage = spacing * (roundsNeeded - 1) + 2f * effectiveR;
var actualExposure = actualCoverage / avgSpeed;
var prob = Math.Min(0.95f, actualExposure / neededExposure);
var expansionTime = (float)Math.Pow((effectiveR - r0) / k, 2);
var recommendedTiming = halfTime - expansionTime;
// 平台类型
var platType = threat.PreferredPlatformType ?? PlatformType.GroundBased;
var isAirBased = platType == PlatformType.AirBased;
// 空基参数
var cruiseSpeed = 55f; // ~200 km/h
var releaseAlt = isAirBased ? (float)target.TypicalAltitude + 500f : 0f;
var fallTime = isAirBased ? Kinematics.AirDropFallTime(releaseAlt, (float)target.TypicalAltitude) : 0f;
// 生成发射计划和平台部署
var fireSchedule = new List<FireEvent>();
var platforms = new List<RecommendedPlatform>();
var gunCount = roundsNeeded;
for (int i = 0; i < roundsNeeded; i++)
{
var offset = (i - (roundsNeeded - 1) / 2f) * spacing;
var tx = mid.X + offset; var ty = mid.Y; var tz = mid.Z;
if (isAirBased)
{
// 巡逻点:防御圈边缘,偏离目标侧方
var patrolX = mid.X - 2000;
var patrolY = 2500f;
var patrolZ = mid.Z - 2000;
var flightDist = Kinematics.Distance3D(patrolX, patrolY, patrolZ, tx, releaseAlt, tz);
var flightTime = flightDist / cruiseSpeed;
var fireTime = recommendedTiming - flightTime - fallTime;
if (fireTime < 0.1f) fireTime = 0.1f;
fireSchedule.Add(new FireEvent { FireTime = fireTime, PlatformIndex = i, TargetX = tx, TargetY = ty, TargetZ = tz, MuzzleVelocity = 0f });
platforms.Add(new RecommendedPlatform
{
Type = PlatformType.AirBased,
Position = new Vector3(patrolX + i * 30, patrolY, patrolZ),
Quantity = 1, MunitionCount = 1,
CruiseSpeed = cruiseSpeed, ReleaseAltitude = releaseAlt,
Cooldown = 5f, CoverageVolume = (float)ammo.InitialVolume,
});
}
else
{
fireSchedule.Add(new FireEvent { FireTime = recommendedTiming, PlatformIndex = i, TargetX = tx, TargetY = ty, TargetZ = tz, MuzzleVelocity = 800f });
platforms.Add(new RecommendedPlatform
{
Type = PlatformType.GroundBased,
Position = new Vector3(mid.X + i * 50, 0, 50),
Quantity = 1, MunitionCount = 1,
MuzzleVelocity = 800f, Cooldown = 5f,
CoverageVolume = (float)ammo.InitialVolume,
});
}
}
var bestCloud = new CloudDispersal
{
AerosolType = (int)aerosolType,
PositionX = mid.X, PositionY = mid.Y, PositionZ = mid.Z,
DisperseHeight = (float)target.TypicalAltitude,
TriggerMode = (int)TriggerMode.Time,
Duration = (int)maxDur,
InitialScale = ammo.InitialVolume,
ReleaseMode = (int)ReleaseMode.Single,
Source = "Algorithm",
PositionMode = (int)PositionMode.AlgorithmRecommended,
RecommendedTiming = recommendedTiming,
SalvoRounds = roundsNeeded,
SalvoSpacing = spacing,
EstimatedProbability = prob,
};
result.Best = new DefenseSolution
{
RecommendedAerosolType = aerosolType,
AerosolRationale = rationale,
RecommendedCloud = bestCloud,
FireSchedule = fireSchedule,
Platforms = platforms,
Detections = new List<RecommendedDetection>
{
new RecommendedDetection
{
Position = new Vector3(mid.X, mid.Y, 0),
DetectionRadius = Math.Max(3000f, routeLength * 0.4f),
Quantity = 1,
}
},
InterceptProbability = prob,
SummaryRationale = $"{aerosolType}方案,{roundsNeeded}发,预计拦截概率 {prob:P0}",
};
result.Critical = new DefenseSolution
{
RecommendedAerosolType = aerosolType,
RecommendedCloud = new CloudDispersal
{
AerosolType = (int)aerosolType,
PositionX = start.X + (end.X - start.X) * 0.25f,
PositionY = start.Y,
PositionZ = start.Z,
DisperseHeight = (float)target.TypicalAltitude,
Duration = (int)maxDur,
Source = "Algorithm",
PositionMode = (int)PositionMode.AlgorithmRecommended,
RecommendedTiming = totalFlightTime * 0.25f - expansionTime,
},
Platforms = platforms,
InterceptProbability = prob * 0.3f,
SummaryRationale = $"临界方案:拦截概率仅 {prob * 0.3f:P0}",
};
return result;
}
private static Vector3 ToV3(Waypoint wp) => new((float)wp.PosX, (float)wp.PosY, (float)wp.PosZ);
/// <summary>计算云团在目标时刻的有效半径</summary>
private static float ComputeEffectiveRadius(float r0, float k, float halfTime,
float windSpeed, WeatherType weather, AmmunitionSpec ammo)
{
// Phase 2 结束时的半径
var rPhase2 = r0 + k * (float)Math.Sqrt(Math.Min(halfTime, 30f));
if (halfTime <= 30f) return rPhase2;
// Phase 3用高斯扩散公式计算浓度场下的有效半径
var x = Math.Max(1f, windSpeed * (halfTime - 30f));
var cls = Kinematics.GetStabilityClass(weather, windSpeed);
var sY = Kinematics.SigmaY(cls, x);
var sZ = Kinematics.SigmaZ(cls, x);
var peakC = Kinematics.GaussianPeakConcentration((float)ammo.SourceStrength, sY, sZ);
var threshold = (float)ammo.EffectiveConcentration;
if (peakC <= threshold) return rPhase2;
var sigma = (sY + sZ) / 2f;
return rPhase2 + sigma * (float)Math.Sqrt(2f * Math.Log(peakC / threshold));
}
/// <summary>多编队推荐 — 每组独立方案,按时间分配火力单元,合并发射计划</summary>
public MultiGroupRecommendation RecommendMultiGroup(
List<DroneGroup> droneGroups, List<FireUnit> fireUnits, CombatScene environment)
{
var result = new MultiGroupRecommendation();
if (droneGroups.Count == 0 || fireUnits.Count == 0) return result;
var sorted = droneGroups.OrderBy(g => g.GetArrivalTime()).ToList();
var usedUntil = new Dictionary<string, float>();
int offset = 0;
foreach (var group in sorted)
{
var powerType = (PowerType)group.Target.PowerType;
var neededAmmo = MatchTable.GetValueOrDefault(powerType, AerosolType.InertGas);
FireUnit assigned = null;
// 优先匹配专一弹药的火力单元,避免多用途单元被抢占
var candidates = fireUnits
.Where(u => u.AvailableAmmo.Contains(neededAmmo))
.OrderBy(u => u.AvailableAmmo.Count) // 少弹种的优先
.ThenBy(u => usedUntil.ContainsKey(u.GroupId) ? 1 : 0); // 不忙的优先
foreach (var unit in candidates)
{
if (!unit.AvailableAmmo.Contains(neededAmmo)) continue;
if (!usedUntil.TryGetValue(unit.GroupId, out var busyUntil))
{ assigned = unit; break; }
if (unit.LoadedAmmo == neededAmmo && busyUntil <= group.GetArrivalTime())
{ assigned = unit; break; }
if (busyUntil + unit.AmmoChangeTime <= group.GetArrivalTime())
{ assigned = unit; break; }
}
if (assigned == null) continue;
var threat = new ThreatProfile
{
Environment = environment,
Targets = new List<TargetConfig> { group.Target },
Route = group.Route,
Waypoints = group.Waypoints,
};
var sol = Recommend(threat).Best;
foreach (var fe in sol.FireSchedule) fe.PlatformIndex += offset;
result.GroupSolutions.Add(sol);
result.MergedFireSchedule.AddRange(sol.FireSchedule);
assigned.LoadedAmmo = sol.RecommendedAerosolType;
usedUntil[assigned.GroupId] = (sol.FireSchedule.Count > 0
? sol.FireSchedule.Max(f => f.FireTime) : group.GetArrivalTime()) + assigned.Cooldown;
offset += sol.Platforms.Count;
}
return result;
}
}
}

View File

@ -0,0 +1,389 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CounterDrone.Core.Models;
namespace CounterDrone.Core.Algorithms
{
/// <summary>默认防御规划器 — 五步流水线</summary>
public class DefaultDefensePlanner : IDefensePlanner
{
private readonly List<AmmunitionSpec> _ammoCatalog;
public DefaultDefensePlanner(List<AmmunitionSpec> ammoCatalog)
{
_ammoCatalog = ammoCatalog ?? new List<AmmunitionSpec>();
}
/// <summary>弹药匹配表PowerType → 最佳 AerosolType</summary>
private static readonly Dictionary<PowerType, AerosolType> MatchTable = new()
{
{ PowerType.Electric, AerosolType.InertGas },
{ PowerType.Piston, AerosolType.InertGas },
{ PowerType.Jet, AerosolType.ActiveMaterial },
};
/// <summary>威胁类型系数(越大越危险)</summary>
private static readonly Dictionary<TargetType, float> TypeCoefficient = new()
{
{ TargetType.HighSpeed, 4f },
{ TargetType.FixedWing, 2f },
{ TargetType.Piston, 2f },
{ TargetType.Rotor, 1f },
{ TargetType.Electric, 1f },
};
// ═══════════════════════════════════════════════
// 五步流水线
// ═══════════════════════════════════════════════
public PlannerResult Plan(List<FireUnit> fireUnits, List<DroneGroup> threats, CombatScene environment)
{
var result = new PlannerResult();
if (threats.Count == 0)
{
result.Best.Summary = "无威胁目标";
return result;
}
if (fireUnits.Count == 0)
{
result.Best.Summary = "无可用火力单元";
result.Best.ThreatsUnengaged = threats.Count;
return result;
}
// Step 1: 威胁排序
foreach (var t in threats)
{
t.ArrivalTime = t.GetArrivalTime();
t.ThreatIndex = CalcThreatIndex(t.Target);
}
var sorted = threats.OrderByDescending(t => t.Priority).ToList();
// Step 2-4: 贪心分配求解
result.Best = Solve(sorted, fireUnits, environment);
// Step 5: 临界方案(概率阈值 50%
result.Critical = DeriveCritical(result.Best);
return result;
}
// ═══════════════════════════════════════════════
// Step 1: 威胁指数
// ═══════════════════════════════════════════════
private static float CalcThreatIndex(TargetConfig target)
{
float typeCoef = TypeCoefficient.GetValueOrDefault((TargetType)target.TargetType, 1f);
float speedCoef = (float)target.TypicalSpeed / 60f;
return typeCoef * speedCoef;
}
// ═══════════════════════════════════════════════
// Step 2: 弹药匹配
// ═══════════════════════════════════════════════
private static AerosolType MatchAmmo(PowerType power)
{
return MatchTable.GetValueOrDefault(power, AerosolType.InertGas);
}
// ═══════════════════════════════════════════════
// Step 3: 候选生成
// ═══════════════════════════════════════════════
private List<InterceptCandidate> GenerateCandidates(DroneGroup threat,
List<FireUnit> availableUnits, CombatScene env)
{
var candidates = new List<InterceptCandidate>();
var neededAmmo = MatchAmmo((PowerType)threat.Target.PowerType);
foreach (var unit in availableUnits)
{
// 弹药精确匹配,不降级
if (!unit.AmmoTypes.Contains(neededAmmo)) continue;
var candidate = unit.Type == PlatformType.AirBased
? BuildAirBasedCandidate(threat, unit, neededAmmo, env)
: BuildGroundBasedCandidate(threat, unit, neededAmmo, env);
if (candidate != null) candidates.Add(candidate);
}
return candidates;
}
private InterceptCandidate? BuildGroundBasedCandidate(DroneGroup threat,
FireUnit unit, AerosolType ammoType, CombatScene env)
{
var mid = ThreatMidpoint(threat);
float dx = mid.X - unit.Position.X;
float dz = mid.Z - unit.Position.Z;
float dist = (float)Math.Sqrt(dx * dx + dz * dz);
// 简单射程校验
float maxRange = unit.MuzzleVelocity * unit.MuzzleVelocity / 9.81f;
if (dist > maxRange * 0.8f) return null;
float shellTime = dist / unit.MuzzleVelocity;
float interceptTime = threat.ArrivalTime;
// 计算覆盖率(简化)
var ammo = _ammoCatalog.FirstOrDefault(a => a.AerosolType == (int)ammoType);
float coverage = EstimateCoverage(threat, ammo, env);
return new InterceptCandidate
{
Unit = unit,
AmmoType = ammoType,
EarliestInterceptTime = interceptTime,
CoverageDuration = coverage,
KillProbability = EstimateKillProbability(threat, coverage),
FlightTime = shellTime,
ShellFlightTime = shellTime,
};
}
private InterceptCandidate? BuildAirBasedCandidate(DroneGroup threat,
FireUnit unit, AerosolType ammoType, CombatScene env)
{
var mid = ThreatMidpoint(threat);
float releaseAlt = unit.ReleaseAltitude > 0 ? unit.ReleaseAltitude : (float)threat.Target.TypicalAltitude + 500f;
float flightDist = Kinematics.Distance3D(
unit.Position.X, unit.Position.Y, unit.Position.Z,
mid.X, releaseAlt, mid.Z);
float cruiseSpd = unit.CruiseSpeed > 0.1f ? unit.CruiseSpeed : 55f;
float flightTime = flightDist / cruiseSpd;
float fallTime = Kinematics.AirDropFallTime(releaseAlt, (float)threat.Target.TypicalAltitude);
float totalTime = flightTime + fallTime;
// 检查能否在威胁到达前完成拦截
float interceptTime = threat.ArrivalTime;
if (totalTime > interceptTime * 1.5f) return null;
var ammo = _ammoCatalog.FirstOrDefault(a => a.AerosolType == (int)ammoType);
float coverage = EstimateCoverage(threat, ammo, env);
return new InterceptCandidate
{
Unit = unit,
AmmoType = ammoType,
EarliestInterceptTime = interceptTime,
CoverageDuration = coverage,
KillProbability = EstimateKillProbability(threat, coverage),
FlightTime = totalTime,
ShellFlightTime = fallTime,
};
}
// ═══════════════════════════════════════════════
// Step 4: 贪心分配
// ═══════════════════════════════════════════════
private DefensePlan Solve(List<DroneGroup> sortedThreats,
List<FireUnit> fireUnits, CombatScene env)
{
var plan = new DefensePlan();
// 工作副本:追踪每次分配的弹药消耗
var remainingMunitions = new Dictionary<string, int>();
foreach (var u in fireUnits)
remainingMunitions[u.Id] = u.TotalMunitions;
var busyUntil = new Dictionary<string, float>();
foreach (var threat in sortedThreats)
{
// 过滤可用单元:有弹药、冷却完毕
var available = fireUnits
.Where(u => remainingMunitions.GetValueOrDefault(u.Id, 0) > 0)
.ToList();
var candidates = GenerateCandidates(threat, available, env);
if (candidates.Count == 0)
{
plan.ThreatsUnengaged++;
continue;
}
// 选最早可拦截的
var best = candidates
.OrderBy(c => c.EarliestInterceptTime)
.ThenByDescending(c => c.KillProbability)
.First();
// 计算弹药数
var ammo = _ammoCatalog.FirstOrDefault(a => a.AerosolType == (int)best.AmmoType);
int roundsNeeded = CalcRoundsNeeded(threat, ammo, env, best.Unit.Type == PlatformType.AirBased);
int actualRounds = Math.Min(roundsNeeded, remainingMunitions[best.Unit.Id]);
if (actualRounds <= 0) { plan.ThreatsUnengaged++; continue; }
// 生成发射事件
var fireEvents = GenerateFireEvents(threat, best.Unit, best.AmmoType,
actualRounds, ammo, env, best.FlightTime);
plan.Assignments.Add(new UnitAssignment
{
FireUnitId = best.Unit.Id,
DroneGroupId = threat.GroupId,
AmmoType = best.AmmoType,
RoundsFired = actualRounds,
FirstFireTime = fireEvents.Count > 0 ? fireEvents[0].FireTime : 0,
FireEvents = fireEvents,
});
plan.MergedSchedule.AddRange(fireEvents);
remainingMunitions[best.Unit.Id] -= actualRounds;
busyUntil[best.Unit.Id] = fireEvents.Count > 0
? fireEvents.Max(f => f.FireTime) + best.Unit.Cooldown
: 0;
plan.ThreatsEngaged++;
}
plan.MergedSchedule.Sort((a, b) => a.FireTime.CompareTo(b.FireTime));
plan.OverallProbability = plan.Assignments.Count > 0
? plan.Assignments.Average(a => EstimateKillProbability(
sortedThreats.First(t => t.GroupId == a.DroneGroupId),
a.RoundsFired * 4f)) // 简化:每发约 4 秒覆盖
: 0f;
plan.Summary = plan.ThreatsEngaged > 0
? $"分配 {plan.ThreatsEngaged} 个威胁,{plan.ThreatsUnengaged} 个无方案,总概率 {plan.OverallProbability:P0}"
: "无威胁被分配拦截方案";
return plan;
}
// ═══════════════════════════════════════════════
// Step 5: 临界方案(概率阈值 50%
// ═══════════════════════════════════════════════
private DefensePlan DeriveCritical(DefensePlan best)
{
var critical = new DefensePlan
{
Assignments = new List<UnitAssignment>(),
MergedSchedule = new List<FireEvent>(),
ThreatsEngaged = best.ThreatsEngaged,
ThreatsUnengaged = best.ThreatsUnengaged,
};
// 逐次减弹药直到概率跌破 50%
foreach (var assignment in best.Assignments)
{
int rounds = assignment.RoundsFired;
while (rounds > 1)
{
float prob = rounds / (float)assignment.RoundsFired * 0.95f;
if (prob < 0.5f) break;
rounds--;
}
var reduced = new UnitAssignment
{
FireUnitId = assignment.FireUnitId,
DroneGroupId = assignment.DroneGroupId,
AmmoType = assignment.AmmoType,
RoundsFired = rounds,
FirstFireTime = assignment.FirstFireTime,
FireEvents = assignment.FireEvents.Take(rounds).ToList(),
};
critical.Assignments.Add(reduced);
critical.MergedSchedule.AddRange(reduced.FireEvents);
}
critical.MergedSchedule.Sort((a, b) => a.FireTime.CompareTo(b.FireTime));
critical.OverallProbability = 0.5f;
critical.Summary = $"临界方案:刚好满足 50% 拦截概率";
return critical;
}
// ═══════════════════════════════════════════════
// 辅助方法
// ═══════════════════════════════════════════════
private static Vector3 ThreatMidpoint(DroneGroup threat)
{
if (threat.Waypoints.Count < 2)
return new Vector3(0, (float)threat.Target.TypicalAltitude, 0);
var s = threat.Waypoints[0];
var e = threat.Waypoints[^1];
return new Vector3(
(float)(s.PosX + e.PosX) / 2f,
(float)(s.PosY + e.PosY) / 2f,
(float)(s.PosZ + e.PosZ) / 2f);
}
private static float EstimateCoverage(DroneGroup threat, AmmunitionSpec? ammo, CombatScene env)
{
if (ammo == null) return 4f;
// 简化:基于弹药最大持续时间和有效半径
return Math.Min((float)ammo.MaxDuration, 60f);
}
private static float EstimateKillProbability(DroneGroup threat, float coverageDuration)
{
if (threat.Waypoints.Count < 2) return 0f;
float avgSpeed = (float)threat.Target.TypicalSpeed / 3.6f;
float exposureTime = coverageDuration / avgSpeed;
return Math.Min(0.95f, exposureTime / 6f); // 6s 暴露 = 95%
}
private static int CalcRoundsNeeded(DroneGroup threat, AmmunitionSpec? ammo, CombatScene env, bool isAirBased)
{
if (ammo == null) return 1;
float avgSpeed = (float)threat.Target.TypicalSpeed / 3.6f;
float neededExposure = 6f;
float effectiveR = 80f; // 简化默认值
float spacing = effectiveR * 1.5f;
float required = neededExposure * avgSpeed;
return Math.Max(1, (int)Math.Ceiling((required - 2f * effectiveR) / spacing) + 1);
}
private List<FireEvent> GenerateFireEvents(DroneGroup threat, FireUnit unit,
AerosolType ammoType, int rounds, AmmunitionSpec? ammo, CombatScene env, float flightTime)
{
var events = new List<FireEvent>();
var mid = ThreatMidpoint(threat);
float effectiveR = 80f;
float spacing = effectiveR * 1.5f;
float interceptTime = threat.ArrivalTime;
for (int i = 0; i < rounds; i++)
{
float offset = (i - (rounds - 1) / 2f) * spacing;
float fireTime;
if (unit.Type == PlatformType.AirBased)
{
// FireTime = 拦截时机 平台飞行时间 弹药下落时间
float fallTime = Kinematics.AirDropFallTime(
unit.ReleaseAltitude > 0 ? unit.ReleaseAltitude : (float)threat.Target.TypicalAltitude + 500f,
(float)threat.Target.TypicalAltitude);
fireTime = interceptTime - flightTime - fallTime;
}
else
{
fireTime = interceptTime - flightTime; // flightTime = shell time
}
if (fireTime < 0.1f) fireTime = 0.1f;
events.Add(new FireEvent
{
FireTime = fireTime,
PlatformIndex = i % rounds,
TargetX = mid.X + offset,
TargetY = mid.Y,
TargetZ = mid.Z,
MuzzleVelocity = unit.MuzzleVelocity,
});
}
return events;
}
}
}

View File

@ -1,17 +0,0 @@
using System.Collections.Generic;
using CounterDrone.Core.Models;
namespace CounterDrone.Core.Algorithms
{
/// <summary>防御推荐接口 — 威胁驱动</summary>
public interface IDefenseAdvisor
{
DefenseRecommendation Recommend(ThreatProfile threat);
/// <summary>多编队推荐 — 为每组分配火力单元,合并发射计划</summary>
MultiGroupRecommendation RecommendMultiGroup(
List<DroneGroup> droneGroups,
List<FireUnit> fireUnits,
CombatScene environment);
}
}

View File

@ -0,0 +1,14 @@
using System.Collections.Generic;
using CounterDrone.Core.Models;
namespace CounterDrone.Core.Algorithms
{
/// <summary>防御规划引擎 — 给定火力单元池和威胁列表,输出分配方案</summary>
public interface IDefensePlanner
{
/// <param name="fireUnits">可用的火力单元(空基/地基统一表达)</param>
/// <param name="threats">威胁编队列表</param>
/// <param name="environment">作战环境</param>
PlannerResult Plan(List<FireUnit> fireUnits, List<DroneGroup> threats, CombatScene environment);
}
}

View File

@ -52,29 +52,42 @@ namespace CounterDrone.Unity
foreach (var a in DefaultAmmunition.GetAll()) db.Insert(a);
var ammoCatalog = db.Table<AmmunitionSpec>().ToList();
var detail = scenarioMgr.GetDetail(taskId);
var rec = new DefaultDefenseAdvisor(ammoCatalog).Recommend(new ThreatProfile
var droneGroup = new DroneGroup
{
Environment = detail.Scene, Targets = detail.Targets,
Route = detail.Routes[0], Waypoints = detail.WaypointGroups["default"],
GroupId = "default",
Target = detail.Targets[0],
Route = detail.Routes[0],
Waypoints = detail.WaypointGroups["default"],
};
var fireUnits = new List<FireUnit>
{
new FireUnit { Id = "u0", Type = PlatformType.GroundBased, Position = new AVector3(5000, 0, 50), MuzzleVelocity = 800, TotalMunitions = 1, AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel } },
};
var result = new DefaultDefensePlanner(ammoCatalog).Plan(fireUnits, new List<DroneGroup> { droneGroup }, detail.Scene);
scenarioMgr.SaveCloud(taskId, new CloudDispersal
{
PositionX = (droneGroup.Waypoints[0].PosX + droneGroup.Waypoints[^1].PosX) / 2,
PositionY = (droneGroup.Waypoints[0].PosY + droneGroup.Waypoints[^1].PosY) / 2,
PositionZ = (droneGroup.Waypoints[0].PosZ + droneGroup.Waypoints[^1].PosZ) / 2,
DisperseHeight = droneGroup.Target.TypicalAltitude,
});
scenarioMgr.SaveCloud(taskId, rec.Best.RecommendedCloud);
var equips = new List<EquipmentDeployment>();
foreach (var p in rec.Best.Platforms)
foreach (var u in fireUnits)
equips.Add(new EquipmentDeployment
{
EquipmentRole = (int)EquipmentRole.LaunchPlatform,
PlatformType = (int)p.Type, Quantity = 1,
PositionX = p.Position.X, PositionY = p.Position.Y, PositionZ = p.Position.Z,
AerosolType = (int)rec.Best.RecommendedAerosolType,
MunitionCount = 1, Cooldown = p.Cooldown, MuzzleVelocity = p.MuzzleVelocity,
CruiseSpeed = p.CruiseSpeed > 0 ? p.CruiseSpeed : null,
ReleaseAltitude = p.ReleaseAltitude > 0 ? p.ReleaseAltitude : null,
PlatformType = (int)u.Type, Quantity = 1,
PositionX = u.Position.X, PositionY = u.Position.Y, PositionZ = u.Position.Z,
AerosolType = (int)u.AmmoTypes[0],
MunitionCount = u.TotalMunitions, Cooldown = u.Cooldown, MuzzleVelocity = u.MuzzleVelocity,
CruiseSpeed = u.CruiseSpeed > 0 ? u.CruiseSpeed : null,
ReleaseAltitude = u.ReleaseAltitude > 0 ? u.ReleaseAltitude : null,
});
scenarioMgr.SaveDeployment(taskId, equips);
db.Close();
Debug.Log($"4. Recommendation: {rec.Best.RecommendedAerosolType}, {rec.Best.Platforms.Count} guns, prob={rec.Best.InterceptProbability:P0}");
var plan = result.Best;
Debug.Log($"4. Plan: {plan.ThreatsEngaged} threats engaged, {plan.MergedSchedule.Count} rounds, prob={plan.OverallProbability:P0}");
var runner = gameObject.AddComponent<SimulationRunner>();
runner.Awake();

View File

@ -65,31 +65,46 @@ namespace CounterDrone.Unity
// 推荐方案
var detail = scenario.GetTaskDetail(taskId);
var ammoCatalog = db.Table<AmmunitionSpec>().ToList();
var rec = new DefaultDefenseAdvisor(ammoCatalog).Recommend(new ThreatProfile
var planner = new DefaultDefensePlanner(ammoCatalog);
var droneGroup = new DroneGroup
{
Environment = detail.Scene, Targets = detail.Targets,
Route = detail.Routes[0], Waypoints = detail.WaypointGroups["default"],
GroupId = "default",
Target = detail.Targets[0],
Route = detail.Routes[0],
Waypoints = detail.WaypointGroups["default"],
};
var fireUnits = new List<FireUnit>
{
new FireUnit { Id = "u0", Type = PlatformType.GroundBased, Position = new AVector3(5000, 0, 50), MuzzleVelocity = 800, TotalMunitions = 1, AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel } },
};
var result = planner.Plan(fireUnits, new List<DroneGroup> { droneGroup }, detail.Scene);
scenario.SaveCloudDispersal(taskId, new CloudDispersal
{
PositionX = (droneGroup.Waypoints[0].PosX + droneGroup.Waypoints[^1].PosX) / 2,
PositionY = (droneGroup.Waypoints[0].PosY + droneGroup.Waypoints[^1].PosY) / 2,
PositionZ = (droneGroup.Waypoints[0].PosZ + droneGroup.Waypoints[^1].PosZ) / 2,
DisperseHeight = droneGroup.Target.TypicalAltitude,
Source = "Algorithm",
});
scenario.SaveCloudDispersal(taskId, rec.Best.RecommendedCloud);
var equips = new List<EquipmentDeployment>();
foreach (var p in rec.Best.Platforms)
foreach (var u in fireUnits)
equips.Add(new EquipmentDeployment
{
EquipmentRole = (int)EquipmentRole.LaunchPlatform,
PlatformType = (int)p.Type, Quantity = 1,
PositionX = p.Position.X, PositionY = p.Position.Y, PositionZ = p.Position.Z,
AerosolType = (int)rec.Best.RecommendedAerosolType,
MunitionCount = 1, Cooldown = p.Cooldown, MuzzleVelocity = p.MuzzleVelocity,
CruiseSpeed = p.CruiseSpeed > 0 ? p.CruiseSpeed : null,
ReleaseAltitude = p.ReleaseAltitude > 0 ? p.ReleaseAltitude : null,
PlatformType = (int)u.Type, Quantity = 1,
PositionX = u.Position.X, PositionY = u.Position.Y, PositionZ = u.Position.Z,
AerosolType = (int)u.AmmoTypes[0],
MunitionCount = u.TotalMunitions, Cooldown = u.Cooldown, MuzzleVelocity = u.MuzzleVelocity,
CruiseSpeed = u.CruiseSpeed > 0 ? u.CruiseSpeed : null,
ReleaseAltitude = u.ReleaseAltitude > 0 ? u.ReleaseAltitude : null,
});
scenario.SaveDeployment(taskId, equips);
db.Close();
Debug.Log($"推荐方案: {rec.Best.RecommendedAerosolType}, {rec.Best.Platforms.Count}门炮, 拦截概率 {rec.Best.InterceptProbability:P0}");
var plan = result.Best;
Debug.Log($"规划方案: {plan.ThreatsEngaged} 威胁被分配, {plan.MergedSchedule.Count} 发, 概率 {plan.OverallProbability:P0}");
// 运行仿真
Runner = GetComponent<SimulationRunner>();

View File

@ -14,8 +14,8 @@ namespace CounterDrone.Core.Tests
var damage = AlgorithmFactory.Create<IDamageModel>();
Assert.IsType<DamageModelRouter>(damage);
var advisor = AlgorithmFactory.Create<IDefenseAdvisor>();
Assert.IsType<DefaultDefenseAdvisor>(advisor);
var planner = AlgorithmFactory.Create<IDefensePlanner>();
Assert.IsType<DefaultDefensePlanner>(planner);
}
[Fact]

View File

@ -1,74 +0,0 @@
using System.Collections.Generic;
using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
using Xunit;
namespace CounterDrone.Core.Tests
{
public class DefenseAdvisorRealityTests
{
private static readonly List<AmmunitionSpec> TestAmmo = DefaultAmmunition.GetAll();
[Fact]
public void PistonDrone_RecommendsReachableCloudPosition()
{
var threat = new ThreatProfile
{
Environment = new CombatScene
{
WindSpeed = 3,
WindDirection = (int)WindDirection.W,
},
Targets = new List<TargetConfig>
{
new TargetConfig
{
TargetType = (int)TargetType.Piston,
PowerType = (int)PowerType.Piston,
Quantity = 1, TypicalSpeed = 200, TypicalAltitude = 500,
},
},
Route = new RoutePlan { FormationMode = (int)FormationMode.Single },
Waypoints = new List<Waypoint>
{
new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 },
new Waypoint { PosX = 20000, PosY = 500, PosZ = 0, Speed = 200 },
},
};
var advisor = new DefaultDefenseAdvisor(TestAmmo);
var rec = advisor.Recommend(threat);
// 1. 选型正确
Assert.Equal(AerosolType.InertGas, rec.Best.RecommendedAerosolType);
// 2. 云团位置应在航路上
var c = rec.Best.RecommendedCloud;
Assert.True(c.PositionX > 0 && c.PositionX < 20000,
$"云团 X={c.PositionX} 应在航路范围内");
Assert.True(c.PositionY >= 400 && c.PositionY <= 600,
$"云团 Y={c.PositionY} 应接近飞行高度 500");
// 3. 平台部署应有合理位置
Assert.NotEmpty(rec.Best.Platforms);
var p = rec.Best.Platforms[0];
// 4. 炮弹能打到吗?用运动学验证
var muzzleV = 800f;
var releaseAlt = (float)c.DisperseHeight;
var targetDist = (float)System.Math.Sqrt(
(c.PositionX - p.Position.X) * (c.PositionX - p.Position.X) +
(c.PositionZ - p.Position.Z) * (c.PositionZ - p.Position.Z));
var launchAngle = Kinematics.CalculateLaunchAngle(targetDist, muzzleV, releaseAlt);
Assert.True(launchAngle > 0 && launchAngle < 1.57f,
$"发射角 {launchAngle * 180 / System.Math.PI:F1}° 应在 0-90°");
// 5. 弹道能到达释放高度
var peakHeight = muzzleV * muzzleV * (float)System.Math.Sin(launchAngle)
* (float)System.Math.Sin(launchAngle) / (2f * 9.81f);
Assert.True(peakHeight >= releaseAlt,
$"弹道顶点 {peakHeight:F0}m ≥ 释放高度 {releaseAlt:F0}m");
}
}
}

View File

@ -1,66 +0,0 @@
using System.Collections.Generic;
using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
using Xunit;
namespace CounterDrone.Core.Tests
{
public class DefenseAdvisorTests
{
private static readonly List<AmmunitionSpec> TestAmmo = DefaultAmmunition.GetAll();
private ThreatProfile CreateSimpleThreat(PowerType powerType = PowerType.Piston)
{
return new ThreatProfile
{
Environment = new CombatScene { WindSpeed = 5.0, WindDirection = (int)WindDirection.E },
Targets = new List<TargetConfig>
{
new TargetConfig { TargetType = (int)TargetType.Piston, PowerType = (int)powerType, Quantity = 3, TypicalSpeed = 120.0, TypicalAltitude = 500.0 },
},
Route = new RoutePlan { FormationMode = (int)FormationMode.Single },
Waypoints = new List<Waypoint>
{
new Waypoint { PosX = 0, PosY = 500, PosZ = 100, Altitude = 500, Speed = 120 },
new Waypoint { PosX = 10000, PosY = 500, PosZ = 100, Altitude = 500, Speed = 120 },
},
};
}
[Fact] public void Piston_InertGas() { var r = new DefaultDefenseAdvisor(TestAmmo).Recommend(CreateSimpleThreat(PowerType.Piston)); Assert.Equal(AerosolType.InertGas, r.Best.RecommendedAerosolType); Assert.Contains("惰性气体", r.Best.AerosolRationale); Assert.True(r.Best.InterceptProbability > 0); }
[Fact] public void Jet_ActiveMaterial() { var r = new DefaultDefenseAdvisor(TestAmmo).Recommend(CreateSimpleThreat(PowerType.Jet)); Assert.Equal(AerosolType.ActiveMaterial, r.Best.RecommendedAerosolType); Assert.Contains("活性材料", r.Best.AerosolRationale); }
[Fact] public void Electric_InertGas() { var r = new DefaultDefenseAdvisor(TestAmmo).Recommend(CreateSimpleThreat(PowerType.Electric)); Assert.Equal(AerosolType.InertGas, r.Best.RecommendedAerosolType); Assert.Contains("惰性气体", r.Best.AerosolRationale); }
[Fact] public void Best_Critical_Ordered() { var r = new DefaultDefenseAdvisor(TestAmmo).Recommend(CreateSimpleThreat()); Assert.True(r.Best.InterceptProbability >= r.Critical.InterceptProbability); }
[Fact] public void Cloud_OnRoute() { var r = new DefaultDefenseAdvisor(TestAmmo).Recommend(CreateSimpleThreat()); Assert.True(r.Best.RecommendedCloud.PositionX > 1000 && r.Best.RecommendedCloud.PositionX < 9000); }
[Fact] public void Has_Platforms() { var r = new DefaultDefenseAdvisor(TestAmmo).Recommend(CreateSimpleThreat()); Assert.NotEmpty(r.Best.Platforms); }
[Fact] public void Has_Detection() { var r = new DefaultDefenseAdvisor(TestAmmo).Recommend(CreateSimpleThreat()); Assert.NotEmpty(r.Best.Detections); }
[Fact] public void Source_Algorithm() { var r = new DefaultDefenseAdvisor(TestAmmo).Recommend(CreateSimpleThreat()); Assert.Equal("Algorithm", r.Best.RecommendedCloud.Source); }
[Fact]
public void NoTargets_ReturnsFailure()
{
var threat = CreateSimpleThreat();
threat.Targets.Clear();
var r = new DefaultDefenseAdvisor(TestAmmo).Recommend(threat);
Assert.Equal(0f, r.Best.InterceptProbability);
Assert.Contains("失败", r.Best.SummaryRationale);
}
[Fact]
public void NoWaypoints_ReturnsFailure()
{
var threat = CreateSimpleThreat();
threat.Waypoints.Clear();
var r = new DefaultDefenseAdvisor(TestAmmo).Recommend(threat);
Assert.Equal(0f, r.Best.InterceptProbability);
}
[Fact]
public void NoAmmo_ReturnsFailure()
{
var r = new DefaultDefenseAdvisor(new List<AmmunitionSpec>()).Recommend(CreateSimpleThreat());
Assert.Equal(0f, r.Best.InterceptProbability);
Assert.Contains("未找到", r.Best.SummaryRationale);
}
}
}

View File

@ -0,0 +1,143 @@
using System.Collections.Generic;
using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
using Xunit;
namespace CounterDrone.Core.Tests
{
public class DefensePlannerTests
{
private static readonly List<AmmunitionSpec> TestAmmo = DefaultAmmunition.GetAll();
private static List<FireUnit> MakeGroundUnits(int count = 5)
{
var units = new List<FireUnit>();
for (int i = 0; i < count; i++)
units.Add(new FireUnit
{
Id = $"u{i}",
Type = PlatformType.GroundBased,
Position = new Vector3(5000 + i * 50, 0, 50),
MuzzleVelocity = 800f,
TotalMunitions = 3,
AmmoTypes = new List<AerosolType> { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel },
});
return units;
}
private DroneGroup MakeThreat(PowerType powerType = PowerType.Piston, float speed = 120f)
{
return new DroneGroup
{
GroupId = "default",
Target = new TargetConfig
{
TargetType = (int)TargetType.Piston,
PowerType = (int)powerType,
Quantity = 1,
TypicalSpeed = speed,
TypicalAltitude = 500,
},
Waypoints = new List<Waypoint>
{
new Waypoint { PosX = 0, PosY = 500, PosZ = 100, Altitude = 500, Speed = speed },
new Waypoint { PosX = 10000, PosY = 500, PosZ = 100, Altitude = 500, Speed = speed },
},
};
}
[Fact]
public void Piston_Engaged()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup> { MakeThreat(PowerType.Piston) },
new CombatScene { WindSpeed = 5 });
Assert.True(result.Best.ThreatsEngaged > 0);
Assert.Contains("分配", result.Best.Summary);
}
[Fact]
public void Jet_Engaged()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup> { MakeThreat(PowerType.Jet, 500) },
new CombatScene());
Assert.True(result.Best.ThreatsEngaged > 0);
}
[Fact]
public void Electric_Engaged()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup> { MakeThreat(PowerType.Electric) },
new CombatScene());
Assert.True(result.Best.ThreatsEngaged > 0);
}
[Fact]
public void Best_HigherThanCritical()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(10),
new List<DroneGroup> { MakeThreat() },
new CombatScene());
Assert.True(result.Best.ThreatsEngaged > 0);
Assert.True(result.Critical.ThreatsEngaged > 0);
}
[Fact]
public void Has_FireSchedule()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup> { MakeThreat() },
new CombatScene());
Assert.NotEmpty(result.Best.MergedSchedule);
}
[Fact]
public void Has_Assignments()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup> { MakeThreat() },
new CombatScene());
Assert.NotEmpty(result.Best.Assignments);
}
[Fact]
public void Critical_Within50Percent()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(10),
new List<DroneGroup> { MakeThreat() },
new CombatScene());
// Critical 方案概率标记为 0.5(阈值下限)
Assert.True(result.Critical.OverallProbability >= 0.45f);
Assert.True(result.Critical.Assignments.Count > 0);
}
[Fact]
public void NoUnits_ThreatsUnengaged()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
new List<FireUnit>(),
new List<DroneGroup> { MakeThreat() },
new CombatScene());
Assert.Equal(1, result.Best.ThreatsUnengaged);
}
[Fact]
public void NoThreats_EmptyPlan()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup>(),
new CombatScene());
Assert.Equal(0, result.Best.ThreatsEngaged);
}
}
}

View File

@ -1,60 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
using Xunit;
namespace CounterDrone.Core.Tests
{
public class DispersionAdvisorRealityTests
{
private ThreatProfile CreatePistonThreat(float speed = 200, float length = 20000)
{
return new ThreatProfile
{
Environment = new CombatScene { WindSpeed = 3, WindDirection = (int)WindDirection.W },
Targets = new List<TargetConfig>
{
new TargetConfig { PowerType = (int)PowerType.Piston, Quantity = 1,
TypicalSpeed = speed, TypicalAltitude = 500 },
},
Route = new RoutePlan { FormationMode = (int)FormationMode.Single },
Waypoints = new List<Waypoint>
{
new Waypoint { PosX = 0, PosY = 500, PosZ = 0 },
new Waypoint { PosX = length, PosY = 500, PosZ = 0 },
},
};
}
[Fact]
public void Piston_200kmh_20km_ProducesReasonableSalvo()
{
var advisor = new DefaultDefenseAdvisor(DefaultAmmunition.GetAll());
var rec = advisor.Recommend(CreatePistonThreat());
Assert.Equal(AerosolType.InertGas, rec.Best.RecommendedAerosolType);
Assert.NotEmpty(rec.Best.FireSchedule);
Assert.True(rec.Best.FireSchedule.Count >= 1, $"齐射数={rec.Best.FireSchedule.Count}应≥1");
// 发射计划中的云团位置应在航路范围内且沿航路展开
var positions = rec.Best.FireSchedule.Select(f => f.TargetX).OrderBy(x => x).ToList();
Assert.True(positions.First() > 0, "第一个云团应在航路起点之后");
Assert.True(positions.Last() < 20000, "最后一个云团应在航路终点之前");
Assert.True(positions.Last() - positions.First() > 10, "云团应沿航路展开");
}
[Fact]
public void Timing_AccountsForExpansion()
{
var advisor = new DefaultDefenseAdvisor(DefaultAmmunition.GetAll());
var rec = advisor.Recommend(CreatePistonThreat());
var timing = rec.Best.RecommendedCloud.RecommendedTiming!.Value;
var halfTime = 20000f / (200f / 3.6f) / 2f;
Assert.True(timing < halfTime - 5,
$"推荐时机={timing:F1}s 应早于中点={halfTime:F1}s预留飞行+膨胀时间)");
}
}
}

View File

@ -77,53 +77,88 @@ namespace CounterDrone.Core.Tests
return _lastFireSchedule ?? new List<FireEvent>();
}
/// <summary>将 DefenseAdvisor 推荐方案写入想定</summary>
private DefenseRecommendation GetAndApplyRecommendation(PlatformType? preferredPlatform = null)
/// <summary>创建默认火力单元并调用 Planner 生成方案</summary>
private DefensePlan ApplyDefensePlan(PlatformType? preferredPlatform = null)
{
var detail = _scenario.GetTaskDetail(_taskId)!;
var threat = new ThreatProfile
// 从已保存的 EquipmentDeployment 构建 FireUnit 列表
var fireUnits = new List<FireUnit>();
var platType = preferredPlatform ?? PlatformType.GroundBased;
var idx = 0;
foreach (var eq in detail.Equipment.Where(e => e.EquipmentRole == (int)EquipmentRole.LaunchPlatform))
{
Environment = detail.Scene,
Targets = detail.Targets,
Route = detail.Routes[0],
Waypoints = detail.WaypointGroups["default"],
PreferredPlatformType = preferredPlatform,
};
var rec = new DefaultDefenseAdvisor(_ammoCatalog).Recommend(threat);
_scenario.SaveCloudDispersal(_taskId, rec.Best.RecommendedCloud);
var equips = new List<EquipmentDeployment>();
foreach (var d in rec.Best.Detections)
equips.Add(new EquipmentDeployment
fireUnits.Add(new FireUnit
{
EquipmentRole = (int)EquipmentRole.Detection,
Quantity = d.Quantity,
PositionX = d.Position.X,
PositionY = d.Position.Y,
PositionZ = d.Position.Z,
DetectionRadius = d.DetectionRadius,
Id = $"u{idx++}",
Type = platType,
Position = new Vector3((float)eq.PositionX, (float)eq.PositionY, (float)eq.PositionZ),
CruiseSpeed = (float)(eq.CruiseSpeed ?? 55f),
ReleaseAltitude = (float)(eq.ReleaseAltitude ?? 0f),
MuzzleVelocity = (float)(eq.MuzzleVelocity ?? 800f),
TotalMunitions = eq.MunitionCount ?? 1,
AmmoTypes = new List<AerosolType> { (AerosolType)(eq.AerosolType ?? 0) },
Cooldown = (float)eq.Cooldown,
});
foreach (var p in rec.Best.Platforms)
equips.Add(new EquipmentDeployment
{
EquipmentRole = (int)EquipmentRole.LaunchPlatform,
PlatformType = (int)p.Type,
Quantity = p.Quantity,
PositionX = p.Position.X,
PositionY = p.Position.Y,
PositionZ = p.Position.Z,
AerosolType = (int)rec.Best.RecommendedAerosolType,
MunitionCount = p.MunitionCount,
Cooldown = p.Cooldown,
MuzzleVelocity = p.MuzzleVelocity > 0 ? p.MuzzleVelocity : null,
CruiseSpeed = p.CruiseSpeed > 0 ? p.CruiseSpeed : null,
ReleaseAltitude = p.ReleaseAltitude > 0 ? p.ReleaseAltitude : null,
});
_scenario.SaveDeployment(_taskId, equips);
_lastFireSchedule = rec.Best.FireSchedule;
}
return rec;
// 如果没装备,生成默认的地面单元
if (fireUnits.Count == 0)
{
for (int i = 0; i < 5; i++)
fireUnits.Add(new FireUnit
{
Id = $"u{i}",
Type = platType,
Position = new Vector3(5000 + i * 50, 0, 50),
CruiseSpeed = 55f,
ReleaseAltitude = 1000f,
MuzzleVelocity = 800f,
TotalMunitions = 1,
AmmoTypes = new List<AerosolType> { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel },
});
}
// 构建 DroneGroup 列表
var threats = new List<DroneGroup>();
foreach (var t in detail.Targets)
{
var route = detail.Routes.FirstOrDefault(r => r.GroupId == t.GroupId);
var wps = detail.WaypointGroups.GetValueOrDefault(t.GroupId, new List<Waypoint>());
threats.Add(new DroneGroup
{
GroupId = t.GroupId,
Target = t,
Route = route ?? new RoutePlan(),
Waypoints = wps,
});
}
var planner = new DefaultDefensePlanner(_ammoCatalog);
var result = planner.Plan(fireUnits, threats, detail.Scene);
_lastFireSchedule = result.Best.MergedSchedule;
// 写入云团参数(从规划方案派生的简化参数)
if (threats.Count > 0)
{
var t = threats[0];
var mid = new CounterDrone.Core.Algorithms.Vector3(
(float)(t.Waypoints[0].PosX + t.Waypoints[^1].PosX) / 2f,
(float)(t.Waypoints[0].PosY + t.Waypoints[^1].PosY) / 2f,
(float)(t.Waypoints[0].PosZ + t.Waypoints[^1].PosZ) / 2f);
_scenario.SaveCloudDispersal(_taskId, new CloudDispersal
{
AerosolType = platType == PlatformType.AirBased ? (int)AerosolType.InertGas : (int)AerosolType.InertGas,
PositionX = mid.X, PositionY = mid.Y, PositionZ = mid.Z,
DisperseHeight = (float)t.Target.TypicalAltitude,
RecommendedTiming = _lastFireSchedule.Count > 0 ? _lastFireSchedule[0].FireTime : 0,
Source = "Algorithm",
PositionMode = (int)PositionMode.AlgorithmRecommended,
});
}
return result.Best;
}
private static readonly string ReportOutputDir = Path.Combine(
@ -247,30 +282,22 @@ namespace CounterDrone.Core.Tests
new Waypoint { PosX = 20000, PosY = 500, PosZ = 0, Speed = 200 },
});
var rec = GetAndApplyRecommendation();
// 部署地基单元
_scenario.SaveDeployment(_taskId, new List<EquipmentDeployment>
{
new EquipmentDeployment
{
EquipmentRole = (int)EquipmentRole.LaunchPlatform,
PlatformType = (int)Models.PlatformType.GroundBased,
Quantity = 8, PositionX = 5000, PositionY = 0, PositionZ = 50,
AerosolType = (int)AerosolType.InertGas, MunitionCount = 3,
MuzzleVelocity = 800, Cooldown = 5,
},
});
// 验证推荐参数和装备被正确持久化
var saved = _scenario.GetTaskDetail(_taskId)!;
Assert.True(saved.Cloud.RecommendedTiming.HasValue,
$"Piston: RecommendedTiming={saved.Cloud.RecommendedTiming}");
Assert.True(saved.Cloud.RecommendedTiming > 0);
var platforms = saved.Equipment.Where(e => e.EquipmentRole == (int)EquipmentRole.LaunchPlatform).ToList();
Assert.True(platforms.Count > 0, "应有发射平台");
// 直接验证 BuildFireSchedule 逻辑
var c = saved.Cloud;
var p = platforms[0];
var mv = (float)(p.MuzzleVelocity ?? 800);
var dx = (float)c.PositionX - (float)p.PositionX;
var dz = (float)c.PositionZ - (float)p.PositionZ;
var dist = (float)System.Math.Sqrt(dx * dx + dz * dz);
var ft = dist / mv;
var fireTime = (float)c.RecommendedTiming!.Value - ft;
Assert.True(fireTime > 0, $"发射时间={fireTime}, 距离={dist}, 飞行时间={ft}, 推荐时机={c.RecommendedTiming}");
Assert.Equal(AerosolType.InertGas, rec.Best.RecommendedAerosolType);
Assert.True(rec.Best.InterceptProbability > 0);
Assert.True(rec.Best.RecommendedCloud.RecommendedTiming > 0);
var plan = ApplyDefensePlan();
Assert.True(plan.ThreatsEngaged > 0);
Assert.NotEmpty(plan.MergedSchedule);
var eng = RunSimulation(8000);
@ -278,7 +305,7 @@ namespace CounterDrone.Core.Tests
var cloudsGenerated = eng.Events.Count(e => e.Type == SimEventType.CloudGenerated);
var drone = eng.Drones[0];
var msg = $"Piston: guns={platforms.Count}, launched={launched}, clouds={cloudsGenerated}, " +
var msg = $"Piston: launched={launched}, clouds={cloudsGenerated}, " +
$"droneStatus={drone.Status}, hp={drone.Hp:F3}, posX={drone.PosX:F0}, " +
$"cloudCount={eng.Clouds.Count}";
@ -314,17 +341,21 @@ namespace CounterDrone.Core.Tests
new Waypoint { PosX = 30000, PosY = 800, PosZ = 0, Speed = 500 },
});
var rec = GetAndApplyRecommendation();
_scenario.SaveDeployment(_taskId, new List<EquipmentDeployment>
{
new EquipmentDeployment
{
EquipmentRole = (int)EquipmentRole.LaunchPlatform,
PlatformType = (int)Models.PlatformType.GroundBased,
Quantity = 8, PositionX = 8000, PositionY = 0, PositionZ = 50,
AerosolType = (int)AerosolType.ActiveMaterial, MunitionCount = 3,
MuzzleVelocity = 800, Cooldown = 5,
},
});
var saved = _scenario.GetTaskDetail(_taskId)!;
Assert.True(saved.Cloud.RecommendedTiming.HasValue,
$"Jet: RecommendedTiming={saved.Cloud.RecommendedTiming}");
Assert.Equal(AerosolType.ActiveMaterial, rec.Best.RecommendedAerosolType);
Assert.True(rec.Best.InterceptProbability > 0);
Assert.True(rec.Best.RecommendedCloud.RecommendedTiming > 0);
var jetPlatforms = saved.Equipment.Where(e => e.EquipmentRole == (int)EquipmentRole.LaunchPlatform).ToList();
var plan = ApplyDefensePlan();
Assert.True(plan.ThreatsEngaged > 0);
Assert.NotEmpty(plan.MergedSchedule);
var eng = RunSimulation(8000);
@ -332,7 +363,7 @@ namespace CounterDrone.Core.Tests
var cloudsGenerated = eng.Events.Count(e => e.Type == SimEventType.CloudGenerated);
var drone = eng.Drones[0];
var msg = $"Jet: guns={jetPlatforms.Count}, launched={launched}, clouds={cloudsGenerated}, " +
var msg = $"Jet: launched={launched}, clouds={cloudsGenerated}, " +
$"droneStatus={drone.Status}, hp={drone.Hp:F3}, posX={drone.PosX:F0}, " +
$"cloudCount={eng.Clouds.Count}";
@ -367,14 +398,22 @@ namespace CounterDrone.Core.Tests
new Waypoint { PosX = 15000, PosY = 500, PosZ = 0, Speed = 300 },
});
// 用推荐算法生成空基方案
var rec = GetAndApplyRecommendation(PlatformType.AirBased);
Assert.NotNull(rec.Best.FireSchedule);
Assert.True(rec.Best.FireSchedule.Count > 0);
Assert.Equal(PlatformType.AirBased, rec.Best.Platforms[0].Type);
Assert.True(rec.Best.Platforms[0].CruiseSpeed > 0);
// 部署空基平台单元
_scenario.SaveDeployment(_taskId, new List<EquipmentDeployment>
{
new EquipmentDeployment
{
EquipmentRole = (int)EquipmentRole.LaunchPlatform,
PlatformType = (int)Models.PlatformType.AirBased,
Quantity = 8, PositionX = 5000, PositionY = 2500, PositionZ = -2000,
AerosolType = (int)AerosolType.InertGas, MunitionCount = 3,
CruiseSpeed = 55, ReleaseAltitude = 1500, Cooldown = 5,
},
});
_lastFireSchedule = rec.Best.FireSchedule;
var plan = ApplyDefensePlan(PlatformType.AirBased);
Assert.True(plan.ThreatsEngaged > 0);
Assert.NotEmpty(plan.MergedSchedule);
var eng = RunSimulation(8000);
@ -382,7 +421,7 @@ namespace CounterDrone.Core.Tests
var cloudsGenerated = eng.Events.Count(e => e.Type == SimEventType.CloudGenerated);
var drone = eng.Drones[0];
var msg = $"AirBased: platforms={rec.Best.Platforms.Count}, launched={launched}, clouds={cloudsGenerated}, " +
var msg = $"AirBased: launched={launched}, clouds={cloudsGenerated}, " +
$"droneStatus={drone.Status}, hp={drone.Hp:F3}, simTime={eng.SimulationTime:F1}s";
Assert.True(launched > 0, msg);

View File

@ -1,47 +0,0 @@
using System;
using System.Collections.Generic;
using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
using Xunit;
namespace CounterDrone.Core.Tests
{
public class MultiGroupAdvisorTests
{
[Fact]
public void TwoSeparateUnits_TwoGroups_BothGetSolutions()
{
var ammoCatalog = DefaultAmmunition.GetAll();
Console.WriteLine($"Ammo: inert={ammoCatalog[0].BurstChargeKg}kg, active={ammoCatalog[1].BurstChargeKg}kg");
var advisor = new DefaultDefenseAdvisor(ammoCatalog);
var g1 = new DroneGroup
{
GroupId = "g1", Target = new TargetConfig { PowerType = (int)PowerType.Piston, Quantity = 1, TypicalSpeed = 200, TypicalAltitude = 500 },
Route = new RoutePlan { FormationMode = (int)FormationMode.Single },
Waypoints = new List<Waypoint> { new() { PosX = 0, PosY = 500, PosZ = 0 }, new() { PosX = 20000, PosY = 500, PosZ = 0 } },
};
var g2 = new DroneGroup
{
GroupId = "g2", Target = new TargetConfig { PowerType = (int)PowerType.Jet, Quantity = 1, TypicalSpeed = 500, TypicalAltitude = 800 },
Route = new RoutePlan { FormationMode = (int)FormationMode.Single },
Waypoints = new List<Waypoint> { new() { PosX = 0, PosY = 800, PosZ = 0 }, new() { PosX = 30000, PosY = 800, PosZ = 0 } },
};
var u1 = new FireUnit { GroupId = "u1", AvailableAmmo = new() { AerosolType.InertGas, AerosolType.ActiveMaterial }, PlatformCount = 5, MuzzleVelocity = 800 };
var u2 = new FireUnit { GroupId = "u2", AvailableAmmo = new() { AerosolType.ActiveMaterial }, PlatformCount = 4, MuzzleVelocity = 800 };
var rec = advisor.RecommendMultiGroup(
new List<DroneGroup> { g1, g2 },
new List<FireUnit> { u1, u2 },
new CombatScene { WindSpeed = 3 });
var msg = $"Solutions={rec.GroupSolutions.Count}, Merged={rec.MergedFireSchedule.Count}";
foreach (var s in rec.GroupSolutions)
msg += $" | {s.RecommendedAerosolType}: plat={s.Platforms.Count} fire={s.FireSchedule.Count}";
Assert.True(rec.GroupSolutions.Count == 2, msg);
Assert.NotEmpty(rec.MergedFireSchedule);
}
}
}