Files
FlyShotHost/tests/Flyshot.Core.Tests/FanucControllerRuntimeDenseTests.cs
yunxiao.zhu 783716ff44 feat(fanuc): 改为按状态包驱动 J519 队列发送
* 预生成稠密轨迹 J519 命令队列,等待机器人状态包逐帧出队
* 让 ExecuteTrajectory 在队列实际取完后返回,避免后台发送提前结束
* 新增 ActualSendTiming.txt,区分实发时间与 speed_ratio 采样时间
* 补充 J519 队列、等待完成和实发时间映射相关单元测试
* 同步文档中的 t_send / t_traj / speed_ratio 说明

Co-authored-by: Copilot <copilot@github.com>
2026-05-06 12:57:56 +08:00

988 lines
41 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.Domain;
using Flyshot.ControllerClientCompat;
using Flyshot.Core.Config;
using Flyshot.Runtime.Fanuc;
using Flyshot.Runtime.Fanuc.Protocol;
using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
namespace Flyshot.Core.Tests;
/// <summary>
/// 验证 FANUC 控制器运行在稠密轨迹流式执行与 IO 触发上的行为。
/// </summary>
public sealed class FanucControllerRuntimeDenseTests
{
private const double SmoothPtpVelocityShapeCoefficient = 2.1875;
private const double SmoothPtpAccelerationShapeCoefficient = 7.513188404399293;
private const double SmoothPtpJerkShapeCoefficient = 52.5;
/// <summary>
/// 验证真机 J519 会预生成按 8ms 轨迹映射的命令队列,并输出角度制目标。
/// </summary>
[Fact]
public void ExecuteTrajectory_WithDenseWaypoints_RealMode_ResamplesBySpeedRatioAndConvertsRadiansToDegrees()
{
using var commandClient = new FanucCommandClient();
using var stateClient = new FanucStateClient();
using var j519Client = new FanucJ519Client();
using var runtime = new FanucControllerRuntime(commandClient, stateClient, j519Client);
var robot = TestRobotFactory.CreateRobotProfile();
runtime.ResetRobot(robot, "FANUC_LR_Mate_200iD");
j519Client.EnableCommandHistoryForTests();
ForceRealModeEnabled(runtime, speedRatio: 0.5);
var denseTrajectory = new[]
{
new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
new[] { 0.008, Math.PI / 2.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
new[] { 0.016, Math.PI, 0.0, 0.0, 0.0, 0.0, 0.0 }
};
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, [Math.PI, 0.0, 0.0, 0.0, 0.0, 0.0]);
WaitUntilIdle(runtime);
var commands = j519Client.GetCommandHistoryForTests();
Assert.Equal(5, commands.Count);
Assert.All(commands, static command => Assert.Equal(0u, command.Sequence));
Assert.Equal([0.0, 45.0, 90.0, 135.0, 180.0], commands.Select(static command => command.TargetJoints[0]));
Assert.False(j519Client.IsCommandQueueDrainedForTests());
}
/// <summary>
/// 验证真机稠密轨迹接口会等待 J519 队列被状态包实际取完后再返回。
/// </summary>
[Fact]
public async Task ExecuteTrajectory_WithDenseWaypoints_RealMode_WaitsForJ519QueueToDrainBeforeReturning()
{
using var server = new UdpClient(0);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var commandClient = new FanucCommandClient();
using var stateClient = new FanucStateClient();
using var j519Client = new FanucJ519Client();
using var runtime = new FanucControllerRuntime(commandClient, stateClient, j519Client);
var robot = TestRobotFactory.CreateRobotProfile();
var port = ((IPEndPoint)server.Client.LocalEndPoint!).Port;
runtime.ResetRobot(robot, "FANUC_LR_Mate_200iD");
j519Client.EnableCommandHistoryForTests();
await j519Client.ConnectAsync("127.0.0.1", port, cts.Token);
var initResult = await server.ReceiveAsync(cts.Token);
j519Client.StartMotion();
ForceRealModeEnabled(runtime, speedRatio: 1.0);
var denseTrajectory = new[]
{
new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
new[] { 0.008, Math.PI / 2.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
new[] { 0.016, Math.PI, 0.0, 0.0, 0.0, 0.0, 0.0 }
};
var result = new TrajectoryResult(
programName: "wait-drain",
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);
var executeTask = Task.Run(() => runtime.ExecuteTrajectory(result, [Math.PI, 0.0, 0.0, 0.0, 0.0, 0.0]), cts.Token);
await WaitUntilAsync(() => j519Client.GetCommandHistoryForTests().Count == 3, cts.Token);
// 只有机器人状态包把队列全部取出后ExecuteTrajectory 才能向上层返回。
Assert.False(executeTask.IsCompleted);
for (uint sequence = 100; sequence < 103; sequence++)
{
await SendStatusPacketAsync(server, initResult.RemoteEndPoint, sequence, cts.Token);
_ = await server.ReceiveAsync(cts.Token);
}
await executeTask.WaitAsync(TimeSpan.FromSeconds(2), cts.Token);
Assert.True(j519Client.IsCommandQueueDrainedForTests());
Assert.False(runtime.GetSnapshot().IsInMotion);
}
/// <summary>
/// 验证真机稠密发送会把实际下发给机器人的整条点位与点间跃度按时间目录落盘。
/// </summary>
[Fact]
public void ExecuteTrajectory_WithDenseWaypoints_RealMode_WritesActualSentPointsAndJerkStats()
{
using var commandClient = new FanucCommandClient();
using var stateClient = new FanucStateClient();
using var j519Client = new FanucJ519Client();
using var runtime = new FanucControllerRuntime(commandClient, stateClient, j519Client);
var robot = TestRobotFactory.CreateRobotProfile();
var programName = $"dense-send-{Guid.NewGuid():N}";
var outputRoot = Path.Combine(AppContext.BaseDirectory, "Config", "Data", programName);
try
{
runtime.ResetRobot(robot, "FANUC_LR_Mate_200iD");
j519Client.EnableCommandHistoryForTests();
ForceRealModeEnabled(runtime, speedRatio: 0.5);
var denseTrajectory = new[]
{
new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
new[] { 0.008, Math.PI / 2.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
new[] { 0.016, Math.PI, 0.0, 0.0, 0.0, 0.0, 0.0 }
};
var result = new TrajectoryResult(
programName: programName,
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, [Math.PI, 0.0, 0.0, 0.0, 0.0, 0.0]);
WaitUntilIdle(runtime);
var commands = j519Client.GetCommandHistoryForTests();
var runDir = GetSingleDenseSendRunDirectory(outputRoot);
var pointsPath = Path.Combine(runDir, "ActualSendJointTraj.txt");
var timingPath = Path.Combine(runDir, "ActualSendTiming.txt");
var jerkPath = Path.Combine(runDir, "ActualSendJerkStats.txt");
Assert.True(File.Exists(pointsPath));
Assert.True(File.Exists(timingPath));
Assert.True(File.Exists(jerkPath));
var pointLines = File.ReadAllLines(pointsPath);
var timingLines = File.ReadAllLines(timingPath);
var jerkLines = File.ReadAllLines(jerkPath);
Assert.Equal(commands.Count, pointLines.Length);
Assert.Equal(commands.Count, timingLines.Length);
Assert.Equal(Math.Max(0, commands.Count - 1), jerkLines.Length);
var firstColumns = ParseColumns(pointLines[0]);
var secondColumns = ParseColumns(pointLines[1]);
var lastColumns = ParseColumns(pointLines[^1]);
Assert.Equal(9, firstColumns.Length);
Assert.Equal(9, lastColumns.Length);
Assert.Equal(0.0, firstColumns[0], precision: 6);
Assert.Equal(0.008, secondColumns[0], precision: 6);
Assert.Equal(180.0, lastColumns[1], precision: 6);
var firstTimingColumns = ParseColumns(timingLines[0]);
var secondTimingColumns = ParseColumns(timingLines[1]);
var lastTimingColumns = ParseColumns(timingLines[^1]);
Assert.Equal(4, firstTimingColumns.Length);
Assert.Equal(0.0, firstTimingColumns[0], precision: 6);
Assert.Equal(0.0, firstTimingColumns[1], precision: 6);
Assert.Equal(0.0, firstTimingColumns[2], precision: 6);
Assert.Equal(0.5, firstTimingColumns[3], precision: 6);
Assert.Equal(1.0, secondTimingColumns[0], precision: 6);
Assert.Equal(0.008, secondTimingColumns[1], precision: 6);
Assert.Equal(0.004, secondTimingColumns[2], precision: 6);
Assert.Equal(commands.Count - 1, lastTimingColumns[0], precision: 6);
Assert.Equal(0.016, lastTimingColumns[2], precision: 6);
var firstJerkColumns = ParseColumns(jerkLines[0]);
Assert.Equal(10, firstJerkColumns.Length);
Assert.Equal(0.0, firstJerkColumns[0], precision: 6);
Assert.Equal(0.008, firstJerkColumns[2], precision: 6);
}
finally
{
if (Directory.Exists(outputRoot))
{
Directory.Delete(outputRoot, recursive: true);
}
}
}
/// <summary>
/// 验证 MoveJoint 会按抓包确认的点到点临时轨迹生成稠密 J519 目标,并继续叠加 speed_ratio 重采样。
/// </summary>
[Theory]
[InlineData(1.0)]
[InlineData(0.7)]
[InlineData(0.5)]
public void MoveJoint_RealMode_GeneratesTemporaryPtpTrajectoryAndResamplesBySpeedRatio(double speedRatio)
{
using var commandClient = new FanucCommandClient();
using var stateClient = new FanucStateClient();
using var j519Client = new FanucJ519Client();
using var runtime = new FanucControllerRuntime(commandClient, stateClient, j519Client);
var service = CreateCompatService(runtime);
var startJoints = new[]
{
1.056731,
0.011664811,
-0.017892333,
-0.01516874,
0.021492079,
0.009567846
};
var targetJoints = new[]
{
0.8532358,
0.03837953,
-0.19235304,
0.0071595116,
0.109054826,
0.040055145
};
service.SetUpRobot("FANUC_LR_Mate_200iD");
j519Client.EnableCommandHistoryForTests();
ForceRealModeEnabled(runtime, speedRatio);
SetPrivateField(runtime, "_jointPositions", startJoints);
var options = new ControllerClientCompatOptions
{
ConfigRoot = TestRobotFactory.GetConfigRoot()
};
var robot = new ControllerClientCompatRobotCatalog(options, new RobotModelLoader())
.LoadProfile("FANUC_LR_Mate_200iD", accLimitScale: 1.0, jerkLimitScale: 1.0);
var expectedResult = MoveJointTrajectoryGenerator.CreateResult(robot, startJoints, targetJoints, speedRatio);
service.MoveJoint(targetJoints);
WaitUntilIdle(runtime);
var commands = j519Client.GetCommandHistoryForTests();
Assert.Equal(expectedResult.DenseJointTrajectory!.Count, commands.Count);
AssertJointDegreesEqual(startJoints, commands[0].TargetJoints);
AssertJointDegreesEqual(targetJoints, commands[^1].TargetJoints);
var middleAlpha = ComputeLineAlpha(commands[commands.Count / 2].TargetJoints, startJoints, targetJoints);
Assert.InRange(middleAlpha, 0.45, 0.55);
var earlyAlpha = ComputeLineAlpha(commands[Math.Min(5, commands.Count - 1)].TargetJoints, startJoints, targetJoints);
Assert.InRange(earlyAlpha, 0.0, 0.02);
}
[Fact]
public void MoveJointTrajectoryGenerator_LowerSpeedUsesMoreSamplesWithoutFixedCountContract()
{
var robot = CreateMoveJointReferenceRobotProfile();
var startJoints = new[] { 1.056731, 0.011664811, -0.017892333, -0.01516874, 0.021492079, 0.009567846 };
var targetJoints = new[] { 0.8532358, 0.03837953, -0.19235304, 0.0071595116, 0.109054826, 0.040055145 };
var fullSpeed = MoveJointTrajectoryGenerator.CreateResult(robot, startJoints, targetJoints, speedRatio: 1.0);
var speed07 = MoveJointTrajectoryGenerator.CreateResult(robot, startJoints, targetJoints, speedRatio: 0.7);
var speed05 = MoveJointTrajectoryGenerator.CreateResult(robot, startJoints, targetJoints, speedRatio: 0.5);
Assert.True(speed07.DenseJointTrajectory!.Count > fullSpeed.DenseJointTrajectory!.Count);
Assert.True(speed05.DenseJointTrajectory!.Count > speed07.DenseJointTrajectory!.Count);
Assert.True(fullSpeed.Duration.TotalSeconds >= ExpectedSmoothPtpDuration(robot, startJoints, targetJoints));
Assert.True(speed07.Duration.TotalSeconds >= ExpectedSmoothPtpDuration(robot, startJoints, targetJoints));
Assert.True(speed05.Duration.TotalSeconds >= ExpectedSmoothPtpDuration(robot, startJoints, targetJoints));
}
[Fact]
public void MoveJoint_RealMode_LeavesFinalTargetForHoldStreaming()
{
using var commandClient = new FanucCommandClient();
using var stateClient = new FanucStateClient();
using var j519Client = new FanucJ519Client();
using var runtime = new FanucControllerRuntime(commandClient, stateClient, j519Client);
var service = CreateCompatService(runtime);
var startJoints = new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 };
var targetJoints = new[] { 0.2, -0.1, 0.05, 0.0, 0.0, 0.1 };
service.SetUpRobot("FANUC_LR_Mate_200iD");
ForceRealModeEnabled(runtime, speedRatio: 1.0);
SetPrivateField(runtime, "_jointPositions", startJoints);
service.MoveJoint(targetJoints);
WaitUntilIdle(runtime);
var currentCommand = j519Client.GetCurrentCommand();
Assert.NotNull(currentCommand);
AssertJointDegreesEqual(targetJoints, currentCommand.TargetJoints);
}
/// <summary>
/// 验证 speed_ratio=0 时不会启动无法推进轨迹时间的后台发送任务。
/// </summary>
[Fact]
public void ExecuteTrajectory_WithDenseWaypoints_RealMode_RejectsZeroSpeedRatio()
{
using var commandClient = new FanucCommandClient();
using var stateClient = new FanucStateClient();
using var j519Client = new FanucJ519Client();
using var runtime = new FanucControllerRuntime(commandClient, stateClient, j519Client);
var robot = TestRobotFactory.CreateRobotProfile();
runtime.ResetRobot(robot, "FANUC_LR_Mate_200iD");
ForceRealModeEnabled(runtime, speedRatio: 0.0);
var denseTrajectory = new[]
{
new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
new[] { 0.016, Math.PI, 0.0, 0.0, 0.0, 0.0, 0.0 }
};
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);
var exception = Assert.Throws<InvalidOperationException>(
() => runtime.ExecuteTrajectory(result, [Math.PI, 0.0, 0.0, 0.0, 0.0, 0.0]));
Assert.Contains("Speed ratio", exception.Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 验证真机模式下若 J519 响应明确显示伺服侧未就绪,则拒绝启动稠密轨迹发送。
/// </summary>
[Fact]
public void ExecuteTrajectory_WithDenseWaypoints_RealMode_RejectsNotReadyJ519Status()
{
using var commandClient = new FanucCommandClient();
using var stateClient = new FanucStateClient();
using var j519Client = new FanucJ519Client();
using var runtime = new FanucControllerRuntime(commandClient, stateClient, j519Client);
var robot = TestRobotFactory.CreateRobotProfile();
runtime.ResetRobot(robot, "FANUC_LR_Mate_200iD");
ForceRealModeEnabled(runtime, speedRatio: 1.0);
SetLatestJ519Response(j519Client, status: 0b0011);
var result = CreateDenseResult(
[
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.008, Math.PI / 2.0, 0.0, 0.0, 0.0, 0.0, 0.0]
],
durationSeconds: 0.008);
var exception = Assert.Throws<InvalidOperationException>(
() => runtime.ExecuteTrajectory(result, [Math.PI / 2.0, 0.0, 0.0, 0.0, 0.0, 0.0]));
Assert.Contains("J519 status is not ready", exception.Message);
Assert.Contains("sysrdy=False", exception.Message);
}
/// <summary>
/// 验证控制器快照暴露最近一次 J519 响应中的四个状态位,便于状态页和诊断接口显示。
/// </summary>
[Fact]
public void GetSnapshot_RealMode_IncludesLatestJ519StatusBits()
{
using var commandClient = new FanucCommandClient();
using var stateClient = new FanucStateClient();
using var j519Client = new FanucJ519Client();
using var runtime = new FanucControllerRuntime(commandClient, stateClient, j519Client);
var robot = TestRobotFactory.CreateRobotProfile();
runtime.ResetRobot(robot, "FANUC_LR_Mate_200iD");
ForceRealModeEnabled(runtime, speedRatio: 1.0);
SetLatestJ519Response(j519Client, status: 0b0111);
var snapshot = runtime.GetSnapshot();
Assert.Equal((byte)0b0111, snapshot.J519Status);
Assert.Equal(10u, snapshot.J519Sequence);
Assert.True(snapshot.J519AcceptsCommand);
Assert.True(snapshot.J519ReceivedCommand);
Assert.True(snapshot.J519SystemReady);
Assert.False(snapshot.J519RobotInMotion);
}
/// <summary>
/// 验证飞拍 IO 脉冲按轨迹时间轴嵌入 J519 命令,并在保持周期后用同一 mask 清零。
/// </summary>
[Fact]
public void ExecuteTrajectory_WithDenseWaypoints_RealMode_EmbedsIoPulseOnTrajectoryTimeline()
{
using var commandClient = new FanucCommandClient();
using var stateClient = new FanucStateClient();
using var j519Client = new FanucJ519Client();
using var runtime = new FanucControllerRuntime(commandClient, stateClient, j519Client);
var robot = TestRobotFactory.CreateRobotProfile();
runtime.ResetRobot(robot, "FANUC_LR_Mate_200iD");
j519Client.EnableCommandHistoryForTests();
ForceRealModeEnabled(runtime, speedRatio: 1.0);
var denseTrajectory = new[]
{
new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
new[] { 0.024, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }
};
var result = new TrajectoryResult(
programName: "demo",
method: PlanningMethod.Icsp,
isValid: true,
duration: TimeSpan.FromSeconds(0.024),
shotEvents: Array.Empty<ShotEvent>(),
triggerTimeline:
[
new TrajectoryDoEvent(
waypointIndex: 1,
triggerTime: 0.008,
offsetCycles: 0,
holdCycles: 2,
addressGroup: new IoAddressGroup([1, 3]))
],
artifacts: Array.Empty<TrajectoryArtifact>(),
failureReason: null,
usedCache: false,
originalWaypointCount: 4,
plannedWaypointCount: 4,
denseJointTrajectory: denseTrajectory);
runtime.ExecuteTrajectory(result, [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
WaitUntilIdle(runtime);
var commands = j519Client.GetCommandHistoryForTests();
Assert.Equal(4, commands.Count);
Assert.Equal([(ushort)0, (ushort)10, (ushort)10, (ushort)10], commands.Select(static command => command.WriteIoMask));
Assert.Equal([(ushort)0, (ushort)10, (ushort)10, (ushort)0], commands.Select(static command => command.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>
/// 验证 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 地址组中的地址号被正确映射为 writeIoValue 的位掩码。
/// </summary>
[Theory]
[InlineData(new[] { 0 }, (ushort)1)]
[InlineData(new[] { 7 }, (ushort)128)]
[InlineData(new[] { 7, 8 }, (ushort)384)] // 128 + 256
[InlineData(new[] { 15 }, (ushort)32768)]
[InlineData(new int[] { }, (ushort)0)]
public void ComputeIoValue_MapsAddressesToBitMask(int[] addresses, ushort expected)
{
var group = new IoAddressGroup(addresses);
var actual = FanucControllerRuntime.ComputeIoValue(group);
Assert.Equal(expected, actual);
}
/// <summary>
/// 验证超过 15 的地址号会被安全忽略,不会溢出位掩码。
/// </summary>
[Fact]
public void ComputeIoValue_IgnoresOutOfRangeAddresses()
{
var group = new IoAddressGroup([0, 16, 7]);
var actual = FanucControllerRuntime.ComputeIoValue(group);
Assert.Equal((ushort)(1 | 128), actual);
}
[Fact]
public void MoveJointTrajectoryGenerator_GeneratesSmoothJerkLimitedPtpTrajectoryAtSpeedOne()
{
var robot = CreateMoveJointReferenceRobotProfile();
var startJoints = new[]
{
DegreesToRadians(60.546227),
DegreesToRadians(0.668344),
DegreesToRadians(-1.025155),
DegreesToRadians(-0.869105),
DegreesToRadians(1.231405),
DegreesToRadians(0.548197)
};
var targetJoints = new[]
{
DegreesToRadians(48.886810),
DegreesToRadians(2.198985),
DegreesToRadians(-11.021017),
DegreesToRadians(0.410210),
DegreesToRadians(6.248381),
DegreesToRadians(2.294991)
};
var result = MoveJointTrajectoryGenerator.CreateResult(robot, startJoints, targetJoints, speedRatio: 1.0);
var rows = result.DenseJointTrajectory!;
Assert.True(rows.Count > 41, $"平滑 MoveJoint 不应再固定输出 41 点actual={rows.Count}。");
Assert.True(result.Duration.TotalSeconds >= ExpectedSmoothPtpDuration(robot, startJoints, targetJoints));
AssertJointDegreesEqual(startJoints, rows[0].Skip(1).Select(RadiansToDegrees).ToArray());
AssertJointDegreesEqual(targetJoints, rows[^1].Skip(1).Select(RadiansToDegrees).ToArray());
AssertAllJointJerksWithinLimits(rows, robot);
}
/// <summary>
/// 验证报警样本中的 MoveJoint 预移动作不会因为点到点时间律重采样而产生任一轴跃度尖峰。
/// </summary>
[Fact]
public void MoveJointTrajectoryGenerator_ActualAlarmSampleKeepsJointOneJerkWithinLimit()
{
var robot = CreateMoveJointRuntimeLimitRobotProfile();
var startJoints = new[]
{
DegreesToRadians(71.454618),
DegreesToRadians(0.688433),
DegreesToRadians(-1.074197),
DegreesToRadians(-0.869001),
DegreesToRadians(1.218057),
DegreesToRadians(0.547036)
};
var targetJoints = new[]
{
DegreesToRadians(60.546227),
DegreesToRadians(0.668344),
DegreesToRadians(-1.025155),
DegreesToRadians(-0.869105),
DegreesToRadians(1.231405),
DegreesToRadians(0.548197)
};
var result = MoveJointTrajectoryGenerator.CreateResult(robot, startJoints, targetJoints, speedRatio: 1.0);
var rows = result.DenseJointTrajectory!;
AssertAllJointJerksWithinLimits(rows, robot);
}
[Fact]
public void MoveJointTrajectoryGenerator_DoesNotShortenLimitDurationWhenSpeedRatioDoesNotDivideWindow()
{
var robot = CreateMoveJointReferenceRobotProfile();
var startJoints = new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 };
var targetJoints = new[] { 0.05, 0.0, 0.0, 0.0, 0.0, 0.0 };
var result = MoveJointTrajectoryGenerator.CreateResult(robot, startJoints, targetJoints, speedRatio: 0.7);
var rows = result.DenseJointTrajectory!;
Assert.True(
result.Duration.TotalSeconds >= ExpectedSmoothPtpDuration(robot, startJoints, targetJoints),
$"Duration was shortened to {result.Duration.TotalSeconds:F6}s.");
AssertJointDegreesEqual(startJoints, rows[0].Skip(1).Select(RadiansToDegrees).ToArray());
AssertJointDegreesEqual(targetJoints, rows[^1].Skip(1).Select(RadiansToDegrees).ToArray());
}
[Fact]
public void MoveJointTrajectoryGenerator_RejectsUnrepresentableSampleCountForTinySpeedRatio()
{
var robot = CreateMoveJointReferenceRobotProfile();
var startJoints = new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 };
var targetJoints = new[] { 0.05, 0.0, 0.0, 0.0, 0.0, 0.0 };
var exception = Assert.Throws<InvalidOperationException>(
() => MoveJointTrajectoryGenerator.CreateResult(robot, startJoints, targetJoints, speedRatio: 1e-12));
Assert.Contains("sample count", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void MoveJointTrajectoryGenerator_StretchesLongMoveFromJointLimitsInsteadOfKeepingFortyCycles()
{
var robot = CreateMoveJointReferenceRobotProfile();
var startJoints = new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 };
var targetJoints = new[] { Math.PI, 0.0, 0.0, 0.0, 0.0, 0.0 };
var result = MoveJointTrajectoryGenerator.CreateResult(robot, startJoints, targetJoints, speedRatio: 1.0);
var rows = result.DenseJointTrajectory!;
var expectedVelocityDuration = Math.PI * SmoothPtpVelocityShapeCoefficient / robot.JointLimits[0].VelocityLimit;
var expectedAccelerationDuration = Math.Sqrt(Math.PI * SmoothPtpAccelerationShapeCoefficient / robot.JointLimits[0].AccelerationLimit);
var expectedJerkDuration = Math.Cbrt(Math.PI * SmoothPtpJerkShapeCoefficient / robot.JointLimits[0].JerkLimit);
var expectedMinimumDuration = new[]
{
expectedVelocityDuration,
expectedAccelerationDuration,
expectedJerkDuration
}.Max();
var expectedCountFromDuration = (int)Math.Floor(result.Duration.TotalSeconds / robot.ServoPeriod.TotalSeconds + 1e-9) + 1;
Assert.True(rows.Count > 41, $"Expected long MoveJoint to produce more than 41 points, got {rows.Count}.");
Assert.True(
result.Duration.TotalSeconds >= expectedMinimumDuration,
$"Expected duration >= {expectedMinimumDuration:F6}s from joint limits, got {result.Duration.TotalSeconds:F6}s.");
Assert.Equal(expectedCountFromDuration, rows.Count);
AssertJointDegreesEqual(startJoints, rows[0].Skip(1).Select(RadiansToDegrees).ToArray());
AssertJointDegreesEqual(targetJoints, rows[^1].Skip(1).Select(RadiansToDegrees).ToArray());
}
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);
SetPrivateField(runtime, "_speedRatio", speedRatio);
}
private static ControllerClientCompatService CreateCompatService(FanucControllerRuntime runtime)
{
var options = new ControllerClientCompatOptions
{
ConfigRoot = TestRobotFactory.GetConfigRoot()
};
return new ControllerClientCompatService(
options,
new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()),
runtime,
new ControllerClientTrajectoryOrchestrator(),
new RobotConfigLoader());
}
private static double ComputeLineAlpha(
IReadOnlyList<double> actualDegrees,
IReadOnlyList<double> startRadians,
IReadOnlyList<double> targetRadians)
{
var numerator = 0.0;
var denominator = 0.0;
for (var index = 0; index < startRadians.Count; index++)
{
var startDegrees = RadiansToDegrees(startRadians[index]);
var deltaDegrees = RadiansToDegrees(targetRadians[index]) - startDegrees;
numerator += (actualDegrees[index] - startDegrees) * deltaDegrees;
denominator += deltaDegrees * deltaDegrees;
}
return denominator <= 0.0 ? 0.0 : numerator / denominator;
}
private static void AssertJointDegreesEqual(IReadOnlyList<double> expectedRadians, IReadOnlyList<double> actualDegrees)
{
Assert.Equal(expectedRadians.Count, actualDegrees.Count);
for (var index = 0; index < expectedRadians.Count; index++)
{
Assert.Equal(RadiansToDegrees(expectedRadians[index]), actualDegrees[index], precision: 4);
}
}
private static RobotProfile CreateMoveJointReferenceRobotProfile()
{
return new RobotProfile(
name: "FANUC_LR_Mate_200iD",
modelPath: "Models/FANUC_LR_Mate_200iD.robot",
degreesOfFreedom: 6,
jointLimits:
[
new JointLimit("J1", 7.85, 32.72, 272.7),
new JointLimit("J2", 6.63, 27.63, 230.28),
new JointLimit("J3", 9.07, 37.81, 315.12),
new JointLimit("J4", 9.59, 39.99, 333.3),
new JointLimit("J5", 9.51, 39.63, 330.27),
new JointLimit("J6", 17.45, 72.72, 606.01)
],
jointCouplings: Array.Empty<JointCoupling>(),
servoPeriod: TimeSpan.FromMilliseconds(8),
triggerPeriod: TimeSpan.FromMilliseconds(8));
}
/// <summary>
/// 创建当前运行配置下的 MoveJoint 限值模型,覆盖报警样本使用的 acc_limit/jerk_limit=0.74。
/// </summary>
private static RobotProfile CreateMoveJointRuntimeLimitRobotProfile()
{
const double limitScale = 0.74;
return new RobotProfile(
name: "FANUC_LR_Mate_200iD",
modelPath: "Models/FANUC_LR_Mate_200iD.robot",
degreesOfFreedom: 6,
jointLimits:
[
new JointLimit("J1", 6.45, 26.90 * limitScale, 224.22 * limitScale),
new JointLimit("J2", 5.41, 22.54 * limitScale, 187.86 * limitScale),
new JointLimit("J3", 7.15, 29.81 * limitScale, 248.46 * limitScale),
new JointLimit("J4", 9.59, 39.99 * limitScale, 333.30 * limitScale),
new JointLimit("J5", 9.51, 39.63 * limitScale, 330.27 * limitScale),
new JointLimit("J6", 17.45, 72.72 * limitScale, 606.01 * limitScale)
],
jointCouplings: Array.Empty<JointCoupling>(),
servoPeriod: TimeSpan.FromMilliseconds(8),
triggerPeriod: TimeSpan.FromMilliseconds(8));
}
/// <summary>
/// 以发送周期上的二阶后向差分估算单轴最大绝对跃度,用来复现 ActualSendJerkStats 的判定口径。
/// </summary>
private static double MaxAbsJointJerk(IReadOnlyList<IReadOnlyList<double>> rows, int jointIndex)
{
double? previousTime = null;
double? previousPosition = null;
double? previousVelocity = null;
double? previousAcceleration = null;
var maxAbsJerk = 0.0;
foreach (var row in rows)
{
var currentTime = row[0];
var currentPosition = row[jointIndex + 1];
if (previousTime is not null && previousPosition is not null)
{
var dt = currentTime - previousTime.Value;
Assert.True(dt > 0.0, "MoveJoint 稠密轨迹时间戳必须严格递增。");
var velocity = (currentPosition - previousPosition.Value) / dt;
var acceleration = previousVelocity is null ? 0.0 : (velocity - previousVelocity.Value) / dt;
if (previousAcceleration is not null)
{
var jerk = (acceleration - previousAcceleration.Value) / dt;
maxAbsJerk = Math.Max(maxAbsJerk, Math.Abs(jerk));
}
previousVelocity = velocity;
previousAcceleration = acceleration;
}
previousTime = currentTime;
previousPosition = currentPosition;
}
return maxAbsJerk;
}
/// <summary>
/// 验证稠密 MoveJoint 轨迹按 8ms 差分后不会超过机器人每轴 jerk 限值。
/// </summary>
private static void AssertAllJointJerksWithinLimits(IReadOnlyList<IReadOnlyList<double>> rows, RobotProfile robot)
{
for (var jointIndex = 0; jointIndex < robot.DegreesOfFreedom; jointIndex++)
{
var peakJerk = MaxAbsJointJerk(rows, jointIndex);
var limit = robot.JointLimits[jointIndex].JerkLimit;
Assert.True(
peakJerk <= limit * 1.000001,
$"J{jointIndex + 1} jerk {peakJerk:F6} rad/s^3 exceeds limit {limit:F6} rad/s^3.");
}
}
/// <summary>
/// 按 7 阶平滑点到点时间律的速度、加速度、jerk 形状系数计算理论最小时长。
/// </summary>
private static double ExpectedSmoothPtpDuration(
RobotProfile robot,
IReadOnlyList<double> startJoints,
IReadOnlyList<double> targetJoints)
{
var duration = 0.0;
for (var index = 0; index < robot.DegreesOfFreedom; index++)
{
var distance = Math.Abs(targetJoints[index] - startJoints[index]);
if (distance <= 0.0)
{
continue;
}
var limit = robot.JointLimits[index];
var velocityDuration = distance * SmoothPtpVelocityShapeCoefficient / limit.VelocityLimit;
var accelerationDuration = Math.Sqrt(distance * SmoothPtpAccelerationShapeCoefficient / limit.AccelerationLimit);
var jerkDuration = Math.Cbrt(distance * SmoothPtpJerkShapeCoefficient / limit.JerkLimit);
duration = Math.Max(duration, Math.Max(velocityDuration, Math.Max(accelerationDuration, jerkDuration)));
}
return duration;
}
private static double DegreesToRadians(double degrees)
{
return degrees * Math.PI / 180.0;
}
private static double RadiansToDegrees(double radians)
{
return radians * 180.0 / Math.PI;
}
private static void WaitUntilIdle(FanucControllerRuntime runtime)
{
var deadline = DateTimeOffset.UtcNow.AddSeconds(1);
while (DateTimeOffset.UtcNow < deadline)
{
if (!runtime.GetSnapshot().IsInMotion)
{
return;
}
Thread.Sleep(1);
}
throw new TimeoutException("Timed out waiting for dense trajectory send task to finish.");
}
/// <summary>
/// 等待测试条件成立,用于观察后台执行路径是否已经到达指定状态。
/// </summary>
private static async Task WaitUntilAsync(Func<bool> condition, CancellationToken cancellationToken)
{
var deadline = DateTimeOffset.UtcNow.AddSeconds(1);
while (DateTimeOffset.UtcNow < deadline)
{
cancellationToken.ThrowIfCancellationRequested();
if (condition())
{
return;
}
await Task.Delay(1, cancellationToken);
}
throw new TimeoutException("Timed out waiting for test condition.");
}
/// <summary>
/// 向被测 J519 客户端发送一帧最小状态包,用机器人侧 status sequence 驱动下一帧命令。
/// </summary>
private static async Task SendStatusPacketAsync(
UdpClient server,
IPEndPoint clientEndpoint,
uint sequence,
CancellationToken cancellationToken)
{
var responsePacket = new byte[FanucJ519Protocol.ResponsePacketLength];
BinaryPrimitives.WriteUInt32BigEndian(responsePacket.AsSpan(0x00, 4), 0);
BinaryPrimitives.WriteUInt32BigEndian(responsePacket.AsSpan(0x04, 4), 1);
BinaryPrimitives.WriteUInt32BigEndian(responsePacket.AsSpan(0x08, 4), sequence);
responsePacket[0x0c] = 0b0111;
await server.SendAsync(responsePacket, clientEndpoint, cancellationToken);
}
/// <summary>
/// 获取一次测试执行生成的唯一稠密发送记录目录。
/// </summary>
private static string GetSingleDenseSendRunDirectory(string outputRoot)
{
var denseSendRoot = Path.Combine(outputRoot, "DenseSend");
Assert.True(Directory.Exists(denseSendRoot));
var runDirectories = Directory.GetDirectories(denseSendRoot);
Assert.Single(runDirectories);
return runDirectories[0];
}
/// <summary>
/// 解析空格分隔的纯文本数值列。
/// </summary>
private static double[] ParseColumns(string line)
{
return line
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(static value => double.Parse(value))
.ToArray();
}
private static void SetPrivateField<T>(FanucControllerRuntime runtime, string name, T value)
{
var field = typeof(FanucControllerRuntime).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(field);
field.SetValue(runtime, value);
}
private static TrajectoryResult CreateDenseResult(IReadOnlyList<IReadOnlyList<double>> denseTrajectory, double durationSeconds)
{
return new TrajectoryResult(
programName: "demo",
method: PlanningMethod.Icsp,
isValid: true,
duration: TimeSpan.FromSeconds(durationSeconds),
shotEvents: Array.Empty<ShotEvent>(),
triggerTimeline: Array.Empty<TrajectoryDoEvent>(),
artifacts: Array.Empty<TrajectoryArtifact>(),
failureReason: null,
usedCache: false,
originalWaypointCount: 4,
plannedWaypointCount: 4,
denseJointTrajectory: denseTrajectory);
}
private static void SetLatestJ519Response(FanucJ519Client client, byte status)
{
var response = new FanucJ519Response(
messageType: 0,
version: 1,
sequence: 10,
status: status,
readIoType: 2,
readIoIndex: 1,
readIoMask: 255,
readIoValue: 0,
timestamp: 1234,
pose: new double[6],
externalAxes: new double[3],
jointDegrees: new double[9],
motorCurrents: new double[9]);
var field = typeof(FanucJ519Client).GetField("_latestResponse", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(field);
field.SetValue(client, response);
}
}