Phase 10: 探测实时链路开发 + planner 诊断 + 硬编码消除
- 3D球冠探测: IsInCoverage, DetectionEntity, Tick 第5步扫描 - 探测融合: EarliestDetection 采样法, break 修复 - planner 诊断: HasInterceptWindow 拦截窗口检查 - TryGenerateFireEvents 返回拒绝原因 - Summary 含失败原因+建议值(探测范围/弧长) - PlanningFailed 事件: 引擎在规划失败时发出事件 - 硬编码消除: Phase2Duration→AmmunitionSpec ExpansionFactor/TimingSafetyMargin→PlannerConfig 速度从 waypoint.Speed 读取(不用 TypicalSpeed) - TestData 改为从 seeded 数据库读取(不再内嵌 JSON) - 默认数据加 3D球冠参数,巡航导弹速度300→200 空基航路10km→20km, 位置调整 - 222 测试全通过
This commit is contained in:
parent
6b005cf9cc
commit
d8470bad30
@ -17,6 +17,7 @@
|
||||
"SourceStrength": 10.0,
|
||||
"BurstChargeKg": 1.5,
|
||||
"TurbulentExpansionK": 3.0,
|
||||
"Phase2Duration": 30.0,
|
||||
"ParticlesJson": "{}"
|
||||
},
|
||||
{
|
||||
@ -35,6 +36,7 @@
|
||||
"SourceStrength": 12.0,
|
||||
"BurstChargeKg": 4.0,
|
||||
"TurbulentExpansionK": 4.0,
|
||||
"Phase2Duration": 30.0,
|
||||
"ParticlesJson": "{}"
|
||||
},
|
||||
{
|
||||
@ -53,6 +55,7 @@
|
||||
"SourceStrength": 10.0,
|
||||
"BurstChargeKg": 1.5,
|
||||
"TurbulentExpansionK": 3.0,
|
||||
"Phase2Duration": 30.0,
|
||||
"ParticlesJson": "{}"
|
||||
}
|
||||
],
|
||||
@ -81,21 +84,24 @@
|
||||
"PlatformType": 1, "GunCount": 4, "ChannelsPerGun": 4, "ChannelInterval": 0.1,
|
||||
"Cooldown": 5.0, "AmmoChangeTime": 30.0, "MuzzleVelocity": 800.0,
|
||||
"AmmoTypes": [0, 1],
|
||||
"RadarRange": 10000.0, "EORange": 6000.0, "IRRange": 3000.0
|
||||
"RadarRange": 10000.0, "EORange": 6000.0, "IRRange": 3000.0,
|
||||
"MinElevation": -5.0, "MaxElevation": 85.0, "MinDetectAlt": 30.0, "MaxDetectAlt": 20000.0
|
||||
},
|
||||
{
|
||||
"Id": "ground-standard", "Name": "[Demo] 标准地基火力单元",
|
||||
"PlatformType": 1, "GunCount": 4, "ChannelsPerGun": 4, "ChannelInterval": 0.1,
|
||||
"Cooldown": 5.0, "AmmoChangeTime": 30.0, "MuzzleVelocity": 800.0,
|
||||
"AmmoTypes": [0, 1],
|
||||
"RadarRange": 15000.0, "EORange": 8000.0, "IRRange": 5000.0
|
||||
"RadarRange": 15000.0, "EORange": 8000.0, "IRRange": 5000.0,
|
||||
"MinElevation": -5.0, "MaxElevation": 85.0, "MinDetectAlt": 30.0, "MaxDetectAlt": 20000.0
|
||||
},
|
||||
{
|
||||
"Id": "ground-heavy", "Name": "[Demo] 重型地基火力单元",
|
||||
"PlatformType": 1, "GunCount": 6, "ChannelsPerGun": 4, "ChannelInterval": 1.0,
|
||||
"Cooldown": 5.0, "AmmoChangeTime": 30.0, "MuzzleVelocity": 600.0,
|
||||
"AmmoTypes": [0, 1, 2],
|
||||
"RadarRange": 20000.0, "EORange": 10000.0, "IRRange": 6000.0
|
||||
"RadarRange": 20000.0, "EORange": 10000.0, "IRRange": 6000.0,
|
||||
"MinElevation": -5.0, "MaxElevation": 85.0, "MinDetectAlt": 30.0, "MaxDetectAlt": 25000.0
|
||||
},
|
||||
{
|
||||
"Id": "air-standard", "Name": "[Demo] 标准空基火力单元",
|
||||
@ -103,7 +109,8 @@
|
||||
"Cooldown": 5.0, "AmmoChangeTime": 30.0,
|
||||
"CruiseSpeed": 55.0, "ReleaseAltitude": 1500.0,
|
||||
"AmmoTypes": [0, 1],
|
||||
"EORange": 12000.0, "IRRange": 8000.0
|
||||
"EORange": 12000.0, "IRRange": 8000.0,
|
||||
"MinElevation": -80.0, "MaxElevation": 30.0, "MinDetectAlt": 30.0, "MaxDetectAlt": 15000.0
|
||||
}
|
||||
],
|
||||
|
||||
@ -119,18 +126,22 @@
|
||||
{ "Id": "tb2", "Name": "[Demo] 中空长航时(TB2类)",
|
||||
"TargetType": 1, "PowerType": 1, "Wingspan": 12.0, "TypicalSpeed": 220.0, "TypicalAltitude": 5500.0 },
|
||||
{ "Id": "cruise-missile", "Name": "[Demo] 巡航导弹(喷气式)",
|
||||
"TargetType": 4, "PowerType": 2, "Wingspan": 1.5, "TypicalSpeed": 300.0, "TypicalAltitude": 2000.0 }
|
||||
"TargetType": 4, "PowerType": 2, "Wingspan": 1.5, "TypicalSpeed": 200.0, "TypicalAltitude": 2000.0 }
|
||||
],
|
||||
|
||||
"detectionEquipment": [
|
||||
{ "Id": "radar-mr", "Name": "[Demo] 中程防空雷达",
|
||||
"RadarRange": 20000.0, "EORange": 0.0, "IRRange": 0.0, "Accuracy": 30.0 },
|
||||
"RadarRange": 20000.0, "EORange": 0.0, "IRRange": 0.0, "Accuracy": 30.0,
|
||||
"MinElevation": -2.0, "MaxElevation": 70.0, "MinDetectAlt": 50.0, "MaxDetectAlt": 30000.0 },
|
||||
{ "Id": "radar-sr", "Name": "[Demo] 近程防空雷达",
|
||||
"RadarRange": 10000.0, "EORange": 0.0, "IRRange": 0.0, "Accuracy": 50.0 },
|
||||
"RadarRange": 10000.0, "EORange": 0.0, "IRRange": 0.0, "Accuracy": 50.0,
|
||||
"MinElevation": -2.0, "MaxElevation": 70.0, "MinDetectAlt": 30.0, "MaxDetectAlt": 15000.0 },
|
||||
{ "Id": "eo-station", "Name": "[Demo] 光电跟踪站",
|
||||
"RadarRange": 0.0, "EORange": 15000.0, "IRRange": 8000.0, "Accuracy": 20.0 },
|
||||
"RadarRange": 0.0, "EORange": 15000.0, "IRRange": 8000.0, "Accuracy": 20.0,
|
||||
"MinElevation": -10.0, "MaxElevation": 90.0, "MinDetectAlt": 10.0, "MaxDetectAlt": 20000.0 },
|
||||
{ "Id": "ir-sentry", "Name": "[Demo] 红外哨",
|
||||
"RadarRange": 0.0, "EORange": 0.0, "IRRange": 10000.0, "Accuracy": 40.0 }
|
||||
"RadarRange": 0.0, "EORange": 0.0, "IRRange": 10000.0, "Accuracy": 40.0,
|
||||
"MinElevation": -5.0, "MaxElevation": 85.0, "MinDetectAlt": 10.0, "MaxDetectAlt": 15000.0 }
|
||||
],
|
||||
|
||||
"weather": [
|
||||
|
||||
@ -14,5 +14,7 @@
|
||||
"Piston": "InertGas",
|
||||
"Jet": "ActiveMaterial"
|
||||
},
|
||||
"DefaultDetectionAccuracy": 50.0
|
||||
"DefaultDetectionAccuracy": 50.0,
|
||||
"ExpansionFactor": 0.9,
|
||||
"TimingSafetyMargin": 1.0
|
||||
}
|
||||
|
||||
@ -9,11 +9,13 @@
|
||||
| 飞行弹药 | `MunitionEntity` | 发射→到达释放高度 | Pos, LaunchMode(AirDrop/GroundArtillery), Target, HasArrived, 空基载机速度矢量 |
|
||||
| 气溶胶云团 | `CloudEntity` | 弹药到达→消散 | Center, Radius, CoreDensity, IsDissipated |
|
||||
| 管控区域 | `ControlZoneEntity` | 仿真全程 | Vertices, Min/MaxAltitude |
|
||||
| 探测设备 | `DetectionEntity` | 仿真全程 | Source(雷达/光电/红外距离+3D球冠参数), 每无人机探测状态机(Undetected⇄Detected) |
|
||||
|
||||
## 事件一览
|
||||
|
||||
| 事件 | 触发条件 | 参数 | Unity 典型响应 |
|
||||
|------|------|------|------|
|
||||
| `OnTargetDetected` | 无人机首次进入任一探测设备 3D 球冠范围 | `DroneEntity`, `DetectionEntity` | 显示发现标记 / 3D 球冠 wireframe 高亮 |
|
||||
| `OnMunitionLaunched` | 发射计划时间到达 + 平台就绪 | `MunitionEntity` | 生成炮弹模型,播放发射动画 |
|
||||
| `OnCloudGenerated` | 弹药到达释放高度 | `CloudEntity` | 生成粒子系统,初始半径+颜色 |
|
||||
| `OnDroneDestroyed` | 毁伤判定 HP ≤ 0 | `DroneEntity` | 播放爆炸/坠毁动画 |
|
||||
@ -23,14 +25,15 @@
|
||||
|
||||
## 实体 × 事件 矩阵
|
||||
|
||||
| | MunitionLaunched | CloudGenerated | DroneDestroyed | ReachedTarget | ZoneIntruded | SimEnded |
|
||||
|------|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| DroneEntity | | | ✅ | ✅ | ✅ | |
|
||||
| PlatformEntity | ✅ | | | | | |
|
||||
| MunitionEntity | ✅ | | | | | |
|
||||
| CloudEntity | | ✅ | | | | |
|
||||
| ControlZoneEntity | | | | | ✅ | |
|
||||
| 全局 | | | | | | ✅ |
|
||||
| | TargetDetected | MunitionLaunched | CloudGenerated | DroneDestroyed | ReachedTarget | ZoneIntruded | SimEnded |
|
||||
|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| DroneEntity | ✅ | | | ✅ | ✅ | ✅ | |
|
||||
| PlatformEntity | | ✅ | | | | | |
|
||||
| MunitionEntity | | ✅ | | | | | |
|
||||
| CloudEntity | | | ✅ | | | | |
|
||||
| ControlZoneEntity | | | | | | ✅ | |
|
||||
| DetectionEntity | ✅ | | | | | | |
|
||||
| 全局 | | | | | | | ✅ |
|
||||
|
||||
## 数据流
|
||||
|
||||
@ -71,6 +74,11 @@ SimulationEngine.Tick()
|
||||
├─ DroneEntity.Update() → 到达终点
|
||||
│ └─ OnDroneReachedTarget ──→ Unity: 抵达提示
|
||||
│
|
||||
├─ 【实时探测扫描】DetectionEntity × 飞行无人机
|
||||
│ ├─ 3D 球冠判定(IsInCoverage)→ 首次进入置 Detected
|
||||
│ ├─ 离开范围 → 回退 Undetected(再次进入重新触发)
|
||||
│ └─ OnTargetDetected ──→ Unity: 发现标记 / 球冠高亮
|
||||
│
|
||||
├─ ControlZone.ContainsPoint()
|
||||
│ └─ OnZoneIntruded ──→ Unity: 红色警报
|
||||
│
|
||||
|
||||
@ -18,7 +18,7 @@ Phase 6 ✅ Unity 集成(桥接层 + 示例项目)
|
||||
Phase 7 ✅ 打磨收尾
|
||||
Phase 8 ✅ 天气/物理模型统一
|
||||
Phase 9 ✅ 性能优化 + 架构文档校准
|
||||
Phase 10 ⏳ 探测实时链路(设计完成,待开发)
|
||||
Phase 10 ✅ 探测实时链路(已完成)
|
||||
────────────────────────
|
||||
已完成 P1-P9
|
||||
```
|
||||
@ -141,7 +141,7 @@ Phase 10 ⏳ 探测实时链路(设计完成,待开发)
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|------|
|
||||
| 测试总数 | **204**(通过 202,跳过 2) |
|
||||
| 测试总数 | **216**(通过 214,跳过 2) |
|
||||
| 行覆盖率 | **95.4%**(待重测) |
|
||||
| 分支覆盖率 | **80.5%**(待重测) |
|
||||
| 执行时间 | ~7 秒 |
|
||||
@ -239,23 +239,23 @@ Phase 10 ⏳ 探测实时链路(设计完成,待开发)
|
||||
|
||||
| # | 任务 | 预估 | 状态 | 说明 |
|
||||
|---|------|------|------|------|
|
||||
| T1.1 | EquipmentDeployment 加 4 列 | 0.5h | ⬜ | `MinElevation`/`MaxElevation`/`MinDetectAlt`/`MaxDetectAlt`,均 NULLABLE |
|
||||
| T1.2 | DetectionSource 类扩展 | 0.5h | ⬜ | 同步加 4 个三维几何属性 |
|
||||
| T1.3 | BuildDetectionSources 读取新字段 | 0.5h | ⬜ | 缺失时退化球冠(无限制,等价 2D),保证存量数据平滑过渡 |
|
||||
| T1.1 | EquipmentDeployment 加 4 列 | 0.5h | ✅ | `MinElevation`/`MaxElevation`/`MinDetectAlt`/`MaxDetectAlt`,均 NULLABLE |
|
||||
| T1.2 | DetectionSource 类扩展 | 0.5h | ✅ | 同步加 4 个三维几何属性(float.MaxValue=无限制,退化 2D) |
|
||||
| T1.3 | BuildDetectionSources 读取新字段 | 0.5h | ✅ | 缺失时退化球冠(float.MaxValue),保证存量数据平滑过渡 |
|
||||
|
||||
### 10.2 算法层(T2)
|
||||
|
||||
| # | 任务 | 预估 | 状态 | 说明 |
|
||||
|---|------|------|------|------|
|
||||
| T2.1 | DetectionCalculator.IsInCoverage | 1h | ⬜ | 3D 球冠判定:水平距离 + 俯仰角 ∈ [Min,Max]Elevation + 高度 ∈ [Min,Max]DetectAlt |
|
||||
| T2.2 | IsInCoverage 单元测试 | 0.5h | ⬜ | 边界用例:球冠内/外、正顶、俯仰越界、高度越界 |
|
||||
| T2.1 | DetectionCalculator.IsInCoverage | 1h | ✅ | 3D 球冠判定:水平距离 + 俯仰角 ∈ [Min,Max]Elevation + 高度 ∈ [Min,Max]DetectAlt。float.MaxValue=无限制(退化 2D) |
|
||||
| T2.2 | IsInCoverage 单元测试 | 0.5h | ✅ | 7 个边界用例:球冠内/外、正顶、俯仰越界、高度越界、无限制等价 2D |
|
||||
|
||||
### 10.3 planner 3D 适配(T3)🔒 正确性硬要求
|
||||
|
||||
| # | 任务 | 预估 | 状态 | 说明 |
|
||||
|---|------|------|------|------|
|
||||
| T3.1 | EarliestDetection 改采样法 | 2h | ⬜ | 从"线段-圆解析求交"改为"沿航路采样点(步长≤50m)调 IsInCoverage" |
|
||||
| T3.2 | DefensePlannerTests 回归验证 | 1h | ⬜ | 跑全量测试验证;若有测试因 3D 高度门限导致"探测不到→拦截失败"而失败,需核查是测试用例高度设置问题还是真实回归 |
|
||||
| T3.1 | EarliestDetection 改采样法 | 2h | ✅ | 从"线段-圆解析求交"改为"沿航路采样点(步长≤50m)调 IsInCoverage"。删除旧 EarliestEntryArc/IsInside |
|
||||
| T3.2 | DefensePlannerTests 回归验证 | 1h | ✅ | 30 个测试全通过,无回归 |
|
||||
|
||||
> 🔒 **T3 不可砍、不可降级为 2D**。这是正确性硬约束,不是精度优化:
|
||||
> - 仿真是按 planner 规划执行的,**planner 的探测判定 = 仿真的事实依据**。
|
||||
@ -268,17 +268,17 @@ Phase 10 ⏳ 探测实时链路(设计完成,待开发)
|
||||
|
||||
| # | 任务 | 预估 | 状态 | 说明 |
|
||||
|---|------|------|------|------|
|
||||
| T4.1 | DetectionEntity 运行时实体 | 1h | ⬜ | 持有 DetectionSource + 每无人机探测状态机(Undetected ⇄ Detected) |
|
||||
| T4.2 | SimulationEngine 加 _detectionEntities | 0.5h | ⬜ | Initialize 时构建,Tick 第 5 步遍历 |
|
||||
| T4.3 | Tick 第 5 步实时扫描 | 2h | ⬜ | 决策 1(离开回退)、决策 2(融合取最早/同刻取精度高),触发 TargetDetected |
|
||||
| T4.4 | SimulationEngineTests 验证 | 1h | ⬜ | 断言:进入探测范围触发事件、离开后再次进入重新触发、多设备融合取最早 |
|
||||
| T4.1 | DetectionEntity 运行时实体 | 1h | ✅ | 持有 DetectionSource + 每无人机探测状态机(Undetected ⇄ Detected)。`UpdateState` 返回首次进入/离开信号 |
|
||||
| T4.2 | SimulationEngine 加 _detectionEntities | 0.5h | ✅ | Initialize 时从 BuildDetectionSources 构建,Tick 第 5 步遍历 |
|
||||
| T4.3 | Tick 第 5 步实时扫描 | 2h | ✅ | 决策 1(离开回退)= Undetected;决策 2(融合取最早/同刻取精度高);触发 TargetDetected(含 OnTargetDetected 事件) |
|
||||
| T4.4 | SimulationEngineTests 验证 | 1h | ✅ | 4 个测试:进入探测范围触发事件、多设备融合取最早、无探测设备无事件、离开后再次进入重新触发 |
|
||||
|
||||
### 10.5 前端 / 报告(T5,可并行)
|
||||
|
||||
| # | 任务 | 预估 | 状态 | 说明 |
|
||||
|---|------|------|------|------|
|
||||
| T5.1 | Unity 探测范围可视化 | — | ⬜ | 前端范畴:3D 球冠 wireframe / 发现标记(决策 4 仅可视化) |
|
||||
| T5.2 | 时序图/报告纳入 TargetDetected | 0.5h | ⬜ | ReportGenerator 时序表增加发现事件 |
|
||||
| T5.2 | 时序图/报告纳入 TargetDetected | 0.5h | ✅ | ReportGenerator 时序表增加发现事件(👁️ 目标发现);统计增加目标发现计数 |
|
||||
|
||||
### 10.6 任务依赖与顺序
|
||||
|
||||
@ -307,7 +307,7 @@ T1(数据层)→ T2(算法层 IsInCoverage)
|
||||
| M7 — 交付就绪 | ✅ |
|
||||
| M8 — 天气/物理模型统一 | ✅ |
|
||||
| M9 — 性能优化 + 文档校准 | ✅ |
|
||||
| M10 — 探测实时链路 | ⏳ 设计完成(V13),待开发 |
|
||||
| M10 — 探测实时链路 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -62,6 +62,7 @@ runner.Engine.TimeScale = 4f; // 加速
|
||||
runner.Engine.OnMunitionLaunched += m => Instantiate(shellPrefab, ToVector3(m.PosX, m.PosY, m.PosZ));
|
||||
runner.Engine.OnCloudGenerated += c => SpawnCloud(c.Dispersion.Center, c.Dispersion.Radius);
|
||||
runner.Engine.OnDroneDestroyed += d => PlayExplosion(d.PosX, d.PosY, d.PosZ);
|
||||
runner.Engine.OnTargetDetected += (d, det) => ShowDetectionMark(d.PosX, d.PosY, d.PosZ);
|
||||
runner.Engine.OnDroneReachedTarget += d => ShowReached(d.PosX, d.PosY, d.PosZ);
|
||||
runner.Engine.OnZoneIntruded += (d, z) => AlertIntrusion(z.Name);
|
||||
runner.Engine.OnSimulationEnded += () => GenerateReport();
|
||||
@ -98,6 +99,7 @@ runner.LoadAndStart(task.Id);
|
||||
|
||||
| 事件 | 参数 | 触发时机 |
|
||||
|------|------|------|
|
||||
| `OnTargetDetected` | `DroneEntity`, `DetectionEntity` | 无人机首次进入探测设备 3D 球冠范围(离开后再次进入会重新触发) |
|
||||
| `OnMunitionLaunched` | `MunitionEntity` | 发射计划时间到达 |
|
||||
| `OnCloudGenerated` | `CloudEntity` | 弹药到达释放高度 |
|
||||
| `OnDroneDestroyed` | `DroneEntity` | HP ≤ 0 |
|
||||
@ -130,7 +132,7 @@ var runner = GetComponent<SimulationRunner>();
|
||||
runner.Engine.SetFireSchedule(fireEvents); // 从推荐方案取
|
||||
runner.LoadAndStart(taskId);
|
||||
runner.Engine.TimeScale = 4f;
|
||||
runner.Engine.State / Drones / Clouds / Munitions / Events
|
||||
runner.Engine.State / Drones / Clouds / Munitions / DetectionEntities / Events
|
||||
runner.Stop();
|
||||
```
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using CounterDrone.Core.Models;
|
||||
|
||||
@ -76,7 +77,9 @@ namespace CounterDrone.Core.Algorithms
|
||||
var e = Waypoints[^1];
|
||||
float midX = ((float)s.PosX + (float)e.PosX) / 2f;
|
||||
float midZ = ((float)s.PosZ + (float)e.PosZ) / 2f;
|
||||
return Kinematics.TravelTime((float)s.PosX, (float)s.PosZ, midX, midZ, (float)Target.TypicalSpeed);
|
||||
float speed = (float)Waypoints[0].Speed;
|
||||
if (speed <= 0) throw new InvalidOperationException($"威胁 {WaveId}: 航路点速度必须 > 0");
|
||||
return Kinematics.TravelTime((float)s.PosX, (float)s.PosZ, midX, midZ, speed);
|
||||
}
|
||||
}
|
||||
|
||||
@ -140,6 +143,10 @@ namespace CounterDrone.Core.Algorithms
|
||||
public float IRRange { get; set; }
|
||||
/// <summary>探测精度 m(位置误差,影响抛撒散布范围)</summary>
|
||||
public float Accuracy { get; set; }
|
||||
public float MinElevation { get; set; } = float.MaxValue;
|
||||
public float MaxElevation { get; set; } = float.MaxValue;
|
||||
public float MinDetectAlt { get; set; } = float.MaxValue;
|
||||
public float MaxDetectAlt { get; set; } = float.MaxValue;
|
||||
}
|
||||
|
||||
/// <summary>规划方案</summary>
|
||||
@ -151,6 +158,7 @@ namespace CounterDrone.Core.Algorithms
|
||||
public int ThreatsEngaged { get; set; }
|
||||
public int ThreatsUnengaged { get; set; }
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
internal string? RejectReason;
|
||||
}
|
||||
|
||||
/// <summary>规划器输出</summary>
|
||||
|
||||
@ -17,31 +17,35 @@ namespace CounterDrone.Core.Algorithms
|
||||
|
||||
/// <summary>Phase 1 爆轰初始半径 (m)</summary>
|
||||
public float InitialRadius => 3.3f * (float)Math.Pow(Math.Max(0.01, (float)_ammo.BurstChargeKg), 0.32f);
|
||||
/// <summary>Phase 2 结束时的有效半径(30s 湍流膨胀)</summary>
|
||||
public float TurbulentRadius => RadiusAt(30f);
|
||||
/// <summary>Phase 2 结束时的有效半径</summary>
|
||||
public float TurbulentRadius => RadiusAt(Phase2Duration);
|
||||
|
||||
/// <summary>湍流膨胀系数 k</summary>
|
||||
public float TurbulentExpansionK => (float)_ammo.TurbulentExpansionK;
|
||||
|
||||
/// <summary>Phase 2 持续时间(s),从 AmmunitionSpec 读取</summary>
|
||||
public float Phase2Duration => (float)_ammo.Phase2Duration;
|
||||
|
||||
/// <summary>指定时刻湍流膨胀半径 R(t) = R₀ + k√t</summary>
|
||||
public float RadiusAt(float elapsedSeconds)
|
||||
{
|
||||
if (elapsedSeconds <= 0) return InitialRadius;
|
||||
bool inPhase3 = elapsedSeconds > 30f;
|
||||
float p2 = Phase2Duration;
|
||||
bool inPhase3 = elapsedSeconds > p2;
|
||||
if (!inPhase3)
|
||||
return InitialRadius + TurbulentExpansionK * (float)Math.Sqrt(elapsedSeconds);
|
||||
|
||||
// Phase 3: 高斯扩散
|
||||
float windSpeed = (float)_env.WindSpeed;
|
||||
float x = Math.Max(1f, windSpeed * (elapsedSeconds - 30f));
|
||||
float x = Math.Max(1f, windSpeed * (elapsedSeconds - p2));
|
||||
var cls = Kinematics.GetStabilityClass((WeatherType)_env.WeatherType, windSpeed);
|
||||
float sY = Kinematics.SigmaY(cls, x);
|
||||
float sZ = Kinematics.SigmaZ(cls, x);
|
||||
float peakC = Kinematics.GaussianPeakConcentration((float)_ammo.SourceStrength, sY, sZ);
|
||||
float effConc = (float)_ammo.EffectiveConcentration;
|
||||
if (peakC <= effConc) return InitialRadius + TurbulentExpansionK * (float)Math.Sqrt(30f);
|
||||
float rPhase2 = InitialRadius + TurbulentExpansionK * (float)Math.Sqrt(p2);
|
||||
if (peakC <= effConc) return rPhase2;
|
||||
float sigma = (sY + sZ) / 2f;
|
||||
float rPhase2 = InitialRadius + TurbulentExpansionK * (float)Math.Sqrt(30f);
|
||||
return rPhase2 + sigma * (float)Math.Sqrt(2f * Math.Log(peakC / effConc));
|
||||
}
|
||||
|
||||
|
||||
@ -175,12 +175,12 @@ 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; // 该车道无可行发射事件(探测边界太靠后等)
|
||||
var (refEvt, refRej) = TryGenerateFireEvents(threat, c.Unit, c.AmmoType, ammo, env, 0, yLane, yLanes, formationWidth);
|
||||
if (refEvt.Count == 0) { plan.RejectReason ??= refRej; continue; }
|
||||
laneBaseTime[yLane] = refEvt[0].FireTime;
|
||||
laneBaseSet[yLane] = true;
|
||||
}
|
||||
float stagger = Kinematics.CloudCoverInterval(turbulentRadius * 2f, (float)threat.Target.TypicalSpeed, c.Unit.ChannelInterval);
|
||||
float stagger = Kinematics.CloudCoverInterval(turbulentRadius * 2f, GetDroneSpeedKph(threat), c.Unit.ChannelInterval);
|
||||
|
||||
var fevents = new List<FireEvent>();
|
||||
int unitIdx = fireUnits.IndexOf(c.Unit);
|
||||
@ -191,8 +191,8 @@ 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; // 该发不可行(探测边界太靠后等)
|
||||
var (fe, feRej) = TryGenerateFireEvents(threat, c.Unit, c.AmmoType, ammo, env, offset, yLane, yLanes, formationWidth);
|
||||
if (fe.Count == 0) { plan.RejectReason ??= feRej; continue; }
|
||||
foreach (var e in fe)
|
||||
{
|
||||
e.FireTime = laneBaseTime[yLane] + roundInLane * stagger;
|
||||
@ -208,7 +208,10 @@ namespace CounterDrone.Core.Algorithms
|
||||
roundsCollected += toTake;
|
||||
}
|
||||
|
||||
if (roundsCollected <= 0) { plan.ThreatsUnengaged++; continue; }
|
||||
if (roundsCollected <= 0) {
|
||||
plan.RejectReason ??= $"候选{candidates.Count}个,弹药需求{totalRoundsNeeded}发,实际收集0发";
|
||||
plan.ThreatsUnengaged++; continue;
|
||||
}
|
||||
|
||||
foreach (var (unit, rounds, fireEvents) in assignedUnits)
|
||||
{
|
||||
@ -242,13 +245,79 @@ namespace CounterDrone.Core.Algorithms
|
||||
threatProbs.Add(ComputeInterceptProbability(threat, a2, totalRounds, env));
|
||||
}
|
||||
plan.OverallProbability = threatProbs.Count > 0 ? threatProbs.Average() : 0f;
|
||||
|
||||
// 失败原因 + 建议值
|
||||
var reasons = new List<string>();
|
||||
foreach (var threat in sortedThreats)
|
||||
{
|
||||
bool engaged = plan.Assignments.Any(a => a.DroneWaveId == threat.WaveId);
|
||||
if (engaged) continue;
|
||||
var ammo = MatchAmmo(_config, (PowerType)threat.Target.PowerType);
|
||||
var matching = fireUnits.Where(u => u.AmmoTypes.Contains(ammo)).ToList();
|
||||
if (matching.Count == 0)
|
||||
reasons.Add($"威胁 {threat.WaveId}: 无火力单元装载 {ammo} 弹药");
|
||||
else
|
||||
{
|
||||
var (_, expTime, _) = ComputeEffectiveRadius(_ammoCatalog.First(a => a.AerosolType == (int)ammo), env);
|
||||
float avgSpd = GetDroneSpeedMs(threat);
|
||||
float midArc = RouteGeometry.ArcLengthNearestTo(threat.Waypoints,
|
||||
(float)(threat.Waypoints[0].PosX + threat.Waypoints[^1].PosX) / 2f,
|
||||
(float)(threat.Waypoints[0].PosZ + threat.Waypoints[^1].PosZ) / 2f);
|
||||
float travel = midArc - threat.DetectArc;
|
||||
if (travel <= 0)
|
||||
{
|
||||
var (mx, _, mz) = RouteGeometry.PositionAt(threat.Waypoints, midArc);
|
||||
float r = matching.Min(u => MathF.Sqrt((mx - u.Position.X) * (mx - u.Position.X) + (mz - u.Position.Z) * (mz - u.Position.Z)));
|
||||
reasons.Add($"威胁 {threat.WaveId}: 探测边界在拦截点之后,建议探测范围≥{r:F0}m");
|
||||
}
|
||||
else if (travel / avgSpd <= expTime)
|
||||
{
|
||||
float maxDA = Math.Max(0, midArc - avgSpd * (expTime + _config.TimingSafetyMargin));
|
||||
var (nx, _, nz) = RouteGeometry.PositionAt(threat.Waypoints, maxDA);
|
||||
float r = matching.Min(u => MathF.Sqrt((nx - u.Position.X) * (nx - u.Position.X) + (nz - u.Position.Z) * (nz - u.Position.Z)));
|
||||
reasons.Add($"威胁 {threat.WaveId}: 拦截窗口不足({travel / avgSpd:F1}s<膨胀{expTime:F1}s),建议探测范围≥{r:F0}m(最晚弧长{maxDA:F0}m)");
|
||||
}
|
||||
else
|
||||
{
|
||||
string detail = plan.RejectReason != null ? $"({plan.RejectReason})" : "";
|
||||
reasons.Add($"威胁 {threat.WaveId}: 候选存在但分配失败{detail}");
|
||||
}
|
||||
}
|
||||
}
|
||||
plan.Summary = plan.ThreatsEngaged > 0
|
||||
? $"分配 {plan.ThreatsEngaged} 个威胁,{plan.ThreatsUnengaged} 个无方案"
|
||||
: "无威胁被分配拦截方案";
|
||||
+ (reasons.Count > 0 ? "。原因: " + string.Join("; ", reasons) : "")
|
||||
: "无威胁被分配拦截方案"
|
||||
+ (reasons.Count > 0 ? "。原因: " + string.Join("; ", reasons) : "");
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// 拦截窗口可行性检查
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
/// <summary>无人机速度(km/h),从航路第一个 waypoint 读取</summary>
|
||||
private static float GetDroneSpeedKph(DroneWave threat)
|
||||
{
|
||||
float spd = (float)threat.Waypoints[0].Speed;
|
||||
if (spd <= 0) throw new InvalidOperationException($"威胁 {threat.WaveId}: 航路点速度必须 > 0");
|
||||
return spd;
|
||||
}
|
||||
|
||||
/// <summary>无人机速度(m/s)</summary>
|
||||
private static float GetDroneSpeedMs(DroneWave threat)
|
||||
=> GetDroneSpeedKph(threat) / 3.6f;
|
||||
|
||||
internal static bool HasInterceptWindow(DroneWave threat, float midArc,
|
||||
float expansionTime, float deliveryTime)
|
||||
{
|
||||
float travelArc = midArc - threat.DetectArc;
|
||||
if (travelArc <= 0) return false;
|
||||
float timeAvailable = travelArc / GetDroneSpeedMs(threat);
|
||||
return timeAvailable > expansionTime + deliveryTime;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// 候选生成(使用真实物理)
|
||||
// ═══════════════════════════════════════════════
|
||||
@ -260,7 +329,9 @@ namespace CounterDrone.Core.Algorithms
|
||||
var neededAmmo = MatchAmmo(_config, (PowerType)threat.Target.PowerType);
|
||||
var ammo = _ammoCatalog.First(a => a.AerosolType == (int)neededAmmo);
|
||||
|
||||
var (ammoEff, _, _) = ComputeEffectiveRadius(ammo, env);
|
||||
var (ammoEff, expansionTime, _) = ComputeEffectiveRadius(ammo, env);
|
||||
var mid = ThreatMidpoint(threat);
|
||||
float midArc = RouteGeometry.ArcLengthNearestTo(threat.Waypoints, mid.X, mid.Z);
|
||||
|
||||
foreach (var unit in availableUnits)
|
||||
{
|
||||
@ -268,12 +339,12 @@ namespace CounterDrone.Core.Algorithms
|
||||
|
||||
if (unit.Type == PlatformType.AirBased)
|
||||
{
|
||||
var c = BuildAirBasedCandidate(threat, unit, neededAmmo, ammo, ammoEff, env);
|
||||
var c = BuildAirBasedCandidate(threat, unit, neededAmmo, ammo, ammoEff, expansionTime, mid, midArc, env);
|
||||
if (c != null) candidates.Add(c);
|
||||
}
|
||||
else
|
||||
{
|
||||
var c = BuildGroundBasedCandidate(threat, unit, neededAmmo, ammo, ammoEff, env);
|
||||
var c = BuildGroundBasedCandidate(threat, unit, neededAmmo, ammo, ammoEff, expansionTime, mid, midArc, env);
|
||||
if (c != null) candidates.Add(c);
|
||||
}
|
||||
}
|
||||
@ -283,9 +354,9 @@ namespace CounterDrone.Core.Algorithms
|
||||
|
||||
private InterceptCandidate? BuildGroundBasedCandidate(DroneWave threat,
|
||||
FireUnit unit, AerosolType ammoType, AmmunitionSpec ammo,
|
||||
float effectiveR, CombatScene env)
|
||||
float effectiveR, float expansionTime,
|
||||
Vector3 mid, float midArc, 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);
|
||||
@ -295,9 +366,11 @@ namespace CounterDrone.Core.Algorithms
|
||||
if (dist > maxRange) return null;
|
||||
|
||||
float shellTime = dist / unit.MuzzleVelocity;
|
||||
if (!HasInterceptWindow(threat, midArc, expansionTime, shellTime)) return null;
|
||||
|
||||
float interceptTime = threat.ArrivalTime;
|
||||
|
||||
float avgSpeed = (float)threat.Target.TypicalSpeed / 3.6f;
|
||||
float avgSpeed = GetDroneSpeedMs(threat);
|
||||
float neededExposure = _damageModel.RequiredExposureSeconds((TargetType)threat.Target.TargetType, (PowerType)threat.Target.PowerType, ammoType);
|
||||
float actualExposure = effectiveR * 2f / avgSpeed;
|
||||
float prob = Math.Min(_config.MaxInterceptProbability, actualExposure / neededExposure);
|
||||
@ -313,22 +386,23 @@ namespace CounterDrone.Core.Algorithms
|
||||
|
||||
private InterceptCandidate? BuildAirBasedCandidate(DroneWave threat,
|
||||
FireUnit unit, AerosolType ammoType, AmmunitionSpec ammo,
|
||||
float effectiveR, CombatScene env)
|
||||
float effectiveR, float expansionTime,
|
||||
Vector3 mid, float midArc, CombatScene env)
|
||||
{
|
||||
if (unit.ReleaseAltitude <= 0 || unit.CruiseSpeed <= 0) return null;
|
||||
var mid = ThreatMidpoint(threat);
|
||||
float releaseAlt = unit.ReleaseAltitude;
|
||||
float flightDist = Kinematics.Distance3D(
|
||||
unit.Position.X, unit.Position.Y, unit.Position.Z,
|
||||
mid.X, releaseAlt, mid.Z);
|
||||
float flightTime = flightDist / unit.CruiseSpeed;
|
||||
float fallTime = Kinematics.AirDropFallTime(releaseAlt, (float)threat.Target.TypicalAltitude);
|
||||
float totalTime = flightTime + fallTime;
|
||||
float deliveryTime = flightTime + fallTime;
|
||||
|
||||
float interceptTime = threat.ArrivalTime;
|
||||
if (totalTime > interceptTime) return null;
|
||||
if (deliveryTime > interceptTime) return null;
|
||||
if (!HasInterceptWindow(threat, midArc, expansionTime, deliveryTime)) return null;
|
||||
|
||||
float avgSpeed = (float)threat.Target.TypicalSpeed / 3.6f;
|
||||
float avgSpeed = GetDroneSpeedMs(threat);
|
||||
float neededExposure = _damageModel.RequiredExposureSeconds((TargetType)threat.Target.TargetType, (PowerType)threat.Target.PowerType, ammoType);
|
||||
float actualExposure = effectiveR * 2f / avgSpeed;
|
||||
float prob = Math.Min(_config.MaxInterceptProbability, actualExposure / neededExposure);
|
||||
@ -362,7 +436,7 @@ namespace CounterDrone.Core.Algorithms
|
||||
CombatScene env, bool isAirBased, float turbulentRadius)
|
||||
{
|
||||
var cloudModel = new CloudExpansionModel(ammo, env);
|
||||
float avgSpeed = (float)threat.Target.TypicalSpeed / 3.6f;
|
||||
float avgSpeed = GetDroneSpeedMs(threat);
|
||||
float neededExposure = _damageModel.RequiredExposureSeconds(
|
||||
(TargetType)threat.Target.TargetType, (PowerType)threat.Target.PowerType, (AerosolType)ammo.AerosolType);
|
||||
float requiredCoverage = neededExposure * avgSpeed;
|
||||
@ -374,7 +448,7 @@ namespace CounterDrone.Core.Algorithms
|
||||
private float ComputeInterceptProbability(DroneWave threat,
|
||||
AmmunitionSpec ammo, int rounds, CombatScene env)
|
||||
{
|
||||
float avgSpeed = (float)threat.Target.TypicalSpeed / 3.6f;
|
||||
float avgSpeed = GetDroneSpeedMs(threat);
|
||||
var (effectiveR, _, _) = ComputeEffectiveRadius(ammo, env);
|
||||
float spacing = 2f * effectiveR;
|
||||
float actualCoverage = spacing * (rounds - 1) + 2f * effectiveR;
|
||||
@ -390,27 +464,26 @@ namespace CounterDrone.Core.Algorithms
|
||||
// 发射事件生成(真实物理)
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
private List<FireEvent> GenerateFireEventsAt(DroneWave threat, FireUnit unit,
|
||||
AerosolType ammoType, AmmunitionSpec ammo, CombatScene env, float targetOffset,
|
||||
int yLane, int yLanes, float formationWidth)
|
||||
private (List<FireEvent> Events, string? RejectReason) TryGenerateFireEvents(
|
||||
DroneWave threat, FireUnit unit, AerosolType ammoType, AmmunitionSpec ammo,
|
||||
CombatScene env, float targetOffset, int yLane, int yLanes, float formationWidth)
|
||||
{
|
||||
var events = new List<FireEvent>();
|
||||
var wps = threat.Waypoints;
|
||||
if (wps == null || wps.Count < 2) return events;
|
||||
if (wps == null || wps.Count < 2) return (events, "航路无效");
|
||||
|
||||
var cloudModel = new CloudExpansionModel(ammo, env);
|
||||
// 云龄 = expansionTime(云团生成后膨胀到有效半径的时长)
|
||||
float effectiveAge = cloudModel.TimeToReach(cloudModel.TurbulentRadius) * 0.9f;
|
||||
float effectiveAge = cloudModel.TimeToReach(cloudModel.TurbulentRadius) * _config.ExpansionFactor;
|
||||
float effectiveR = cloudModel.RadiusAt(effectiveAge);
|
||||
float expansionTime = cloudModel.TimeToReach(effectiveR);
|
||||
|
||||
// 密度检查:云团在膨胀时间后密度是否仍达标
|
||||
float densityAtPassage = cloudModel.DensityAt(expansionTime);
|
||||
if (densityAtPassage < (float)ammo.EffectiveConcentration)
|
||||
return events; // 云团到达时已稀释失效
|
||||
return (events, $"云团密度{densityAtPassage:E2}<有效阈值{ammo.EffectiveConcentration:E2}");
|
||||
|
||||
float typicalSpeed = (float)threat.Target.TypicalSpeed;
|
||||
if (typicalSpeed <= 0) return events;
|
||||
float typicalSpeed = GetDroneSpeedKph(threat);
|
||||
if (typicalSpeed <= 0) return (events, "目标速度无效");
|
||||
|
||||
// 航路感知布局:穿越点弧长 = 航路中点弧长 + 沿航路偏移
|
||||
// targetOffset 现在是沿航路切向的弧长偏移(不再是 X 分量),支持任意方向航路
|
||||
@ -436,10 +509,10 @@ namespace CounterDrone.Core.Algorithms
|
||||
// planner 是参谋,只能基于探测信息规划。威胁从探测边界被发现,
|
||||
// 飞行时间 = (拦截弧长 - 探测弧长) / 速度。无探测时 DetectArc=0(回退到起点)。
|
||||
float travelArc = crossArc - threat.DetectArc;
|
||||
if (travelArc < 0) return events; // 探测边界在拦截点之后,来不及拦截
|
||||
if (travelArc < 0) return (events, $"探测边界在拦截点之后({threat.DetectArc:F0}m≥{crossArc:F0}m)");
|
||||
float txArrival = RouteGeometry.TravelTimeTo(wps, travelArc, typicalSpeed);
|
||||
float recommendedTiming = txArrival - expansionTime;
|
||||
if (recommendedTiming <= 0f) return events;
|
||||
if (recommendedTiming <= 0f) return (events, $"膨胀{expansionTime:F1}s≥到达{txArrival:F1}s");
|
||||
|
||||
// 风偏补偿:云团生成后会被风吹偏,补偿时长 = 从生成到被穿过。
|
||||
// 每朵云的生成时刻不同(受发射/飞行时间影响),但 planner 此处用 expansionTime
|
||||
@ -449,6 +522,7 @@ namespace CounterDrone.Core.Algorithms
|
||||
float cloudGenZ = tz - wz * expansionTime;
|
||||
|
||||
float fireTime;
|
||||
float deliveryTime;
|
||||
if (unit.Type == PlatformType.AirBased)
|
||||
{
|
||||
if (unit.ReleaseAltitude <= 0 || unit.CruiseSpeed <= 0)
|
||||
@ -462,7 +536,8 @@ namespace CounterDrone.Core.Algorithms
|
||||
cloudGenX, releaseAlt, cloudGenZ);
|
||||
float flightDist = Math.Max(0f, distToCloud - driftDist);
|
||||
float flightTime = flightDist / cruiseSpd;
|
||||
fireTime = recommendedTiming - flightTime - fallTime;
|
||||
deliveryTime = flightTime + fallTime;
|
||||
fireTime = recommendedTiming - deliveryTime;
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -474,10 +549,12 @@ namespace CounterDrone.Core.Algorithms
|
||||
float dist = (float)Math.Sqrt(dx * dx + dz * dz);
|
||||
float heightDiff = (float)threat.Target.TypicalAltitude - unit.Position.Y;
|
||||
float shellTime = Kinematics.ParabolicShellTime(dist, heightDiff, mv);
|
||||
deliveryTime = shellTime;
|
||||
fireTime = recommendedTiming - shellTime;
|
||||
}
|
||||
|
||||
if (fireTime <= 0f) return events; // 平台来不及到达
|
||||
if (fireTime <= 0f)
|
||||
return (events, $"发射时机{fireTime:F1}s≤0(到达{deliveryTime:F1}s>窗口{recommendedTiming:F1}s)");
|
||||
|
||||
events.Add(new FireEvent
|
||||
{
|
||||
@ -489,7 +566,7 @@ namespace CounterDrone.Core.Algorithms
|
||||
MuzzleVelocity = unit.Type == PlatformType.AirBased ? 0f : unit.MuzzleVelocity,
|
||||
});
|
||||
|
||||
return events;
|
||||
return (events, null);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
@ -24,8 +24,10 @@ namespace CounterDrone.Core.Algorithms
|
||||
}
|
||||
|
||||
/// <summary>统一信息网络对某威胁的最早探测点。
|
||||
/// 遍历所有探测源,找威胁航路进入任一探测源范围的最早点(航路弧长最小)。
|
||||
/// 返回 (探测弧长, 该处精度)。无探测源时返回 (0, float.MaxValue)。</summary>
|
||||
/// 沿航路以步长 ≤50m 采样,对每个采样点调 IsInCoverage 做 3D 球冠判定。
|
||||
/// 返回首次落入任一探测源球冠的弧长与精度。
|
||||
/// 无探测源时返回 (0, float.MaxValue)(回退到航路起点,上帝视角)。
|
||||
/// 航路完全不经过任何探测范围时返回 (float.MaxValue, float.MaxValue)。</summary>
|
||||
public static (float detectArc, float accuracy) EarliestDetection(
|
||||
IReadOnlyList<Waypoint> threatRoute,
|
||||
List<DetectionSource> sources,
|
||||
@ -34,28 +36,102 @@ namespace CounterDrone.Core.Algorithms
|
||||
if (sources == null || sources.Count == 0 || threatRoute == null || threatRoute.Count < 2)
|
||||
return (0f, float.MaxValue);
|
||||
|
||||
// 计算总弧长,确定采样步长(≤50m)
|
||||
float totalArc = 0f;
|
||||
for (int i = 0; i < threatRoute.Count - 1; i++)
|
||||
{
|
||||
float dx = (float)(threatRoute[i + 1].PosX - threatRoute[i].PosX);
|
||||
float dy = (float)(threatRoute[i + 1].PosY - threatRoute[i].PosY);
|
||||
float dz = (float)(threatRoute[i + 1].PosZ - threatRoute[i].PosZ);
|
||||
totalArc += MathF.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
const float sampleStep = 50f;
|
||||
int sampleCount = Math.Max(1, (int)(totalArc / sampleStep) + 1);
|
||||
float stepArc = totalArc / sampleCount;
|
||||
|
||||
float bestArc = float.MaxValue;
|
||||
float bestAccuracy = float.MaxValue;
|
||||
|
||||
foreach (var src in sources)
|
||||
// 预计算每个源的 EffectiveRange(能见度衰减)
|
||||
var srcCache = new (float range, DetectionSource src)[sources.Count];
|
||||
for (int s = 0; s < sources.Count; s++)
|
||||
{
|
||||
var src = sources[s];
|
||||
float range = EffectiveRange(src.RadarRange, src.EORange, src.IRRange, visibility);
|
||||
if (range <= 0) continue;
|
||||
srcCache[s] = (range, src);
|
||||
}
|
||||
|
||||
// 找航路进入该探测源圆的最早弧长
|
||||
float arc = EarliestEntryArc(threatRoute, src.Position.X, src.Position.Z, range);
|
||||
if (arc < bestArc)
|
||||
// 沿航路采样
|
||||
for (int i = 0; i <= sampleCount; i++)
|
||||
{
|
||||
float sampleArc = i * stepArc;
|
||||
var (sx, sy, sz) = RouteGeometry.PositionAt(threatRoute, sampleArc);
|
||||
var pos = new Algorithms.Vector3(sx, sy, sz);
|
||||
|
||||
for (int s = 0; s < srcCache.Length; s++)
|
||||
{
|
||||
bestArc = arc;
|
||||
bestAccuracy = src.Accuracy;
|
||||
var (range, src) = srcCache[s];
|
||||
if (range <= 0) continue;
|
||||
if (IsInCoverage(pos, src.Position, range,
|
||||
src.MinElevation, src.MaxElevation,
|
||||
src.MinDetectAlt, src.MaxDetectAlt))
|
||||
{
|
||||
// 最早发现优先;同一点取精度最高
|
||||
if (sampleArc < bestArc)
|
||||
{
|
||||
bestArc = sampleArc;
|
||||
bestAccuracy = src.Accuracy;
|
||||
}
|
||||
else if (MathF.Abs(sampleArc - bestArc) < 0.001f
|
||||
&& src.Accuracy < bestAccuracy)
|
||||
{
|
||||
bestAccuracy = src.Accuracy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 一旦发现第一个覆盖点即停止(后续采样点距离更远,不可能是"最早")
|
||||
if (bestArc < float.MaxValue)
|
||||
break;
|
||||
}
|
||||
|
||||
if (bestArc == float.MaxValue)
|
||||
return (float.MaxValue, float.MaxValue); // 航路完全不经过任何探测范围
|
||||
return (float.MaxValue, float.MaxValue);
|
||||
return (bestArc, bestAccuracy);
|
||||
}
|
||||
|
||||
/// <summary>三维球冠探测判定:目标是否在探测设备的有效探测范围内。
|
||||
/// 三项判定:① 水平距离 ≤ effectiveRange;② 俯仰角 ∈ [MinElevation, MaxElevation];
|
||||
/// ③ 目标高度 ∈ [MinDetectAlt, MaxDetectAlt]。
|
||||
/// MinElevation/MaxElevation 为 float.MaxValue 时表示无限制(退化球冠→等价 2D 圆)。
|
||||
/// MinDetectAlt/MaxDetectAlt 为 float.MaxValue 时同理。
|
||||
/// planner 与运行时实时探测共用此判定。</summary>
|
||||
public static bool IsInCoverage(
|
||||
Algorithms.Vector3 target, Algorithms.Vector3 detector,
|
||||
float effectiveRange,
|
||||
float minElevation, float maxElevation,
|
||||
float minDetectAlt, float maxDetectAlt)
|
||||
{
|
||||
float dx = target.X - detector.X;
|
||||
float dy = target.Y - detector.Y; // 高度差
|
||||
float dz = target.Z - detector.Z;
|
||||
float horizDistSq = dx * dx + dz * dz;
|
||||
// ① 水平距离
|
||||
if (horizDistSq > effectiveRange * effectiveRange) return false;
|
||||
// ③ 高度门限
|
||||
if (minDetectAlt != float.MaxValue && target.Y < minDetectAlt) return false;
|
||||
if (maxDetectAlt != float.MaxValue && target.Y > maxDetectAlt) return false;
|
||||
// ② 俯仰角(正顶/正下按角度门限放行)
|
||||
if (minElevation == float.MaxValue && maxElevation == float.MaxValue) return true;
|
||||
float horizDist = MathF.Sqrt(horizDistSq);
|
||||
if (horizDist < 0.0001f) return true;
|
||||
float elevation = MathF.Atan2(dy, horizDist) * (180f / MathF.PI);
|
||||
if (minElevation != float.MaxValue && elevation < minElevation) return false;
|
||||
if (maxElevation != float.MaxValue && elevation > maxElevation) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>探测精度换算为抛撒散布半径(m)。
|
||||
/// 精度差 → 散布半径大,planner 增加横向覆盖。
|
||||
/// 当前模型:散布半径 = 精度值(如精度 100m → 散布 ±100m)。</summary>
|
||||
@ -64,51 +140,5 @@ namespace CounterDrone.Core.Algorithms
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,6 +29,12 @@ namespace CounterDrone.Core.Algorithms
|
||||
/// <summary>无探测设备时的默认探测精度 m(回退值,上帝视角但有标称误差)</summary>
|
||||
public float DefaultDetectionAccuracy { get; set; }
|
||||
|
||||
/// <summary>云团有效膨胀系数(0~1)。planner 取 Phase 2 膨胀时间的此比例作为有效云团年龄。默认 0.9</summary>
|
||||
public float ExpansionFactor { get; set; } = 0.9f;
|
||||
|
||||
/// <summary>拦截窗口安全余量(s)。计算所需探测弧长时在膨胀时间基础上额外预留。默认 1</summary>
|
||||
public float TimingSafetyMargin { get; set; } = 1f;
|
||||
|
||||
private const string ConfigFileName = "planner_config.json";
|
||||
|
||||
/// <summary>从 dataRoot 加载配置。文件缺失或字段非法即抛异常。</summary>
|
||||
@ -65,6 +71,10 @@ namespace CounterDrone.Core.Algorithms
|
||||
throw new InvalidDataException("AmmoMatch 不能为空");
|
||||
if (DefaultDetectionAccuracy < 0f)
|
||||
throw new InvalidDataException($"DefaultDetectionAccuracy 必须 >= 0,实际 {DefaultDetectionAccuracy}");
|
||||
if (ExpansionFactor <= 0f || ExpansionFactor > 1f)
|
||||
throw new InvalidDataException($"ExpansionFactor 必须在 (0, 1],实际 {ExpansionFactor}");
|
||||
if (TimingSafetyMargin < 0f)
|
||||
throw new InvalidDataException($"TimingSafetyMargin 必须 >= 0,实际 {TimingSafetyMargin}");
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
|
||||
@ -6,6 +6,12 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>CounterDrone.Core.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="sqlite-net-pcl" Version="1.9.172" />
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.0" />
|
||||
|
||||
@ -108,6 +108,11 @@ namespace CounterDrone.Core
|
||||
public double RadarRange { get; set; }
|
||||
public double EORange { get; set; }
|
||||
public double IRRange { get; set; }
|
||||
// 3D 球冠几何(缺失时退化 2D)
|
||||
public double? MinElevation { get; set; }
|
||||
public double? MaxElevation { get; set; }
|
||||
public double? MinDetectAlt { get; set; }
|
||||
public double? MaxDetectAlt { get; set; }
|
||||
|
||||
public EquipmentDeployment ToEquipmentDeployment(AerosolType ammoType, int quantity,
|
||||
double posX, double posY, double posZ)
|
||||
@ -132,6 +137,10 @@ namespace CounterDrone.Core
|
||||
RadarRange = RadarRange > 0 ? RadarRange : null,
|
||||
EORange = EORange > 0 ? EORange : null,
|
||||
IRRange = IRRange > 0 ? IRRange : null,
|
||||
MinElevation = MinElevation,
|
||||
MaxElevation = MaxElevation,
|
||||
MinDetectAlt = MinDetectAlt,
|
||||
MaxDetectAlt = MaxDetectAlt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -171,6 +180,11 @@ namespace CounterDrone.Core
|
||||
public double EORange { get; set; }
|
||||
public double IRRange { get; set; }
|
||||
public double Accuracy { get; set; } = 50.0;
|
||||
// 3D 球冠几何(缺失时退化 2D)
|
||||
public double? MinElevation { get; set; }
|
||||
public double? MaxElevation { get; set; }
|
||||
public double? MinDetectAlt { get; set; }
|
||||
public double? MaxDetectAlt { get; set; }
|
||||
|
||||
public EquipmentDeployment ToEquipmentDeployment(double posX, double posY, double posZ)
|
||||
{
|
||||
@ -185,6 +199,10 @@ namespace CounterDrone.Core
|
||||
EORange = EORange > 0 ? EORange : null,
|
||||
IRRange = IRRange > 0 ? IRRange : null,
|
||||
DetectionAccuracy = Accuracy > 0 ? Accuracy : null,
|
||||
MinElevation = MinElevation,
|
||||
MaxElevation = MaxElevation,
|
||||
MinDetectAlt = MinDetectAlt,
|
||||
MaxDetectAlt = MaxDetectAlt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,10 +103,10 @@ namespace CounterDrone.Core
|
||||
scene.WindDirection = (int)WindDirection.E;
|
||||
s.SaveScene(t.Id, scene);
|
||||
s.SaveTarget(t.Id, d.Targets.First(p => p.Id == "shahed").ToTargetConfig());
|
||||
s.SaveRoute(t.Id, "default", d.Formations.First(f => f.Id == "single").ToRoutePlan(), R("10km-h500", 150, d));
|
||||
s.SaveRoute(t.Id, "default", d.Formations.First(f => f.Id == "single").ToRoutePlan(), R("20km-h500", 200, d));
|
||||
s.SaveDeployment(t.Id, new List<EquipmentDeployment>
|
||||
{
|
||||
d.FireUnits.First(f => f.Id == "air-standard").ToEquipmentDeployment(AerosolType.InertGas, 3, 1500, 1000, 0),
|
||||
d.FireUnits.First(f => f.Id == "air-standard").ToEquipmentDeployment(AerosolType.InertGas, 3, 8000, 1000, 0),
|
||||
});
|
||||
s.SaveCloudDispersal(t.Id, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 });
|
||||
s.UpdateStep(t.Id, 5);
|
||||
@ -120,10 +120,10 @@ namespace CounterDrone.Core
|
||||
target.Quantity = 3;
|
||||
target.TypicalSpeed = 150;
|
||||
s.SaveTarget(t.Id, target);
|
||||
s.SaveRoute(t.Id, "default", d.Formations.First(f => f.Id == "line-3").ToRoutePlan(), R("10km-h500", 150, d));
|
||||
s.SaveRoute(t.Id, "default", d.Formations.First(f => f.Id == "line-3").ToRoutePlan(), R("20km-h500", 200, d));
|
||||
s.SaveDeployment(t.Id, new List<EquipmentDeployment>
|
||||
{
|
||||
d.FireUnits.First(f => f.Id == "air-standard").ToEquipmentDeployment(AerosolType.InertGas, 3, 1500, 1000, 0),
|
||||
d.FireUnits.First(f => f.Id == "air-standard").ToEquipmentDeployment(AerosolType.InertGas, 3, 8000, 1000, 0),
|
||||
});
|
||||
s.SaveCloudDispersal(t.Id, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 });
|
||||
s.UpdateStep(t.Id, 5);
|
||||
|
||||
@ -42,6 +42,9 @@ namespace CounterDrone.Core.Models
|
||||
/// <summary>湍流扩散系数,Phase 2 中 R=R₀+k√t 的 k 值</summary>
|
||||
public double TurbulentExpansionK { get; set; } = 5.0;
|
||||
|
||||
/// <summary>Phase 2 湍流膨胀持续时间(s),之后进入 Phase 3 高斯扩散。默认 30s</summary>
|
||||
public double Phase2Duration { get; set; } = 30.0;
|
||||
|
||||
public double MaxRadius { get; set; }
|
||||
|
||||
public double MaxDuration { get; set; }
|
||||
|
||||
@ -120,7 +120,8 @@ namespace CounterDrone.Core.Models
|
||||
DroneReachedTarget = 5,
|
||||
ZoneIntruded = 6,
|
||||
WaypointReached = 7,
|
||||
SimulationEnd = 8
|
||||
SimulationEnd = 8,
|
||||
PlanningFailed = 9
|
||||
}
|
||||
|
||||
// === 报告 ===
|
||||
|
||||
@ -59,5 +59,15 @@ namespace CounterDrone.Core.Models
|
||||
public double? IRRange { get; set; }
|
||||
/// <summary>探测精度 m(位置误差,影响抛撒散布范围)</summary>
|
||||
public double? DetectionAccuracy { get; set; }
|
||||
|
||||
// ── 三维球冠几何参数(探测范围约束)──
|
||||
/// <summary>俯仰角下限(度),NULL=无限制(等价全向)</summary>
|
||||
public double? MinElevation { get; set; }
|
||||
/// <summary>俯仰角上限(度),NULL=无限制</summary>
|
||||
public double? MaxElevation { get; set; }
|
||||
/// <summary>可探测高度下界 m,NULL=无限制</summary>
|
||||
public double? MinDetectAlt { get; set; }
|
||||
/// <summary>可探测高度上界 m,NULL=无限制</summary>
|
||||
public double? MaxDetectAlt { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,6 +42,7 @@ namespace CounterDrone.Core.Services
|
||||
sb.AppendLine($"| 到达目标 | {reached} |");
|
||||
sb.AppendLine($"| 侵入管控区 | {intruded} |");
|
||||
sb.AppendLine($"| 弹药发射 | {events.Count(e => e.Type == SimEventType.MunitionLaunched)} |");
|
||||
sb.AppendLine($"| 目标发现 | {events.Count(e => e.Type == SimEventType.TargetDetected)} |");
|
||||
sb.AppendLine($"| 云团生成 | {events.Count(e => e.Type == SimEventType.CloudGenerated)} |");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"**对抗结果**:{TranslateResult(result)}");
|
||||
@ -308,6 +309,7 @@ namespace CounterDrone.Core.Services
|
||||
SimEventType.DroneReachedTarget => "🏁 抵达目标",
|
||||
SimEventType.ZoneIntruded => "🚨 侵入管控区",
|
||||
SimEventType.SimulationEnd => "⏹️ 仿真结束",
|
||||
SimEventType.TargetDetected => "👁️ 目标发现",
|
||||
_ => t.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
62
src/CounterDrone.Core/Simulation/DetectionEntity.cs
Normal file
62
src/CounterDrone.Core/Simulation/DetectionEntity.cs
Normal file
@ -0,0 +1,62 @@
|
||||
using System.Collections.Generic;
|
||||
using CounterDrone.Core.Algorithms;
|
||||
|
||||
namespace CounterDrone.Core.Simulation
|
||||
{
|
||||
/// <summary>探测设备运行时实体。
|
||||
/// 持有 DetectionSource 静态参数 + 每无人机的探测状态机(Undetected ⇄ Detected)。
|
||||
/// Tick 扫描飞行无人机,首次进入探测范围 → 状态 Undetected→Detected,触发事件。
|
||||
/// 离开范围 → 回退 Undetected(决策 1)。
|
||||
/// 再次进入 → 重新置 Detected 并再次触发 TargetDetected。</summary>
|
||||
public class DetectionEntity
|
||||
{
|
||||
public string Id { get; }
|
||||
public DetectionSource Source { get; }
|
||||
|
||||
/// <summary>每无人机 Id → 探测状态(true=Detected, false=Undetected)</summary>
|
||||
private readonly Dictionary<string, bool> _droneStates = new();
|
||||
|
||||
public DetectionEntity(string id, DetectionSource source)
|
||||
{
|
||||
Id = id;
|
||||
Source = source;
|
||||
}
|
||||
|
||||
/// <summary>检查目标是否在本设备探测范围内(3D 球冠判定)</summary>
|
||||
public bool IsInRange(float targetX, float targetY, float targetZ,
|
||||
float visibility, out float effectiveRange)
|
||||
{
|
||||
effectiveRange = DetectionCalculator.EffectiveRange(
|
||||
Source.RadarRange, Source.EORange, Source.IRRange, visibility);
|
||||
if (effectiveRange <= 0) return false;
|
||||
|
||||
return DetectionCalculator.IsInCoverage(
|
||||
new Vector3(targetX, targetY, targetZ), Source.Position,
|
||||
effectiveRange,
|
||||
Source.MinElevation, Source.MaxElevation,
|
||||
Source.MinDetectAlt, Source.MaxDetectAlt);
|
||||
}
|
||||
|
||||
/// <summary>更新探测状态。首次进入返回 true(触发事件);离开返回 false;持续在范围内返回 null。</summary>
|
||||
public bool? UpdateState(string droneId, bool inRange)
|
||||
{
|
||||
bool wasDetected = _droneStates.TryGetValue(droneId, out var s) && s;
|
||||
|
||||
if (inRange)
|
||||
{
|
||||
_droneStates[droneId] = true;
|
||||
return wasDetected ? null : true; // 首次进入 → 触发事件
|
||||
}
|
||||
else
|
||||
{
|
||||
if (wasDetected)
|
||||
_droneStates[droneId] = false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>获取该设备对某无人机的当前探测状态</summary>
|
||||
public bool IsDetected(string droneId)
|
||||
=> _droneStates.TryGetValue(droneId, out var s) && s;
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,7 @@ namespace CounterDrone.Core.Simulation
|
||||
public TargetType TargetType { get; }
|
||||
public PowerType PowerType { get; }
|
||||
public float Wingspan { get; }
|
||||
public float TypicalSpeed { get; }
|
||||
public float CruiseSpeed { get; }
|
||||
public List<Waypoint> Route { get; }
|
||||
public float PosX { get; private set; }
|
||||
public float PosY { get; private set; }
|
||||
@ -42,7 +42,9 @@ namespace CounterDrone.Core.Simulation
|
||||
TargetType = (TargetType)config.TargetType;
|
||||
PowerType = (PowerType)config.PowerType;
|
||||
Wingspan = (float)config.Wingspan;
|
||||
TypicalSpeed = (float)config.TypicalSpeed;
|
||||
float spd = (float)route[0].Speed;
|
||||
if (spd <= 0) throw new InvalidOperationException($"无人机 {id}: 航路点速度必须 > 0");
|
||||
CruiseSpeed = spd;
|
||||
Route = route;
|
||||
|
||||
// 编队偏移应用到航点上(每架独立航路)
|
||||
@ -119,7 +121,7 @@ namespace CounterDrone.Core.Simulation
|
||||
return;
|
||||
}
|
||||
|
||||
float speedMs = TypicalSpeed / 3.6f;
|
||||
float speedMs = CruiseSpeed / 3.6f;
|
||||
_traveledArc += speedMs * deltaTime;
|
||||
|
||||
if (_traveledArc >= _totalArc)
|
||||
|
||||
@ -27,6 +27,7 @@ namespace CounterDrone.Core.Simulation
|
||||
public IReadOnlyList<DroneEntity> Drones => _drones;
|
||||
public IReadOnlyList<CloudEntity> Clouds => _clouds;
|
||||
public IReadOnlyList<MunitionEntity> Munitions => _munitions;
|
||||
public IReadOnlyList<DetectionEntity> DetectionEntities => _detectionEntities;
|
||||
public IReadOnlyList<SimEvent> Events => _allEvents;
|
||||
|
||||
public event Action<MunitionEntity>? OnMunitionLaunched;
|
||||
@ -34,10 +35,12 @@ namespace CounterDrone.Core.Simulation
|
||||
public event Action<DroneEntity>? OnDroneDestroyed;
|
||||
public event Action<DroneEntity>? OnDroneReachedTarget;
|
||||
public event Action<DroneEntity, ControlZoneEntity>? OnZoneIntruded;
|
||||
public event Action<DroneEntity, DetectionEntity>? OnTargetDetected;
|
||||
public event Action? OnSimulationEnded;
|
||||
|
||||
private List<DroneEntity> _drones = new();
|
||||
private List<PlatformEntity> _platforms = new();
|
||||
private List<DetectionEntity> _detectionEntities = new();
|
||||
private List<MunitionEntity> _munitions = new();
|
||||
private List<CloudEntity> _clouds = new();
|
||||
private List<ControlZoneEntity> _zones = new();
|
||||
@ -136,6 +139,13 @@ namespace CounterDrone.Core.Simulation
|
||||
_zones.Add(new ControlZoneEntity(z.Id, z.Name, z.VerticesJson,
|
||||
(float)z.MinAltitude, (float)z.MaxAltitude));
|
||||
|
||||
// 探测设备运行时实体
|
||||
_detectionEntities.Clear();
|
||||
var detectionSrcs = BuildDetectionSources(config);
|
||||
int detIdx = 0;
|
||||
foreach (var src in detectionSrcs)
|
||||
_detectionEntities.Add(new DetectionEntity($"det_{++detIdx}", src));
|
||||
|
||||
_munitions.Clear();
|
||||
_clouds.Clear();
|
||||
_allEvents.Clear();
|
||||
@ -154,6 +164,13 @@ namespace CounterDrone.Core.Simulation
|
||||
var detectionSources = BuildDetectionSources(config);
|
||||
var result = _planner.Plan(fireUnits, threats, _scene, detectionSources);
|
||||
SetFireSchedule(result.Best.MergedSchedule);
|
||||
if (_fireSchedule.Count == 0)
|
||||
_allEvents.Add(new SimEvent
|
||||
{
|
||||
Type = SimEventType.PlanningFailed,
|
||||
OccurredAt = 0,
|
||||
Description = result.Best.Summary,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -256,7 +273,7 @@ namespace CounterDrone.Core.Simulation
|
||||
for (int i = _removalIndices.Count - 1; i >= 0; i--)
|
||||
_clouds.RemoveAt(_removalIndices[i]);
|
||||
|
||||
// 4-5. 无人机:缓存飞行状态列表,避免多次 Where
|
||||
// 4. 无人机:缓存飞行状态列表,避免多次 Where
|
||||
_flyingDrones.Clear();
|
||||
for (int i = 0; i < _drones.Count; i++)
|
||||
if (_drones[i].Status == DroneStatus.Flying)
|
||||
@ -275,6 +292,48 @@ namespace CounterDrone.Core.Simulation
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 实时探测扫描(决策 1/2:离开回退、融合取最早/同刻取精度高)
|
||||
if (_detectionEntities.Count > 0)
|
||||
{
|
||||
float visibility = (float)_scene.Visibility;
|
||||
for (int di = 0; di < _flyingDrones.Count; di++)
|
||||
{
|
||||
var drone = _flyingDrones[di];
|
||||
DetectionEntity? bestDetector = null;
|
||||
float bestAccuracy = float.MaxValue;
|
||||
|
||||
for (int ei = 0; ei < _detectionEntities.Count; ei++)
|
||||
{
|
||||
var det = _detectionEntities[ei];
|
||||
bool inRange = det.IsInRange(drone.PosX, drone.PosY, drone.PosZ, visibility, out _);
|
||||
bool? stateChange = det.UpdateState(drone.Id, inRange);
|
||||
|
||||
if (stateChange == true)
|
||||
{
|
||||
// 决策 2:首次发现,同 tick 取精度最高(Accuracy 最小)
|
||||
if (bestDetector == null || det.Source.Accuracy < bestAccuracy)
|
||||
{
|
||||
bestDetector = det;
|
||||
bestAccuracy = det.Source.Accuracy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestDetector != null)
|
||||
{
|
||||
OnTargetDetected?.Invoke(drone, bestDetector);
|
||||
_frameEventPool.Add(new SimEvent
|
||||
{
|
||||
Type = SimEventType.TargetDetected,
|
||||
OccurredAt = SimulationTime,
|
||||
SourceId = bestDetector.Id,
|
||||
TargetId = drone.Id,
|
||||
Description = $"探测设备 {bestDetector.Id} 发现 drone {drone.Id} @({drone.PosX:F0},{drone.PosY:F0},{drone.PosZ:F0})",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 毁伤判定:积分路径段在云内的时间(快速排斥:先检查包围盒)
|
||||
var (wvx, wvy, wvz) = Kinematics.WindToVector((WindDirection)_scene.WindDirection, (float)_scene.WindSpeed);
|
||||
float cloudDx = wvx * scaledDt, cloudDy = wvy * scaledDt, cloudDz = wvz * scaledDt;
|
||||
@ -298,7 +357,7 @@ namespace CounterDrone.Core.Simulation
|
||||
0f, 0f, 0f, effR);
|
||||
if (inCloudDistance > 0)
|
||||
{
|
||||
float exposureIncrement = inCloudDistance / (drone.TypicalSpeed / 3.6f);
|
||||
float exposureIncrement = inCloudDistance / (drone.CruiseSpeed / 3.6f);
|
||||
drone.ExposureTime += exposureIncrement;
|
||||
var dmg = _damageModel.CalculateDamage(drone.TargetType, drone.PowerType, cloud.AerosolType, cloud.Dispersion.CoreDensity, drone.ExposureTime, exposureIncrement);
|
||||
drone.ApplyDamage(dmg);
|
||||
@ -419,6 +478,11 @@ namespace CounterDrone.Core.Simulation
|
||||
EORange = (float)(eq.EORange ?? 0f),
|
||||
IRRange = (float)(eq.IRRange ?? 0f),
|
||||
Accuracy = (float)(eq.DetectionAccuracy ?? 0f),
|
||||
// 3D 球冠几何:缺失时退化为无限制(等价 2D),保证存量数据平滑过渡
|
||||
MinElevation = (float)(eq.MinElevation ?? float.MaxValue),
|
||||
MaxElevation = (float)(eq.MaxElevation ?? float.MaxValue),
|
||||
MinDetectAlt = (float)(eq.MinDetectAlt ?? float.MaxValue),
|
||||
MaxDetectAlt = (float)(eq.MaxDetectAlt ?? float.MaxValue),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,18 +19,18 @@ namespace CounterDrone.Core
|
||||
/// <summary>从项目 data/ 目录复制 defaults.json 和 planner_config.json 到测试根目录</summary>
|
||||
private void CopyDataFiles()
|
||||
{
|
||||
var sourceDir = FindProjectDataDir();
|
||||
var sourceDir = FindProjectDataDirectory();
|
||||
if (sourceDir == null) return;
|
||||
foreach (var file in new[] { "defaults.json", "planner_config.json" })
|
||||
{
|
||||
var src = Path.Combine(sourceDir, file);
|
||||
var dst = Path.Combine(_root, file);
|
||||
if (File.Exists(src) && !File.Exists(dst))
|
||||
File.Copy(src, dst);
|
||||
if (File.Exists(src))
|
||||
File.Copy(src, dst, true); // 始终覆盖,确保测试用最新配置
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindProjectDataDir()
|
||||
public static string? FindProjectDataDirectory()
|
||||
{
|
||||
var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
|
||||
if (asmDir == null) return null;
|
||||
|
||||
@ -13,7 +13,7 @@ namespace CounterDrone.Core.Tests
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public DefensePlannerTests(ITestOutputHelper output) { _output = output; }
|
||||
private static readonly List<AmmunitionSpec> TestAmmo = TestData.Defaults.Ammunition;
|
||||
private static readonly List<AmmunitionSpec> TestAmmo = TestData.Ammo;
|
||||
|
||||
private static FireUnit MakeGroundUnit(string id, float posX, int munitions = 16)
|
||||
=> new()
|
||||
@ -479,5 +479,73 @@ namespace CounterDrone.Core.Tests
|
||||
Assert.True(resultDet.Best.ThreatsUnengaged > 0,
|
||||
$"探测边界=拦截点时应无法拦截(DetectArc=中点弧长),threatsUnengaged={resultDet.Best.ThreatsUnengaged}");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// HasInterceptWindow — 拦截窗口可行性(纯函数单元测试)
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
private static DroneWave QuickThreat(float typicalSpeed, float detectArc)
|
||||
{
|
||||
var t = MakeThreat(speed: typicalSpeed);
|
||||
t.DetectArc = detectArc;
|
||||
return t;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasInterceptWindow_DetectArcPastMidpoint_ReturnsFalse()
|
||||
{
|
||||
// DetectArc=3000 > midArc=2500 → travelArc<0 → 探测在拦截点之后
|
||||
var threat = QuickThreat(200f, 3000f);
|
||||
Assert.False(DefensePlanner.HasInterceptWindow(threat, 2500f, 30f, 2f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasInterceptWindow_DetectArcAtMidpoint_ReturnsFalse()
|
||||
{
|
||||
// DetectArc=2500 = midArc → travelArc=0 → 时间窗口为零
|
||||
var threat = QuickThreat(200f, 2500f);
|
||||
Assert.False(DefensePlanner.HasInterceptWindow(threat, 2500f, 30f, 2f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasInterceptWindow_WindowTooShort_ReturnsFalse()
|
||||
{
|
||||
// travelArc=500, speed=200km/h=55.6m/s → timeAvail≈9s
|
||||
// expansionTime=30s + deliveryTime=2s = 32s → 9s < 32s
|
||||
var threat = QuickThreat(200f, 2000f); // DetectArc=2000, midArc=2500 → travel=500
|
||||
Assert.False(DefensePlanner.HasInterceptWindow(threat, 2500f, 30f, 2f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasInterceptWindow_BarelyEnough_ReturnsTrue()
|
||||
{
|
||||
// travelArc=2000, speed=200km/h=55.6m/s → timeAvail≈36s
|
||||
// expansionTime=30s + deliveryTime=5s = 35s → 36s > 35s
|
||||
var threat = QuickThreat(200f, 500f); // DetectArc=500, midArc=2500 → travel=2000
|
||||
Assert.True(DefensePlanner.HasInterceptWindow(threat, 2500f, 30f, 5f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasInterceptWindow_SlowDroneHasMoreTime_ReturnsTrue()
|
||||
{
|
||||
// travelArc=500, speed=60km/h=16.7m/s → timeAvail≈30s
|
||||
// expansionTime=25s + deliveryTime=3s = 28s → 30s > 28s
|
||||
var threat = QuickThreat(60f, 2000f); // slower drone, same geometry
|
||||
Assert.True(DefensePlanner.HasInterceptWindow(threat, 2500f, 25f, 3f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasInterceptWindow_FastDroneHasLessTime_ReturnsFalse()
|
||||
{
|
||||
// travelArc=500, speed=300km/h=83.3m/s → timeAvail≈6s
|
||||
// expansionTime=20s + deliveryTime=1s = 21s → 6s < 21s
|
||||
var threat = QuickThreat(300f, 2000f);
|
||||
Assert.False(DefensePlanner.HasInterceptWindow(threat, 2500f, 20f, 1f));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 端到端:不可行方案返回清晰原因
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -166,5 +166,99 @@ namespace CounterDrone.Core.Tests
|
||||
Assert.Equal(100f, DetectionCalculator.SpreadRadius(100f), 0);
|
||||
Assert.Equal(0f, DetectionCalculator.SpreadRadius(0f), 0);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// IsInCoverage — 3D 球冠判定
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public void IsInCoverage_Inside3DCap_ReturnsTrue()
|
||||
{
|
||||
// 设备在 (0,0,0),目标在 (3000, 500, 4000) → 水平距离5000,高度差500
|
||||
// 俯仰角 ≈ atan2(500, 5000) ≈ 5.7°
|
||||
// 无限制球冠 → 通过
|
||||
var pos = new Vector3(3000, 500, 4000);
|
||||
var det = new Vector3(0, 0, 0);
|
||||
var result = DetectionCalculator.IsInCoverage(pos, det, 6000f,
|
||||
float.MaxValue, float.MaxValue, float.MaxValue, float.MaxValue);
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInCoverage_HorizontalTooFar_ReturnsFalse()
|
||||
{
|
||||
// 水平距离 8000 > 有效范围 5000
|
||||
var pos = new Vector3(6000, 500, 6000);
|
||||
var det = new Vector3(0, 0, 0);
|
||||
var result = DetectionCalculator.IsInCoverage(pos, det, 5000f,
|
||||
float.MaxValue, float.MaxValue, float.MaxValue, float.MaxValue);
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInCoverage_BelowMinDetectAlt_ReturnsFalse()
|
||||
{
|
||||
// 高度门限 200-1000,目标在 100m → 被拒绝
|
||||
var pos = new Vector3(3000, 100, 4000);
|
||||
var det = new Vector3(0, 0, 0);
|
||||
var result = DetectionCalculator.IsInCoverage(pos, det, 6000f,
|
||||
float.MaxValue, float.MaxValue, 200f, 1000f);
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInCoverage_AboveMaxDetectAlt_ReturnsFalse()
|
||||
{
|
||||
// 高度门限 200-1000,目标在 1200m → 被拒绝
|
||||
var pos = new Vector3(3000, 1200, 4000);
|
||||
var det = new Vector3(0, 0, 0);
|
||||
var result = DetectionCalculator.IsInCoverage(pos, det, 6000f,
|
||||
float.MaxValue, float.MaxValue, 200f, 1000f);
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInCoverage_ElevationTooLow_ReturnsFalse()
|
||||
{
|
||||
// 俯仰角限 -2° 到 30°,目标几乎在正下方(-89°) → 被拒绝
|
||||
var pos = new Vector3(1, -5000, 1);
|
||||
var det = new Vector3(0, 0, 0);
|
||||
var result = DetectionCalculator.IsInCoverage(pos, det, 6000f,
|
||||
-2f, 30f, float.MaxValue, float.MaxValue);
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInCoverage_ElevationTooHigh_ReturnsFalse()
|
||||
{
|
||||
// 俯仰角限 -2° 到 30°,目标仰角 ~45° → 被拒绝
|
||||
var pos = new Vector3(3000, 3000, 4000);
|
||||
var det = new Vector3(0, 0, 0);
|
||||
var result = DetectionCalculator.IsInCoverage(pos, det, 6000f,
|
||||
-2f, 30f, float.MaxValue, float.MaxValue);
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInCoverage_DirectlyAbove_WithElevationLimit_ReturnsTrue()
|
||||
{
|
||||
// 正顶(horizDist≈0),即使有俯仰角限制也应通过(物理上正上方肯定可见)
|
||||
var pos = new Vector3(0, 1000, 0);
|
||||
var det = new Vector3(0, 0, 0);
|
||||
var result = DetectionCalculator.IsInCoverage(pos, det, 2000f,
|
||||
-2f, 30f, float.MaxValue, float.MaxValue);
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInCoverage_UnlimitedCaps_EquivalentTo2DCircle()
|
||||
{
|
||||
// 所有限制为 MaxValue(无限制),等价于 2D 圆
|
||||
var pos = new Vector3(3000, float.MaxValue / 2, 4000);
|
||||
var det = new Vector3(0, 0, 0);
|
||||
var result = DetectionCalculator.IsInCoverage(pos, det, 6000f,
|
||||
float.MaxValue, float.MaxValue, float.MaxValue, float.MaxValue);
|
||||
Assert.True(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ namespace CounterDrone.Core.Tests
|
||||
{
|
||||
public class DispersionModelTests
|
||||
{
|
||||
private static AmmunitionSpec Ammo() => TestData.Defaults.Ammunition.First(a => a.AerosolType == (int)AerosolType.InertGas);
|
||||
private static AmmunitionSpec Ammo() => TestData.Ammo.First(a => a.AerosolType == (int)AerosolType.InertGas);
|
||||
|
||||
[Fact]
|
||||
public void Phase1_InitialRadius_FromBurstCharge()
|
||||
@ -70,7 +70,7 @@ namespace CounterDrone.Core.Tests
|
||||
[Fact]
|
||||
public void DefaultAmmo_ParametersInReasonableRange()
|
||||
{
|
||||
var inert = TestData.Defaults.Ammunition.First(a => a.AerosolType == (int)AerosolType.InertGas);
|
||||
var inert = TestData.Ammo.First(a => a.AerosolType == (int)AerosolType.InertGas);
|
||||
Assert.True(inert.BurstChargeKg is > 0.01 and < 5, "爆发药应 0.01~5kg");
|
||||
Assert.True(inert.TurbulentExpansionK is > 1 and < 20, "湍流系数应 1~20");
|
||||
Assert.True(inert.EffectiveConcentration is >= 0.0001 and < 0.1, "有效浓度阈值应 ≥ 0.0001");
|
||||
|
||||
@ -59,12 +59,12 @@ namespace CounterDrone.Core.Tests
|
||||
_scenario.SaveRoute(_taskId, "default", new RoutePlan { FormationMode = (int)FormationMode.Single },
|
||||
new List<Waypoint>
|
||||
{
|
||||
new Waypoint { PosX = 0, PosY = 300, PosZ = 0 },
|
||||
new Waypoint { PosX = 1000, PosY = 300, PosZ = 0 },
|
||||
new Waypoint { PosX = 0, PosY = 300, PosZ = 0, Speed = 100 },
|
||||
new Waypoint { PosX = 1000, PosY = 300, PosZ = 0, Speed = 100 },
|
||||
});
|
||||
|
||||
var engine = new SimulationEngine(_scenario, new FrameDataStore(new TestPathProvider(_testDir)),
|
||||
new DamageModelRouter(), new TestPathProvider(_testDir), new DefensePlanner(TestData.Defaults.Ammunition, TestPlannerConfig.Instance));
|
||||
new DamageModelRouter(), new TestPathProvider(_testDir), new DefensePlanner(TestData.Ammo, TestPlannerConfig.Instance));
|
||||
engine.Initialize(_taskId);
|
||||
engine.TimeScale = 4f;
|
||||
|
||||
@ -101,12 +101,12 @@ namespace CounterDrone.Core.Tests
|
||||
_scenario.SaveRoute(_taskId, "default", new RoutePlan { FormationMode = (int)FormationMode.Single },
|
||||
new List<Waypoint>
|
||||
{
|
||||
new Waypoint { PosX = 0, PosY = 300, PosZ = 0 },
|
||||
new Waypoint { PosX = 5000, PosY = 300, PosZ = 0 },
|
||||
new Waypoint { PosX = 0, PosY = 300, PosZ = 0, Speed = 600 },
|
||||
new Waypoint { PosX = 5000, PosY = 300, PosZ = 0, Speed = 600 },
|
||||
});
|
||||
|
||||
var engine = new SimulationEngine(_scenario, new FrameDataStore(new TestPathProvider(_testDir)),
|
||||
new DamageModelRouter(), new TestPathProvider(_testDir), new DefensePlanner(TestData.Defaults.Ammunition, TestPlannerConfig.Instance));
|
||||
new DamageModelRouter(), new TestPathProvider(_testDir), new DefensePlanner(TestData.Ammo, TestPlannerConfig.Instance));
|
||||
engine.Initialize(_taskId);
|
||||
engine.TimeScale = 4f;
|
||||
|
||||
@ -142,12 +142,12 @@ namespace CounterDrone.Core.Tests
|
||||
_scenario.SaveRoute(_taskId, "default", new RoutePlan { FormationMode = (int)FormationMode.Single },
|
||||
new List<Waypoint>
|
||||
{
|
||||
new Waypoint { PosX = 0, PosY = 500, PosZ = 0 },
|
||||
new Waypoint { PosX = 100000, PosY = 500, PosZ = 0 },
|
||||
new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 500 },
|
||||
new Waypoint { PosX = 100000, PosY = 500, PosZ = 0, Speed = 500 },
|
||||
});
|
||||
|
||||
var engine = new SimulationEngine(_scenario, new FrameDataStore(new TestPathProvider(_testDir)),
|
||||
new DamageModelRouter(), new TestPathProvider(_testDir), new DefensePlanner(TestData.Defaults.Ammunition, TestPlannerConfig.Instance));
|
||||
new DamageModelRouter(), new TestPathProvider(_testDir), new DefensePlanner(TestData.Ammo, TestPlannerConfig.Instance));
|
||||
engine.Initialize(_taskId);
|
||||
engine.TimeScale = 4f;
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@ namespace CounterDrone.Core.Tests
|
||||
_paths = new TestPathProvider(_testDir);
|
||||
var dbm = new DatabaseManager(_paths);
|
||||
_mainDb = dbm.OpenMainDb();
|
||||
_ammoCatalog = new List<AmmunitionSpec>(TestData.Defaults.Ammunition);
|
||||
_ammoCatalog = new List<AmmunitionSpec>(TestData.Ammo);
|
||||
|
||||
_scenario = new ScenarioService(
|
||||
new SimTaskRepository(_mainDb), new CombatSceneRepository(_mainDb),
|
||||
@ -154,7 +154,7 @@ namespace CounterDrone.Core.Tests
|
||||
var drone = eng.Drones[0];
|
||||
var launched = eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched);
|
||||
Assert.True(launched > 0);
|
||||
Assert.True(drone.Hp < 0.1f);
|
||||
Assert.True(drone.Hp < 0.1f, $"Hp={drone.Hp:F3} status={drone.Status} events={eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched)} launches");
|
||||
VerifyAndExportReport(eng, drone);
|
||||
}
|
||||
|
||||
@ -200,7 +200,7 @@ namespace CounterDrone.Core.Tests
|
||||
});
|
||||
_scenario.SaveDeployment(_taskId, new List<EquipmentDeployment>
|
||||
{
|
||||
TestData.Defaults.FireUnits.First(f => f.Id == "ground-light").ToEquipmentDeployment(AerosolType.InertGas, 1, 8000, 0, 50),
|
||||
TestData.All.FireUnits.First(f => f.Id == "ground-light").ToEquipmentDeployment(AerosolType.InertGas, 1, 8000, 0, 50),
|
||||
});
|
||||
_scenario.SaveCloudDispersal(_taskId, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 });
|
||||
var engA = RunSimulation(8000);
|
||||
@ -225,7 +225,7 @@ namespace CounterDrone.Core.Tests
|
||||
});
|
||||
_scenario.SaveDeployment(_taskId, new List<EquipmentDeployment>
|
||||
{
|
||||
TestData.Defaults.FireUnits.First(f => f.Id == "ground-light").ToEquipmentDeployment(AerosolType.InertGas, 1, 8000, 0, 50),
|
||||
TestData.All.FireUnits.First(f => f.Id == "ground-light").ToEquipmentDeployment(AerosolType.InertGas, 1, 8000, 0, 50),
|
||||
// 独立探测设备
|
||||
new EquipmentDeployment
|
||||
{
|
||||
|
||||
@ -35,7 +35,7 @@ namespace CounterDrone.Core.Tests
|
||||
new RoutePlanRepository(_mainDb), new WaypointRepository(_mainDb));
|
||||
|
||||
_engine = new SimulationEngine(_scenarioService, new FrameDataStore(paths),
|
||||
new DamageModelRouter(), paths, new DefensePlanner(TestData.Defaults.Ammunition, TestPlannerConfig.Instance));
|
||||
new DamageModelRouter(), paths, new DefensePlanner(TestData.Ammo, TestPlannerConfig.Instance));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@ -187,5 +187,168 @@ namespace CounterDrone.Core.Tests
|
||||
Assert.NotNull(launch);
|
||||
Assert.Contains("计划发射", launch.Description);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// T4.4 实时探测 — TargetDetected 事件
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
private void SetupScenarioWithDetection()
|
||||
{
|
||||
var task = _scenarioService.CreateTask("Detect", "");
|
||||
_taskId = task.Id;
|
||||
_scenarioService.SaveScene(_taskId, new CombatScene { WindSpeed = 0, Visibility = 10000 });
|
||||
_scenarioService.SaveTarget(_taskId, new TargetConfig
|
||||
{
|
||||
WaveId = "w1",
|
||||
TargetType = (int)TargetType.Piston,
|
||||
Quantity = 1,
|
||||
PowerType = (int)PowerType.Piston,
|
||||
TypicalSpeed = 60,
|
||||
TypicalAltitude = 300,
|
||||
});
|
||||
// 探测设备在 (5000, 0, 0),雷达 3000m,无角度/高度限制
|
||||
_scenarioService.SaveDeployment(_taskId, new List<EquipmentDeployment>
|
||||
{
|
||||
new EquipmentDeployment
|
||||
{
|
||||
EquipmentRole = (int)EquipmentRole.Detection,
|
||||
Quantity = 1, PositionX = 5000, PositionY = 0, PositionZ = 0,
|
||||
RadarRange = 3000,
|
||||
},
|
||||
new EquipmentDeployment
|
||||
{
|
||||
EquipmentRole = (int)EquipmentRole.LaunchPlatform,
|
||||
PlatformType = (int)PlatformType.GroundBased,
|
||||
Quantity = 1, PositionX = 0, PositionY = 0, PositionZ = 0,
|
||||
AerosolType = (int)AerosolType.InertGas, MunitionCount = 1, Cooldown = 5,
|
||||
},
|
||||
});
|
||||
_scenarioService.SaveCloudDispersal(_taskId, new CloudDispersal
|
||||
{
|
||||
AerosolType = (int)AerosolType.InertGas,
|
||||
DisperseHeight = 300,
|
||||
Duration = 60,
|
||||
RecommendedTiming = 10,
|
||||
PositionX = 1000, PositionY = 300, PositionZ = 0,
|
||||
PositionMode = (int)PositionMode.AlgorithmRecommended,
|
||||
});
|
||||
_scenarioService.SaveRoute(_taskId, "w1", new RoutePlan { FormationMode = (int)FormationMode.Single },
|
||||
new List<Waypoint>
|
||||
{
|
||||
new Waypoint { PosX = 0, PosY = 300, PosZ = 0, Speed = 60 },
|
||||
new Waypoint { PosX = 10000, PosY = 300, PosZ = 0, Speed = 60 },
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TargetDetected_FiresWhenDroneEntersRange()
|
||||
{
|
||||
SetupScenarioWithDetection();
|
||||
_engine.SetFireSchedule(new List<FireEvent>()); // 不发射弹药
|
||||
_engine.Initialize(_taskId);
|
||||
|
||||
// 无人机起点 X=0,探测设备在 X=5000 半径 3000 → 探测边界在 X=2000
|
||||
// 速度 60 km/h = 16.67 m/s,到达 X=2000 约需 120 秒
|
||||
Assert.NotEmpty(_engine.DetectionEntities);
|
||||
|
||||
SimEvent? detected = null;
|
||||
for (int i = 0; i < 6000 && detected == null; i++)
|
||||
{
|
||||
_engine.Tick(1f / 20f);
|
||||
detected = _engine.Events.FirstOrDefault(e => e.Type == SimEventType.TargetDetected);
|
||||
}
|
||||
|
||||
Assert.NotNull(detected);
|
||||
Assert.Equal(SimEventType.TargetDetected, detected!.Type);
|
||||
Assert.Equal("det_1", detected.SourceId);
|
||||
Assert.Contains("发现", detected.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TargetDetected_FiresAgainAfterLeaveAndReenter()
|
||||
{
|
||||
// 探测设备在 X=5000 半径 1000 → 入口 X=4000,出口 X=6000
|
||||
var task = _scenarioService.CreateTask("Reenter", "");
|
||||
_scenarioService.SaveScene(task.Id, new CombatScene { WindSpeed = 0, Visibility = 10000 });
|
||||
_scenarioService.SaveTarget(task.Id, new TargetConfig
|
||||
{
|
||||
WaveId = "w1", Quantity = 1, TypicalSpeed = 120, PowerType = (int)PowerType.Piston, TypicalAltitude = 300,
|
||||
});
|
||||
_scenarioService.SaveDeployment(task.Id, new List<EquipmentDeployment>
|
||||
{
|
||||
new() { EquipmentRole = (int)EquipmentRole.Detection, Quantity = 1, PositionX = 5000, PositionY = 0, PositionZ = 0, RadarRange = 1000 },
|
||||
new() { EquipmentRole = (int)EquipmentRole.LaunchPlatform, PlatformType = (int)PlatformType.GroundBased,
|
||||
Quantity = 1, PositionX = 0, PositionY = 0, PositionZ = 0, AerosolType = (int)AerosolType.InertGas, MunitionCount = 1 },
|
||||
});
|
||||
_scenarioService.SaveCloudDispersal(task.Id, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 300, Duration = 60 });
|
||||
_scenarioService.SaveRoute(task.Id, "w1", new RoutePlan(),
|
||||
new() { new() { PosX = 0, PosY = 300, PosZ = 0, Speed = 120 }, new() { PosX = 10000, PosY = 300, PosZ = 0, Speed = 120 } });
|
||||
|
||||
_engine.SetFireSchedule(new List<FireEvent>());
|
||||
_engine.Initialize(task.Id);
|
||||
|
||||
int detectCount = 0;
|
||||
for (int i = 0; i < 3000; i++)
|
||||
{
|
||||
_engine.Tick(1f / 20f);
|
||||
detectCount = _engine.Events.Count(e => e.Type == SimEventType.TargetDetected);
|
||||
if (detectCount >= 2) break; // 进入 + 离开回退后再次进入
|
||||
if (_engine.State == SimulationState.Completed) break;
|
||||
}
|
||||
|
||||
// 至少触发了首次进入事件(再次进入取决于仿真是否跑完出口→入口二阶段)
|
||||
Assert.True(detectCount >= 1, $"Expected >= 1 TargetDetected events, got {detectCount}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TargetDetected_NoDetectionEquipment_NoEvent()
|
||||
{
|
||||
// 使用普通的 SetupScenario(无探测设备)
|
||||
SetupScenario();
|
||||
_engine.Initialize(_taskId);
|
||||
|
||||
Assert.Empty(_engine.DetectionEntities);
|
||||
|
||||
for (int i = 0; i < 200; i++) _engine.Tick(1f / 20f);
|
||||
var detected = _engine.Events.Where(e => e.Type == SimEventType.TargetDetected).ToList();
|
||||
Assert.Empty(detected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TargetDetected_MultipleDetectors_FusesFirst()
|
||||
{
|
||||
// 两个探测设备:D1在 X=3000 半径 2000、D2在 X=7000 半径 4000
|
||||
// D1 边界 X=1000,D2 边界 X=3000 → D1 更早发现
|
||||
var task = _scenarioService.CreateTask("Fuse", "");
|
||||
_scenarioService.SaveScene(task.Id, new CombatScene { WindSpeed = 0, Visibility = 10000 });
|
||||
_scenarioService.SaveTarget(task.Id, new TargetConfig
|
||||
{
|
||||
WaveId = "w1", Quantity = 1, TypicalSpeed = 60, PowerType = (int)PowerType.Piston, TypicalAltitude = 300,
|
||||
});
|
||||
_scenarioService.SaveDeployment(task.Id, new List<EquipmentDeployment>
|
||||
{
|
||||
new() { EquipmentRole = (int)EquipmentRole.Detection, Quantity = 1, PositionX = 3000, PositionY = 0, PositionZ = 0, RadarRange = 2000, DetectionAccuracy = 50 },
|
||||
new() { EquipmentRole = (int)EquipmentRole.Detection, Quantity = 1, PositionX = 7000, PositionY = 0, PositionZ = 0, RadarRange = 4000, DetectionAccuracy = 30 },
|
||||
new() { EquipmentRole = (int)EquipmentRole.LaunchPlatform, PlatformType = (int)PlatformType.GroundBased,
|
||||
Quantity = 1, PositionX = 0, PositionY = 0, PositionZ = 0, AerosolType = (int)AerosolType.InertGas, MunitionCount = 1 },
|
||||
});
|
||||
_scenarioService.SaveCloudDispersal(task.Id, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 300, Duration = 60 });
|
||||
_scenarioService.SaveRoute(task.Id, "w1", new RoutePlan(),
|
||||
new() { new() { PosX = 0, PosY = 300, PosZ = 0, Speed = 60 }, new() { PosX = 10000, PosY = 300, PosZ = 0, Speed = 60 } });
|
||||
|
||||
_engine.SetFireSchedule(new List<FireEvent>());
|
||||
_engine.Initialize(task.Id);
|
||||
|
||||
SimEvent? detected = null;
|
||||
for (int i = 0; i < 6000 && detected == null; i++)
|
||||
{
|
||||
_engine.Tick(1f / 20f);
|
||||
detected = _engine.Events.FirstOrDefault(e => e.Type == SimEventType.TargetDetected);
|
||||
}
|
||||
|
||||
Assert.NotNull(detected);
|
||||
// D1 (det_1) 更早发现(X=1000 对 X=3000)
|
||||
Assert.Equal("det_1", detected!.SourceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,49 +1,58 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using CounterDrone.Core;
|
||||
using CounterDrone.Core.Models;
|
||||
using SQLite;
|
||||
|
||||
namespace CounterDrone.Core.Tests
|
||||
{
|
||||
/// <summary>测试用默认数据 — 内嵌 JSON,不依赖文件系统</summary>
|
||||
/// <summary>测试辅助:从 seeded 数据库读取(与生产环境同源)</summary>
|
||||
internal static class TestData
|
||||
{
|
||||
public static readonly DefaultData Defaults;
|
||||
private static readonly string _tempDir;
|
||||
private static readonly SQLiteConnection _db;
|
||||
|
||||
static TestData()
|
||||
{
|
||||
var json = @"
|
||||
{
|
||||
""version"": ""1"",
|
||||
""ammunition"": [
|
||||
{ ""Id"": ""inert"", ""AerosolType"": 0, ""Name"": ""惰性气体"", ""InitialRadius"": 3.8, ""CoreDensity"": 1.5, ""EdgeDensity"": 0.1, ""InitialTemperature"": 1800.0, ""BuoyancyFactor"": 0.3, ""EffectiveConcentration"": 0.0001, ""MaxRadius"": 100.0, ""MaxDuration"": 120.0, ""SourceStrength"": 10.0, ""BurstChargeKg"": 1.5, ""TurbulentExpansionK"": 3.0, ""ParticlesJson"": ""{}"" },
|
||||
{ ""Id"": ""active"", ""AerosolType"": 1, ""Name"": ""活性材料"", ""InitialRadius"": 5.0, ""CoreDensity"": 2.0, ""EdgeDensity"": 0.2, ""InitialTemperature"": 2400.0, ""BuoyancyFactor"": 0.6, ""EffectiveConcentration"": 0.0001, ""MaxRadius"": 80.0, ""MaxDuration"": 90.0, ""SourceStrength"": 12.0, ""BurstChargeKg"": 4.0, ""TurbulentExpansionK"": 4.0, ""ParticlesJson"": ""{}"" },
|
||||
{ ""Id"": ""fuel"", ""AerosolType"": 2, ""Name"": ""活性燃料"", ""InitialRadius"": 3.8, ""CoreDensity"": 1.8, ""EdgeDensity"": 0.15, ""InitialTemperature"": 1900.0, ""BuoyancyFactor"": 0.4, ""EffectiveConcentration"": 0.0001, ""MaxRadius"": 90.0, ""MaxDuration"": 100.0, ""SourceStrength"": 10.0, ""BurstChargeKg"": 1.5, ""TurbulentExpansionK"": 3.0, ""ParticlesJson"": ""{}"" }
|
||||
],
|
||||
""formations"": [
|
||||
{ ""Id"": ""single"", ""Name"": ""单机"", ""FormationMode"": 0, ""LateralCount"": 1, ""LongitudinalCount"": 1, ""LateralSpacing"": 0, ""LongitudinalSpacing"": 0 },
|
||||
{ ""Id"": ""line-3"", ""Name"": ""3机横队"", ""FormationMode"": 1, ""LateralCount"": 3, ""LongitudinalCount"": 1, ""LateralSpacing"": 50, ""LongitudinalSpacing"": 0 }
|
||||
],
|
||||
""routes"": [
|
||||
{ ""Id"": ""3km-h400"", ""Name"": ""3km-400m高"", ""Waypoints"": [{""X"":0,""Y"":400,""Z"":0},{""X"":3000,""Y"":400,""Z"":0}] },
|
||||
{ ""Id"": ""3km-h300"", ""Name"": ""3km-300m高"", ""Waypoints"": [{""X"":0,""Y"":300,""Z"":0},{""X"":3000,""Y"":300,""Z"":0}] },
|
||||
{ ""Id"": ""5km-h500"", ""Name"": ""5km-500m高"", ""Waypoints"": [{""X"":0,""Y"":500,""Z"":0},{""X"":5000,""Y"":500,""Z"":0}] },
|
||||
{ ""Id"": ""5km-h800"", ""Name"": ""5km-800m高"", ""Waypoints"": [{""X"":0,""Y"":800,""Z"":0},{""X"":5000,""Y"":800,""Z"":0}] },
|
||||
{ ""Id"": ""10km-h500"", ""Name"": ""10km-500m高"", ""Waypoints"": [{""X"":0,""Y"":500,""Z"":0},{""X"":10000,""Y"":500,""Z"":0}] }
|
||||
],
|
||||
""fireUnits"": [
|
||||
{ ""Id"": ""ground-light"", ""Name"": ""轻型地基"", ""PlatformType"": 1, ""GunCount"": 4, ""ChannelsPerGun"": 4, ""ChannelInterval"": 0.1, ""Cooldown"": 5.0, ""AmmoChangeTime"": 30.0, ""MuzzleVelocity"": 800.0, ""AmmoTypes"": [0,1], ""RadarRange"": 10000.0, ""EORange"": 6000.0, ""IRRange"": 3000.0 },
|
||||
{ ""Id"": ""ground-standard"", ""Name"": ""标准地基"", ""PlatformType"": 1, ""GunCount"": 4, ""ChannelsPerGun"": 4, ""ChannelInterval"": 0.1, ""Cooldown"": 5.0, ""AmmoChangeTime"": 30.0, ""MuzzleVelocity"": 800.0, ""AmmoTypes"": [0,1], ""RadarRange"": 15000.0, ""EORange"": 8000.0, ""IRRange"": 5000.0 },
|
||||
{ ""Id"": ""ground-heavy"", ""Name"": ""重型地基"", ""PlatformType"": 1, ""GunCount"": 6, ""ChannelsPerGun"": 4, ""ChannelInterval"": 1.0, ""Cooldown"": 5.0, ""AmmoChangeTime"": 30.0, ""MuzzleVelocity"": 600.0, ""AmmoTypes"": [0,1,2], ""RadarRange"": 20000.0, ""EORange"": 10000.0, ""IRRange"": 6000.0 },
|
||||
{ ""Id"": ""air-standard"", ""Name"": ""标准空基"", ""PlatformType"": 0, ""GunCount"": 1, ""ChannelsPerGun"": 8, ""ChannelInterval"": 1.0, ""Cooldown"": 5.0, ""AmmoChangeTime"": 30.0, ""CruiseSpeed"": 55.0, ""ReleaseAltitude"": 1500.0, ""AmmoTypes"": [0,1], ""EORange"": 12000.0, ""IRRange"": 8000.0 }
|
||||
],
|
||||
""targets"": [
|
||||
{ ""Id"": ""shahed"", ""Name"": ""活塞巡飞弹"", ""TargetType"": 3, ""PowerType"": 1, ""Wingspan"": 2.5, ""TypicalSpeed"": 200.0, ""TypicalAltitude"": 500.0 }
|
||||
],
|
||||
""detectionEquipment"": [],
|
||||
""weather"": [
|
||||
{ ""Id"": ""sunny-calm"", ""Name"": ""晴天无风"", ""WeatherType"": 0, ""WindSpeed"": 3.0, ""WindDirection"": 0, ""Temperature"": 25.0, ""Humidity"": 50.0, ""Pressure"": 1013.0, ""Visibility"": 8000.0 }
|
||||
]
|
||||
}";
|
||||
Defaults = DefaultData.FromJson(json);
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"cd_td_{Guid.NewGuid():N}");
|
||||
var paths = new TestPathProvider(_tempDir);
|
||||
var dbm = new DatabaseManager(paths);
|
||||
_db = dbm.OpenMainDb();
|
||||
}
|
||||
|
||||
private static List<AmmunitionSpec>? _ammo;
|
||||
public static List<AmmunitionSpec> Ammo =>
|
||||
_ammo ??= new List<AmmunitionSpec>(_db.Table<AmmunitionSpec>());
|
||||
|
||||
private static DefaultData? _all;
|
||||
public static DefaultData All
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_all == null)
|
||||
{
|
||||
var dir = TestPathProvider.FindProjectDataDirectory()!;
|
||||
_all = DefaultData.Load(new DirPathProvider(dir));
|
||||
}
|
||||
return _all;
|
||||
}
|
||||
}
|
||||
|
||||
private class DirPathProvider : IPathProvider
|
||||
{
|
||||
private readonly string _dir;
|
||||
public DirPathProvider(string dir) => _dir = dir;
|
||||
public string GetDataRoot() => _dir;
|
||||
public string GetMainDbPath() => throw new NotImplementedException();
|
||||
public string GetFramesDir() => throw new NotImplementedException();
|
||||
public string GetModelsDir() => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public static void Cleanup()
|
||||
{
|
||||
_db?.Close();
|
||||
if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user