新增 GetDefenseRecommendation 防御推荐方案 + 去掉 Critical

- IScenarioService 新增 GetDefenseRecommendation(scenarioId)
- ScenarioService 构造函数加可选 IPathProvider + IDefensePlanner
- DefenseRecommendation / RecommendOption 返回类型
- 前端调用获取最佳抛撒参数 → 用户一键应用写入 CloudDispersal
- 去掉 Critical(省弹药到 50% 概率,无决策价值)
- Unity ScenarioManager 桥接 + 构造注入 planner
- 对接文档加推荐用法示例
- 265 测试通过(+3 推荐),Unity 编译通过
This commit is contained in:
tian 2026-06-22 08:05:09 +08:00
parent 0002e7efbe
commit 9d81ba0b28
8 changed files with 365 additions and 2 deletions

View File

@ -324,8 +324,37 @@ public void DeleteWaypoint(string id);
// Misc
public void UpdateStep(string scenarioId, int step);
public EnumMetadata GetEnums(); // 枚举中英文对照(下拉框数据源)
// 防御推荐(配置阶段调用,仿真前生成最优抛撒参数)
public DefenseRecommendation GetDefenseRecommendation(string scenarioId);
```
**防御推荐用法**"一键应用"功能):
```csharp
var rec = scenario.GetDefenseRecommendation(scenarioId);
if (rec.Success)
{
// rec.Best.PosX/Y/Z — 推荐抛撒位置
// rec.Best.Timing — 推荐抛撒时机(仿真秒)
// rec.Best.SalvoRounds — 推荐弹药数
// rec.Best.EstimatedProbability — 估计拦截概率0~1
// rec.Best.ThreatsEngaged / ThreatsUnengaged — 已拦截/未拦截威胁数
// 用户点"一键应用" → 写入 CloudDispersal
var cloud = scenario.GetCloud(scenarioId);
cloud.PositionX = rec.Best.PosX;
cloud.PositionY = rec.Best.PosY;
cloud.PositionZ = rec.Best.PosZ;
cloud.Source = "Algorithm";
cloud.PositionMode = (int)PositionMode.AlgorithmRecommended;
scenario.SaveCloud(scenarioId, cloud);
}
else
{
Debug.Log($"推荐失败: {rec.Summary}");
}
### IDataService通过 `ScenarioManager.DataService` 访问)
7 类基础数据,每类 4 个方法(`GetAll*` / `Get*(id)` / `Save*(spec)` / `Delete*(id)`
@ -589,6 +618,26 @@ public class EnumMetadata
| `Content` | string | Markdown 报告文本(快速展示用) |
| `ReportDataJson` | string? | 结构化数据 JSONPDF 导出用) |
### DefenseRecommendation防御推荐方案
| 字段 | 类型 | 说明 |
|------|------|------|
| `Success` | bool | 是否规划成功 |
| `Summary` | string | 方案摘要(失败时含原因) |
| `Best` | RecommendOption | 最佳推荐参数 |
### RecommendOption推荐参数
| 字段 | 类型 | 说明 |
|------|------|------|
| `PosX/Y/Z` | double | 推荐抛撒位置Y=高度,米) |
| `Timing` | double | 推荐抛撒时机(仿真秒) |
| `SalvoRounds` | int | 推荐弹药数(齐射发数) |
| `SalvoSpacing` | double | 云团展开间距(米) |
| `EstimatedProbability` | double | 估计拦截概率0~1 |
| `ThreatsEngaged` | int | 已拦截威胁数 |
| `ThreatsUnengaged` | int | 未拦截威胁数 |
---
## 八、运行时实体属性

View File

@ -0,0 +1,46 @@
namespace CounterDrone.Core.Services
{
/// <summary>防御推荐方案 — 配置阶段调用 planner 生成,供前端"一键应用"</summary>
public class DefenseRecommendation
{
/// <summary>最佳推荐参数(拦截概率最高的抛撒参数)</summary>
public RecommendOption Best { get; set; } = new();
/// <summary>方案摘要(含失败原因,如有)</summary>
public string Summary { get; set; } = "";
/// <summary>是否规划成功</summary>
public bool Success { get; set; }
}
/// <summary>推荐参数档位</summary>
public class RecommendOption
{
/// <summary>推荐抛撒位置 X</summary>
public double PosX { get; set; }
/// <summary>推荐抛撒位置 Y高度</summary>
public double PosY { get; set; }
/// <summary>推荐抛撒位置 Z</summary>
public double PosZ { get; set; }
/// <summary>推荐抛撒时机(仿真秒)</summary>
public double Timing { get; set; }
/// <summary>推荐弹药数(齐射发数)</summary>
public int SalvoRounds { get; set; }
/// <summary>云团展开间距(米)</summary>
public double SalvoSpacing { get; set; }
/// <summary>估计拦截概率0~1</summary>
public double EstimatedProbability { get; set; }
/// <summary>已拦截威胁数</summary>
public int ThreatsEngaged { get; set; }
/// <summary>未拦截威胁数</summary>
public int ThreatsUnengaged { get; set; }
}
}

