using Flyshot.Core.Planning;
namespace Flyshot.Core.Planning.Sampling;
///
/// 负责把规划层关节轨迹重采样为 J519 物理发送周期上的角度制目标。
///
/// 算法约定:
/// 输入的稠密关节轨迹行格式固定为 [time, j1..jN],time 为规划轨迹时间,关节单位为弧度;
/// 输出的 J519 采样点按物理伺服周期排列,关节单位转换为角度制,供 UDP 60015 实时下发和离线 ActualSend 文件共用。
///
///
/// 采样点数先按轨迹时间步长 trajectoryStep = servoPeriod * speedRatio 计算:
/// sampleCount = ceil(max(0, duration / trajectoryStep - 1e-9)) + 1。
/// 末尾额外保留一个终点钳制周期,确保轨迹时长不是周期整数倍时仍会输出最终点。
///
///
/// 飞拍最终发送队列优先从 重建连续三次样条并直接取点;
/// 旧稠密点入口仅保留给运行时 fallback 和兼容导出。
/// 第 k 个采样点的物理发送时间为 sendTime = k * servoPeriod;
/// speedRatio 不改变物理发送周期,只用于把发送时间映射回规划轨迹时间:
/// trajectoryTime = min(sendTime * speedRatio, duration)。
/// 之后在连续样条或旧稠密轨迹上求取关节,并把每个关节从 rad 转为 deg。
///
///
/// 诊断行也在这里统一生成:Timing 行格式为 sample_index + send_time + trajectory_time + speed_ratio;
/// Jerk 行使用相邻发送点上的角度制关节目标做后向差分,依次近似速度、加速度和跃度,格式为
/// start_time + end_time + dt + max_abs_jerk + jerk[j1..jN]。
///
///
public static class J519SendTrajectorySampler
{
///
/// 根据连续规划样条和 J519 伺服周期生成完整实发采样序列。
///
/// 规划阶段产出的连续轨迹信息。
/// J519 物理发送周期,单位为秒。
/// 速度倍率;只缩放轨迹采样时间,不改变物理发送周期。
/// 按 J519 发送周期排列的角度制采样序列。
public static IReadOnlyList SamplePlannedTrajectory(
PlannedTrajectory trajectory,
double servoPeriodSeconds,
double speedRatio)
{
ArgumentNullException.ThrowIfNull(trajectory);
ValidatePlannedTrajectoryInputs(trajectory, servoPeriodSeconds, speedRatio);
var durationSeconds = trajectory.WaypointTimes[^1];
var trajectoryStepSeconds = servoPeriodSeconds * speedRatio;
var sampleCount = CalculateSampleCount(durationSeconds, trajectoryStepSeconds);
var spline = RebuildSpline(trajectory);
var samples = new List((int)Math.Min(sampleCount, int.MaxValue));
for (long sampleIndex = 0; sampleIndex < sampleCount; sampleIndex++)
{
// 直接在连续样条上按 trajectoryTime 取点,避免先离散再线性回采样引入局部跃度尖峰。
var sendTime = sampleIndex * servoPeriodSeconds;
var trajectoryTime = Math.Min(sendTime * speedRatio, durationSeconds);
var joints = spline.Evaluate(trajectoryTime).Select(RadiansToDegrees).ToArray();
samples.Add(new J519SendSample(sampleIndex, sendTime, trajectoryTime, speedRatio, joints));
}
return samples;
}
///
/// 根据 J519 伺服周期和 speed_ratio 生成完整实发采样序列。
///
/// 规划层稠密关节轨迹,每行格式为 [time, j1..jN],关节单位为弧度。
/// 规划轨迹总时长,单位为秒。
/// J519 物理发送周期,单位为秒。
/// 速度倍率;只缩放轨迹采样时间,不改变物理发送周期。
/// 按 J519 发送周期排列的角度制采样序列。
public static IReadOnlyList SampleDenseJointTrajectory(
IReadOnlyList> denseJointTrajectory,
double durationSeconds,
double servoPeriodSeconds,
double speedRatio)
{
ArgumentNullException.ThrowIfNull(denseJointTrajectory);
ValidateInputs(denseJointTrajectory, durationSeconds, servoPeriodSeconds, speedRatio);
var trajectoryStepSeconds = servoPeriodSeconds * speedRatio;
var sampleCount = CalculateSampleCount(durationSeconds, trajectoryStepSeconds);
var samples = new List((int)Math.Min(sampleCount, int.MaxValue));
var segmentIndex = 0;
for (long sampleIndex = 0; sampleIndex < sampleCount; sampleIndex++)
{
// J519 物理周期固定,speed_ratio 只用于把发送时间映射回原始轨迹时间。
var sendTime = sampleIndex * servoPeriodSeconds;
var trajectoryTime = Math.Min(sendTime * speedRatio, durationSeconds);
var joints = SampleDenseJointTrajectoryDegrees(denseJointTrajectory, trajectoryTime, ref segmentIndex);
samples.Add(new J519SendSample(sampleIndex, sendTime, trajectoryTime, speedRatio, joints));
}
return samples;
}
///
/// 按原始轨迹时长和 speed_ratio 后的轨迹时间步长计算 J519 实发采样数。
///
/// 规划轨迹总时长,单位为秒。
/// 每个物理发送周期对应的轨迹时间步长,单位为秒。
/// 包含终点钳制周期的采样点数量。
public static long CalculateSampleCount(double durationSeconds, double trajectoryStepSeconds)
{
if (durationSeconds < 0.0)
{
throw new ArgumentOutOfRangeException(nameof(durationSeconds), "轨迹时长不能为负数。");
}
if (trajectoryStepSeconds <= 0.0 || double.IsNaN(trajectoryStepSeconds) || double.IsInfinity(trajectoryStepSeconds))
{
throw new ArgumentOutOfRangeException(nameof(trajectoryStepSeconds), "轨迹采样步长必须是有限正数。");
}
// 非周期整数倍时多保留一个终点钳制周期,和真实 J519 下发序列保持一致。
return (long)Math.Ceiling(Math.Max(0.0, (durationSeconds / trajectoryStepSeconds) - 1e-9)) + 1;
}
///
/// 构造实发时间映射文本行,格式为 sample_index + send_time + trajectory_time + final_speed_ratio。
///
/// 待写出的 J519 实发采样点。
/// 与 ActualSendTiming.txt 兼容的数值行。
public static IReadOnlyList BuildTimingRow(J519SendSample sample)
{
ArgumentNullException.ThrowIfNull(sample);
return
[
sample.SampleIndex,
Math.Round(sample.SendTime, 6),
Math.Round(sample.TrajectoryTime, 6),
Math.Round(sample.SpeedRatio, 6)
];
}
///
/// 构造带倍率诊断信息的实发时间映射文本行。
///
/// 待写出的 J519 实发采样点。
/// 用户请求的执行倍率。
/// 历史诊断字段;当前执行侧不自动改写 speed_ratio,因此正常为 0。
/// 前 4 列兼容 ActualSendTiming.txt,后续列记录请求倍率和倍率改写次数。
public static IReadOnlyList BuildTimingRow(
J519SendSample sample,
double requestedSpeedRatio,
int stretchIterationCount)
{
if (requestedSpeedRatio <= 0.0 || double.IsNaN(requestedSpeedRatio) || double.IsInfinity(requestedSpeedRatio))
{
throw new ArgumentOutOfRangeException(nameof(requestedSpeedRatio), "请求 speed_ratio 必须是有限正数。");
}
if (stretchIterationCount < 0)
{
throw new ArgumentOutOfRangeException(nameof(stretchIterationCount), "倍率改写次数不能为负数。");
}
var legacyRow = BuildTimingRow(sample);
return
[
legacyRow[0],
legacyRow[1],
legacyRow[2],
legacyRow[3],
Math.Round(requestedSpeedRatio, 6),
stretchIterationCount
];
}
///
/// 构造相邻发送点之间的角度制跃度统计行。
///
/// 上一帧发送时间,单位为秒。
/// 当前帧发送时间,单位为秒。
/// 上一帧角度制关节目标。
/// 当前帧角度制关节目标。
/// 上一帧关节速度,调用后更新为当前帧速度。
/// 上一帧关节加速度,调用后更新为当前帧加速度。
/// 与 ActualSendJerkStats.txt 兼容的数值行。
public static IReadOnlyList BuildJerkRow(
double previousTime,
double currentTime,
IReadOnlyList previousJoints,
IReadOnlyList currentJoints,
ref double[]? previousVelocity,
ref double[]? previousAcceleration)
{
ArgumentNullException.ThrowIfNull(previousJoints);
ArgumentNullException.ThrowIfNull(currentJoints);
var dt = currentTime - previousTime;
if (dt <= 0.0)
{
dt = 1e-9;
}
var jointCount = currentJoints.Count;
var currentVelocity = new double[jointCount];
var currentAcceleration = new double[jointCount];
var currentJerk = new double[jointCount];
var maxAbsJerk = 0.0;
for (var index = 0; index < jointCount; index++)
{
currentVelocity[index] = (currentJoints[index] - previousJoints[index]) / dt;
if (previousVelocity is not null)
{
currentAcceleration[index] = (currentVelocity[index] - previousVelocity[index]) / dt;
}
if (previousAcceleration is not null)
{
currentJerk[index] = (currentAcceleration[index] - previousAcceleration[index]) / dt;
maxAbsJerk = Math.Max(maxAbsJerk, Math.Abs(currentJerk[index]));
}
}
previousVelocity = currentVelocity;
previousAcceleration = currentAcceleration;
var row = new double[jointCount + 4];
row[0] = Math.Round(previousTime, 6);
row[1] = Math.Round(currentTime, 6);
row[2] = Math.Round(dt, 6);
row[3] = Math.Round(maxAbsJerk, 6);
for (var index = 0; index < jointCount; index++)
{
row[index + 4] = Math.Round(currentJerk[index], 6);
}
return row;
}
///
/// 在稠密关节轨迹上按时间线性插值,并转换成 J519 下发使用的角度制目标。
///
private static double[] SampleDenseJointTrajectoryDegrees(
IReadOnlyList> denseJointTrajectory,
double trajectoryTime,
ref int segmentIndex)
{
if (denseJointTrajectory.Count == 1 || trajectoryTime <= denseJointTrajectory[0][0])
{
return denseJointTrajectory[0].Skip(1).Select(RadiansToDegrees).ToArray();
}
var lastIndex = denseJointTrajectory.Count - 1;
if (trajectoryTime >= denseJointTrajectory[lastIndex][0])
{
return denseJointTrajectory[lastIndex].Skip(1).Select(RadiansToDegrees).ToArray();
}
while (segmentIndex < lastIndex - 1 && denseJointTrajectory[segmentIndex + 1][0] < trajectoryTime)
{
segmentIndex++;
}
var start = denseJointTrajectory[segmentIndex];
var end = denseJointTrajectory[segmentIndex + 1];
var startTime = start[0];
var endTime = end[0];
var segmentDuration = endTime - startTime;
var alpha = segmentDuration <= 0.0 ? 0.0 : (trajectoryTime - startTime) / segmentDuration;
var joints = new double[start.Count - 1];
for (var index = 0; index < joints.Length; index++)
{
joints[index] = RadiansToDegrees(start[index + 1] + ((end[index + 1] - start[index + 1]) * alpha));
}
return joints;
}
///
/// 校验 J519 实发采样的基础输入,避免错误时间轴进入运行时链路。
///
private static void ValidateInputs(
IReadOnlyList> denseJointTrajectory,
double durationSeconds,
double servoPeriodSeconds,
double speedRatio)
{
if (denseJointTrajectory.Count == 0)
{
throw new InvalidOperationException("稠密关节轨迹为空。");
}
if (durationSeconds < 0.0)
{
throw new ArgumentOutOfRangeException(nameof(durationSeconds), "轨迹时长不能为负数。");
}
if (servoPeriodSeconds <= 0.0 || double.IsNaN(servoPeriodSeconds) || double.IsInfinity(servoPeriodSeconds))
{
throw new ArgumentOutOfRangeException(nameof(servoPeriodSeconds), "J519 伺服周期必须是有限正数。");
}
if (speedRatio <= 0.0 || double.IsNaN(speedRatio) || double.IsInfinity(speedRatio))
{
throw new ArgumentOutOfRangeException(nameof(speedRatio), "speed_ratio 必须是有限正数。");
}
}
///
/// 校验连续规划轨迹采样入口的基础输入,避免异常时间轴进入最终发送队列。
///
private static void ValidatePlannedTrajectoryInputs(
PlannedTrajectory trajectory,
double servoPeriodSeconds,
double speedRatio)
{
if (trajectory.WaypointTimes.Count < 2)
{
throw new InvalidOperationException("规划轨迹至少需要两个路点才能重建连续样条。");
}
if (trajectory.WaypointTimes[^1] < 0.0)
{
throw new ArgumentOutOfRangeException(nameof(trajectory), "规划轨迹总时长不能为负数。");
}
if (servoPeriodSeconds <= 0.0 || double.IsNaN(servoPeriodSeconds) || double.IsInfinity(servoPeriodSeconds))
{
throw new ArgumentOutOfRangeException(nameof(servoPeriodSeconds), "J519 伺服周期必须是有限正数。");
}
if (speedRatio <= 0.0 || double.IsNaN(speedRatio) || double.IsInfinity(speedRatio))
{
throw new ArgumentOutOfRangeException(nameof(speedRatio), "speed_ratio 必须是有限正数。");
}
}
///
/// 从规划轨迹重建连续三次样条,供飞拍最终发送队列直接采样。
///
private static CubicSplineInterpolator RebuildSpline(PlannedTrajectory trajectory)
{
var times = trajectory.WaypointTimes.ToArray();
var values = trajectory.PlannedWaypoints.Select(static waypoint => waypoint.Positions.ToArray()).ToArray();
return new CubicSplineInterpolator(times, values);
}
///
/// 角度单位转换:rad -> deg。
///
private static double RadiansToDegrees(double radians)
{
return radians * 180.0 / Math.PI;
}
}