diff --git a/src/CounterDrone.Core/Algorithms/AlgorithmFactory.cs b/src/CounterDrone.Core/Algorithms/AlgorithmFactory.cs index b6b9d5a..ff8eb02 100644 --- a/src/CounterDrone.Core/Algorithms/AlgorithmFactory.cs +++ b/src/CounterDrone.Core/Algorithms/AlgorithmFactory.cs @@ -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(Func factory) diff --git a/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs b/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs index 4c78eb1..2800680 100644 --- a/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs +++ b/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs @@ -50,21 +50,31 @@ namespace CounterDrone.Core.Algorithms public List MergedFireSchedule { get; set; } = new(); } - /// 火力单元(装备编组) + /// 火力单元(单个平台,统一表达空基/地基) 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; + /// 平台类型:AirBased / GroundBased + public PlatformType Type { get; set; } + /// 待命位置(空基含巡航高度) + public Vector3 Position { get; set; } + /// 空基巡航速度 m/s + public float CruiseSpeed { get; set; } + /// 空基投放高度 m + public float ReleaseAltitude { get; set; } + /// 地基初速 m/s public float MuzzleVelocity { get; set; } = 800f; + /// 总载弹量 + public int TotalMunitions { get; set; } + /// 可装填的弹药类型 + public List AmmoTypes { get; set; } = new(); + /// 同弹种连发冷却 s public float Cooldown { get; set; } = 5f; + /// 更换弹种时间 s public float AmmoChangeTime { get; set; } = 300f; - // 弹药库:可以装填的弹药类型 - public List AvailableAmmo { get; set; } = new(); } - /// 无人机编队(多编队推荐输入) + /// 无人机编队(规划器输入) 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 Waypoints { get; set; } = new(); + /// 到达防御区域中点的时间(秒),由规划器预计算 + public float ArrivalTime { get; set; } + /// 威胁指数(速度系数 × 类型系数) + public float ThreatIndex { get; set; } + /// 综合优先级 = 威胁指数 / (到达时间 + 1) + public float Priority => ArrivalTime > -1 ? ThreatIndex / (ArrivalTime + 1f) : ThreatIndex; + /// 预计到达航路中点的时间(秒) public float GetArrivalTime() { @@ -135,4 +152,49 @@ namespace CounterDrone.Core.Algorithms public float TargetX, TargetY, TargetZ; public float MuzzleVelocity; } + + // ═══════════════════════════════════════════════ + // DefensePlanner 类型 + // ═══════════════════════════════════════════════ + + /// 拦截候选:一个火力单元对一个威胁的可行性评估 + 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; } // 仅地基用 + } + + /// 单元分配:一个火力单元被分配给一个威胁的配置 + 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 FireEvents { get; set; } = new(); + } + + /// 规划方案 + public class DefensePlan + { + public List Assignments { get; set; } = new(); + public List 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; + } + + /// 规划器输出 + public class PlannerResult + { + public DefensePlan Best { get; set; } = new(); + public DefensePlan Critical { get; set; } = new(); + } } diff --git a/src/CounterDrone.Core/Algorithms/DefaultDefenseAdvisor.cs b/src/CounterDrone.Core/Algorithms/DefaultDefenseAdvisor.cs deleted file mode 100644 index 09334f8..0000000 --- a/src/CounterDrone.Core/Algorithms/DefaultDefenseAdvisor.cs +++ /dev/null @@ -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 _ammoCatalog; - - public DefaultDefenseAdvisor(List ammoCatalog) - { - _ammoCatalog = ammoCatalog ?? new List(); - } - - private static readonly Dictionary 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(); - var platforms = new List(); - 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 - { - 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); - - /// 计算云团在目标时刻的有效半径 - 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)); - } - - /// 多编队推荐 — 每组独立方案,按时间分配火力单元,合并发射计划 - public MultiGroupRecommendation RecommendMultiGroup( - List droneGroups, List 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(); - 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 { 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; - } - } -} diff --git a/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs b/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs new file mode 100644 index 0000000..786757b --- /dev/null +++ b/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs @@ -0,0 +1,389 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CounterDrone.Core.Models; + +namespace CounterDrone.Core.Algorithms +{ + /// 默认防御规划器 — 五步流水线 + public class DefaultDefensePlanner : IDefensePlanner + { + private readonly List _ammoCatalog; + + public DefaultDefensePlanner(List ammoCatalog) + { + _ammoCatalog = ammoCatalog ?? new List(); + } + + /// 弹药匹配表:PowerType → 最佳 AerosolType + private static readonly Dictionary MatchTable = new() + { + { PowerType.Electric, AerosolType.InertGas }, + { PowerType.Piston, AerosolType.InertGas }, + { PowerType.Jet, AerosolType.ActiveMaterial }, + }; + + /// 威胁类型系数(越大越危险) + private static readonly Dictionary TypeCoefficient = new() + { + { TargetType.HighSpeed, 4f }, + { TargetType.FixedWing, 2f }, + { TargetType.Piston, 2f }, + { TargetType.Rotor, 1f }, + { TargetType.Electric, 1f }, + }; + + // ═══════════════════════════════════════════════ + // 五步流水线 + // ═══════════════════════════════════════════════ + + public PlannerResult Plan(List fireUnits, List 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 GenerateCandidates(DroneGroup threat, + List availableUnits, CombatScene env) + { + var candidates = new List(); + 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 sortedThreats, + List fireUnits, CombatScene env) + { + var plan = new DefensePlan(); + // 工作副本:追踪每次分配的弹药消耗 + var remainingMunitions = new Dictionary(); + foreach (var u in fireUnits) + remainingMunitions[u.Id] = u.TotalMunitions; + + var busyUntil = new Dictionary(); + + 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(), + MergedSchedule = new List(), + 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 GenerateFireEvents(DroneGroup threat, FireUnit unit, + AerosolType ammoType, int rounds, AmmunitionSpec? ammo, CombatScene env, float flightTime) + { + var events = new List(); + 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; + } + } +} diff --git a/src/CounterDrone.Core/Algorithms/IDefenseAdvisor.cs b/src/CounterDrone.Core/Algorithms/IDefenseAdvisor.cs deleted file mode 100644 index 5ed3aab..0000000 --- a/src/CounterDrone.Core/Algorithms/IDefenseAdvisor.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using CounterDrone.Core.Models; - -namespace CounterDrone.Core.Algorithms -{ - /// 防御推荐接口 — 威胁驱动 - public interface IDefenseAdvisor - { - DefenseRecommendation Recommend(ThreatProfile threat); - - /// 多编队推荐 — 为每组分配火力单元,合并发射计划 - MultiGroupRecommendation RecommendMultiGroup( - List droneGroups, - List fireUnits, - CombatScene environment); - } -} diff --git a/src/CounterDrone.Core/Algorithms/IDefensePlanner.cs b/src/CounterDrone.Core/Algorithms/IDefensePlanner.cs new file mode 100644 index 0000000..648ee9f --- /dev/null +++ b/src/CounterDrone.Core/Algorithms/IDefensePlanner.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using CounterDrone.Core.Models; + +namespace CounterDrone.Core.Algorithms +{ + /// 防御规划引擎 — 给定火力单元池和威胁列表,输出分配方案 + public interface IDefensePlanner + { + /// 可用的火力单元(空基/地基统一表达) + /// 威胁编队列表 + /// 作战环境 + PlannerResult Plan(List fireUnits, List threats, CombatScene environment); + } +} diff --git a/src/Unity/Assets/Scripts/Managers/ManagerVerification.cs b/src/Unity/Assets/Scripts/Managers/ManagerVerification.cs index 3b77fde..e443d8a 100644 --- a/src/Unity/Assets/Scripts/Managers/ManagerVerification.cs +++ b/src/Unity/Assets/Scripts/Managers/ManagerVerification.cs @@ -52,29 +52,42 @@ namespace CounterDrone.Unity foreach (var a in DefaultAmmunition.GetAll()) db.Insert(a); var ammoCatalog = db.Table().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 + { + 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 }, 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(); - 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(); runner.Awake(); diff --git a/src/Unity/Assets/Scripts/Managers/SimulationBootstrap.cs b/src/Unity/Assets/Scripts/Managers/SimulationBootstrap.cs index b22fcce..0ca006c 100644 --- a/src/Unity/Assets/Scripts/Managers/SimulationBootstrap.cs +++ b/src/Unity/Assets/Scripts/Managers/SimulationBootstrap.cs @@ -65,31 +65,46 @@ namespace CounterDrone.Unity // 推荐方案 var detail = scenario.GetTaskDetail(taskId); var ammoCatalog = db.Table().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 + { + 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 }, 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(); - 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(); diff --git a/test/unit/CounterDrone.Core.Tests/AlgorithmFactoryTests.cs b/test/unit/CounterDrone.Core.Tests/AlgorithmFactoryTests.cs index 87abc62..6dd0054 100644 --- a/test/unit/CounterDrone.Core.Tests/AlgorithmFactoryTests.cs +++ b/test/unit/CounterDrone.Core.Tests/AlgorithmFactoryTests.cs @@ -14,8 +14,8 @@ namespace CounterDrone.Core.Tests var damage = AlgorithmFactory.Create(); Assert.IsType(damage); - var advisor = AlgorithmFactory.Create(); - Assert.IsType(advisor); + var planner = AlgorithmFactory.Create(); + Assert.IsType(planner); } [Fact] diff --git a/test/unit/CounterDrone.Core.Tests/DefenseAdvisorRealityTests.cs b/test/unit/CounterDrone.Core.Tests/DefenseAdvisorRealityTests.cs deleted file mode 100644 index 1d50f60..0000000 --- a/test/unit/CounterDrone.Core.Tests/DefenseAdvisorRealityTests.cs +++ /dev/null @@ -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 TestAmmo = DefaultAmmunition.GetAll(); - - [Fact] - public void PistonDrone_RecommendsReachableCloudPosition() - { - var threat = new ThreatProfile - { - Environment = new CombatScene - { - WindSpeed = 3, - WindDirection = (int)WindDirection.W, - }, - Targets = new List - { - 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 - { - 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"); - } - } -} diff --git a/test/unit/CounterDrone.Core.Tests/DefenseAdvisorTests.cs b/test/unit/CounterDrone.Core.Tests/DefenseAdvisorTests.cs deleted file mode 100644 index 83f04fc..0000000 --- a/test/unit/CounterDrone.Core.Tests/DefenseAdvisorTests.cs +++ /dev/null @@ -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 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 - { - 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 - { - 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()).Recommend(CreateSimpleThreat()); - Assert.Equal(0f, r.Best.InterceptProbability); - Assert.Contains("未找到", r.Best.SummaryRationale); - } - } -} diff --git a/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs b/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs new file mode 100644 index 0000000..1a297f6 --- /dev/null +++ b/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs @@ -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 TestAmmo = DefaultAmmunition.GetAll(); + + private static List MakeGroundUnits(int count = 5) + { + var units = new List(); + 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.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 + { + 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 { 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 { 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 { 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 { 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 { MakeThreat() }, + new CombatScene()); + Assert.NotEmpty(result.Best.MergedSchedule); + } + + [Fact] + public void Has_Assignments() + { + var result = new DefaultDefensePlanner(TestAmmo).Plan( + MakeGroundUnits(), + new List { MakeThreat() }, + new CombatScene()); + Assert.NotEmpty(result.Best.Assignments); + } + + [Fact] + public void Critical_Within50Percent() + { + var result = new DefaultDefensePlanner(TestAmmo).Plan( + MakeGroundUnits(10), + new List { 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(), + new List { MakeThreat() }, + new CombatScene()); + Assert.Equal(1, result.Best.ThreatsUnengaged); + } + + [Fact] + public void NoThreats_EmptyPlan() + { + var result = new DefaultDefensePlanner(TestAmmo).Plan( + MakeGroundUnits(), + new List(), + new CombatScene()); + Assert.Equal(0, result.Best.ThreatsEngaged); + } + } +} diff --git a/test/unit/CounterDrone.Core.Tests/DispersionAdvisorRealityTests.cs b/test/unit/CounterDrone.Core.Tests/DispersionAdvisorRealityTests.cs deleted file mode 100644 index be19603..0000000 --- a/test/unit/CounterDrone.Core.Tests/DispersionAdvisorRealityTests.cs +++ /dev/null @@ -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 - { - new TargetConfig { PowerType = (int)PowerType.Piston, Quantity = 1, - TypicalSpeed = speed, TypicalAltitude = 500 }, - }, - Route = new RoutePlan { FormationMode = (int)FormationMode.Single }, - Waypoints = new List - { - 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(预留飞行+膨胀时间)"); - } - } -} diff --git a/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs b/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs index da24b1e..a24b9bf 100644 --- a/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs +++ b/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs @@ -77,53 +77,88 @@ namespace CounterDrone.Core.Tests return _lastFireSchedule ?? new List(); } - /// 将 DefenseAdvisor 推荐方案写入想定 - private DefenseRecommendation GetAndApplyRecommendation(PlatformType? preferredPlatform = null) + /// 创建默认火力单元并调用 Planner 生成方案 + private DefensePlan ApplyDefensePlan(PlatformType? preferredPlatform = null) { var detail = _scenario.GetTaskDetail(_taskId)!; - var threat = new ThreatProfile + + // 从已保存的 EquipmentDeployment 构建 FireUnit 列表 + var fireUnits = new List(); + 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(); - 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)(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.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel }, + }); + } + + // 构建 DroneGroup 列表 + var threats = new List(); + 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()); + 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 + { + 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 + { + 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 + { + 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); diff --git a/test/unit/CounterDrone.Core.Tests/MultiGroupAdvisorTests.cs b/test/unit/CounterDrone.Core.Tests/MultiGroupAdvisorTests.cs deleted file mode 100644 index 0d78a77..0000000 --- a/test/unit/CounterDrone.Core.Tests/MultiGroupAdvisorTests.cs +++ /dev/null @@ -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 { 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 { 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 { g1, g2 }, - new List { 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); - } - } -}