View File

@ -59,5 +59,8 @@ namespace CounterDrone.Core.Services
/// <summary>返回所有枚举定义(供前端下拉框使用)</summary>
EnumMetadata GetEnums();
/// <summary>生成防御推荐方案(配置阶段调用,仿真前基于已配置参数计算最佳/最危险抛撒参数)</summary>
DefenseRecommendation GetDefenseRecommendation(string scenarioId);
}
}

View File

@ -2,8 +2,10 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
using CounterDrone.Core.Repository;
using SQLite;
namespace CounterDrone.Core.Services
{
@ -17,6 +19,8 @@ namespace CounterDrone.Core.Services
private readonly CloudDispersalRepository _cloudRepo;
private readonly RoutePlanRepository _routeRepo;
private readonly WaypointRepository _waypointRepo;
private readonly IPathProvider? _paths;
private readonly IDefensePlanner? _planner;
public ScenarioService(
ScenarioRepository scenarioRepo,
@ -26,7 +30,9 @@ namespace CounterDrone.Core.Services
ScenarioUnitRepository equipRepo,
CloudDispersalRepository cloudRepo,
RoutePlanRepository routeRepo,
WaypointRepository waypointRepo)
WaypointRepository waypointRepo,
IPathProvider? paths = null,
IDefensePlanner? planner = null)
{
_scenarioRepo = scenarioRepo;
_sceneRepo = sceneRepo;
@ -36,6 +42,8 @@ namespace CounterDrone.Core.Services
_cloudRepo = cloudRepo;
_routeRepo = routeRepo;
_waypointRepo = waypointRepo;
_paths = paths;
_planner = planner;
}
// ========== scenario CRUD ==========
@ -479,6 +487,157 @@ namespace CounterDrone.Core.Services
private static int _counter;
// ========== Defense Recommendation ==========
public DefenseRecommendation GetDefenseRecommendation(string scenarioId)
{
if (_paths == null || _planner == null)
throw new InvalidOperationException("ScenarioService 未配置 IPathProvider/IDefensePlanner无法生成推荐方案");
var config = GetScenarioDetail(scenarioId);
if (config == null)
throw new ArgumentException($"想定不存在: {scenarioId}");
// 加载 spec 数据
Dictionary<string, DroneSpec> droneSpecs;
Dictionary<string, LaunchPlatformSpec> platformSpecs;
Dictionary<string, SensorSpec> sensorSpecs;
List<AmmunitionSpec> ammoCatalog;
using (var db = new SQLiteConnection(_paths.GetMainDbPath()))
{
droneSpecs = db.Table<DroneSpec>().ToDictionary(d => d.Id);
platformSpecs = db.Table<LaunchPlatformSpec>().ToDictionary(f => f.Id);
sensorSpecs = db.Table<SensorSpec>().ToDictionary(s => s.Id);
ammoCatalog = db.Table<AmmunitionSpec>().ToList();
}
var fireUnits = BuildFireUnits(config, platformSpecs);
var threats = BuildDroneWaves(config, droneSpecs);
var detectionSources = BuildDetectionSources(config, sensorSpecs);
if (fireUnits.Count == 0 || threats.Count == 0)
return new DefenseRecommendation
{
Success = false,
Summary = "缺少火力单元或威胁目标,无法生成推荐方案",
};
var result = _planner.Plan(fireUnits, threats, config.Scene, detectionSources);
if (result.Best.MergedSchedule.Count == 0)
return new DefenseRecommendation
{
Success = false,
Summary = result.Best.Summary,
};
return new DefenseRecommendation
{
Success = true,
Summary = result.Best.Summary,
Best = PlanToOption(result.Best),
};
}
private static RecommendOption PlanToOption(DefensePlan plan)
{
if (plan.MergedSchedule.Count == 0)
return new RecommendOption();
// 取首发作为代表抛撒点
var first = plan.MergedSchedule.OrderBy(f => f.FireTime).First();
return new RecommendOption
{
PosX = first.TargetX,
PosY = first.TargetY,
PosZ = first.TargetZ,
Timing = first.FireTime,
SalvoRounds = plan.MergedSchedule.Count,
SalvoSpacing = 0, // spacing 在 planner 内部计算,不暴露到 DefensePlan
EstimatedProbability = plan.OverallProbability,
ThreatsEngaged = plan.ThreatsEngaged,
ThreatsUnengaged = plan.ThreatsUnengaged,
};
}
private static List<FireUnit> BuildFireUnits(ScenarioConfig config, Dictionary<string, LaunchPlatformSpec> specs)
{
var units = new List<FireUnit>();
foreach (var eq in config.Units.Where(e => e.EquipmentRole == (int)EquipmentRole.LaunchPlatform))
{
if (!specs.TryGetValue(eq.LaunchPlatformSpecId, out var spec)) continue;
int qty = Math.Max(1, eq.Quantity);
for (int i = 0; i < qty; i++)
{
units.Add(new FireUnit
{
Id = $"fu{units.Count}",
Type = (PlatformType)spec.PlatformType,
Position = new Vector3((float)eq.PositionX + i * 50, (float)eq.PositionY, (float)eq.PositionZ),
GunCount = spec.GunCount,
ChannelsPerGun = spec.ChannelsPerGun,
ChannelInterval = (float)spec.ChannelInterval,
CruiseSpeed = (float)spec.CruiseSpeed,
ReleaseAltitude = (float)spec.ReleaseAltitude,
MuzzleVelocity = (float)spec.MuzzleVelocity,
TotalMunitions = eq.MunitionCount ?? spec.GunCount * spec.ChannelsPerGun,
AmmoTypes = new List<AerosolType> { (AerosolType)(eq.AerosolType ?? 0) },
Cooldown = (float)spec.Cooldown,
});
}
}
return units;
}
private static List<DroneWave> BuildDroneWaves(ScenarioConfig config, Dictionary<string, DroneSpec> specs)
{
var waves = new List<DroneWave>();
foreach (var t in config.Drones)
{
if (!specs.TryGetValue(t.DroneSpecId, out var droneSpec)) continue;
var route = config.Routes.FirstOrDefault(r => r.WaveId == t.WaveId);
var wps = config.WaypointGroups.GetValueOrDefault(t.WaveId, new List<Waypoint>());
waves.Add(new DroneWave
{
WaveId = t.WaveId,
Profile = droneSpec,
Quantity = t.Quantity,
Route = route ?? new RoutePlan(),
Waypoints = wps,
});
}
return waves;
}
private static List<DetectionSource> BuildDetectionSources(ScenarioConfig config, Dictionary<string, SensorSpec> sensorSpecs)
{
var sources = new List<DetectionSource>();
foreach (var eq in config.Units)
{
if (string.IsNullOrEmpty(eq.SensorSpecId)) continue;
if (!sensorSpecs.TryGetValue(eq.SensorSpecId, out var sensor)) continue;
bool hasDetection = sensor.RadarRange > 0 || sensor.EORange > 0 || sensor.IRRange > 0;
if (!hasDetection) continue;
int qty = Math.Max(1, eq.Quantity);
for (int i = 0; i < qty; i++)
{
sources.Add(new DetectionSource
{
Position = new Vector3((float)eq.PositionX + i * 50, (float)eq.PositionY, (float)eq.PositionZ),
RadarRange = (float)sensor.RadarRange,
EORange = (float)sensor.EORange,
IRRange = (float)sensor.IRRange,
Accuracy = (float)sensor.Accuracy,
MinElevation = (float)(sensor.MinElevation ?? float.MaxValue),
MaxElevation = (float)(sensor.MaxElevation ?? float.MaxValue),
MinDetectAlt = (float)(sensor.MinDetectAlt ?? float.MaxValue),
MaxDetectAlt = (float)(sensor.MaxDetectAlt ?? float.MaxValue),
});
}
}
return sources;
}
public static string GenerateScenarioNumber()
{
var now = DateTime.Now;

View File

@ -1,5 +1,6 @@
using System.Collections.Generic;
using CounterDrone.Core;
using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
using CounterDrone.Core.Repository;
using CounterDrone.Core.Services;
@ -25,11 +26,15 @@ namespace CounterDrone.Unity
Debug.Log($"ScenarioManager.Awake: dbPath={paths.GetMainDbPath()}");
_db = new DatabaseManager(paths).OpenMainDb();
SqliteConnectionTracker.Track(_db);
var planner = new DefensePlanner(
DefaultData.Load(paths).Ammunition,
PlannerConfig.Load(paths));
_service = new ScenarioService(
new ScenarioRepository(_db), new CombatSceneRepository(_db),
new ControlZoneRepository(_db), new ScenarioDroneRepository(_db),
new ScenarioUnitRepository(_db), new CloudDispersalRepository(_db),
new RoutePlanRepository(_db), new WaypointRepository(_db));
new RoutePlanRepository(_db), new WaypointRepository(_db),
paths, planner);
_dataService = new DataService(
new AmmunitionSpecRepository(_db), new FireUnitSpecRepository(_db),
new DroneSpecRepository(_db), new SensorSpecRepository(_db),
@ -90,6 +95,10 @@ namespace CounterDrone.Unity
public EnumMetadata GetEnums() => _service.GetEnums();
/// <summary>生成防御推荐方案(配置阶段调用,返回最佳值/最危险值供前端"一键应用"</summary>
public DefenseRecommendation GetDefenseRecommendation(string scenarioId)
=> _service.GetDefenseRecommendation(scenarioId);
[ContextMenu("Verify")]
void Verify()
{

View File

@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CounterDrone.Core;
using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
using CounterDrone.Core.Repository;
using CounterDrone.Core.Services;
using SQLite;
using Xunit;
namespace CounterDrone.Core.Tests
{
public class DefenseRecommendationTests : IDisposable
{
private readonly string _testDir;
private readonly IPathProvider _paths;
private readonly SQLiteConnection _db;
private readonly ScenarioService _scenario;
private string _scenarioId = string.Empty;
public DefenseRecommendationTests()
{
_testDir = Path.Combine(Path.GetTempPath(), $"cd_rec_{Guid.NewGuid():N}");
_paths = new TestPathProvider(_testDir);
var dbm = new DatabaseManager(_paths);
_db = dbm.OpenMainDb();
var planner = new DefensePlanner(
DefaultData.Load(_paths).Ammunition,
PlannerConfig.Load(_paths));
_scenario = new ScenarioService(
new ScenarioRepository(_db), new CombatSceneRepository(_db),
new ControlZoneRepository(_db), new ScenarioDroneRepository(_db),
new ScenarioUnitRepository(_db), new CloudDispersalRepository(_db),
new RoutePlanRepository(_db), new WaypointRepository(_db),
_paths, planner);
}
public void Dispose()
{
_db?.Close();
if (Directory.Exists(_testDir)) Directory.Delete(_testDir, true);
}
[Fact]
public void GetDefenseRecommendation_PresetScenario_ReturnsBest()
{
// 用预设想定
var results = _scenario.SearchScenarios("活塞", null, null, 1, 10);
Assert.True(results.TotalCount > 0, "预设想定未找到");
_scenarioId = results.Items[0].Id;
var rec = _scenario.GetDefenseRecommendation(_scenarioId);
Assert.True(rec.Success, $"推荐失败: {rec.Summary}");
Assert.True(rec.Best.SalvoRounds > 0, "最佳方案弹药数应 > 0");
Assert.True(rec.Best.EstimatedProbability > 0, "最佳方案概率应 > 0");
Assert.NotEmpty(rec.Summary);
}
[Fact]
public void GetDefenseRecommendation_NoFireUnits_ReturnsFailure()
{
// 创建无火力单元的想定
var sc = _scenario.CreateScenario("无防御测试", "");
_scenarioId = sc.Id;
_scenario.SaveScene(_scenarioId, new CombatScene { WindSpeed = 0, Visibility = 10000 });
_scenario.SaveScenarioDrone(_scenarioId, TestData.CreateScenarioDrone("default", 1));
_scenario.SaveRoute(_scenarioId, "default", new RoutePlan { FormationMode = (int)FormationMode.Single },
new List<Waypoint> {
new() { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 },
new() { PosX = 10000, PosY = 500, PosZ = 0, Speed = 200 },
});
_scenario.SaveCloudDispersal(_scenarioId, new CloudDispersal { AerosolType = (int)AerosolType.InertGas });
var rec = _scenario.GetDefenseRecommendation(_scenarioId);
Assert.False(rec.Success);
Assert.Contains("火力单元", rec.Summary);
}
[Fact]
public void GetDefenseRecommendation_NoPlanner_Throws()
{
// 不传 planner 的 ScenarioService
var svcNoPlanner = new ScenarioService(
new ScenarioRepository(_db), new CombatSceneRepository(_db),
new ControlZoneRepository(_db), new ScenarioDroneRepository(_db),
new ScenarioUnitRepository(_db), new CloudDispersalRepository(_db),
new RoutePlanRepository(_db), new WaypointRepository(_db));
Assert.Throws<InvalidOperationException>(() => svcNoPlanner.GetDefenseRecommendation("any"));
}
}
}

Binary file not shown.