feat(v0.5.0): weather into planner + unified physics model
Planner no longer writes local physics formulas. All kinematics/geometry/damage go through shared utility classes (Kinematics/RouteGeometry/CloudExpansionModel/DamageAssessment). Core: RouteGeometry (route geometry), PlannerConfig + planner_config.json (config externalization). Planner route-aware layout (offset along tangent), cloud overlap 20pct. DroneEntity arc-length driven. Fixes: PathInSphere cloud-frame correction (root cause), GaussianPuffDispersion hardcoded Sunny, ComputeEffectiveRadius cloud age, planner wind offset, remove drone wind bias. Tests 167 to 191, 41s. Windy scenarios pass.
This commit is contained in:
parent
53ac665a0e
commit
8515f7821a
51
CHANGELOG.md
51
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
|
||||
|
||||
17
data/planner_config.json
Normal file
17
data/planner_config.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
@ -1004,6 +1004,56 @@ static readonly Dictionary<PowerType, AerosolType> 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:反推平台部署
|
||||
|
||||
```
|
||||
|
||||
@ -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` 默认值 |
|
||||
|
||||
35
docs/requirements/changes/2026-06-14-天气纳入规划与物理模型统一.md
Normal file
35
docs/requirements/changes/2026-06-14-天气纳入规划与物理模型统一.md
Normal file
@ -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 运动正确
|
||||
@ -108,14 +108,16 @@ namespace CounterDrone.Core.Algorithms
|
||||
/// <summary>综合优先级 = 威胁指数 / (到达时间 + 1)</summary>
|
||||
public float Priority => ArrivalTime > -1 ? ThreatIndex / (ArrivalTime + 1f) : ThreatIndex;
|
||||
|
||||
/// <summary>预计到达航路中点的时间(秒)</summary>
|
||||
/// <summary>预计到达航路中点的时间(秒)。
|
||||
/// 物理含义:匀速直线运动从航路起点到中点的飞行时间。</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -69,12 +69,11 @@ namespace CounterDrone.Core.Algorithms
|
||||
|
||||
/// <summary>覆盖指定距离需要的云团数量</summary>
|
||||
/// <param name="requiredCoverage">需要覆盖的距离 (m)</param>
|
||||
/// <param name="effectiveRadius">使用哪个半径值(null=默认TurbulentRadius)</param>
|
||||
public int RoundsNeeded(float requiredCoverage, float? effectiveRadius = null)
|
||||
/// <param name="spacing">相邻云团中心间距 (m),默认=2R(相切)。重叠时由调用方传入更小值。</param>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>达到指定半径所需时间 (s)</summary>
|
||||
|
||||
@ -5,36 +5,23 @@ using CounterDrone.Core.Models;
|
||||
|
||||
namespace CounterDrone.Core.Algorithms
|
||||
{
|
||||
/// <summary>默认防御规划器 — 五步流水线,全部使用真实物理计算</summary>
|
||||
/// <summary>默认防御规划器 — 五步流水线,全部调用与引擎共享的物理工具类</summary>
|
||||
public class DefaultDefensePlanner : IDefensePlanner
|
||||
{
|
||||
private readonly List<AmmunitionSpec> _ammoCatalog;
|
||||
private readonly IDamageModel _damageModel;
|
||||
private readonly PlannerConfig _config;
|
||||
|
||||
public DefaultDefensePlanner(List<AmmunitionSpec> ammoCatalog, IDamageModel damageModel = null)
|
||||
public DefaultDefensePlanner(List<AmmunitionSpec> 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<PowerType, AerosolType> MatchTable = new()
|
||||
{
|
||||
{ PowerType.Electric, AerosolType.InertGas },
|
||||
{ PowerType.Piston, AerosolType.InertGas },
|
||||
{ PowerType.Jet, AerosolType.ActiveMaterial },
|
||||
};
|
||||
|
||||
private static readonly Dictionary<TargetType, float> 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<FireUnit> availableUnits, CombatScene env)
|
||||
{
|
||||
var candidates = new List<InterceptCandidate>();
|
||||
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<FireEvent>();
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -162,6 +162,18 @@ namespace CounterDrone.Core.Algorithms
|
||||
return (float)Math.Sqrt(dx * dx + dz * dz);
|
||||
}
|
||||
|
||||
/// <summary>匀速直线运动从一点到另一点的飞行时间(秒)。
|
||||
/// 物理模型:无人机/平台沿直线匀速飞行,时间 = 距离 / 速度。
|
||||
/// speedKmh 为 0 时抛异常(速度必须 > 0,由调用方保证)。</summary>
|
||||
/// <param name="speedKmh">速度 km/h(与 TypicalSpeed 单位一致)</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>点到三维点距离</summary>
|
||||
public static float Distance3D(float x1, float y1, float z1, float x2, float y2, float z2)
|
||||
{
|
||||
|
||||
71
src/CounterDrone.Core/Algorithms/PlannerConfig.cs
Normal file
71
src/CounterDrone.Core/Algorithms/PlannerConfig.cs
Normal file
@ -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
|
||||
{
|
||||
/// <summary>防御规划器配置 — 全局策略参数,从 planner_config.json 加载。
|
||||
/// 代码零默认值,所有字段必须从配置文件读取;文件缺失或字段缺失即抛异常。</summary>
|
||||
public class PlannerConfig
|
||||
{
|
||||
/// <summary>云团重叠比例(0=相切,0.2=重叠 20%)。间距 = 2R × (1 − 重叠比例)。</summary>
|
||||
public float CloudOverlapRatio { get; set; }
|
||||
|
||||
/// <summary>临界方案概率阈值(DeriveCritical 用)</summary>
|
||||
public float CriticalProbabilityThreshold { get; set; }
|
||||
|
||||
/// <summary>拦截概率上限(封顶值)</summary>
|
||||
public float MaxInterceptProbability { get; set; }
|
||||
|
||||
/// <summary>威胁类型系数表(TargetType → 系数)</summary>
|
||||
public Dictionary<TargetType, float> TypeCoefficient { get; set; } = new();
|
||||
|
||||
/// <summary>弹药匹配表(PowerType → AerosolType)</summary>
|
||||
public Dictionary<PowerType, AerosolType> AmmoMatch { get; set; } = new();
|
||||
|
||||
private const string ConfigFileName = "planner_config.json";
|
||||
|
||||
/// <summary>从 dataRoot 加载配置。文件缺失或字段非法即抛异常。</summary>
|
||||
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<PlannerConfig>(json, JsonOptions)
|
||||
?? throw new InvalidDataException($"planner 配置解析失败: {path}");
|
||||
config.Validate();
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>从 IPathProvider 加载(便捷重载)</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
131
src/CounterDrone.Core/Algorithms/RouteGeometry.cs
Normal file
131
src/CounterDrone.Core/Algorithms/RouteGeometry.cs
Normal file
@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using CounterDrone.Core.Models;
|
||||
|
||||
namespace CounterDrone.Core.Algorithms
|
||||
{
|
||||
/// <summary>航路几何工具——对 waypoint 序列做纯几何运算。
|
||||
/// 无状态、纯函数,与 Kinematics/DamageAssessment 同范式。
|
||||
/// planner(预测)和 DroneEntity(执行)共用,确保两边航路模型一致。</summary>
|
||||
public static class RouteGeometry
|
||||
{
|
||||
/// <summary>航路总弧长(米):所有相邻 waypoint 三维距离之和。</summary>
|
||||
public static float TotalLength(IReadOnlyList<Waypoint> 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;
|
||||
}
|
||||
|
||||
/// <summary>沿航路弧长 s 处的三维位置(分段线性插值)。
|
||||
/// s≤0 返回起点,s≥TotalLength 返回终点。</summary>
|
||||
public static (float X, float Y, float Z) PositionAt(IReadOnlyList<Waypoint> 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);
|
||||
}
|
||||
|
||||
/// <summary>航路上离目标点 (x,z) 最近的点对应的弧长(水平投影最近)。
|
||||
/// 用于 planner 定位"无人机穿越点在航路上的弧长位置"。
|
||||
/// 算法:逐段求点到线段的最近点,取全局最近者。</summary>
|
||||
public static float ArcLengthNearestTo(IReadOnlyList<Waypoint> 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;
|
||||
}
|
||||
|
||||
/// <summary>从航路起点匀速运动到弧长 s 处的飞行时间(秒)。
|
||||
/// 匀速直线模型,时间 = 弧长 / 速度。内部调用 Kinematics.TravelTime。</summary>
|
||||
/// <param name="speedKmh">速度 km/h(与 TypicalSpeed 单位一致),必须 > 0</param>
|
||||
public static float TravelTimeTo(IReadOnlyList<Waypoint> 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);
|
||||
}
|
||||
|
||||
/// <summary>沿航路方向给定弧长 s 处的水平单位切向量 (DirX, DirZ)。
|
||||
/// 用于 planner 沿航路方向布云(offset 沿切向,不再写死 X 轴)。
|
||||
/// 弧长超出范围时取末段方向。</summary>
|
||||
public static (float DirX, float DirZ) TangentAt(IReadOnlyList<Waypoint> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -27,6 +27,8 @@ namespace CounterDrone.Core.Simulation
|
||||
public float PrevZ { get; private set; }
|
||||
public float FormationOffsetX { get; set; }
|
||||
public float FormationOffsetY { get; set; }
|
||||
/// <summary>沿航路已飞行弧长(米),由 RouteGeometry 驱动</summary>
|
||||
private float _traveledArc;
|
||||
|
||||
public DroneEntity(string id, string groupId, TargetConfig config, List<Waypoint> 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)
|
||||
|
||||
@ -47,8 +47,6 @@ namespace CounterDrone.Core.Simulation
|
||||
private readonly List<FireEvent> _fireSchedule = new();
|
||||
private int _nextFireIndex;
|
||||
private readonly List<SimEvent> _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<string> { "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 });
|
||||
}
|
||||
|
||||
Binary file not shown.
@ -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> { droneGroup }, detail.Scene);
|
||||
var result = new DefaultDefensePlanner(ammoCatalog, PlannerConfig.Load(paths)).Plan(fireUnits, new List<DroneGroup> { droneGroup }, detail.Scene);
|
||||
scenarioMgr.SaveCloud(taskId, new CloudDispersal
|
||||
{
|
||||
PositionX = (droneGroup.Waypoints[0].PosX + droneGroup.Waypoints[^1].PosX) / 2,
|
||||
|
||||
@ -67,7 +67,7 @@ namespace CounterDrone.Unity
|
||||
// 推荐方案
|
||||
var detail = scenario.GetTaskDetail(taskId);
|
||||
var ammoCatalog = db.Table<AmmunitionSpec>().ToList();
|
||||
var planner = new DefaultDefensePlanner(ammoCatalog);
|
||||
var planner = new DefaultDefensePlanner(ammoCatalog, PlannerConfig.Load(paths));
|
||||
var droneGroup = new DroneGroup
|
||||
{
|
||||
GroupId = "default",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -59,7 +59,7 @@ namespace CounterDrone.Core.Tests
|
||||
|
||||
private PlannerResult Plan(List<FireUnit> 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<Waypoint>
|
||||
{
|
||||
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 发散"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Waypoint>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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<Waypoint>
|
||||
{
|
||||
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<EquipmentDeployment>
|
||||
{
|
||||
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<Waypoint>
|
||||
{
|
||||
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<EquipmentDeployment>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
95
test/unit/CounterDrone.Core.Tests/PlannerConfigTests.cs
Normal file
95
test/unit/CounterDrone.Core.Tests/PlannerConfigTests.cs
Normal file
@ -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<FileNotFoundException>(() => 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<InvalidDataException>(() => 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<InvalidDataException>(() => PlannerConfig.Load(_testDir));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_EmptyDataRoot_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
187
test/unit/CounterDrone.Core.Tests/RouteGeometryTests.cs
Normal file
187
test/unit/CounterDrone.Core.Tests/RouteGeometryTests.cs
Normal file
@ -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<Waypoint> 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<Waypoint> 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<Waypoint> { 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<System.ArgumentException>(
|
||||
() => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
21
test/unit/CounterDrone.Core.Tests/TestPlannerConfig.cs
Normal file
21
test/unit/CounterDrone.Core.Tests/TestPlannerConfig.cs
Normal file
@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using CounterDrone.Core.Algorithms;
|
||||
|
||||
namespace CounterDrone.Core.Tests
|
||||
{
|
||||
/// <summary>测试用 PlannerConfig 加载辅助。
|
||||
/// 从仓库 data/planner_config.json 加载真实配置,确保测试与生产配置一致。</summary>
|
||||
public static class TestPlannerConfig
|
||||
{
|
||||
private static readonly Lazy<PlannerConfig> _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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user