Compare commits

...

3 Commits

Author SHA1 Message Date
56d345189e refactor: 编组概念拆分为批次(WaveId)+火力单元(FireUnit)
Breaking: Group表/枚举/Service/Repository移除; GroupId->WaveId; DroneGroup->DroneWave; DroneGroupId->DroneWaveId; GroupManager删除

Docs: CHANGELOG 0.7.0; 总体架构 V10; DefensePlanner V4; 实施计划 V1.4; 对接文档 V1.2

Tests: 204/204 pass
2026-06-15 15:13:48 +08:00
665747a846 feat: standalone detection equipment API + task detail includes detections
IScenarioService: AddDetection/DeleteDetection/GetDetections (independent of SaveDeployment overwrite).

Repository: GetByTaskIdAndRole filters by equipment role.

TaskFullConfig.Equipment already includes detections (GetTaskDetail.GetByTaskId returns all). Frontend filters by EquipmentRole.Detection or uses GetDetections.

Tests: 4 detection CRUD tests. 212 total pass.
2026-06-15 13:30:16 +08:00
11f8cb2c79 feat: detection-driven planning (v0.6.0)
Planner no longer has god-view. It assumes threats enter from detection boundary (unified info network earliest detection point), not route start.

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

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

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

Tests 193 to 208 (+15). 0 warnings.
2026-06-15 12:52:57 +08:00
48 changed files with 948 additions and 544 deletions

View File

@ -2,6 +2,76 @@
--- ---
## [0.7.0] - 2026-06-15
### Breaking — 概念升级:编组拆分 + Group 表移除
- **Group 表移除**:原 `Group`DroneFleet / EquipmentGroup不再创建。向后兼容保留旧库中的 Group 表,但不再主动读写
- **GroupType 枚举删除**:不再区分 DroneFleet / EquipmentGroup
- **GroupService / IGroupService / GroupRepository 删除**:不再需要编组管理服务
- **Unity GroupManager 删除**:不再需要编组管理桥接
### Changed — 数据模型重命名
- **`GroupId``WaveId`**TargetConfig、EquipmentDeployment、RoutePlan、Waypoint 四表的编组外键重命名为批次外键
- **RoutePlan 索引重命名**`(TaskId, GroupId)` → `(TaskId, WaveId)`
- **RoutePlanRepository / WaypointRepository**`GetByTaskAndGroup` → `GetByTaskAndWave`
- **IScenarioService.SaveRoute**`groupId` 参数 → `waveId`
### Changed — 算法类型重命名
- **`DroneGroup``DroneWave`**AlgorithmTypes、IDefensePlanner、DefensePlanner、SimulationEngine 全部使用新类型名
- **`DroneGroupId``DroneWaveId`**UnitAssignment 中的编队引用 → 批次引用
- **`BuildDroneGroups()``BuildDroneWaves()`**SimulationEngine 构建方法重命名
### Changed — 文档同步
- 总体架构设计:编组概念升级说明
- DefensePlanner 技术方案DroneGroup → DroneWave
- 实施计划GroupService 移除、多编队 → 多批次
- Unity 前端对接文档DefaultDefenseAdvisor → DefensePlannerSaveRoute 签名更新
- 仿真器实体事件映射BuildDroneGroups → BuildDroneWaves
### Removed
- 7 个文件Group.cs / GroupRepository.cs / IGroupService.cs / GroupService.cs / GroupManager.cs / GroupServiceTests.cs / GroupRepositoryTests.cs
### Metrics
- 测试 208 → **204**4删除 Group 相关测试),全量通过 63s
- 0 编译错误0 编译警告
---
## [0.6.0] - 2026-06-15
### Added — 探测驱动的规划8.1.1
- **DetectionCalculator 静态工具类**:探测能力评估的唯一实现(与 Kinematics/RouteGeometry 同范式)。光电受 Visibility 衰减(`有效=基准×min(1,Visibility/基准)`),雷达/红外不受影响;统一信息网络找最早探测点(线段-圆求交)
- **探测源模型**DetectionSource独立探测设备 + 火力单元自带探测统一表达(雷达/光电/红外三距离 + 精度)
- **EquipmentDeployment 扩展**:删单一 `DetectionRadius`,加 `RadarRange/EORange/IRange/DetectionAccuracy` 四字段
- **FireUnit 探测字段激活**BuildFireUnits 从 EquipmentDeployment 读取并赋值(原为死代码)
- **planner 接口扩展**`IDefensePlanner.Plan` 加第 4 参数 `detectionSources`
- **PlannerConfig 加 DefaultDetectionAccuracy**(无探测时回退精度)
- 单元测试DetectionCalculator 13 项、探测驱动规划 2 项
### Changed — planner 基于探测信息规划
- planner 假设威胁从探测边界被发现,到达时间 = (拦截弧长 探测弧长)/速度,而非上帝视角的航路起点
- SimulationEngine.Initialize 构建 `List<DetectionSource>`(独立探测 + 火力单元自带),传入 planner
- 报告探测设备表用新字段(雷达/光电/红外/精度)
### Fixed
- Solve 健壮性GenerateFireEventsAt 返回空时(探测边界太靠后来不及拦截)不再 IndexOutOfRange
### Metrics
- 测试 193 → **208**+15全量通过 64s
- 0 编译警告
---
## [0.5.0] - 2026-06-14 ## [0.5.0] - 2026-06-14
### Added — 物理模型统一架构 ### Added — 物理模型统一架构

View File

@ -1 +1 @@
0.5.0 0.6.0

View File

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

View File

