From d8470bad3091e12a7300a82a9146f9152a6f045e Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Tue, 16 Jun 2026 14:10:23 +0800 Subject: [PATCH] =?UTF-8?q?Phase=2010:=20=E6=8E=A2=E6=B5=8B=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E9=93=BE=E8=B7=AF=E5=BC=80=E5=8F=91=20+=20planner=20?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=20+=20=E7=A1=AC=E7=BC=96=E7=A0=81=E6=B6=88?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 测试全通过 --- data/defaults.json | 29 ++- data/planner_config.json | 4 +- docs/design/technical/仿真器实体与事件映射.md | 24 ++- .../tasks/实施计划与任务跟踪.md | 30 ++-- docs/对接文档_Unity前端.md | 4 +- .../Algorithms/AlgorithmTypes.cs | 10 +- .../Algorithms/CloudExpansionModel.cs | 16 +- .../Algorithms/DefensePlanner.cs | 145 +++++++++++---- .../Algorithms/DetectionCalculator.cs | 142 +++++++++------ .../Algorithms/PlannerConfig.cs | 10 ++ .../CounterDrone.Core.csproj | 6 + src/CounterDrone.Core/DefaultData.cs | 18 ++ src/CounterDrone.Core/DefaultScenarios.cs | 8 +- .../Models/AmmunitionSpec.cs | 3 + src/CounterDrone.Core/Models/Enums.cs | 3 +- .../Models/EquipmentDeployment.cs | 10 ++ .../Services/ReportGenerator.cs | 2 + .../Simulation/DetectionEntity.cs | 62 +++++++ .../Simulation/DroneEntity.cs | 8 +- .../Simulation/SimulationEngine.cs | 68 +++++++- src/CounterDrone.Core/TestPathProvider.cs | 8 +- .../DefensePlannerTests.cs | 70 +++++++- .../DetectionCalculatorTests.cs | 94 ++++++++++ .../DispersionModelTests.cs | 4 +- .../CounterDrone.Core.Tests/EdgeCaseTests.cs | 18 +- .../FullPipelineTests.cs | 8 +- .../SimulationEngineTests.cs | 165 +++++++++++++++++- test/unit/CounterDrone.Core.Tests/TestData.cs | 81 +++++---- 28 files changed, 852 insertions(+), 198 deletions(-) create mode 100644 src/CounterDrone.Core/Simulation/DetectionEntity.cs diff --git a/data/defaults.json b/data/defaults.json index c182be7..5115d2a 100644 --- a/data/defaults.json +++ b/data/defaults.json @@ -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": [ diff --git a/data/planner_config.json b/data/planner_config.json index 92488ff..8518c27 100644 --- a/data/planner_config.json +++ b/data/planner_config.json @@ -14,5 +14,7 @@ "Piston": "InertGas", "Jet": "ActiveMaterial" }, - "DefaultDetectionAccuracy": 50.0 + "DefaultDetectionAccuracy": 50.0, + "ExpansionFactor": 0.9, + "TimingSafetyMargin": 1.0 } diff --git a/docs/design/technical/仿真器实体与事件映射.md b/docs/design/technical/仿真器实体与事件映射.md index 306378c..7429db6 100644 --- a/docs/design/technical/仿真器实体与事件映射.md +++ b/docs/design/technical/仿真器实体与事件映射.md @@ -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: 红色警报 │ diff --git a/docs/implementation/tasks/实施计划与任务跟踪.md b/docs/implementation/tasks/实施计划与任务跟踪.md index 237fe7d..e531264 100644 --- a/docs/implementation/tasks/实施计划与任务跟踪.md +++ b/docs/implementation/tasks/实施计划与任务跟踪.md @@ -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 — 探测实时链路 | ✅ | --- diff --git a/docs/对接文档_Unity前端.md b/docs/对接文档_Unity前端.md index c4974a2..70c3ce2 100644 --- a/docs/对接文档_Unity前端.md +++ b/docs/对接文档_Unity前端.md @@ -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(); 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(); ``` diff --git a/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs b/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs index eb31381..37c029c 100644 --- a/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs +++ b/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs @@ -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; } /// 探测精度 m(位置误差,影响抛撒散布范围) 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; } /// 规划方案 @@ -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; } /// 规划器输出 diff --git a/src/CounterDrone.Core/Algorithms/CloudExpansionModel.cs b/src/CounterDrone.Core/Algorithms/CloudExpansionModel.cs index a007717..6c95bba 100644 --- a/src/CounterDrone.Core/Algorithms/CloudExpansionModel.cs +++ b/src/CounterDrone.Core/Algorithms/CloudExpansionModel.cs @@ -17,31 +17,35 @@ namespace CounterDrone.Core.Algorithms /// Phase 1 爆轰初始半径 (m) public float InitialRadius => 3.3f * (float)Math.Pow(Math.Max(0.01, (float)_ammo.BurstChargeKg), 0.32f); - /// Phase 2 结束时的有效半径(30s 湍流膨胀) - public float TurbulentRadius => RadiusAt(30f); + /// Phase 2 结束时的有效半径 + public float TurbulentRadius => RadiusAt(Phase2Duration); /// 湍流膨胀系数 k public float TurbulentExpansionK => (float)_ammo.TurbulentExpansionK; + /// Phase 2 持续时间(s),从 AmmunitionSpec 读取 + public float Phase2Duration => (float)_ammo.Phase2Duration; + /// 指定时刻湍流膨胀半径 R(t) = R₀ + k√t 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)); } diff --git a/src/CounterDrone.Core/Algorithms/DefensePlanner.cs b/src/CounterDrone.Core/Algorithms/DefensePlanner.cs index 17307a5..5456b75 100644 --- a/src/CounterDrone.Core/Algorithms/DefensePlanner.cs +++ b/src/CounterDrone.Core/Algorithms/DefensePlanner.cs @@ -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(); 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(); + 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; } + // ═══════════════════════════════════════════════ + // 拦截窗口可行性检查 + // ═══════════════════════════════════════════════ + + /// 无人机速度(km/h),从航路第一个 waypoint 读取 + private static float GetDroneSpeedKph(DroneWave threat) + { + float spd = (float)threat.Waypoints[0].Speed; + if (spd <= 0) throw new InvalidOperationException($"威胁 {threat.WaveId}: 航路点速度必须 > 0"); + return spd; + } + + /// 无人机速度(m/s) + 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 GenerateFireEventsAt(DroneWave threat, FireUnit unit, - AerosolType ammoType, AmmunitionSpec ammo, CombatScene env, float targetOffset, - int yLane, int yLanes, float formationWidth) + private (List 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(); 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); } // ═══════════════════════════════════════════════ diff --git a/src/CounterDrone.Core/Algorithms/DetectionCalculator.cs b/src/CounterDrone.Core/Algorithms/DetectionCalculator.cs index 4e0fda2..8c1e972 100644 --- a/src/CounterDrone.Core/Algorithms/DetectionCalculator.cs +++ b/src/CounterDrone.Core/Algorithms/DetectionCalculator.cs @@ -24,8 +24,10 @@ namespace CounterDrone.Core.Algorithms } /// 统一信息网络对某威胁的最早探测点。 - /// 遍历所有探测源,找威胁航路进入任一探测源范围的最早点(航路弧长最小)。 - /// 返回 (探测弧长, 该处精度)。无探测源时返回 (0, float.MaxValue)。 + /// 沿航路以步长 ≤50m 采样,对每个采样点调 IsInCoverage 做 3D 球冠判定。 + /// 返回首次落入任一探测源球冠的弧长与精度。 + /// 无探测源时返回 (0, float.MaxValue)(回退到航路起点,上帝视角)。 + /// 航路完全不经过任何探测范围时返回 (float.MaxValue, float.MaxValue)。 public static (float detectArc, float accuracy) EarliestDetection( IReadOnlyList threatRoute, List 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); } + /// 三维球冠探测判定:目标是否在探测设备的有效探测范围内。 + /// 三项判定:① 水平距离 ≤ effectiveRange;② 俯仰角 ∈ [MinElevation, MaxElevation]; + /// ③ 目标高度 ∈ [MinDetectAlt, MaxDetectAlt]。 + /// MinElevation/MaxElevation 为 float.MaxValue 时表示无限制(退化球冠→等价 2D 圆)。 + /// MinDetectAlt/MaxDetectAlt 为 float.MaxValue 时同理。 + /// planner 与运行时实时探测共用此判定。 + 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; + } + /// 探测精度换算为抛撒散布半径(m)。 /// 精度差 → 散布半径大,planner 增加横向覆盖。 /// 当前模型:散布半径 = 精度值(如精度 100m → 散布 ±100m)。 @@ -64,51 +140,5 @@ namespace CounterDrone.Core.Algorithms return Math.Max(0f, accuracy); } - /// 折线航路进入圆(水平面 XZ)的最早弧长。 - /// 逐段求线段-圆交点,返回首次进入的累积弧长。 - /// 若起点已在圆内,返回 0。若完全不交,返回 float.MaxValue。 - private static float EarliestEntryArc(IReadOnlyList wps, float cx, float cz, float r) - { - float accumArc = 0f; - bool wasInside = IsInside((float)wps[0].PosX, (float)wps[0].PosZ, cx, cz, r); - if (wasInside) return 0f; // 起点已在探测范围内 - - for (int i = 0; i < wps.Count - 1; i++) - { - float ax = (float)wps[i].PosX, az = (float)wps[i].PosZ; - float bx = (float)wps[i + 1].PosX, bz = (float)wps[i + 1].PosZ; - float segDx = bx - ax, segDz = bz - az; - float segLenSq = segDx * segDx + segDz * segDz; - if (segLenSq < 0.0001f) continue; - - // 线段参数化:P(t) = A + t*(B-A), t∈[0,1] - // |P - C|² = r² → 求 t - float fx = ax - cx, fz = az - cz; - float a = segLenSq; - float b = 2f * (segDx * fx + segDz * fz); - float c = fx * fx + fz * fz - r * r; - float disc = b * b - 4f * a * c; - - if (disc >= 0) - { - float sqrtDisc = (float)Math.Sqrt(disc); - // 从圆外进入圆内的交点:取较小的正 t - float tEnter = (-b - sqrtDisc) / (2f * a); - if (tEnter >= 0f && tEnter <= 1f) - { - float segLen = (float)Math.Sqrt(segLenSq); - return accumArc + tEnter * segLen; - } - } - accumArc += (float)Math.Sqrt(segLenSq); - } - return float.MaxValue; - } - - private static bool IsInside(float px, float pz, float cx, float cz, float r) - { - float dx = px - cx, dz = pz - cz; - return dx * dx + dz * dz <= r * r; - } } } diff --git a/src/CounterDrone.Core/Algorithms/PlannerConfig.cs b/src/CounterDrone.Core/Algorithms/PlannerConfig.cs index 4682b5f..66421cc 100644 --- a/src/CounterDrone.Core/Algorithms/PlannerConfig.cs +++ b/src/CounterDrone.Core/Algorithms/PlannerConfig.cs @@ -29,6 +29,12 @@ namespace CounterDrone.Core.Algorithms /// 无探测设备时的默认探测精度 m(回退值,上帝视角但有标称误差) public float DefaultDetectionAccuracy { get; set; } + /// 云团有效膨胀系数(0~1)。planner 取 Phase 2 膨胀时间的此比例作为有效云团年龄。默认 0.9 + public float ExpansionFactor { get; set; } = 0.9f; + + /// 拦截窗口安全余量(s)。计算所需探测弧长时在膨胀时间基础上额外预留。默认 1 + public float TimingSafetyMargin { get; set; } = 1f; + private const string ConfigFileName = "planner_config.json"; /// 从 dataRoot 加载配置。文件缺失或字段非法即抛异常。 @@ -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() diff --git a/src/CounterDrone.Core/CounterDrone.Core.csproj b/src/CounterDrone.Core/CounterDrone.Core.csproj index 4cca765..adde67d 100644 --- a/src/CounterDrone.Core/CounterDrone.Core.csproj +++ b/src/CounterDrone.Core/CounterDrone.Core.csproj @@ -6,6 +6,12 @@ enable + + + <_Parameter1>CounterDrone.Core.Tests + + + diff --git a/src/CounterDrone.Core/DefaultData.cs b/src/CounterDrone.Core/DefaultData.cs index 1b52125..3fb0f56 100644 --- a/src/CounterDrone.Core/DefaultData.cs +++ b/src/CounterDrone.Core/DefaultData.cs @@ -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, }; } } diff --git a/src/CounterDrone.Core/DefaultScenarios.cs b/src/CounterDrone.Core/DefaultScenarios.cs index 8fbff47..12bb1b1 100644 --- a/src/CounterDrone.Core/DefaultScenarios.cs +++ b/src/CounterDrone.Core/DefaultScenarios.cs @@ -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 { - 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 { - 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); diff --git a/src/CounterDrone.Core/Models/AmmunitionSpec.cs b/src/CounterDrone.Core/Models/AmmunitionSpec.cs index baf3be3..be0f0ed 100644 --- a/src/CounterDrone.Core/Models/AmmunitionSpec.cs +++ b/src/CounterDrone.Core/Models/AmmunitionSpec.cs @@ -42,6 +42,9 @@ namespace CounterDrone.Core.Models /// 湍流扩散系数,Phase 2 中 R=R₀+k√t 的 k 值 public double TurbulentExpansionK { get; set; } = 5.0; + /// Phase 2 湍流膨胀持续时间(s),之后进入 Phase 3 高斯扩散。默认 30s + public double Phase2Duration { get; set; } = 30.0; + public double MaxRadius { get; set; } public double MaxDuration { get; set; } diff --git a/src/CounterDrone.Core/Models/Enums.cs b/src/CounterDrone.Core/Models/Enums.cs index 93e2085..9ed80a0 100644 --- a/src/CounterDrone.Core/Models/Enums.cs +++ b/src/CounterDrone.Core/Models/Enums.cs @@ -120,7 +120,8 @@ namespace CounterDrone.Core.Models DroneReachedTarget = 5, ZoneIntruded = 6, WaypointReached = 7, - SimulationEnd = 8 + SimulationEnd = 8, + PlanningFailed = 9 } // === 报告 === diff --git a/src/CounterDrone.Core/Models/EquipmentDeployment.cs b/src/CounterDrone.Core/Models/EquipmentDeployment.cs index b4003ec..bd864ee 100644 --- a/src/CounterDrone.Core/Models/EquipmentDeployment.cs +++ b/src/CounterDrone.Core/Models/EquipmentDeployment.cs @@ -59,5 +59,15 @@ namespace CounterDrone.Core.Models public double? IRRange { get; set; } /// 探测精度 m(位置误差,影响抛撒散布范围) public double? DetectionAccuracy { get; set; } + + // ── 三维球冠几何参数(探测范围约束)── + /// 俯仰角下限(度),NULL=无限制(等价全向) + public double? MinElevation { get; set; } + /// 俯仰角上限(度),NULL=无限制 + public double? MaxElevation { get; set; } + /// 可探测高度下界 m,NULL=无限制 + public double? MinDetectAlt { get; set; } + /// 可探测高度上界 m,NULL=无限制 + public double? MaxDetectAlt { get; set; } } } diff --git a/src/CounterDrone.Core/Services/ReportGenerator.cs b/src/CounterDrone.Core/Services/ReportGenerator.cs index f23a57d..45b1d5d 100644 --- a/src/CounterDrone.Core/Services/ReportGenerator.cs +++ b/src/CounterDrone.Core/Services/ReportGenerator.cs @@ -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(), }; } diff --git a/src/CounterDrone.Core/Simulation/DetectionEntity.cs b/src/CounterDrone.Core/Simulation/DetectionEntity.cs new file mode 100644 index 0000000..4255cea --- /dev/null +++ b/src/CounterDrone.Core/Simulation/DetectionEntity.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using CounterDrone.Core.Algorithms; + +namespace CounterDrone.Core.Simulation +{ + /// 探测设备运行时实体。 + /// 持有 DetectionSource 静态参数 + 每无人机的探测状态机(Undetected ⇄ Detected)。 + /// Tick 扫描飞行无人机,首次进入探测范围 → 状态 Undetected→Detected,触发事件。 + /// 离开范围 → 回退 Undetected(决策 1)。 + /// 再次进入 → 重新置 Detected 并再次触发 TargetDetected。 + public class DetectionEntity + { + public string Id { get; } + public DetectionSource Source { get; } + + /// 每无人机 Id → 探测状态(true=Detected, false=Undetected) + private readonly Dictionary _droneStates = new(); + + public DetectionEntity(string id, DetectionSource source) + { + Id = id; + Source = source; + } + + /// 检查目标是否在本设备探测范围内(3D 球冠判定) + 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); + } + + /// 更新探测状态。首次进入返回 true(触发事件);离开返回 false;持续在范围内返回 null。 + 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; + } + } + + /// 获取该设备对某无人机的当前探测状态 + public bool IsDetected(string droneId) + => _droneStates.TryGetValue(droneId, out var s) && s; + } +} diff --git a/src/CounterDrone.Core/Simulation/DroneEntity.cs b/src/CounterDrone.Core/Simulation/DroneEntity.cs index de95616..c7c786d 100644 --- a/src/CounterDrone.Core/Simulation/DroneEntity.cs +++ b/src/CounterDrone.Core/Simulation/DroneEntity.cs @@ -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 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) diff --git a/src/CounterDrone.Core/Simulation/SimulationEngine.cs b/src/CounterDrone.Core/Simulation/SimulationEngine.cs index f63c47f..8fb699d 100644 --- a/src/CounterDrone.Core/Simulation/SimulationEngine.cs +++ b/src/CounterDrone.Core/Simulation/SimulationEngine.cs @@ -27,6 +27,7 @@ namespace CounterDrone.Core.Simulation public IReadOnlyList Drones => _drones; public IReadOnlyList Clouds => _clouds; public IReadOnlyList Munitions => _munitions; + public IReadOnlyList DetectionEntities => _detectionEntities; public IReadOnlyList Events => _allEvents; public event Action? OnMunitionLaunched; @@ -34,10 +35,12 @@ namespace CounterDrone.Core.Simulation public event Action? OnDroneDestroyed; public event Action? OnDroneReachedTarget; public event Action? OnZoneIntruded; + public event Action? OnTargetDetected; public event Action? OnSimulationEnded; private List _drones = new(); private List _platforms = new(); + private List _detectionEntities = new(); private List _munitions = new(); private List _clouds = new(); private List _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), }); } } diff --git a/src/CounterDrone.Core/TestPathProvider.cs b/src/CounterDrone.Core/TestPathProvider.cs index dda5542..0673729 100644 --- a/src/CounterDrone.Core/TestPathProvider.cs +++ b/src/CounterDrone.Core/TestPathProvider.cs @@ -19,18 +19,18 @@ namespace CounterDrone.Core /// 从项目 data/ 目录复制 defaults.json 和 planner_config.json 到测试根目录 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; diff --git a/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs b/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs index 7eb0ae8..336f7fd 100644 --- a/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs +++ b/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs @@ -13,7 +13,7 @@ namespace CounterDrone.Core.Tests private readonly ITestOutputHelper _output; public DefensePlannerTests(ITestOutputHelper output) { _output = output; } - private static readonly List TestAmmo = TestData.Defaults.Ammunition; + private static readonly List 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)); + } + + // ═══════════════════════════════════════ + // 端到端:不可行方案返回清晰原因 + // ═══════════════════════════════════════ + } } diff --git a/test/unit/CounterDrone.Core.Tests/DetectionCalculatorTests.cs b/test/unit/CounterDrone.Core.Tests/DetectionCalculatorTests.cs index a4c38e4..b83310b 100644 --- a/test/unit/CounterDrone.Core.Tests/DetectionCalculatorTests.cs +++ b/test/unit/CounterDrone.Core.Tests/DetectionCalculatorTests.cs @@ -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); + } } } diff --git a/test/unit/CounterDrone.Core.Tests/DispersionModelTests.cs b/test/unit/CounterDrone.Core.Tests/DispersionModelTests.cs index 526ae0c..1cfc868 100644 --- a/test/unit/CounterDrone.Core.Tests/DispersionModelTests.cs +++ b/test/unit/CounterDrone.Core.Tests/DispersionModelTests.cs @@ -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"); diff --git a/test/unit/CounterDrone.Core.Tests/EdgeCaseTests.cs b/test/unit/CounterDrone.Core.Tests/EdgeCaseTests.cs index 5d69799..ea8896e 100644 --- a/test/unit/CounterDrone.Core.Tests/EdgeCaseTests.cs +++ b/test/unit/CounterDrone.Core.Tests/EdgeCaseTests.cs @@ -59,12 +59,12 @@ namespace CounterDrone.Core.Tests _scenario.SaveRoute(_taskId, "default", new RoutePlan { FormationMode = (int)FormationMode.Single }, new List { - 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 { - 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 { - 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; diff --git a/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs b/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs index 24da989..94fb506 100644 --- a/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs +++ b/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs @@ -29,7 +29,7 @@ namespace CounterDrone.Core.Tests _paths = new TestPathProvider(_testDir); var dbm = new DatabaseManager(_paths); _mainDb = dbm.OpenMainDb(); - _ammoCatalog = new List(TestData.Defaults.Ammunition); + _ammoCatalog = new List(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 { - 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 { - 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 { diff --git a/test/unit/CounterDrone.Core.Tests/SimulationEngineTests.cs b/test/unit/CounterDrone.Core.Tests/SimulationEngineTests.cs index 33e8aca..09f932b 100644 --- a/test/unit/CounterDrone.Core.Tests/SimulationEngineTests.cs +++ b/test/unit/CounterDrone.Core.Tests/SimulationEngineTests.cs @@ -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 + { + 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 + { + 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()); // 不发射弹药 + _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 + { + 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()); + _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 + { + 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()); + _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); + } } } diff --git a/test/unit/CounterDrone.Core.Tests/TestData.cs b/test/unit/CounterDrone.Core.Tests/TestData.cs index 54b5505..e9bfa24 100644 --- a/test/unit/CounterDrone.Core.Tests/TestData.cs +++ b/test/unit/CounterDrone.Core.Tests/TestData.cs @@ -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 { - /// 测试用默认数据 — 内嵌 JSON,不依赖文件系统 + /// 测试辅助:从 seeded 数据库读取(与生产环境同源) 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? _ammo; + public static List Ammo => + _ammo ??= new List(_db.Table()); + + 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); } } }