* 划分 J519 发送循环与稠密轨迹循环职责边界, FanucJ519Client 负责 UDP 周期发送, FanucControllerRuntime 按轨迹时间更新下一帧命令 * 执行时将规划输出 rad 转为 J519 deg 目标, 并按 speed_ratio 调整 8ms 发送时间尺度 * 补齐 accept_cmd/received_cmd/sysrdy/rbt_inmotion 状态位解析与启动前闭环检查 * MoveJoint 改为关节空间直线 + smoothstep 进度 的临时 PTP 稠密轨迹,按 status=15 运动窗口复现 * 新增 UTTC 2026-04-28 三份抓包 golden tests, 覆盖 0.5/0.7/1.0 speed_ratio 下的 J519 命令、 IO 脉冲与响应滞后 * 状态通道补充超时重连策略与退避逻辑 * TCP 10012 命令响应统一检查 result_code * 状态页扩展 J519 状态位与快照诊断信息 * 新增 docs/fanuc-field-runtime-workflow.md 现场工作流 * 补充 LR Mate 200iD 模型、RobotConfig.json 与 workpiece
527 lines
19 KiB
C#
527 lines
19 KiB
C#
using System.Buffers.Binary;
|
|
using System.Globalization;
|
|
using Flyshot.Runtime.Fanuc.Protocol;
|
|
|
|
namespace Flyshot.Core.Tests;
|
|
|
|
/// <summary>
|
|
/// 使用 2026-04-28 UTTC 真实抓包验证 J519 主运行点位与 JointDetialTraj 重采样规则一致。
|
|
/// </summary>
|
|
public sealed class UttcJ519GoldenTests
|
|
{
|
|
private const int JointCount = 6;
|
|
private const int RobotJ519Port = 60015;
|
|
private const double ServoPeriodSeconds = 0.008;
|
|
|
|
public static IEnumerable<object[]> SpeedSweepCases()
|
|
{
|
|
yield return ["2026042802-0.5.pcap", 0.5, 1851, 14.800309];
|
|
yield return ["2026042802-0.7.pcap", 0.7, 1322, 10.568313];
|
|
yield return ["2026042802-1.pcap", 1.0, 926, 7.400125];
|
|
}
|
|
|
|
/// <summary>
|
|
/// 验证 speed=0.5/0.7/1.0 三份真实抓包都符合当前运行时采用的发送点生成规则。
|
|
/// </summary>
|
|
[Theory]
|
|
[MemberData(nameof(SpeedSweepCases))]
|
|
public void CapturedJ519Run_ReplaysJointDetailTrajectoryWithSpeedRatio(
|
|
string pcapFileName,
|
|
double speedRatio,
|
|
int expectedPointCount,
|
|
double expectedSendDurationSeconds)
|
|
{
|
|
var repositoryRoot = FindRepositoryRoot();
|
|
var pcapPath = Path.Combine(repositoryRoot, "Rvbust", "uttc-20260428", pcapFileName);
|
|
var jointDetailPath = Path.Combine(repositoryRoot, "Rvbust", "uttc-20260428", "Data", "JointDetialTraj.txt");
|
|
|
|
var packets = ParsePcapUdp(pcapPath);
|
|
var hostPort = DetectHostJ519Port(packets);
|
|
var commands = ParseJ519Commands(packets, hostPort);
|
|
var responses = ParseJ519Responses(packets, hostPort);
|
|
var responseSegment = LongestStatusSegment(responses, status: 15);
|
|
var jointRows = ReadJointDetail(jointDetailPath);
|
|
var expected = GenerateExpectedPoints(jointRows, speedRatio);
|
|
|
|
var commandBySequence = new Dictionary<uint, CapturedJ519Command>();
|
|
var duplicateSequenceCount = 0;
|
|
foreach (var command in commands)
|
|
{
|
|
if (!commandBySequence.TryAdd(command.Sequence, command))
|
|
{
|
|
duplicateSequenceCount++;
|
|
}
|
|
}
|
|
|
|
var (startSequence, windowRms) = FindBestCommandWindow(commandBySequence, expected, responseSegment, searchRadius: 32);
|
|
var actualRun = Enumerable
|
|
.Range(0, expected.Length)
|
|
.Select(index => commandBySequence[startSequence + (uint)index])
|
|
.ToArray();
|
|
var comparison = Compare(actualRun, expected);
|
|
|
|
Assert.Equal(464, jointRows.Length);
|
|
Assert.Equal(expectedPointCount, expected.Length);
|
|
Assert.Equal(expectedPointCount, actualRun.Length);
|
|
Assert.Equal(0, duplicateSequenceCount);
|
|
Assert.Equal(17, comparison.IoSetPulses);
|
|
Assert.Equal(17, comparison.IoClearFrames);
|
|
Assert.Equal(
|
|
new ushort[] { 10, 12, 14 },
|
|
actualRun
|
|
.Where(static command => command.WriteIoMask != 0)
|
|
.Select(static command => command.WriteIoMask)
|
|
.Distinct()
|
|
.Order()
|
|
.ToArray());
|
|
Assert.True(responseSegment.Length >= expectedPointCount - 1, $"status=15 segment too short: {responseSegment.Length}");
|
|
Assert.InRange((long)responseSegment[0].Sequence - startSequence, 2, 8);
|
|
Assert.All(actualRun, static command => Assert.Equal(0, command.LastData));
|
|
for (var index = 0; index < actualRun.Length; index++)
|
|
{
|
|
Assert.Equal(startSequence + (uint)index, actualRun[index].Sequence);
|
|
}
|
|
|
|
Assert.True(windowRms < 0.012, $"J519 global RMS {windowRms:F9} deg exceeds tolerance.");
|
|
Assert.True(comparison.GlobalMaxAbsDeg < 0.07, $"J519 max abs diff {comparison.GlobalMaxAbsDeg:F9} deg exceeds tolerance.");
|
|
|
|
var sendDuration = actualRun[^1].TimeRelativeSeconds - actualRun[0].TimeRelativeSeconds;
|
|
Assert.InRange(sendDuration, expectedSendDurationSeconds - 0.04, expectedSendDurationSeconds + 0.04);
|
|
}
|
|
|
|
private static string FindRepositoryRoot()
|
|
{
|
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (directory is not null)
|
|
{
|
|
var jointDetailPath = Path.Combine(directory.FullName, "Rvbust", "uttc-20260428", "Data", "JointDetialTraj.txt");
|
|
if (File.Exists(jointDetailPath))
|
|
{
|
|
return directory.FullName;
|
|
}
|
|
|
|
directory = directory.Parent;
|
|
}
|
|
|
|
throw new DirectoryNotFoundException("Cannot locate repository root containing Rvbust/uttc-20260428/Data/JointDetialTraj.txt.");
|
|
}
|
|
|
|
private static IReadOnlyList<UdpPacket> ParsePcapUdp(string path)
|
|
{
|
|
using var stream = File.OpenRead(path);
|
|
var header = new byte[24];
|
|
stream.ReadExactly(header);
|
|
|
|
var magic = header.AsSpan(0, 4);
|
|
var bigEndian = false;
|
|
var timestampScale = 1_000_000.0;
|
|
if (magic.SequenceEqual(new byte[] { 0xd4, 0xc3, 0xb2, 0xa1 }))
|
|
{
|
|
bigEndian = false;
|
|
}
|
|
else if (magic.SequenceEqual(new byte[] { 0xa1, 0xb2, 0xc3, 0xd4 }))
|
|
{
|
|
bigEndian = true;
|
|
}
|
|
else if (magic.SequenceEqual(new byte[] { 0x4d, 0x3c, 0xb2, 0xa1 }))
|
|
{
|
|
bigEndian = false;
|
|
timestampScale = 1_000_000_000.0;
|
|
}
|
|
else if (magic.SequenceEqual(new byte[] { 0xa1, 0xb2, 0x3c, 0x4d }))
|
|
{
|
|
bigEndian = true;
|
|
timestampScale = 1_000_000_000.0;
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidDataException($"Unsupported pcap magic: {Convert.ToHexString(header.AsSpan(0, 4))}");
|
|
}
|
|
|
|
var linkType = ReadUInt32(header.AsSpan(20, 4), bigEndian);
|
|
if (linkType != 1)
|
|
{
|
|
throw new InvalidDataException($"Only Ethernet pcap is supported, got linktype {linkType}.");
|
|
}
|
|
|
|
var packets = new List<UdpPacket>();
|
|
var recordHeader = new byte[16];
|
|
double? firstTimestamp = null;
|
|
var frameNumber = 0;
|
|
while (ReadFullOrEnd(stream, recordHeader))
|
|
{
|
|
frameNumber++;
|
|
var tsSec = ReadUInt32(recordHeader.AsSpan(0, 4), bigEndian);
|
|
var tsFraction = ReadUInt32(recordHeader.AsSpan(4, 4), bigEndian);
|
|
var includedLength = ReadUInt32(recordHeader.AsSpan(8, 4), bigEndian);
|
|
var packet = new byte[includedLength];
|
|
stream.ReadExactly(packet);
|
|
|
|
var timestamp = tsSec + (tsFraction / timestampScale);
|
|
firstTimestamp ??= timestamp;
|
|
var udp = ParseEthernetIpv4Udp(packet, frameNumber, timestamp - firstTimestamp.Value);
|
|
if (udp is not null)
|
|
{
|
|
packets.Add(udp);
|
|
}
|
|
}
|
|
|
|
return packets;
|
|
}
|
|
|
|
private static bool ReadFullOrEnd(Stream stream, byte[] buffer)
|
|
{
|
|
var offset = 0;
|
|
while (offset < buffer.Length)
|
|
{
|
|
var read = stream.Read(buffer, offset, buffer.Length - offset);
|
|
if (read == 0)
|
|
{
|
|
if (offset == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
throw new EndOfStreamException("Truncated pcap record header.");
|
|
}
|
|
|
|
offset += read;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static UdpPacket? ParseEthernetIpv4Udp(byte[] packet, int frameNumber, double timeRelativeSeconds)
|
|
{
|
|
if (packet.Length < 14)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var offset = 14;
|
|
var etherType = BinaryPrimitives.ReadUInt16BigEndian(packet.AsSpan(12, 2));
|
|
if (etherType == 0x8100)
|
|
{
|
|
if (packet.Length < 18)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
etherType = BinaryPrimitives.ReadUInt16BigEndian(packet.AsSpan(16, 2));
|
|
offset = 18;
|
|
}
|
|
|
|
if (etherType != 0x0800 || packet.Length < offset + 20)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var versionIhl = packet[offset];
|
|
var version = versionIhl >> 4;
|
|
var ihl = (versionIhl & 0x0f) * 4;
|
|
if (version != 4 || ihl < 20 || packet.Length < offset + ihl)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var protocol = packet[offset + 9];
|
|
if (protocol != 17)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var totalLength = BinaryPrimitives.ReadUInt16BigEndian(packet.AsSpan(offset + 2, 2));
|
|
var udpOffset = offset + ihl;
|
|
if (packet.Length < udpOffset + 8)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var sourcePort = BinaryPrimitives.ReadUInt16BigEndian(packet.AsSpan(udpOffset, 2));
|
|
var destinationPort = BinaryPrimitives.ReadUInt16BigEndian(packet.AsSpan(udpOffset + 2, 2));
|
|
var udpLength = BinaryPrimitives.ReadUInt16BigEndian(packet.AsSpan(udpOffset + 4, 2));
|
|
var payloadOffset = udpOffset + 8;
|
|
var payloadLength = Math.Max(0, Math.Min(udpLength - 8, totalLength - ihl - 8));
|
|
if (packet.Length < payloadOffset + payloadLength)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new UdpPacket(
|
|
frameNumber,
|
|
timeRelativeSeconds,
|
|
sourcePort,
|
|
destinationPort,
|
|
packet.AsSpan(payloadOffset, payloadLength).ToArray());
|
|
}
|
|
|
|
private static ushort DetectHostJ519Port(IEnumerable<UdpPacket> packets)
|
|
{
|
|
return packets
|
|
.Where(static packet => packet.DestinationPort == RobotJ519Port && packet.Payload.Length == FanucJ519Protocol.CommandPacketLength)
|
|
.GroupBy(static packet => packet.SourcePort)
|
|
.OrderByDescending(static group => group.Count())
|
|
.Select(static group => group.Key)
|
|
.First();
|
|
}
|
|
|
|
private static CapturedJ519Command[] ParseJ519Commands(IEnumerable<UdpPacket> packets, ushort hostPort)
|
|
{
|
|
return packets
|
|
.Where(packet =>
|
|
packet.SourcePort == hostPort
|
|
&& packet.DestinationPort == RobotJ519Port
|
|
&& packet.Payload.Length == FanucJ519Protocol.CommandPacketLength)
|
|
.Select(ParseCommand)
|
|
.Where(static command => command is not null)
|
|
.Cast<CapturedJ519Command>()
|
|
.ToArray();
|
|
}
|
|
|
|
private static CapturedJ519Command? ParseCommand(UdpPacket packet)
|
|
{
|
|
var payload = packet.Payload;
|
|
var messageType = BinaryPrimitives.ReadUInt32BigEndian(payload.AsSpan(0x00, 4));
|
|
var version = BinaryPrimitives.ReadUInt32BigEndian(payload.AsSpan(0x04, 4));
|
|
if (messageType != 1 || version != 1)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var targets = new double[9];
|
|
for (var index = 0; index < targets.Length; index++)
|
|
{
|
|
targets[index] = BinaryPrimitives.ReadSingleBigEndian(payload.AsSpan(0x1c + (index * 4), 4));
|
|
}
|
|
|
|
return new CapturedJ519Command(
|
|
packet.FrameNumber,
|
|
packet.TimeRelativeSeconds,
|
|
BinaryPrimitives.ReadUInt32BigEndian(payload.AsSpan(0x08, 4)),
|
|
payload[0x0c],
|
|
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(0x16, 2)),
|
|
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(0x18, 2)),
|
|
targets);
|
|
}
|
|
|
|
private static FanucJ519Response[] ParseJ519Responses(IEnumerable<UdpPacket> packets, ushort hostPort)
|
|
{
|
|
return packets
|
|
.Where(packet =>
|
|
packet.SourcePort == RobotJ519Port
|
|
&& packet.DestinationPort == hostPort
|
|
&& packet.Payload.Length == FanucJ519Protocol.ResponsePacketLength)
|
|
.Select(packet => FanucJ519Protocol.ParseResponse(packet.Payload))
|
|
.ToArray();
|
|
}
|
|
|
|
private static FanucJ519Response[] LongestStatusSegment(IEnumerable<FanucJ519Response> responses, byte status)
|
|
{
|
|
var best = new List<FanucJ519Response>();
|
|
var current = new List<FanucJ519Response>();
|
|
foreach (var response in responses)
|
|
{
|
|
if (response.Status == status)
|
|
{
|
|
current.Add(response);
|
|
continue;
|
|
}
|
|
|
|
if (current.Count > best.Count)
|
|
{
|
|
best = current;
|
|
}
|
|
|
|
current = [];
|
|
}
|
|
|
|
return (current.Count > best.Count ? current : best).ToArray();
|
|
}
|
|
|
|
private static JointRow[] ReadJointDetail(string path)
|
|
{
|
|
return File.ReadLines(path)
|
|
.Where(static line => !string.IsNullOrWhiteSpace(line) && !line.TrimStart().StartsWith('#'))
|
|
.Select(static line =>
|
|
{
|
|
var values = line
|
|
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
|
|
.Select(value => double.Parse(value, CultureInfo.InvariantCulture))
|
|
.ToArray();
|
|
return new JointRow(values[0], values.Skip(1).Take(JointCount).ToArray());
|
|
})
|
|
.ToArray();
|
|
}
|
|
|
|
private static ExpectedPoint[] GenerateExpectedPoints(IReadOnlyList<JointRow> rows, double speedRatio)
|
|
{
|
|
var durationSeconds = rows[^1].TimeSeconds;
|
|
var trajectoryStepSeconds = ServoPeriodSeconds * speedRatio;
|
|
var sampleCount = (int)Math.Floor((durationSeconds / trajectoryStepSeconds) + 1e-9) + 1;
|
|
var points = new ExpectedPoint[sampleCount];
|
|
var segmentIndex = 0;
|
|
|
|
for (var index = 0; index < sampleCount; index++)
|
|
{
|
|
var trajectoryTime = Math.Min(index * trajectoryStepSeconds, durationSeconds);
|
|
var jointsRad = Interpolate(rows, trajectoryTime, ref segmentIndex);
|
|
points[index] = new ExpectedPoint(
|
|
index,
|
|
trajectoryTime,
|
|
jointsRad.Select(static value => value * 180.0 / Math.PI).ToArray());
|
|
}
|
|
|
|
return points;
|
|
}
|
|
|
|
private static double[] Interpolate(IReadOnlyList<JointRow> rows, double trajectoryTime, ref int segmentIndex)
|
|
{
|
|
if (rows.Count == 1 || trajectoryTime <= rows[0].TimeSeconds)
|
|
{
|
|
return rows[0].JointsRad.ToArray();
|
|
}
|
|
|
|
var lastIndex = rows.Count - 1;
|
|
if (trajectoryTime >= rows[lastIndex].TimeSeconds)
|
|
{
|
|
return rows[lastIndex].JointsRad.ToArray();
|
|
}
|
|
|
|
while (segmentIndex < lastIndex - 1 && rows[segmentIndex + 1].TimeSeconds < trajectoryTime)
|
|
{
|
|
segmentIndex++;
|
|
}
|
|
|
|
var start = rows[segmentIndex];
|
|
var end = rows[segmentIndex + 1];
|
|
var duration = end.TimeSeconds - start.TimeSeconds;
|
|
var alpha = duration <= 0.0 ? 0.0 : (trajectoryTime - start.TimeSeconds) / duration;
|
|
var joints = new double[JointCount];
|
|
for (var index = 0; index < joints.Length; index++)
|
|
{
|
|
joints[index] = start.JointsRad[index] + ((end.JointsRad[index] - start.JointsRad[index]) * alpha);
|
|
}
|
|
|
|
return joints;
|
|
}
|
|
|
|
private static (uint StartSequence, double RmsDeg) FindBestCommandWindow(
|
|
IReadOnlyDictionary<uint, CapturedJ519Command> commandBySequence,
|
|
IReadOnlyList<ExpectedPoint> expected,
|
|
IReadOnlyList<FanucJ519Response> responseSegment,
|
|
int searchRadius)
|
|
{
|
|
if (responseSegment.Count == 0)
|
|
{
|
|
throw new InvalidDataException("No status=15 response segment found.");
|
|
}
|
|
|
|
var responseStartSequence = (long)responseSegment[0].Sequence;
|
|
uint? bestStartSequence = null;
|
|
var bestRms = double.PositiveInfinity;
|
|
|
|
for (var startSequence = responseStartSequence - searchRadius; startSequence <= responseStartSequence + searchRadius; startSequence++)
|
|
{
|
|
if (startSequence < 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var differences = new List<double>(expected.Count * JointCount);
|
|
var completeWindow = true;
|
|
for (var index = 0; index < expected.Count; index++)
|
|
{
|
|
var sequence = (uint)(startSequence + index);
|
|
if (!commandBySequence.TryGetValue(sequence, out var command))
|
|
{
|
|
completeWindow = false;
|
|
break;
|
|
}
|
|
|
|
for (var joint = 0; joint < JointCount; joint++)
|
|
{
|
|
differences.Add(command.TargetDegrees[joint] - expected[index].JointsDeg[joint]);
|
|
}
|
|
}
|
|
|
|
if (!completeWindow)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var rms = Rms(differences);
|
|
if (rms < bestRms)
|
|
{
|
|
bestRms = rms;
|
|
bestStartSequence = (uint)startSequence;
|
|
}
|
|
}
|
|
|
|
if (bestStartSequence is null)
|
|
{
|
|
throw new InvalidDataException("No complete command window found near the status=15 response segment.");
|
|
}
|
|
|
|
return (bestStartSequence.Value, bestRms);
|
|
}
|
|
|
|
private static ComparisonSummary Compare(IReadOnlyList<CapturedJ519Command> actual, IReadOnlyList<ExpectedPoint> expected)
|
|
{
|
|
var differences = new List<double>(actual.Count * JointCount);
|
|
var maxAbs = 0.0;
|
|
for (var index = 0; index < actual.Count; index++)
|
|
{
|
|
for (var joint = 0; joint < JointCount; joint++)
|
|
{
|
|
var difference = actual[index].TargetDegrees[joint] - expected[index].JointsDeg[joint];
|
|
differences.Add(difference);
|
|
maxAbs = Math.Max(maxAbs, Math.Abs(difference));
|
|
}
|
|
}
|
|
|
|
var ioSetPulses = actual.Count(command => command.WriteIoMask != 0 && command.WriteIoValue != 0);
|
|
var ioClearFrames = actual.Count(command => command.WriteIoMask != 0 && command.WriteIoValue == 0);
|
|
return new ComparisonSummary(Rms(differences), maxAbs, ioSetPulses, ioClearFrames);
|
|
}
|
|
|
|
private static double Rms(IEnumerable<double> values)
|
|
{
|
|
var sum = 0.0;
|
|
var count = 0;
|
|
foreach (var value in values)
|
|
{
|
|
sum += value * value;
|
|
count++;
|
|
}
|
|
|
|
return count == 0 ? 0.0 : Math.Sqrt(sum / count);
|
|
}
|
|
|
|
private static uint ReadUInt32(ReadOnlySpan<byte> value, bool bigEndian)
|
|
{
|
|
return bigEndian ? BinaryPrimitives.ReadUInt32BigEndian(value) : BinaryPrimitives.ReadUInt32LittleEndian(value);
|
|
}
|
|
|
|
private sealed record UdpPacket(
|
|
int FrameNumber,
|
|
double TimeRelativeSeconds,
|
|
ushort SourcePort,
|
|
ushort DestinationPort,
|
|
byte[] Payload);
|
|
|
|
private sealed record CapturedJ519Command(
|
|
int FrameNumber,
|
|
double TimeRelativeSeconds,
|
|
uint Sequence,
|
|
byte LastData,
|
|
ushort WriteIoMask,
|
|
ushort WriteIoValue,
|
|
IReadOnlyList<double> TargetDegrees);
|
|
|
|
private sealed record JointRow(double TimeSeconds, IReadOnlyList<double> JointsRad);
|
|
|
|
private sealed record ExpectedPoint(int Index, double TrajectoryTimeSeconds, IReadOnlyList<double> JointsDeg);
|
|
|
|
private sealed record ComparisonSummary(double GlobalRmsDeg, double GlobalMaxAbsDeg, int IoSetPulses, int IoClearFrames);
|
|
}
|