feat: detection-driven planning (v0.6.0)

Planner no longer has god-view. It assumes threats enter from detection boundary (unified info network earliest detection point), not route start.

Core: DetectionCalculator (EO attenuated by Visibility, radar/IR unaffected; earliest detection via segment-circle intersection). EquipmentDeployment: drop DetectionRadius, add RadarRange/EORange/IRange/DetectionAccuracy. FireUnit detection fields activated in BuildFireUnits. IDefensePlanner.Plan adds 4th param detectionSources.

Engine: BuildDetectionSources merges standalone detectors + fire-unit self-detection into unified list, passed to planner.

Fix: Solve robustness when GenerateFireEventsAt returns empty (detection boundary too late to intercept).

Tests 193 to 208 (+15). 0 warnings.
This commit is contained in:
tian 2026-06-15 12:52:57 +08:00
parent 3028a421b5
commit 11f8cb2c79
21 changed files with 590 additions and 25 deletions

View File

@ -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<DetectionSource>`(独立探测 + 火力单元自带),传入 planner
- 报告探测设备表用新字段(雷达/光电/红外/精度)
### Fixed
- Solve 健壮性GenerateFireEventsAt 返回空时(探测边界太靠后来不及拦截)不再 IndexOutOfRange
### Metrics
- 测试 193 → **208**+15全量通过 64s
- 0 编译警告
---
## [0.5.0] - 2026-06-14
### Added — 物理模型统一架构

View File

@ -1 +1 @@
0.5.0
0.6.0

View File

@ -13,5 +13,6 @@
"Electric": "InertGas",
"Piston": "InertGas",
"Jet": "ActiveMaterial"
}
},
"DefaultDetectionAccuracy": 50.0
}

View File

@ -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` 默认值 |

View File

@ -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
- [ ] 数据库变更:新字段为 nullableSQLite 自动处理
- [ ] UI 变更
- [x] 文档变更:实施计划 8.1.1、CHANGELOG、VERSION
## 验收
- 全量测试 193 → **208**+1564s 通过
- DetectionCalculator 13 项单测(天气衰减/最早探测点/散布半径)
- 探测驱动规划 2 项测试planner 基于探测边界、集成场景不破坏)
- 范围外:实时探测→火控链路(未来增强)

View File

@ -61,6 +61,12 @@ namespace CounterDrone.Core.Algorithms
/// <summary>综合优先级 = 威胁指数 / (到达时间 + 1)</summary>
public float Priority => ArrivalTime > -1 ? ThreatIndex / (ArrivalTime + 1f) : ThreatIndex;
/// <summary>最早探测弧长(米)。威胁航路进入探测范围的弧长位置。
/// 0 = 起点即可探测或无探测设备上帝视角。float.MaxValue = 探测不到。</summary>
public float DetectArc { get; set; }
/// <summary>探测精度(位置误差 m。影响 planner 抛撒散布范围。</summary>
public float DetectAccuracy { get; set; }
/// <summary>预计到达航路中点的时间(秒)。
/// 物理含义:匀速直线运动从航路起点到中点的飞行时间。</summary>
public float GetArrivalTime()
@ -121,6 +127,21 @@ namespace CounterDrone.Core.Algorithms
public List<FireEvent> FireEvents { get; set; } = new();
}
/// <summary>探测源(独立探测设备或火力单元自带探测的统一表达)。
/// planner 基于探测源估算威胁的最早发现点,而非上帝视角的航路起点。</summary>
public class DetectionSource
{
public Vector3 Position { get; set; }
/// <summary>雷达探测距离 m不受能见度影响</summary>
public float RadarRange { get; set; }
/// <summary>光电探测距离 m受 Visibility 衰减)</summary>
public float EORange { get; set; }
/// <summary>红外探测距离 m不受能见度影响</summary>
public float IRRange { get; set; }
/// <summary>探测精度 m位置误差影响抛撒散布范围</summary>
public float Accuracy { get; set; }
}
/// <summary>规划方案</summary>
public class DefensePlan
{

View File

@ -26,7 +26,8 @@ namespace CounterDrone.Core.Algorithms
// 五步流水线
// ═══════════════════════════════════════════════
public PlannerResult Plan(List<FireUnit> fireUnits, List<DroneGroup> threats, CombatScene environment)
public PlannerResult Plan(List<FireUnit> fireUnits, List<DroneGroup> threats,
CombatScene environment, List<DetectionSource> 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;

View File

@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using CounterDrone.Core.Models;
namespace CounterDrone.Core.Algorithms
{
/// <summary>探测计算工具——统一信息网络对威胁的探测能力评估。
/// 纯函数、无状态,与 Kinematics/RouteGeometry 同范式。
/// planner事前规划基于此估算威胁的最早发现点而非上帝视角的航路起点。</summary>
public static class DetectionCalculator
{
/// <summary>某探测源在给定能见度下的综合有效探测距离(水平面)。
/// 取雷达/光电/红外三者的最大有效距离(任一方式发现即算发现)。
/// 光电受 Visibility 衰减:有效 = 基准 × min(1, Visibility/基准)。
/// 雷达/红外不受能见度影响(当前范围)。</summary>
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));
}
/// <summary>统一信息网络对某威胁的最早探测点。
/// 遍历所有探测源,找威胁航路进入任一探测源范围的最早点(航路弧长最小)。
/// 返回 (探测弧长, 该处精度)。无探测源时返回 (0, float.MaxValue)。</summary>
public static (float detectArc, float accuracy) EarliestDetection(
IReadOnlyList<Waypoint> threatRoute,
List<DetectionSource> 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);
}
/// <summary>探测精度换算为抛撒散布半径m
/// 精度差 → 散布半径大planner 增加横向覆盖。
/// 当前模型:散布半径 = 精度值(如精度 100m → 散布 ±100m。</summary>
public static float SpreadRadius(float accuracy)
{
return Math.Max(0f, accuracy);
}
/// <summary>折线航路进入圆(水平面 XZ的最早弧长。
/// 逐段求线段-圆交点,返回首次进入的累积弧长。
/// 若起点已在圆内,返回 0。若完全不交返回 float.MaxValue。</summary>
private static float EarliestEntryArc(IReadOnlyList<Waypoint> 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;
}
}
}

