✨ feat(planning): 添加离线飞拍规划能力

* 固化离线飞拍轨迹加载、保存和规划结果。

* 真机初始化失败时保留模型加载后的离线规划能力。

* 更新状态接口、调试页和回归测试覆盖。
This commit is contained in:
2026-06-24 11:40:07 +08:00
parent 070d2ba5c0
commit 5193f537b0
24 changed files with 1398 additions and 738 deletions

View File

@@ -900,47 +900,6 @@ public sealed class FanucControllerRuntimeDenseTests
Assert.Equal(0, commands[^1].WriteIoValue);
}
/// <summary>
/// 验证仿真模式下即使传入稠密路点,也会回退到单点同步更新。
/// </summary>
[Fact]
public void ExecuteTrajectory_WithDenseWaypoints_SimulationMode_FallsBackToSinglePoint()
{
var runtime = new FanucControllerRuntime();
var robot = TestRobotFactory.CreateRobotProfile();
runtime.ResetRobot(robot, "FANUC_LR_Mate_200iD");
runtime.SetActiveController(sim: true);
runtime.Connect("192.168.10.101");
runtime.EnableRobot(bufferSize: 2);
var denseTrajectory = new[]
{
new[] { 0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6 },
new[] { 0.008, 0.11, 0.21, 0.31, 0.41, 0.51, 0.61 },
new[] { 0.016, 0.12, 0.22, 0.32, 0.42, 0.52, 0.62 }
};
var result = new TrajectoryResult(
programName: "demo",
method: PlanningMethod.Icsp,
isValid: true,
duration: TimeSpan.FromSeconds(0.016),
shotEvents: Array.Empty<ShotEvent>(),
triggerTimeline: Array.Empty<TrajectoryDoEvent>(),
artifacts: Array.Empty<TrajectoryArtifact>(),
failureReason: null,
usedCache: false,
originalWaypointCount: 4,
plannedWaypointCount: 4,
denseJointTrajectory: denseTrajectory);
runtime.ExecuteTrajectory(result, [0.12, 0.22, 0.32, 0.42, 0.52, 0.62]);
var snapshot = runtime.GetSnapshot();
Assert.False(snapshot.IsInMotion);
Assert.Equal([0.12, 0.22, 0.32, 0.42, 0.52, 0.62], snapshot.JointPositions);
}
/// <summary>
/// 验证稠密执行前若 J519 首次未就绪,会先尝试一次 EnableRobot,再在 500ms 后复查状态。
/// </summary>
@@ -997,24 +956,6 @@ public sealed class FanucControllerRuntimeDenseTests
Assert.Contains("seq=22", exception.Message, StringComparison.Ordinal);
}
/// <summary>
/// 验证 StopMove 在没有任何后台发送任务运行时不会抛出异常。
/// </summary>
[Fact]
public void StopMove_DoesNotThrowWhenNoSendTaskRunning()
{
var runtime = new FanucControllerRuntime();
var robot = TestRobotFactory.CreateRobotProfile();
runtime.ResetRobot(robot, "FANUC_LR_Mate_200iD");
runtime.SetActiveController(sim: true);
runtime.Connect("192.168.10.101");
runtime.EnableRobot(bufferSize: 2);
var exception = Record.Exception(() => runtime.StopMove());
Assert.Null(exception);
Assert.False(runtime.GetSnapshot().IsInMotion);
}
/// <summary>
/// 验证 IO 地址组中的地址号按 writeIoIndex=1 的手册语义映射为 writeIoValue 位掩码。
/// </summary>
@@ -1171,7 +1112,6 @@ public sealed class FanucControllerRuntimeDenseTests
private static void ForceRealModeEnabled(FanucControllerRuntime runtime, double speedRatio)
{
SetPrivateField(runtime, "_activeControllerIsSimulation", false);
SetPrivateField(runtime, "_connectedRobotIp", "127.0.0.1");
SetPrivateField(runtime, "_isEnabled", true);
SetPrivateField(runtime, "_bufferSize", 2);

View File

@@ -16,40 +16,6 @@ namespace Flyshot.Core.Tests;
/// </summary>
public sealed class RuntimeOrchestrationTests
{
/// <summary>
/// 验证 FANUC 最小运行时执行轨迹后会更新状态快照与最终关节位置。
/// </summary>
[Fact]
public void FanucControllerRuntime_ExecuteTrajectory_UpdatesSnapshotAndFinalJointPositions()
{
var runtime = new FanucControllerRuntime();
var robot = TestRobotFactory.CreateRobotProfile();
runtime.ResetRobot(robot, "FANUC_LR_Mate_200iD");
runtime.SetActiveController(sim: true);
runtime.Connect("192.168.10.101");
runtime.EnableRobot(bufferSize: 2);
var result = new TrajectoryResult(
programName: "demo",
method: PlanningMethod.Icsp,
isValid: true,
duration: TimeSpan.FromSeconds(1.2),
shotEvents: Array.Empty<ShotEvent>(),
triggerTimeline: Array.Empty<TrajectoryDoEvent>(),
artifacts: Array.Empty<TrajectoryArtifact>(),
failureReason: null,
usedCache: false,
originalWaypointCount: 4,
plannedWaypointCount: 4);
runtime.ExecuteTrajectory(result, [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
var snapshot = runtime.GetSnapshot();
Assert.Equal("Connected", snapshot.ConnectionState);
Assert.False(snapshot.IsInMotion);
Assert.Equal([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], snapshot.JointPositions);
}
/// <summary>
/// 验证真机运行时会把 TCP 10010 状态通道健康度映射为可诊断连接状态。
/// </summary>
@@ -761,7 +727,6 @@ public sealed class RuntimeOrchestrationTests
{
var service = TestRobotFactory.CreateCompatService();
service.SetUpRobot("FANUC_LR_Mate_200iD");
service.SetActiveController(sim: true);
service.Connect("192.168.10.101");
service.EnableRobot(2);
@@ -803,7 +768,6 @@ public sealed class RuntimeOrchestrationTests
};
service.SetUpRobot("FANUC_LR_Mate_200iD");
service.SetActiveController(sim: false);
service.Connect("192.168.10.101");
service.EnableRobot(2);
runtime.SetSpeedRatio(0.5);
@@ -849,7 +813,6 @@ public sealed class RuntimeOrchestrationTests
new RobotConfigLoader());
service.SetUpRobot("FANUC_LR_Mate_200iD");
service.SetActiveController(sim: false);
service.Connect("192.168.10.101");
service.EnableRobot(2);
service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot());
@@ -894,7 +857,6 @@ public sealed class RuntimeOrchestrationTests
new RobotConfigLoader());
service.SetUpRobot("FANUC_LR_Mate_200iD");
service.SetActiveController(sim: false);
service.Connect("192.168.10.101");
service.EnableRobot(2);
service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot());
@@ -938,7 +900,6 @@ public sealed class RuntimeOrchestrationTests
new RobotConfigLoader());
service.SetUpRobot("FANUC_LR_Mate_200iD");
service.SetActiveController(sim: false);
service.Connect("192.168.10.101");
service.EnableRobot(2);
service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot());
@@ -979,7 +940,6 @@ public sealed class RuntimeOrchestrationTests
new RobotConfigLoader());
service.SetUpRobot("FANUC_LR_Mate_200iD");
service.SetActiveController(sim: false);
service.Connect("192.168.10.101");
service.EnableRobot(2);
service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot());
@@ -1030,7 +990,6 @@ public sealed class RuntimeOrchestrationTests
new RobotConfigLoader());
service.SetUpRobot("FANUC_LR_Mate_200iD");
service.SetActiveController(sim: false);
service.Connect("192.168.10.101");
service.EnableRobot(2);
service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot());
@@ -1081,7 +1040,6 @@ public sealed class RuntimeOrchestrationTests
new RobotConfigLoader());
service.SetUpRobot("FANUC_LR_Mate_200iD");
service.SetActiveController(sim: false);
service.Connect("192.168.10.101");
service.EnableRobot(2);
service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot());
@@ -1240,8 +1198,8 @@ public sealed class RuntimeOrchestrationTests
$"ActualSend 应按当前 speedRatio 的执行视图导出,不能再用旧 JointDetialTraj 规划时长约束点数。actual={pointRows.Length}, planningRows={planningExportRowsAtServoPeriod}");
Assert.Equal(0.0, pointRows[0][0], precision: 6);
Assert.Equal(0.008, pointRows[1][0], precision: 6);
Assert.Equal(0.008, timingRows[1][2], precision: 6);
Assert.Equal(1.0, timingRows[1][3], precision: 6);
Assert.Equal(0.004, timingRows[1][2], precision: 6);
Assert.Equal(0.5, timingRows[1][3], precision: 6);
Assert.Equal(0.5, timingRows[1][4], precision: 6);
Assert.Equal(0.0, timingRows[1][5], precision: 6);
Assert.Contains("\"trigger_window_seconds\": 0.1", shotEventsJson);
@@ -1371,6 +1329,142 @@ public sealed class RuntimeOrchestrationTests
return configRoot;
}
/// <summary>
/// 验证未加载机器人模型时仍可上传、列出和删除飞拍资产。
/// </summary>
[Fact]
public void ControllerClientCompatService_ManagesFlyshotAssetsWithoutRobotSetup()
{
var configRoot = CreateTempConfigRoot();
try
{
var options = new ControllerClientCompatOptions { ConfigRoot = configRoot };
var service = new ControllerClientCompatService(
options,
new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()),
new RecordingControllerRuntime(),
new ControllerClientTrajectoryOrchestrator(),
new RobotConfigLoader());
service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot());
Assert.Contains("demo-flyshot", service.ListTrajectoryNames());
Assert.Contains(
"\"demo-flyshot\"",
File.ReadAllText(Path.Combine(configRoot, "RobotConfig.json")),
StringComparison.Ordinal);
service.DeleteTrajectory("demo-flyshot");
Assert.DoesNotContain("demo-flyshot", service.ListTrajectoryNames());
}
finally
{
Directory.Delete(configRoot, recursive: true);
}
}
/// <summary>
/// 验证重新加载机器人模型不会清空已经上传的飞拍资产。
/// </summary>
[Fact]
public void ControllerClientCompatService_SetUpRobot_PreservesUploadedFlyshotAssets()
{
var configRoot = CreateTempConfigRoot();
try
{
var options = new ControllerClientCompatOptions { ConfigRoot = configRoot };
var service = new ControllerClientCompatService(
options,
new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()),
new RecordingControllerRuntime(),
new ControllerClientTrajectoryOrchestrator(),
new RobotConfigLoader());
service.UploadTrajectory(TestRobotFactory.CreateUploadedTrajectoryWithSingleShot());
service.SetUpRobot("FANUC_LR_Mate_200iD");
Assert.Contains("demo-flyshot", service.ListTrajectoryNames());
Assert.Equal("OfflineReady", service.GetStatusSnapshotMetadata().OperatingMode);
Assert.True(service.GetStatusSnapshotMetadata().CanPlanOffline);
Assert.False(service.GetStatusSnapshotMetadata().CanExecuteRealMotion);
}
finally
{
Directory.Delete(configRoot, recursive: true);
}
}
/// <summary>
/// 验证离线状态可以生成完整最终发送点列,并返回导出文件绝对路径。
/// </summary>
[Fact]
public void ControllerClientCompatService_SaveTrajectoryInfo_ReturnsOfflinePlanningResult()
{
var configRoot = CreateTempConfigRoot();
WriteRobotConfigWithDemoTrajectory(configRoot, planningSpeedScale: 0.45, speedRatio: 1.0);
try
{
var options = new ControllerClientCompatOptions { ConfigRoot = configRoot };
var service = new ControllerClientCompatService(
options,
new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()),
new RecordingControllerRuntime(),
new ControllerClientTrajectoryOrchestrator(),
new RobotConfigLoader());
service.SetUpRobot("FANUC_LR_Mate_200iD");
var result = service.SaveTrajectoryInfo("demo-flyshot");
Assert.True(result.IsValid);
Assert.Equal("demo-flyshot", result.TrajectoryName);
Assert.Equal(1.0, result.SpeedRatio, precision: 6);
Assert.NotEmpty(result.FinalSendPoints);
Assert.NotEmpty(result.ShotEvents);
Assert.NotEmpty(result.CartesianTrajectory);
Assert.True(result.Diagnostics.MaxAbsVelocityDegreesPerSecond > 0.0);
Assert.All(result.ArtifactPaths.Values, path => Assert.True(Path.IsPathFullyQualified(path)));
}
finally
{
Directory.Delete(configRoot, recursive: true);
}
}
/// <summary>
/// 验证模型加载成功但真机连接失败时,状态降级为 OfflineReady 并保留离线规划能力。
/// </summary>
[Fact]
public void ControllerClientCompatService_InitializeRobot_ConnectionFailureKeepsOfflineReady()
{
var configRoot = CreateTempConfigRoot();
try
{
var options = new ControllerClientCompatOptions { ConfigRoot = configRoot };
var runtime = new RecordingControllerRuntime { ThrowOnConnect = true };
var service = new ControllerClientCompatService(
options,
new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()),
runtime,
new ControllerClientTrajectoryOrchestrator(),
new RobotConfigLoader());
Assert.Throws<InvalidOperationException>(
() => service.InitializeRobot("FANUC_LR_Mate_200iD", "192.168.10.101"));
var metadata = service.GetStatusSnapshotMetadata();
Assert.Equal("OfflineReady", metadata.OperatingMode);
Assert.True(metadata.CanPlanOffline);
Assert.False(metadata.CanExecuteRealMotion);
Assert.NotNull(metadata.LastConnectionError);
Assert.NotNull(metadata.LastConnectionErrorAt);
}
finally
{
Directory.Delete(configRoot, recursive: true);
}
}
/// <summary>
/// 写入包含一条飞拍轨迹的最小 RobotConfig.json,供兼容服务从统一配置恢复轨迹。
/// </summary>
@@ -1952,7 +2046,7 @@ internal static class TestRobotFactory
return new ControllerClientCompatService(
options,
new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()),
new FanucControllerRuntime(),
new RecordingControllerRuntime(),
new ControllerClientTrajectoryOrchestrator(),
new RobotConfigLoader());
}
@@ -2024,6 +2118,11 @@ internal sealed class RecordingControllerRuntime : IControllerRuntime
/// </summary>
public TrajectoryResult? LastExecutedResult { get; private set; }
/// <summary>
/// 获取或设置连接时是否抛出测试异常。
/// </summary>
public bool ThrowOnConnect { get; set; }
/// <inheritdoc />
public void ResetRobot(RobotProfile robot, string robotName)
{
@@ -2038,6 +2137,10 @@ internal sealed class RecordingControllerRuntime : IControllerRuntime
/// <inheritdoc />
public void Connect(string robotIp)
{
if (ThrowOnConnect)
{
throw new InvalidOperationException("测试连接失败。");
}
}
/// <inheritdoc />