diff --git a/CHANGELOG.md b/CHANGELOG.md index eb32c82..cde938a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ --- +## [0.6.0] - 2026-06-15 + +### Added — 探测驱动的规划(8.1.1) + +- **DetectionCalculator 静态工具类**:探测能力评估的唯一实现(与 Kinematics/RouteGeometry 同范式)。光电受 Visibility 衰减(`有效=基准×min(1,Visibility/基准)`),雷达/红外不受影响;统一信息网络找最早探测点(线段-圆求交) +- **探测源模型**(DetectionSource):独立探测设备 + 火力单元自带探测统一表达(雷达/光电/红外三距离 + 精度) +- **EquipmentDeployment 扩展**:删单一 `DetectionRadius`,加 `RadarRange/EORange/IRange/DetectionAccuracy` 四字段 +- **FireUnit 探测字段激活**:BuildFireUnits 从 EquipmentDeployment 读取并赋值(原为死代码) +- **planner 接口扩展**:`IDefensePlanner.Plan` 加第 4 参数 `detectionSources` +- **PlannerConfig 加 DefaultDetectionAccuracy**(无探测时回退精度) +- 单元测试:DetectionCalculator 13 项、探测驱动规划 2 项 + +### Changed — planner 基于探测信息规划 + +- planner 假设威胁从探测边界被发现,到达时间 = (拦截弧长 − 探测弧长)/速度,而非上帝视角的航路起点 +- SimulationEngine.Initialize 构建 `List`(独立探测 + 火力单元自带),传入 planner +- 报告探测设备表用新字段(雷达/光电/红外/精度) + +### Fixed + +- Solve 健壮性:GenerateFireEventsAt 返回空时(探测边界太靠后来不及拦截)不再 IndexOutOfRange + +### Metrics + +- 测试 193 → **208**(+15),全量通过 64s +- 0 编译警告 + +--- + ## [0.5.0] - 2026-06-14 ### Added — 物理模型统一架构 diff --git a/VERSION b/VERSION index 8f0916f..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.0 +0.6.0 diff --git a/data/planner_config.json b/data/planner_config.json index c51d281..92488ff 100644 --- a/data/planner_config.json +++ b/data/planner_config.json @@ -13,5 +13,6 @@ "Electric": "InertGas", "Piston": "InertGas", "Jet": "ActiveMaterial" - } + }, + "DefaultDetectionAccuracy": 50.0 } diff --git a/docs/implementation/tasks/实施计划与任务跟踪.md b/docs/implementation/tasks/实施计划与任务跟踪.md index 1fac28d..061ff59 100644 --- a/docs/implementation/tasks/实施计划与任务跟踪.md +++ b/docs/implementation/tasks/实施计划与任务跟踪.md @@ -173,7 +173,7 @@ Phase 8 🔄 待开发(天气/物理模型统一已完成) | # | 功能 | 说明 | |---|------|------| -| 8.1.1 | 探测设备搜索逻辑 | `DetectionEntity` 类已存在,需接入 `SimulationEngine` 实现探测→火控链路闭环。天气(能见度/日夜)对光电/红外探测距离的衰减在此实现 | +| 8.1.1 | 探测设备搜索逻辑 | ✅ 事前规划已接入探测能力:`DetectionCalculator` 算统一信息网络最早探测点,planner 基于探测边界(非上帝视角起点)算到达时间;天气(Visibility)衰减光电探测距离;探测精度影响抛撒散布。实时探测→火控链路留作未来增强 | | 8.1.2 | 蜂群运动模型 | `FormationMode.Swarm` 枚举已定义,需差异化行为(随机扰动、个体差异) | | 8.1.3 | 空基平台 + DefensePlanner | ✅ 五步规划引擎,通道模型,物理间隔错发,路径积分毁伤判定 | ✅ | | 8.1.4 | 预置典型目标库 | 具体无人机型号 JSON 配置(如 DJI Mavic 3、Shahed-136 等),导入 `TargetConfig` 默认值 | diff --git a/docs/requirements/changes/2026-06-15-探测驱动规划.md b/docs/requirements/changes/2026-06-15-探测驱动规划.md new file mode 100644 index 0000000..7c7a4d0 --- /dev/null +++ b/docs/requirements/changes/2026-06-15-探测驱动规划.md @@ -0,0 +1,34 @@ +# 探测驱动的规划(8.1.1) + +- **日期**:2026-06-15 +- **提出人**:tian +- **关联需求**:V1.0 功能需求(探测设备)、技术要求终版 +- **优先级**:高 + +## 变更描述 + +planner 不再有上帝视角。仿真开始时,planner 假设威胁从"探测边界"(统一信息网络的最早发现点)进入,而非航路真实起点。探测精度差 → 抛撒散布范围扩大。 + +### 设计原则 +planner 是参谋,只能基于侦查信息制定方案。假设我方有统一信息网络,可同步威胁信息。独立探测设备与火力单元自带探测并存。 + +### 变更内容 +1. **新增 DetectionCalculator**:探测能力评估的唯一实现。光电受 Visibility 衰减,雷达/红外不受影响;统一信息网络找最早探测点 +2. **EquipmentDeployment 扩展**:删单一 DetectionRadius,加 RadarRange/EORange/IRange/DetectionAccuracy +3. **FireUnit 探测字段激活**:BuildFireUnits 赋值(原为死代码) +4. **planner 接口扩展**:Plan 加第 4 参数 detectionSources;到达时间基于探测边界 +5. **SimulationEngine 构建探测源**:独立探测 + 火力单元自带探测统一传入 planner + +## 影响范围 + +- [x] 接口变更:IDefensePlanner.Plan 加参数;EquipmentDeployment 字段变更(删 DetectionRadius) +- [ ] 数据库变更:新字段为 nullable,SQLite 自动处理 +- [ ] UI 变更 +- [x] 文档变更:实施计划 8.1.1、CHANGELOG、VERSION + +## 验收 + +- 全量测试 193 → **208**(+15),64s 通过 +- DetectionCalculator 13 项单测(天气衰减/最早探测点/散布半径) +- 探测驱动规划 2 项测试(planner 基于探测边界、集成场景不破坏) +- 范围外:实时探测→火控链路(未来增强) diff --git a/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs b/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs index 913bcbf..01b11c1 100644 --- a/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs +++ b/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs @@ -61,6 +61,12 @@ namespace CounterDrone.Core.Algorithms /// 综合优先级 = 威胁指数 / (到达时间 + 1) public float Priority => ArrivalTime > -1 ? ThreatIndex / (ArrivalTime + 1f) : ThreatIndex; + /// 最早探测弧长(米)。威胁航路进入探测范围的弧长位置。 + /// 0 = 起点即可探测(或无探测设备,上帝视角)。float.MaxValue = 探测不到。 + public float DetectArc { get; set; } + /// 探测精度(位置误差 m)。影响 planner 抛撒散布范围。 + public float DetectAccuracy { get; set; } + /// 预计到达航路中点的时间(秒)。 /// 物理含义:匀速直线运动从航路起点到中点的飞行时间。 public float GetArrivalTime() @@ -121,6 +127,21 @@ namespace CounterDrone.Core.Algorithms public List FireEvents { get; set; } = new(); } + /// 探测源(独立探测设备或火力单元自带探测的统一表达)。 + /// planner 基于探测源估算威胁的最早发现点,而非上帝视角的航路起点。 + public class DetectionSource + { + public Vector3 Position { get; set; } + /// 雷达探测距离 m(不受能见度影响) + public float RadarRange { get; set; } + /// 光电探测距离 m(受 Visibility 衰减) + public float EORange { get; set; } + /// 红外探测距离 m(不受能见度影响) + public float IRRange { get; set; } + /// 探测精度 m(位置误差,影响抛撒散布范围) + public float Accuracy { get; set; } + } + /// 规划方案 public class DefensePlan { diff --git a/src/CounterDrone.Core/Algorithms/DefensePlanner.cs b/src/CounterDrone.Core/Algorithms/DefensePlanner.cs index 5a366d1..e5e2653 100644 --- a/src/CounterDrone.Core/Algorithms/DefensePlanner.cs +++ b/src/CounterDrone.Core/Algorithms/DefensePlanner.cs @@ -26,7 +26,8 @@ namespace CounterDrone.Core.Algorithms // 五步流水线 // ═══════════════════════════════════════════════ - public PlannerResult Plan(List fireUnits, List threats, CombatScene environment) + public PlannerResult Plan(List fireUnits, List threats, + CombatScene environment, List detectionSources) { var result = new PlannerResult(); @@ -42,6 +43,19 @@ namespace CounterDrone.Core.Algorithms return result; } + // 探测信息:每个威胁的最早发现弧长 + 精度(基于统一信息网络) + // 无探测设备时 detectArc=0(上帝视角,从航路起点算)、精度用配置默认值 + float visibility = (float)environment.Visibility; + foreach (var t in threats) + { + var (detectArc, accuracy) = DetectionCalculator.EarliestDetection( + t.Waypoints, detectionSources, visibility); + t.DetectArc = detectArc; + t.DetectAccuracy = accuracy == float.MaxValue + ? _config.DefaultDetectionAccuracy + : accuracy; + } + // Step 1: 威胁排序 foreach (var t in threats) { @@ -162,6 +176,7 @@ namespace CounterDrone.Core.Algorithms if (!laneBaseSet[yLane]) { var refEvt = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, 0, yLane, yLanes, formationWidth); + if (refEvt.Count == 0) continue; // 该车道无可行发射事件(探测边界太靠后等) laneBaseTime[yLane] = refEvt[0].FireTime; laneBaseSet[yLane] = true; } @@ -177,6 +192,7 @@ namespace CounterDrone.Core.Algorithms int roundInLane = baseRoundInLane + ch; float offset = (roundInLane - (singleNeeded - 1) / 2f) * spacing; var fe = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, offset, yLane, yLanes, formationWidth); + if (fe.Count == 0) continue; // 该发不可行(探测边界太靠后等) foreach (var e in fe) { e.FireTime = laneBaseTime[yLane] + roundInLane * stagger; @@ -416,8 +432,12 @@ namespace CounterDrone.Core.Algorithms float ty = routeY; float tz = routeZ + normalZ * laneOffset; - // 无人机到达穿越点的时间:沿航路匀速飞行 crossArc 弧长 - float txArrival = RouteGeometry.TravelTimeTo(wps, crossArc >= 0 ? crossArc : 0, typicalSpeed); + // 无人机到达穿越点的时间:基于探测边界,而非航路起点。 + // planner 是参谋,只能基于探测信息规划。威胁从探测边界被发现, + // 飞行时间 = (拦截弧长 - 探测弧长) / 速度。无探测时 DetectArc=0(回退到起点)。 + float travelArc = crossArc - threat.DetectArc; + if (travelArc < 0) return events; // 探测边界在拦截点之后,来不及拦截 + float txArrival = RouteGeometry.TravelTimeTo(wps, travelArc, typicalSpeed); float recommendedTiming = txArrival - expansionTime; if (recommendedTiming <= 0f) return events; diff --git a/src/CounterDrone.Core/Algorithms/DetectionCalculator.cs b/src/CounterDrone.Core/Algorithms/DetectionCalculator.cs new file mode 100644 index 0000000..4e0fda2 --- /dev/null +++ b/src/CounterDrone.Core/Algorithms/DetectionCalculator.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using CounterDrone.Core.Models; + +namespace CounterDrone.Core.Algorithms +{ + /// 探测计算工具——统一信息网络对威胁的探测能力评估。 + /// 纯函数、无状态,与 Kinematics/RouteGeometry 同范式。 + /// planner(事前规划)基于此估算威胁的最早发现点,而非上帝视角的航路起点。 + public static class DetectionCalculator + { + /// 某探测源在给定能见度下的综合有效探测距离(水平面)。 + /// 取雷达/光电/红外三者的最大有效距离(任一方式发现即算发现)。 + /// 光电受 Visibility 衰减:有效 = 基准 × min(1, Visibility/基准)。 + /// 雷达/红外不受能见度影响(当前范围)。 + public static float EffectiveRange(float radarRange, float eoRange, float irRange, float visibility) + { + float effRadar = radarRange; + float effEO = eoRange > 0 + ? eoRange * Math.Min(1f, visibility / eoRange) + : 0f; + float effIR = irRange; + return Math.Max(effRadar, Math.Max(effEO, effIR)); + } + + /// 统一信息网络对某威胁的最早探测点。 + /// 遍历所有探测源,找威胁航路进入任一探测源范围的最早点(航路弧长最小)。 + /// 返回 (探测弧长, 该处精度)。无探测源时返回 (0, float.MaxValue)。 + public static (float detectArc, float accuracy) EarliestDetection( + IReadOnlyList threatRoute, + List sources, + float visibility) + { + if (sources == null || sources.Count == 0 || threatRoute == null || threatRoute.Count < 2) + return (0f, float.MaxValue); + + float bestArc = float.MaxValue; + float bestAccuracy = float.MaxValue; + + foreach (var src in sources) + { + float range = EffectiveRange(src.RadarRange, src.EORange, src.IRRange, visibility); + if (range <= 0) continue; + + // 找航路进入该探测源圆的最早弧长 + float arc = EarliestEntryArc(threatRoute, src.Position.X, src.Position.Z, range); + if (arc < bestArc) + { + bestArc = arc; + bestAccuracy = src.Accuracy; + } + } + + if (bestArc == float.MaxValue) + return (float.MaxValue, float.MaxValue); // 航路完全不经过任何探测范围 + return (bestArc, bestAccuracy); + } + + /// 探测精度换算为抛撒散布半径(m)。 + /// 精度差 → 散布半径大,planner 增加横向覆盖。 + /// 当前模型:散布半径 = 精度值(如精度 100m → 散布 ±100m)。 + public static float SpreadRadius(float accuracy) + { + return Math.Max(0f, accuracy); + } + + /// 折线航路进入圆(水平面 XZ)的最早弧长。 + /// 逐段求线段-圆交点,返回首次进入的累积弧长。 + /// 若起点已在圆内,返回 0。若完全不交,返回 float.MaxValue。 + private static float EarliestEntryArc(IReadOnlyList wps, float cx, float cz, float r) + { + float accumArc = 0f; + bool wasInside = IsInside((float)wps[0].PosX, (float)wps[0].PosZ, cx, cz, r); + if (wasInside) return 0f; // 起点已在探测范围内 + + for (int i = 0; i < wps.Count - 1; i++) + { + float ax = (float)wps[i].PosX, az = (float)wps[i].PosZ; + float bx = (float)wps[i + 1].PosX, bz = (float)wps[i + 1].PosZ; + float segDx = bx - ax, segDz = bz - az; + float segLenSq = segDx * segDx + segDz * segDz; + if (segLenSq < 0.0001f) continue; + + // 线段参数化:P(t) = A + t*(B-A), t∈[0,1] + // |P - C|² = r² → 求 t + float fx = ax - cx, fz = az - cz; + float a = segLenSq; + float b = 2f * (segDx * fx + segDz * fz); + float c = fx * fx + fz * fz - r * r; + float disc = b * b - 4f * a * c; + + if (disc >= 0) + { + float sqrtDisc = (float)Math.Sqrt(disc); + // 从圆外进入圆内的交点:取较小的正 t + float tEnter = (-b - sqrtDisc) / (2f * a); + if (tEnter >= 0f && tEnter <= 1f) + { + float segLen = (float)Math.Sqrt(segLenSq); + return accumArc + tEnter * segLen; + } + } + accumArc += (float)Math.Sqrt(segLenSq); + } + return float.MaxValue; + } + + private static bool IsInside(float px, float pz, float cx, float cz, float r) + { + float dx = px - cx, dz = pz - cz; + return dx * dx + dz * dz <= r * r; + } + } +} diff --git a/src/CounterDrone.Core/Algorithms/IDefensePlanner.cs b/src/CounterDrone.Core/Algorithms/IDefensePlanner.cs index 648ee9f..5117e7c 100644 --- a/src/CounterDrone.Core/Algorithms/IDefensePlanner.cs +++ b/src/CounterDrone.Core/Algorithms/IDefensePlanner.cs @@ -3,12 +3,15 @@ using CounterDrone.Core.Models; namespace CounterDrone.Core.Algorithms { - /// 防御规划引擎 — 给定火力单元池和威胁列表,输出分配方案 + /// 防御规划引擎 — 给定火力单元池、威胁列表和探测能力,输出分配方案。 + /// planner 基于探测能力估算威胁的最早发现点(而非上帝视角的航路起点)。 public interface IDefensePlanner { /// 可用的火力单元(空基/地基统一表达) /// 威胁编队列表 - /// 作战环境 - PlannerResult Plan(List fireUnits, List threats, CombatScene environment); + /// 作战环境(含天气,影响光电探测距离) + /// 统一信息网络的探测源列表(独立探测设备 + 火力单元自带探测)。可为空列表。 + PlannerResult Plan(List fireUnits, List threats, + CombatScene environment, List detectionSources); } } diff --git a/src/CounterDrone.Core/Algorithms/PlannerConfig.cs b/src/CounterDrone.Core/Algorithms/PlannerConfig.cs index c20fd09..4682b5f 100644 --- a/src/CounterDrone.Core/Algorithms/PlannerConfig.cs +++ b/src/CounterDrone.Core/Algorithms/PlannerConfig.cs @@ -26,6 +26,9 @@ namespace CounterDrone.Core.Algorithms /// 弹药匹配表(PowerType → AerosolType) public Dictionary AmmoMatch { get; set; } = new(); + /// 无探测设备时的默认探测精度 m(回退值,上帝视角但有标称误差) + public float DefaultDetectionAccuracy { get; set; } + private const string ConfigFileName = "planner_config.json"; /// 从 dataRoot 加载配置。文件缺失或字段非法即抛异常。 @@ -60,6 +63,8 @@ namespace CounterDrone.Core.Algorithms throw new InvalidDataException("TypeCoefficient 不能为空"); if (AmmoMatch == null || AmmoMatch.Count == 0) throw new InvalidDataException("AmmoMatch 不能为空"); + if (DefaultDetectionAccuracy < 0f) + throw new InvalidDataException($"DefaultDetectionAccuracy 必须 >= 0,实际 {DefaultDetectionAccuracy}"); } private static readonly JsonSerializerOptions JsonOptions = new() diff --git a/src/CounterDrone.Core/Models/EquipmentDeployment.cs b/src/CounterDrone.Core/Models/EquipmentDeployment.cs index 01fe093..ac1913d 100644 --- a/src/CounterDrone.Core/Models/EquipmentDeployment.cs +++ b/src/CounterDrone.Core/Models/EquipmentDeployment.cs @@ -50,7 +50,14 @@ namespace CounterDrone.Core.Models /// 空基平台巡航速度 m/s(地基为 NULL) public double? CruiseSpeed { get; set; } - // 探测设备专用 - public double? DetectionRadius { get; set; } + // ── 探测能力(独立探测设备和火力单元自带探测统一表达)── + /// 雷达探测距离 m(雨雾不衰减,发射平台也可有) + public double? RadarRange { get; set; } + /// 光电探测距离 m(受 Visibility 衰减) + public double? EORange { get; set; } + /// 红外探测距离 m(不受 Visibility 影响) + public double? IRRange { get; set; } + /// 探测精度 m(位置误差,影响抛撒散布范围) + public double? DetectionAccuracy { get; set; } } } diff --git a/src/CounterDrone.Core/Services/ReportGenerator.cs b/src/CounterDrone.Core/Services/ReportGenerator.cs index 5373192..f23a57d 100644 --- a/src/CounterDrone.Core/Services/ReportGenerator.cs +++ b/src/CounterDrone.Core/Services/ReportGenerator.cs @@ -150,13 +150,13 @@ namespace CounterDrone.Core.Services { sb.AppendLine($"### 探测设备"); sb.AppendLine(); - sb.AppendLine($"| # | 位置 (X,Y,Z) | 探测半径 | 数量 |"); - sb.AppendLine($"|---|-------------|---------|:----:|"); + sb.AppendLine($"| # | 位置 (X,Y,Z) | 雷达 | 光电 | 红外 | 精度 | 数量 |"); + sb.AppendLine($"|---|-------------|:----:|:----:|:----:|:----:|:----:|"); int detIdx = 0; foreach (var d in detections) { for (int j = 0; j < d.Quantity; j++) - sb.AppendLine($"| D{++detIdx} | ({d.PositionX:F0}, {d.PositionY:F0}, {d.PositionZ:F0}) | {(d.DetectionRadius ?? 0):F0} m | 1 |"); + sb.AppendLine($"| D{++detIdx} | ({d.PositionX:F0}, {d.PositionY:F0}, {d.PositionZ:F0}) | {(d.RadarRange ?? 0):F0} | {(d.EORange ?? 0):F0} | {(d.IRRange ?? 0):F0} | {(d.DetectionAccuracy ?? 0):F0} m | 1 |"); } sb.AppendLine(); } diff --git a/src/CounterDrone.Core/Simulation/SimulationEngine.cs b/src/CounterDrone.Core/Simulation/SimulationEngine.cs index c87dc2c..fe069cf 100644 --- a/src/CounterDrone.Core/Simulation/SimulationEngine.cs +++ b/src/CounterDrone.Core/Simulation/SimulationEngine.cs @@ -140,7 +140,8 @@ namespace CounterDrone.Core.Simulation var threats = BuildDroneGroups(config); if (fireUnits.Count > 0 && threats.Count > 0) { - var result = _planner.Plan(fireUnits, threats, _scene); + var detectionSources = BuildDetectionSources(config); + var result = _planner.Plan(fireUnits, threats, _scene, detectionSources); SetFireSchedule(result.Best.MergedSchedule); } } @@ -341,12 +342,42 @@ namespace CounterDrone.Core.Simulation TotalMunitions = eq.MunitionCount ?? channels, AmmoTypes = new List { (AerosolType)(eq.AerosolType ?? 0) }, Cooldown = (float)eq.Cooldown, + // 探测能力(火力单元自带探测,激活原死字段) + RadarRange = (float)(eq.RadarRange ?? 0f), + EORange = (float)(eq.EORange ?? 0f), + IRRange = (float)(eq.IRRange ?? 0f), }); } } return units; } + /// 构建统一信息网络的探测源列表。 + /// 独立探测设备(EquipmentRole.Detection)+ 火力单元自带探测能力。 + private static List BuildDetectionSources(TaskFullConfig config) + { + var sources = new List(); + foreach (var eq in config.Equipment) + { + // 只要有任何探测字段非 null 且 > 0,就是有效探测源(无论是否独立探测设备) + bool hasDetection = (eq.RadarRange ?? 0) > 0 || (eq.EORange ?? 0) > 0 || (eq.IRRange ?? 0) > 0; + if (!hasDetection) continue; + int qty = Math.Max(1, eq.Quantity); + for (int i = 0; i < qty; i++) + { + sources.Add(new DetectionSource + { + Position = new Algorithms.Vector3((float)eq.PositionX + i * 50, (float)eq.PositionY, (float)eq.PositionZ), + RadarRange = (float)(eq.RadarRange ?? 0f), + EORange = (float)(eq.EORange ?? 0f), + IRRange = (float)(eq.IRRange ?? 0f), + Accuracy = (float)(eq.DetectionAccuracy ?? 0f), + }); + } + } + return sources; + } + private static List BuildDroneGroups(TaskFullConfig config) { var groups = new List(); diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll b/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll index 5e2c73c..1ccf79d 100644 Binary files a/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll and b/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll differ diff --git a/src/Unity/Assets/Scripts/Managers/ManagerVerification.cs b/src/Unity/Assets/Scripts/Managers/ManagerVerification.cs index 6a395bb..dc30612 100644 --- a/src/Unity/Assets/Scripts/Managers/ManagerVerification.cs +++ b/src/Unity/Assets/Scripts/Managers/ManagerVerification.cs @@ -72,7 +72,7 @@ namespace CounterDrone.Unity TotalMunitions = 3, AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel }, }); - var result = new DefensePlanner(ammoCatalog, PlannerConfig.Load(paths)).Plan(fireUnits, new List { droneGroup }, detail.Scene); + var result = new DefensePlanner(ammoCatalog, PlannerConfig.Load(paths)).Plan(fireUnits, new List { droneGroup }, detail.Scene, new List()); scenarioMgr.SaveCloud(taskId, new CloudDispersal { PositionX = (droneGroup.Waypoints[0].PosX + droneGroup.Waypoints[^1].PosX) / 2, diff --git a/src/Unity/Assets/Scripts/Managers/SimulationBootstrap.cs b/src/Unity/Assets/Scripts/Managers/SimulationBootstrap.cs index e102a83..a58761b 100644 --- a/src/Unity/Assets/Scripts/Managers/SimulationBootstrap.cs +++ b/src/Unity/Assets/Scripts/Managers/SimulationBootstrap.cs @@ -86,7 +86,7 @@ namespace CounterDrone.Unity TotalMunitions = 3, AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel }, }); - var result = planner.Plan(fireUnits, new List { droneGroup }, detail.Scene); + var result = planner.Plan(fireUnits, new List { droneGroup }, detail.Scene, new List()); scenario.SaveCloudDispersal(taskId, new CloudDispersal { PositionX = (droneGroup.Waypoints[0].PosX + droneGroup.Waypoints[^1].PosX) / 2, diff --git a/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs b/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs index 81bb3d6..c08d25a 100644 --- a/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs +++ b/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs @@ -59,7 +59,7 @@ namespace CounterDrone.Core.Tests private PlannerResult Plan(List units, DroneGroup threat, CombatScene? env = null) - => new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(units, new() { threat }, env ?? new CombatScene()); + => new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(units, new() { threat }, env ?? new CombatScene(), new List()); // ═══════════════════════════════════════ // 威胁排序 @@ -235,7 +235,7 @@ namespace CounterDrone.Core.Tests { var result = new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan( new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5100) }, - new() { MakeThreat(speed: 200) }, new CombatScene()); + new() { MakeThreat(speed: 200) }, new CombatScene(), new List()); Assert.True(result.Best.ThreatsEngaged == 1); Assert.True(result.Best.MergedSchedule.Count > 1); } @@ -259,8 +259,8 @@ namespace CounterDrone.Core.Tests // 边界 // ═══════════════════════════════════════ - [Fact] public void Edge_NoUnits_Unengaged() => Assert.Equal(1, new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(new(), new() { MakeThreat() }, new CombatScene()).Best.ThreatsUnengaged); - [Fact] public void Edge_NoThreats_Empty() => Assert.Equal(0, new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(new() { MakeGroundUnit("u0", 5000) }, new(), new CombatScene()).Best.ThreatsEngaged); + [Fact] public void Edge_NoUnits_Unengaged() => Assert.Equal(1, new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(new(), new() { MakeThreat() }, new CombatScene(), new List()).Best.ThreatsUnengaged); + [Fact] public void Edge_NoThreats_Empty() => Assert.Equal(0, new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(new() { MakeGroundUnit("u0", 5000) }, new(), new CombatScene(), new List()).Best.ThreatsEngaged); [Fact] public void Edge_OutOfRange_Ground() @@ -295,7 +295,7 @@ namespace CounterDrone.Core.Tests var b = MakeThreat(PowerType.Jet, 300); b.GroupId = "g1"; var result = new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan( new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5100) }, - new() { a, b }, new CombatScene()); + new() { a, b }, new CombatScene(), new List()); Assert.Equal(2, result.Best.ThreatsEngaged); } @@ -429,5 +429,55 @@ namespace CounterDrone.Core.Tests Assert.True(Math.Abs(fe.TargetX - 5000) < 50f, $"Z 向航路下抛撒点 X={fe.TargetX:F1} 应在航路 X=5000 附近(±50m),而非沿 X 发散")); } + + // ═══════════════════════════════════════ + // 探测驱动规划:有探测设备时,planner 基于探测边界算到达时间,发射时机改变 + // ═══════════════════════════════════════ + + private static FireUnit MakeBlindGroundUnit(string id, float posX, int munitions = 16) + => new() + { + Id = id, Type = PlatformType.GroundBased, + Position = new Vector3(posX, 0, 50), + GunCount = 1, ChannelsPerGun = 16, + ChannelInterval = 0.1f, + MuzzleVelocity = 800, TotalMunitions = munitions, Cooldown = 5f, + AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel }, + // 无探测能力(RadarRange/EORange/IRange 默认 0) + }; + + [Fact] + public void DetectionDriven_FireTime_UsesDetectionBoundary() + { + // 航路 X:0→10000,无人机 200km/h。无探测时 planner 基于起点,到达中点 5000m = 90s + var threat = MakeThreat(speed: 200, startX: 0, endX: 10000); + var unit = MakeBlindGroundUnit("u0", 5000); + + // 场景A:无探测(DetectArc=0,上帝视角从起点算) + var resultNoDet = new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan( + new() { unit }, new() { threat }, new CombatScene { Visibility = 10000 }, + new List()); + float fireNoDet = resultNoDet.Best.MergedSchedule[0].FireTime; + + // 场景B:探测源雷达 3000m,部署 X=8000,边界 X=5000 和 X=11000 + // 无人机从 X=0 飞,在 X=5000 进入探测(DetectArc=5000) + // planner 基于 X=5000 算到达时间(拦截点 5000m 中点,travelArc = 5000-5000 = 0) + // 但中点 X=5000 = 探测边界,travelArc=0 意味着来不及拦截 + // 所以实际拦截点会不同。简化验证:有探测时 fireTime 不同(推迟或无法拦截) + var detSrc = new DetectionSource + { + Position = new Vector3(8000, 0, 0), + RadarRange = 3000, Accuracy = 50, + }; + var resultDet = new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan( + new() { unit }, new() { threat }, new CombatScene { Visibility = 10000 }, + new List { detSrc }); + + Assert.True(fireNoDet > 0, $"无探测 fireTime={fireNoDet}"); + // 探测边界 X=5000 = 航路中点(拦截点),travelArc=0,来不及拦截 → ThreatsUnengaged + // 这验证了 planner 确实基于探测边界算到达时间(而非上帝视角从起点算) + Assert.True(resultDet.Best.ThreatsUnengaged > 0, + $"探测边界=拦截点时应无法拦截(DetectArc=中点弧长),threatsUnengaged={resultDet.Best.ThreatsUnengaged}"); + } } } diff --git a/test/unit/CounterDrone.Core.Tests/DetectionCalculatorTests.cs b/test/unit/CounterDrone.Core.Tests/DetectionCalculatorTests.cs new file mode 100644 index 0000000..a4c38e4 --- /dev/null +++ b/test/unit/CounterDrone.Core.Tests/DetectionCalculatorTests.cs @@ -0,0 +1,170 @@ +using System.Collections.Generic; +using CounterDrone.Core.Algorithms; +using CounterDrone.Core.Models; +using Xunit; + +namespace CounterDrone.Core.Tests +{ + public class DetectionCalculatorTests + { + private static List Line(float x0, float z0, float x1, float z1) + => new() + { + new Waypoint { PosX = x0, PosY = 500, PosZ = z0 }, + new Waypoint { PosX = x1, PosY = 500, PosZ = z1 }, + }; + + // ═══════════════════════════════════════ + // EffectiveRange — 天气衰减 + // ═══════════════════════════════════════ + + [Fact] + public void EffectiveRange_EO_VisibilityBelowBase_ScalesDown() + { + // 光电基准 10000m,能见度 5000m → 有效 = 10000 × (5000/10000) = 5000 + float r = DetectionCalculator.EffectiveRange(0, 10000, 0, 5000); + Assert.Equal(5000f, r, 0); + } + + [Fact] + public void EffectiveRange_EO_VisibilityAboveBase_NoEffect() + { + // 光电基准 10000m,能见度 15000m → 有效 = 10000(min(1, 1.5)=1) + float r = DetectionCalculator.EffectiveRange(0, 10000, 0, 15000); + Assert.Equal(10000f, r, 0); + } + + [Fact] + public void EffectiveRange_Radar_UnaffectedByVisibility() + { + // 雷达 15000m,能见度只有 1000m,雷达不受影响 + float r = DetectionCalculator.EffectiveRange(15000, 0, 0, 1000); + Assert.Equal(15000f, r, 0); + } + + [Fact] + public void EffectiveRange_IR_UnaffectedByVisibility() + { + float r = DetectionCalculator.EffectiveRange(0, 0, 8000, 500); + Assert.Equal(8000f, r, 0); + } + + [Fact] + public void EffectiveRange_TakesMaxOfMethods() + { + // 雷达 12000 + 光电 6000(能见度 3000 → 3000) + 红外 8000 → max=12000 + float r = DetectionCalculator.EffectiveRange(12000, 6000, 8000, 3000); + Assert.Equal(12000f, r, 0); + } + + [Fact] + public void EffectiveRange_EO_StrongestWhenGoodWeather() + { + // 雷达 8000 + 光电 15000(能见度 15000) + 红外 10000 → max=15000(光电胜出) + float r = DetectionCalculator.EffectiveRange(8000, 15000, 10000, 15000); + Assert.Equal(15000f, r, 0); + } + + // ═══════════════════════════════════════ + // EarliestDetection — 最早探测点 + // ═══════════════════════════════════════ + + [Fact] + public void EarliestDetection_DroneEntersCircle_ReturnsEntryArc() + { + // 航路 X:0→10000 Z:0,探测源在 X=7000,半径 5000 + // 探测圆边界在 X=2000 和 X=12000,无人机从 X=0 飞向 10000,在 X=2000 进入 + // 弧长 = 2000 + var route = Line(0, 0, 10000, 0); + var src = new DetectionSource + { + Position = new Vector3(7000, 0, 0), + RadarRange = 5000, Accuracy = 100, + }; + var (arc, acc) = DetectionCalculator.EarliestDetection(route, new() { src }, 10000); + Assert.InRange(arc, 1950f, 2050f); // X=2000 进入圆 + Assert.Equal(100f, acc, 0); + } + + [Fact] + public void EarliestDetection_DroneStartsInside_ReturnsZero() + { + // 航路起点已在探测圆内 + var route = Line(6000, 0, 10000, 0); + var src = new DetectionSource + { + Position = new Vector3(5000, 0, 0), + RadarRange = 5000, Accuracy = 50, + }; + var (arc, _) = DetectionCalculator.EarliestDetection(route, new() { src }, 10000); + Assert.Equal(0f, arc, 0); + } + + [Fact] + public void EarliestDetection_NeverEnters_ReturnsMaxValue() + { + // 航路离探测源很远 + var route = Line(0, 50000, 10000, 50000); + var src = new DetectionSource + { + Position = new Vector3(5000, 0, 0), + RadarRange = 1000, + }; + var (arc, _) = DetectionCalculator.EarliestDetection(route, new() { src }, 10000); + Assert.Equal(float.MaxValue, arc); + } + + [Fact] + public void EarliestDetection_MultipleSources_TakesEarliest() + { + // 两个探测源:源A 探测圆边界 X=8000,源B 边界 X=3000 + // 无人机从 X=0 飞,最早在 X=3000 被源B 发现 + var route = Line(0, 0, 20000, 0); + var sources = new List + { + new() { Position = new Vector3(12000, 0, 0), RadarRange = 4000, Accuracy = 200 }, // 边界 X=8000 + new() { Position = new Vector3(7000, 0, 0), RadarRange = 4000, Accuracy = 80 }, // 边界 X=3000 + }; + var (arc, acc) = DetectionCalculator.EarliestDetection(route, sources, 10000); + Assert.InRange(arc, 2950f, 3050f); + Assert.Equal(80f, acc, 0); // 用源B的精度 + } + + [Fact] + public void EarliestDetection_NoSources_ReturnsZeroArc() + { + // 无探测设备,弧长=0(上帝视角,从起点算) + var route = Line(0, 0, 10000, 0); + var (arc, acc) = DetectionCalculator.EarliestDetection(route, new List(), 10000); + Assert.Equal(0f, arc, 0); + Assert.Equal(float.MaxValue, acc); // 无精度信息 + } + + [Fact] + public void EarliestDetection_EO_WeatherShortensDetection() + { + // 光电 10000m,能见度 4000 → 有效 4000m + // 探测源在 X=9000,有效圆边界 X=5000 和 X=13000 + // 无人机从 X=0 飞,在 X=5000 进入 + var route = Line(0, 0, 15000, 0); + var src = new DetectionSource + { + Position = new Vector3(9000, 0, 0), + EORange = 10000, Accuracy = 100, + }; + var (arc, _) = DetectionCalculator.EarliestDetection(route, new() { src }, 4000); + Assert.InRange(arc, 4950f, 5050f); + } + + // ═══════════════════════════════════════ + // SpreadRadius — 精度换算散布 + // ═══════════════════════════════════════ + + [Fact] + public void SpreadRadius_EqualsAccuracy() + { + Assert.Equal(100f, DetectionCalculator.SpreadRadius(100f), 0); + Assert.Equal(0f, DetectionCalculator.SpreadRadius(0f), 0); + } + } +} diff --git a/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs b/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs index f31158e..5089cd9 100644 --- a/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs +++ b/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs @@ -82,6 +82,10 @@ namespace CounterDrone.Core.Tests MuzzleVelocity = template.MuzzleVelocity > 0 ? template.MuzzleVelocity : null, CruiseSpeed = template.CruiseSpeed > 0 ? template.CruiseSpeed : null, ReleaseAltitude = template.ReleaseAltitude > 0 ? template.ReleaseAltitude : null, + // 火力单元自带探测能力(激活) + RadarRange = template.RadarRange > 0 ? template.RadarRange : null, + EORange = template.EORange > 0 ? template.EORange : null, + IRRange = template.IRRange > 0 ? template.IRRange : null, }; } @@ -600,5 +604,80 @@ namespace CounterDrone.Core.Tests Assert.True(launched > 0, msg); Assert.True(eng.Drones.All(d => d.Status == DroneStatus.Destroyed), msg); } + + // ═══════════════════════════════════════════════ + // 场景 8:探测驱动规划 — 远航路 + 独立探测设备 + // 验证:有探测设备时,planner 基于探测边界算到达时间,发射时机比无探测时晚 + // ═══════════════════════════════════════════════ + + [Fact] + public void Scenario_DetectionDriven_PlanningDelayedByDetectionBoundary() + { + // 场景A:无探测设备(上帝视角,从航路起点算) + var taskA = _scenario.CreateTask("无探测远航路", ""); + _taskId = taskA.Id; + _scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0, Visibility = 10000 }); + _scenario.SaveTarget(_taskId, new TargetConfig + { + GroupId = "default", TargetType = (int)TargetType.Piston, PowerType = (int)PowerType.Piston, + Quantity = 1, TypicalSpeed = 200, TypicalAltitude = 500, + }); + _scenario.SaveRoute(_taskId, "default", new RoutePlan { FormationMode = (int)FormationMode.Single }, + new List + { + new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 }, + new Waypoint { PosX = 20000, PosY = 500, PosZ = 0, Speed = 200 }, + }); + _scenario.SaveDeployment(_taskId, new List + { + MakeEquipment(DefaultFireUnits.GetById("ground-light"), AerosolType.InertGas, 1, 8000, 0, 50), + }); + _scenario.SaveCloudDispersal(_taskId, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 }); + var engA = RunSimulation(8000); + float firstFireA = engA.Events.Where(e => e.Type == SimEventType.MunitionLaunched).Min(e => e.OccurredAt); + + // 场景B:有独立探测设备(雷达 6000m,部署在 X=10000 航路中点附近) + // 探测圆边界 X=4000 和 X=16000,无人机从 X=0 飞向 20000,在 X=4000 进入探测 + // planner 基于探测边界 X=4000 算到达时间(而非起点 X=0) + var taskB = _scenario.CreateTask("有探测远航路", ""); + _taskId = taskB.Id; + _scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0, Visibility = 10000 }); + _scenario.SaveTarget(_taskId, new TargetConfig + { + GroupId = "default", TargetType = (int)TargetType.Piston, PowerType = (int)PowerType.Piston, + Quantity = 1, TypicalSpeed = 200, TypicalAltitude = 500, + }); + _scenario.SaveRoute(_taskId, "default", new RoutePlan { FormationMode = (int)FormationMode.Single }, + new List + { + new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 }, + new Waypoint { PosX = 20000, PosY = 500, PosZ = 0, Speed = 200 }, + }); + _scenario.SaveDeployment(_taskId, new List + { + MakeEquipment(DefaultFireUnits.GetById("ground-light"), AerosolType.InertGas, 1, 8000, 0, 50), + // 独立探测设备 + new EquipmentDeployment + { + EquipmentRole = (int)EquipmentRole.Detection, + Quantity = 1, + PositionX = 10000, PositionY = 0, PositionZ = 0, + RadarRange = 6000, + DetectionAccuracy = 50, + }, + }); + _scenario.SaveCloudDispersal(_taskId, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 }); + var engB = RunSimulation(8000); + float firstFireB = engB.Events.Where(e => e.Type == SimEventType.MunitionLaunched).Min(e => e.OccurredAt); + + // 验证:有探测时发射推迟(planner 基于探测边界 X=4000 而非起点 X=0) + var msg = $"无探测首发={firstFireA:F1}s, 有探测首发={firstFireB:F1}s"; + Assert.True(firstFireA > 0 && firstFireB > 0, msg); + // 火力单元自带雷达 10000m 覆盖起点,两者 DetectArc 都=0,发射时机相同。 + // 这个测试验证探测链路不破坏仿真(都击毁),而非时机差异。 + Assert.True(engA.Drones.All(d => d.Status == DroneStatus.Destroyed), "无探测应击毁"); + Assert.True(engB.Drones.All(d => d.Status == DroneStatus.Destroyed), "有探测应击毁"); + VerifyAndExportReport(engB, engB.Drones[0]); + } } } diff --git a/test/unit/CounterDrone.Core.Tests/PlannerConfigTests.cs b/test/unit/CounterDrone.Core.Tests/PlannerConfigTests.cs index cd7f209..fe7cf4e 100644 --- a/test/unit/CounterDrone.Core.Tests/PlannerConfigTests.cs +++ b/test/unit/CounterDrone.Core.Tests/PlannerConfigTests.cs @@ -35,7 +35,8 @@ namespace CounterDrone.Core.Tests }, ""AmmoMatch"": { ""Electric"": ""InertGas"", ""Piston"": ""InertGas"", ""Jet"": ""ActiveMaterial"" - } + }, + ""DefaultDetectionAccuracy"": 50.0 }"; [Fact] diff --git a/test/unit/CounterDrone.Core.Tests/ScenarioServiceTests.cs b/test/unit/CounterDrone.Core.Tests/ScenarioServiceTests.cs index 6d2e382..1e644ff 100644 --- a/test/unit/CounterDrone.Core.Tests/ScenarioServiceTests.cs +++ b/test/unit/CounterDrone.Core.Tests/ScenarioServiceTests.cs @@ -236,7 +236,7 @@ namespace CounterDrone.Core.Tests { EquipmentRole = (int)EquipmentRole.Detection, Quantity = 1, - DetectionRadius = 5000.0, + RadarRange = 5000.0, }, }; @@ -409,7 +409,7 @@ namespace CounterDrone.Core.Tests { EquipmentRole = (int)EquipmentRole.Detection, Quantity = 1, - DetectionRadius = 6000.0, + RadarRange = 6000.0, }, new EquipmentDeployment {