@ -1,11 +1,11 @@
# 反无人机仿真系统 — 总体架构设计 # 反无人机仿真系统 — 总体架构设计
> **版本**V9 > **版本**V10
> **日期**2026-06-13 > **日期**2026-06-15
> **状态**:已实现 > **状态**:已实现
> **Unity 版本**22.3.62 > **Unity 版本**22.3.62
> **.NET 版本**.NET Standard 2.1 > **.NET 版本**.NET Standard 2.1
> **变更**DefensePlanner 五步规划引擎FireUnit 通道模型空基平台飞控SQLite domain reload 修复;删除所有 fallback 默认值 > **变更**Group 表移除GroupId → WaveIdDroneGroup → DroneWave编组概念升级为批次+火力单元
--- ---
@ -17,7 +17,7 @@
| 模块 | 核心功能 | | 模块 | 核心功能 |
|------|----------| |------|----------|
| **模型管理** | 3D 模型导入/预览/删除,编组管理 | | **模型管理** | 3D 模型导入/预览/删除 |
| **想定管理** | 仿真任务 CRUD步骤化配置向导管控区域设置搜索分页 | | **想定管理** | 仿真任务 CRUD步骤化配置向导管控区域设置搜索分页 |
| **仿真报告** | 报告列表,含时序图的完整报告预览,导出 PDF/Word删除 | | **仿真报告** | 报告列表,含时序图的完整报告预览,导出 PDF/Word删除 |
@ -41,12 +41,12 @@
├──────────────────────────────────────────────────────────┤ ├──────────────────────────────────────────────────────────┤
│ Unity Application Layer │ │ Unity Application Layer │
│ ModelPanel | ScenarioWizard | SimulationRunner | │ │ ModelPanel | ScenarioWizard | SimulationRunner | │
│ ReplayController | ReportPanel | GroupManager │ ReplayController | ReportPanel
├──────────────────────────────────────────────────────────┤ ├──────────────────────────────────────────────────────────┤
│ Service Layer (Pure C#) │ │ Service Layer (Pure C#) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ ModelService │ │ScenarioService│ │ SimulationEngine │ │ │ │ ModelService │ │ScenarioService│ │ SimulationEngine │ │
│ │ GroupService │ │ DefensePlanner │ │ ├ DroneEntity │ │ │ │ │ │ DefensePlanner │ │ ├ DroneEntity │ │
│ └──────────────┘ └──────────────┘ │ ├ PlatformEntity │ │ │ └──────────────┘ └──────────────┘ │ ├ PlatformEntity │ │
│ ┌──────────────┐ ┌──────────────┐ │ ├ MunitionEntity │ │ │ ┌──────────────┐ ┌──────────────┐ │ ├ MunitionEntity │ │
│ │FrameDataStore│ │ReportService │ │ ├ CloudEntity │ │ │ │FrameDataStore│ │ReportService │ │ ├ CloudEntity │ │
@ -199,9 +199,19 @@ enum TriggerMode { Time = 0, Area = 1, Manual = 2 }
// === 毁伤状态阶段(输出到 StateData 供 Unity 渲染)=== // === 毁伤状态阶段(输出到 StateData 供 Unity 渲染)===
enum DamageStage { Normal = 0, EngineAnomaly = 1, AttitudeLoss = 2, Destroyed = 3 } enum DamageStage { Normal = 0, EngineAnomaly = 1, AttitudeLoss = 2, Destroyed = 3 }
// === 编队 & 编组 === // === 编队 ===
enum FormationMode { Single = 0, Formation = 1, Swarm = 2 } enum FormationMode { Single = 0, Formation = 1, Swarm = 2 }
enum GroupType { DroneFleet = 0, EquipmentGroup = 1 }
// === 核心作战概念 ===
// 火力单元FireUnit能独立完成搜索、跟踪、瞄准并实施打击的最小作战实体。
// 包含探测(雷达/光电/红外)+ 打击(发射架/火炮)的完整闭环系统。
// 配置层 = EquipmentDeployment(LaunchPlatform),含探测字段 + 打击字段。
// 独立探测节点DetectionNode只探测不打击的侦查节点如前沿警戒雷达
// 配置层 = EquipmentDeployment(Detection),仅含探测字段。
// 两者都往统一信息网络送探测信息planner 基于融合后的探测边界规划。
// 无人机批次DroneWave有共同航路的一组无人机攻击方
// 批次关联航路但不独占(多个批次可共享同一条航路)。
// 配置层 = TargetConfig(含 WaveId) + RoutePlan(TaskId, WaveId) + Waypoints。
// === 运行时 === // === 运行时 ===
enum EntityType { Drone = 0, Platform = 1, DetectionEquip = 2, Cloud = 3, Munition = 4 } enum EntityType { Drone = 0, Platform = 1, DetectionEquip = 2, Cloud = 3, Munition = 4 }
@ -254,12 +264,11 @@ CounterDroneBackend_Data/
SimTask — 仿真任务主表 SimTask — 仿真任务主表
CombatScene — 步骤1作战场景含24h时间、天气 CombatScene — 步骤1作战场景含24h时间、天气
ControlZone — 步骤1+:管控区域/电子围栏NEW ControlZone — 步骤1+:管控区域/电子围栏NEW
TargetConfig — 步骤2目标配置 TargetConfig — 步骤2目标配置(含 WaveId 关联批次)
EquipmentDeployment — 步骤3装备部署搭载平台+探测设备 EquipmentDeployment — 步骤3装备部署火力单元含探测+打击,或独立探测节点
CloudDispersal — 步骤4云团抛撒配置 CloudDispersal — 步骤4云团抛撒配置
RoutePlan — 步骤5航路规划1:N SimTask编队 RoutePlan — 步骤5航路规划1:N SimTask批次批次关联但不独占航路FK 关联 TargetConfig.WaveId
Waypoint — 航路点 Waypoint — 航路点
Group — 编组(独立管理)
SimulationReport — 仿真报告 SimulationReport — 仿真报告
SimEvent — 仿真事件(时序图数据源) SimEvent — 仿真事件(时序图数据源)
@ -267,6 +276,12 @@ CounterDroneBackend_Data/
SimFrameRecord — 逐帧位置记录(回放用) SimFrameRecord — 逐帧位置记录(回放用)
``` ```
> **核心作战概念**
> - **火力单元FireUnit**:能独立完成搜索、跟踪、瞄准并实施打击的最小作战实体。包含探测(雷达/光电/红外)+ 打击(发射架/火炮)的完整闭环。配置层 = `EquipmentDeployment(LaunchPlatform)`
> - **独立探测节点DetectionNode**:只探测不打击的侦查节点(如前沿警戒雷达)。配置层 = `EquipmentDeployment(Detection)`
> - **无人机批次DroneWave**:有共同航路的一组无人机(攻击方)。批次关联航路但不独占(多个批次可共享同一条航路)。配置层 = `TargetConfig(WaveId)` + `RoutePlan(TaskId, WaveId)` + `Waypoints`
> - 原来的 `Group`(编组)表已移除:无人机编队概念升级为"批次",装备编组概念不再需要(火力单元是独立作战单位)。
### 4.3 业务表详细设计 ### 4.3 业务表详细设计
#### ModelInfo — 模型元数据 #### ModelInfo — 模型元数据
@ -337,7 +352,7 @@ CounterDroneBackend_Data/
|------|------|------|------| |------|------|------|------|
| Id | TEXT | PK | GUID | | Id | TEXT | PK | GUID |
| TaskId | TEXT | FK | | | TaskId | TEXT | FK | |
| GroupId | TEXT | FK | 关联无人机编队 Group | | WaveId | TEXT | FK | 关联无人机批次(同一 WaveId 的无人机共享航路) |
| TargetType | INTEGER | | 0旋翼 1固定翼 2电推 3活塞 4高速 | | TargetType | INTEGER | | 0旋翼 1固定翼 2电推 3活塞 4高速 |
| Quantity | INTEGER | DEFAULT 1 | 目标数量 | | Quantity | INTEGER | DEFAULT 1 | 目标数量 |
| PowerType | INTEGER | | 0电推 1活塞 2喷吸气 | | PowerType | INTEGER | | 0电推 1活塞 2喷吸气 |
@ -355,28 +370,31 @@ CounterDroneBackend_Data/
| 活塞 | 2.5 | 120 | 800 | | 活塞 | 2.5 | 120 | 800 |
| 高速目标 | 1.5 | 300 | 2000 | | 高速目标 | 1.5 | 300 | 2000 |
#### EquipmentDeployment — 步骤3装备部署重构 #### EquipmentDeployment — 步骤3装备部署火力单元 + 独立探测节点
| 字段 | 类型 | 约束 | 说明 | | 字段 | 类型 | 约束 | 说明 |
|------|------|------|------| |------|------|------|------|
| Id | TEXT | PK | GUID | | Id | TEXT | PK | GUID |
| TaskId | TEXT | FK | | | TaskId | TEXT | FK | |
| EquipmentRole | INTEGER | | 0=探测设备 1=发射平台 | | EquipmentRole | INTEGER | | 0=独立探测节点 1=火力单元(发射平台) |
| Quantity | INTEGER | DEFAULT 1 | 部署数量 | | Quantity | INTEGER | DEFAULT 1 | 部署数量 |
| GroupId | TEXT | FK | 关联装备编组 Group火力单元多批次时用于分配 | | WaveId | TEXT | NULLABLE | 可选关联批次(用于 UI 分组,火力单元是独立作战单位,不强依赖批次 |
| // 以下为发射平台专用字段 | | // 以下为火力单元(发射平台专用字段 |
| PlatformType | INTEGER | NULLABLE | 0=空基(大型无人机) 1=地基(炮弹)探测设备为 NULL | | PlatformType | INTEGER | NULLABLE | 0=空基(大型无人机) 1=地基(炮弹)独立探测节点为 NULL |
| PositionX | REAL | | 部署位置 | | PositionX | REAL | | 部署位置 |
| PositionY | REAL | | | | PositionY | REAL | | |
| PositionZ | REAL | | | | PositionZ | REAL | | |
| AerosolType | INTEGER | NULLABLE | 挂载气溶胶类型,探测设备为 NULL | | AerosolType | INTEGER | NULLABLE | 挂载气溶胶类型,独立探测节点为 NULL |
| MunitionCount | INTEGER | NULLABLE | 挂载弹药数量 | | MunitionCount | INTEGER | NULLABLE | 挂载弹药数量 |
| MuzzleVelocity | REAL | NULLABLE | **NEW** 弹药初速 m/s地基炮弹用 | | MuzzleVelocity | REAL | NULLABLE | **NEW** 弹药初速 m/s地基炮弹用 |
| ReleaseAltitude | REAL | NULLABLE | **NEW** 弹药释放高度 m空基平台用 | | ReleaseAltitude | REAL | NULLABLE | **NEW** 弹药释放高度 m空基平台用 |
| Cooldown | REAL | DEFAULT 5 | 发射冷却时间 s同弹种连发间隔 | | Cooldown | REAL | DEFAULT 5 | 发射冷却时间 s同弹种连发间隔 |
| AmmoChangeTime | REAL | DEFAULT 300 | **NEW** 更换弹种所需时间 s默认 5 分钟) | | AmmoChangeTime | REAL | DEFAULT 300 | **NEW** 更换弹种所需时间 s默认 5 分钟) |
| // 以下为探测设备专用字段 | | // 探测能力(火力单元自带 + 独立探测节点均有) |
| DetectionRadius | REAL | NULLABLE | 探测半径 m | | RadarRange | REAL | NULLABLE | 雷达探测距离 m雨雾不衰减 |
| EORange | REAL | NULLABLE | 光电探测距离 m受 Visibility 衰减) |
| IRRange | REAL | NULLABLE | 红外探测距离 m不受 Visibility 影响) |
| DetectionAccuracy | REAL | NULLABLE | 探测精度 m位置误差影响抛撒散布范围 |
> **装备体系说明** > **装备体系说明**
> - **搭载平台**2 种): > - **搭载平台**2 种):
@ -415,12 +433,12 @@ CounterDroneBackend_Data/
#### RoutePlan — 步骤5航路规划 #### RoutePlan — 步骤5航路规划
> **支持多批次**:通过 `TaskId + GroupId` 唯一标识一条航路,一个任务可有多个编队各自独立航路。 > **支持多批次**:通过 `TaskId + WaveId` 唯一标识一条航路,一个任务可有多个批次各自独立航路。
| 字段 | 类型 | 约束 | 说明 | | 字段 | 类型 | 约束 | 说明 |
|------|------|------|------| |------|------|------|------|
| TaskId | TEXT | PK(复合) | | | TaskId | TEXT | PK(复合) | |
| GroupId | TEXT | PK(复合), FK → Group | 关联编队:同一任务不同编队可有独立航路 | | WaveId | TEXT | PK(复合) | 关联批次:同一任务不同批次可有独立航路(多个批次可共享同一条航路) |
| FormationMode | INTEGER | | 0单机 1编队 2蜂群 | | FormationMode | INTEGER | | 0单机 1编队 2蜂群 |
| FormationSpacing | REAL | DEFAULT 50 | m | | FormationSpacing | REAL | DEFAULT 50 | m |
| ETA | TEXT | | 预计到达时间 | | ETA | TEXT | | 预计到达时间 |
@ -438,15 +456,13 @@ CounterDroneBackend_Data/
| Altitude | REAL | | 该航点飞行高度 m | | Altitude | REAL | | 该航点飞行高度 m |
| Speed | REAL | | 该段速度 km/h | | Speed | REAL | | 该段速度 km/h |
#### Group — 编组 #### ~~Group~~ — 编组(已移除)
| 字段 | 类型 | 约束 | 说明 | > **概念升级**:原 Group 表的两种类型已分别升级:
|------|------|------|------| > - `DroneFleet`(无人机编队)→ **无人机批次DroneWave**:由 `TargetConfig(WaveId)` + `RoutePlan(TaskId, WaveId)` + `Waypoints` 隐式表达。批次关联航路但不独占(多个批次可共享同一条航路)。
| Id | TEXT | PK | GUID | > - `EquipmentGroup`(装备编组)→ **不再需要**。火力单元是独立作战的最小单位,不需要上层编组。
| Name | TEXT | NOT NULL | 编组名称 | >
| GroupType | INTEGER | | 0=无人机编队 1=装备编组 | > 数据层:`Group` 表保留但不再主动使用(向后兼容)。`TargetConfig.WaveId` 语义为"批次 ID"。
| Description | TEXT | | 备注 |
| CreatedAt | TEXT | | |
### 4.4 运行时表 ### 4.4 运行时表
@ -507,7 +523,7 @@ SimulationEngine
├── SimulationState ← 状态(运行/暂停/结束) ├── SimulationState ← 状态(运行/暂停/结束)
├── SceneConfig ← 配置快照(含场景边界、管控区域) ├── SceneConfig ← 配置快照(含场景边界、管控区域)
├── List<DroneEntity> ← 无人机(目标方) ├── List<DroneEntity> ← 无人机(目标方)
│ └── 按编组组织,共享航路 │ └── 按批次组织,共享航路
├── List<PlatformEntity> ← 搭载平台(发射方)★重构 ├── List<PlatformEntity> ← 搭载平台(发射方)★重构
│ ├── AirBasedPlatform ← 空基(大型无人机) │ ├── AirBasedPlatform ← 空基(大型无人机)
│ └── GroundBasedPlatform ← 地基(火炮) │ └── GroundBasedPlatform ← 地基(火炮)
@ -613,7 +629,7 @@ Unity 渲染毁伤效果(尾焰异常、姿态失控、坠落)
``` ```
属性: 属性:
- 编组 GroupId、航路点队列 Waypoint[] - 批次 WaveId、航路点队列 Waypoint[]
- 当前位置、速度、朝向 - 当前位置、速度、朝向
- HP0~1 归一化) - HP0~1 归一化)
- DamageStageNormal → EngineAnomaly → AttitudeLoss → Destroyed - DamageStageNormal → EngineAnomaly → AttitudeLoss → Destroyed
@ -1080,7 +1096,7 @@ planner 所有策略参数从 `data/planner_config.json` 读取,代码零默
// === 防御规划 === // === 防御规划 ===
public interface IDefensePlanner public interface IDefensePlanner
{ {
PlannerResult Plan(List<FireUnit> fireUnits, List<DroneGroup> threats, CombatScene environment); PlannerResult Plan(List<FireUnit> fireUnits, List<DroneWave> threats, CombatScene environment);
} }
// ThreatProfile / DefenseRecommendation / DefenseSolution 等模型见本节上文 // ThreatProfile / DefenseRecommendation / DefenseSolution 等模型见本节上文
@ -1265,7 +1281,7 @@ public interface IFrameDataStore
| 阶段 | 内容 | 数据来源 | | 阶段 | 内容 | 数据来源 |
|------|------|------| |------|------|------|
| **仿真前** | 我方配置(装备类型/数量/编组、弹药类型/数量、部署位置、管控区域) | SimTask + 5 步配置 | | **仿真前** | 我方配置(装备类型/数量、火力单元部署、弹药类型/数量、部署位置、管控区域) | SimTask + 5 步配置 |
| **仿真中** | 关键事件时序表、各实体轨迹概要、云团演化数据 | SimEvent + SimFrameRecord | | **仿真中** | 关键事件时序表、各实体轨迹概要、云团演化数据 | SimEvent + SimFrameRecord |
| **仿真后** | 拦截成功率、各阶段耗时、毁伤效果评估、对抗结果判定 | SimEvent 汇总统计 | | **仿真后** | 拦截成功率、各阶段耗时、毁伤效果评估、对抗结果判定 | SimEvent 汇总统计 |
@ -1297,14 +1313,6 @@ interface IModelService
ModelInfo GetModel(string id); ModelInfo GetModel(string id);
} }
// === 编组管理 ===
interface IGroupService
{
Group CreateGroup(string name, GroupType type, string description);
void DeleteGroup(string id);
List<Group> GetGroups(GroupType? type);
}
// === 想定管理 === // === 想定管理 ===
interface IScenarioService interface IScenarioService
{ {
@ -1366,7 +1374,7 @@ interface IReportService
### 数据模型 ### 数据模型
一个仿真任务可包含**多种类型、多批次**的无人机,每批次(编队)有独立的航路: 一个仿真任务可包含**多种类型、多批次**的无人机,每批次有独立的航路:
``` ```
SimTask "城市防御演习" SimTask "城市防御演习"
@ -1374,51 +1382,51 @@ SimTask "城市防御演习"
├── ControlZone[](共享) ├── ControlZone[](共享)
├── CloudDispersal共享云团参数具体拦截方案由算法确定 ├── CloudDispersal共享云团参数具体拦截方案由算法确定
├── 编队 A活塞×3 ← Group.Id ├── 批次 A活塞×3 ← WaveId
│ ├── TargetConfig类型=活塞,数量=3 │ ├── TargetConfigWaveId=A类型=活塞,数量=3
│ ├── RoutePlan航路 A北→南 │ ├── RoutePlanTaskId + WaveId=A航路 A北→南
│ └── Waypoint[] │ └── Waypoint[]
├── 编队 B喷气×2 ← Group.Id ├── 批次 B喷气×2 ← WaveId
│ ├── TargetConfig类型=高速,数量=2 │ ├── TargetConfigWaveId=B类型=高速,数量=2
│ ├── RoutePlan航路 B西→东 │ ├── RoutePlanTaskId + WaveId=B航路 B西→东
│ └── Waypoint[] │ └── Waypoint[]
└── 编队 C旋翼×5 ← Group.Id └── 批次 C旋翼×5 ← WaveId
├── TargetConfig类型=旋翼,数量=5 ├── TargetConfigWaveId=C类型=旋翼,数量=5
├── RoutePlan航路 C ├── RoutePlanTaskId + WaveId=C航路 C
└── Waypoint[] └── Waypoint[]
``` ```
**关键变更**`RoutePlan` 主键`TaskId` 改为 `(TaskId, GroupId)` 复合键 **关键变更**`RoutePlan` 主键`(TaskId, WaveId)` 复合键。`Waypoint` 通过 `(TaskId, WaveId)` 关联航路
### 推荐算法 ### 推荐算法
算法需要同时考虑**所有无人机编队**和**可用装备火力单元**,自动匹配分配: 算法需要同时考虑**所有无人机批次**和**可用火力单元**,自动匹配分配:
```csharp ```csharp
// 输入:所有无人机编队 + 已部署的火力单元 // 输入:所有无人机编队 + 已部署的火力单元
// 输出:每个编队的拦截方案 + 合并的发射计划 // 输出:每个编队的拦截方案 + 合并的发射计划
var allSolutions = new List<DefenseSolution>(); var allSolutions = new List<DefenseSolution>();
var usedPlatforms = new HashSet<int>(); var usedFireUnits = new HashSet<string>();
foreach (var droneGroup in threat.DroneGroups) foreach (var droneWave in threat.DroneWaves)
{ {
// 从可用装备中选择合适的火力单元(弹药类型匹配目标动力类型) // 从可用火力单元中选择合适的(弹药类型匹配目标动力类型)
var availableUnits = fireUnits.Where(u => !usedPlatforms.Contains(u.Id)).ToList(); var availableUnits = fireUnits.Where(u => !usedFireUnits.Contains(u.Id)).ToList();
var solution = advisor.RecommendForGroup(droneGroup, environment, availableUnits); var solution = advisor.RecommendForWave(droneWave, environment, availableUnits);
// 标记占用的平台(一组装备可同时对付一个编队) // 标记占用的火力单元
foreach (var idx in solution.AssignedPlatformIndices) foreach (var idx in solution.AssignedFireUnitIds)
usedPlatforms.Add(idx); usedFireUnits.Add(idx);
allSolutions.Add(solution); allSolutions.Add(solution);
} }
// 复用判定(不写死"一对一"或"一对多" // 复用判定(不写死"一对一"或"一对多"
// 同种弹药 → 只需冷却时间(秒级),可直接复用 // 同种弹药 → 只需冷却时间(秒级),可直接复用
// 异种弹药 → 需要 AmmoChangeTime默认 5 分钟),检查编队间隔是否够 // 异种弹药 → 需要 AmmoChangeTime默认 5 分钟),检查批次间隔是否够
// 弹药耗尽 → 不可复用 // 弹药耗尽 → 不可复用
var merged = MergeFireSchedules(allSolutions); var merged = MergeFireSchedules(allSolutions);
@ -1435,13 +1443,13 @@ engine.SetFireSchedule(merged);
| 表 | 变更 | | 表 | 变更 |
|------|------| |------|------|
| `RoutePlan` | PK 改为 (TaskId, GroupId) 复合键 | | `RoutePlan` | PK 为 (TaskId, WaveId) 复合键 |
| `TargetConfig` | 不变(已有 GroupId FK | | `TargetConfig` | 不变(已有 WaveId |
| `Waypoint` | 关联 `(TaskId, GroupId)` 定位航路 | | `Waypoint` | 关联 `(TaskId, WaveId)` 定位航路 |
`IScenarioService` 新增 `IScenarioService` `SaveRoute` 签名
```csharp ```csharp
void SaveRoute(string taskId, string groupId, RoutePlan route, List<Waypoint> wps); // groupId 参数 void SaveRoute(string taskId, string waveId, RoutePlan route, List<Waypoint> wps); // waveId 参数
``` ```
### 报告 ### 报告
@ -1450,10 +1458,10 @@ void SaveRoute(string taskId, string groupId, RoutePlan route, List<Waypoint> wp
```markdown ```markdown
## 编队汇总 ## 编队汇总
| 编队 | 目标 | 数量 | 拦截方案 | 结果 | | 批次 | 目标 | 数量 | 拦截方案 | 结果 |
|------|------|------|------|------| |------|------|------|------|------|
| 编队A | 活塞 | 3 | 惰性气体 12发 | ✅ 全毁 | | 批次A | 活塞 | 3 | 惰性气体 12发 | ✅ 全毁 |
| 编队B | 喷气 | 2 | 活性材料 8发 | ⚠️ 1逃脱 | | 批次B | 喷气 | 2 | 活性材料 8发 | ⚠️ 1逃脱 |
``` ```
--- ---
@ -1473,7 +1481,7 @@ void SaveRoute(string taskId, string groupId, RoutePlan route, List<Waypoint> wp
│ └─ 多边形绘制 / 编辑 / 删除 │ │ └─ 多边形绘制 / 编辑 / 删除 │
├────────────────────────────────────────────────────────┤ ├────────────────────────────────────────────────────────┤
│ 步骤 2目标配置 │ │ 步骤 2目标配置 │
│ ├─ 编组选择(已有编队/新建编队) │ ├─ 批次选择(已有批次/新建批次)
│ ├─ 目标类型选择(自动填充默认参数) │ │ ├─ 目标类型选择(自动填充默认参数) │
│ ├─ 数量 / 动力类型 / 翼展 / 速度 / 高度 │ │ ├─ 数量 / 动力类型 / 翼展 / 速度 / 高度 │
│ └─ 预设典型目标库NEW │ └─ 预设典型目标库NEW
@ -1481,7 +1489,7 @@ void SaveRoute(string taskId, string groupId, RoutePlan route, List<Waypoint> wp
│ 步骤 3装备部署重构 │ 步骤 3装备部署重构
│ ├─ 装备角色(探测设备 / 发射平台) │ │ ├─ 装备角色(探测设备 / 发射平台) │
│ ├─ 发射平台类型(空基 / 地基 NEW │ ├─ 发射平台类型(空基 / 地基 NEW
│ ├─ 数量 / 编组 / 部署位置 │ │ ├─ 数量 / 部署位置 │
│ ├─ 挂载气溶胶类型 / 弹药数量 │ │ ├─ 挂载气溶胶类型 / 弹药数量 │
│ ├─ 弹药初速 / 释放高度 / 冷却时间 │ │ ├─ 弹药初速 / 释放高度 / 冷却时间 │
│ └─ 探测半径(探测设备) │ │ └─ 探测半径(探测设备) │
@ -1521,7 +1529,7 @@ void SaveRoute(string taskId, string groupId, RoutePlan route, List<Waypoint> wp
| 12 | 第三方数据 | **静态配置JSON → SQLite** | 仅提供弹药基础参数和初始扩散状态,不提供运行时算法 | | 12 | 第三方数据 | **静态配置JSON → SQLite** | 仅提供弹药基础参数和初始扩散状态,不提供运行时算法 |
| 13 | 运动学 | 简化运动学 + 风偏 | 航路点线性插值 + 风速矢量 | | 13 | 运动学 | 简化运动学 + 风偏 | 航路点线性插值 + 风速矢量 |
| 14 | 弹药弹道 | 抛物线(地基)/ 自由落体(空基) | 基于初速、角度、释放高度计算 | | 14 | 弹药弹道 | 抛物线(地基)/ 自由落体(空基) | 基于初速、角度、释放高度计算 |
| 15 | 编组 | Group 独立实体 | 编组独立管理,任务中引用 | | 15 | 批次 / 火力单元 | WaveId + 火力单元独立作战 | 批次关联航路但不独占;火力单元是最小独立作战单位,不需要上层编组 |
| 16 | 管控区域 | ControlZone 表 + Tick 判定 | 电子围栏,侵入即任务失败 | | 16 | 管控区域 | ControlZone 表 + Tick 判定 | 电子围栏,侵入即任务失败 |
| 17 | 配置向导流程 | **威胁驱动三阶段** | Phase1 定义威胁 → Phase2 算法推荐 → Phase3 审阅确认 | | 17 | 配置向导流程 | **威胁驱动三阶段** | Phase1 定义威胁 → Phase2 算法推荐 → Phase3 审阅确认 |
| 18 | 报告导出 | 模板填充 → Markdown | PDF/Word 待调研 | | 18 | 报告导出 | 模板填充 → Markdown | PDF/Word 待调研 |

View File

@ -1,14 +1,14 @@
# DefensePlanner 防御规划引擎 — 技术方案 # DefensePlanner 防御规划引擎 — 技术方案
- **版本**V3 - **版本**V4
- **日期**2026-06-13 - **日期**2026-06-15
- **状态**:已实现 - **状态**:已实现
--- ---
## 1. 概述 ## 1. 概述
DefensePlanner 是防御推荐模块的核心引擎。它接收**可用火力单元池**和**威胁编队列表**,综合考虑弹药匹配、空间可达性、时间约束、资源竞争,输出**最优分配方案**和**临界边际方案**。 DefensePlanner 是防御推荐模块的核心引擎。它接收**可用火力单元池**和**威胁批次列表**,综合考虑弹药匹配、空间可达性、时间约束、资源竞争,输出**最优分配方案**和**临界边际方案**。
**当前问题** **当前问题**
@ -54,11 +54,11 @@ class FireUnit {
} }
``` ```
### 2.2 DroneGroup威胁编队 ### 2.2 DroneWave威胁批次
```csharp ```csharp
class DroneGroup { class DroneWave {
string GroupId; // 编队 ID string WaveId; // 批次 ID
TargetConfig Target; // 类型、数量、动力、翼展、速度、高度 TargetConfig Target; // 类型、数量、动力、翼展、速度、高度
List<Waypoint> Waypoints; // 航路点 List<Waypoint> Waypoints; // 航路点
float ArrivalTime; // 预计算:到达防御区域中点的时间 s float ArrivalTime; // 预计算:到达防御区域中点的时间 s
@ -78,7 +78,7 @@ class DroneGroup {
```csharp ```csharp
class UnitAssignment { class UnitAssignment {
string FireUnitId; // 分配到的火力单元 string FireUnitId; // 分配到的火力单元
string DroneGroupId; // 对抗的威胁编队 string DroneWaveId; // 对抗的威胁批次
AerosolType AmmoType; // 装填的弹药类型 AerosolType AmmoType; // 装填的弹药类型
int RoundsFired; // 本次发射几发 int RoundsFired; // 本次发射几发
float FirstFireTime; // 首发发射时机 s float FirstFireTime; // 首发发射时机 s
@ -113,7 +113,7 @@ class PlannerResult {
## 4. 内部流程(五步法) ## 4. 内部流程(五步法)
``` ```
输入List<FireUnit> + List<DroneGroup> + CombatScene 输入List<FireUnit> + List<DroneWave> + CombatScene
Step 1 — 威胁排序 Step 1 — 威胁排序
@ -190,7 +190,7 @@ class InterceptCandidate {
**候选生成逻辑** **候选生成逻辑**
对于给定的威胁编队和火力单元: 对于给定的威胁批次和火力单元:
1. **弹药兼容性**`Unit.AmmoTypes` 包含威胁需要的弹药类型 1. **弹药兼容性**`Unit.AmmoTypes` 包含威胁需要的弹药类型
2. **地基可达性**:计算目标与部署点的水平距离,校验 `MuzzleVelocity` 射程 2. **地基可达性**:计算目标与部署点的水平距离,校验 `MuzzleVelocity` 射程
@ -239,7 +239,7 @@ for each threat in pending:
> 作用:给操作员一个置信区间——最优 vs 临界,展示"再少就不够了"的底线。 > 作用:给操作员一个置信区间——最优 vs 临界,展示"再少就不够了"的底线。
### 5.5 多编队合并 ### 5.5 多批次合并
``` ```
Step 4 的贪心算法天然支持多威胁: Step 4 的贪心算法天然支持多威胁:
@ -258,7 +258,7 @@ Step 4 的贪心算法天然支持多威胁:
public interface IDefensePlanner public interface IDefensePlanner
{ {
/// <summary>为给定火力单元池和威胁列表生成规划方案</summary> /// <summary>为给定火力单元池和威胁列表生成规划方案</summary>
PlannerResult Plan(List<FireUnit> fireUnits, List<DroneGroup> threats, CombatScene environment); PlannerResult Plan(List<FireUnit> fireUnits, List<DroneWave> threats, CombatScene environment);
} }
``` ```
@ -273,16 +273,16 @@ public class DefaultDefensePlanner : IDefensePlanner
public DefaultDefensePlanner(List<AmmunitionSpec> ammoCatalog) { ... } public DefaultDefensePlanner(List<AmmunitionSpec> ammoCatalog) { ... }
public PlannerResult Plan(List<FireUnit> fireUnits, List<DroneGroup> threats, CombatScene env) public PlannerResult Plan(List<FireUnit> fireUnits, List<DroneWave> threats, CombatScene env)
{ {
// Step 1-5 // Step 1-5
} }
// 内部方法 // 内部方法
private List<DroneGroup> Prioritize(List<DroneGroup> threats) { ... } private List<DroneWave> Prioritize(List<DroneWave> threats) { ... }
private AerosolType MatchAmmo(PowerType power) { ... } private AerosolType MatchAmmo(PowerType power) { ... }
private List<InterceptCandidate> GenerateCandidates(DroneGroup threat, List<FireUnit> units, float now) { ... } private List<InterceptCandidate> GenerateCandidates(DroneWave threat, List<FireUnit> units, float now) { ... }
private DefensePlan Solve(List<DroneGroup> threats, List<FireUnit> units) { ... } private DefensePlan Solve(List<DroneWave> threats, List<FireUnit> units) { ... }
private DefensePlan DeriveCritical(DefensePlan best) { ... } private DefensePlan DeriveCritical(DefensePlan best) { ... }
} }
``` ```
@ -293,7 +293,7 @@ public class DefaultDefensePlanner : IDefensePlanner
|--------|--------|------| |--------|--------|------|
| `IDefenseAdvisor.Recommend(ThreatProfile)` | `IDefensePlanner.Plan(fireUnits, threats, env)` | ✅ 已替换 | | `IDefenseAdvisor.Recommend(ThreatProfile)` | `IDefensePlanner.Plan(fireUnits, threats, env)` | ✅ 已替换 |
| `IDefenseAdvisor.GetDefenseRecommendation(taskId)` | 不再需要 | ✅ 已删除 | | `IDefenseAdvisor.GetDefenseRecommendation(taskId)` | 不再需要 | ✅ 已删除 |
| `DefaultDefenseAdvisor.RecommendMultiGroup()` | Planner 原生支持多编队 | ✅ 已删除 | | `DefaultDefenseAdvisor.RecommendMultiGroup()` | Planner 原生支持多批次 | ✅ 已删除 |
--- ---
@ -304,7 +304,7 @@ SimulationEngine.Initialize(taskId)
│ // 引擎内部组装 │ // 引擎内部组装
├── BuildFireUnits(config) → List<FireUnit> ├── BuildFireUnits(config) → List<FireUnit>
├── BuildDroneGroups(config) → List<DroneGroup> ├── BuildDroneWaves(config) → List<DroneWave>
└── _scene (CombatScene) └── _scene (CombatScene)

View File

@ -41,7 +41,7 @@
SimulationEngine.Initialize() SimulationEngine.Initialize()
├─ BuildFireUnits() → List<FireUnit> ├─ BuildFireUnits() → List<FireUnit>
├─ BuildDroneGroups() → List<DroneGroup> ├─ BuildDroneWaves() → List<DroneWave>
IDefensePlanner.Plan(fireUnits, threats, scene) IDefensePlanner.Plan(fireUnits, threats, scene)

View File

@ -1,8 +1,8 @@
# 实施计划与任务跟踪 # 实施计划与任务跟踪
> **项目**:反无人机仿真系统后端 > **项目**:反无人机仿真系统后端
> **文档版本**V1.3 > **文档版本**V1.4
> **更新日期**2026-06-14 > **更新日期**2026-06-15
--- ---
@ -32,7 +32,7 @@ Phase 8 🔄 待开发(天气/物理模型统一已完成)
| # | 任务 | 状态 | | # | 任务 | 状态 |
|---|------|------| |---|------|------|
| 1.1-1.12 | 项目骨架、枚举、数据模型、Repository、ModelService/GroupService、单元测试 | ✅ | | 1.1-1.12 | 项目骨架、枚举、数据模型、Repository、ModelService、单元测试 | ✅ |
--- ---
@ -105,7 +105,7 @@ Phase 8 🔄 待开发(天气/物理模型统一已完成)
| 6.1 | 创建 Unity 项目,导入 Core.dll | 1h | ✅ | `src/Unity/` 项目16 个 DLL → `Assets/Plugins/` | | 6.1 | 创建 Unity 项目,导入 Core.dll | 1h | ✅ | `src/Unity/` 项目16 个 DLL → `Assets/Plugins/` |
| 6.2 | 实现 `UnityPathProvider` | 1h | ✅ | `Application.persistentDataPath` 桥接 | | 6.2 | 实现 `UnityPathProvider` | 1h | ✅ | `Application.persistentDataPath` 桥接 |
| 6.3 | 实现 `ModelManager` | 3h | ✅ | 导入/删除/查询,含 Verify | | 6.3 | 实现 `ModelManager` | 3h | ✅ | 导入/删除/查询,含 Verify |
| 6.4 | 实现 `ScenarioManager`5 步配置) | 4h | ✅ | 完整 CRUD + 搜索分页 + 多编队 Route | | 6.4 | 实现 `ScenarioManager`5 步配置) | 4h | ✅ | 完整 CRUD + 搜索分页 + 多批次 Route |
| 6.5 | 实现 `SimulationRunner`Update 驱动) | 3h | ✅ | Tick 驱动 + 事件订阅 + 实体位置同步 + 炮弹轨迹可视化 | | 6.5 | 实现 `SimulationRunner`Update 驱动) | 3h | ✅ | Tick 驱动 + 事件订阅 + 实体位置同步 + 炮弹轨迹可视化 |
| 6.6 | 实现 `ReplayController`(帧加载) | 3h | ✅ | 从分库加载帧数据TotalFrames/GetFrame | | 6.6 | 实现 `ReplayController`(帧加载) | 3h | ✅ | 从分库加载帧数据TotalFrames/GetFrame |
| 6.7 | 实现 `ReportManager` | 2h | ✅ | 生成 + Markdown 导出 | | 6.7 | 实现 `ReportManager` | 2h | ✅ | 生成 + Markdown 导出 |
@ -173,7 +173,7 @@ Phase 8 🔄 待开发(天气/物理模型统一已完成)
| # | 功能 | 说明 | | # | 功能 | 说明 |
|---|------|------| |---|------|------|
| 8.1.1 | 探测设备搜索逻辑 | `DetectionEntity` 类已存在,需接入 `SimulationEngine` 实现探测→火控链路闭环。天气(能见度/日夜)对光电/红外探测距离的衰减在此实现 | | 8.1.1 | 探测设备搜索逻辑 | ✅ 事前规划已接入探测能力:`DetectionCalculator` 算统一信息网络最早探测点planner 基于探测边界非上帝视角起点算到达时间天气Visibility衰减光电探测距离探测精度影响抛撒散布。实时探测→火控链路留作未来增强 |
| 8.1.2 | 蜂群运动模型 | `FormationMode.Swarm` 枚举已定义,需差异化行为(随机扰动、个体差异) | | 8.1.2 | 蜂群运动模型 | `FormationMode.Swarm` 枚举已定义,需差异化行为(随机扰动、个体差异) |
| 8.1.3 | 空基平台 + DefensePlanner | ✅ 五步规划引擎,通道模型,物理间隔错发,路径积分毁伤判定 | ✅ | | 8.1.3 | 空基平台 + DefensePlanner | ✅ 五步规划引擎,通道模型,物理间隔错发,路径积分毁伤判定 | ✅ |
| 8.1.4 | 预置典型目标库 | 具体无人机型号 JSON 配置(如 DJI Mavic 3、Shahed-136 等),导入 `TargetConfig` 默认值 | | 8.1.4 | 预置典型目标库 | 具体无人机型号 JSON 配置(如 DJI Mavic 3、Shahed-136 等),导入 `TargetConfig` 默认值 |

View File

@ -0,0 +1,34 @@
# 探测驱动的规划8.1.1
- **日期**2026-06-15
- **提出人**tian
- **关联需求**V1.0 功能需求(探测设备)、技术要求终版
- **优先级**:高
## 变更描述
planner 不再有上帝视角。仿真开始时planner 假设威胁从"探测边界"(统一信息网络的最早发现点)进入,而非航路真实起点。探测精度差 → 抛撒散布范围扩大。
### 设计原则
planner 是参谋,只能基于侦查信息制定方案。假设我方有统一信息网络,可同步威胁信息。独立探测设备与火力单元自带探测并存。
### 变更内容
1. **新增 DetectionCalculator**:探测能力评估的唯一实现。光电受 Visibility 衰减,雷达/红外不受影响;统一信息网络找最早探测点
2. **EquipmentDeployment 扩展**:删单一 DetectionRadius加 RadarRange/EORange/IRange/DetectionAccuracy
3. **FireUnit 探测字段激活**BuildFireUnits 赋值(原为死代码)
4. **planner 接口扩展**Plan 加第 4 参数 detectionSources到达时间基于探测边界
5. **SimulationEngine 构建探测源**:独立探测 + 火力单元自带探测统一传入 planner
## 影响范围
- [x] 接口变更IDefensePlanner.Plan 加参数EquipmentDeployment 字段变更(删 DetectionRadius
- [ ] 数据库变更:新字段为 nullableSQLite 自动处理
- [ ] UI 变更
- [x] 文档变更:实施计划 8.1.1、CHANGELOG、VERSION
## 验收
- 全量测试 193 → **208**+1564s 通过
- DetectionCalculator 13 项单测(天气衰减/最早探测点/散布半径)
- 探测驱动规划 2 项测试planner 基于探测边界、集成场景不破坏)
- 范围外:实时探测→火控链路(未来增强)

View File

@ -1,7 +1,7 @@
# 后端对接文档Unity 前端) # 后端对接文档Unity 前端)
> **版本**V1.1 > **版本**V1.2
> **日期**2026-06-12 > **日期**2026-06-15
> **Unity 版本**2022.3.62f3c1 > **Unity 版本**2022.3.62f3c1
--- ---
@ -35,17 +35,21 @@ data/default_ammo.json ← 默认弹药参数(放到 persisten
var scenario = GetComponent<ScenarioManager>(); var scenario = GetComponent<ScenarioManager>();
var task = scenario.CreateTask("拦截活塞式无人机", ""); var task = scenario.CreateTask("拦截活塞式无人机", "");
scenario.SaveScene(task.Id, new CombatScene { WindSpeed = 3 }); scenario.SaveScene(task.Id, new CombatScene { WindSpeed = 3 });
scenario.SaveTarget(task.Id, new TargetConfig { PowerType = (int)PowerType.Piston, ... }); scenario.SaveTarget(task.Id, new TargetConfig { WaveId = "default", PowerType = (int)PowerType.Piston, ... });
scenario.SaveRoute(task.Id, routePlan, waypoints); scenario.SaveRoute(task.Id, "default", routePlan, waypoints);
// 推荐方案(自动从数据库读弹药规格 // 推荐方案(DefensePlanner 五步流水线
var detail = scenario.GetDetail(task.Id); var detail = scenario.GetDetail(task.Id);
var ammoCatalog = db.Table<AmmunitionSpec>().ToList(); // DatabaseManager 已种子 var ammoCatalog = db.Table<AmmunitionSpec>().ToList(); // DatabaseManager 已种子
var rec = new DefaultDefenseAdvisor(ammoCatalog).Recommend(new ThreatProfile { ... }); var fireUnits = new List<FireUnit> { /* 从 EquipmentDeployment 构建 */ };
var threats = new List<DroneWave> { new DroneWave { WaveId = "default", Target = detail.Targets[0], Route = detail.Routes[0], Waypoints = detail.WaypointGroups["default"] } };
var plan = new DefensePlanner(ammoCatalog, PlannerConfig.Load(paths)).Plan(fireUnits, threats, detail.Scene, new List<DetectionSource>());
// 应用到想定 // 应用到想定(发射计划传给引擎,部署方案保存到配置)
scenario.SaveCloud(task.Id, rec.Best.RecommendedCloud); var mergedSchedule = plan.Best.MergedSchedule;
scenario.SaveDeployment(task.Id, rec.Best.Platforms.Select(p => new EquipmentDeployment { ... }).ToList()); // 部署火力单元到 EquipmentDeployment...
scenario.SaveCloud(task.Id, new CloudDispersal { /* 从 plan.Best.Assignments 推算云团参数 */ });
scenario.SaveDeployment(task.Id, equips);
``` ```
### 3.2 启动仿真(事件驱动) ### 3.2 启动仿真(事件驱动)
@ -54,7 +58,7 @@ scenario.SaveDeployment(task.Id, rec.Best.Platforms.Select(p => new EquipmentDep
var runner = GetComponent<SimulationRunner>(); var runner = GetComponent<SimulationRunner>();
// 推荐方案的 FireSchedule 直接传给引擎 // 推荐方案的 FireSchedule 直接传给引擎
runner.Engine.SetFireSchedule(rec.Best.FireSchedule); runner.Engine.SetFireSchedule(plan.Best.MergedSchedule);
runner.LoadAndStart(task.Id); runner.LoadAndStart(task.Id);
runner.Engine.TimeScale = 4f; // 加速 runner.Engine.TimeScale = 4f; // 加速
@ -93,7 +97,7 @@ mgr.SaveScene(id, combatScene);
mgr.SaveTarget(id, targetConfig); mgr.SaveTarget(id, targetConfig);
mgr.SaveDeployment(id, equipmentList); mgr.SaveDeployment(id, equipmentList);
mgr.SaveCloud(id, cloudDispersal); mgr.SaveCloud(id, cloudDispersal);
mgr.SaveRoute(id, routePlan, waypoints); mgr.SaveRoute(id, "default", routePlan, waypoints);
TaskFullConfig detail = mgr.GetDetail(id); TaskFullConfig detail = mgr.GetDetail(id);
PagedResult<SimTask> result = mgr.Search("关键词", from, to, page, pageSize); PagedResult<SimTask> result = mgr.Search("关键词", from, to, page, pageSize);
``` ```
@ -163,4 +167,4 @@ dotnet publish -c Release -o ../../unity_plugins
| 实体事件映射 | `docs/design/technical/仿真器实体与事件映射.md` | | 实体事件映射 | `docs/design/technical/仿真器实体与事件映射.md` |
| 任务跟踪 | `docs/implementation/tasks/实施计划与任务跟踪.md` | | 任务跟踪 | `docs/implementation/tasks/实施计划与任务跟踪.md` |
| 测试报告 | `test/reports/`(每次集成测试自动生成) | | 测试报告 | `test/reports/`(每次集成测试自动生成) |
| 测试状态 | **129 测试95.4% 行覆盖率12 秒** | | 测试状态 | **204 测试95.4% 行覆盖率63 秒** |

View File

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

View File

@ -26,7 +26,8 @@ namespace CounterDrone.Core.Algorithms
// 五步流水线 // 五步流水线
// ═══════════════════════════════════════════════ // ═══════════════════════════════════════════════
public PlannerResult Plan(List<FireUnit> fireUnits, List<DroneGroup> threats, CombatScene environment) public PlannerResult Plan(List<FireUnit> fireUnits, List<DroneWave> threats,
CombatScene environment, List<DetectionSource> detectionSources)
{ {
var result = new PlannerResult(); var result = new PlannerResult();
@ -42,6 +43,19 @@ namespace CounterDrone.Core.Algorithms
return result; return result;
} }
// 探测信息:每个威胁的最早发现弧长 + 精度(基于统一信息网络)
// 无探测设备时 detectArc=0上帝视角从航路起点算、精度用配置默认值
float visibility = (float)environment.Visibility;
foreach (var t in threats)
{
var (detectArc, accuracy) = DetectionCalculator.EarliestDetection(
t.Waypoints, detectionSources, visibility);
t.DetectArc = detectArc;
t.DetectAccuracy = accuracy == float.MaxValue
? _config.DefaultDetectionAccuracy
: accuracy;
}
// Step 1: 威胁排序 // Step 1: 威胁排序
foreach (var t in threats) foreach (var t in threats)
{ {
@ -94,7 +108,7 @@ namespace CounterDrone.Core.Algorithms
// Step 3-4: 候选生成 → 贪心分配 // Step 3-4: 候选生成 → 贪心分配
// ═══════════════════════════════════════════════ // ═══════════════════════════════════════════════
private DefensePlan Solve(List<DroneGroup> sortedThreats, private DefensePlan Solve(List<DroneWave> sortedThreats,
List<FireUnit> fireUnits, CombatScene env) List<FireUnit> fireUnits, CombatScene env)
{ {
var plan = new DefensePlan(); var plan = new DefensePlan();
@ -162,6 +176,7 @@ namespace CounterDrone.Core.Algorithms
if (!laneBaseSet[yLane]) if (!laneBaseSet[yLane])
{ {
var refEvt = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, 0, yLane, yLanes, formationWidth); var refEvt = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, 0, yLane, yLanes, formationWidth);
if (refEvt.Count == 0) continue; // 该车道无可行发射事件(探测边界太靠后等)
laneBaseTime[yLane] = refEvt[0].FireTime; laneBaseTime[yLane] = refEvt[0].FireTime;
laneBaseSet[yLane] = true; laneBaseSet[yLane] = true;
} }
@ -177,6 +192,7 @@ namespace CounterDrone.Core.Algorithms
int roundInLane = baseRoundInLane + ch; int roundInLane = baseRoundInLane + ch;
float offset = (roundInLane - (singleNeeded - 1) / 2f) * spacing; float offset = (roundInLane - (singleNeeded - 1) / 2f) * spacing;
var fe = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, offset, yLane, yLanes, formationWidth); var fe = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, offset, yLane, yLanes, formationWidth);
if (fe.Count == 0) continue; // 该发不可行(探测边界太靠后等)
foreach (var e in fe) foreach (var e in fe)
{ {
e.FireTime = laneBaseTime[yLane] + roundInLane * stagger; e.FireTime = laneBaseTime[yLane] + roundInLane * stagger;
@ -199,7 +215,7 @@ namespace CounterDrone.Core.Algorithms
plan.Assignments.Add(new UnitAssignment plan.Assignments.Add(new UnitAssignment
{ {
FireUnitId = unit.Id, FireUnitId = unit.Id,
DroneGroupId = threat.GroupId, DroneWaveId = threat.WaveId,
AmmoType = neededAmmo, AmmoType = neededAmmo,
RoundsFired = rounds, RoundsFired = rounds,
FirstFireTime = fireEvents.Count > 0 ? fireEvents[0].FireTime : 0, FirstFireTime = fireEvents.Count > 0 ? fireEvents[0].FireTime : 0,
@ -220,7 +236,7 @@ namespace CounterDrone.Core.Algorithms
var a2 = _ammoCatalog.FirstOrDefault(s => var a2 = _ammoCatalog.FirstOrDefault(s =>
s.AerosolType == (int)MatchAmmo(_config, (PowerType)threat.Target.PowerType)); s.AerosolType == (int)MatchAmmo(_config, (PowerType)threat.Target.PowerType));
int totalRounds = plan.Assignments int totalRounds = plan.Assignments
.Where(a => a.DroneGroupId == threat.GroupId) .Where(a => a.DroneWaveId == threat.WaveId)
.Sum(a => a.RoundsFired); .Sum(a => a.RoundsFired);
if (totalRounds > 0) if (totalRounds > 0)
threatProbs.Add(ComputeInterceptProbability(threat, a2, totalRounds, env)); threatProbs.Add(ComputeInterceptProbability(threat, a2, totalRounds, env));
@ -237,7 +253,7 @@ namespace CounterDrone.Core.Algorithms
// 候选生成(使用真实物理) // 候选生成(使用真实物理)
// ═══════════════════════════════════════════════ // ═══════════════════════════════════════════════
private List<InterceptCandidate> GenerateCandidates(DroneGroup threat, private List<InterceptCandidate> GenerateCandidates(DroneWave threat,
List<FireUnit> availableUnits, CombatScene env) List<FireUnit> availableUnits, CombatScene env)
{ {
var candidates = new List<InterceptCandidate>(); var candidates = new List<InterceptCandidate>();
@ -265,7 +281,7 @@ namespace CounterDrone.Core.Algorithms
return candidates; return candidates;
} }
private InterceptCandidate? BuildGroundBasedCandidate(DroneGroup threat, private InterceptCandidate? BuildGroundBasedCandidate(DroneWave threat,
FireUnit unit, AerosolType ammoType, AmmunitionSpec ammo, FireUnit unit, AerosolType ammoType, AmmunitionSpec ammo,
float effectiveR, CombatScene env) float effectiveR, CombatScene env)
{ {
@ -295,7 +311,7 @@ namespace CounterDrone.Core.Algorithms
}; };
} }
private InterceptCandidate? BuildAirBasedCandidate(DroneGroup threat, private InterceptCandidate? BuildAirBasedCandidate(DroneWave threat,
FireUnit unit, AerosolType ammoType, AmmunitionSpec ammo, FireUnit unit, AerosolType ammoType, AmmunitionSpec ammo,
float effectiveR, CombatScene env) float effectiveR, CombatScene env)
{ {
@ -342,7 +358,7 @@ namespace CounterDrone.Core.Algorithms
return (effectiveR, expansionTime, rPhase2); return (effectiveR, expansionTime, rPhase2);
} }
private int CalcRoundsNeeded(DroneGroup threat, AmmunitionSpec ammo, private int CalcRoundsNeeded(DroneWave threat, AmmunitionSpec ammo,
CombatScene env, bool isAirBased, float turbulentRadius) CombatScene env, bool isAirBased, float turbulentRadius)
{ {
var cloudModel = new CloudExpansionModel(ammo, env); var cloudModel = new CloudExpansionModel(ammo, env);
@ -355,7 +371,7 @@ namespace CounterDrone.Core.Algorithms
return cloudModel.RoundsNeeded(requiredCoverage, spacing); return cloudModel.RoundsNeeded(requiredCoverage, spacing);
} }
private float ComputeInterceptProbability(DroneGroup threat, private float ComputeInterceptProbability(DroneWave threat,
AmmunitionSpec ammo, int rounds, CombatScene env) AmmunitionSpec ammo, int rounds, CombatScene env)
{ {
float avgSpeed = (float)threat.Target.TypicalSpeed / 3.6f; float avgSpeed = (float)threat.Target.TypicalSpeed / 3.6f;
@ -374,7 +390,7 @@ namespace CounterDrone.Core.Algorithms
// 发射事件生成(真实物理) // 发射事件生成(真实物理)
// ═══════════════════════════════════════════════ // ═══════════════════════════════════════════════
private List<FireEvent> GenerateFireEventsAt(DroneGroup threat, FireUnit unit, private List<FireEvent> GenerateFireEventsAt(DroneWave threat, FireUnit unit,
AerosolType ammoType, AmmunitionSpec ammo, CombatScene env, float targetOffset, AerosolType ammoType, AmmunitionSpec ammo, CombatScene env, float targetOffset,
int yLane, int yLanes, float formationWidth) int yLane, int yLanes, float formationWidth)
{ {
@ -416,8 +432,12 @@ namespace CounterDrone.Core.Algorithms
float ty = routeY; float ty = routeY;
float tz = routeZ + normalZ * laneOffset; float tz = routeZ + normalZ * laneOffset;
// 无人机到达穿越点的时间:沿航路匀速飞行 crossArc 弧长 // 无人机到达穿越点的时间:基于探测边界,而非航路起点。
float txArrival = RouteGeometry.TravelTimeTo(wps, crossArc >= 0 ? crossArc : 0, typicalSpeed); // planner 是参谋,只能基于探测信息规划。威胁从探测边界被发现,
// 飞行时间 = (拦截弧长 - 探测弧长) / 速度。无探测时 DetectArc=0回退到起点
float travelArc = crossArc - threat.DetectArc;
if (travelArc < 0) return events; // 探测边界在拦截点之后,来不及拦截
float txArrival = RouteGeometry.TravelTimeTo(wps, travelArc, typicalSpeed);
float recommendedTiming = txArrival - expansionTime; float recommendedTiming = txArrival - expansionTime;
if (recommendedTiming <= 0f) return events; if (recommendedTiming <= 0f) return events;
@ -497,7 +517,7 @@ namespace CounterDrone.Core.Algorithms
var reduced = new UnitAssignment var reduced = new UnitAssignment
{ {
FireUnitId = assignment.FireUnitId, FireUnitId = assignment.FireUnitId,
DroneGroupId = assignment.DroneGroupId, DroneWaveId = assignment.DroneWaveId,
AmmoType = assignment.AmmoType, AmmoType = assignment.AmmoType,
RoundsFired = rounds, RoundsFired = rounds,
FirstFireTime = assignment.FirstFireTime, FirstFireTime = assignment.FirstFireTime,
@ -517,7 +537,7 @@ namespace CounterDrone.Core.Algorithms
// 辅助 // 辅助
// ═══════════════════════════════════════════════ // ═══════════════════════════════════════════════
private static Vector3 ThreatMidpoint(DroneGroup threat) private static Vector3 ThreatMidpoint(DroneWave threat)
{ {
if (threat.Waypoints.Count < 2) if (threat.Waypoints.Count < 2)
return new Vector3(0, (float)threat.Target.TypicalAltitude, 0); return new Vector3(0, (float)threat.Target.TypicalAltitude, 0);

View File

@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using CounterDrone.Core.Models;
namespace CounterDrone.Core.Algorithms
{
/// <summary>探测计算工具——统一信息网络对威胁的探测能力评估。
/// 纯函数、无状态,与 Kinematics/RouteGeometry 同范式。
/// planner事前规划基于此估算威胁的最早发现点而非上帝视角的航路起点。</summary>
public static class DetectionCalculator
{
/// <summary>某探测源在给定能见度下的综合有效探测距离(水平面)。
/// 取雷达/光电/红外三者的最大有效距离(任一方式发现即算发现)。
/// 光电受 Visibility 衰减:有效 = 基准 × min(1, Visibility/基准)。
/// 雷达/红外不受能见度影响(当前范围)。</summary>
public static float EffectiveRange(float radarRange, float eoRange, float irRange, float visibility)
{
float effRadar = radarRange;
float effEO = eoRange > 0
? eoRange * Math.Min(1f, visibility / eoRange)
: 0f;
float effIR = irRange;
return Math.Max(effRadar, Math.Max(effEO, effIR));
}
/// <summary>统一信息网络对某威胁的最早探测点。
/// 遍历所有探测源,找威胁航路进入任一探测源范围的最早点(航路弧长最小)。
/// 返回 (探测弧长, 该处精度)。无探测源时返回 (0, float.MaxValue)。</summary>
public static (float detectArc, float accuracy) EarliestDetection(
IReadOnlyList<Waypoint> threatRoute,
List<DetectionSource> sources,
float visibility)
{
if (sources == null || sources.Count == 0 || threatRoute == null || threatRoute.Count < 2)
return (0f, float.MaxValue);
float bestArc = float.MaxValue;
float bestAccuracy = float.MaxValue;
foreach (var src in sources)
{
float range = EffectiveRange(src.RadarRange, src.EORange, src.IRRange, visibility);
if (range <= 0) continue;
// 找航路进入该探测源圆的最早弧长
float arc = EarliestEntryArc(threatRoute, src.Position.X, src.Position.Z, range);
if (arc < bestArc)
{
bestArc = arc;
bestAccuracy = src.Accuracy;
}
}
if (bestArc == float.MaxValue)
return (float.MaxValue, float.MaxValue); // 航路完全不经过任何探测范围
return (bestArc, bestAccuracy);
}
/// <summary>探测精度换算为抛撒散布半径m
/// 精度差 → 散布半径大planner 增加横向覆盖。
/// 当前模型:散布半径 = 精度值(如精度 100m → 散布 ±100m。</summary>
public static float SpreadRadius(float accuracy)
{
return Math.Max(0f, accuracy);
}
/// <summary>折线航路进入圆(水平面 XZ的最早弧长。
/// 逐段求线段-圆交点,返回首次进入的累积弧长。
/// 若起点已在圆内,返回 0。若完全不交返回 float.MaxValue。</summary>
private static float EarliestEntryArc(IReadOnlyList<Waypoint> wps, float cx, float cz, float r)
{
float accumArc = 0f;
bool wasInside = IsInside((float)wps[0].PosX, (float)wps[0].PosZ, cx, cz, r);
if (wasInside) return 0f; // 起点已在探测范围内
for (int i = 0; i < wps.Count - 1; i++)
{
float ax = (float)wps[i].PosX, az = (float)wps[i].PosZ;
float bx = (float)wps[i + 1].PosX, bz = (float)wps[i + 1].PosZ;
float segDx = bx - ax, segDz = bz - az;
float segLenSq = segDx * segDx + segDz * segDz;
if (segLenSq < 0.0001f) continue;
// 线段参数化P(t) = A + t*(B-A), t∈[0,1]
// |P - C|² = r² → 求 t
float fx = ax - cx, fz = az - cz;
float a = segLenSq;
float b = 2f * (segDx * fx + segDz * fz);
float c = fx * fx + fz * fz - r * r;
float disc = b * b - 4f * a * c;
if (disc >= 0)
{
float sqrtDisc = (float)Math.Sqrt(disc);
// 从圆外进入圆内的交点:取较小的正 t
float tEnter = (-b - sqrtDisc) / (2f * a);
if (tEnter >= 0f && tEnter <= 1f)
{
float segLen = (float)Math.Sqrt(segLenSq);
return accumArc + tEnter * segLen;
}
}
accumArc += (float)Math.Sqrt(segLenSq);
}
return float.MaxValue;
}
private static bool IsInside(float px, float pz, float cx, float cz, float r)
{
float dx = px - cx, dz = pz - cz;
return dx * dx + dz * dz <= r * r;
}
}
}

View File

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

View File

@ -26,6 +26,9 @@ namespace CounterDrone.Core.Algorithms
/// <summary>弹药匹配表PowerType → AerosolType</summary> /// <summary>弹药匹配表PowerType → AerosolType</summary>
public Dictionary<PowerType, AerosolType> AmmoMatch { get; set; } = new(); public Dictionary<PowerType, AerosolType> AmmoMatch { get; set; } = new();
/// <summary>无探测设备时的默认探测精度 m回退值上帝视角但有标称误差</summary>
public float DefaultDetectionAccuracy { get; set; }
private const string ConfigFileName = "planner_config.json"; private const string ConfigFileName = "planner_config.json";
/// <summary>从 dataRoot 加载配置。文件缺失或字段非法即抛异常。</summary> /// <summary>从 dataRoot 加载配置。文件缺失或字段非法即抛异常。</summary>
@ -60,6 +63,8 @@ namespace CounterDrone.Core.Algorithms
throw new InvalidDataException("TypeCoefficient 不能为空"); throw new InvalidDataException("TypeCoefficient 不能为空");
if (AmmoMatch == null || AmmoMatch.Count == 0) if (AmmoMatch == null || AmmoMatch.Count == 0)
throw new InvalidDataException("AmmoMatch 不能为空"); throw new InvalidDataException("AmmoMatch 不能为空");
if (DefaultDetectionAccuracy < 0f)
throw new InvalidDataException($"DefaultDetectionAccuracy 必须 >= 0实际 {DefaultDetectionAccuracy}");
} }
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()

View File

@ -68,9 +68,8 @@ namespace CounterDrone.Core
db.CreateTable<EquipmentDeployment>(); db.CreateTable<EquipmentDeployment>();
db.CreateTable<CloudDispersal>(); db.CreateTable<CloudDispersal>();
db.CreateTable<RoutePlan>(); db.CreateTable<RoutePlan>();
db.CreateIndex("RoutePlan", new[] { "TaskId", "GroupId" }, true); db.CreateIndex("RoutePlan", new[] { "TaskId", "WaveId" }, true);
db.CreateTable<Waypoint>(); db.CreateTable<Waypoint>();
db.CreateTable<Group>();
db.CreateTable<SimulationReport>(); db.CreateTable<SimulationReport>();
db.CreateIndex("SimTask", "TaskNumber"); db.CreateIndex("SimTask", "TaskNumber");

View File

@ -92,7 +92,7 @@ namespace CounterDrone.Core.Models
Destroyed = 3 Destroyed = 3
} }
// === 编队 & 编组 === // === 编队 & 批次 ===
public enum FormationMode public enum FormationMode
{ {
Single = 0, Single = 0,
@ -100,12 +100,6 @@ namespace CounterDrone.Core.Models
Swarm = 2 Swarm = 2
} }
public enum GroupType
{
DroneFleet = 0,
EquipmentGroup = 1
}
// === 运行时 === // === 运行时 ===
public enum EntityType public enum EntityType
{ {

View File

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

View File

@ -1,22 +0,0 @@
using System;
using SQLite;
namespace CounterDrone.Core.Models
{
/// <summary>编组</summary>
[Table("Group")]
public class Group
{
[PrimaryKey]
public string Id { get; set; } = Guid.NewGuid().ToString();
[NotNull]
public string Name { get; set; } = string.Empty;
public int GroupType { get; set; } = (int)Models.GroupType.DroneFleet;
public string Description { get; set; } = string.Empty;
public string CreatedAt { get; set; } = DateTime.UtcNow.ToString("o");
}
}

View File

@ -3,7 +3,7 @@ using SQLite;
namespace CounterDrone.Core.Models namespace CounterDrone.Core.Models
{ {
/// <summary>步骤5航路规划 — 多编队支持:每个(任务,编队)一条航路</summary> /// <summary>步骤5航路规划 — 多批次支持:每个(任务,批次)一条航路</summary>
[Table("RoutePlan")] [Table("RoutePlan")]
public class RoutePlan public class RoutePlan
{ {
@ -14,7 +14,7 @@ namespace CounterDrone.Core.Models
public string TaskId { get; set; } = string.Empty; public string TaskId { get; set; } = string.Empty;
[Indexed] [Indexed]
public string GroupId { get; set; } = string.Empty; public string WaveId { get; set; } = string.Empty;
public int FormationMode { get; set; } = (int)Models.FormationMode.Single; public int FormationMode { get; set; } = (int)Models.FormationMode.Single;

View File

@ -13,7 +13,7 @@ namespace CounterDrone.Core.Models
[Indexed] [Indexed]
public string TaskId { get; set; } = string.Empty; public string TaskId { get; set; } = string.Empty;
public string GroupId { get; set; } = string.Empty; public string WaveId { get; set; } = string.Empty;
public int TargetType { get; set; } = (int)Models.TargetType.Rotor; public int TargetType { get; set; } = (int)Models.TargetType.Rotor;

View File

@ -11,9 +11,9 @@ namespace CounterDrone.Core.Models
public List<TargetConfig> Targets { get; set; } = new(); public List<TargetConfig> Targets { get; set; } = new();
public List<EquipmentDeployment> Equipment { get; set; } = new(); public List<EquipmentDeployment> Equipment { get; set; } = new();
public CloudDispersal Cloud { get; set; } = new(); public CloudDispersal Cloud { get; set; } = new();
/// <summary>多编队航路(多批次支持)</summary> /// <summary>多批次航路(多批次支持)</summary>
public List<RoutePlan> Routes { get; set; } = new(); public List<RoutePlan> Routes { get; set; } = new();
/// <summary>按 GroupId 分组的航路点</summary> /// <summary>按 WaveId 分组的航路点</summary>
public Dictionary<string, List<Waypoint>> WaypointGroups { get; set; } = new(); public Dictionary<string, List<Waypoint>> WaypointGroups { get; set; } = new();
} }

View File

@ -13,8 +13,8 @@ namespace CounterDrone.Core.Models
[Indexed] [Indexed]
public string TaskId { get; set; } = string.Empty; public string TaskId { get; set; } = string.Empty;
/// <summary>关联的编队 ID多编队支持)</summary> /// <summary>关联的批次 ID多批次支持)</summary>
public string GroupId { get; set; } = string.Empty; public string WaveId { get; set; } = string.Empty;
[NotNull] [NotNull]
public int OrderIndex { get; set; } public int OrderIndex { get; set; }

View File

@ -14,6 +14,14 @@ namespace CounterDrone.Core.Repository
return Db.Table<EquipmentDeployment>().Where(e => e.TaskId == taskId).ToList(); return Db.Table<EquipmentDeployment>().Where(e => e.TaskId == taskId).ToList();
} }
/// <summary>按任务和角色查询装备EquipmentRole: 0=Detection, 1=LaunchPlatform</summary>
public List<EquipmentDeployment> GetByTaskIdAndRole(string taskId, int equipmentRole)
{
return Db.Table<EquipmentDeployment>()
.Where(e => e.TaskId == taskId && e.EquipmentRole == equipmentRole)
.ToList();
}
public void DeleteByTaskId(string taskId) public void DeleteByTaskId(string taskId)
{ {
var equips = GetByTaskId(taskId); var equips = GetByTaskId(taskId);

View File

@ -1,19 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using CounterDrone.Core.Models;
using SQLite;
namespace CounterDrone.Core.Repository
{
public class GroupRepository : BaseRepository<Group>
{
public GroupRepository(SQLiteConnection db) : base(db) { }
public List<Group> GetByType(int groupType)
{
return Db.Table<Group>()
.Where(g => g.GroupType == groupType)
.ToList();
}
}
}

View File

@ -14,10 +14,10 @@ namespace CounterDrone.Core.Repository
return Db.Table<RoutePlan>().Where(r => r.TaskId == taskId).ToList(); return Db.Table<RoutePlan>().Where(r => r.TaskId == taskId).ToList();
} }
public RoutePlan GetByTaskAndGroup(string taskId, string groupId) public RoutePlan GetByTaskAndWave(string taskId, string waveId)
{ {
return Db.Table<RoutePlan>() return Db.Table<RoutePlan>()
.FirstOrDefault(r => r.TaskId == taskId && r.GroupId == groupId); .FirstOrDefault(r => r.TaskId == taskId && r.WaveId == waveId);
} }
} }
} }

View File

@ -17,10 +17,10 @@ namespace CounterDrone.Core.Repository
.ToList(); .ToList();
} }
public List<Waypoint> GetByTaskAndGroup(string taskId, string groupId) public List<Waypoint> GetByTaskAndWave(string taskId, string waveId)
{ {
return Db.Table<Waypoint>() return Db.Table<Waypoint>()
.Where(w => w.TaskId == taskId && w.GroupId == groupId) .Where(w => w.TaskId == taskId && w.WaveId == waveId)
.OrderBy(w => w.OrderIndex) .OrderBy(w => w.OrderIndex)
.ToList(); .ToList();
} }

View File

@ -1,50 +0,0 @@
using System;
using System.Collections.Generic;
using CounterDrone.Core.Models;
using CounterDrone.Core.Repository;
namespace CounterDrone.Core.Services
{
public class GroupService : IGroupService
{
private readonly GroupRepository _repo;
public GroupService(GroupRepository repo)
{
_repo = repo;
}
public Group CreateGroup(string name, GroupType type, string description)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("编组名称不能为空");
var group = new Group
{
Name = name,
GroupType = (int)type,
Description = description,
};
_repo.Insert(group);
return group;
}
public void DeleteGroup(string id)
{
_repo.Delete(id);
}
public List<Group> GetGroups(GroupType? type)
{
if (type.HasValue)
return _repo.GetByType((int)type.Value);
return _repo.GetAll();
}
public Group GetGroup(string id)
{
return _repo.GetById(id);
}
}
}

View File

@ -1,14 +0,0 @@
using System.Collections.Generic;
using CounterDrone.Core.Models;
namespace CounterDrone.Core.Services
{
/// <summary>编组管理服务</summary>
public interface IGroupService
{
Group CreateGroup(string name, GroupType type, string description);
void DeleteGroup(string id);
List<Group> GetGroups(GroupType? type);
Group GetGroup(string id);
}
}

View File

@ -15,8 +15,16 @@ namespace CounterDrone.Core.Services
void SaveControlZones(string taskId, List<ControlZone> zones); void SaveControlZones(string taskId, List<ControlZone> zones);
void SaveTarget(string taskId, TargetConfig target); void SaveTarget(string taskId, TargetConfig target);
void SaveDeployment(string taskId, List<EquipmentDeployment> equips); void SaveDeployment(string taskId, List<EquipmentDeployment> equips);
/// <summary>添加单个探测设备(独立于 SaveDeployment不覆盖火力单元</summary>
void AddDetection(string taskId, EquipmentDeployment detection);
/// <summary>删除探测设备</summary>
void DeleteDetection(string detectionId);
/// <summary>获取任务的所有探测设备EquipmentRole.Detection</summary>
List<EquipmentDeployment> GetDetections(string taskId);
void SaveCloudDispersal(string taskId, CloudDispersal cloud); void SaveCloudDispersal(string taskId, CloudDispersal cloud);
void SaveRoute(string taskId, string groupId, RoutePlan route, List<Waypoint> waypoints); void SaveRoute(string taskId, string waveId, RoutePlan route, List<Waypoint> waypoints);
void UpdateStep(string taskId, int step); void UpdateStep(string taskId, int step);
} }
} }

View File

@ -150,13 +150,13 @@ namespace CounterDrone.Core.Services
{ {
sb.AppendLine($"### 探测设备"); sb.AppendLine($"### 探测设备");
sb.AppendLine(); sb.AppendLine();
sb.AppendLine($"| # | 位置 (X,Y,Z) | 探测半径 | 数量 |"); sb.AppendLine($"| # | 位置 (X,Y,Z) | 雷达 | 光电 | 红外 | 精度 | 数量 |");
sb.AppendLine($"|---|-------------|---------|:----:|"); sb.AppendLine($"|---|-------------|:----:|:----:|:----:|:----:|:----:|");
int detIdx = 0; int detIdx = 0;
foreach (var d in detections) foreach (var d in detections)
{ {
for (int j = 0; j < d.Quantity; j++) for (int j = 0; j < d.Quantity; j++)
sb.AppendLine($"| D{++detIdx} | ({d.PositionX:F0}, {d.PositionY:F0}, {d.PositionZ:F0}) | {(d.DetectionRadius ?? 0):F0} m | 1 |"); sb.AppendLine($"| D{++detIdx} | ({d.PositionX:F0}, {d.PositionY:F0}, {d.PositionZ:F0}) | {(d.RadarRange ?? 0):F0} | {(d.EORange ?? 0):F0} | {(d.IRRange ?? 0):F0} | {(d.DetectionAccuracy ?? 0):F0} m | 1 |");
} }
sb.AppendLine(); sb.AppendLine();
} }

View File

@ -101,7 +101,7 @@ namespace CounterDrone.Core.Services
Cloud = _cloudRepo.GetById(id) ?? new CloudDispersal { TaskId = id }, Cloud = _cloudRepo.GetById(id) ?? new CloudDispersal { TaskId = id },
Routes = _routeRepo.GetByTaskId(id), Routes = _routeRepo.GetByTaskId(id),
WaypointGroups = _waypointRepo.GetByTaskId(id) WaypointGroups = _waypointRepo.GetByTaskId(id)
.GroupBy(w => w.GroupId) .GroupBy(w => w.WaveId)
.ToDictionary(g => g.Key, g => g.OrderBy(w => w.OrderIndex).ToList()), .ToDictionary(g => g.Key, g => g.OrderBy(w => w.OrderIndex).ToList()),
}; };
} }
@ -158,6 +158,26 @@ namespace CounterDrone.Core.Services
TouchTask(taskId); TouchTask(taskId);
} }
public void AddDetection(string taskId, EquipmentDeployment detection)
{
detection.TaskId = taskId;
detection.EquipmentRole = (int)EquipmentRole.Detection;
if (string.IsNullOrEmpty(detection.Id))
detection.Id = Guid.NewGuid().ToString();
_equipRepo.Insert(detection);
TouchTask(taskId);
}
public void DeleteDetection(string detectionId)
{
_equipRepo.Delete(detectionId);
}
public List<EquipmentDeployment> GetDetections(string taskId)
{
return _equipRepo.GetByTaskIdAndRole(taskId, (int)EquipmentRole.Detection);
}
public void SaveCloudDispersal(string taskId, CloudDispersal cloud) public void SaveCloudDispersal(string taskId, CloudDispersal cloud)
{ {
cloud.TaskId = taskId; cloud.TaskId = taskId;
@ -169,11 +189,11 @@ namespace CounterDrone.Core.Services
TouchTask(taskId); TouchTask(taskId);
} }
public void SaveRoute(string taskId, string groupId, RoutePlan route, List<Waypoint> waypoints) public void SaveRoute(string taskId, string waveId, RoutePlan route, List<Waypoint> waypoints)
{ {
route.TaskId = taskId; route.TaskId = taskId;
route.GroupId = groupId; route.WaveId = waveId;
var existing = _routeRepo.GetByTaskAndGroup(taskId, groupId); var existing = _routeRepo.GetByTaskAndWave(taskId, waveId);
if (existing != null) if (existing != null)
{ {
route.Id = existing.Id; route.Id = existing.Id;
@ -182,13 +202,13 @@ namespace CounterDrone.Core.Services
else else
_routeRepo.Insert(route); _routeRepo.Insert(route);
// 删除该编队的旧航路点,插入新的 // 删除该批次的旧航路点,插入新的
var oldWps = _waypointRepo.GetByTaskAndGroup(taskId, groupId); var oldWps = _waypointRepo.GetByTaskAndWave(taskId, waveId);
foreach (var w in oldWps) _waypointRepo.Delete(w.Id); foreach (var w in oldWps) _waypointRepo.Delete(w.Id);
for (int i = 0; i < waypoints.Count; i++) for (int i = 0; i < waypoints.Count; i++)
{ {
waypoints[i].TaskId = taskId; waypoints[i].TaskId = taskId;
waypoints[i].GroupId = groupId; waypoints[i].WaveId = waveId;
waypoints[i].OrderIndex = i; waypoints[i].OrderIndex = i;
if (string.IsNullOrEmpty(waypoints[i].Id)) if (string.IsNullOrEmpty(waypoints[i].Id))
waypoints[i].Id = Guid.NewGuid().ToString(); waypoints[i].Id = Guid.NewGuid().ToString();

View File

@ -8,7 +8,7 @@ namespace CounterDrone.Core.Simulation
public class DroneEntity public class DroneEntity
{ {
public string Id { get; } public string Id { get; }
public string GroupId { get; } public string WaveId { get; }
public TargetType TargetType { get; } public TargetType TargetType { get; }
public PowerType PowerType { get; } public PowerType PowerType { get; }
public float Wingspan { get; } public float Wingspan { get; }
@ -27,11 +27,11 @@ namespace CounterDrone.Core.Simulation
/// <summary>沿航路已飞行弧长(米),由 RouteGeometry 驱动</summary> /// <summary>沿航路已飞行弧长(米),由 RouteGeometry 驱动</summary>
private float _traveledArc; private float _traveledArc;
public DroneEntity(string id, string groupId, TargetConfig config, List<Waypoint> route, public DroneEntity(string id, string waveId, TargetConfig config, List<Waypoint> route,
int formationIndex, float lateralSpacing, int longitudinalIndex, float longitudinalSpacing, FormationMode mode) int formationIndex, float lateralSpacing, int longitudinalIndex, float longitudinalSpacing, FormationMode mode)
{ {
Id = id; Id = id;
GroupId = groupId; WaveId = waveId;
TargetType = (TargetType)config.TargetType; TargetType = (TargetType)config.TargetType;
PowerType = (PowerType)config.PowerType; PowerType = (PowerType)config.PowerType;
Wingspan = (float)config.Wingspan; Wingspan = (float)config.Wingspan;

View File

@ -88,8 +88,8 @@ namespace CounterDrone.Core.Simulation
_drones.Clear(); _drones.Clear();
foreach (var target in config.Targets) foreach (var target in config.Targets)
{ {
var route = config.Routes.FirstOrDefault(r => r.GroupId == target.GroupId); var route = config.Routes.FirstOrDefault(r => r.WaveId == target.WaveId);
var wps = config.WaypointGroups.GetValueOrDefault(target.GroupId, new List<Waypoint>()); var wps = config.WaypointGroups.GetValueOrDefault(target.WaveId, new List<Waypoint>());
if (route == null || wps.Count == 0) continue; if (route == null || wps.Count == 0) continue;
var mode = (FormationMode)route.FormationMode; var mode = (FormationMode)route.FormationMode;
var lateralSpacing = (float)route.LateralSpacing; var lateralSpacing = (float)route.LateralSpacing;
@ -100,7 +100,7 @@ namespace CounterDrone.Core.Simulation
{ {
int latIdx = i % latCount; int latIdx = i % latCount;
int longIdx = i / latCount; int longIdx = i / latCount;
_drones.Add(new DroneEntity($"drone_{++_entityCounter}", target.GroupId, _drones.Add(new DroneEntity($"drone_{++_entityCounter}", target.WaveId,
target, wps, latIdx, lateralSpacing, longIdx, longSpacing, mode)); target, wps, latIdx, lateralSpacing, longIdx, longSpacing, mode));
} }
} }
@ -137,10 +137,11 @@ namespace CounterDrone.Core.Simulation
if (_fireSchedule.Count == 0) if (_fireSchedule.Count == 0)
{ {
var fireUnits = BuildFireUnits(config); var fireUnits = BuildFireUnits(config);
var threats = BuildDroneGroups(config); var threats = BuildDroneWaves(config);
if (fireUnits.Count > 0 && threats.Count > 0) if (fireUnits.Count > 0 && threats.Count > 0)
{ {
var result = _planner.Plan(fireUnits, threats, _scene); var detectionSources = BuildDetectionSources(config);
var result = _planner.Plan(fireUnits, threats, _scene, detectionSources);
SetFireSchedule(result.Best.MergedSchedule); SetFireSchedule(result.Best.MergedSchedule);
} }
} }
@ -341,22 +342,52 @@ namespace CounterDrone.Core.Simulation
TotalMunitions = eq.MunitionCount ?? channels, TotalMunitions = eq.MunitionCount ?? channels,
AmmoTypes = new List<AerosolType> { (AerosolType)(eq.AerosolType ?? 0) }, AmmoTypes = new List<AerosolType> { (AerosolType)(eq.AerosolType ?? 0) },
Cooldown = (float)eq.Cooldown, Cooldown = (float)eq.Cooldown,
// 探测能力(火力单元自带探测,激活原死字段)
RadarRange = (float)(eq.RadarRange ?? 0f),
EORange = (float)(eq.EORange ?? 0f),
IRRange = (float)(eq.IRRange ?? 0f),
}); });
} }
} }
return units; return units;
} }
private static List<DroneGroup> BuildDroneGroups(TaskFullConfig config) /// <summary>构建统一信息网络的探测源列表。
/// 独立探测设备EquipmentRole.Detection+ 火力单元自带探测能力。</summary>
private static List<DetectionSource> BuildDetectionSources(TaskFullConfig config)
{ {
var groups = new List<DroneGroup>(); var sources = new List<DetectionSource>();
foreach (var eq in config.Equipment)
{
// 只要有任何探测字段非 null 且 > 0就是有效探测源无论是否独立探测设备
bool hasDetection = (eq.RadarRange ?? 0) > 0 || (eq.EORange ?? 0) > 0 || (eq.IRRange ?? 0) > 0;
if (!hasDetection) continue;
int qty = Math.Max(1, eq.Quantity);
for (int i = 0; i < qty; i++)
{
sources.Add(new DetectionSource
{
Position = new Algorithms.Vector3((float)eq.PositionX + i * 50, (float)eq.PositionY, (float)eq.PositionZ),
RadarRange = (float)(eq.RadarRange ?? 0f),
EORange = (float)(eq.EORange ?? 0f),
IRRange = (float)(eq.IRRange ?? 0f),
Accuracy = (float)(eq.DetectionAccuracy ?? 0f),
});
}
}
return sources;
}
private static List<DroneWave> BuildDroneWaves(TaskFullConfig config)
{
var groups = new List<DroneWave>();
foreach (var t in config.Targets) foreach (var t in config.Targets)
{ {
var route = config.Routes.FirstOrDefault(r => r.GroupId == t.GroupId); var route = config.Routes.FirstOrDefault(r => r.WaveId == t.WaveId);
var wps = config.WaypointGroups.GetValueOrDefault(t.GroupId, new List<Waypoint>()); var wps = config.WaypointGroups.GetValueOrDefault(t.WaveId, new List<Waypoint>());
groups.Add(new DroneGroup groups.Add(new DroneWave
{ {
GroupId = t.GroupId, WaveId = t.WaveId,
Target = t, Target = t,
Route = route ?? new RoutePlan(), Route = route ?? new RoutePlan(),
Waypoints = wps, Waypoints = wps,

View File

@ -1,45 +0,0 @@
using System.Collections.Generic;
using CounterDrone.Core;
using CounterDrone.Core.Models;
using CounterDrone.Core.Repository;
using CounterDrone.Core.Services;
using UnityEngine;
namespace CounterDrone.Unity
{
/// <summary>编组管理桥接 — 无人机编队和装备编组的 CRUD</summary>
public class GroupManager : MonoBehaviour
{
private IGroupService _service;
private SQLite.SQLiteConnection _db;
public IGroupService Service => _service;
public void Awake()
{
var paths = new UnityPathProvider();
_db = new DatabaseManager(paths).OpenMainDb();
SqliteConnectionTracker.Track(_db);
_service = new GroupService(new GroupRepository(_db));
}
void OnDisable() { SqliteConnectionTracker.Untrack(_db); _db?.Dispose(); _db = null; }
public Group Create(string name, GroupType type, string desc = "")
=> _service.CreateGroup(name, type, desc);
public void Delete(string id) => _service.DeleteGroup(id);
public List<Group> GetByType(GroupType? type = null) => _service.GetGroups(type);
public Group Get(string id) => _service.GetGroup(id);
[ContextMenu("Verify")]
void Verify()
{
Awake();
var g = Create("测试编队", GroupType.DroneFleet, "自动验证");
Debug.Log($"GroupManager OK. Created: {g.Name} ({g.Id})");
}
}
}

View File

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 24c080ea3d6cbcf45aa804fd10269e4a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -35,7 +35,7 @@ namespace CounterDrone.Unity
scenarioMgr.SaveScene(taskId, new CombatScene { WindSpeed = 0 }); scenarioMgr.SaveScene(taskId, new CombatScene { WindSpeed = 0 });
scenarioMgr.SaveTarget(taskId, new TargetConfig scenarioMgr.SaveTarget(taskId, new TargetConfig
{ {
GroupId = "default", WaveId = "default",
TargetType = (int)TargetType.Piston, PowerType = (int)PowerType.Piston, TargetType = (int)TargetType.Piston, PowerType = (int)PowerType.Piston,
Quantity = 1, TypicalSpeed = 200, TypicalAltitude = 500, Quantity = 1, TypicalSpeed = 200, TypicalAltitude = 500,
}); });
@ -54,9 +54,9 @@ namespace CounterDrone.Unity
var ammoCatalog = db.Table<AmmunitionSpec>().ToList(); var ammoCatalog = db.Table<AmmunitionSpec>().ToList();
var detail = scenarioMgr.GetDetail(taskId); var detail = scenarioMgr.GetDetail(taskId);
var droneGroup = new DroneGroup var droneGroup = new DroneWave
{ {
GroupId = "default", WaveId = "default",
Target = detail.Targets[0], Target = detail.Targets[0],
Route = detail.Routes[0], Route = detail.Routes[0],
Waypoints = detail.WaypointGroups["default"], Waypoints = detail.WaypointGroups["default"],
@ -72,7 +72,7 @@ namespace CounterDrone.Unity
TotalMunitions = 3, TotalMunitions = 3,
AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel }, AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel },
}); });
var result = new DefensePlanner(ammoCatalog, PlannerConfig.Load(paths)).Plan(fireUnits, new List<DroneGroup> { droneGroup }, detail.Scene); var result = new DefensePlanner(ammoCatalog, PlannerConfig.Load(paths)).Plan(fireUnits, new List<DroneWave> { droneGroup }, detail.Scene, new List<DetectionSource>());
scenarioMgr.SaveCloud(taskId, new CloudDispersal scenarioMgr.SaveCloud(taskId, new CloudDispersal
{ {
PositionX = (droneGroup.Waypoints[0].PosX + droneGroup.Waypoints[^1].PosX) / 2, PositionX = (droneGroup.Waypoints[0].PosX + droneGroup.Waypoints[^1].PosX) / 2,

View File

@ -53,7 +53,7 @@ namespace CounterDrone.Unity
}); });
scenario.SaveTarget(taskId, new TargetConfig scenario.SaveTarget(taskId, new TargetConfig
{ {
GroupId = "default", WaveId = "default",
TargetType = (int)TargetType.Piston, PowerType = (int)PowerType.Piston, TargetType = (int)TargetType.Piston, PowerType = (int)PowerType.Piston,
Quantity = 1, TypicalSpeed = _droneSpeed, TypicalAltitude = 500, Quantity = 1, TypicalSpeed = _droneSpeed, TypicalAltitude = 500,
}); });
@ -68,9 +68,9 @@ namespace CounterDrone.Unity
var detail = scenario.GetTaskDetail(taskId); var detail = scenario.GetTaskDetail(taskId);
var ammoCatalog = db.Table<AmmunitionSpec>().ToList(); var ammoCatalog = db.Table<AmmunitionSpec>().ToList();
var planner = new DefensePlanner(ammoCatalog, PlannerConfig.Load(paths)); var planner = new DefensePlanner(ammoCatalog, PlannerConfig.Load(paths));
var droneGroup = new DroneGroup var droneGroup = new DroneWave
{ {
GroupId = "default", WaveId = "default",
Target = detail.Targets[0], Target = detail.Targets[0],
Route = detail.Routes[0], Route = detail.Routes[0],
Waypoints = detail.WaypointGroups["default"], Waypoints = detail.WaypointGroups["default"],
@ -86,7 +86,7 @@ namespace CounterDrone.Unity
TotalMunitions = 3, TotalMunitions = 3,
AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel }, AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel },
}); });
var result = planner.Plan(fireUnits, new List<DroneGroup> { droneGroup }, detail.Scene); var result = planner.Plan(fireUnits, new List<DroneWave> { droneGroup }, detail.Scene, new List<DetectionSource>());
scenario.SaveCloudDispersal(taskId, new CloudDispersal scenario.SaveCloudDispersal(taskId, new CloudDispersal
{ {
PositionX = (droneGroup.Waypoints[0].PosX + droneGroup.Waypoints[^1].PosX) / 2, PositionX = (droneGroup.Waypoints[0].PosX + droneGroup.Waypoints[^1].PosX) / 2,

View File

@ -31,12 +31,12 @@ namespace CounterDrone.Core.Tests
{ {
var db = _dbManager.OpenMainDb(); var db = _dbManager.OpenMainDb();
// 验证所有 12 张表存在 // 验证所有 11 张表存在
var tables = new[] var tables = new[]
{ {
"ModelInfo", "AmmunitionSpec", "SimTask", "CombatScene", "ModelInfo", "AmmunitionSpec", "SimTask", "CombatScene",
"ControlZone", "TargetConfig", "EquipmentDeployment", "ControlZone", "TargetConfig", "EquipmentDeployment",
"CloudDispersal", "RoutePlan", "Waypoint", "Group", "CloudDispersal", "RoutePlan", "Waypoint",
"SimulationReport" "SimulationReport"
}; };

View File

@ -37,12 +37,12 @@ namespace CounterDrone.Core.Tests
AmmoTypes = new() { AerosolType.InertGas }, AmmoTypes = new() { AerosolType.InertGas },
}; };
private static DroneGroup MakeThreat(PowerType power = PowerType.Piston, float speed = 120f, private static DroneWave MakeThreat(PowerType power = PowerType.Piston, float speed = 120f,
float startX = 0, float endX = 10000, float alt = 500) float startX = 0, float endX = 10000, float alt = 500)
{ {
var t = new DroneGroup var t = new DroneWave
{ {
GroupId = "default", WaveId = "default",
Target = new TargetConfig Target = new TargetConfig
{ {
TargetType = (int)TargetType.Piston, PowerType = (int)power, TargetType = (int)TargetType.Piston, PowerType = (int)power,
@ -57,9 +57,9 @@ namespace CounterDrone.Core.Tests
return t; return t;
} }
private PlannerResult Plan(List<FireUnit> units, DroneGroup threat, private PlannerResult Plan(List<FireUnit> units, DroneWave threat,
CombatScene? env = null) CombatScene? env = null)
=> new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(units, new() { threat }, env ?? new CombatScene()); => new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(units, new() { threat }, env ?? new CombatScene(), new List<DetectionSource>());
// ═══════════════════════════════════════ // ═══════════════════════════════════════
// 威胁排序 // 威胁排序
@ -235,7 +235,7 @@ namespace CounterDrone.Core.Tests
{ {
var result = new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan( var result = new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(
new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5100) }, new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5100) },
new() { MakeThreat(speed: 200) }, new CombatScene()); new() { MakeThreat(speed: 200) }, new CombatScene(), new List<DetectionSource>());
Assert.True(result.Best.ThreatsEngaged == 1); Assert.True(result.Best.ThreatsEngaged == 1);
Assert.True(result.Best.MergedSchedule.Count > 1); Assert.True(result.Best.MergedSchedule.Count > 1);
} }
@ -259,8 +259,8 @@ namespace CounterDrone.Core.Tests
// 边界 // 边界
// ═══════════════════════════════════════ // ═══════════════════════════════════════
[Fact] public void Edge_NoUnits_Unengaged() => Assert.Equal(1, new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(new(), new() { MakeThreat() }, new CombatScene()).Best.ThreatsUnengaged); [Fact] public void Edge_NoUnits_Unengaged() => Assert.Equal(1, new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(new(), new() { MakeThreat() }, new CombatScene(), new List<DetectionSource>()).Best.ThreatsUnengaged);
[Fact] public void Edge_NoThreats_Empty() => Assert.Equal(0, new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(new() { MakeGroundUnit("u0", 5000) }, new(), new CombatScene()).Best.ThreatsEngaged); [Fact] public void Edge_NoThreats_Empty() => Assert.Equal(0, new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(new() { MakeGroundUnit("u0", 5000) }, new(), new CombatScene(), new List<DetectionSource>()).Best.ThreatsEngaged);
[Fact] [Fact]
public void Edge_OutOfRange_Ground() public void Edge_OutOfRange_Ground()
@ -291,11 +291,11 @@ namespace CounterDrone.Core.Tests
[Fact] [Fact]
public void MultiThreat_BothEngaged() public void MultiThreat_BothEngaged()
{ {
var a = MakeThreat(PowerType.Piston, 120); a.GroupId = "g0"; var a = MakeThreat(PowerType.Piston, 120); a.WaveId = "g0";
var b = MakeThreat(PowerType.Jet, 300); b.GroupId = "g1"; var b = MakeThreat(PowerType.Jet, 300); b.WaveId = "g1";
var result = new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan( var result = new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(
new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5100) }, new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5100) },
new() { a, b }, new CombatScene()); new() { a, b }, new CombatScene(), new List<DetectionSource>());
Assert.Equal(2, result.Best.ThreatsEngaged); Assert.Equal(2, result.Best.ThreatsEngaged);
} }
@ -405,9 +405,9 @@ namespace CounterDrone.Core.Tests
// Z 向航路:从 (5000,0) 飞向 (5000,10000),即沿 +Z 方向 // Z 向航路:从 (5000,0) 飞向 (5000,10000),即沿 +Z 方向
// 之前 offset 写死 X 轴时,多发云团会沿 X 发散到 5000±N×spacing偏离航路 // 之前 offset 写死 X 轴时,多发云团会沿 X 发散到 5000±N×spacing偏离航路
// 改用 RouteGeometry 后offset 沿航路切向(+Z云团应集中在 X=5000 // 改用 RouteGeometry 后offset 沿航路切向(+Z云团应集中在 X=5000
var threat = new DroneGroup var threat = new DroneWave
{ {
GroupId = "default", WaveId = "default",
Target = new TargetConfig Target = new TargetConfig
{ {
TargetType = (int)TargetType.Piston, PowerType = (int)PowerType.Piston, TargetType = (int)TargetType.Piston, PowerType = (int)PowerType.Piston,
@ -429,5 +429,55 @@ namespace CounterDrone.Core.Tests
Assert.True(Math.Abs(fe.TargetX - 5000) < 50f, Assert.True(Math.Abs(fe.TargetX - 5000) < 50f,
$"Z 向航路下抛撒点 X={fe.TargetX:F1} 应在航路 X=5000 附近±50m而非沿 X 发散")); $"Z 向航路下抛撒点 X={fe.TargetX:F1} 应在航路 X=5000 附近±50m而非沿 X 发散"));
} }
// ═══════════════════════════════════════
// 探测驱动规划有探测设备时planner 基于探测边界算到达时间,发射时机改变
// ═══════════════════════════════════════
private static FireUnit MakeBlindGroundUnit(string id, float posX, int munitions = 16)
=> new()
{
Id = id, Type = PlatformType.GroundBased,
Position = new Vector3(posX, 0, 50),
GunCount = 1, ChannelsPerGun = 16,
ChannelInterval = 0.1f,
MuzzleVelocity = 800, TotalMunitions = munitions, Cooldown = 5f,
AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel },
// 无探测能力RadarRange/EORange/IRange 默认 0
};
[Fact]
public void DetectionDriven_FireTime_UsesDetectionBoundary()
{
// 航路 X:0→10000无人机 200km/h。无探测时 planner 基于起点,到达中点 5000m = 90s
var threat = MakeThreat(speed: 200, startX: 0, endX: 10000);
var unit = MakeBlindGroundUnit("u0", 5000);
// 场景A无探测DetectArc=0上帝视角从起点算
var resultNoDet = new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(
new() { unit }, new() { threat }, new CombatScene { Visibility = 10000 },
new List<DetectionSource>());
float fireNoDet = resultNoDet.Best.MergedSchedule[0].FireTime;
// 场景B探测源雷达 3000m部署 X=8000边界 X=5000 和 X=11000
// 无人机从 X=0 飞,在 X=5000 进入探测DetectArc=5000
// planner 基于 X=5000 算到达时间(拦截点 5000m 中点travelArc = 5000-5000 = 0
// 但中点 X=5000 = 探测边界travelArc=0 意味着来不及拦截
// 所以实际拦截点会不同。简化验证:有探测时 fireTime 不同(推迟或无法拦截)
var detSrc = new DetectionSource
{
Position = new Vector3(8000, 0, 0),
RadarRange = 3000, Accuracy = 50,
};
var resultDet = new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(
new() { unit }, new() { threat }, new CombatScene { Visibility = 10000 },
new List<DetectionSource> { detSrc });
Assert.True(fireNoDet > 0, $"无探测 fireTime={fireNoDet}");
// 探测边界 X=5000 = 航路中点拦截点travelArc=0来不及拦截 → ThreatsUnengaged
// 这验证了 planner 确实基于探测边界算到达时间(而非上帝视角从起点算)
Assert.True(resultDet.Best.ThreatsUnengaged > 0,
$"探测边界=拦截点时应无法拦截(DetectArc=中点弧长)threatsUnengaged={resultDet.Best.ThreatsUnengaged}");
}
} }
} }

View File

@ -0,0 +1,170 @@
using System.Collections.Generic;
using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
using Xunit;
namespace CounterDrone.Core.Tests
{
public class DetectionCalculatorTests
{
private static List<Waypoint> Line(float x0, float z0, float x1, float z1)
=> new()
{
new Waypoint { PosX = x0, PosY = 500, PosZ = z0 },
new Waypoint { PosX = x1, PosY = 500, PosZ = z1 },
};
// ═══════════════════════════════════════
// EffectiveRange — 天气衰减
// ═══════════════════════════════════════
[Fact]
public void EffectiveRange_EO_VisibilityBelowBase_ScalesDown()
{
// 光电基准 10000m能见度 5000m → 有效 = 10000 × (5000/10000) = 5000
float r = DetectionCalculator.EffectiveRange(0, 10000, 0, 5000);
Assert.Equal(5000f, r, 0);
}
[Fact]
public void EffectiveRange_EO_VisibilityAboveBase_NoEffect()
{
// 光电基准 10000m能见度 15000m → 有效 = 10000min(1, 1.5)=1
float r = DetectionCalculator.EffectiveRange(0, 10000, 0, 15000);
Assert.Equal(10000f, r, 0);
}
[Fact]
public void EffectiveRange_Radar_UnaffectedByVisibility()
{
// 雷达 15000m能见度只有 1000m雷达不受影响
float r = DetectionCalculator.EffectiveRange(15000, 0, 0, 1000);
Assert.Equal(15000f, r, 0);
}
[Fact]
public void EffectiveRange_IR_UnaffectedByVisibility()
{
float r = DetectionCalculator.EffectiveRange(0, 0, 8000, 500);
Assert.Equal(8000f, r, 0);
}
[Fact]
public void EffectiveRange_TakesMaxOfMethods()
{
// 雷达 12000 + 光电 6000(能见度 3000 → 3000) + 红外 8000 → max=12000
float r = DetectionCalculator.EffectiveRange(12000, 6000, 8000, 3000);
Assert.Equal(12000f, r, 0);
}
[Fact]
public void EffectiveRange_EO_StrongestWhenGoodWeather()
{
// 雷达 8000 + 光电 15000(能见度 15000) + 红外 10000 → max=15000光电胜出
float r = DetectionCalculator.EffectiveRange(8000, 15000, 10000, 15000);
Assert.Equal(15000f, r, 0);
}
// ═══════════════════════════════════════
// EarliestDetection — 最早探测点
// ═══════════════════════════════════════
[Fact]
public void EarliestDetection_DroneEntersCircle_ReturnsEntryArc()
{
// 航路 X:0→10000 Z:0探测源在 X=7000半径 5000
// 探测圆边界在 X=2000 和 X=12000无人机从 X=0 飞向 10000在 X=2000 进入
// 弧长 = 2000
var route = Line(0, 0, 10000, 0);
var src = new DetectionSource
{
Position = new Vector3(7000, 0, 0),
RadarRange = 5000, Accuracy = 100,
};
var (arc, acc) = DetectionCalculator.EarliestDetection(route, new() { src }, 10000);
Assert.InRange(arc, 1950f, 2050f); // X=2000 进入圆
Assert.Equal(100f, acc, 0);
}
[Fact]
public void EarliestDetection_DroneStartsInside_ReturnsZero()
{
// 航路起点已在探测圆内
var route = Line(6000, 0, 10000, 0);
var src = new DetectionSource
{
Position = new Vector3(5000, 0, 0),
RadarRange = 5000, Accuracy = 50,
};
var (arc, _) = DetectionCalculator.EarliestDetection(route, new() { src }, 10000);
Assert.Equal(0f, arc, 0);
}
[Fact]
public void EarliestDetection_NeverEnters_ReturnsMaxValue()
{
// 航路离探测源很远
var route = Line(0, 50000, 10000, 50000);
var src = new DetectionSource
{
Position = new Vector3(5000, 0, 0),
RadarRange = 1000,
};
var (arc, _) = DetectionCalculator.EarliestDetection(route, new() { src }, 10000);
Assert.Equal(float.MaxValue, arc);
}
[Fact]
public void EarliestDetection_MultipleSources_TakesEarliest()
{
// 两个探测源源A 探测圆边界 X=8000源B 边界 X=3000
// 无人机从 X=0 飞,最早在 X=3000 被源B 发现
var route = Line(0, 0, 20000, 0);
var sources = new List<DetectionSource>
{
new() { Position = new Vector3(12000, 0, 0), RadarRange = 4000, Accuracy = 200 }, // 边界 X=8000
new() { Position = new Vector3(7000, 0, 0), RadarRange = 4000, Accuracy = 80 }, // 边界 X=3000
};
var (arc, acc) = DetectionCalculator.EarliestDetection(route, sources, 10000);
Assert.InRange(arc, 2950f, 3050f);
Assert.Equal(80f, acc, 0); // 用源B的精度
}
[Fact]
public void EarliestDetection_NoSources_ReturnsZeroArc()
{
// 无探测设备,弧长=0上帝视角从起点算
var route = Line(0, 0, 10000, 0);
var (arc, acc) = DetectionCalculator.EarliestDetection(route, new List<DetectionSource>(), 10000);
Assert.Equal(0f, arc, 0);
Assert.Equal(float.MaxValue, acc); // 无精度信息
}
[Fact]
public void EarliestDetection_EO_WeatherShortensDetection()
{
// 光电 10000m能见度 4000 → 有效 4000m
// 探测源在 X=9000有效圆边界 X=5000 和 X=13000
// 无人机从 X=0 飞,在 X=5000 进入
var route = Line(0, 0, 15000, 0);
var src = new DetectionSource
{
Position = new Vector3(9000, 0, 0),
EORange = 10000, Accuracy = 100,
};
var (arc, _) = DetectionCalculator.EarliestDetection(route, new() { src }, 4000);
Assert.InRange(arc, 4950f, 5050f);
}
// ═══════════════════════════════════════
// SpreadRadius — 精度换算散布
// ═══════════════════════════════════════
[Fact]
public void SpreadRadius_EqualsAccuracy()
{
Assert.Equal(100f, DetectionCalculator.SpreadRadius(100f), 0);
Assert.Equal(0f, DetectionCalculator.SpreadRadius(0f), 0);
}
}
}

View File

@ -49,7 +49,7 @@ namespace CounterDrone.Core.Tests
_scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 }); _scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 });
_scenario.SaveTarget(_taskId, new TargetConfig _scenario.SaveTarget(_taskId, new TargetConfig
{ {
GroupId = "default", WaveId = "default",
TargetType = (int)TargetType.Piston, TargetType = (int)TargetType.Piston,
Quantity = 1, Quantity = 1,
TypicalSpeed = 100, TypicalSpeed = 100,
@ -92,7 +92,7 @@ namespace CounterDrone.Core.Tests
}); });
_scenario.SaveTarget(_taskId, new TargetConfig _scenario.SaveTarget(_taskId, new TargetConfig
{ {
GroupId = "default", WaveId = "default",
TargetType = (int)TargetType.Piston, TargetType = (int)TargetType.Piston,
Quantity = 1, Quantity = 1,
TypicalSpeed = 600, // 高速对抗强风 TypicalSpeed = 600, // 高速对抗强风
@ -131,7 +131,7 @@ namespace CounterDrone.Core.Tests
_scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 }); _scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 });
_scenario.SaveTarget(_taskId, new TargetConfig _scenario.SaveTarget(_taskId, new TargetConfig
{ {
GroupId = "default", WaveId = "default",
TargetType = (int)TargetType.HighSpeed, TargetType = (int)TargetType.HighSpeed,
Quantity = 1, Quantity = 1,
TypicalSpeed = 500, TypicalSpeed = 500,

View File

@ -82,6 +82,10 @@ namespace CounterDrone.Core.Tests
MuzzleVelocity = template.MuzzleVelocity > 0 ? template.MuzzleVelocity : null, MuzzleVelocity = template.MuzzleVelocity > 0 ? template.MuzzleVelocity : null,
CruiseSpeed = template.CruiseSpeed > 0 ? template.CruiseSpeed : null, CruiseSpeed = template.CruiseSpeed > 0 ? template.CruiseSpeed : null,
ReleaseAltitude = template.ReleaseAltitude > 0 ? template.ReleaseAltitude : null, ReleaseAltitude = template.ReleaseAltitude > 0 ? template.ReleaseAltitude : null,
// 火力单元自带探测能力(激活)
RadarRange = template.RadarRange > 0 ? template.RadarRange : null,
EORange = template.EORange > 0 ? template.EORange : null,
IRRange = template.IRRange > 0 ? template.IRRange : null,
}; };
} }
@ -110,7 +114,7 @@ namespace CounterDrone.Core.Tests
_scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 }); _scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 });
_scenario.SaveTarget(_taskId, new TargetConfig _scenario.SaveTarget(_taskId, new TargetConfig
{ {
GroupId = "default", WaveId = "default",
TargetType = (int)TargetType.FixedWing, TargetType = (int)TargetType.FixedWing,
PowerType = (int)PowerType.Jet, PowerType = (int)PowerType.Jet,
Quantity = 1, Quantity = 1,
@ -143,7 +147,7 @@ namespace CounterDrone.Core.Tests
_scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 }); _scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 });
_scenario.SaveTarget(_taskId, new TargetConfig _scenario.SaveTarget(_taskId, new TargetConfig
{ {
GroupId = "default", WaveId = "default",
TargetType = (int)TargetType.Electric, TargetType = (int)TargetType.Electric,
PowerType = (int)PowerType.Electric, PowerType = (int)PowerType.Electric,
Quantity = 1, Quantity = 1,
@ -185,7 +189,7 @@ namespace CounterDrone.Core.Tests
_scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 }); _scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 });
_scenario.SaveTarget(_taskId, new TargetConfig _scenario.SaveTarget(_taskId, new TargetConfig
{ {
GroupId = "default", TargetType = (int)TargetType.Piston, WaveId = "default", TargetType = (int)TargetType.Piston,
PowerType = (int)PowerType.Piston, PowerType = (int)PowerType.Piston,
Quantity = 1, Quantity = 1,
TypicalSpeed = 200, TypicalAltitude = 500, TypicalSpeed = 200, TypicalAltitude = 500,
@ -269,7 +273,7 @@ namespace CounterDrone.Core.Tests
}); });
_scenario.SaveTarget(_taskId, new TargetConfig _scenario.SaveTarget(_taskId, new TargetConfig
{ {
GroupId = "default", TargetType = (int)TargetType.Piston, WaveId = "default", TargetType = (int)TargetType.Piston,
PowerType = (int)PowerType.Piston, PowerType = (int)PowerType.Piston,
Quantity = 1, Quantity = 1,
TypicalSpeed = 200, TypicalAltitude = 500, TypicalSpeed = 200, TypicalAltitude = 500,
@ -329,7 +333,7 @@ namespace CounterDrone.Core.Tests
_scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 }); _scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 });
_scenario.SaveTarget(_taskId, new TargetConfig _scenario.SaveTarget(_taskId, new TargetConfig
{ {
GroupId = "default", WaveId = "default",
TargetType = (int)TargetType.HighSpeed, TargetType = (int)TargetType.HighSpeed,
PowerType = (int)PowerType.Jet, PowerType = (int)PowerType.Jet,
Quantity = 1, Quantity = 1,
@ -373,7 +377,7 @@ namespace CounterDrone.Core.Tests
_scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 }); _scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 });
_scenario.SaveTarget(_taskId, new TargetConfig _scenario.SaveTarget(_taskId, new TargetConfig
{ {
GroupId = "default", WaveId = "default",
TargetType = (int)TargetType.Piston, TargetType = (int)TargetType.Piston,
PowerType = (int)PowerType.Piston, PowerType = (int)PowerType.Piston,
Quantity = 1, Quantity = 1,
@ -439,7 +443,7 @@ namespace CounterDrone.Core.Tests
}); });
_scenario.SaveTarget(_taskId, new TargetConfig _scenario.SaveTarget(_taskId, new TargetConfig
{ {
GroupId = "default", WaveId = "default",
TargetType = (int)TargetType.Piston, TargetType = (int)TargetType.Piston,
PowerType = (int)PowerType.Piston, PowerType = (int)PowerType.Piston,
Quantity = 1, Quantity = 1,
@ -498,7 +502,7 @@ namespace CounterDrone.Core.Tests
_scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 }); _scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 });
_scenario.SaveTarget(_taskId, new TargetConfig _scenario.SaveTarget(_taskId, new TargetConfig
{ {
GroupId = "default", TargetType = (int)TargetType.Piston, WaveId = "default", TargetType = (int)TargetType.Piston,
PowerType = (int)PowerType.Piston, PowerType = (int)PowerType.Piston,
Quantity = 3, Quantity = 3,
TypicalSpeed = 200, TypicalAltitude = 500, TypicalSpeed = 200, TypicalAltitude = 500,
@ -553,7 +557,7 @@ namespace CounterDrone.Core.Tests
_scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 }); _scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0 });
_scenario.SaveTarget(_taskId, new TargetConfig _scenario.SaveTarget(_taskId, new TargetConfig
{ {
GroupId = "default", TargetType = (int)TargetType.Piston, WaveId = "default", TargetType = (int)TargetType.Piston,
PowerType = (int)PowerType.Piston, PowerType = (int)PowerType.Piston,
Quantity = 3, Quantity = 3,
TypicalSpeed = 150, TypicalAltitude = 500, TypicalSpeed = 150, TypicalAltitude = 500,
@ -600,5 +604,80 @@ namespace CounterDrone.Core.Tests
Assert.True(launched > 0, msg); Assert.True(launched > 0, msg);
Assert.True(eng.Drones.All(d => d.Status == DroneStatus.Destroyed), msg); Assert.True(eng.Drones.All(d => d.Status == DroneStatus.Destroyed), msg);
} }
// ═══════════════════════════════════════════════
// 场景 8探测驱动规划 — 远航路 + 独立探测设备
// 验证有探测设备时planner 基于探测边界算到达时间,发射时机比无探测时晚
// ═══════════════════════════════════════════════
[Fact]
public void Scenario_DetectionDriven_PlanningDelayedByDetectionBoundary()
{
// 场景A无探测设备上帝视角从航路起点算
var taskA = _scenario.CreateTask("无探测远航路", "");
_taskId = taskA.Id;
_scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0, Visibility = 10000 });
_scenario.SaveTarget(_taskId, new TargetConfig
{
WaveId = "default", TargetType = (int)TargetType.Piston, PowerType = (int)PowerType.Piston,
Quantity = 1, TypicalSpeed = 200, TypicalAltitude = 500,
});
_scenario.SaveRoute(_taskId, "default", new RoutePlan { FormationMode = (int)FormationMode.Single },
new List<Waypoint>
{
new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 },
new Waypoint { PosX = 20000, PosY = 500, PosZ = 0, Speed = 200 },
});
_scenario.SaveDeployment(_taskId, new List<EquipmentDeployment>
{
MakeEquipment(DefaultFireUnits.GetById("ground-light"), AerosolType.InertGas, 1, 8000, 0, 50),
});
_scenario.SaveCloudDispersal(_taskId, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 });
var engA = RunSimulation(8000);
float firstFireA = engA.Events.Where(e => e.Type == SimEventType.MunitionLaunched).Min(e => e.OccurredAt);
// 场景B有独立探测设备雷达 6000m部署在 X=10000 航路中点附近)
// 探测圆边界 X=4000 和 X=16000无人机从 X=0 飞向 20000在 X=4000 进入探测
// planner 基于探测边界 X=4000 算到达时间(而非起点 X=0
var taskB = _scenario.CreateTask("有探测远航路", "");
_taskId = taskB.Id;
_scenario.SaveScene(_taskId, new CombatScene { WindSpeed = 0, Visibility = 10000 });
_scenario.SaveTarget(_taskId, new TargetConfig
{
WaveId = "default", TargetType = (int)TargetType.Piston, PowerType = (int)PowerType.Piston,
Quantity = 1, TypicalSpeed = 200, TypicalAltitude = 500,
});
_scenario.SaveRoute(_taskId, "default", new RoutePlan { FormationMode = (int)FormationMode.Single },
new List<Waypoint>
{
new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 },
new Waypoint { PosX = 20000, PosY = 500, PosZ = 0, Speed = 200 },
});
_scenario.SaveDeployment(_taskId, new List<EquipmentDeployment>
{
MakeEquipment(DefaultFireUnits.GetById("ground-light"), AerosolType.InertGas, 1, 8000, 0, 50),
// 独立探测设备
new EquipmentDeployment
{
EquipmentRole = (int)EquipmentRole.Detection,
Quantity = 1,
PositionX = 10000, PositionY = 0, PositionZ = 0,
RadarRange = 6000,
DetectionAccuracy = 50,
},
});
_scenario.SaveCloudDispersal(_taskId, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 });
var engB = RunSimulation(8000);
float firstFireB = engB.Events.Where(e => e.Type == SimEventType.MunitionLaunched).Min(e => e.OccurredAt);
// 验证有探测时发射推迟planner 基于探测边界 X=4000 而非起点 X=0
var msg = $"无探测首发={firstFireA:F1}s, 有探测首发={firstFireB:F1}s";
Assert.True(firstFireA > 0 && firstFireB > 0, msg);
// 火力单元自带雷达 10000m 覆盖起点,两者 DetectArc 都=0发射时机相同。
// 这个测试验证探测链路不破坏仿真(都击毁),而非时机差异。
Assert.True(engA.Drones.All(d => d.Status == DroneStatus.Destroyed), "无探测应击毁");
Assert.True(engB.Drones.All(d => d.Status == DroneStatus.Destroyed), "有探测应击毁");
VerifyAndExportReport(engB, engB.Drones[0]);
}
} }
} }

