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