Files
FlyShotHost/tests/Flyshot.Core.Tests/PlanningCompatibilityTests.cs
yunxiao.zhu 69fa3edd89 feat(runtime): 完善 FANUC 命令参数与状态通道重连
* 在 FanucCommandProtocol/Client 中补齐速度倍率、TCP 位姿和
  IO 的封包/解析,并引入 FanucIoTypes 字符串到枚举映射
* FanucControllerRuntime 在非仿真模式下接入真机命令通道,本地
  缓存仅作为兜底,TCP 操作扩展为 7 维 Pose
* FanucStateClient 增加帧超时检测、退避自动重连和诊断状态接口,
  超时或重连期间不再把陈旧帧当作当前机器人状态
* FanucStateProtocol 锁定 90B 帧字段为 pose[6]、joint[6]、
  external_axes[3] 和 raw_tail_words[4],并保留状态字诊断槽位
* ICspPlanner 增加 global_scale > 1.0 失败判定,self-adapt-icsp
  内部禁用该判定以保留补点重试链路
* 同步更新 README/AGENTS/计划文档的 todo 状态和实现说明
2026-04-27 00:18:50 +08:00

252 lines
9.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Flyshot.Core.Config;
using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
using Flyshot.Core.Triggering;
namespace Flyshot.Core.Tests;
/// <summary>
/// 锁定 Task 4 的最小兼容面,覆盖 ICSP、自适应补点以及飞拍触发时间轴。
/// </summary>
public sealed class PlanningCompatibilityTests
{
/// <summary>
/// 验证 ICSP 规划至少会生成严格递增的 waypoint 时间轴。
/// </summary>
[Fact]
public void ICspPlanner_ReturnsMonotonicWaypointTimes()
{
var request = new TrajectoryRequest(
robot: CreateRobotProfile([1, 1, 1, 1, 1, 1], [2, 2, 2, 2, 2, 2], [10, 10, 10, 10, 10, 10]),
program: CreateProgram(
new[]
{
new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
new[] { 0.4, 0.1, 0.0, 0.0, 0.0, 0.0 },
new[] { 0.8, 0.3, 0.0, 0.0, 0.0, 0.0 },
new[] { 1.0, 0.2, 0.0, 0.0, 0.0, 0.0 }
}),
method: PlanningMethod.Icsp);
var trajectory = new ICspPlanner().Plan(request);
Assert.Equal(4, trajectory.WaypointTimes.Count);
Assert.All(trajectory.WaypointTimes.Zip(trajectory.WaypointTimes.Skip(1)), pair => Assert.True(pair.Second > pair.First));
}
/// <summary>
/// 验证普通 ICSP 在最终最优解仍超限时会显式失败,而不是返回不可执行轨迹。
/// </summary>
[Fact]
public void ICspPlanner_Throws_WhenFinalGlobalScaleExceedsOne()
{
var request = new TrajectoryRequest(
robot: CreateRobotProfile([0.1], [0.1], [0.1]),
program: CreateProgram(
new[]
{
new[] { 0.0 },
new[] { 10.0 },
new[] { 20.0 },
new[] { 30.0 }
}),
method: PlanningMethod.Icsp);
var planner = new ICspPlanner(maxIterations: 0);
var exception = Assert.Throws<InvalidOperationException>(() => planner.Plan(request));
Assert.Contains("global_scale", exception.Message);
}
/// <summary>
/// 验证 speed09 风格的大跳变样本在 self-adapt-icsp 下会通过补中点收敛。
/// </summary>
[Fact]
public void SelfAdaptIcspPlanner_InsertsMidpoints_ForEol9Case()
{
var workspaceRoot = GetWorkspaceRoot();
var configPath = Path.Combine(workspaceRoot, "Rvbust", "EOL9 EAU 90", "eol9_eau_90.json");
var modelPath = Path.Combine(workspaceRoot, "FlyingShot", "FlyingShot", "Models", "LR_Mate_200iD_7L.robot");
var config = new RobotConfigLoader().Load(configPath);
var baseProfile = new RobotModelLoader().LoadProfile(modelPath, config.Robot.AccLimitScale, config.Robot.JerkLimitScale);
var constrainedProfile = ScaleRobotProfile(baseProfile, velocityScale: 0.9, accelerationScale: 0.9 * 0.9, jerkScale: 0.9 * 0.9 * 0.9);
var request = new TrajectoryRequest(
robot: constrainedProfile,
program: config.Programs["EOL9_EAU_90"],
method: PlanningMethod.SelfAdaptIcsp);
var trajectory = new SelfAdaptIcspPlanner().Plan(request, adaptIcspTryNum: config.Robot.AdaptIcspTryNum);
Assert.True(trajectory.InsertedWaypointCount > 0);
Assert.True(trajectory.PlannedWaypointCount > trajectory.OriginalWaypointCount);
Assert.True(trajectory.SegmentScales.Max() <= 1.0005);
}
/// <summary>
/// 验证补点后仍然能按原始示教点顺序找回时间戳,而不是错误地绑定到新增中点。
/// </summary>
[Fact]
public void WaypointTimestampResolver_UsesOriginalTeachPointsAfterInsertion()
{
var originalProgram = CreateProgram(
new[]
{
new[] { 0.0, 0.0 },
new[] { 1.0, 0.0 },
new[] { 2.0, 0.0 },
new[] { 3.0, 0.0 }
});
var trajectory = new PlannedTrajectory(
robot: CreateRobotProfile([1, 1], [1, 1], [1, 1]),
originalProgram: originalProgram,
plannedWaypoints:
[
new JointWaypoint([0.0, 0.0]),
new JointWaypoint([0.5, 0.0]),
new JointWaypoint([1.0, 0.0]),
new JointWaypoint([2.0, 0.0]),
new JointWaypoint([2.5, 0.0]),
new JointWaypoint([3.0, 0.0])
],
waypointTimes: [0.0, 0.25, 0.5, 1.0, 1.5, 2.0],
segmentDurations: [0.25, 0.25, 0.5, 0.5, 0.5],
segmentScales: [1.0, 1.0, 1.0, 1.0, 1.0],
method: PlanningMethod.SelfAdaptIcsp,
iterations: 3,
threshold: 0.0);
var timestamps = new WaypointTimestampResolver().Resolve(trajectory);
Assert.Equal([0.0, 0.5, 1.0, 2.0], timestamps);
}
/// <summary>
/// 验证触发时间轴会使用原始 waypoint 时间、offset 周期和地址组生成 ShotEvent/TrajectoryDoEvent。
/// </summary>
[Fact]
public void ShotTimelineBuilder_MapsOffsetsToShotEventsAndTriggerTimeline()
{
var robot = CreateRobotProfile([1, 1], [1, 1], [1, 1]);
var program = new FlyshotProgram(
name: "demo",
waypoints:
[
new JointWaypoint([0.0, 0.0]),
new JointWaypoint([1.0, 0.0]),
new JointWaypoint([2.0, 0.0])
],
shotFlags: [false, true, false],
offsetValues: [0, 1, 0],
addressGroups:
[
new IoAddressGroup(Array.Empty<int>()),
new IoAddressGroup([2, 4]),
new IoAddressGroup(Array.Empty<int>())
]);
var trajectory = new PlannedTrajectory(
robot: robot,
originalProgram: program,
plannedWaypoints: program.Waypoints,
waypointTimes: [0.0, 0.5, 1.0],
segmentDurations: [0.5, 0.5],
segmentScales: [1.0, 1.0],
method: PlanningMethod.Icsp,
iterations: 1,
threshold: 0.0);
var timeline = new ShotTimelineBuilder(new WaypointTimestampResolver())
.Build(trajectory, holdCycles: 2, samplePeriod: TimeSpan.FromMilliseconds(16));
var shotEvent = Assert.Single(timeline.ShotEvents);
Assert.Equal(0.508, shotEvent.TriggerTime, precision: 3);
Assert.Equal([2, 4], shotEvent.AddressGroup.Addresses);
var doEvent = Assert.Single(timeline.TriggerTimeline);
Assert.Equal(1, doEvent.WaypointIndex);
Assert.Equal(1, doEvent.OffsetCycles);
Assert.Equal(2, doEvent.HoldCycles);
}
/// <summary>
/// 构造一个最小 RobotProfile便于规划层单元测试聚焦在时间轴逻辑上。
/// </summary>
private static RobotProfile CreateRobotProfile(
IReadOnlyList<double> velocityLimits,
IReadOnlyList<double> accelerationLimits,
IReadOnlyList<double> jerkLimits)
{
return new RobotProfile(
name: "TestRobot",
modelPath: "Models/Test.robot",
degreesOfFreedom: velocityLimits.Count,
jointLimits: velocityLimits
.Select((velocity, index) => new JointLimit(
$"J{index + 1}",
velocity,
accelerationLimits[index],
jerkLimits[index]))
.ToArray(),
jointCouplings: Array.Empty<JointCoupling>(),
servoPeriod: TimeSpan.FromMilliseconds(8),
triggerPeriod: TimeSpan.FromMilliseconds(8));
}
/// <summary>
/// 构造一个默认不带拍照点的最小 FlyshotProgram。
/// </summary>
private static FlyshotProgram CreateProgram(IEnumerable<double[]> waypointRows)
{
var rows = waypointRows.ToArray();
return new FlyshotProgram(
name: "demo",
waypoints: rows.Select(row => new JointWaypoint(row)).ToArray(),
shotFlags: Enumerable.Repeat(false, rows.Length).ToArray(),
offsetValues: Enumerable.Repeat(0, rows.Length).ToArray(),
addressGroups: Enumerable.Range(0, rows.Length).Select(_ => new IoAddressGroup(Array.Empty<int>())).ToArray());
}
/// <summary>
/// 在不丢失 couple 元数据的前提下,按比例缩放机器人关节限制。
/// </summary>
private static RobotProfile ScaleRobotProfile(RobotProfile source, double velocityScale, double accelerationScale, double jerkScale)
{
return new RobotProfile(
name: source.Name,
modelPath: source.ModelPath,
degreesOfFreedom: source.DegreesOfFreedom,
jointLimits: source.JointLimits
.Select(limit => new JointLimit(
limit.JointName,
limit.VelocityLimit * velocityScale,
limit.AccelerationLimit * accelerationScale,
limit.JerkLimit * jerkScale))
.ToArray(),
jointCouplings: source.JointCouplings,
servoPeriod: source.ServoPeriod,
triggerPeriod: source.TriggerPeriod);
}
/// <summary>
/// 定位父工作区根目录,读取真实的 EOL9 样本和机器人模型。
/// </summary>
private static string GetWorkspaceRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "FlyshotReplacement.sln")))
{
return Path.GetFullPath(Path.Combine(current.FullName, ".."));
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Unable to locate the flyshot workspace root.");
}
}