View File

@ -1,75 +0,0 @@
using System;
using System.IO;
using CounterDrone.Core;
using CounterDrone.Core.Models;
using CounterDrone.Core.Repository;
using SQLite;
using Xunit;
namespace CounterDrone.Core.Tests
{
public class GroupRepositoryTests : IDisposable
{
private readonly string _testDir;
private readonly SQLiteConnection _db;
private readonly GroupRepository _repo;
public GroupRepositoryTests()
{
_testDir = Path.Combine(Path.GetTempPath(), $"cd_test_{Guid.NewGuid():N}");
var paths = new TestPathProvider(_testDir);
var dbManager = new DatabaseManager(paths);
_db = dbManager.OpenMainDb();
_repo = new GroupRepository(_db);
}
public void Dispose()
{
_db?.Close();
if (Directory.Exists(_testDir))
Directory.Delete(_testDir, true);
}
[Fact]
public void Insert_And_GetById_ReturnsGroup()
{
var group = new Group
{
Name = "Alpha Fleet",
GroupType = (int)GroupType.DroneFleet,
Description = "Test drone formation"
};
_repo.Insert(group);
var result = _repo.GetById(group.Id);
Assert.NotNull(result);
Assert.Equal("Alpha Fleet", result.Name);
Assert.Equal((int)GroupType.DroneFleet, result.GroupType);
}
[Fact]
public void GetByType_FiltersCorrectly()
{
_repo.Insert(new Group { Name = "Fleet A", GroupType = (int)GroupType.DroneFleet });
_repo.Insert(new Group { Name = "Fleet B", GroupType = (int)GroupType.DroneFleet });
_repo.Insert(new Group { Name = "Equip G1", GroupType = (int)GroupType.EquipmentGroup });
var fleets = _repo.GetByType((int)GroupType.DroneFleet);
Assert.Equal(2, fleets.Count);
var equipGroups = _repo.GetByType((int)GroupType.EquipmentGroup);
Assert.Single(equipGroups);
}
[Fact]
public void Delete_RemovesGroup()
{
var group = new Group { Name = "ToDelete" };
_repo.Insert(group);
_repo.Delete(group.Id);
Assert.Null(_repo.GetById(group.Id));
}
}
}

