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);
}
}