using Flyshot.Core.Config; using Flyshot.Core.Domain; using Flyshot.Core.Planning; using Flyshot.Core.Planning.Export; using Flyshot.Core.Planning.Kinematics; using Flyshot.Core.Planning.Sampling; using Microsoft.Extensions.Logging; using System.Text; namespace Flyshot.ControllerClientCompat; /// /// 负责把 saveTrajectory 生成的规划结果点位写入运行目录 Config/Data。 /// public sealed class FlyshotTrajectoryArtifactWriter { /// /// 旧 Data 明细点位文件使用的默认采样周期,单位为秒。 /// private const double LegacyDetailSamplePeriodSeconds = 0.016; /// /// FANUC J519 实际下发的固定伺服周期,单位为秒。 /// private const double ActualSendServoPeriodSeconds = 0.008; private readonly ControllerClientCompatOptions _options; private readonly RobotModelLoader _robotModelLoader; private readonly ILogger? _logger; /// /// 初始化规划结果点位导出器。 /// /// 兼容层基础配置,用于定位运行配置根目录。 /// 机器人模型加载器,用于生成笛卡尔点位。 /// 日志记录器;允许 null。 public FlyshotTrajectoryArtifactWriter( ControllerClientCompatOptions options, RobotModelLoader robotModelLoader, ILogger? logger = null) { _options = options ?? throw new ArgumentNullException(nameof(options)); _robotModelLoader = robotModelLoader ?? throw new ArgumentNullException(nameof(robotModelLoader)); _logger = logger; } /// /// 将飞拍规划结果导出到 Config/Data/name。 /// /// 飞拍轨迹名称。 /// 当前机器人配置。 /// 规划结果包;旧规划导出和实发诊断使用同一份视图。 /// 请求速度倍率,仅作为诊断列保留;最终采样倍率已在规划阶段处理。 public void WriteUploadedFlyshot(string trajectoryName, RobotProfile robot, PlannedExecutionBundle bundle, double speedRatio = 1.0) { WriteUploadedFlyshot(trajectoryName, robot, bundle, bundle, speedRatio); } /// /// 将飞拍规划结果导出到 Config/Data/name,并显式区分旧规划导出视图和实发诊断视图。 /// /// 飞拍轨迹名称。 /// 当前机器人配置。 /// 旧格式规划导出使用的结果包,不应包含运行时 speedRatio 折算。 /// 实发诊断使用的结果包,应与运行时最终发送队列保持一致。 /// 请求速度倍率,仅作为 ActualSend 诊断列保留。 public void WriteUploadedFlyshot( string trajectoryName, RobotProfile robot, PlannedExecutionBundle exportPlanningBundle, PlannedExecutionBundle executionBundle, double speedRatio = 1.0) { if (string.IsNullOrWhiteSpace(trajectoryName)) { throw new ArgumentException("轨迹名称不能为空。", nameof(trajectoryName)); } ArgumentNullException.ThrowIfNull(robot); ArgumentNullException.ThrowIfNull(exportPlanningBundle); ArgumentNullException.ThrowIfNull(executionBundle); var outputDir = Path.Combine(_options.ResolveConfigRoot(), "Data", SanitizeDirectoryName(trajectoryName)); Directory.CreateDirectory(outputDir); if (exportPlanningBundle.Result.DenseJointTrajectory is null) { throw new InvalidOperationException("导出飞拍旧格式规划工件前必须先生成规划明细轨迹。"); } // 旧格式规划文件固定使用不含运行时 speedRatio 的规划视图,避免和 ActualSend 实发诊断混在一起。 var kinematicsModel = _robotModelLoader.LoadKinematicsModel(robot.ModelPath); var jointTrajectory = BuildJointRows(exportPlanningBundle.PlannedTrajectory); _logger?.LogInformation("规划之后的轨迹点位数量为:{}", jointTrajectory.Count); var executionDenseTrajectory = exportPlanningBundle.Result.DenseJointTrajectory; var jointDetailTrajectory = DownsampleDenseRows( executionDenseTrajectory, samplePeriodSeconds: LegacyDetailSamplePeriodSeconds); var cartTrajectory = BuildCartesianRows(exportPlanningBundle.PlannedTrajectory, kinematicsModel); var cartDetailTrajectory = BuildCartesianRowsFromJointDense(jointDetailTrajectory, kinematicsModel); TrajectoryExporter.WriteJointTrajectory(Path.Combine(outputDir, "JointTraj.txt"), jointTrajectory); TrajectoryExporter.WriteJointDenseTrajectory(Path.Combine(outputDir, "JointDetialTraj.txt"), jointDetailTrajectory); TrajectoryExporter.WriteCartesianTrajectory(Path.Combine(outputDir, "CartTraj.txt"), cartTrajectory); TrajectoryExporter.WriteCartesianDenseTrajectory(Path.Combine(outputDir, "CartDetialTraj.txt"), cartDetailTrajectory); WriteActualSendArtifacts(outputDir, robot, executionBundle.Result, speedRatio); _logger?.LogInformation( "saveTrajectory 已导出规划点位: name={TrajectoryName}, outputDir={OutputDir}, jointRows={JointRows}, detailRows={DetailRows}, speedRatio={SpeedRatio}", trajectoryName, outputDir, jointTrajectory.Count, jointDetailTrajectory.Count, speedRatio); } /// /// 生成按 J519 8ms 实际发送周期重采样的轨迹点,供 saveTrajectory 离线对比真实下发序列。 /// private void WriteActualSendArtifacts(string outputDir, RobotProfile robot, TrajectoryResult result, double speedRatio) { ArgumentNullException.ThrowIfNull(robot); var preparedExecution = result.PreparedFlyshotExecution; if (result.DenseJointTrajectory is null && preparedExecution is null) { return; } var samples = preparedExecution is null ? J519SendTrajectorySampler.SampleDenseJointTrajectory( result.DenseJointTrajectory!, result.Duration.TotalSeconds, ActualSendServoPeriodSeconds, speedRatio: 1.0) : preparedExecution.Samples.Select(static sample => new J519SendSample( sample.SampleIndex, sample.SendTime, sample.TrajectoryTime, sample.SpeedRatio, sample.JointsDegrees)).ToArray(); var triggerBindings = preparedExecution is null ? TriggerSampleBinder.Bind( result.TriggerTimeline, samples, result.TriggerSampleIndexOffsetCycles) : preparedExecution.TriggerBindings.Select(static binding => new TriggerSampleBinding( binding.Trigger, new J519SendSample( binding.Sample.SampleIndex, binding.Sample.SendTime, binding.Sample.TrajectoryTime, binding.Sample.SpeedRatio, binding.Sample.JointsDegrees), binding.SampleIndex, binding.FoundInWindow)).ToArray(); TrajectoryLimitValidator.ValidateJ519SendSamples( robot, samples, trajectoryName: result.ProgramName, validateJerk: false); var jointRows = new List>(samples.Count); List> timingRows = preparedExecution is null ? new List>(samples.Count) : preparedExecution.TimingRows.Select(static row => (IReadOnlyList)row.ToArray()).ToList(); List> jerkRows = preparedExecution is null ? [] : preparedExecution.JerkRows.Select(static row => (IReadOnlyList)row.ToArray()).ToList(); double? previousSendTime = null; double[]? previousJoints = null; double[]? previousVelocity = null; double[]? previousAcceleration = null; foreach (var sample in samples) { jointRows.Add(BuildActualSendJointRow(sample.SendTime, sample.JointsDegrees)); if (preparedExecution is null) { timingRows.Add(J519SendTrajectorySampler.BuildTimingRow(sample, speedRatio, stretchIterationCount: 0)); if (previousSendTime is not null && previousJoints is not null) { jerkRows.Add(J519SendTrajectorySampler.BuildJerkRow( previousSendTime.Value, sample.SendTime, previousJoints, sample.JointsDegrees, ref previousVelocity, ref previousAcceleration)); } } previousSendTime = sample.SendTime; previousJoints = sample.JointsDegrees.ToArray(); } WriteDenseRows(Path.Combine(outputDir, "ActualSendJointTraj.txt"), jointRows); WriteDenseRows(Path.Combine(outputDir, "ActualSendTiming.txt"), timingRows); WriteDenseRows(Path.Combine(outputDir, "ActualSendJerkStats.txt"), jerkRows); TrajectoryExporter.WriteShotEvents(Path.Combine(outputDir, "ShotEvents.json"), result.ShotEvents, triggerBindings); } /// /// 构造实际发送点位文本行,格式为 send_time + 关节角度 + io_mask + io_value。 /// private static IReadOnlyList BuildActualSendJointRow(double sendTime, IReadOnlyList joints) { var row = new double[joints.Count + 3]; row[0] = Math.Round(sendTime, 6); for (var index = 0; index < joints.Count; index++) { row[index + 1] = Math.Round(joints[index], 6); } row[^2] = 0.0; row[^1] = 0.0; return row; } /// /// 以空格分隔的旧轨迹文本格式写出数值行。 /// private static void WriteDenseRows(string path, IReadOnlyList> rows) { var sb = new StringBuilder(); foreach (var row in rows) { sb.AppendLine(string.Join(" ", row.Select(static value => $"{value:F6}"))); } File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); } /// /// 构造 JointTraj.txt 行数据,格式为 time + 关节弧度。 /// private static IReadOnlyList> BuildJointRows(PlannedTrajectory trajectory) { var rows = new List>(trajectory.PlannedWaypoints.Count); for (var index = 0; index < trajectory.PlannedWaypoints.Count; index++) { var row = new List(trajectory.PlannedWaypoints[index].Positions.Count + 1) { Math.Round(trajectory.WaypointTimes[index], 6) }; row.AddRange(trajectory.PlannedWaypoints[index].Positions.Select(static value => Math.Round(value, 6))); rows.Add(row); } return rows; } /// /// 构造 CartTraj.txt 行数据,格式为 time + x/y/z/qx/qy/qz/qw。 /// private static IReadOnlyList> BuildCartesianRows( PlannedTrajectory trajectory, RobotKinematicsModel kinematicsModel) { var rows = new List>(trajectory.PlannedWaypoints.Count); for (var index = 0; index < trajectory.PlannedWaypoints.Count; index++) { var pose = RobotKinematics.ForwardKinematics(kinematicsModel, trajectory.PlannedWaypoints[index].Positions.ToArray()); var row = new List(pose.Length + 1) { Math.Round(trajectory.WaypointTimes[index], 6) }; row.AddRange(pose.Select(static value => Math.Round(value, 6))); rows.Add(row); } return rows; } /// /// 基于执行侧稠密关节轨迹生成笛卡尔导出行,保持与 JointDetialTraj.txt 同一来源。 /// private static IReadOnlyList> BuildCartesianRowsFromJointDense( IReadOnlyList> jointDenseRows, RobotKinematicsModel kinematicsModel) { var rows = new List>(jointDenseRows.Count); foreach (var jointRow in jointDenseRows) { var jointPositions = jointRow.Skip(1).ToArray(); var pose = RobotKinematics.ForwardKinematics(kinematicsModel, jointPositions); var row = new List(pose.Length + 1) { Math.Round(jointRow[0], 6) }; row.AddRange(pose.Select(static value => Math.Round(value, 6))); rows.Add(row); } return rows; } /// /// 将 8ms 执行稠密轨迹按指定周期抽稀为低频兼容视图,并始终保留终点。 /// private static IReadOnlyList> DownsampleDenseRows( IReadOnlyList> denseRows, double samplePeriodSeconds) { var result = new List>(); var epsilon = 1e-6; var nextSampleTime = 0.0; foreach (var row in denseRows) { var sampleTime = row[0]; if (sampleTime + epsilon < nextSampleTime) { continue; } if (Math.Abs(sampleTime - nextSampleTime) <= epsilon || sampleTime.Equals(0.0)) { result.Add(row); nextSampleTime += samplePeriodSeconds; } } if (result.Count == 0 || !ReferenceEquals(result[^1], denseRows[^1])) { result.Add(denseRows[^1]); } return result; } /// /// 将轨迹名转换为可用目录名,避免 HTTP 输入中的路径字符污染输出目录。 /// private static string SanitizeDirectoryName(string name) { var invalidChars = Path.GetInvalidFileNameChars(); var chars = name.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray(); return new string(chars); } }