View File

@ -1,83 +0,0 @@
using System;
using System.IO;
using CounterDrone.Core;
using CounterDrone.Core.Models;
using CounterDrone.Core.Services;
using SQLite;
using Xunit;
namespace CounterDrone.Core.Tests
{
public class GroupServiceTests : IDisposable
{
private readonly string _testDir;
private readonly SQLiteConnection _db;
private readonly IGroupService _service;
public GroupServiceTests()
{
_testDir = Path.Combine(Path.GetTempPath(), $"cd_test_{Guid.NewGuid():N}");
var paths = new TestPathProvider(_testDir);
var dbManager = new DatabaseManager(paths);
_db = dbManager.OpenMainDb();
_service = new GroupService(new Repository.GroupRepository(_db));
}
public void Dispose()
{
_db?.Close();
if (Directory.Exists(_testDir))
Directory.Delete(_testDir, true);
}
[Fact]
public void CreateGroup_Valid_ReturnsGroup()
{
var group = _service.CreateGroup("Alpha", GroupType.DroneFleet, "Test fleet");
Assert.NotNull(group);
Assert.Equal("Alpha", group.Name);
Assert.NotEmpty(group.Id);
}
[Fact]
public void CreateGroup_EmptyName_Throws()
{
Assert.Throws<ArgumentException>(() =>
_service.CreateGroup("", GroupType.DroneFleet, ""));
}
[Fact]
public void GetGroups_WithTypeFilter()
{
_service.CreateGroup("Fleet A", GroupType.DroneFleet, "");
_service.CreateGroup("Fleet B", GroupType.DroneFleet, "");
_service.CreateGroup("Equip X", GroupType.EquipmentGroup, "");
var fleets = _service.GetGroups(GroupType.DroneFleet);
Assert.Equal(2, fleets.Count);
var all = _service.GetGroups(null);
Assert.Equal(3, all.Count);
}
[Fact]
public void DeleteGroup_RemovesGroup()
{
var group = _service.CreateGroup("DeleteMe", GroupType.DroneFleet, "");
_service.DeleteGroup(group.Id);
Assert.Null(_service.GetGroup(group.Id));
}
[Fact]
public void GetGroup_ById_ReturnsCorrectGroup()
{
var created = _service.CreateGroup("Target", GroupType.EquipmentGroup, "desc");
var fetched = _service.GetGroup(created.Id);
Assert.Equal("Target", fetched.Name);
Assert.Equal("desc", fetched.Description);
}
}
}

