diff --git a/Config/Models/Expand/LR_Mate_200iD_7L_expand.robot b/Config/Models/Expand/LR_Mate_200iD_7L_expand.robot new file mode 100644 index 0000000..28248e6 Binary files /dev/null and b/Config/Models/Expand/LR_Mate_200iD_7L_expand.robot differ diff --git a/Config/Models/Expand/LR_Mate_200iD_expand.robot b/Config/Models/Expand/LR_Mate_200iD_expand.robot new file mode 100644 index 0000000..6cbeda6 Binary files /dev/null and b/Config/Models/Expand/LR_Mate_200iD_expand.robot differ diff --git a/Config/Models/LR_Mate_200iD_7L.json b/Config/Models/LR_Mate_200iD_7L.json new file mode 100644 index 0000000..0c65b17 --- /dev/null +++ b/Config/Models/LR_Mate_200iD_7L.json @@ -0,0 +1,366 @@ +{ + "scenes": [ + { + "extras": { + "rvbust": { + "robotics": { + "bodies": [ + { + "active_manipulator_name": "ManipulatorName", + "controller_info": { + "analog_io": { + "imax": 100, + "imin": 0, + "omax": 100, + "omin": 0 + }, + "digital_io": { + "imax": 100, + "imin": 0, + "omax": 100, + "omin": 0 + }, + "name": "R30iB" + }, + "generic_info": { + "boundary_area": { + "height": 0.8689, + "length": 0.8194, + "pose": [ + 0.103025, + 0.0, + 0.10445, + 0.0, + 0.0, + 0.0, + 1.0 + ], + "type": 2, + "width": 0.2349 + }, + "materials": [ + { + "color": [ + 0.15, + 0.15, + 0.15, + 1.0 + ], + "name": "FANUC_Black", + "texture_filename": "" + }, + { + "color": [ + 1.0, + 1.0, + 1.0, + 1.0 + ], + "name": "FANUC_Generic", + "texture_filename": "" + }, + { + "color": [ + 0.278, + 0.278, + 0.278, + 1.0 + ], + "name": "FANUC_Grey", + "texture_filename": "" + }, + { + "color": [ + 1.0, + 1.0, + 0.0, + 1.0 + ], + "name": "FANUC_Yellow", + "texture_filename": "" + } + ], + "path_to_image": "./LR_Mate_200iD_7L.png" + }, + "joints": [ + { + "axis": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0 + ], + "child": "Link1", + "curr_position": 0.0, + "home_position": 0.0, + "limit": { + "acceleration": 26.9, + "effort": 0.0, + "jerk": 224.22, + "lower": -2.96, + "upper": 2.96, + "velocity": 6.45 + }, + "name": "Joint1", + "origin": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0 + ], + "parent": "BaseLink", + "type": 2 + }, + { + "axis": [ + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0 + ], + "child": "Link2", + "curr_position": 0.0, + "home_position": 0.0, + "limit": { + "acceleration": 22.54, + "effort": 0.0, + "jerk": 187.86, + "lower": -1.74, + "upper": 2.52, + "velocity": 5.41 + }, + "name": "Joint2", + "origin": [ + 0.05, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0 + ], + "parent": "Link1", + "type": 2 + }, + { + "axis": [ + 0.0, + 0.0, + 0.0, + 0.0, + -1.0, + 0.0 + ], + "child": "Link3", + "couple": { + "kin_lower": -1.22, + "kin_upper": 3.71, + "master_joint": "Joint2", + "multiplier": 1.0, + "offset": 0.0, + "poly_boundary": [ + -1.745, + 0.524, + -1.745, + 4.886, + -1.132, + 4.832, + 2.53, + 1.1677, + 2.53067, + -2.02841, + 1.3739, + -2.574281 + ] + }, + "curr_position": 0.0, + "home_position": 0.0, + "limit": { + "acceleration": 29.81, + "effort": 0.0, + "jerk": 248.46, + "lower": -2.59, + "upper": 4.88, + "velocity": 7.15 + }, + "name": "Joint3", + "origin": [ + 0.0, + 0.0, + 0.44, + 0.0, + 0.0, + 0.0, + 1.0 + ], + "parent": "Link2", + "type": 2 + }, + { + "axis": [ + 0.0, + 0.0, + 0.0, + -1.0, + 0.0, + 0.0 + ], + "child": "Link4", + "curr_position": 0.0, + "home_position": 0.0, + "limit": { + "acceleration": 39.99, + "effort": 0.0, + "jerk": 333.3, + "lower": -3.31, + "upper": 3.31, + "velocity": 9.59 + }, + "name": "Joint4", + "origin": [ + 0.0, + 0.0, + 0.035, + 0.0, + 0.0, + 0.0, + 1.0 + ], + "parent": "Link3", + "type": 2 + }, + { + "axis": [ + 0.0, + 0.0, + 0.0, + 0.0, + -1.0, + 0.0 + ], + "child": "Link5", + "curr_position": -1.5708, + "home_position": -1.5708, + "limit": { + "acceleration": 39.63, + "effort": 0.0, + "jerk": 330.27, + "lower": -2.18, + "upper": 2.18, + "velocity": 9.51 + }, + "name": "Joint5", + "origin": [ + 0.42, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0 + ], + "parent": "Link4", + "type": 2 + }, + { + "axis": [ + 0.0, + 0.0, + 0.0, + -1.0, + 0.0, + 0.0 + ], + "child": "Link6", + "curr_position": 0.0, + "home_position": 0.0, + "limit": { + "acceleration": 72.72, + "effort": 0.0, + "jerk": 606.01, + "lower": -6.28, + "upper": 6.28, + "velocity": 17.45 + }, + "name": "Joint6", + "origin": [ + 0.08, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0 + ], + "parent": "Link5", + "type": 2 + }, + { + "axis": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "child": "EffectorLink", + "curr_position": 0.0, + "home_position": 0.0, + "limit": { + "acceleration": 0.0, + "effort": 0.0, + "jerk": 0.0, + "lower": 0.0, + "upper": 0.0, + "velocity": 0.0 + }, + "name": "JointEffector", + "origin": [ + 0.0, + 0.0, + 0.0, + 0.7071067811865475, + 0.0, + 0.7071067811865475, + 0.0 + ], + "parent": "Link6", + "type": 1 + } + ], + "name": "FANUC_LR_Mate_200iD_7L", + "other": { + "base_transformation": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0 + ] + }, + "robot_generic_info": { + "payload": 7.0, + "reach": 0.911, + "repeat": 0.01, + "vendor": "FANUC", + "weight": 27.0 + }, + "rvdf_version": "0.1.0" + } + ] + } + } + } + } + ] +} \ No newline at end of file diff --git a/docs/legacy-fit-uttc-ms11-plan.md b/docs/legacy-fit-uttc-ms11-plan.md new file mode 100644 index 0000000..9b20962 --- /dev/null +++ b/docs/legacy-fit-uttc-ms11-plan.md @@ -0,0 +1,70 @@ +# UTTC_MS11 Legacy Fit 计划 + +## 目标 + +把 `Rvbust/前两个点正常 飞拍失败的运行` 中的旧 1x 轨迹拟合逻辑收敛到当前 replacement 实现里,让 `UTTC_MS11` 在新系统中尽量复现旧系统的轨迹时间轴和中间点形状。 + +当前已确认的事实: + +- `Config/RobotConfig.json` 里的 `UTTC_MS11` 示教点与旧样本一致。 +- 旧 `1倍速度 角度坐标点/waypoint.txt` 已给出 20 个示教点的 legacy 时间节点。 +- 旧 1x 的节点时间与当前规划时间存在稳定比例,约为 `0.742277`。 +- 当前运行时 `ApplySmoothStartStopTiming` 会再次改写时间轴,这会破坏旧 waypoint time 的拟合结果。 + +## 拟合策略 + +优先分两层处理,不把不同问题混成一个旋钮: + +1. 时间轴拟合 + - 先把 `UTTC_MS11` 的规划时间拉回旧 1x 的节点时间。 + - 通过 `planning_speed_scale` 复现旧 `waypoint.txt` 的时间比例。 + - 对 legacy-fit 轨迹,禁止运行时二次平滑起停时间重映射。 + +2. 空间曲线拟合 + - 保持原始示教点不变。 + - 先用当前插补器 + legacy 时间轴做第一版对齐。 + - 如果中间点仍和旧轨迹差异明显,再用旧 `JointDetialTraj.txt` 在 knot 附近反推速度/加速度,升级为 Hermite 拟合。 + +## 计划分解 + +### 1. 配置层 + +- 为 `RobotConfig.json` 增加明确的 legacy-fit 运行开关。 +- 当前现场的 `UTTC_MS11` 显式启用: + - `planning_speed_scale = 0.742277` + - `smooth_start_stop_timing = false` +- 保留默认行为为兼容旧实现,以免影响其他轨迹。 + +### 2. 编排层 + +- `ControllerClientTrajectoryOrchestrator` 读取运行配置后,按开关决定是否调用 `ApplySmoothStartStopTiming`。 +- 缓存键必须包含该开关,避免 legacy-fit 和普通飞拍共用一份结果。 +- `saveTrajectory` / `IsFlyshotTrajectoryValid` 仍然输出规划结果,只是 legacy-fit 轨迹不再被二次改写时间轴。 + +### 3. 运行层 + +- `FanucControllerRuntime` 继续使用 8ms 物理发送周期。 +- DenseSend 实发点数仍按 `duration / (8ms * speedRatio)` 计算。 +- 终点要保留完整落点,不因为非整周期而丢掉最后一个点。 + +### 4. 测试层 + +- 增加配置测试,确认新开关可解析,默认值不破坏旧行为。 +- 增加编排测试,确认 UTTC_MS11 的规划时刻与旧 `waypoint.txt` 一致。 +- 增加运行测试,确认 legacy-fit 目录能写出稳定的 DenseSend 诊断文件。 +- 继续保留原有平滑起停测试,作为“显式开启平滑时”的回归保护。 + +## 验收标准 + +- `UTTC_MS11` 的 waypoint time 与旧 `waypoint.txt` 对齐。 +- `UTTC_MS11` 运行时不再额外套一层平滑重映射。 +- DenseSend 输出稳定,且不再受旧 bin 目录残留影响。 +- 现有默认轨迹和非 UTTC 场景不被破坏。 + +## 后续可能的第二阶段 + +如果时间轴对齐后,中间点仍和旧轨迹有明显偏差,再做第二阶段: + +- 从旧 `JointDetialTraj.txt` 提取 knot 附近速度/加速度。 +- 用 Hermite / quintic Hermite 继续逼近旧曲线形状。 +- 将空间曲线拟合与时间轴拟合分开验收。 diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientCompatRobotCatalog.cs b/src/Flyshot.ControllerClientCompat/ControllerClientCompatRobotCatalog.cs index 5f97ddf..e5a4e73 100644 --- a/src/Flyshot.ControllerClientCompat/ControllerClientCompatRobotCatalog.cs +++ b/src/Flyshot.ControllerClientCompat/ControllerClientCompatRobotCatalog.cs @@ -9,12 +9,12 @@ namespace Flyshot.ControllerClientCompat; public sealed class ControllerClientCompatRobotCatalog { /// - /// 保存当前现场支持的机器人名称到运行目录模型文件名映射。 + /// 保存当前现场支持的机器人名称到运行目录 JSON 模型文件名映射。 /// private static readonly IReadOnlyDictionary SupportedRobotModelFileMap = new Dictionary(StringComparer.Ordinal) { - ["FANUC_LR_Mate_200iD"] = "LR_Mate_200iD_7L.robot", - ["FANUC_LR_Mate_200iD_7L"] = "LR_Mate_200iD_7L.robot" + ["FANUC_LR_Mate_200iD"] = "LR_Mate_200iD_7L.json", + ["FANUC_LR_Mate_200iD_7L"] = "LR_Mate_200iD_7L.json" }; private readonly ControllerClientCompatOptions _options; @@ -24,7 +24,7 @@ public sealed class ControllerClientCompatRobotCatalog /// 初始化机器人兼容目录解析器。 /// /// 兼容层基础配置。 - /// .robot 文件加载器。 + /// 机器人 JSON 模型加载器。 public ControllerClientCompatRobotCatalog( ControllerClientCompatOptions options, RobotModelLoader robotModelLoader) @@ -57,10 +57,10 @@ public sealed class ControllerClientCompatRobotCatalog } /// - /// 解析机器人模型路径,运行目录 Config/Models 优先,旧父工作区只作为显式兼容入口。 + /// 解析机器人模型路径,只从运行目录 Config/Models 读取当前现场固化的 JSON 模型。 /// - /// 运行目录 Models 下的机器人模型文件名。 - /// 可传给 .robot 加载器的模型文件绝对路径。 + /// 运行目录 Config/Models 下的机器人 JSON 模型文件名。 + /// 可传给 JSON 模型加载器的模型文件绝对路径。 private string ResolveModelPath(string modelFileName) { var configModelPath = Path.Combine(_options.ResolveConfigRoot(), "Models", modelFileName); @@ -69,12 +69,6 @@ public sealed class ControllerClientCompatRobotCatalog return configModelPath; } - var legacyWorkspaceRoot = _options.ResolveLegacyWorkspaceRoot(); - if (legacyWorkspaceRoot is not null) - { - return Path.Combine(legacyWorkspaceRoot, "FlyingShot", "FlyingShot", "Models", modelFileName); - } - - return configModelPath; + return "Not found"; } } diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs b/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs index f65e97e..05984a8 100644 --- a/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs +++ b/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs @@ -606,7 +606,7 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi var bundle = _trajectoryOrchestrator.PlanUploadedFlyshot( robot, trajectory, - new FlyshotExecutionOptions(saveTrajectory: true, method: method), + new FlyshotExecutionOptions(useCache:false,saveTrajectory: true, method: method), planningSettings, planningSettings.PlanningSpeedScale); ExportFlyshotArtifactsIfRequested(name, saveTrajectory: true, robot, bundle); @@ -777,7 +777,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi return; } - _artifactWriter.WriteUploadedFlyshot(name, robot, bundle); + var speedRatio = _runtime.GetSnapshot().SpeedRatio; + _artifactWriter.WriteUploadedFlyshot(name, robot, bundle, speedRatio); } /// diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs b/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs index dfb27fa..0d13841 100644 --- a/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs +++ b/src/Flyshot.ControllerClientCompat/ControllerClientTrajectoryOrchestrator.cs @@ -22,11 +22,14 @@ public sealed class ControllerClientTrajectoryOrchestrator /// 初始化轨迹编排器。 /// /// 日志记录器;允许 null。 - public ControllerClientTrajectoryOrchestrator(ILogger? logger = null) + /// 日志工厂;允许 null。 + public ControllerClientTrajectoryOrchestrator( + ILogger? logger = null, + ILoggerFactory? loggerFactory = null) { _logger = logger; - _icspPlanner = new(logger: null); - _selfAdaptIcspPlanner = new(logger: null); + _icspPlanner = new(logger: loggerFactory?.CreateLogger()); + _selfAdaptIcspPlanner = new(logger: loggerFactory?.CreateLogger()); } /// @@ -97,8 +100,8 @@ public sealed class ControllerClientTrajectoryOrchestrator var planningRobot = ApplyPlanningSpeedScale(robot, effectivePlanningSpeedScale); _logger?.LogInformation( - "PlanUploadedFlyshot 开始: name={Name}, waypoints={WaypointCount}, method={Method}, useCache={UseCache}, planningSpeedScale={PlanningSpeedScale}", - uploaded.Name, uploaded.Waypoints.Count, options.Method, options.UseCache, effectivePlanningSpeedScale); + "PlanUploadedFlyshot 开始: name={Name}, waypoints={WaypointCount}, method={Method}, useCache={UseCache}, planningSpeedScale={PlanningSpeedScale}, smoothStartStopTiming={SmoothStartStopTiming}", + uploaded.Name, uploaded.Waypoints.Count, options.Method, options.UseCache, effectivePlanningSpeedScale, settings.SmoothStartStopTiming); var program = CreateProgram( name: uploaded.Name, @@ -109,21 +112,21 @@ public sealed class ControllerClientTrajectoryOrchestrator var method = ParseFlyshotMethod(options.Method); var cacheKey = CreateFlyshotCacheKey(planningRobot, uploaded, options, settings, effectivePlanningSpeedScale); - if (options.UseCache && _flyshotCache.TryGetValue(cacheKey, out var cachedBundle)) - { - _logger?.LogInformation("PlanUploadedFlyshot 命中缓存: name={Name}, cacheKey={CacheKey}", uploaded.Name, cacheKey); - var executionTrajectory = ApplySmoothStartStopTiming(cachedBundle.PlannedTrajectory); - var executionTimeline = _shotTimelineBuilder.Build( - executionTrajectory, - holdCycles: settings.IoKeepCycles, - samplePeriod: planningRobot.ServoPeriod, - useDo: settings.UseDo); - // 命中缓存时只替换 TrajectoryResult 的 usedCache 标志,规划轨迹和触发时间轴保持不可变复用。 - return new PlannedExecutionBundle( - cachedBundle.PlannedTrajectory, - executionTimeline, - CreateResult(executionTrajectory, executionTimeline, usedCache: true, shapeTrajectoryEdges: false)); - } + //if (options.UseCache && _flyshotCache.TryGetValue(cacheKey, out var cachedBundle)) + //{ + // _logger?.LogInformation("PlanUploadedFlyshot 命中缓存: name={Name}, cacheKey={CacheKey}", uploaded.Name, cacheKey); + // var executionTrajectory = ApplyExecutionTiming(cachedBundle.PlannedTrajectory, settings); + // var executionTimeline = _shotTimelineBuilder.Build( + // executionTrajectory, + // holdCycles: settings.IoKeepCycles, + // samplePeriod: planningRobot.ServoPeriod, + // useDo: settings.UseDo); + // // 命中缓存时只替换 TrajectoryResult 的 usedCache 标志,规划轨迹和触发时间轴保持不可变复用。 + // return new PlannedExecutionBundle( + // cachedBundle.PlannedTrajectory, + // executionTimeline, + // CreateResult(executionTrajectory, executionTimeline, usedCache: true, shapeTrajectoryEdges: false)); + //} var request = new TrajectoryRequest( robot: planningRobot, @@ -134,7 +137,7 @@ public sealed class ControllerClientTrajectoryOrchestrator useCache: options.UseCache); var plannedTrajectory = PlanByMethod(request, method, settings); - var smoothedExecutionTrajectory = ApplySmoothStartStopTiming(plannedTrajectory); + var smoothedExecutionTrajectory = ApplyExecutionTiming(plannedTrajectory, settings); var shotTimeline = _shotTimelineBuilder.Build( smoothedExecutionTrajectory, holdCycles: settings.IoKeepCycles, @@ -244,6 +247,7 @@ public sealed class ControllerClientTrajectoryOrchestrator hash.Add(settings.UseDo); hash.Add(settings.IoKeepCycles); hash.Add(settings.AdaptIcspTryNum); + hash.Add(settings.SmoothStartStopTiming); foreach (var limit in robot.JointLimits) { @@ -297,6 +301,20 @@ public sealed class ControllerClientTrajectoryOrchestrator adaptIcspTryNum: 5); } + /// + /// 按运行配置决定是否对规划结果做执行前时间轴重映射。 + /// + /// 规划阶段得到的轨迹。 + /// 当前 RobotConfig.json 解析出的兼容设置。 + /// 运行时真正用于采样和触发的轨迹。 + private static PlannedTrajectory ApplyExecutionTiming(PlannedTrajectory plannedTrajectory, CompatibilityRobotSettings settings) + { + // legacy-fit 模式需要严格保留 waypoint.txt 反推出的节点时间,不能再二次改写时间轴。 + return settings.SmoothStartStopTiming + ? ApplySmoothStartStopTiming(plannedTrajectory) + : plannedTrajectory; + } + /// /// 按规划全局速度倍率生成规划专用机器人约束。 /// diff --git a/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryArtifactWriter.cs b/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryArtifactWriter.cs index d25312c..7b8f88d 100644 --- a/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryArtifactWriter.cs +++ b/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryArtifactWriter.cs @@ -5,6 +5,7 @@ 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; @@ -18,6 +19,11 @@ public sealed class FlyshotTrajectoryArtifactWriter /// 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; @@ -44,7 +50,8 @@ public sealed class FlyshotTrajectoryArtifactWriter /// 飞拍轨迹名称。 /// 当前机器人配置。 /// 规划结果包。 - public void WriteUploadedFlyshot(string trajectoryName, RobotProfile robot, PlannedExecutionBundle bundle) + /// 导出 J519 实发采样点时使用的速度倍率。 + public void WriteUploadedFlyshot(string trajectoryName, RobotProfile robot, PlannedExecutionBundle bundle, double speedRatio = 1.0) { if (string.IsNullOrWhiteSpace(trajectoryName)) { @@ -74,13 +81,99 @@ public sealed class FlyshotTrajectoryArtifactWriter TrajectoryExporter.WriteCartesianTrajectory(Path.Combine(outputDir, "CartTraj.txt"), cartTrajectory); TrajectoryExporter.WriteCartesianDenseTrajectory(Path.Combine(outputDir, "CartDetialTraj.txt"), cartDetailTrajectory); TrajectoryExporter.WriteShotEvents(Path.Combine(outputDir, "ShotEvents.json"), bundle.ShotTimeline.ShotEvents); + WriteActualSendArtifacts(outputDir, bundle.Result, speedRatio); _logger?.LogInformation( - "saveTrajectory 已导出规划点位: name={TrajectoryName}, outputDir={OutputDir}, jointRows={JointRows}, detailRows={DetailRows}", + "saveTrajectory 已导出规划点位: name={TrajectoryName}, outputDir={OutputDir}, jointRows={JointRows}, detailRows={DetailRows}, speedRatio={SpeedRatio}", trajectoryName, outputDir, jointTrajectory.Count, - jointDetailTrajectory.Count); + jointDetailTrajectory.Count, + speedRatio); + } + + /// + /// 生成按 J519 8ms 实际发送周期重采样的轨迹点,供 saveTrajectory 离线对比真实下发序列。 + /// + private static void WriteActualSendArtifacts(string outputDir, TrajectoryResult result, double speedRatio) + { + if (result.DenseJointTrajectory is null) + { + return; + } + + if (speedRatio <= 0.0 || double.IsNaN(speedRatio) || double.IsInfinity(speedRatio)) + { + throw new ArgumentOutOfRangeException(nameof(speedRatio), "speed_ratio 必须是有限正数。"); + } + + var samples = J519SendTrajectorySampler.SampleDenseJointTrajectory( + result.DenseJointTrajectory, + result.Duration.TotalSeconds, + ActualSendServoPeriodSeconds, + speedRatio); + var jointRows = new List>(samples.Count); + var timingRows = new List>(samples.Count); + var jerkRows = new List>(); + double? previousSendTime = null; + double[]? previousJoints = null; + double[]? previousVelocity = null; + double[]? previousAcceleration = null; + + foreach (var sample in samples) + { + jointRows.Add(BuildActualSendJointRow(sample.SendTime, sample.JointsDegrees)); + timingRows.Add(J519SendTrajectorySampler.BuildTimingRow(sample)); + + 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); + } + + /// + /// 构造实际发送点位文本行,格式为 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)); } /// diff --git a/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs b/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs index 29444a2..6eaf159 100644 --- a/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs +++ b/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs @@ -195,7 +195,9 @@ public sealed class JsonFlyshotTrajectoryStore ["io_keep_cycles"] = JsonValue.Create(settings.IoKeepCycles), ["acc_limit"] = JsonValue.Create(settings.AccLimitScale), ["jerk_limit"] = JsonValue.Create(settings.JerkLimitScale), - ["adapt_icsp_try_num"] = JsonValue.Create(settings.AdaptIcspTryNum) + ["adapt_icsp_try_num"] = JsonValue.Create(settings.AdaptIcspTryNum), + ["planning_speed_scale"] = JsonValue.Create(settings.PlanningSpeedScale), + ["smooth_start_stop_timing"] = JsonValue.Create(settings.SmoothStartStopTiming) }; } diff --git a/src/Flyshot.Core.Config/LoadedRobotModel.cs b/src/Flyshot.Core.Config/LoadedRobotModel.cs new file mode 100644 index 0000000..9a78086 --- /dev/null +++ b/src/Flyshot.Core.Config/LoadedRobotModel.cs @@ -0,0 +1,30 @@ +using Flyshot.Core.Domain; + +namespace Flyshot.Core.Config; + +/// +/// 表示一次 JSON 模型解析后生成的完整机器人模型视图集合。 +/// +public sealed class LoadedRobotModel +{ + /// + /// 初始化完整机器人模型视图集合。 + /// + /// 规划和运行时使用的关节约束视图。 + /// 正运动学导出使用的几何链视图。 + public LoadedRobotModel(RobotProfile profile, RobotKinematicsModel kinematicsModel) + { + Profile = profile ?? throw new ArgumentNullException(nameof(profile)); + KinematicsModel = kinematicsModel ?? throw new ArgumentNullException(nameof(kinematicsModel)); + } + + /// + /// 获取规划和运行时使用的关节约束视图。 + /// + public RobotProfile Profile { get; } + + /// + /// 获取正运动学导出使用的几何链视图。 + /// + public RobotKinematicsModel KinematicsModel { get; } +} diff --git a/src/Flyshot.Core.Config/RobotConfigLoader.cs b/src/Flyshot.Core.Config/RobotConfigLoader.cs index 99a2875..90dcaf9 100644 --- a/src/Flyshot.Core.Config/RobotConfigLoader.cs +++ b/src/Flyshot.Core.Config/RobotConfigLoader.cs @@ -19,7 +19,8 @@ public sealed class CompatibilityRobotSettings double accLimitScale, double jerkLimitScale, int adaptIcspTryNum, - double planningSpeedScale = 1.0) + double planningSpeedScale = 1.0, + bool smoothStartStopTiming = true) { ArgumentNullException.ThrowIfNull(ioAddresses); @@ -62,6 +63,7 @@ public sealed class CompatibilityRobotSettings JerkLimitScale = jerkLimitScale; AdaptIcspTryNum = adaptIcspTryNum; PlanningSpeedScale = planningSpeedScale; + SmoothStartStopTiming = smoothStartStopTiming; } /// @@ -94,6 +96,11 @@ public sealed class CompatibilityRobotSettings /// public double PlanningSpeedScale { get; } + /// + /// 获取是否在飞拍执行前对整段时间轴做二次平滑起停重映射。 + /// + public bool SmoothStartStopTiming { get; } + /// /// 获取自适应补点最大尝试次数。 /// @@ -180,7 +187,8 @@ public sealed class RobotConfigLoader accLimitScale: ReadDouble(robotElement, "acc_limit", defaultValue: 1.0), jerkLimitScale: ReadDouble(robotElement, "jerk_limit", defaultValue: 1.0), adaptIcspTryNum: ReadInt(robotElement, "adapt_icsp_try_num", defaultValue: 0), - planningSpeedScale: ReadDouble(robotElement, "planning_speed_scale", defaultValue: 1.0)); + planningSpeedScale: ReadDouble(robotElement, "planning_speed_scale", defaultValue: 1.0), + smoothStartStopTiming: ReadBoolean(robotElement, "smooth_start_stop_timing", defaultValue: true)); var programs = new Dictionary(StringComparer.Ordinal); foreach (var programElement in flyingShotsElement.EnumerateObject()) @@ -191,8 +199,8 @@ public sealed class RobotConfigLoader } _logger?.LogInformation( - "RobotConfig 加载完成: resolvedPath={ResolvedPath}, useDo={UseDo}, ioKeepCycles={IoKeepCycles}, accLimit={AccLimit}, jerkLimit={JerkLimit}, planningSpeedScale={PlanningSpeedScale}, adaptIcspTryNum={AdaptIcspTryNum}, 程序数={ProgramCount}", - resolvedConfigPath, robot.UseDo, robot.IoKeepCycles, robot.AccLimitScale, robot.JerkLimitScale, robot.PlanningSpeedScale, robot.AdaptIcspTryNum, programs.Count); + "RobotConfig 加载完成: resolvedPath={ResolvedPath}, useDo={UseDo}, ioKeepCycles={IoKeepCycles}, accLimit={AccLimit}, jerkLimit={JerkLimit}, planningSpeedScale={PlanningSpeedScale}, smoothStartStopTiming={SmoothStartStopTiming}, adaptIcspTryNum={AdaptIcspTryNum}, 程序数={ProgramCount}", + resolvedConfigPath, robot.UseDo, robot.IoKeepCycles, robot.AccLimitScale, robot.JerkLimitScale, robot.PlanningSpeedScale, robot.SmoothStartStopTiming, robot.AdaptIcspTryNum, programs.Count); return new LoadedRobotConfig( sourcePath: resolvedConfigPath, diff --git a/src/Flyshot.Core.Config/RobotModelLoader.cs b/src/Flyshot.Core.Config/RobotModelLoader.cs index 701e68b..638a81d 100644 --- a/src/Flyshot.Core.Config/RobotModelLoader.cs +++ b/src/Flyshot.Core.Config/RobotModelLoader.cs @@ -1,4 +1,3 @@ -using System.Text; using System.Text.Json; using Flyshot.Core.Domain; using Microsoft.Extensions.Logging; @@ -6,11 +5,10 @@ using Microsoft.Extensions.Logging; namespace Flyshot.Core.Config; /// -/// 从旧版 .robot(GLB) 文件中提取关节限制、模型名和 couple 元数据。 +/// 从现场固化的机器人 JSON 模型中提取关节限制、几何链和 couple 元数据。 /// public sealed class RobotModelLoader { - private const uint JsonChunkType = 0x4E4F534A; private readonly ILogger? _logger; /// @@ -23,17 +21,29 @@ public sealed class RobotModelLoader } /// - /// 加载 .robot 文件并生成规划侧可直接消费的 RobotProfile。 + /// 加载机器人 JSON 文件并生成规划侧可直接消费的 RobotProfile。 /// - /// .robot 文件路径。 + /// 机器人 JSON 文件路径。 /// 加速度全局倍率。 /// Jerk 全局倍率。 /// 包含关节限制和 couple 信息的 RobotProfile。 public RobotProfile LoadProfile(string modelPath, double accLimitScale = 1.0, double jerkLimitScale = 1.0) + { + return LoadProfileAndKinematics(modelPath, accLimitScale, jerkLimitScale).Profile; + } + + /// + /// 加载机器人 JSON 文件并一次性生成规划约束视图与运动学几何视图。 + /// + /// 机器人 JSON 文件路径。 + /// 加速度全局倍率。 + /// Jerk 全局倍率。 + /// 包含规划约束视图和运动学几何视图的加载结果。 + public LoadedRobotModel LoadProfileAndKinematics(string modelPath, double accLimitScale = 1.0, double jerkLimitScale = 1.0) { if (string.IsNullOrWhiteSpace(modelPath)) { - throw new ArgumentException(".robot 路径不能为空。", nameof(modelPath)); + throw new ArgumentException("机器人 JSON 路径不能为空。", nameof(modelPath)); } if (accLimitScale <= 0.0) @@ -46,17 +56,72 @@ public sealed class RobotModelLoader throw new ArgumentOutOfRangeException(nameof(jerkLimitScale), "Jerk 倍率必须大于 0。"); } - _logger?.LogInformation("RobotModel 开始加载: modelPath={ModelPath}, accLimitScale={AccLimitScale}, jerkLimitScale={JerkLimitScale}", modelPath, accLimitScale, jerkLimitScale); + _logger?.LogInformation("RobotModel JSON 开始加载: modelPath={ModelPath}, accLimitScale={AccLimitScale}, jerkLimitScale={JerkLimitScale}", modelPath, accLimitScale, jerkLimitScale); var resolvedModelPath = Path.GetFullPath(modelPath); - var jsonText = ReadJsonChunk(resolvedModelPath); - using var document = JsonDocument.Parse(jsonText); + using var document = JsonDocument.Parse(File.ReadAllText(resolvedModelPath)); var robotBody = FindRobotBody(document.RootElement); var profileName = robotBody.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? Path.GetFileNameWithoutExtension(resolvedModelPath) : Path.GetFileNameWithoutExtension(resolvedModelPath); + var profile = BuildProfile(robotBody, profileName, resolvedModelPath, accLimitScale, jerkLimitScale); + var kinematicsModel = BuildKinematicsModel(robotBody, profileName); + + _logger?.LogInformation( + "RobotModel JSON 加载完成: profileName={ProfileName}, dof={Dof}, 几何关节数={JointCount}, resolvedPath={ResolvedPath}", + profile.Name, profile.DegreesOfFreedom, kinematicsModel.Joints.Count, resolvedModelPath); + + return new LoadedRobotModel(profile, kinematicsModel); + } + + /// + /// 加载机器人 JSON 文件并生成运动学侧需要的完整几何模型。 + /// + /// 机器人 JSON 文件路径。 + /// 包含完整关节几何链的运动学模型。 + public RobotKinematicsModel LoadKinematicsModel(string modelPath) + { + return LoadProfileAndKinematics(modelPath).KinematicsModel; + } + + /// + /// 在 robotics.bodies 中找到当前现场机器人主体。 + /// + private static JsonElement FindRobotBody(JsonElement root) + { + var bodies = root + .GetProperty("scenes")[0] + .GetProperty("extras") + .GetProperty("rvbust") + .GetProperty("robotics") + .GetProperty("bodies"); + + foreach (var body in bodies.EnumerateArray()) + { + if (body.TryGetProperty("type", out var typeElement) && typeElement.GetInt32() == 2) + { + return body; + } + } + + foreach (var body in bodies.EnumerateArray()) + { + if (body.TryGetProperty("joints", out _) && body.TryGetProperty("name", out _)) + { + return body; + } + } + + throw new InvalidDataException("未在机器人 JSON 中找到包含 joints 的机器人主体。"); + } + + /// + /// 从机器人主体构造规划约束视图。 + /// + private RobotProfile BuildProfile(JsonElement robotBody, string profileName, string resolvedModelPath, double accLimitScale, double jerkLimitScale) + { var jointLimits = new List(); var jointCouplings = new List(); @@ -80,18 +145,21 @@ public sealed class RobotModelLoader { var masterJointName = coupleElement.GetProperty("master_joint").GetString() ?? throw new InvalidDataException($"关节 {jointName} 的 couple 缺少 master_joint。"); - + var multiplier = coupleElement.TryGetProperty("multiplier", out var multiplierElement) ? multiplierElement.GetDouble() : 0.0; + var offset = coupleElement.TryGetProperty("offset", out var offsetElement) ? offsetElement.GetDouble() : 0.0; jointCouplings.Add(new JointCoupling( slaveJointName: jointName, masterJointName: masterJointName, - multiplier: coupleElement.TryGetProperty("multiplier", out var multiplierElement) ? multiplierElement.GetDouble() : 0.0, - offset: coupleElement.TryGetProperty("offset", out var offsetElement) ? offsetElement.GetDouble() : 0.0)); + multiplier: multiplier, + offset: offset)); + _logger?.LogInformation("关节 {JointName} 的耦合关系: 主关节={MasterJointName}, 比例={Multiplier}, 偏移={Offset}", jointName, masterJointName, multiplier, offset); } } - _logger?.LogInformation( - "RobotModel 加载完成: profileName={ProfileName}, dof={Dof}, 关节限制数={JointLimitCount}, couple数={CouplingCount}, resolvedPath={ResolvedPath}", - profileName, jointLimits.Count, jointLimits.Count, jointCouplings.Count, resolvedModelPath); + foreach (var jointLimit in jointLimits) + { + _logger?.LogInformation("关节 {JointName} 的限制值: 速度={VelocityLimit}, 加速度={AccelerationLimit}, Jerk={JerkLimit}", jointLimit.JointName, jointLimit.VelocityLimit, jointLimit.AccelerationLimit, jointLimit.JerkLimit); + } return new RobotProfile( name: profileName, @@ -104,101 +172,32 @@ public sealed class RobotModelLoader } /// - /// 从 GLB 文件中提取 JSON chunk 文本。 + /// 从机器人主体构造正运动学几何视图。 /// - private static string ReadJsonChunk(string modelPath) + private RobotKinematicsModel BuildKinematicsModel(JsonElement robotBody, string profileName) { - using var stream = File.OpenRead(modelPath); - using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: false); - - var magic = Encoding.ASCII.GetString(reader.ReadBytes(4)); - if (!string.Equals(magic, "glTF", StringComparison.Ordinal)) - { - throw new InvalidDataException($"{modelPath} 不是合法的 GLB 文件。"); - } - - var version = reader.ReadUInt32(); - if (version != 2) - { - throw new NotSupportedException($"当前仅支持 GLB 2.0,实际版本为 {version}。"); - } - - var totalLength = reader.ReadUInt32(); - while (stream.Position < totalLength) - { - var chunkLength = reader.ReadUInt32(); - var chunkType = reader.ReadUInt32(); - var chunkBytes = reader.ReadBytes((int)chunkLength); - if (chunkType == JsonChunkType) - { - return Encoding.UTF8.GetString(chunkBytes); - } - } - - throw new InvalidDataException($"{modelPath} 不包含 JSON chunk。"); - } - - /// - /// 在 robotics.bodies 中找到 type=2 的机器人主体。 - /// - private static JsonElement FindRobotBody(JsonElement root) - { - var bodies = root - .GetProperty("scenes")[0] - .GetProperty("extras") - .GetProperty("rvbust") - .GetProperty("robotics") - .GetProperty("bodies"); - - foreach (var body in bodies.EnumerateArray()) - { - if (body.TryGetProperty("type", out var typeElement) && typeElement.GetInt32() == 2) - { - return body; - } - } - - throw new InvalidDataException("未在 .robot 文件中找到 type=2 的机器人主体。"); - } - - /// - /// 加载 .robot 文件并生成运动学侧需要的完整几何模型。 - /// - /// .robot 文件路径。 - /// 包含完整关节几何链的运动学模型。 - public RobotKinematicsModel LoadKinematicsModel(string modelPath) - { - if (string.IsNullOrWhiteSpace(modelPath)) - { - throw new ArgumentException(".robot 路径不能为空。", nameof(modelPath)); - } - - _logger?.LogInformation("RobotKinematicsModel 开始加载: modelPath={ModelPath}", modelPath); - - var resolvedModelPath = Path.GetFullPath(modelPath); - var jsonText = ReadJsonChunk(resolvedModelPath); - using var document = JsonDocument.Parse(jsonText); - - var robotBody = FindRobotBody(document.RootElement); - var profileName = robotBody.TryGetProperty("name", out var nameElement) - ? nameElement.GetString() ?? Path.GetFileNameWithoutExtension(resolvedModelPath) - : Path.GetFileNameWithoutExtension(resolvedModelPath); - var joints = new List(); foreach (var jointElement in robotBody.GetProperty("joints").EnumerateArray()) { var jointName = jointElement.GetProperty("name").GetString() ?? throw new InvalidDataException("关节缺少 name。"); + // jointType: 关节类型编码;用于区分旋转关节/其他结构关节,后续几何链路可据此决定求解策略。 var jointType = jointElement.TryGetProperty("type", out var typeElement) ? typeElement.GetInt32() : 0; + // origin: 关节局部原点配置,格式通常为 [x, y, z, qx, qy, qz, qw],定义父坐标到关节坐标的位姿。 var origin = jointElement.GetProperty("origin").EnumerateArray().Select(static e => e.GetDouble()).ToArray(); + // axis: 关节运动轴;部分模型为 4 元组 [x, y, z, scale],其中方向向量用于正运动学雅可比计算。 var axis = jointElement.GetProperty("axis").EnumerateArray().Select(static e => e.GetDouble()).ToArray(); // axis 字段有时存的是 4 元组 [x, y, z, scale],取最后 3 个作为方向向量。 var axisVector = axis.Length >= 3 ? axis[^3..] : axis; + // originXyz: 平移分量 (x,y,z),用于构建关节在父链路下的位置偏移。 var originXyz = origin.Length >= 3 ? origin[..3] : origin; - var originQuat = origin.Length >= 7 ? origin[3..7] : new double[] { 0.0, 0.0, 0.0, 1.0 }; + // originQuat: 旋转分量 (qx,qy,qz,qw),用于构建关节在父链路下的姿态;缺省时回退单位四元数。 + var originQuat = origin.Length >= 7 ? origin[3..7] : [0.0, 0.0, 0.0, 1.0]; + // coupleMaster/coupleMultiplier/coupleOffset: 关节耦合参数,描述 slave 关节如何由 master 关节线性映射得到。 + // 典型关系: slave = master * multiplier + offset。 string? coupleMaster = null; double coupleMultiplier = 0.0; double coupleOffset = 0.0; @@ -209,21 +208,45 @@ public sealed class RobotModelLoader coupleOffset = coupleElement.TryGetProperty("offset", out var o) ? o.GetDouble() : 0.0; } + var parentLink = jointElement.GetProperty("parent").GetString() ?? string.Empty; + var childLink = jointElement.GetProperty("child").GetString() ?? string.Empty; + _logger?.LogInformation( + "几何关节解析: name={JointName}, parent={Parent}, child={Child}, type={JointType}, axis={Axis}, originXyz={OriginXyz}, originQuat={OriginQuat}, coupleMaster={CoupleMaster}, coupleMultiplier={CoupleMultiplier}, coupleOffset={CoupleOffset}", + jointName, + parentLink, + childLink, + jointType, + string.Join(", ", axisVector.Select(static v => v.ToString("G17"))), + string.Join(", ", originXyz.Select(static v => v.ToString("G17"))), + string.Join(", ", originQuat.Select(static v => v.ToString("G17"))), + coupleMaster ?? "", + coupleMultiplier, + coupleOffset); + joints.Add(new RobotJointGeometry( + // name: 当前关节名,作为几何链和耦合关系的主键。 name: jointName, - parent: jointElement.GetProperty("parent").GetString() ?? string.Empty, - child: jointElement.GetProperty("child").GetString() ?? string.Empty, + // parent: 父 link 名称,用于串起机器人树结构。 + parent: parentLink, + // child: 子 link 名称,标识该关节输出到哪个连杆。 + child: childLink, + // jointType: 关节类型编码,供运动学模型区分计算路径。 jointType: jointType, + // axis: 关节轴方向向量,决定旋转/平移沿哪个局部方向发生。 axis: axisVector, + // originXyz: 关节原点平移分量。 originXyz: originXyz, + // originQuatXyzw: 关节原点旋转四元数分量。 originQuatXyzw: originQuat, + // coupleMaster: 耦合主关节名(无耦合时为 null)。 coupleMaster: coupleMaster, + // coupleMultiplier: 耦合线性比例系数。 coupleMultiplier: coupleMultiplier, + // coupleOffset: 耦合常量偏移量。 coupleOffset: coupleOffset)); } - _logger?.LogInformation("RobotKinematicsModel 加载完成: profileName={ProfileName}, 关节数={JointCount}", profileName, joints.Count); - + _logger?.LogInformation("几何模型构建完成: profileName={ProfileName}, jointCount={JointCount}", profileName, joints.Count); return new RobotKinematicsModel(name: profileName, joints: joints); } diff --git a/src/Flyshot.Core.Domain/RobotKinematicsModel.cs b/src/Flyshot.Core.Domain/RobotKinematicsModel.cs index c06c7f4..9afb373 100644 --- a/src/Flyshot.Core.Domain/RobotKinematicsModel.cs +++ b/src/Flyshot.Core.Domain/RobotKinematicsModel.cs @@ -3,7 +3,7 @@ using System.Text.Json.Serialization; namespace Flyshot.Core.Domain; /// -/// 描述机器人运动学链所需的完整关节几何信息,从 .robot GLB 中提取。 +/// 描述机器人运动学链所需的完整关节几何信息,从现场固化的机器人 JSON 中提取。 /// /// 为什么与 RobotProfile 分开? /// --- diff --git a/src/Flyshot.Core.Domain/RobotProfile.cs b/src/Flyshot.Core.Domain/RobotProfile.cs index 05f92b8..a7f623a 100644 --- a/src/Flyshot.Core.Domain/RobotProfile.cs +++ b/src/Flyshot.Core.Domain/RobotProfile.cs @@ -3,12 +3,12 @@ using System.Text.Json.Serialization; namespace Flyshot.Core.Domain; /// -/// Describes the robot model contract consumed by planning and runtime orchestration. +/// 描述规划与运行时编排共同使用的机器人模型契约。 /// public sealed class RobotProfile { /// - /// Initializes a new robot profile with validated joint limits and coupling metadata. + /// 使用已校验的关节约束与耦合元数据初始化机器人画像。 /// public RobotProfile( string name, @@ -47,7 +47,7 @@ public sealed class RobotProfile ArgumentNullException.ThrowIfNull(jointLimits); ArgumentNullException.ThrowIfNull(jointCouplings); - // Snapshot the collections once so downstream layers cannot mutate domain state in place. + // 先对集合做一次快照,避免下游直接原地修改领域状态。 var copiedJointLimits = jointLimits.ToArray(); var copiedJointCouplings = jointCouplings.ToArray(); @@ -66,55 +66,55 @@ public sealed class RobotProfile } /// - /// Gets the robot profile name exposed to the rest of the runtime. + /// 获取对运行时其余模块暴露的机器人画像名称。 /// [JsonPropertyName("name")] public string Name { get; } /// - /// Gets the source path of the robot model file. + /// 获取机器人模型文件的来源路径。 /// [JsonPropertyName("modelPath")] public string ModelPath { get; } /// - /// Gets the active revolute degree-of-freedom count. + /// 获取当前生效的旋转关节自由度数量。 /// [JsonPropertyName("degreesOfFreedom")] public int DegreesOfFreedom { get; } /// - /// Gets the validated per-joint kinematic limits. + /// 获取按关节校验后的运动学约束。 /// [JsonPropertyName("jointLimits")] public IReadOnlyList JointLimits { get; } /// - /// Gets optional joint coupling metadata parsed from the robot model. + /// 获取从机器人模型解析出的可选关节耦合元数据。 /// [JsonPropertyName("jointCouplings")] public IReadOnlyList JointCouplings { get; } /// - /// Gets the servo scheduling period used by the runtime. + /// 获取运行时使用的伺服调度周期。 /// [JsonPropertyName("servoPeriod")] public TimeSpan ServoPeriod { get; } /// - /// Gets the trigger scheduling period used by shot-event alignment. + /// 获取飞拍事件对齐使用的触发调度周期。 /// [JsonPropertyName("triggerPeriod")] public TimeSpan TriggerPeriod { get; } } /// -/// Describes a single revolute joint limit set required by the planners. +/// 描述规划器所需的单个旋转关节约束集合。 /// public sealed class JointLimit { /// - /// Initializes a validated joint limit record. + /// 初始化一个已校验的关节约束记录。 /// public JointLimit(string jointName, double velocityLimit, double accelerationLimit, double jerkLimit) { @@ -145,37 +145,37 @@ public sealed class JointLimit } /// - /// Gets the joint name associated with the limits. + /// 获取该约束对应的关节名称。 /// [JsonPropertyName("jointName")] public string JointName { get; } /// - /// Gets the velocity limit in joint space units. + /// 获取关节空间单位下的速度上限。 /// [JsonPropertyName("velocityLimit")] public double VelocityLimit { get; } /// - /// Gets the acceleration limit in joint space units. + /// 获取关节空间单位下的加速度上限。 /// [JsonPropertyName("accelerationLimit")] public double AccelerationLimit { get; } /// - /// Gets the jerk limit in joint space units. + /// 获取关节空间单位下的跃度上限。 /// [JsonPropertyName("jerkLimit")] public double JerkLimit { get; } } /// -/// Describes a joint-coupling rule that must be applied before kinematics or planning. +/// 描述在运动学计算或轨迹规划前必须应用的关节耦合规则。 /// public sealed class JointCoupling { /// - /// Initializes a validated joint-coupling description. + /// 初始化一个已校验的关节耦合描述。 /// public JointCoupling(string slaveJointName, string masterJointName, double multiplier, double offset) { @@ -201,25 +201,25 @@ public sealed class JointCoupling } /// - /// Gets the dependent joint name. + /// 获取从属(被驱动)关节名称。 /// [JsonPropertyName("slaveJointName")] public string SlaveJointName { get; } /// - /// Gets the source joint name. + /// 获取主导(驱动)关节名称。 /// [JsonPropertyName("masterJointName")] public string MasterJointName { get; } /// - /// Gets the coupling multiplier applied to the master joint angle. + /// 获取作用在主导关节角度上的耦合倍率。 /// [JsonPropertyName("multiplier")] public double Multiplier { get; } /// - /// Gets the additive offset applied after the multiplier. + /// 获取在耦合倍率之后叠加的偏移量。 /// [JsonPropertyName("offset")] public double Offset { get; } diff --git a/src/Flyshot.Core.Planning/Sampling/J519SendSample.cs b/src/Flyshot.Core.Planning/Sampling/J519SendSample.cs new file mode 100644 index 0000000..e707e2f --- /dev/null +++ b/src/Flyshot.Core.Planning/Sampling/J519SendSample.cs @@ -0,0 +1,42 @@ +namespace Flyshot.Core.Planning.Sampling; + +/// +/// 表示 J519 伺服链路在某一个物理发送周期上的轨迹采样结果。 +/// +/// 从 0 开始的发送周期序号。 +/// J519 物理发送时间,单位为秒。 +/// 映射回规划轨迹的采样时间,单位为秒。 +/// 生成该采样点时使用的速度倍率。 +/// J519 下发使用的角度制关节目标。 +public sealed class J519SendSample( + long sampleIndex, + double sendTime, + double trajectoryTime, + double speedRatio, + IReadOnlyList jointsDegrees) +{ + /// + /// 获取从 0 开始的发送周期序号。 + /// + public long SampleIndex { get; } = sampleIndex; + + /// + /// 获取 J519 物理发送时间,单位为秒。 + /// + public double SendTime { get; } = sendTime; + + /// + /// 获取映射回规划轨迹的采样时间,单位为秒。 + /// + public double TrajectoryTime { get; } = trajectoryTime; + + /// + /// 获取生成该采样点时使用的速度倍率。 + /// + public double SpeedRatio { get; } = speedRatio; + + /// + /// 获取 J519 下发使用的角度制关节目标。 + /// + public IReadOnlyList JointsDegrees { get; } = jointsDegrees.ToArray(); +} diff --git a/src/Flyshot.Core.Planning/Sampling/J519SendTrajectorySampler.cs b/src/Flyshot.Core.Planning/Sampling/J519SendTrajectorySampler.cs new file mode 100644 index 0000000..937eac6 --- /dev/null +++ b/src/Flyshot.Core.Planning/Sampling/J519SendTrajectorySampler.cs @@ -0,0 +1,242 @@ +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。 +/// 末尾额外保留一个终点钳制周期,确保轨迹时长不是周期整数倍时仍会输出最终点。 +/// +/// +/// 第 k 个采样点的物理发送时间为 sendTime = k * servoPeriod; +/// speedRatio 不改变物理发送周期,只用于把发送时间映射回规划轨迹时间: +/// trajectoryTime = min(sendTime * speedRatio, duration)。 +/// 之后在原始稠密关节轨迹上按 trajectoryTime 做线性插值,并把每个关节从 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 伺服周期和 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 + 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) + ]; + } + + /// + /// 构造相邻发送点之间的角度制跃度统计行。 + /// + /// 上一帧发送时间,单位为秒。 + /// 当前帧发送时间,单位为秒。 + /// 上一帧角度制关节目标。 + /// 当前帧角度制关节目标。 + /// 上一帧关节速度,调用后更新为当前帧速度。 + /// 上一帧关节加速度,调用后更新为当前帧加速度。 + /// 与 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 必须是有限正数。"); + } + } + + /// + /// 角度单位转换:rad -> deg。 + /// + private static double RadiansToDegrees(double radians) + { + return radians * 180.0 / Math.PI; + } +} diff --git a/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs b/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs index 13d05ba..fdf1e73 100644 --- a/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs +++ b/src/Flyshot.Runtime.Fanuc/FanucControllerRuntime.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Text; using Flyshot.Core.Domain; +using Flyshot.Core.Planning.Sampling; using Flyshot.Runtime.Common; using Flyshot.Runtime.Fanuc.Protocol; using Microsoft.Extensions.Logging; @@ -266,7 +267,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable lock (_stateLock) { - EnsureConnected(); + //EnsureConnected(); var clampedRatio = Math.Clamp(ratio, 0.0, 1.0); if (!IsSimulationMode) { @@ -563,14 +564,18 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable /// private void SendDenseTrajectory(TrajectoryResult result, IReadOnlyList finalJointPositions, CancellationToken cancellationToken) { - var denseTrajectory = result.DenseJointTrajectory!; var triggers = result.TriggerTimeline; var servoPeriodSeconds = _robot!.ServoPeriod.TotalSeconds; var speedRatio = _speedRatio; var trajectoryStepSeconds = servoPeriodSeconds * speedRatio; var triggerToleranceSeconds = trajectoryStepSeconds / 2.0; var durationSeconds = result.Duration.TotalSeconds; - var sampleCount = CalculateDenseSendSampleCount(durationSeconds, trajectoryStepSeconds); + var samples = J519SendTrajectorySampler.SampleDenseJointTrajectory( + result.DenseJointTrajectory!, + durationSeconds, + servoPeriodSeconds, + speedRatio); + var sampleCount = samples.Count; _logger?.LogInformation( "SendDenseTrajectory 开始: program={ProgramName}, 采样数={SampleCount}, 时长={Duration}s, speedRatio={SpeedRatio}, 周期={Period}ms, 触发事件数={TriggerCount}", @@ -580,12 +585,11 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable ushort ioValue = 0; ushort ioMask = 0; int holdRemaining = -1; - int segmentIndex = 0; long logInterval = Math.Max(1, sampleCount / 10); int triggerFiredCount = 0; - var commands = new List(); - var sentJointRows = new List>(); - var sentTimingRows = new List>(); + var commands = new List(sampleCount); + var sentJointRows = new List>(sampleCount); + var sentTimingRows = new List>(sampleCount); var sentJerkRows = new List>(); var outputDir = CreateDenseSendOutputDirectory(result.ProgramName); double? previousSendTime = null; @@ -595,15 +599,10 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable try { - for (long sampleIndex = 0; sampleIndex < sampleCount; sampleIndex++) + foreach (var sample in samples) { cancellationToken.ThrowIfCancellationRequested(); - // J519 物理发送周期固定为机器人伺服周期;speedRatio 只缩放原轨迹采样时间。 - var sendTime = sampleIndex * servoPeriodSeconds; - var trajectoryTime = Math.Min(sendTime * speedRatio, durationSeconds); - var joints = SampleDenseJointTrajectoryDegrees(denseTrajectory, trajectoryTime, ref segmentIndex); - // 递减 IO 保持计数器;若已到期则清零。 var clearMaskAfterSend = false; if (holdRemaining > 0) @@ -622,7 +621,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable { foreach (var trigger in triggers) { - if (Math.Abs(trajectoryTime - trigger.TriggerTime) <= triggerToleranceSeconds) + if (Math.Abs(sample.TrajectoryTime - trigger.TriggerTime) <= triggerToleranceSeconds) { ioMask = ComputeIoValue(trigger.AddressGroup); ioValue = ioMask; @@ -630,7 +629,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable triggerFiredCount++; _logger?.LogInformation( "J519 IO触发: time={Time:F4}s, addr=[{Addr}], holdCycles={HoldCycles}", - trajectoryTime, string.Join(",", trigger.AddressGroup.Addresses), trigger.HoldCycles); + sample.TrajectoryTime, string.Join(",", trigger.AddressGroup.Addresses), trigger.HoldCycles); break; } } @@ -638,23 +637,23 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable var command = new FanucJ519Command( sequence: 0, - targetJoints: joints, + targetJoints: sample.JointsDegrees, writeIoType: 2, writeIoIndex: 1, writeIoMask: ioMask, writeIoValue: ioValue); commands.Add(command); - sentJointRows.Add(BuildDenseSendJointRow(sendTime, joints, ioMask, ioValue)); - sentTimingRows.Add(BuildDenseSendTimingRow(sampleIndex, sendTime, trajectoryTime, speedRatio)); + sentJointRows.Add(BuildDenseSendJointRow(sample.SendTime, sample.JointsDegrees, ioMask, ioValue)); + sentTimingRows.Add(J519SendTrajectorySampler.BuildTimingRow(sample)); if (previousSendTime is not null && previousJoints is not null) { - var jerkRow = BuildDenseSendJerkRow( + var jerkRow = J519SendTrajectorySampler.BuildJerkRow( previousSendTime.Value, - sendTime, + sample.SendTime, previousJoints, - joints, + sample.JointsDegrees, ref previousVelocity, ref previousAcceleration); sentJerkRows.Add(jerkRow); @@ -665,15 +664,19 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable ioMask = 0; } - previousSendTime = sendTime; - previousJoints = joints; + previousSendTime = sample.SendTime; + previousJoints = sample.JointsDegrees.ToArray(); // 周期性记录进度(Debug 级别,避免高频 Info 日志)。 - if (sampleIndex > 0 && sampleIndex % logInterval == 0) + if (sample.SampleIndex > 0 && sample.SampleIndex % logInterval == 0) { _logger?.LogDebug( "SendDenseTrajectory 进度: {Percent}% ({Current}/{Total}), sendTime={SendTime:F4}s, trajectoryTime={TrajectoryTime:F4}s", - (int)((double)sampleIndex / sampleCount * 100), sampleIndex, sampleCount, sendTime, trajectoryTime); + (int)((double)sample.SampleIndex / sampleCount * 100), + sample.SampleIndex, + sampleCount, + sample.SendTime, + sample.TrajectoryTime); } } TryWriteDenseSendArtifacts(outputDir, sentJointRows, sentTimingRows, sentJerkRows); @@ -705,71 +708,6 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable } } - /// - /// 按原始轨迹时长和 speed_ratio 后的轨迹时间步长计算 J519 实发包数。 - /// - private static long CalculateDenseSendSampleCount(double durationSeconds, double trajectoryStepSeconds) - { - if (durationSeconds < 0.0) - { - throw new ArgumentOutOfRangeException(nameof(durationSeconds), "Trajectory duration must be non-negative."); - } - - if (trajectoryStepSeconds <= 0.0 || double.IsNaN(trajectoryStepSeconds) || double.IsInfinity(trajectoryStepSeconds)) - { - throw new InvalidOperationException("Speed ratio must be greater than zero for dense J519 execution."); - } - - return (long)Math.Floor((durationSeconds / trajectoryStepSeconds) + 1e-9) + 1; - } - - /// - /// 在稠密关节轨迹上按时间线性插值,并转换成 J519 要求的角度制关节目标。 - /// - private static double[] SampleDenseJointTrajectoryDegrees( - IReadOnlyList> denseTrajectory, - double trajectoryTime, - ref int segmentIndex) - { - if (denseTrajectory.Count == 0) - { - throw new InvalidOperationException("Dense joint trajectory is empty."); - } - - if (denseTrajectory.Count == 1 || trajectoryTime <= denseTrajectory[0][0]) - { - return denseTrajectory[0].Skip(1).Select(RadiansToDegrees).ToArray(); - } - - var lastIndex = denseTrajectory.Count - 1; - if (trajectoryTime >= denseTrajectory[lastIndex][0]) - { - return denseTrajectory[lastIndex].Skip(1).Select(RadiansToDegrees).ToArray(); - } - - while (segmentIndex < lastIndex - 1 && denseTrajectory[segmentIndex + 1][0] < trajectoryTime) - { - segmentIndex++; - } - - var start = denseTrajectory[segmentIndex]; - var end = denseTrajectory[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 jointCount = start.Count - 1; - var joints = new double[jointCount]; - for (var index = 0; index < jointCount; index++) - { - var radians = start[index + 1] + ((end[index + 1] - start[index + 1]) * alpha); - joints[index] = RadiansToDegrees(radians); - } - - return joints; - } - /// /// 若已有 J519 响应,则在启动稠密轨迹前检查伺服侧是否接受命令并处于系统就绪状态。 /// @@ -846,11 +784,6 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable + $"status=0x{response.Status:X2}."; } - private static double RadiansToDegrees(double radians) - { - return radians * 180.0 / Math.PI; - } - /// /// 为单次稠密发送创建按日期时间归档的输出目录。 /// @@ -886,98 +819,6 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable return row; } - /// - /// 构造实发时间映射文本行,格式为 sample_index + send_time + trajectory_time + speed_ratio。 - /// - private static IReadOnlyList BuildDenseSendTimingRow( - long sampleIndex, - double sendTime, - double trajectoryTime, - double speedRatio) - { - return - [ - sampleIndex, - Math.Round(sendTime, 6), - Math.Round(trajectoryTime, 6), - Math.Round(speedRatio, 6) - ]; - } - - /// - /// 构造相邻发送点之间的跃度统计行,格式为 start_time + end_time + dt + max_abs_jerk + jerk[j1..jn]。 - /// 通过 ref 参数维护前两帧的速度和加速度状态,从而用二阶后向差分近似跃度。 - /// - /// 上一帧时间戳(秒)。 - /// 当前帧时间戳(秒)。 - /// 上一帧 J519 角度制关节目标。 - /// 当前帧 J519 角度制关节目标。 - /// 上一帧关节速度,ref 更新为当前帧速度。 - /// 上一帧关节加速度,ref 更新为当前帧加速度。 - /// 长度为 jointCount + 4 的行向量。 - private static IReadOnlyList BuildDenseSendJerkRow( - double previousTime, - double currentTime, - IReadOnlyList previousJoints, - IReadOnlyList currentJoints, - ref double[]? previousVelocity, - ref double[]? previousAcceleration) - { - // 1. 计算时间步长 dt;若相邻帧时间戳相同(同一批次下发),兜底为极小值防止除零 - var dt = currentTime - previousTime; - if (dt <= 0.0) - { - dt = 1e-9; - } - - // 2. 初始化当前帧的速度、加速度、跃度数组,以及本帧最大绝对值跃度追踪变量 - var jointCount = currentJoints.Count; - var currentVelocity = new double[jointCount]; - var currentAcceleration = new double[jointCount]; - var currentJerk = new double[jointCount]; - var maxAbsJerk = 0.0; - - // 3. 逐关节计算数值微分:一阶后向差分求速度,二阶后向差分求加速度,三阶后向差分求跃度 - // 由于跃度需要加速度历史,前两帧的加速度不可用时对应的跃度保持为 0 - for (var index = 0; index < jointCount; index++) - { - // 3a. 一阶差分:v(t) = (q(t) - q(t-1)) / dt - currentVelocity[index] = (currentJoints[index] - previousJoints[index]) / dt; - - // 3b. 二阶差分:a(t) = (v(t) - v(t-1)) / dt;至少需要上一帧速度数据 - if (previousVelocity is not null) - { - currentAcceleration[index] = (currentVelocity[index] - previousVelocity[index]) / dt; - } - - // 3c. 三阶差分:j(t) = (a(t) - a(t-1)) / dt;至少需要上一帧加速度数据 - if (previousAcceleration is not null) - { - currentJerk[index] = (currentAcceleration[index] - previousAcceleration[index]) / dt; - // 3d. 追踪所有关节中绝对值最大的跃度,用于后续评估轨迹平滑度 - maxAbsJerk = Math.Max(maxAbsJerk, Math.Abs(currentJerk[index])); - } - } - - // 4. 将当前帧的速度和加速度保存为"上一帧"值,供下一帧调用时继续做差分 - previousVelocity = currentVelocity; - previousAcceleration = currentAcceleration; - - // 5. 组装输出行:前 4 列为起始时间、结束时间、步长、最大绝对值跃度,之后按关节顺序排列各关节跃度 - // 格式设计兼容旧版 JointDetialTraj.txt 的导出风格,便于对比验证 - 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; - } - /// /// 尝试把实际发送点位、时间映射和跃度统计写入纯文本文件;若落盘失败,只记录日志,不影响运动主流程。 /// diff --git a/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj b/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj index fb0f8ff..487c72c 100644 --- a/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj +++ b/src/Flyshot.Runtime.Fanuc/Flyshot.Runtime.Fanuc.csproj @@ -18,6 +18,7 @@ + diff --git a/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs b/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs index 55f5812..483ba07 100644 --- a/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs +++ b/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs @@ -719,7 +719,7 @@ public sealed class LegacyHttpApiController : ControllerBase _compatService.SetActiveController(data.sim); _compatService.Connect(data.robot_ip); - _compatService.EnableRobot(4); + _compatService.EnableRobot(8); _logger.LogInformation("InitMpcRobot 成功: robot_name={RobotName}", data.robot_name); return Ok(new { message = "init_Success", returnCode = 0 }); } diff --git a/tests/Flyshot.Core.Tests/ConfigCompatibilityTests.cs b/tests/Flyshot.Core.Tests/ConfigCompatibilityTests.cs index ed2a3ba..36bd11f 100644 --- a/tests/Flyshot.Core.Tests/ConfigCompatibilityTests.cs +++ b/tests/Flyshot.Core.Tests/ConfigCompatibilityTests.cs @@ -1,10 +1,9 @@ using Flyshot.Core.Config; -using Flyshot.Core.Domain; namespace Flyshot.Core.Tests; /// -/// 锁定 Task 3 的兼容输入行为,确保旧配置、.robot 元数据和路径策略都能被稳定加载。 +/// 锁定 Task 3 的兼容输入行为,确保旧配置、JSON 模型元数据和路径策略都能被稳定加载。 /// public sealed class ConfigCompatibilityTests { @@ -25,6 +24,7 @@ public sealed class ConfigCompatibilityTests Assert.Equal(1.0, loaded.Robot.AccLimitScale); Assert.Equal(1.0, loaded.Robot.JerkLimitScale); Assert.Equal(1.0, loaded.Robot.PlanningSpeedScale); + Assert.True(loaded.Robot.SmoothStartStopTiming); Assert.Equal(5, loaded.Robot.AdaptIcspTryNum); var program = Assert.Contains("EOL10_EAU_0", loaded.Programs); @@ -73,6 +73,7 @@ public sealed class ConfigCompatibilityTests Assert.Equal(0.5, loaded.Robot.AccLimitScale); Assert.Equal(0.25, loaded.Robot.JerkLimitScale); Assert.Equal(1.0, loaded.Robot.PlanningSpeedScale); + Assert.True(loaded.Robot.SmoothStartStopTiming); Assert.Equal([0, 0, 0], program.OffsetValues); Assert.All(program.AddressGroups, group => Assert.Empty(group.Addresses)); } @@ -123,13 +124,53 @@ public sealed class ConfigCompatibilityTests } /// - /// 验证 .robot 解析会保留 Joint3 对 Joint2 的 couple 元数据,并构造规划侧可直接消费的 RobotProfile。 + /// 验证 RobotConfig.json 可以关闭飞拍执行前的二次平滑起停时间重映射。 + /// + [Fact] + public void RobotConfigLoader_LoadsSmoothStartStopTimingSwitch() + { + var tempRoot = CreateTempDirectory(); + try + { + var configPath = Path.Combine(tempRoot, "legacy.json"); + File.WriteAllText( + configPath, + """ + { + "robot": { + "use_do": true, + "io_keep_cycles": 2, + "acc_limit": 1.0, + "jerk_limit": 1.0, + "smooth_start_stop_timing": false + }, + "flying_shots": { + "demo": { + "traj_waypoints": [[0, 1], [2, 3], [4, 5], [6, 7]], + "shot_flags": [false, false, false, false] + } + } + } + """); + + var loaded = new RobotConfigLoader().Load(configPath); + + Assert.False(loaded.Robot.SmoothStartStopTiming); + } + finally + { + Directory.Delete(tempRoot, recursive: true); + } + } + + /// + /// 验证 JSON 模型解析会保留 Joint3 对 Joint2 的 couple 元数据,并构造规划侧可直接消费的 RobotProfile。 /// [Fact] public void RobotModelLoader_LoadsRobotProfile_WithJointLimitsAndCoupling() { - var workspaceRoot = GetWorkspaceRoot(); - var modelPath = Path.Combine(workspaceRoot, "FlyingShot", "FlyingShot", "Models", "LR_Mate_200iD_7L.robot"); + var replacementRoot = GetReplacementRoot(); + var modelPath = Path.Combine(replacementRoot, "Config", "LR_Mate_200iD_7L.json"); var profile = new RobotModelLoader().LoadProfile(modelPath); @@ -147,13 +188,13 @@ public sealed class ConfigCompatibilityTests } /// - /// 验证 RobotConfig 中的 acc_limit 和 jerk_limit 乘子会正确叠加到模型关节限制上。 + /// 验证 RobotConfig 中的 acc_limit 和 jerk_limit 乘子会正确叠加到 JSON 模型关节限制上。 /// [Fact] public void RobotModelLoader_AppliesAccelerationAndJerkScales() { - var workspaceRoot = GetWorkspaceRoot(); - var modelPath = Path.Combine(workspaceRoot, "FlyingShot", "FlyingShot", "Models", "LR_Mate_200iD_7L.robot"); + var replacementRoot = GetReplacementRoot(); + var modelPath = Path.Combine(replacementRoot, "Config", "LR_Mate_200iD_7L.json"); var profile = new RobotModelLoader().LoadProfile(modelPath, accLimitScale: 0.5, jerkLimitScale: 0.25); @@ -161,6 +202,28 @@ public sealed class ConfigCompatibilityTests Assert.Equal(62.115, profile.JointLimits[2].JerkLimit, precision: 3); } + /// + /// 验证 JSON 模型可一次解析后同时生成规划约束视图和运动学几何视图。 + /// + [Fact] + public void RobotModelLoader_LoadsProfileAndKinematics_FromSingleParse() + { + var replacementRoot = GetReplacementRoot(); + var modelPath = Path.Combine(replacementRoot, "Config", "LR_Mate_200iD_7L.json"); + + var loaded = new RobotModelLoader().LoadProfileAndKinematics(modelPath, accLimitScale: 0.5, jerkLimitScale: 0.25); + + Assert.Equal("FANUC_LR_Mate_200iD_7L", loaded.Profile.Name); + Assert.Equal(modelPath, loaded.Profile.ModelPath); + Assert.Equal(6, loaded.Profile.DegreesOfFreedom); + Assert.Equal(14.905, loaded.Profile.JointLimits[2].AccelerationLimit, precision: 3); + Assert.Equal(62.115, loaded.Profile.JointLimits[2].JerkLimit, precision: 3); + + Assert.Equal("FANUC_LR_Mate_200iD_7L", loaded.KinematicsModel.Name); + Assert.True(loaded.KinematicsModel.Joints.Count >= loaded.Profile.DegreesOfFreedom); + Assert.Contains(loaded.KinematicsModel.Joints, static joint => joint.Name == "Joint3" && joint.CoupleMaster == "Joint2"); + } + /// /// 验证路径兼容层只从当前服务配置目录解析相对配置,并按平台策略生成默认用户数据目录。 /// diff --git a/tests/Flyshot.Core.Tests/ControllerClientCompatConfigRootTests.cs b/tests/Flyshot.Core.Tests/ControllerClientCompatConfigRootTests.cs index f4c7938..dd1df01 100644 --- a/tests/Flyshot.Core.Tests/ControllerClientCompatConfigRootTests.cs +++ b/tests/Flyshot.Core.Tests/ControllerClientCompatConfigRootTests.cs @@ -34,7 +34,7 @@ public sealed class ControllerClientCompatConfigRootTests } /// - /// 验证机器人目录优先从显式 ConfigRoot/Models 加载 .robot 文件。 + /// 验证机器人目录优先从显式 ConfigRoot/Models 加载现场 JSON 模型文件。 /// [Fact] public void ControllerClientCompatRobotCatalog_LoadsModelFromConfigRootModels() @@ -48,7 +48,7 @@ public sealed class ControllerClientCompatConfigRootTests var profile = catalog.LoadProfile("FANUC_LR_Mate_200iD"); - Assert.Equal(Path.Combine(configRoot, "Models", "LR_Mate_200iD_7L.robot"), profile.ModelPath); + Assert.Equal(Path.Combine(configRoot, "Models", "LR_Mate_200iD_7L.json"), profile.ModelPath); } finally { @@ -117,15 +117,15 @@ public sealed class ControllerClientCompatConfigRootTests } /// - /// 复制仓库内已固化的现场机器人模型到临时 Config/Models 目录。 + /// 复制仓库内已固化的现场机器人 JSON 模型到临时 Config/Models 目录。 /// private static void CopySampleRobotModel(string configRoot) { var modelDir = Path.Combine(configRoot, "Models"); Directory.CreateDirectory(modelDir); File.Copy( - Path.Combine(GetReplacementRoot(), "Config", "Models", "LR_Mate_200iD_7L.robot"), - Path.Combine(modelDir, "LR_Mate_200iD_7L.robot")); + Path.Combine(GetReplacementRoot(), "Config", "Models", "LR_Mate_200iD_7L.json"), + Path.Combine(modelDir, "LR_Mate_200iD_7L.json")); } /// diff --git a/tests/Flyshot.Core.Tests/FanucControllerRuntimeDenseTests.cs b/tests/Flyshot.Core.Tests/FanucControllerRuntimeDenseTests.cs index be70e27..c71b565 100644 --- a/tests/Flyshot.Core.Tests/FanucControllerRuntimeDenseTests.cs +++ b/tests/Flyshot.Core.Tests/FanucControllerRuntimeDenseTests.cs @@ -237,9 +237,9 @@ public sealed class FanucControllerRuntimeDenseTests var bundle = orchestrator.PlanUploadedFlyshot( fixture.Robot, fixture.Uploaded, - settings: fixture.Settings, - planningSpeedScale: 1.0); - var outputRoot = Path.Combine(AppContext.BaseDirectory, "Config", "Data", bundle.Result.ProgramName); + settings: fixture.Settings); + var result = WithUniqueProgramName(bundle.Result, $"UTTC_MS11_legacyfit_{Guid.NewGuid():N}"); + var outputRoot = Path.Combine(AppContext.BaseDirectory, "Config", "Data", result.ProgramName); var denseSendRoot = Path.Combine(outputRoot, "DenseSend"); var beforeRunDirectories = Directory.Exists(denseSendRoot) ? Directory.GetDirectories(denseSendRoot).ToHashSet(StringComparer.OrdinalIgnoreCase) @@ -249,7 +249,7 @@ public sealed class FanucControllerRuntimeDenseTests j519Client.EnableCommandHistoryForTests(); ForceRealModeEnabled(runtime, speedRatio: 1.0); - runtime.ExecuteTrajectory(bundle.Result, bundle.Result.DenseJointTrajectory![0].Skip(1).ToArray()); + runtime.ExecuteTrajectory(result, result.DenseJointTrajectory![0].Skip(1).ToArray()); WaitUntilIdle(runtime); var runDirectory = GetNewDenseSendRunDirectory(outputRoot, beforeRunDirectories); @@ -269,6 +269,8 @@ public sealed class FanucControllerRuntimeDenseTests Assert.NotEmpty(pointsLines); Assert.NotEmpty(timingLines); Assert.NotEmpty(jerkLines); + Assert.Equal(927, pointsLines.Length); + Assert.Equal(927, timingLines.Length); var firstPoint = ParseColumns(pointsLines[0]); var secondPoint = ParseColumns(pointsLines[1]); @@ -282,7 +284,7 @@ public sealed class FanucControllerRuntimeDenseTests const double windowHalfWidth = 0.024; var summaryLines = new List { - $"program={bundle.Result.ProgramName}", + $"program={result.ProgramName}", $"send_time_target_seconds={targetSendTime:F6}", $"window_half_width_seconds={windowHalfWidth:F6}", $"points_path={pointsPath}", @@ -1121,16 +1123,8 @@ public sealed class FanucControllerRuntimeDenseTests /// private static UttcMs11RuntimeFixture LoadUttcMs11RuntimeFixture() { - var configPath = Path.Combine( - TestRobotFactory.GetReplacementRoot(), - "src", - "Flyshot.Server.Host", - "bin", - "Debug", - "net8.0", - "Config", - "RobotConfig.json"); - var configRoot = Path.GetDirectoryName(configPath)!; + var configRoot = TestRobotFactory.GetConfigRoot(); + var configPath = Path.Combine(configRoot, "RobotConfig.json"); var loaded = new RobotConfigLoader().Load(configPath, configRoot); var program = loaded.Programs["UTTC_MS11"]; var uploaded = new ControllerClientCompatUploadedTrajectory( @@ -1149,6 +1143,26 @@ public sealed class FanucControllerRuntimeDenseTests return new UttcMs11RuntimeFixture(configRoot, loaded.Robot, uploaded, robot); } + /// + /// 复制一份结果并替换程序名,让 DenseSend 调试文件写入唯一目录,避免测试之间复用旧目录权限。 + /// + private static TrajectoryResult WithUniqueProgramName(TrajectoryResult result, string programName) + { + return new TrajectoryResult( + programName: programName, + method: result.Method, + isValid: result.IsValid, + duration: result.Duration, + shotEvents: result.ShotEvents, + triggerTimeline: result.TriggerTimeline, + artifacts: result.Artifacts, + failureReason: result.FailureReason, + usedCache: result.UsedCache, + originalWaypointCount: result.OriginalWaypointCount, + plannedWaypointCount: result.PlannedWaypointCount, + denseJointTrajectory: result.DenseJointTrajectory); + } + /// /// 解析空格分隔的纯文本数值列。 /// diff --git a/tests/Flyshot.Core.Tests/J519SendTrajectorySamplerTests.cs b/tests/Flyshot.Core.Tests/J519SendTrajectorySamplerTests.cs new file mode 100644 index 0000000..a89fde2 --- /dev/null +++ b/tests/Flyshot.Core.Tests/J519SendTrajectorySamplerTests.cs @@ -0,0 +1,99 @@ +using Flyshot.Core.Planning.Sampling; + +namespace Flyshot.Core.Tests; + +/// +/// 验证 J519 实发重采样器在离线导出和运行时下发之间保持一致的时间轴语义。 +/// +public sealed class J519SendTrajectorySamplerTests +{ + /// + /// 验证 speed_ratio 只缩放轨迹时间,物理发送时间仍按固定伺服周期推进。 + /// + [Fact] + public void SampleDenseJointTrajectory_MapsSendTimeToScaledTrajectoryTimeAndDegrees() + { + var denseTrajectory = new[] + { + new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, + new[] { 0.008, Math.PI / 2.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, + new[] { 0.016, Math.PI, 0.0, 0.0, 0.0, 0.0, 0.0 } + }; + + var samples = J519SendTrajectorySampler.SampleDenseJointTrajectory( + denseTrajectory, + durationSeconds: 0.016, + servoPeriodSeconds: 0.008, + speedRatio: 0.5); + + Assert.Equal(5, samples.Count); + Assert.Equal(0, samples[0].SampleIndex); + Assert.Equal(0.0, samples[0].SendTime, precision: 6); + Assert.Equal(0.0, samples[0].TrajectoryTime, precision: 6); + Assert.Equal(0.0, samples[0].JointsDegrees[0], precision: 6); + + Assert.Equal(1, samples[1].SampleIndex); + Assert.Equal(0.008, samples[1].SendTime, precision: 6); + Assert.Equal(0.004, samples[1].TrajectoryTime, precision: 6); + Assert.Equal(45.0, samples[1].JointsDegrees[0], precision: 6); + + Assert.Equal(4, samples[^1].SampleIndex); + Assert.Equal(0.032, samples[^1].SendTime, precision: 6); + Assert.Equal(0.016, samples[^1].TrajectoryTime, precision: 6); + Assert.Equal(180.0, samples[^1].JointsDegrees[0], precision: 6); + } + + /// + /// 验证空稠密轨迹会直接暴露为调用错误,避免生成无意义下发点。 + /// + [Fact] + public void SampleDenseJointTrajectory_RejectsEmptyDenseTrajectory() + { + var exception = Assert.Throws(() => + J519SendTrajectorySampler.SampleDenseJointTrajectory( + Array.Empty>(), + durationSeconds: 0.016, + servoPeriodSeconds: 0.008, + speedRatio: 1.0)); + + Assert.Contains("稠密关节轨迹为空", exception.Message); + } + + /// + /// 验证非法 speed_ratio 会在公共入口统一拦截。 + /// + [Theory] + [InlineData(0.0)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void SampleDenseJointTrajectory_RejectsInvalidSpeedRatio(double speedRatio) + { + var denseTrajectory = new[] + { + new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 } + }; + + Assert.Throws(() => + J519SendTrajectorySampler.SampleDenseJointTrajectory( + denseTrajectory, + durationSeconds: 0.0, + servoPeriodSeconds: 0.008, + speedRatio: speedRatio)); + } + + /// + /// 验证公共诊断行格式与既有 ActualSendTiming 文件保持一致。 + /// + [Fact] + public void BuildTimingRow_UsesLegacyActualSendColumnOrder() + { + var row = J519SendTrajectorySampler.BuildTimingRow(new J519SendSample( + sampleIndex: 2, + sendTime: 0.016, + trajectoryTime: 0.008, + speedRatio: 0.5, + jointsDegrees: [90.0, 0.0, 0.0, 0.0, 0.0, 0.0])); + + Assert.Equal([2.0, 0.016, 0.008, 0.5], row); + } +} diff --git a/tests/Flyshot.Core.Tests/OfflinePlanTests.cs b/tests/Flyshot.Core.Tests/OfflinePlanTests.cs index dc92379..74aeb7b 100644 --- a/tests/Flyshot.Core.Tests/OfflinePlanTests.cs +++ b/tests/Flyshot.Core.Tests/OfflinePlanTests.cs @@ -2,7 +2,6 @@ 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 Flyshot.Core.Triggering; using Xunit.Abstractions; @@ -24,12 +23,11 @@ public sealed class OfflinePlanTests /// 使用预设参数生成离线轨迹,输出到 analysis/output/dotnet/。 /// [Theory] - [InlineData("Rvbust/EOL9 EAU 90/eol9_eau_90.json", "FlyingShot/FlyingShot/Models/LR_Mate_200iD_7L.robot", "EOL9_EAU_90", false, 1.0)] - [InlineData("Rvbust/EOL9 EAU 90/eol9_eau_90.json", "FlyingShot/FlyingShot/Models/LR_Mate_200iD_7L.robot", "EOL9_EAU_90", true, 0.9)] - [InlineData("Rvbust/EOL10_EAU_0/EOL10_EAU_0.json", "FlyingShot/FlyingShot/Models/LR_Mate_200iD_7L.robot", "EOL10_EAU_0", false, 1.0)] + [InlineData("Rvbust/EOL9 EAU 90/eol9_eau_90.json", "EOL9_EAU_90", false, 1.0)] + [InlineData("Rvbust/EOL9 EAU 90/eol9_eau_90.json", "EOL9_EAU_90", true, 0.9)] + [InlineData("Rvbust/EOL10_EAU_0/EOL10_EAU_0.json", "EOL10_EAU_0", false, 1.0)] public void GenerateTrajectory_MatchesPythonDemo( string configPath, - string robotModelPath, string trajName, bool useSelfAdapt, double speedRatio) @@ -42,9 +40,13 @@ public sealed class OfflinePlanTests // 1. 加载配置和模型。 var loadedConfig = new RobotConfigLoader().Load(resolvedConfigPath, repoRoot: workspaceRoot); var program = loadedConfig.Programs[trajName]; - var resolvedRobotModelPath = Path.Combine(workspaceRoot, robotModelPath); - var baseProfile = new RobotModelLoader().LoadProfile(resolvedRobotModelPath, loadedConfig.Robot.AccLimitScale, loadedConfig.Robot.JerkLimitScale); - var kinematicsModel = new RobotModelLoader().LoadKinematicsModel(resolvedRobotModelPath); + var resolvedRobotModelPath = Path.Combine(workspaceRoot, "flyshot-replacement", "Config", "LR_Mate_200iD_7L.json"); + var loadedModel = new RobotModelLoader().LoadProfileAndKinematics( + resolvedRobotModelPath, + loadedConfig.Robot.AccLimitScale, + loadedConfig.Robot.JerkLimitScale); + var baseProfile = loadedModel.Profile; + var kinematicsModel = loadedModel.KinematicsModel; // 2. 应用 speed_ratio 缩放。 var scaledProfile = ScaleRobotProfile(baseProfile, speedRatio); diff --git a/tests/Flyshot.Core.Tests/PlanningCompatibilityTests.cs b/tests/Flyshot.Core.Tests/PlanningCompatibilityTests.cs index 468740f..ac4a9c0 100644 --- a/tests/Flyshot.Core.Tests/PlanningCompatibilityTests.cs +++ b/tests/Flyshot.Core.Tests/PlanningCompatibilityTests.cs @@ -66,7 +66,7 @@ public sealed class PlanningCompatibilityTests { var workspaceRoot = GetWorkspaceRoot(); var configPath = Path.Combine(workspaceRoot, "Rvbust", "EOL9 EAU 90", "eol9_eau_90.json"); - var modelPath = Path.Combine(workspaceRoot, "FlyingShot", "FlyingShot", "Models", "LR_Mate_200iD_7L.robot"); + var modelPath = Path.Combine(workspaceRoot, "flyshot-replacement", "Config", "LR_Mate_200iD_7L.json"); var config = new RobotConfigLoader().Load(configPath); var baseProfile = new RobotModelLoader().LoadProfile(modelPath, config.Robot.AccLimitScale, config.Robot.JerkLimitScale); diff --git a/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs b/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs index e63a0cb..41f4d1f 100644 --- a/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs +++ b/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs @@ -287,7 +287,7 @@ public sealed class RuntimeOrchestrationTests var bundle = orchestrator.PlanUploadedFlyshot( fixture.Robot, fixture.Uploaded, - settings: fixture.Settings, + settings: EnableSmoothStartStopTiming(fixture.Settings), planningSpeedScale: 1.0); Assert.Equal("UTTC_MS11", bundle.Result.ProgramName); @@ -304,6 +304,45 @@ public sealed class RuntimeOrchestrationTests AssertJointRadiansEqual(fixture.Uploaded.Waypoints[^1], bundle.Result.DenseJointTrajectory[^1].Skip(1).ToArray()); } + /// + /// 验证现场 UTTC_MS11 配置使用通用规划速度倍率拉长时间轴,并关闭二次平滑时间重映射。 + /// + [Fact] + public void ControllerClientTrajectoryOrchestrator_PlanUploadedFlyshot_FromRuntimeLegacyFitConfig_UsesPlanningScaleWithoutSecondRemap() + { + var fixture = LoadUttcMs11RuntimeFixture(); + var orchestrator = new ControllerClientTrajectoryOrchestrator(); + + var bundle = orchestrator.PlanUploadedFlyshot( + fixture.Robot, + fixture.Uploaded, + settings: fixture.Settings); + var baselineBundle = orchestrator.PlanUploadedFlyshot( + fixture.Robot, + fixture.Uploaded, + settings: EnableSmoothStartStopTiming(fixture.Settings), + planningSpeedScale: 1.0); + var rawDense = TrajectorySampler.SampleJointTrajectory( + bundle.PlannedTrajectory, + samplePeriod: fixture.Robot.ServoPeriod.TotalSeconds); + + Assert.Equal(0.7422771653721995, fixture.Settings.PlanningSpeedScale, precision: 12); + Assert.False(fixture.Settings.SmoothStartStopTiming); + Assert.Equal(fixture.Uploaded.Waypoints.Count, bundle.PlannedTrajectory.WaypointTimes.Count); + Assert.Equal( + baselineBundle.PlannedTrajectory.WaypointTimes[^1] / fixture.Settings.PlanningSpeedScale, + bundle.PlannedTrajectory.WaypointTimes[^1], + precision: 6); + Assert.Equal(7.403046, bundle.PlannedTrajectory.WaypointTimes[^1], precision: 3); + + // 关闭二次时间重映射时,运行时稠密点应直接使用规划样条采样,避免再次改变通用规划时间轴。 + Assert.Equal(rawDense.Count, bundle.Result.DenseJointTrajectory!.Count); + Assert.Equal(rawDense[1][1], bundle.Result.DenseJointTrajectory[1][1], precision: 12); + Assert.True( + Math.Abs(bundle.PlannedTrajectory.WaypointTimes[^1] - bundle.Result.Duration.TotalSeconds) < 1e-6, + $"执行时长应保留规划终点时间,planned={bundle.PlannedTrajectory.WaypointTimes[^1]}, result={bundle.Result.Duration.TotalSeconds}"); + } + /// /// 使用运行时 RobotConfig.json 中的真实 UTTC_MS11 示教点,导出首尾 10 点整形前后的逐点对比数据, /// 便于和现场报警时间段逐项核对关节、速度、加速度与跃度。 @@ -347,6 +386,7 @@ public sealed class RuntimeOrchestrationTests { $"program=UTTC_MS11", $"planning_speed_scale=1.000000", + $"smooth_start_stop_timing=true", $"servo_period_seconds={fixture.Robot.ServoPeriod.TotalSeconds.ToString("F6", CultureInfo.InvariantCulture)}", $"dense_point_count={shapedDense.Count}", $"leading_first_step_raw_deg={ComputeStepDegrees(rawDense, 1, 0).ToString("F6", CultureInfo.InvariantCulture)}", @@ -380,7 +420,7 @@ public sealed class RuntimeOrchestrationTests var bundle = orchestrator.PlanUploadedFlyshot( fixture.Robot, fixture.Uploaded, - settings: fixture.Settings, + settings: EnableSmoothStartStopTiming(fixture.Settings), planningSpeedScale: 1.0); var rawDense = TrajectorySampler.SampleJointTrajectory( bundle.PlannedTrajectory, @@ -412,7 +452,7 @@ public sealed class RuntimeOrchestrationTests var bundle = orchestrator.PlanUploadedFlyshot( fixture.Robot, fixture.Uploaded, - settings: fixture.Settings, + settings: EnableSmoothStartStopTiming(fixture.Settings), planningSpeedScale: 1.0); var rawDense = TrajectorySampler.SampleJointTrajectory( bundle.PlannedTrajectory, @@ -874,7 +914,55 @@ public sealed class RuntimeOrchestrationTests } /// - /// 创建只包含当前支持机器人模型和 RobotConfig.json 的临时运行配置根。 + /// 验证 SaveTrajectoryInfo 会同时导出按 J519 8ms 实发周期重采样的点位,并应用当前 speed_ratio。 + /// + [Fact] + public void ControllerClientCompatService_SaveTrajectoryInfo_ExportsActualSendRowsWithSpeedRatio() + { + var configRoot = CreateTempConfigRoot(); + try + { + WriteRobotConfigWithDemoTrajectory(configRoot); + var options = new ControllerClientCompatOptions { ConfigRoot = configRoot }; + var runtime = new RecordingControllerRuntime(); + var service = new ControllerClientCompatService( + options, + new ControllerClientCompatRobotCatalog(options, new RobotModelLoader()), + runtime, + new ControllerClientTrajectoryOrchestrator(), + new RobotConfigLoader()); + + service.SetUpRobot("FANUC_LR_Mate_200iD"); + runtime.SetSpeedRatio(0.5); + + service.SaveTrajectoryInfo("demo-flyshot"); + + var outputDir = Path.Combine(configRoot, "Data", "demo-flyshot"); + var pointsPath = Path.Combine(outputDir, "ActualSendJointTraj.txt"); + var timingPath = Path.Combine(outputDir, "ActualSendTiming.txt"); + Assert.True(File.Exists(pointsPath)); + Assert.True(File.Exists(timingPath)); + + var pointRows = File.ReadAllLines(pointsPath).Select(ParseSpaceSeparatedDoubles).ToArray(); + var timingRows = File.ReadAllLines(timingPath).Select(ParseSpaceSeparatedDoubles).ToArray(); + var duration = double.Parse(File.ReadLines(Path.Combine(outputDir, "JointTraj.txt")).Last().Split(' ')[0], CultureInfo.InvariantCulture); + var expectedRows = (int)Math.Ceiling(Math.Max(0.0, (duration / (0.008 * 0.5)) - 1e-9)) + 1; + + Assert.Equal(expectedRows, pointRows.Length); + Assert.Equal(expectedRows, timingRows.Length); + Assert.Equal(0.0, pointRows[0][0], precision: 6); + Assert.Equal(0.008, pointRows[1][0], precision: 6); + Assert.Equal(0.004, timingRows[1][2], precision: 6); + Assert.Equal(0.5, timingRows[1][3], precision: 6); + } + finally + { + Directory.Delete(configRoot, recursive: true); + } + } + + /// + /// 创建只包含当前支持机器人 JSON 模型和 RobotConfig.json 的临时运行配置根。 /// private static string CreateTempConfigRoot() { @@ -886,8 +974,8 @@ public sealed class RuntimeOrchestrationTests TestRobotFactory.GetReplacementRoot(), "Config", "Models", - "LR_Mate_200iD_7L.robot"); - File.Copy(sourceModel, Path.Combine(modelDir, "LR_Mate_200iD_7L.robot")); + "LR_Mate_200iD_7L.json"); + File.Copy(sourceModel, Path.Combine(modelDir, "LR_Mate_200iD_7L.json")); return configRoot; } @@ -960,16 +1048,8 @@ public sealed class RuntimeOrchestrationTests /// private static UttcMs11RuntimeFixture LoadUttcMs11RuntimeFixture() { - var configPath = Path.Combine( - TestRobotFactory.GetReplacementRoot(), - "src", - "Flyshot.Server.Host", - "bin", - "Debug", - "net8.0", - "Config", - "RobotConfig.json"); - var configRoot = Path.GetDirectoryName(configPath)!; + var configRoot = TestRobotFactory.GetConfigRoot(); + var configPath = Path.Combine(configRoot, "RobotConfig.json"); var loaded = new RobotConfigLoader().Load(configPath, configRoot); var program = loaded.Programs["UTTC_MS11"]; var uploaded = new ControllerClientCompatUploadedTrajectory( @@ -988,6 +1068,22 @@ public sealed class RuntimeOrchestrationTests return new UttcMs11RuntimeFixture(configRoot, loaded.Robot, uploaded, robot); } + /// + /// 为首尾平滑对比测试显式打开二次时间重映射,避免受现场 legacy-fit 配置影响。 + /// + private static CompatibilityRobotSettings EnableSmoothStartStopTiming(CompatibilityRobotSettings settings) + { + return new CompatibilityRobotSettings( + useDo: settings.UseDo, + ioAddresses: settings.IoAddresses, + ioKeepCycles: settings.IoKeepCycles, + accLimitScale: settings.AccLimitScale, + jerkLimitScale: settings.JerkLimitScale, + adaptIcspTryNum: settings.AdaptIcspTryNum, + planningSpeedScale: settings.PlanningSpeedScale, + smoothStartStopTiming: true); + } + /// /// 把指定窗口内的整形前后逐点数据导出为 CSV,包含关节、步长、速度、加速度与跃度。 /// @@ -1412,6 +1508,8 @@ internal static class TestRobotFactory /// internal sealed class RecordingControllerRuntime : IControllerRuntime { + private double _speedRatio = 1.0; + /// /// 获取最近一次 ResetRobot 收到的机器人配置。 /// @@ -1454,11 +1552,12 @@ internal sealed class RecordingControllerRuntime : IControllerRuntime } /// - public double GetSpeedRatio() => 1.0; + public double GetSpeedRatio() => _speedRatio; /// public void SetSpeedRatio(double ratio) { + _speedRatio = ratio; } /// @@ -1491,7 +1590,7 @@ internal sealed class RecordingControllerRuntime : IControllerRuntime connectionState: "Connected", isEnabled: true, isInMotion: false, - speedRatio: 1.0, + speedRatio: _speedRatio, jointPositions: Array.Empty(), cartesianPose: Array.Empty(), activeAlarms: Array.Empty());