diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc8d66..eb32c82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,57 @@ --- +## [0.5.0] - 2026-06-14 + +### Added — 物理模型统一架构 + +- **RouteGeometry 静态工具类**:航路几何唯一实现(总弧长/弧长→位置/点→最近弧长/切向量/到达时间)。planner(预测)和 DroneEntity(执行)共用,消除本地折线插值 +- **PlannerConfig + planner_config.json**:planner 策略参数全部外置(重叠系数、威胁类型系数、弹药匹配表、临界/上限概率阈值)。代码零默认值,文件缺失即抛异常 +- 单元测试:RouteGeometry 16 项、PlannerConfig 6 项、DroneEntity L 形多 waypoint 1 项、Z 向航路感知 1 项 + +### Changed — planner 不再写本地物理公式 + +- **DefaultDefensePlanner 航路感知布局**:云团 offset 沿航路切向(`RouteGeometry.TangentAt`),不再写死 X 轴;穿越点用 `RouteGeometry.PositionAt`,到达时间用 `RouteGeometry.TravelTimeTo`——支持任意方向/折线航路 +- **云团重叠**:间距从 `2R`(相切)改为 `2R×(1−重叠系数)`,重叠 20% 由配置驱动,消除相切处的密度空洞 +- **DroneEntity 弧长驱动**:运动改为 `_traveledArc += speed×dt` + `RouteGeometry.PositionAt`,删除 `CurrentWaypointIndex` 逐段插值、`dist<1.0` 阈值、`step>=dist` snap 丢位移等本地逻辑 +- **CloudExpansionModel.RoundsNeeded** 签名:`effectiveRadius` 参数改为 `spacing`,间距由调用方(planner)按重叠系数传入,公式仍在共享模块 +- **策略参数从配置读**:威胁类型系数、弹药匹配表、临界概率(0.5)、拦截概率上限(0.95)全部从 `PlannerConfig` 读,planner 内零硬编码 + +### Fixed — planner 与引擎物理一致性 + +- **PathInSphere 云团参考系修正**:毁伤判定改在云团参考系计算(`drone.Pos − cloud.Center`),修正云团在 tick 内移动导致的每 tick ~2m 系统误差 +- **ComputeEffectiveRadius 云龄 bug**:从 `ArrivalTime×2`(无人机飞行时间,概念错误)改为 `expansionTime`(云团自身膨胀时长) + +### Removed + +- SimulationEngine 所有硬编码桌面路径的诊断写入(`planner_targets.csv`/`cloud_actual.csv`/`path_in_cloud.txt`/`_hitLog`/`_totalPathInCloud`)——这些造成集成测试并行时文件竞争(flaky 失败根因) +- DefaultDefensePlanner 的硬编码 `MatchTable`/`TypeCoefficient` 字典(移入配置) + +### Metrics + +- 测试 167 → **191**(+24),全量通过 41s +- 关键验证:活塞+西风、空基+东风有风场景击毁成功;Z 向航路云团沿航路分布;L 形折线多 waypoint 运动正确 + +--- + +## [0.4.1] - 2026-06-14 + +### Added + +- 天气纳入 Planner 规划:抛撒点风偏预补偿。云团生成后会在 `expansionTime` 内被风吹偏 `windVec × expansionTime`,Planner 逆风预置抛撒点 `cloudGen = 穿越点 − windVec × expansionTime`,使云团漂移后中心正好回到无人机航路上 +- 单元测试:`DispersionModelTests` Phase3 天气差异 2 项(雾 vs 晴、夜 vs 晴);`DefensePlannerTests` 风偏补偿 4 项(无风/东风/西风/北风方向性) + +### Fixed + +- `GaussianPuffDispersion.Tick` Phase3 写死 `WeatherType.Sunny` 的 bug:原代码 `GetStabilityClass((WeatherType)0, windSpeed)` 导致任何天气下扩散行为都相同,预估(`CloudExpansionModel` 已正确读 env)与运行时模型不一致。改为存储 `env` 字段,使用真实 `env.WeatherType` + +### Changed + +- `DefaultDefensePlanner.GenerateFireEventsAt` 区分无人机穿越点 `tx,tz`(用于 `txArrival` 计算)与云团生成点 `cloudGenX,Z`(弹药瞄准目标 + FireEvent 输出) +- 移除 `Solve` 中 `e.TargetX = mid.X + offset` 的覆盖(会抹掉风偏补偿;多发散布偏移已由 `targetOffset` 参数处理) + +--- + ## [0.4.0] - 2026-06-13 ### Added diff --git a/VERSION b/VERSION index 1d0ba9e..8f0916f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.5.0 diff --git a/data/planner_config.json b/data/planner_config.json new file mode 100644 index 0000000..c51d281 --- /dev/null +++ b/data/planner_config.json @@ -0,0 +1,17 @@ +{ + "CloudOverlapRatio": 0.2, + "CriticalProbabilityThreshold": 0.5, + "MaxInterceptProbability": 0.95, + "TypeCoefficient": { + "HighSpeed": 4.0, + "FixedWing": 2.0, + "Piston": 2.0, + "Rotor": 1.0, + "Electric": 1.0 + }, + "AmmoMatch": { + "Electric": "InertGas", + "Piston": "InertGas", + "Jet": "ActiveMaterial" + } +} diff --git a/docs/design/architecture/总体架构设计.md b/docs/design/architecture/总体架构设计.md index 36acee6..6876635 100644 --- a/docs/design/architecture/总体架构设计.md +++ b/docs/design/architecture/总体架构设计.md @@ -1004,6 +1004,56 @@ static readonly Dictionary MatchTable = new() - 最危险 = 无人机刚好擦边 ``` +##### 风偏补偿(天气纳入规划) + +云团生成后会被风吹偏。为使云团中心在无人机到达穿越点时正好漂移回航路,抛撒点须**逆风预置**: + +``` +穿越点 (tx, tz) = 无人机航路上的目标点(不变,用于 txArrival 计算) +抛撒点 (cloudGenX, cloudGenZ) = (tx, tz) − windVec × expansionTime + windVec = Kinematics.WindToVector(WindDirection, WindSpeed) // 只影响 X/Z + expansionTime ≈ 30s(Phase 2 湍流膨胀时长) +``` + +- **弹药瞄准抛撒点**:地基 `dist = |cloudGen − platform|`;空基 `distToCloud = |cloudGen − platform|` +- **FireEvent.TargetX/Z = cloudGen**:引擎据此生成云团,云团被风吹 `windVec × expansionTime` 后回到穿越点 +- **天气(稳定度)间接影响**:`CloudExpansionModel.RadiusAt` 已根据 `env.WeatherType` 推导 Pasquill 稳定度,影响 `expansionTime` 与有效半径估算;修复 `GaussianPuffDispersion` 写死 Sunny 的 bug 后,预估与运行时一致 + +> `tx, tz`(穿越点)与 `cloudGenX, cloudGenZ`(抛撒点)在有风时不同,无风时相同。原代码混淆二者,导致有风场景规划错误。 + +##### 物理模型统一原则(planner 与引擎共用) + +**planner 禁止写任何本地运动学/几何/毁伤公式**,全部调用与引擎共享的独立工具类。每个物理量只有一个实现: + +``` +共享工具类(唯一实例) planner 用途 引擎用途 +───────────────────────────────────────────────────────────────── +Kinematics(质点运动学) 弹道/风矢量/到达时间 MunitionEntity/DroneEntity +RouteGeometry(航路几何) 穿越点弧长/位置/切向 DroneEntity 位置更新 +CloudExpansionModel(云团膨胀) 有效半径/膨胀时间/弹药数 CloudEntity 半径演化 +DamageAssessment(毁伤几何) (暂未直接用,估算用模型) PathInSphere 穿云路径 +``` + +- **RouteGeometry**:纯静态工具类。`TotalLength`/`PositionAt(arcLen)`/`ArcLengthNearestTo(x,z)`/`TangentAt(arcLen)`/`TravelTimeTo(arcLen,speed)`。planner 用它定位穿越点、沿航路分布多发、算到达时间;DroneEntity 用它做弧长驱动的位置更新(`_traveledArc += speed×dt` → `PositionAt`)。 +- **云团重叠**:间距 `= 2R × (1 − CloudOverlapRatio)`,重叠比例由 `planner_config.json` 驱动(默认 20%)。`CloudExpansionModel.RoundsNeeded` 接受 `spacing` 参数,公式仍在共享模块,planner 只传配置值。 + +##### 配置外置(planner_config.json) + +planner 所有策略参数从 `data/planner_config.json` 读取,代码零默认值: + +```json +{ + "CloudOverlapRatio": 0.2, + "CriticalProbabilityThreshold": 0.5, + "MaxInterceptProbability": 0.95, + "TypeCoefficient": { "HighSpeed": 4.0, "FixedWing": 2.0, ... }, + "AmmoMatch": { "Electric": "InertGas", "Piston": "InertGas", "Jet": "ActiveMaterial" } +} +``` + +- `PlannerConfig.Load(IPathProvider)` 从 dataRoot 加载;文件缺失或字段非法即抛异常 +- `DefaultDefensePlanner` 构造函数必传 `PlannerConfig` + #### 6.5.3 Step C:反推平台部署 ``` diff --git a/docs/implementation/tasks/实施计划与任务跟踪.md b/docs/implementation/tasks/实施计划与任务跟踪.md index fdf9192..1fac28d 100644 --- a/docs/implementation/tasks/实施计划与任务跟踪.md +++ b/docs/implementation/tasks/实施计划与任务跟踪.md @@ -1,8 +1,8 @@ # 实施计划与任务跟踪 > **项目**:反无人机仿真系统后端 -> **文档版本**:V1.2 -> **更新日期**:2026-06-12 +> **文档版本**:V1.3 +> **更新日期**:2026-06-14 --- @@ -16,7 +16,7 @@ Phase 4 ✅ 仿真引擎 Phase 5 ✅ 报告生成 Phase 6 ✅ Unity 集成(桥接层 + 示例项目) Phase 7 ✅ 打磨收尾 -Phase 8 ⬜ 待开发 +Phase 8 🔄 待开发(天气/物理模型统一已完成) ──────────────────────── 已完成 P1-P7 ``` @@ -139,11 +139,13 @@ Phase 8 ⬜ 待开发 | 指标 | 值 | |------|------| -| 测试总数 | **141** | -| 行覆盖率 | **95.4%** | -| 分支覆盖率 | **80.5%** | -| 执行时间 | ~26 秒 | +| 测试总数 | **191** | +| 行覆盖率 | **95.4%**(待重测) | +| 分支覆盖率 | **80.5%**(待重测) | +| 执行时间 | ~41 秒 | | Core 程序集 | `CounterDrone.Core.dll` (.NET Standard 2.1) | +| 共享物理工具类 | `Kinematics` / `RouteGeometry` / `CloudExpansionModel` / `DamageAssessment` | +| 全局配置 | `data/planner_config.json`(planner 策略参数,代码零默认值) | | Unity 项目 | `src/Unity/`(Unity 2022.3.62) | | Unity Manager | 8 个 MonoBehaviour 桥接 + Bootstrap + SqliteConnectionTracker | | 零 Unity 依赖 | ✅ Core 可脱离 Unity 独立运行和测试 | @@ -154,11 +156,24 @@ Phase 8 ⬜ 待开发 > UI/视觉/动画属于前端同事范畴,以下仅列后端 Core 需实现的功能。 +### 8.0 天气与物理模型统一(✅ 已完成) + +| # | 功能 | 说明 | +|---|------|------| +| 8.0.1 | 天气纳入扩散模型 | ✅ 修复 `GaussianPuffDispersion` 写死 Sunny 的 bug,Phase3 用真实 `env.WeatherType` 推导 Pasquill 稳定度 | +| 8.0.2 | Planner 风偏补偿 | ✅ 抛撒点逆风预置(`cloudGen = 穿越点 − windVec×expansionTime`),云团漂移后回到航路 | +| 8.0.3 | 去除无人机风偏叠加 | ✅ DroneEntity 不再叠加风位移(真实无人机有飞控修正),planner 与引擎速度模型一致 | +| 8.0.4 | PathInSphere 云团参考系修正 | ✅ 毁伤判定改在云团参考系(`drone.Pos − cloud.Center`),修正移动球导致的每 tick ~2m 系统误差 | +| 8.0.5 | ComputeEffectiveRadius 云龄 bug | ✅ 从 `ArrivalTime×2`(无人机飞行时间,概念错误)改为 `expansionTime`(云团自身膨胀时长) | +| 8.0.6 | 物理模型统一架构 | ✅ 新增 `RouteGeometry` 静态工具类,planner 与引擎共用航路几何;planner 删除所有本地物理公式 | +| 8.0.7 | 配置外置 | ✅ 新增 `PlannerConfig` + `planner_config.json`,策略参数(重叠系数、类型系数、弹药匹配、概率阈值)全部从配置读,代码零默认值 | +| 8.0.8 | 云团重叠布局 | ✅ 间距 `2R×(1−重叠比例)`,默认重叠 20%,消除相切处密度空洞;offset 沿航路切向,支持任意方向/折线航路 | + ### 8.1 仿真增强 | # | 功能 | 说明 | |---|------|------| -| 8.1.1 | 探测设备搜索逻辑 | `DetectionEntity` 类已存在,需接入 `SimulationEngine` 实现探测→火控链路闭环 | +| 8.1.1 | 探测设备搜索逻辑 | `DetectionEntity` 类已存在,需接入 `SimulationEngine` 实现探测→火控链路闭环。天气(能见度/日夜)对光电/红外探测距离的衰减在此实现 | | 8.1.2 | 蜂群运动模型 | `FormationMode.Swarm` 枚举已定义,需差异化行为(随机扰动、个体差异) | | 8.1.3 | 空基平台 + DefensePlanner | ✅ 五步规划引擎,通道模型,物理间隔错发,路径积分毁伤判定 | ✅ | | 8.1.4 | 预置典型目标库 | 具体无人机型号 JSON 配置(如 DJI Mavic 3、Shahed-136 等),导入 `TargetConfig` 默认值 | diff --git a/docs/requirements/changes/2026-06-14-天气纳入规划与物理模型统一.md b/docs/requirements/changes/2026-06-14-天气纳入规划与物理模型统一.md new file mode 100644 index 0000000..6c4bc36 --- /dev/null +++ b/docs/requirements/changes/2026-06-14-天气纳入规划与物理模型统一.md @@ -0,0 +1,35 @@ +# 天气纳入 Planner 规划与物理模型统一 + +- **日期**:2026-06-14 +- **提出人**:tian +- **关联需求**:技术要求终版 2.2.3(云团扩散效果受气象条件影响) +- **优先级**:高 + +## 变更描述 + +将天气/风要素纳入 DefensePlanner 规划,并在此过程中发现并修复 planner 与仿真引擎物理模型分裂的根本问题。 + +### 起因 +原 planner 未考虑天气对云团漂移的影响,有风场景下规划方案与仿真结果不一致。深入排查后发现根因不止于风偏补偿,而是 planner 大量本地重写了运动学/几何公式(直线距离、写死 X 轴 offset、`2R` 相切假设等),与引擎的实际行为(折线航路、云团移动、PathInSphere 几何)系统性偏离。 + +### 变更内容 +1. **天气纳入扩散模型**:修复 `GaussianPuffDispersion` 写死 `WeatherType.Sunny` 的 bug,Phase3 高斯扩散改用真实环境天气推导 Pasquill 稳定度 +2. **Planner 风偏补偿**:抛撒点逆风预置,云团生成后漂移 expansionTime 秒回到无人机穿越点 +3. **PathInSphere 云团参考系修正**:毁伤判定改在云团参考系计算,修正云团在 tick 内移动导致的每 tick ~2m 系统误差(关键根因) +4. **物理模型统一架构**:新增 `RouteGeometry` 静态工具类,planner 与引擎共用航路几何;planner 删除所有本地物理公式 +5. **配置外置**:新增 `PlannerConfig` + `planner_config.json`,策略参数(重叠系数、类型系数、弹药匹配、概率阈值)全部从配置读,代码零默认值 +6. **云团重叠布局**:间距 `2R×(1−重叠比例)`,默认重叠 20%,offset 沿航路切向,支持任意方向/折线航路 + +## 影响范围 + +- [x] 接口变更:`DefaultDefensePlanner` 构造函数必传 `PlannerConfig`;`CloudExpansionModel.RoundsNeeded` 签名变更(`effectiveRadius` → `spacing`) +- [ ] 数据库变更 +- [ ] UI 变更 +- [x] 文档变更:架构设计 6.5 节、CHANGELOG、VERSION、实施计划 + +## 验收 + +- 全量测试 167 → **191**(+24),41s 通过 +- 活塞+西风 5m/s、空基+东风 5m/s 有风场景击毁成功(非边界) +- Z 向航路云团沿航路分布(不再写死 X 轴) +- L 形折线多 waypoint 运动正确 diff --git a/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs b/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs index 9839c38..5433b6c 100644 --- a/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs +++ b/src/CounterDrone.Core/Algorithms/AlgorithmTypes.cs @@ -108,14 +108,16 @@ namespace CounterDrone.Core.Algorithms /// 综合优先级 = 威胁指数 / (到达时间 + 1) public float Priority => ArrivalTime > -1 ? ThreatIndex / (ArrivalTime + 1f) : ThreatIndex; - /// 预计到达航路中点的时间(秒) + /// 预计到达航路中点的时间(秒)。 + /// 物理含义:匀速直线运动从航路起点到中点的飞行时间。 public float GetArrivalTime() { if (Waypoints.Count < 2) return 0; - var start = new Vector3((float)Waypoints[0].PosX, (float)Waypoints[0].PosY, (float)Waypoints[0].PosZ); - var end = new Vector3((float)Waypoints[^1].PosX, (float)Waypoints[^1].PosY, (float)Waypoints[^1].PosZ); - var speed = (float)Target.TypicalSpeed / 3.6f; - return start.DistanceTo(end) / speed / 2f; + var s = Waypoints[0]; + 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); } } diff --git a/src/CounterDrone.Core/Algorithms/CloudExpansionModel.cs b/src/CounterDrone.Core/Algorithms/CloudExpansionModel.cs index 1e6a9b8..713b207 100644 --- a/src/CounterDrone.Core/Algorithms/CloudExpansionModel.cs +++ b/src/CounterDrone.Core/Algorithms/CloudExpansionModel.cs @@ -69,12 +69,11 @@ namespace CounterDrone.Core.Algorithms /// 覆盖指定距离需要的云团数量 /// 需要覆盖的距离 (m) - /// 使用哪个半径值(null=默认TurbulentRadius) - public int RoundsNeeded(float requiredCoverage, float? effectiveRadius = null) + /// 相邻云团中心间距 (m),默认=2R(相切)。重叠时由调用方传入更小值。 + public int RoundsNeeded(float requiredCoverage, float? spacing = null) { - float R = effectiveRadius ?? TurbulentRadius; - float spacing = 2f * R; - return Math.Max(1, (int)Math.Ceiling(requiredCoverage / spacing)); + float s = spacing ?? 2f * TurbulentRadius; + return Math.Max(1, (int)Math.Ceiling(requiredCoverage / s)); } /// 达到指定半径所需时间 (s) diff --git a/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs b/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs index 00ff7f5..22c8ac2 100644 --- a/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs +++ b/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs @@ -5,36 +5,23 @@ using CounterDrone.Core.Models; namespace CounterDrone.Core.Algorithms { - /// 默认防御规划器 — 五步流水线,全部使用真实物理计算 + /// 默认防御规划器 — 五步流水线,全部调用与引擎共享的物理工具类 public class DefaultDefensePlanner : IDefensePlanner { private readonly List _ammoCatalog; private readonly IDamageModel _damageModel; + private readonly PlannerConfig _config; - public DefaultDefensePlanner(List ammoCatalog, IDamageModel damageModel = null) + public DefaultDefensePlanner(List ammoCatalog, PlannerConfig config, + IDamageModel damageModel = null) { _ammoCatalog = ammoCatalog ?? throw new ArgumentNullException(nameof(ammoCatalog)); + _config = config ?? throw new ArgumentNullException(nameof(config)); _damageModel = damageModel ?? new DamageModelRouter(); if (_ammoCatalog.Count == 0) throw new ArgumentException("弹药规格目录不能为空"); } - private static readonly Dictionary MatchTable = new() - { - { PowerType.Electric, AerosolType.InertGas }, - { PowerType.Piston, AerosolType.InertGas }, - { PowerType.Jet, AerosolType.ActiveMaterial }, - }; - - private static readonly Dictionary TypeCoefficient = new() - { - { TargetType.HighSpeed, 4f }, - { TargetType.FixedWing, 2f }, - { TargetType.Piston, 2f }, - { TargetType.Rotor, 1f }, - { TargetType.Electric, 1f }, - }; - // ═══════════════════════════════════════════════ // 五步流水线 // ═══════════════════════════════════════════════ @@ -59,14 +46,14 @@ namespace CounterDrone.Core.Algorithms foreach (var t in threats) { t.ArrivalTime = t.GetArrivalTime(); - t.ThreatIndex = CalcThreatIndex(t.Target); + t.ThreatIndex = CalcThreatIndex(_config, t.Target); } var sorted = threats.OrderByDescending(t => t.Priority).ToList(); // Step 2-4: 贪心分配求解 // 预先检查:所有需要的弹药类型都在目录中 var neededTypes = sorted - .Select(t => MatchAmmo((PowerType)t.Target.PowerType)) + .Select(t => MatchAmmo(_config, (PowerType)t.Target.PowerType)) .Distinct() .ToList(); foreach (var t in neededTypes) @@ -87,9 +74,9 @@ namespace CounterDrone.Core.Algorithms // Step 1: 威胁指数 // ═══════════════════════════════════════════════ - private static float CalcThreatIndex(TargetConfig target) + private static float CalcThreatIndex(PlannerConfig config, TargetConfig target) { - float typeCoef = TypeCoefficient.GetValueOrDefault((TargetType)target.TargetType, 1f); + float typeCoef = config.TypeCoefficient.GetValueOrDefault((TargetType)target.TargetType, 1f); float speedCoef = (float)target.TypicalSpeed / 60f; return typeCoef * speedCoef; } @@ -98,9 +85,9 @@ namespace CounterDrone.Core.Algorithms // Step 2: 弹药匹配 // ═══════════════════════════════════════════════ - private static AerosolType MatchAmmo(PowerType power) + private static AerosolType MatchAmmo(PlannerConfig config, PowerType power) { - return MatchTable.GetValueOrDefault(power, AerosolType.InertGas); + return config.AmmoMatch.GetValueOrDefault(power, AerosolType.InertGas); } // ═══════════════════════════════════════════════ @@ -129,11 +116,11 @@ namespace CounterDrone.Core.Algorithms } // 计算总弹药需求 - var neededAmmo = MatchAmmo((PowerType)threat.Target.PowerType); + var neededAmmo = MatchAmmo(_config, (PowerType)threat.Target.PowerType); var ammo = _ammoCatalog.First(a => a.AerosolType == (int)neededAmmo); // 单机需求 - var (effectiveRadius, expansionTime, turbulentRadius) = ComputeEffectiveRadius(threat, ammo, env); + var (effectiveRadius, expansionTime, turbulentRadius) = ComputeEffectiveRadius(ammo, env); int singleNeeded = CalcRoundsNeeded(threat, ammo, env, false, turbulentRadius); // 横向编队:每种 Width = Quantity 个独立车道 @@ -147,7 +134,8 @@ namespace CounterDrone.Core.Algorithms int totalRoundsNeeded = singleNeeded * yLanes; // 逐单元分配:每个单元锁定一个 Y 车道 - float spacing = 2f * turbulentRadius; + // 云团间距 = 2R × (1 - 重叠比例),重叠由配置驱动,保证有效区互相覆盖 + float spacing = 2f * turbulentRadius * (1f - _config.CloudOverlapRatio); int[] laneNeeded = new int[yLanes]; float[] laneBaseTime = new float[yLanes]; bool[] laneBaseSet = new bool[yLanes]; @@ -171,7 +159,6 @@ namespace CounterDrone.Core.Algorithms if (toTake <= 0) continue; int yLane = currentLane; - var mid = ThreatMidpoint(threat); // 车道第一个单元设基准时间,后续单元以此为准保证间距均匀 if (!laneBaseSet[yLane]) { @@ -191,7 +178,7 @@ namespace CounterDrone.Core.Algorithms foreach (var e in fe) { e.FireTime = laneBaseTime[yLane] + (baseRoundInLane + ch) * stagger; - e.TargetX = mid.X + offset; + // TargetX/Z 保留 GenerateFireEventsAt 算出的风偏补偿后抛撒点 cloudGenX/Z e.PlatformIndex = unitIdx * c.Unit.TotalChannels + ch; } fevents.AddRange(fe); @@ -230,7 +217,7 @@ namespace CounterDrone.Core.Algorithms foreach (var threat in sortedThreats) { var a2 = _ammoCatalog.FirstOrDefault(s => - s.AerosolType == (int)MatchAmmo((PowerType)threat.Target.PowerType)); + s.AerosolType == (int)MatchAmmo(_config, (PowerType)threat.Target.PowerType)); int totalRounds = plan.Assignments .Where(a => a.DroneGroupId == threat.GroupId) .Sum(a => a.RoundsFired); @@ -253,10 +240,10 @@ namespace CounterDrone.Core.Algorithms List availableUnits, CombatScene env) { var candidates = new List(); - var neededAmmo = MatchAmmo((PowerType)threat.Target.PowerType); + var neededAmmo = MatchAmmo(_config, (PowerType)threat.Target.PowerType); var ammo = _ammoCatalog.First(a => a.AerosolType == (int)neededAmmo); - var (ammoEff, _, _) = ComputeEffectiveRadius(threat, ammo, env); + var (ammoEff, _, _) = ComputeEffectiveRadius(ammo, env); foreach (var unit in availableUnits) { @@ -296,7 +283,7 @@ namespace CounterDrone.Core.Algorithms float avgSpeed = (float)threat.Target.TypicalSpeed / 3.6f; float neededExposure = _damageModel.RequiredExposureSeconds((TargetType)threat.Target.TargetType, (PowerType)threat.Target.PowerType, ammoType); float actualExposure = effectiveR * 2f / avgSpeed; - float prob = Math.Min(0.95f, actualExposure / neededExposure); + float prob = Math.Min(_config.MaxInterceptProbability, actualExposure / neededExposure); return new InterceptCandidate { @@ -330,7 +317,7 @@ namespace CounterDrone.Core.Algorithms float avgSpeed = (float)threat.Target.TypicalSpeed / 3.6f; float neededExposure = _damageModel.RequiredExposureSeconds((TargetType)threat.Target.TargetType, (PowerType)threat.Target.PowerType, ammoType); float actualExposure = effectiveR * 2f / avgSpeed; - float prob = Math.Min(0.95f, actualExposure / neededExposure); + float prob = Math.Min(_config.MaxInterceptProbability, actualExposure / neededExposure); return new InterceptCandidate { @@ -349,14 +336,14 @@ namespace CounterDrone.Core.Algorithms // ═══════════════════════════════════════════════ private (float effectiveRadius, float expansionTime, float rPhase2) ComputeEffectiveRadius( - DroneGroup threat, AmmunitionSpec ammo, CombatScene env) + AmmunitionSpec ammo, CombatScene env) { var model = new CloudExpansionModel(ammo, env); - float tPhase2 = 30f; - float rPhase2 = model.RadiusAt(tPhase2); + float rPhase2 = model.RadiusAt(30f); float expansionTime = model.TimeToReach(rPhase2); - float halfTime = threat.ArrivalTime * 2f; // 总飞行时间的一半 - float effectiveR = model.RadiusAt(halfTime); + // 云团被穿过时的真实年龄 = expansionTime(云团生成后膨胀到有效半径的时长)。 + // 这是云团自身的物理量,与无人机飞行时间无关。 + float effectiveR = model.RadiusAt(expansionTime); return (effectiveR, expansionTime, rPhase2); } @@ -368,14 +355,16 @@ namespace CounterDrone.Core.Algorithms float neededExposure = _damageModel.RequiredExposureSeconds( (TargetType)threat.Target.TargetType, (PowerType)threat.Target.PowerType, (AerosolType)ammo.AerosolType); float requiredCoverage = neededExposure * avgSpeed; - return cloudModel.RoundsNeeded(requiredCoverage); + // 间距由配置的重叠比例驱动,公式在 CloudExpansionModel(共享) + float spacing = 2f * turbulentRadius * (1f - _config.CloudOverlapRatio); + return cloudModel.RoundsNeeded(requiredCoverage, spacing); } private float ComputeInterceptProbability(DroneGroup threat, AmmunitionSpec ammo, int rounds, CombatScene env) { float avgSpeed = (float)threat.Target.TypicalSpeed / 3.6f; - var (effectiveR, _, _) = ComputeEffectiveRadius(threat, ammo, env); + var (effectiveR, _, _) = ComputeEffectiveRadius(ammo, env); float spacing = 2f * effectiveR; float actualCoverage = spacing * (rounds - 1) + 2f * effectiveR; float actualExposure = actualCoverage / avgSpeed; @@ -383,7 +372,7 @@ namespace CounterDrone.Core.Algorithms var aerosolType = (AerosolType)ammo.AerosolType; float neededExposure = _damageModel.RequiredExposureSeconds((TargetType)threat.Target.TargetType, (PowerType)threat.Target.PowerType, aerosolType); - return Math.Min(0.95f, actualExposure / neededExposure); + return Math.Min(_config.MaxInterceptProbability, actualExposure / neededExposure); } // ═══════════════════════════════════════════════ @@ -395,12 +384,11 @@ namespace CounterDrone.Core.Algorithms int yLane, int yLanes, float formationWidth) { var events = new List(); + var wps = threat.Waypoints; + if (wps == null || wps.Count < 2) return events; - var mid = ThreatMidpoint(threat); var cloudModel = new CloudExpansionModel(ammo, env); - // 云龄 = expansionTime,expansionTime = TimeToReach(RadiusAt(30s)) ≈ 30s - // 考虑弹间间隔,最后云的龄 = expansionTime - (rounds-1)*stagger - // 用保守值:取 expansionTime 的 90%(实际间距导致龄差 ~3s) + // 云龄 = expansionTime(云团生成后膨胀到有效半径的时长) float effectiveAge = cloudModel.TimeToReach(cloudModel.TurbulentRadius) * 0.9f; float effectiveR = cloudModel.RadiusAt(effectiveAge); float expansionTime = cloudModel.TimeToReach(effectiveR); @@ -410,27 +398,39 @@ namespace CounterDrone.Core.Algorithms if (densityAtPassage < (float)ammo.EffectiveConcentration) return events; // 云团到达时已稀释失效 - // 编队 Y 偏移:按车道分布 - float laneSpacingZ = yLanes > 1 ? formationWidth / (yLanes - 1) : 0f; - float tz = mid.Z + yLane * laneSpacingZ; + float typicalSpeed = (float)threat.Target.TypicalSpeed; + if (typicalSpeed <= 0) return events; - float tx = mid.X + targetOffset; + // 航路感知布局:穿越点弧长 = 航路中点弧长 + 沿航路偏移 + // targetOffset 现在是沿航路切向的弧长偏移(不再是 X 分量),支持任意方向航路 + var mid = ThreatMidpoint(threat); + float midArc = RouteGeometry.ArcLengthNearestTo(wps, mid.X, mid.Z); + float crossArc = midArc + targetOffset; - // 无人机到达目标点时间 - float droneSpeed = (float)threat.Target.TypicalSpeed / 3.6f; - if (droneSpeed <= 0) return events; - float startX = (float)threat.Waypoints[0].PosX; - float startZ = (float)threat.Waypoints[0].PosZ; - float endX = (float)threat.Waypoints[^1].PosX; - float endZ = (float)threat.Waypoints[^1].PosZ; - float totalDist = Kinematics.Distance2D(startX, startZ, endX, endZ); - if (totalDist <= 0) return events; - float distToTx = Kinematics.Distance2D(startX, startZ, tx, tz); - distToTx = Math.Max(0, Math.Min(totalDist, distToTx)); - float txArrival = distToTx / droneSpeed; + // 编队横向偏移:按车道分布在航路法向上 + // 法向 = 切向旋转 90°,用 RouteGeometry.TangentAt 取穿越点处航路方向 + var (tanX, tanZ) = RouteGeometry.TangentAt(wps, crossArc); + float normalX = -tanZ, normalZ = tanX; // 切向逆时针 90° = 左侧法向 + float laneOffset = yLanes > 1 ? (yLane - (yLanes - 1) / 2f) * (formationWidth / Math.Max(1, yLanes - 1)) : 0f; + + // 穿越点 = 航路上 crossArc 处 + 横向车道偏移 + var (routeX, routeY, routeZ) = RouteGeometry.PositionAt(wps, crossArc); + float tx = routeX + normalX * laneOffset; + float ty = routeY; + float tz = routeZ + normalZ * laneOffset; + + // 无人机到达穿越点的时间:沿航路匀速飞行 crossArc 弧长 + float txArrival = RouteGeometry.TravelTimeTo(wps, crossArc >= 0 ? crossArc : 0, typicalSpeed); float recommendedTiming = txArrival - expansionTime; if (recommendedTiming <= 0f) return events; + // 风偏补偿:云团生成后会被风吹偏,补偿时长 = 从生成到被穿过。 + // 每朵云的生成时刻不同(受发射/飞行时间影响),但 planner 此处用 expansionTime + // 作为云团从生成到被穿过的时长(recommendedTiming 已对齐),各发独立用各自的 expansionTime。 + var (wx, _, wz) = Kinematics.WindToVector((WindDirection)env.WindDirection, (float)env.WindSpeed); + float cloudGenX = tx - wx * expansionTime; + float cloudGenZ = tz - wz * expansionTime; + float fireTime; if (unit.Type == PlatformType.AirBased) { @@ -439,12 +439,10 @@ namespace CounterDrone.Core.Algorithms float releaseAlt = unit.ReleaseAltitude; float cruiseSpd = unit.CruiseSpeed; float fallTime = Kinematics.AirDropFallTime(releaseAlt, (float)threat.Target.TypicalAltitude); - // 载具飞向云端方向,漂移距离 = 巡航速度 × 落体时间 float driftDist = cruiseSpd * fallTime; float distToCloud = Kinematics.Distance3D( unit.Position.X, unit.Position.Y, unit.Position.Z, - tx, releaseAlt, tz); - // 投放点间距 = 总距离 - 漂移,投后弹药滑翔至预期云位 + cloudGenX, releaseAlt, cloudGenZ); float flightDist = Math.Max(0f, distToCloud - driftDist); float flightTime = flightDist / cruiseSpd; fireTime = recommendedTiming - flightTime - fallTime; @@ -454,8 +452,8 @@ namespace CounterDrone.Core.Algorithms if (unit.MuzzleVelocity <= 0) throw new InvalidOperationException($"地基单元 {unit.Id}: MuzzleVelocity={unit.MuzzleVelocity} 必须>0"); float mv = unit.MuzzleVelocity; - float dx = tx - unit.Position.X; - float dz = tz - unit.Position.Z; + float dx = cloudGenX - unit.Position.X; + float dz = cloudGenZ - unit.Position.Z; 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); @@ -468,9 +466,9 @@ namespace CounterDrone.Core.Algorithms { FireTime = fireTime, PlatformIndex = 0, - TargetX = tx, - TargetY = mid.Y, - TargetZ = tz, + TargetX = cloudGenX, + TargetY = ty, + TargetZ = cloudGenZ, MuzzleVelocity = unit.Type == PlatformType.AirBased ? 0f : unit.MuzzleVelocity, }); @@ -495,7 +493,7 @@ namespace CounterDrone.Core.Algorithms while (rounds > 1) { // 简化:弹药减半 → 概率减半 - if ((float)rounds / assignment.RoundsFired < 0.5f) break; + if ((float)rounds / assignment.RoundsFired < _config.CriticalProbabilityThreshold) break; rounds--; } @@ -513,7 +511,7 @@ namespace CounterDrone.Core.Algorithms } critical.MergedSchedule.Sort((a, b) => a.FireTime.CompareTo(b.FireTime)); - critical.OverallProbability = 0.5f; + critical.OverallProbability = _config.CriticalProbabilityThreshold; critical.Summary = $"临界方案:刚好满足 50% 拦截概率"; return critical; } diff --git a/src/CounterDrone.Core/Algorithms/GaussianPuffDispersion.cs b/src/CounterDrone.Core/Algorithms/GaussianPuffDispersion.cs index 7f72d9e..4c47e30 100644 --- a/src/CounterDrone.Core/Algorithms/GaussianPuffDispersion.cs +++ b/src/CounterDrone.Core/Algorithms/GaussianPuffDispersion.cs @@ -12,6 +12,7 @@ namespace CounterDrone.Core.Algorithms public class GaussianPuffDispersion : ICloudDispersionModel { private AmmunitionSpec _ammo = null!; + private CombatScene _env = null!; private float _elapsed; private float _currentRadius; private float _currentDensity; @@ -33,6 +34,7 @@ namespace CounterDrone.Core.Algorithms public void Initialize(AmmunitionSpec ammo, CombatScene env, Vector3 releasePos, float releaseTime) { _ammo = ammo; + _env = env; _elapsed = 0f; _inPhase3 = false; @@ -75,7 +77,7 @@ namespace CounterDrone.Core.Algorithms { // Phase 3: 高斯扩散 var x = Math.Max(1f, windSpeed * (_elapsed - 30f)); - var cls = Kinematics.GetStabilityClass((WeatherType)0, windSpeed); + var cls = Kinematics.GetStabilityClass((WeatherType)_env.WeatherType, windSpeed); var sY = Kinematics.SigmaY(cls, x); var sZ = Kinematics.SigmaZ(cls, x); _currentDensity = Kinematics.GaussianPeakConcentration((float)_ammo.SourceStrength, sY, sZ); diff --git a/src/CounterDrone.Core/Algorithms/Kinematics.cs b/src/CounterDrone.Core/Algorithms/Kinematics.cs index 01b0e65..9528f88 100644 --- a/src/CounterDrone.Core/Algorithms/Kinematics.cs +++ b/src/CounterDrone.Core/Algorithms/Kinematics.cs @@ -162,6 +162,18 @@ namespace CounterDrone.Core.Algorithms return (float)Math.Sqrt(dx * dx + dz * dz); } + /// 匀速直线运动从一点到另一点的飞行时间(秒)。 + /// 物理模型:无人机/平台沿直线匀速飞行,时间 = 距离 / 速度。 + /// speedKmh 为 0 时抛异常(速度必须 > 0,由调用方保证)。 + /// 速度 km/h(与 TypicalSpeed 单位一致) + public static float TravelTime(float fromX, float fromZ, float toX, float toZ, float speedKmh) + { + if (speedKmh <= 0) + throw new ArgumentException("速度必须 > 0", nameof(speedKmh)); + float dist = Distance2D(fromX, fromZ, toX, toZ); + return dist / (speedKmh / 3.6f); + } + /// 点到三维点距离 public static float Distance3D(float x1, float y1, float z1, float x2, float y2, float z2) { diff --git a/src/CounterDrone.Core/Algorithms/PlannerConfig.cs b/src/CounterDrone.Core/Algorithms/PlannerConfig.cs new file mode 100644 index 0000000..c20fd09 --- /dev/null +++ b/src/CounterDrone.Core/Algorithms/PlannerConfig.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using CounterDrone.Core.Models; + +namespace CounterDrone.Core.Algorithms +{ + /// 防御规划器配置 — 全局策略参数,从 planner_config.json 加载。 + /// 代码零默认值,所有字段必须从配置文件读取;文件缺失或字段缺失即抛异常。 + public class PlannerConfig + { + /// 云团重叠比例(0=相切,0.2=重叠 20%)。间距 = 2R × (1 − 重叠比例)。 + public float CloudOverlapRatio { get; set; } + + /// 临界方案概率阈值(DeriveCritical 用) + public float CriticalProbabilityThreshold { get; set; } + + /// 拦截概率上限(封顶值) + public float MaxInterceptProbability { get; set; } + + /// 威胁类型系数表(TargetType → 系数) + public Dictionary TypeCoefficient { get; set; } = new(); + + /// 弹药匹配表(PowerType → AerosolType) + public Dictionary AmmoMatch { get; set; } = new(); + + private const string ConfigFileName = "planner_config.json"; + + /// 从 dataRoot 加载配置。文件缺失或字段非法即抛异常。 + public static PlannerConfig Load(string dataRoot) + { + if (string.IsNullOrEmpty(dataRoot)) + throw new ArgumentException("dataRoot 不能为空", nameof(dataRoot)); + string path = Path.Combine(dataRoot, ConfigFileName); + if (!File.Exists(path)) + throw new FileNotFoundException($"planner 配置文件不存在: {path}"); + + string json = File.ReadAllText(path); + var config = JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidDataException($"planner 配置解析失败: {path}"); + config.Validate(); + return config; + } + + /// 从 IPathProvider 加载(便捷重载) + public static PlannerConfig Load(IPathProvider paths) + => Load(paths?.GetDataRoot() ?? throw new ArgumentNullException(nameof(paths))); + + private void Validate() + { + if (CloudOverlapRatio < 0f || CloudOverlapRatio >= 1f) + throw new InvalidDataException($"CloudOverlapRatio 必须在 [0, 1),实际 {CloudOverlapRatio}"); + if (CriticalProbabilityThreshold <= 0f || CriticalProbabilityThreshold >= 1f) + throw new InvalidDataException($"CriticalProbabilityThreshold 必须在 (0, 1),实际 {CriticalProbabilityThreshold}"); + if (MaxInterceptProbability <= 0f || MaxInterceptProbability > 1f) + throw new InvalidDataException($"MaxInterceptProbability 必须在 (0, 1],实际 {MaxInterceptProbability}"); + if (TypeCoefficient == null || TypeCoefficient.Count == 0) + throw new InvalidDataException("TypeCoefficient 不能为空"); + if (AmmoMatch == null || AmmoMatch.Count == 0) + throw new InvalidDataException("AmmoMatch 不能为空"); + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + Converters = { new JsonStringEnumConverter() }, + PropertyNameCaseInsensitive = true, + }; + } +} diff --git a/src/CounterDrone.Core/Algorithms/RouteGeometry.cs b/src/CounterDrone.Core/Algorithms/RouteGeometry.cs new file mode 100644 index 0000000..5dae24c --- /dev/null +++ b/src/CounterDrone.Core/Algorithms/RouteGeometry.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using CounterDrone.Core.Models; + +namespace CounterDrone.Core.Algorithms +{ + /// 航路几何工具——对 waypoint 序列做纯几何运算。 + /// 无状态、纯函数,与 Kinematics/DamageAssessment 同范式。 + /// planner(预测)和 DroneEntity(执行)共用,确保两边航路模型一致。 + public static class RouteGeometry + { + /// 航路总弧长(米):所有相邻 waypoint 三维距离之和。 + public static float TotalLength(IReadOnlyList wps) + { + if (wps == null || wps.Count < 2) return 0f; + float total = 0f; + for (int i = 0; i < wps.Count - 1; i++) + total += Kinematics.Distance3D( + (float)wps[i].PosX, (float)wps[i].PosY, (float)wps[i].PosZ, + (float)wps[i + 1].PosX, (float)wps[i + 1].PosY, (float)wps[i + 1].PosZ); + return total; + } + + /// 沿航路弧长 s 处的三维位置(分段线性插值)。 + /// s≤0 返回起点,s≥TotalLength 返回终点。 + public static (float X, float Y, float Z) PositionAt(IReadOnlyList wps, float arcLength) + { + if (wps == null || wps.Count == 0) return (0f, 0f, 0f); + if (wps.Count == 1 || arcLength <= 0f) + return ((float)wps[0].PosX, (float)wps[0].PosY, (float)wps[0].PosZ); + + float remaining = arcLength; + for (int i = 0; i < wps.Count - 1; i++) + { + float segLen = Kinematics.Distance3D( + (float)wps[i].PosX, (float)wps[i].PosY, (float)wps[i].PosZ, + (float)wps[i + 1].PosX, (float)wps[i + 1].PosY, (float)wps[i + 1].PosZ); + if (remaining <= segLen) + { + float t = segLen > 0.0001f ? remaining / segLen : 0f; + return ( + (float)(wps[i].PosX + (wps[i + 1].PosX - wps[i].PosX) * t), + (float)(wps[i].PosY + (wps[i + 1].PosY - wps[i].PosY) * t), + (float)(wps[i].PosZ + (wps[i + 1].PosZ - wps[i].PosZ) * t)); + } + remaining -= segLen; + } + // 超过总弧长,返回终点 + var last = wps[^1]; + return ((float)last.PosX, (float)last.PosY, (float)last.PosZ); + } + + /// 航路上离目标点 (x,z) 最近的点对应的弧长(水平投影最近)。 + /// 用于 planner 定位"无人机穿越点在航路上的弧长位置"。 + /// 算法:逐段求点到线段的最近点,取全局最近者。 + public static float ArcLengthNearestTo(IReadOnlyList wps, float x, float z) + { + if (wps == null || wps.Count < 2) return 0f; + + float bestArc = 0f; + float bestDist = float.MaxValue; + float accumArc = 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; + + float t = 0f; + if (segLenSq > 0.0001f) + { + t = ((x - ax) * segDx + (z - az) * segDz) / segLenSq; + t = Math.Max(0f, Math.Min(1f, t)); + } + float projX = ax + segDx * t; + float projZ = az + segDz * t; + float distSq = (projX - x) * (projX - x) + (projZ - z) * (projZ - z); + + if (distSq < bestDist) + { + bestDist = distSq; + float segLen = (float)Math.Sqrt(segLenSq); + bestArc = accumArc + t * segLen; + } + accumArc += (float)Math.Sqrt(segLenSq); + } + return bestArc; + } + + /// 从航路起点匀速运动到弧长 s 处的飞行时间(秒)。 + /// 匀速直线模型,时间 = 弧长 / 速度。内部调用 Kinematics.TravelTime。 + /// 速度 km/h(与 TypicalSpeed 单位一致),必须 > 0 + public static float TravelTimeTo(IReadOnlyList wps, float arcLength, float speedKmh) + { + if (speedKmh <= 0) + throw new ArgumentException("速度必须 > 0", nameof(speedKmh)); + // 弧长 = 距离,匀速直线时间 = 距离/速度 + // 用 Kinematics.TravelTime 保持单位换算唯一(km/h → m/s 在 Kinematics 内) + return arcLength / (speedKmh / 3.6f); + } + + /// 沿航路方向给定弧长 s 处的水平单位切向量 (DirX, DirZ)。 + /// 用于 planner 沿航路方向布云(offset 沿切向,不再写死 X 轴)。 + /// 弧长超出范围时取末段方向。 + public static (float DirX, float DirZ) TangentAt(IReadOnlyList wps, float arcLength) + { + if (wps == null || wps.Count < 2) return (1f, 0f); + + float remaining = Math.Max(0f, arcLength); + for (int i = 0; i < wps.Count - 1; i++) + { + float dx = (float)wps[i + 1].PosX - (float)wps[i].PosX; + float dz = (float)wps[i + 1].PosZ - (float)wps[i].PosZ; + float segLen = (float)Math.Sqrt(dx * dx + dz * dz); + if (remaining <= segLen || i == wps.Count - 2) + { + if (segLen < 0.0001f) return (1f, 0f); + return (dx / segLen, dz / segLen); + } + remaining -= segLen; + } + // 兜底:末段方向 + float ldx = (float)wps[^1].PosX - (float)wps[^2].PosX; + float ldz = (float)wps[^1].PosZ - (float)wps[^2].PosZ; + float llen = (float)Math.Sqrt(ldx * ldx + ldz * ldz); + return llen < 0.0001f ? (1f, 0f) : (ldx / llen, ldz / llen); + } + } +} diff --git a/src/CounterDrone.Core/Simulation/DroneEntity.cs b/src/CounterDrone.Core/Simulation/DroneEntity.cs index b82620a..8ecf7e2 100644 --- a/src/CounterDrone.Core/Simulation/DroneEntity.cs +++ b/src/CounterDrone.Core/Simulation/DroneEntity.cs @@ -27,6 +27,8 @@ namespace CounterDrone.Core.Simulation public float PrevZ { get; private set; } public float FormationOffsetX { get; set; } public float FormationOffsetY { get; set; } + /// 沿航路已飞行弧长(米),由 RouteGeometry 驱动 + private float _traveledArc; public DroneEntity(string id, string groupId, TargetConfig config, List route, int formationIndex, float lateralSpacing, int longitudinalIndex, float longitudinalSpacing, FormationMode mode) @@ -103,40 +105,34 @@ namespace CounterDrone.Core.Simulation { if (Status != DroneStatus.Flying) return; if (Route.Count == 0) return; - if (CurrentWaypointIndex >= Route.Count - 1) + + // 运动模型统一在 RouteGeometry:弧长推进 + 位置查询。 + // 无人机严格沿航路匀速飞行,不受风偏影响(风偏已移除,见风偏补偿说明)。 + // windSpeed/windDir 保留签名供未来飞控模型使用,当前不参与运动。 + float totalLen = RouteGeometry.TotalLength(Route); + if (totalLen <= 0f) { Status = DroneStatus.ReachedTarget; return; } - var target = Route[CurrentWaypointIndex + 1]; - var speedMs = TypicalSpeed / 3.6f; - var dx = (float)(target.PosX - PosX); - var dy = (float)(target.PosY - PosY); - var dz = (float)(target.PosZ - PosZ); - var dist = (float)Math.Sqrt(dx * dx + dy * dy + dz * dz); - var step = speedMs * deltaTime; + float speedMs = TypicalSpeed / 3.6f; + _traveledArc += speedMs * deltaTime; - if (dist < 1.0f || step >= dist) + if (_traveledArc >= totalLen) { - PosX = (float)target.PosX; - PosY = (float)target.PosY; - PosZ = (float)target.PosZ; - CurrentWaypointIndex++; - if (CurrentWaypointIndex >= Route.Count - 1) - Status = DroneStatus.ReachedTarget; + var end = Route[^1]; + PosX = (float)end.PosX; + PosY = (float)end.PosY; + PosZ = (float)end.PosZ; + Status = DroneStatus.ReachedTarget; return; } - var ratio = step / dist; - PosX += dx * ratio; - PosY += dy * ratio; - PosZ += dz * ratio; - - (float wx, float wy, float wz) = Kinematics.WindToVector(windDir, windSpeed); - PosX += wx * deltaTime; - PosY += wy * deltaTime; - PosZ += wz * deltaTime; + var (x, y, z) = RouteGeometry.PositionAt(Route, _traveledArc); + PosX = x; + PosY = y; + PosZ = z; } public void ApplyDamage(float damage) diff --git a/src/CounterDrone.Core/Simulation/SimulationEngine.cs b/src/CounterDrone.Core/Simulation/SimulationEngine.cs index 1a89aff..3ef9ba9 100644 --- a/src/CounterDrone.Core/Simulation/SimulationEngine.cs +++ b/src/CounterDrone.Core/Simulation/SimulationEngine.cs @@ -47,8 +47,6 @@ namespace CounterDrone.Core.Simulation private readonly List _fireSchedule = new(); private int _nextFireIndex; private readonly List _allEvents = new(); - private System.Text.StringBuilder _hitLog = new(); - private float _totalPathInCloud; private SQLiteConnection _frameDb = null!; private string _taskId = string.Empty; private readonly IDefensePlanner _planner; @@ -130,8 +128,6 @@ namespace CounterDrone.Core.Simulation _munitions.Clear(); _clouds.Clear(); _allEvents.Clear(); - _hitLog.Clear(); - _hitLog.AppendLine("droneX,droneY,cloudX,cloudY,dist,radius,inside"); FrameIndex = 0; SimulationTime = 0; _entityCounter = 0; @@ -146,19 +142,9 @@ namespace CounterDrone.Core.Simulation { var result = _planner.Plan(fireUnits, threats, _scene); SetFireSchedule(result.Best.MergedSchedule); - // 诊断:输出 Planner 云团预期位置 - var lines = new List { "idx,fireTime,tx,ty,tz,mv" }; - for (int i = 0; i < _fireSchedule.Count; i++) - { - var f = _fireSchedule[i]; - lines.Add($"{i},{f.FireTime:F3},{f.TargetX:F1},{f.TargetY:F1},{f.TargetZ:F1},{f.MuzzleVelocity:F0}"); - } - System.IO.File.WriteAllLines(@"C:\Users\Tellme\Desktop\planner_targets.csv", lines); } } - // 初始化 cloud dump - System.IO.File.WriteAllText(@"C:\Users\Tellme\Desktop\cloud_actual.csv", "m.id,arrivalTime,px,py,pz,cloud.id,tx,ty,tz\n"); _frameDb = _frameStore.CreateOrOpen(_taskId); State = SimulationState.Running; } @@ -231,8 +217,6 @@ namespace CounterDrone.Core.Simulation _munitions.Remove(m); OnCloudGenerated?.Invoke(cloud); frameEvents.Add(new SimEvent { Type = SimEventType.CloudGenerated, OccurredAt = m.ArrivalTime, Description = "云团生成" }); - System.IO.File.AppendAllText(@"C:\Users\Tellme\Desktop\cloud_actual.csv", - $"{m.Id},{m.ArrivalTime:F3},{m.PosX:F1},{m.PosY:F1},{m.PosZ:F1},{cloud.Id},{m.TargetX:F1},{m.TargetY:F1},{m.TargetZ:F1}\n"); } } @@ -262,19 +246,24 @@ namespace CounterDrone.Core.Simulation } // 6. 毁伤判定:积分路径段在云内的时间 + // 在云团参考系计算:云团中心在该参考系为原点。 + // 云团本 tick 内也在移动(风偏),故 Prev 帧的云团中心 = 本帧中心 − 风位移。 + // 不做此修正的话 PathInSphere 会把云团当静止球,每 tick 引入 ~2m 误差。 + var (wvx, wvy, wvz) = Kinematics.WindToVector((WindDirection)_scene.WindDirection, (float)_scene.WindSpeed); + float cloudDx = wvx * scaledDt, cloudDy = wvy * scaledDt, cloudDz = wvz * scaledDt; foreach (var drone in _drones.Where(d => d.Status == DroneStatus.Flying)) { foreach (var cloud in _clouds) { + float ccx = cloud.Dispersion.Center.X, ccy = cloud.Dispersion.Center.Y, ccz = cloud.Dispersion.Center.Z; + // 无人机线段换算到云团参考系:相对位置 = drone - cloud float inCloudTime = DamageAssessment.PathInSphere( - drone.PrevX, drone.PrevY, drone.PrevZ, - drone.PosX, drone.PosY, drone.PosZ, - cloud.Dispersion.Center.X, cloud.Dispersion.Center.Y, cloud.Dispersion.Center.Z, + drone.PrevX - (ccx - cloudDx), drone.PrevY - (ccy - cloudDy), drone.PrevZ - (ccz - cloudDz), + drone.PosX - ccx, drone.PosY - ccy, drone.PosZ - ccz, + 0f, 0f, 0f, cloud.Dispersion.EffectiveRadius); if (inCloudTime > 0 && cloud.Dispersion.CoreDensity >= (float)_ammoSpec.EffectiveConcentration) { - _totalPathInCloud += inCloudTime; - System.IO.File.AppendAllText(@"C:\Users\Tellme\Desktop\path_debug.txt", $"t={SimulationTime:F1} drone={drone.PosX:F1} cloud={cloud.Dispersion.Center.X:F1} path={inCloudTime:F1} total={_totalPathInCloud:F1}\n"); float exposureIncrement = inCloudTime / (drone.TypicalSpeed / 3.6f); drone.ExposureTime += exposureIncrement; var dmg = _damageModel.CalculateDamage(drone.TargetType, drone.PowerType, cloud.AerosolType, cloud.Dispersion.CoreDensity, drone.ExposureTime, exposureIncrement); @@ -309,7 +298,6 @@ namespace CounterDrone.Core.Simulation if (_drones.All(d => d.Status != DroneStatus.Flying)) { State = SimulationState.Completed; - System.IO.File.WriteAllText(@"C:\Users\Tellme\Desktop\path_in_cloud.txt", $"totalPath={_totalPathInCloud:F1}m"); OnSimulationEnded?.Invoke(); frameEvents.Add(new SimEvent { Type = SimEventType.SimulationEnd, OccurredAt = SimulationTime }); } diff --git a/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll b/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll index b75bf2f..34d7ca7 100644 Binary files a/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll and b/src/Unity/Assets/Plugins/CounterDrone.Core/CounterDrone.Core.dll differ diff --git a/src/Unity/Assets/Scripts/Managers/ManagerVerification.cs b/src/Unity/Assets/Scripts/Managers/ManagerVerification.cs index 4319b2c..7faf40c 100644 --- a/src/Unity/Assets/Scripts/Managers/ManagerVerification.cs +++ b/src/Unity/Assets/Scripts/Managers/ManagerVerification.cs @@ -72,7 +72,7 @@ namespace CounterDrone.Unity TotalMunitions = 3, AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel }, }); - var result = new DefaultDefensePlanner(ammoCatalog).Plan(fireUnits, new List { droneGroup }, detail.Scene); + var result = new DefaultDefensePlanner(ammoCatalog, PlannerConfig.Load(paths)).Plan(fireUnits, new List { droneGroup }, detail.Scene); scenarioMgr.SaveCloud(taskId, new CloudDispersal { PositionX = (droneGroup.Waypoints[0].PosX + droneGroup.Waypoints[^1].PosX) / 2, diff --git a/src/Unity/Assets/Scripts/Managers/SimulationBootstrap.cs b/src/Unity/Assets/Scripts/Managers/SimulationBootstrap.cs index 0985d8c..a459f71 100644 --- a/src/Unity/Assets/Scripts/Managers/SimulationBootstrap.cs +++ b/src/Unity/Assets/Scripts/Managers/SimulationBootstrap.cs @@ -67,7 +67,7 @@ namespace CounterDrone.Unity // 推荐方案 var detail = scenario.GetTaskDetail(taskId); var ammoCatalog = db.Table().ToList(); - var planner = new DefaultDefensePlanner(ammoCatalog); + var planner = new DefaultDefensePlanner(ammoCatalog, PlannerConfig.Load(paths)); var droneGroup = new DroneGroup { GroupId = "default", diff --git a/src/Unity/Assets/Scripts/Managers/SimulationRunner.cs b/src/Unity/Assets/Scripts/Managers/SimulationRunner.cs index 9ebe475..fcaa156 100644 --- a/src/Unity/Assets/Scripts/Managers/SimulationRunner.cs +++ b/src/Unity/Assets/Scripts/Managers/SimulationRunner.cs @@ -48,7 +48,7 @@ namespace CounterDrone.Unity new RoutePlanRepository(_db), new WaypointRepository(_db)); _frameStore = new FrameDataStore(_paths); - _engine = new SimulationEngine(_scenario, _frameStore, new DamageModelRouter(), _paths, new DefaultDefensePlanner(DefaultAmmunition.GetAll())); + _engine = new SimulationEngine(_scenario, _frameStore, new DamageModelRouter(), _paths, new DefaultDefensePlanner(DefaultAmmunition.GetAll(), PlannerConfig.Load(_paths))); } public void LoadAndStart(string taskId) diff --git a/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs b/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs index 4c1a392..94af12c 100644 --- a/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs +++ b/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs @@ -59,7 +59,7 @@ namespace CounterDrone.Core.Tests private PlannerResult Plan(List units, DroneGroup threat, CombatScene? env = null) - => new DefaultDefensePlanner(TestAmmo).Plan(units, new() { threat }, env ?? new CombatScene()); + => new DefaultDefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(units, new() { threat }, env ?? new CombatScene()); // ═══════════════════════════════════════ // 威胁排序 @@ -233,7 +233,7 @@ namespace CounterDrone.Core.Tests [Fact] public void Allocation_MultiUnit_PoolsChannels() { - var result = new DefaultDefensePlanner(TestAmmo).Plan( + var result = new DefaultDefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan( new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5100) }, new() { MakeThreat(speed: 200) }, new CombatScene()); Assert.True(result.Best.ThreatsEngaged == 1); @@ -259,8 +259,8 @@ namespace CounterDrone.Core.Tests // 边界 // ═══════════════════════════════════════ - [Fact] public void Edge_NoUnits_Unengaged() => Assert.Equal(1, new DefaultDefensePlanner(TestAmmo).Plan(new(), new() { MakeThreat() }, new CombatScene()).Best.ThreatsUnengaged); - [Fact] public void Edge_NoThreats_Empty() => Assert.Equal(0, new DefaultDefensePlanner(TestAmmo).Plan(new() { MakeGroundUnit("u0", 5000) }, new(), new CombatScene()).Best.ThreatsEngaged); + [Fact] public void Edge_NoUnits_Unengaged() => Assert.Equal(1, new DefaultDefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(new(), new() { MakeThreat() }, new CombatScene()).Best.ThreatsUnengaged); + [Fact] public void Edge_NoThreats_Empty() => Assert.Equal(0, new DefaultDefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(new() { MakeGroundUnit("u0", 5000) }, new(), new CombatScene()).Best.ThreatsEngaged); [Fact] public void Edge_OutOfRange_Ground() @@ -293,7 +293,7 @@ namespace CounterDrone.Core.Tests { var a = MakeThreat(PowerType.Piston, 120); a.GroupId = "g0"; var b = MakeThreat(PowerType.Jet, 300); b.GroupId = "g1"; - var result = new DefaultDefensePlanner(TestAmmo).Plan( + var result = new DefaultDefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan( new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5100) }, new() { a, b }, new CombatScene()); Assert.Equal(2, result.Best.ThreatsEngaged); @@ -310,5 +310,124 @@ namespace CounterDrone.Core.Tests Assert.True(result.Critical.Assignments.Count > 0); Assert.True(result.Critical.OverallProbability >= 0.4f); } + + // ═══════════════════════════════════════ + // P1 风偏补偿:云团生成后会被风吹偏 windVec × expansionTime, + // Planner 须逆风预置抛撒点,使云团漂移回无人机穿越点。 + // tx,tz = 无人机穿越点(航路中点,不变) + // cloudGenX/Z = 抛撒点(FireEvent.TargetX/Z,含风偏补偿) + // ═══════════════════════════════════════ + + [Fact] + public void WindOffset_NoWind_NoDirectionalBias() + { + // 无风时,无论 WindDirection 取何值,抛撒点都应相同 + // (风偏补偿 = windVec × expansionTime,windVec=0 时补偿为 0) + var threat = MakeThreat(startX: 0, endX: 10000); + + // 风向取不同值,但风速都是 0 + var envN = new CombatScene { WindSpeed = 0, WindDirection = (int)WindDirection.N }; + var envE = new CombatScene { WindSpeed = 0, WindDirection = (int)WindDirection.E }; + + var rN = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, envN); + var rE = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, envE); + + float xN = rN.Best.MergedSchedule[0].TargetX; + float xE = rE.Best.MergedSchedule[0].TargetX; + Assert.True(Math.Abs(xN - xE) < 1f, + $"无风时风向不应影响抛撒点:N={xN:F1}, E={xE:F1}"); + } + + [Fact] + public void WindOffset_EastWind_TargetShiftedUpwind() + { + // 东风(WindDirection.E):WindToVector 返回 (speed, 0, 0),云团被吹向 +X。 + // 抛撒点应逆风预置 → TargetX < 航路中点 X=5000 + var threat = MakeThreat(startX: 0, endX: 10000); + var env = new CombatScene { WindSpeed = 10f, WindDirection = (int)WindDirection.E }; + + var calmResult = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, new CombatScene { WindSpeed = 0 }); + var windyResult = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, env); + + float calmX = calmResult.Best.MergedSchedule[0].TargetX; + float windyX = windyResult.Best.MergedSchedule[0].TargetX; + Assert.True(windyX < calmX, + $"东风下抛撒点 X={windyX:F1} 应小于无风 X={calmX:F1}(逆风预置补偿)"); + // 偏移量级合理性:expansionTime≈30s,风速 10m/s → 偏移约 300m + Assert.True(calmX - windyX > 100f, + $"偏移量 {calmX - windyX:F1}m 应有显著量级(期望 ~300m)"); + } + + [Fact] + public void WindOffset_WestWind_TargetShiftedOpposite() + { + // 西风:风矢量 (-speed, 0, 0),云团被吹向 -X。 + // 抛撒点应在 +X 方向(TargetX > 中点),与东风对称 + var threat = MakeThreat(startX: 0, endX: 10000); + var env = new CombatScene { WindSpeed = 10f, WindDirection = (int)WindDirection.W }; + + var calmResult = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, new CombatScene { WindSpeed = 0 }); + var windyResult = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, env); + + float calmX = calmResult.Best.MergedSchedule[0].TargetX; + float windyX = windyResult.Best.MergedSchedule[0].TargetX; + Assert.True(windyX > calmX, + $"西风下抛撒点 X={windyX:F1} 应大于无风 X={calmX:F1}(与东风相反方向)"); + } + + [Fact] + public void WindOffset_NorthWind_ShiftsZ() + { + // 北风:WindToVector 返回 (0, 0, speed),云团被吹向 +Z。 + // 抛撒点应在 -Z 方向(TargetZ 更小) + var threat = MakeThreat(startX: 0, endX: 10000); + threat.Waypoints[0].PosZ = 100; + threat.Waypoints[1].PosZ = 100; + var env = new CombatScene { WindSpeed = 10f, WindDirection = (int)WindDirection.N }; + + var calmResult = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, new CombatScene { WindSpeed = 0 }); + var windyResult = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, env); + + float calmZ = calmResult.Best.MergedSchedule[0].TargetZ; + float windyZ = windyResult.Best.MergedSchedule[0].TargetZ; + Assert.True(windyZ < calmZ, + $"北风下抛撒点 Z={windyZ:F1} 应小于无风 Z={calmZ:F1}(Z 方向逆风补偿)"); + } + + // ═══════════════════════════════════════ + // 航路感知布局:云团沿真实航路方向分布,不写死 X 轴。 + // Z 向航路(南北飞)的多发云团,X 坐标应集中在航路 X 附近(非发散)。 + // ═══════════════════════════════════════ + + [Fact] + public void RouteAware_ZDirectionRoute_CloudsOnRoute() + { + // Z 向航路:从 (5000,0) 飞向 (5000,10000),即沿 +Z 方向 + // 之前 offset 写死 X 轴时,多发云团会沿 X 发散到 5000±N×spacing,偏离航路 + // 改用 RouteGeometry 后,offset 沿航路切向(+Z),云团应集中在 X=5000 + var threat = new DroneGroup + { + GroupId = "default", + Target = new TargetConfig + { + TargetType = (int)TargetType.Piston, PowerType = (int)PowerType.Piston, + Quantity = 1, TypicalSpeed = 200, TypicalAltitude = 500, + }, + Waypoints = new List + { + new() { PosX = 5000, PosY = 500, PosZ = 0, Speed = 200 }, + new() { PosX = 5000, PosY = 500, PosZ = 10000, Speed = 200 }, + }, + }; + // 火力单元放在航路起点附近(X=5000) + var unit = MakeGroundUnit("u0", 5000); + var result = Plan(new() { unit }, threat, new CombatScene()); + + // 所有抛撒点的 X 应集中在 5000 附近(±少量风偏,此处无风) + // 若 offset 仍写死 X 轴,多发会沿 X 散开到 5000±N×40 + Assert.All(result.Best.MergedSchedule, fe => + Assert.True(Math.Abs(fe.TargetX - 5000) < 50f, + $"Z 向航路下抛撒点 X={fe.TargetX:F1} 应在航路 X=5000 附近(±50m),而非沿 X 发散")); + } } } diff --git a/test/unit/CounterDrone.Core.Tests/DispersionModelTests.cs b/test/unit/CounterDrone.Core.Tests/DispersionModelTests.cs index c266140..fac37c3 100644 --- a/test/unit/CounterDrone.Core.Tests/DispersionModelTests.cs +++ b/test/unit/CounterDrone.Core.Tests/DispersionModelTests.cs @@ -70,5 +70,53 @@ namespace CounterDrone.Core.Tests Assert.True(inert.EffectiveConcentration is >= 0.0001 and < 0.1, "有效浓度阈值应 ≥ 0.0001"); Assert.True(inert.SourceStrength is > 1 and < 100, "源强应 1~100kg"); } + + // ═══════════════════════════════════════ + // P0 修复:Phase3 高斯扩散应使用环境真实天气的稳定度 + // 原先 Tick 中 GetStabilityClass 写死 (WeatherType)0 (Sunny), + // 导致任何天气下 Phase3 扩散行为都相同。 + // ═══════════════════════════════════════ + + [Fact] + public void Phase3_DifferentWeather_ProduceDifferentDensity() + { + // 同样风速下,雾天(E稳定)扩散慢 → 浓度高;晴天低风(A极不稳定)扩散快 → 浓度低 + // 两模型都推进到 Phase3 (>30s),仅天气不同 + float windSpeed = 3f; + + var fogModel = new GaussianPuffDispersion(); + fogModel.Initialize(Ammo(), new CombatScene { WeatherType = (int)WeatherType.Fog, WindSpeed = windSpeed }, + new Algorithms.Vector3(0, 0, 0), 0f); + fogModel.Tick(40f, windSpeed, WindDirection.N); // 进入 Phase3 + + var sunnyModel = new GaussianPuffDispersion(); + sunnyModel.Initialize(Ammo(), new CombatScene { WeatherType = (int)WeatherType.Sunny, WindSpeed = windSpeed }, + new Algorithms.Vector3(0, 0, 0), 0f); + sunnyModel.Tick(40f, windSpeed, WindDirection.N); + + // 雾天稳定度高 → 云团收得紧 → 中心浓度显著高于晴天低风 + Assert.True(fogModel.CoreDensity > sunnyModel.CoreDensity, + $"雾天浓度={fogModel.CoreDensity:F6} 应高于晴天={sunnyModel.CoreDensity:F6}(P0: 天气应真实影响扩散)"); + } + + [Fact] + public void Phase3_Night_MoreStableThanSunny() + { + // 夜间(F极稳定) vs 晴天低风(A极不稳定),风速相同 + float windSpeed = 2f; + + var nightModel = new GaussianPuffDispersion(); + nightModel.Initialize(Ammo(), new CombatScene { WeatherType = (int)WeatherType.Night, WindSpeed = windSpeed }, + new Algorithms.Vector3(0, 0, 0), 0f); + nightModel.Tick(40f, windSpeed, WindDirection.N); + + var sunnyModel = new GaussianPuffDispersion(); + sunnyModel.Initialize(Ammo(), new CombatScene { WeatherType = (int)WeatherType.Sunny, WindSpeed = windSpeed }, + new Algorithms.Vector3(0, 0, 0), 0f); + sunnyModel.Tick(40f, windSpeed, WindDirection.N); + + Assert.True(nightModel.CoreDensity > sunnyModel.CoreDensity, + $"夜间浓度={nightModel.CoreDensity:F6} 应高于晴天={sunnyModel.CoreDensity:F6}"); + } } } diff --git a/test/unit/CounterDrone.Core.Tests/DroneEntityTests.cs b/test/unit/CounterDrone.Core.Tests/DroneEntityTests.cs index 4c74089..748736e 100644 --- a/test/unit/CounterDrone.Core.Tests/DroneEntityTests.cs +++ b/test/unit/CounterDrone.Core.Tests/DroneEntityTests.cs @@ -76,16 +76,6 @@ namespace CounterDrone.Core.Tests Assert.Equal(DroneStatus.Destroyed, drone.Status); } - [Fact] - public void Wind_AffectsPosition() - { - var drone = new DroneEntity("d1", "g1", CreateConfig(), - CreateRoute(0, 1000, 300, 60), 0, 50, 0, 0, FormationMode.Single); - float xBefore = drone.PosX; - drone.Update(1f, 20f, WindDirection.E); - Assert.True(drone.PosX > xBefore + 15f); - } - [Fact] public void Formation_Lateral_ZOffsetsDiffer() { @@ -130,5 +120,48 @@ namespace CounterDrone.Core.Tests var d1 = new DroneEntity("d1", "g1", CreateConfig(), route, 1, 30, 0, 0, FormationMode.Swarm); Assert.True(d0.PosX != d1.PosX || d0.PosZ != d1.PosZ); } + + // ═══════════════════════════════════════ + // RouteGeometry 驱动的多 waypoint 折线运动 + // 验证无人机沿 L 形航路(X 段→Z 段)正确移动并最终到达终点 + // ═══════════════════════════════════════ + + [Fact] + public void MultiWaypoint_LShape_MovesAlongBothSegments() + { + // L 形航路:(0,0) → (100,0) → (100,100),总长 200m,速度 60km/h≈16.67m/s + var route = new List + { + new() { PosX = 0, PosY = 300, PosZ = 0, Altitude = 300, Speed = 60 }, + new() { PosX = 100, PosY = 300, PosZ = 0, Altitude = 300, Speed = 60 }, + new() { PosX = 100, PosY = 300, PosZ = 100, Altitude = 300, Speed = 60 }, + }; + var drone = new DroneEntity("d1", "g1", CreateConfig(speed: 60), route, + 0, 0, 0, 0, FormationMode.Single); + + // 推进到第一段中点(弧长 50):应在 X=50, Z=0 + drone.Update(50f / (60f / 3.6f), 0, WindDirection.N); + Assert.Equal(50f, drone.PosX, 1); + Assert.Equal(0f, drone.PosZ, 1); + Assert.Equal(DroneStatus.Flying, drone.Status); + + // 推进到拐点(弧长 100):应在 X=100, Z=0 + drone.Update(50f / (60f / 3.6f), 0, WindDirection.N); + Assert.Equal(100f, drone.PosX, 1); + Assert.Equal(0f, drone.PosZ, 1); + Assert.Equal(DroneStatus.Flying, drone.Status); + + // 推进到第二段中点(弧长 150):应在 X=100, Z=50 + drone.Update(50f / (60f / 3.6f), 0, WindDirection.N); + Assert.Equal(100f, drone.PosX, 1); + Assert.Equal(50f, drone.PosZ, 1); + Assert.Equal(DroneStatus.Flying, drone.Status); + + // 推进到终点(弧长 200):应在 X=100, Z=100,状态 ReachedTarget + drone.Update(50f / (60f / 3.6f), 0, WindDirection.N); + Assert.Equal(100f, drone.PosX, 1); + Assert.Equal(100f, drone.PosZ, 1); + Assert.Equal(DroneStatus.ReachedTarget, drone.Status); + } } } diff --git a/test/unit/CounterDrone.Core.Tests/EdgeCaseTests.cs b/test/unit/CounterDrone.Core.Tests/EdgeCaseTests.cs index 109c3f2..68cce2b 100644 --- a/test/unit/CounterDrone.Core.Tests/EdgeCaseTests.cs +++ b/test/unit/CounterDrone.Core.Tests/EdgeCaseTests.cs @@ -65,7 +65,7 @@ namespace CounterDrone.Core.Tests }); var engine = new SimulationEngine(_scenario, new FrameDataStore(new TestPathProvider(_testDir)), - new DamageModelRouter(), new TestPathProvider(_testDir), new DefaultDefensePlanner(DefaultAmmunition.GetAll())); + new DamageModelRouter(), new TestPathProvider(_testDir), new DefaultDefensePlanner(DefaultAmmunition.GetAll(), TestPlannerConfig.Instance)); engine.Initialize(_taskId); engine.TimeScale = 4f; @@ -107,7 +107,7 @@ namespace CounterDrone.Core.Tests }); var engine = new SimulationEngine(_scenario, new FrameDataStore(new TestPathProvider(_testDir)), - new DamageModelRouter(), new TestPathProvider(_testDir), new DefaultDefensePlanner(DefaultAmmunition.GetAll())); + new DamageModelRouter(), new TestPathProvider(_testDir), new DefaultDefensePlanner(DefaultAmmunition.GetAll(), TestPlannerConfig.Instance)); engine.Initialize(_taskId); engine.TimeScale = 4f; @@ -148,7 +148,7 @@ namespace CounterDrone.Core.Tests }); var engine = new SimulationEngine(_scenario, new FrameDataStore(new TestPathProvider(_testDir)), - new DamageModelRouter(), new TestPathProvider(_testDir), new DefaultDefensePlanner(DefaultAmmunition.GetAll())); + new DamageModelRouter(), new TestPathProvider(_testDir), new DefaultDefensePlanner(DefaultAmmunition.GetAll(), 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 60aa6a1..2f2705d 100644 --- a/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs +++ b/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs @@ -49,7 +49,7 @@ namespace CounterDrone.Core.Tests private SimulationEngine RunSimulation(int maxTicks, float tickDt = 1f / 20f, float timeScale = 8f) { - var engine = new SimulationEngine(_scenario, _frameStore, new DamageModelRouter(), _paths, new DefaultDefensePlanner(_ammoCatalog)); + var engine = new SimulationEngine(_scenario, _frameStore, new DamageModelRouter(), _paths, new DefaultDefensePlanner(_ammoCatalog, TestPlannerConfig.Instance)); engine.Initialize(_taskId); engine.TimeScale = timeScale; // 8倍速加速 @@ -249,6 +249,72 @@ namespace CounterDrone.Core.Tests Assert.True(eng.Drones.All(d => d.Status == DroneStatus.Destroyed), msg); } + // ═══════════════════════════════════════════════ + // 场景 3b:活塞式 + 西风 5m/s — 验证风偏补偿后仍能拦截 + // 航路 X:0→5000 Z:0;西风 WindToVector(W)=(-5,0,0) 把云团吹向 -X + // Planner 应逆风预置抛撒点(+X方向),云团漂移 ~150m 后回到航路 + // ═══════════════════════════════════════════════ + + [Fact] + public void Scenario_Piston_Windy_InertGasIntercept() + { + var task = _scenario.CreateTask("活塞+西风拦截测试", ""); + _taskId = task.Id; + + _scenario.SaveScene(_taskId, new CombatScene + { + WeatherType = (int)WeatherType.Sunny, + WindSpeed = 5, + WindDirection = (int)WindDirection.W, + }); + _scenario.SaveTarget(_taskId, new TargetConfig + { + GroupId = "default", TargetType = (int)TargetType.Piston, + PowerType = (int)PowerType.Piston, + Quantity = 1, + TypicalSpeed = 200, TypicalAltitude = 500, + }); + _scenario.SaveRoute(_taskId, "default", new RoutePlan + { + FormationMode = (int)FormationMode.Formation, + LateralSpacing = 50, + LateralCount = 1, + LongitudinalCount = 1, + }, + new List + { + new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 150 }, + new Waypoint { PosX = 5000, PosY = 500, PosZ = 0, Speed = 150 }, + }); + _scenario.SaveDeployment(_taskId, new List + { + MakeEquipment(DefaultFireUnits.GetById("ground-light"), AerosolType.InertGas, 1, 1500, 0, 50), + }); + _scenario.SaveCloudDispersal(_taskId, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 }); + + var eng = RunSimulation(8000); + var launched = eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched); + var clouds = eng.Events.Count(e => e.Type == SimEventType.CloudGenerated); + + var dump = new System.Text.StringBuilder(); + dump.AppendLine("=== 活塞+西风5m/s ==="); + dump.AppendLine("cloudCreatedAt,centerX,centerY,centerZ,radius,density,elapsed"); + foreach (var c in eng.Clouds) + dump.AppendLine($"{c.CreatedAt:F3},{c.Dispersion.Center.X:F1},{c.Dispersion.Center.Y:F1},{c.Dispersion.Center.Z:F1},{c.Dispersion.EffectiveRadius:F2},{c.Dispersion.CoreDensity:F6},{c.Dispersion.Elapsed:F1}"); + dump.AppendLine("=== 无人机 ==="); + var drone = eng.Drones[0]; + dump.AppendLine($"status={drone.Status} hp={drone.Hp:F3} exposureTime={drone.ExposureTime:F3}"); + dump.AppendLine($"endPos=({drone.PosX:F1},{drone.PosY:F1},{drone.PosZ:F1})"); + System.IO.File.WriteAllText(@"C:\Users\Tellme\Desktop\piston_windy.txt", dump.ToString()); + + var msg = $"PistonWindy: launched={launched} clouds={clouds}"; + foreach (var d in eng.Drones) + msg += $" [{d.Status} hp={d.Hp:F2}]"; + Assert.True(launched > 0, msg); + Assert.True(eng.Drones.All(d => d.Status == DroneStatus.Destroyed), msg); + } + + // ═══════════════════════════════════════════════ // 场景 4:喷气发动机 → 算法推荐活性材料 // ═══════════════════════════════════════════════ @@ -351,5 +417,69 @@ namespace CounterDrone.Core.Tests Assert.True(launched > 0, msg); Assert.True(drone.Hp < 0.1f, msg); } + + // ═══════════════════════════════════════════════ + // 场景 5b:空基平台 + 东风 5m/s — 验证风偏补偿后仍能拦截 + // 航路 X:0→10000 Z:0;东风 WindToVector(E)=(5,0,0) 把云团吹向 +X + // Planner 应逆风预置抛撒点(-X方向),空基平台投放后云团漂移回航路 + // ═══════════════════════════════════════════════ + + [Fact] + public void Scenario_AirBased_Windy_PlatformFliesAndDrops() + { + var task = _scenario.CreateTask("空基+东风拦截测试", ""); + _taskId = task.Id; + + _scenario.SaveScene(_taskId, new CombatScene + { + WeatherType = (int)WeatherType.Sunny, + WindSpeed = 5, + WindDirection = (int)WindDirection.E, + }); + _scenario.SaveTarget(_taskId, new TargetConfig + { + GroupId = "default", + TargetType = (int)TargetType.Piston, + PowerType = (int)PowerType.Piston, + Quantity = 1, + TypicalSpeed = 150, + TypicalAltitude = 500, + }); + _scenario.SaveRoute(_taskId, "default", new RoutePlan { FormationMode = (int)FormationMode.Single }, + new List + { + new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 150 }, + new Waypoint { PosX = 10000, PosY = 500, PosZ = 0, Speed = 150 }, + }); + + _scenario.SaveDeployment(_taskId, new List + { + MakeEquipment(DefaultFireUnits.GetById("air-standard"), AerosolType.InertGas, 3, 1500, 1000, 0), + }); + _scenario.SaveCloudDispersal(_taskId, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 }); + + var eng = RunSimulation(8000); + + Assert.All(eng.Events.Where(e => e.Type == SimEventType.MunitionLaunched), + e => Assert.Contains("空基", e.Description)); + + var drone = eng.Drones[0]; + var launched = eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched); + var clouds = eng.Events.Count(e => e.Type == SimEventType.CloudGenerated); + + var dump = new System.Text.StringBuilder(); + dump.AppendLine("=== 空基+东风5m/s ==="); + dump.AppendLine("cloudCreatedAt,centerX,centerY,centerZ,radius,density,elapsed"); + foreach (var c in eng.Clouds) + dump.AppendLine($"{c.CreatedAt:F3},{c.Dispersion.Center.X:F1},{c.Dispersion.Center.Y:F1},{c.Dispersion.Center.Z:F1},{c.Dispersion.EffectiveRadius:F2},{c.Dispersion.CoreDensity:F6},{c.Dispersion.Elapsed:F1}"); + dump.AppendLine("=== 无人机 ==="); + dump.AppendLine($"status={drone.Status} hp={drone.Hp:F3} exposureTime={drone.ExposureTime:F3}"); + dump.AppendLine($"endPos=({drone.PosX:F1},{drone.PosY:F1},{drone.PosZ:F1})"); + System.IO.File.WriteAllText(@"C:\Users\Tellme\Desktop\air_windy.txt", dump.ToString()); + + var msg = $"AirBasedWindy: launched={launched} clouds={clouds} status={drone.Status} hp={drone.Hp:F2}"; + Assert.True(launched > 0, msg); + Assert.True(drone.Hp < 0.1f, msg); + } } } diff --git a/test/unit/CounterDrone.Core.Tests/PlannerConfigTests.cs b/test/unit/CounterDrone.Core.Tests/PlannerConfigTests.cs new file mode 100644 index 0000000..cd7f209 --- /dev/null +++ b/test/unit/CounterDrone.Core.Tests/PlannerConfigTests.cs @@ -0,0 +1,95 @@ +using System; +using System.IO; +using CounterDrone.Core.Algorithms; +using CounterDrone.Core.Models; +using Xunit; + +namespace CounterDrone.Core.Tests +{ + public class PlannerConfigTests : IDisposable + { + private readonly string _testDir; + + public PlannerConfigTests() + { + _testDir = Path.Combine(Path.GetTempPath(), $"cd_cfg_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testDir); + } + + public void Dispose() + { + if (Directory.Exists(_testDir)) Directory.Delete(_testDir, true); + } + + private void WriteConfig(string json) + => File.WriteAllText(Path.Combine(_testDir, "planner_config.json"), json); + + private static readonly string ValidJson = @" +{ + ""CloudOverlapRatio"": 0.2, + ""CriticalProbabilityThreshold"": 0.5, + ""MaxInterceptProbability"": 0.95, + ""TypeCoefficient"": { + ""HighSpeed"": 4.0, ""FixedWing"": 2.0, ""Piston"": 2.0, + ""Rotor"": 1.0, ""Electric"": 1.0 + }, + ""AmmoMatch"": { + ""Electric"": ""InertGas"", ""Piston"": ""InertGas"", ""Jet"": ""ActiveMaterial"" + } +}"; + + [Fact] + public void Load_ValidJson_ReturnsAllFields() + { + WriteConfig(ValidJson); + var cfg = PlannerConfig.Load(_testDir); + Assert.Equal(0.2f, cfg.CloudOverlapRatio); + Assert.Equal(0.5f, cfg.CriticalProbabilityThreshold); + Assert.Equal(0.95f, cfg.MaxInterceptProbability); + Assert.Equal(4.0f, cfg.TypeCoefficient[TargetType.HighSpeed]); + Assert.Equal(AerosolType.InertGas, cfg.AmmoMatch[PowerType.Piston]); + Assert.Equal(AerosolType.ActiveMaterial, cfg.AmmoMatch[PowerType.Jet]); + } + + [Fact] + public void Load_MissingFile_Throws() + { + // 不写文件 + Assert.Throws(() => PlannerConfig.Load(_testDir)); + } + + [Fact] + public void Load_MissingField_Throws() + { + // 缺 CriticalProbabilityThreshold + WriteConfig(@"{ ""CloudOverlapRatio"": 0.2, ""MaxInterceptProbability"": 0.95, + ""TypeCoefficient"": {""Piston"": 2.0}, ""AmmoMatch"": {""Piston"": ""InertGas""} }"); + Assert.Throws(() => PlannerConfig.Load(_testDir)); + } + + [Fact] + public void Load_OverlapOutOfRange_Throws() + { + // CloudOverlapRatio = 1.0 非法(必须 < 1) + var bad = ValidJson.Replace("0.2", "1.0"); + WriteConfig(bad); + Assert.Throws(() => PlannerConfig.Load(_testDir)); + } + + [Fact] + public void Load_EmptyDataRoot_Throws() + { + Assert.Throws(() => PlannerConfig.Load("")); + } + + [Fact] + public void Load_EnumParsedFromString() + { + // 验证枚举用字符串(非数字)也能正确解析 + WriteConfig(ValidJson); + var cfg = PlannerConfig.Load(_testDir); + Assert.Equal(1.0f, cfg.TypeCoefficient[TargetType.Rotor]); + Assert.Equal(AerosolType.InertGas, cfg.AmmoMatch[PowerType.Electric]); + } + } +} diff --git a/test/unit/CounterDrone.Core.Tests/RouteGeometryTests.cs b/test/unit/CounterDrone.Core.Tests/RouteGeometryTests.cs new file mode 100644 index 0000000..eb246ff --- /dev/null +++ b/test/unit/CounterDrone.Core.Tests/RouteGeometryTests.cs @@ -0,0 +1,187 @@ +using System.Collections.Generic; +using CounterDrone.Core.Algorithms; +using CounterDrone.Core.Models; +using Xunit; + +namespace CounterDrone.Core.Tests +{ + public class RouteGeometryTests + { + private static List Line(float x0, float z0, float x1, float z1, float y = 500f) + => new() + { + new Waypoint { PosX = x0, PosY = y, PosZ = z0 }, + new Waypoint { PosX = x1, PosY = y, PosZ = z1 }, + }; + + private static List LShape() + => new() + { + new Waypoint { PosX = 0, PosY = 500, PosZ = 0 }, // 起点 + new Waypoint { PosX = 100, PosY = 500, PosZ = 0 }, // 拐点(X 段 100m) + new Waypoint { PosX = 100, PosY = 500, PosZ = 100 }, // 终点(Z 段 100m) + }; + + // ═══════════════════════════════════════ + // TotalLength + // ═══════════════════════════════════════ + + [Fact] + public void TotalLength_StraightLine() + { + var wps = Line(0, 0, 3000, 4000); // 3-4-5 → 5000m + Assert.Equal(5000f, RouteGeometry.TotalLength(wps), 1); + } + + [Fact] + public void TotalLength_LShape() + { + var wps = LShape(); // 100 + 100 = 200m + Assert.Equal(200f, RouteGeometry.TotalLength(wps), 1); + } + + [Fact] + public void TotalLength_SingleWaypoint_Zero() + { + var wps = new List { new Waypoint { PosX = 5, PosY = 5, PosZ = 5 } }; + Assert.Equal(0f, RouteGeometry.TotalLength(wps)); + } + + // ═══════════════════════════════════════ + // PositionAt + // ═══════════════════════════════════════ + + [Fact] + public void PositionAt_Start_ReturnsFirstWaypoint() + { + var wps = Line(0, 0, 1000, 0); + var (x, y, z) = RouteGeometry.PositionAt(wps, 0f); + Assert.Equal(0f, x, 1); + Assert.Equal(0f, z, 1); + } + + [Fact] + public void PositionAt_Midpoint_LinearInterp() + { + var wps = Line(0, 0, 1000, 0); + var (x, _, _) = RouteGeometry.PositionAt(wps, 500f); // 中点 + Assert.Equal(500f, x, 1); + } + + [Fact] + public void PositionAt_LShape_Corner() + { + var wps = LShape(); // 0→(100,0)→(100,100) + // 弧长 100 = 拐点 + var (x, _, z) = RouteGeometry.PositionAt(wps, 100f); + Assert.Equal(100f, x, 1); + Assert.Equal(0f, z, 1); + // 弧长 150 = Z 段中点 + var (x2, _, z2) = RouteGeometry.PositionAt(wps, 150f); + Assert.Equal(100f, x2, 1); + Assert.Equal(50f, z2, 1); + } + + [Fact] + public void PositionAt_BeyondEnd_ReturnsLastWaypoint() + { + var wps = Line(0, 0, 1000, 0); + var (x, _, _) = RouteGeometry.PositionAt(wps, 99999f); + Assert.Equal(1000f, x, 1); + } + + // ═══════════════════════════════════════ + // ArcLengthNearestTo + // ═══════════════════════════════════════ + + [Fact] + public void ArcLengthNearestTo_PointOnLine() + { + var wps = Line(0, 0, 1000, 0); // X 轴上 + // 点 (300, 0) 在航路上,弧长应=300 + Assert.Equal(300f, RouteGeometry.ArcLengthNearestTo(wps, 300f, 0f), 1); + } + + [Fact] + public void ArcLengthNearestTo_PointOffLine_Projects() + { + var wps = Line(0, 0, 1000, 0); // X 轴 + // 点 (300, 50) 投影回 X 轴弧长=300 + Assert.Equal(300f, RouteGeometry.ArcLengthNearestTo(wps, 300f, 50f), 1); + } + + [Fact] + public void ArcLengthNearestTo_LShape_CorrectSegment() + { + var wps = LShape(); // 0→(100,0)→(100,100) + // 点 (50, 0) 在第一段,弧长=50 + Assert.Equal(50f, RouteGeometry.ArcLengthNearestTo(wps, 50f, 0f), 1); + // 点 (100, 50) 在第二段(X 段 100 + Z 段 50),弧长=150 + Assert.Equal(150f, RouteGeometry.ArcLengthNearestTo(wps, 100f, 50f), 1); + } + + // ═══════════════════════════════════════ + // TravelTimeTo + // ═══════════════════════════════════════ + + [Fact] + public void TravelTimeTo_UniformSpeed() + { + // 弧长 1000m,速度 36km/h=10m/s → 100s + Assert.Equal(100f, RouteGeometry.TravelTimeTo(Line(0, 0, 1000, 0), 1000f, 36f), 1); + } + + [Fact] + public void TravelTimeTo_ZeroSpeed_Throws() + { + Assert.Throws( + () => RouteGeometry.TravelTimeTo(Line(0, 0, 1000, 0), 1000f, 0f)); + } + + // ═══════════════════════════════════════ + // TangentAt + // ═══════════════════════════════════════ + + [Fact] + public void TangentAt_XDirection_IsUnitX() + { + var wps = Line(0, 0, 1000, 0); // +X 方向 + var (dx, dz) = RouteGeometry.TangentAt(wps, 500f); + Assert.Equal(1f, dx, 3); + Assert.Equal(0f, dz, 3); + } + + [Fact] + public void TangentAt_ZDirection_IsUnitZ() + { + var wps = Line(0, 0, 0, 1000); // +Z 方向 + var (dx, dz) = RouteGeometry.TangentAt(wps, 500f); + Assert.Equal(0f, dx, 3); + Assert.Equal(1f, dz, 3); + } + + [Fact] + public void TangentAt_LShape_CornerTransition() + { + var wps = LShape(); + // 第一段(弧长 50):+X + var (dx1, dz1) = RouteGeometry.TangentAt(wps, 50f); + Assert.Equal(1f, dx1, 3); + Assert.Equal(0f, dz1, 3); + // 第二段(弧长 150):+Z + var (dx2, dz2) = RouteGeometry.TangentAt(wps, 150f); + Assert.Equal(0f, dx2, 3); + Assert.Equal(1f, dz2, 3); + } + + [Fact] + public void TangentAt_AlwaysUnitLength() + { + // 任意方向直线,切向量必须是单位向量 + var wps = Line(0, 0, 3000, 4000); // 3-4-5 方向 + var (dx, dz) = RouteGeometry.TangentAt(wps, 100f); + float mag = (float)System.Math.Sqrt(dx * dx + dz * dz); + Assert.Equal(1f, mag, 3); + } + } +} diff --git a/test/unit/CounterDrone.Core.Tests/SimulationEngineTests.cs b/test/unit/CounterDrone.Core.Tests/SimulationEngineTests.cs index 3047d25..66db8b5 100644 --- a/test/unit/CounterDrone.Core.Tests/SimulationEngineTests.cs +++ b/test/unit/CounterDrone.Core.Tests/SimulationEngineTests.cs @@ -36,7 +36,7 @@ namespace CounterDrone.Core.Tests new RoutePlanRepository(_mainDb), new WaypointRepository(_mainDb)); _engine = new SimulationEngine(_scenarioService, new FrameDataStore(paths), - new DamageModelRouter(), paths, new DefaultDefensePlanner(DefaultAmmunition.GetAll())); + new DamageModelRouter(), paths, new DefaultDefensePlanner(DefaultAmmunition.GetAll(), TestPlannerConfig.Instance)); } public void Dispose() diff --git a/test/unit/CounterDrone.Core.Tests/TestPlannerConfig.cs b/test/unit/CounterDrone.Core.Tests/TestPlannerConfig.cs new file mode 100644 index 0000000..31352ba --- /dev/null +++ b/test/unit/CounterDrone.Core.Tests/TestPlannerConfig.cs @@ -0,0 +1,21 @@ +using System; +using System.IO; +using CounterDrone.Core.Algorithms; + +namespace CounterDrone.Core.Tests +{ + /// 测试用 PlannerConfig 加载辅助。 + /// 从仓库 data/planner_config.json 加载真实配置,确保测试与生产配置一致。 + public static class TestPlannerConfig + { + private static readonly Lazy _instance = new(() => + { + // bin\Debug\net10.0 → 上 6 层到仓库根,再进 data 目录 + string baseDir = AppDomain.CurrentDomain.BaseDirectory; + string repoRoot = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", "..")); + return PlannerConfig.Load(Path.Combine(repoRoot, "data")); + }); + + public static PlannerConfig Instance => _instance.Value; + } +}