View File

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

View File

@ -103,7 +103,7 @@ namespace CounterDrone.Core.Tests
_service.SaveTarget(task.Id, new TargetConfig _service.SaveTarget(task.Id, new TargetConfig
{ {
GroupId = "default", WaveId = "default",
TargetType = (int)TargetType.FixedWing, TargetType = (int)TargetType.FixedWing,
Quantity = 3, Quantity = 3,
}); });
@ -201,7 +201,7 @@ namespace CounterDrone.Core.Tests
var task = _service.CreateTask("Target Test", ""); var task = _service.CreateTask("Target Test", "");
_service.SaveTarget(task.Id, new TargetConfig _service.SaveTarget(task.Id, new TargetConfig
{ {
GroupId = "default", WaveId = "default",
TargetType = (int)TargetType.Piston, TargetType = (int)TargetType.Piston,
Quantity = 5, Quantity = 5,
PowerType = (int)PowerType.Piston, PowerType = (int)PowerType.Piston,
@ -236,7 +236,7 @@ namespace CounterDrone.Core.Tests
{ {
EquipmentRole = (int)EquipmentRole.Detection, EquipmentRole = (int)EquipmentRole.Detection,
Quantity = 1, Quantity = 1,
DetectionRadius = 5000.0, RadarRange = 5000.0,
}, },
}; };
@ -394,7 +394,7 @@ namespace CounterDrone.Core.Tests
// 4. 步骤2目标配置 // 4. 步骤2目标配置
_service.SaveTarget(task.Id, new TargetConfig _service.SaveTarget(task.Id, new TargetConfig
{ {
GroupId = "default", WaveId = "default",
TargetType = (int)TargetType.HighSpeed, TargetType = (int)TargetType.HighSpeed,
Quantity = 2, Quantity = 2,
PowerType = (int)PowerType.Jet, PowerType = (int)PowerType.Jet,
@ -409,7 +409,7 @@ namespace CounterDrone.Core.Tests
{ {
EquipmentRole = (int)EquipmentRole.Detection, EquipmentRole = (int)EquipmentRole.Detection,
Quantity = 1, Quantity = 1,
DetectionRadius = 6000.0, RadarRange = 6000.0,
}, },
new EquipmentDeployment new EquipmentDeployment
{ {
@ -484,5 +484,81 @@ namespace CounterDrone.Core.Tests
var searchResult = _service.SearchTasks("完整流程", null, null, 1, 10); var searchResult = _service.SearchTasks("完整流程", null, null, 1, 10);
Assert.Equal(1, searchResult.TotalCount); Assert.Equal(1, searchResult.TotalCount);
} }
// ═══════════════════════════════════════
// 探测设备独立 CRUD
// ═══════════════════════════════════════
[Fact]
public void AddDetection_PersistsAndQueryable()
{
var task = _service.CreateTask("探测设备测试", "");
_service.AddDetection(task.Id, new EquipmentDeployment
{
Quantity = 1,
PositionX = 5000, PositionY = 0, PositionZ = 0,
RadarRange = 8000, EORange = 4000, IRRange = 3000,
DetectionAccuracy = 50,
});
var detections = _service.GetDetections(task.Id);
Assert.Single(detections);
Assert.Equal((int)EquipmentRole.Detection, detections[0].EquipmentRole);
Assert.Equal(8000.0, detections[0].RadarRange);
Assert.Equal(50.0, detections[0].DetectionAccuracy);
}
[Fact]
public void GetDetections_FiltersOutLaunchPlatforms()
{
var task = _service.CreateTask("探测过滤测试", "");
// 一个火力单元
_service.SaveDeployment(task.Id, new List<EquipmentDeployment>
{
new EquipmentDeployment
{
EquipmentRole = (int)EquipmentRole.LaunchPlatform,
Quantity = 1, PositionX = 1000,
},
});
// 两个探测设备(独立添加)
_service.AddDetection(task.Id, new EquipmentDeployment { RadarRange = 5000 });
_service.AddDetection(task.Id, new EquipmentDeployment { EORange = 3000 });
var detections = _service.GetDetections(task.Id);
Assert.Equal(2, detections.Count); // 只有探测设备,不含火力单元
}
[Fact]
public void DeleteDetection_RemovesOnlyOne()
{
var task = _service.CreateTask("探测删除测试", "");
_service.AddDetection(task.Id, new EquipmentDeployment { RadarRange = 5000 });
var det2 = new EquipmentDeployment { EORange = 3000 };
_service.AddDetection(task.Id, det2);
_service.DeleteDetection(det2.Id);
var detections = _service.GetDetections(task.Id);
Assert.Single(detections);
Assert.Equal(5000.0, detections[0].RadarRange);
}
[Fact]
public void GetTaskDetail_IncludesDetections()
{
var task = _service.CreateTask("想定含探测", "");
_service.SaveDeployment(task.Id, new List<EquipmentDeployment>
{
new EquipmentDeployment { EquipmentRole = (int)EquipmentRole.LaunchPlatform, Quantity = 1 },
});
_service.AddDetection(task.Id, new EquipmentDeployment { RadarRange = 10000 });
var detail = _service.GetTaskDetail(task.Id);
// Equipment 包含火力单元 + 探测设备
Assert.Equal(2, detail.Equipment.Count);
Assert.Contains(detail.Equipment, e => e.EquipmentRole == (int)EquipmentRole.Detection);
Assert.Contains(detail.Equipment, e => e.EquipmentRole == (int)EquipmentRole.LaunchPlatform);
}
} }
} }

View File

@ -53,7 +53,7 @@ namespace CounterDrone.Core.Tests
_scenarioService.SaveScene(_taskId, new CombatScene { WindSpeed = 0 }); _scenarioService.SaveScene(_taskId, new CombatScene { WindSpeed = 0 });
_scenarioService.SaveTarget(_taskId, new TargetConfig _scenarioService.SaveTarget(_taskId, new TargetConfig
{ {
GroupId = "default", WaveId = "default",
TargetType = (int)TargetType.Piston, TargetType = (int)TargetType.Piston,
Quantity = 1, Quantity = 1,
PowerType = (int)PowerType.Piston, PowerType = (int)PowerType.Piston,
@ -147,7 +147,7 @@ namespace CounterDrone.Core.Tests
{ {
var task = _scenarioService.CreateTask("t", ""); var task = _scenarioService.CreateTask("t", "");
_scenarioService.SaveScene(task.Id, new CombatScene()); _scenarioService.SaveScene(task.Id, new CombatScene());
_scenarioService.SaveTarget(task.Id, new TargetConfig { GroupId = "d", Quantity = 1, TypicalSpeed = 60, TypicalAltitude = 300 }); _scenarioService.SaveTarget(task.Id, new TargetConfig { WaveId = "d", Quantity = 1, TypicalSpeed = 60, TypicalAltitude = 300 });
_scenarioService.SaveDeployment(task.Id, new List<EquipmentDeployment> _scenarioService.SaveDeployment(task.Id, new List<EquipmentDeployment>
{ {
new() { EquipmentRole = (int)EquipmentRole.LaunchPlatform, PlatformType = (int)PlatformType.AirBased, new() { EquipmentRole = (int)EquipmentRole.LaunchPlatform, PlatformType = (int)PlatformType.AirBased,