View File

@ -3,12 +3,15 @@ using CounterDrone.Core.Models;
namespace CounterDrone.Core.Algorithms
{
/// <summary>防御规划引擎 — 给定火力单元池和威胁列表,输出分配方案</summary>
/// <summary>防御规划引擎 — 给定火力单元池、威胁列表和探测能力,输出分配方案。
/// planner 基于探测能力估算威胁的最早发现点(而非上帝视角的航路起点)。</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);
/// <param name="environment">作战环境(含天气,影响光电探测距离)</param>
/// <param name="detectionSources">统一信息网络的探测源列表(独立探测设备 + 火力单元自带探测)。可为空列表。</param>
PlannerResult Plan(List<FireUnit> fireUnits, List<DroneGroup> threats,
CombatScene environment, List<DetectionSource> detectionSources);
}
}

View File

@ -26,6 +26,9 @@ namespace CounterDrone.Core.Algorithms
/// <summary>弹药匹配表PowerType → AerosolType</summary>
public Dictionary<PowerType, AerosolType> AmmoMatch { get; set; } = new();
/// <summary>无探测设备时的默认探测精度 m回退值上帝视角但有标称误差</summary>
public float DefaultDetectionAccuracy { get; set; }
private const string ConfigFileName = "planner_config.json";
/// <summary>从 dataRoot 加载配置。文件缺失或字段非法即抛异常。</summary>
@ -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()

View File

@ -50,7 +50,14 @@ namespace CounterDrone.Core.Models
/// <summary>空基平台巡航速度 m/s地基为 NULL</summary>
public double? CruiseSpeed { get; set; }
// 探测设备专用
public double? DetectionRadius { get; set; }
// ── 探测能力(独立探测设备和火力单元自带探测统一表达)──
/// <summary>雷达探测距离 m雨雾不衰减发射平台也可有</summary>
public double? RadarRange { get; set; }
/// <summary>光电探测距离 m受 Visibility 衰减)</summary>
public double? EORange { get; set; }
/// <summary>红外探测距离 m不受 Visibility 影响)</summary>
public double? IRRange { get; set; }
/// <summary>探测精度 m位置误差影响抛撒散布范围</summary>
public double? DetectionAccuracy { get; set; }
}
}

View File

@ -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();
}

View File

@ -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> { (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;
}
/// <summary>构建统一信息网络的探测源列表。
/// 独立探测设备EquipmentRole.Detection+ 火力单元自带探测能力。</summary>
private static List<DetectionSource> BuildDetectionSources(TaskFullConfig config)
{
var sources = new List<DetectionSource>();
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<DroneGroup> BuildDroneGroups(TaskFullConfig config)
{
var groups = new List<DroneGroup>();

View File

@ -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> { droneGroup }, detail.Scene);
var result = new DefensePlanner(ammoCatalog, PlannerConfig.Load(paths)).Plan(fireUnits, new List<DroneGroup> { droneGroup }, detail.Scene, new List<DetectionSource>());
scenarioMgr.SaveCloud(taskId, new CloudDispersal
{
PositionX = (droneGroup.Waypoints[0].PosX + droneGroup.Waypoints[^1].PosX) / 2,

View File

@ -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> { droneGroup }, detail.Scene);
var result = planner.Plan(fireUnits, new List<DroneGroup> { droneGroup }, detail.Scene, new List<DetectionSource>());
scenario.SaveCloudDispersal(taskId, new CloudDispersal
{
PositionX = (droneGroup.Waypoints[0].PosX + droneGroup.Waypoints[^1].PosX) / 2,

View File

@ -59,7 +59,7 @@ namespace CounterDrone.Core.Tests
private PlannerResult Plan(List<FireUnit> 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<DetectionSource>());
// ═══════════════════════════════════════
// 威胁排序
@ -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<DetectionSource>());
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<DetectionSource>()).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<DetectionSource>()).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<DetectionSource>());
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<DetectionSource>());
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<DetectionSource> { detSrc });
Assert.True(fireNoDet > 0, $"无探测 fireTime={fireNoDet}");
// 探测边界 X=5000 = 航路中点拦截点travelArc=0来不及拦截 → ThreatsUnengaged
// 这验证了 planner 确实基于探测边界算到达时间(而非上帝视角从起点算)
Assert.True(resultDet.Best.ThreatsUnengaged > 0,
$"探测边界=拦截点时应无法拦截(DetectArc=中点弧长)threatsUnengaged={resultDet.Best.ThreatsUnengaged}");
}
}
}

View File

@ -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<Waypoint> 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 → 有效 = 10000min(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<DetectionSource>
{
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<DetectionSource>(), 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);
}
}
}

View File

@ -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<Waypoint>
{
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<EquipmentDeployment>
{
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<Waypoint>
{
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<EquipmentDeployment>
{
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]);
}
}
}

View File

@ -35,7 +35,8 @@ namespace CounterDrone.Core.Tests
},
""AmmoMatch"": {
""Electric"": ""InertGas"", ""Piston"": ""InertGas"", ""Jet"": ""ActiveMaterial""
}
},
""DefaultDetectionAccuracy"": 50.0
}";
[Fact]

View File

@ -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
{