using System; using System.Collections.Generic; using MultiWheelC.TrajectoryPlanning.CoarsePath.Facade; using MultiWheelC.TrajectoryPlanning.Mapping; namespace MultiWheelC.TrajectoryPlanning.CoarsePath.Test; /// Clumsy 粗路径手动测试可选择的固定场景。 public enum CoarsePathTestScenario { /// 明确允许的空地图直达场景。 ExplicitEmpty, /// 由中央矩形阻断直线的绕行场景。 RectangleDetour, /// 同时包含手工圆形、矩形与 TwoLeg 快照的多来源场景。 ManualAndTwoLeg, /// 与矩形绕行输入完全一致,用于在同一服务中验证输入缓存命中。 CacheHit, /// 起步前进、终点倒车进入的换向场景。 ReverseGearSwitch, /// 由贯穿边界的障碍带分隔起终点的无解场景。 NoFeasiblePath, } /// 手动障碍物输入支持的世界几何类型。 public enum ManualCoarsePathObstacleKind { /// 由世界中心和半径定义的圆形障碍物。 Circle, /// 由世界中心、X 方向长度和 Y 方向宽度定义的轴对齐矩形障碍物。 AxisAlignedRectangle, } /// /// 手动粗路径测试的不可变障碍物输入。 /// 所有中心和尺寸均使用世界 mm;矩形始终与世界坐标轴平行,不包含旋转角。 /// public sealed class ManualCoarsePathObstacle { private ManualCoarsePathObstacle(ManualCoarsePathObstacleKind kind, double centerXMillimeters, double centerYMillimeters, double sizeXMillimeters, double sizeYMillimeters) { Kind = kind; CenterXMillimeters = centerXMillimeters; CenterYMillimeters = centerYMillimeters; SizeXMillimeters = sizeXMillimeters; SizeYMillimeters = sizeYMillimeters; } /// 障碍物的支持几何类型。 public ManualCoarsePathObstacleKind Kind { get; } /// 几何中心世界 X 坐标,单位 mm。 public double CenterXMillimeters { get; } /// 几何中心世界 Y 坐标,单位 mm。 public double CenterYMillimeters { get; } /// 圆形时为半径,矩形时为 X 方向长度;单位 mm。 public double SizeXMillimeters { get; } /// 圆形时为半径,矩形时为 Y 方向宽度;单位 mm。 public double SizeYMillimeters { get; } /// 创建圆形障碍物。参数:圆心和半径均使用世界 mm,半径必须为有限正数。 public static ManualCoarsePathObstacle Circle(double centerXMillimeters, double centerYMillimeters, double radiusMillimeters) { EnsureFinite(centerXMillimeters, nameof(centerXMillimeters)); EnsureFinite(centerYMillimeters, nameof(centerYMillimeters)); EnsurePositiveFinite(radiusMillimeters, nameof(radiusMillimeters)); return new ManualCoarsePathObstacle(ManualCoarsePathObstacleKind.Circle, centerXMillimeters, centerYMillimeters, radiusMillimeters, radiusMillimeters); } /// /// 创建轴对齐矩形障碍物。 /// 参数:中心、X 方向长度和 Y 方向宽度均使用世界 mm;两个尺寸必须为有限正数。 /// public static ManualCoarsePathObstacle AxisAlignedRectangle(double centerXMillimeters, double centerYMillimeters, double lengthXMillimeters, double widthYMillimeters) { EnsureFinite(centerXMillimeters, nameof(centerXMillimeters)); EnsureFinite(centerYMillimeters, nameof(centerYMillimeters)); EnsurePositiveFinite(lengthXMillimeters, nameof(lengthXMillimeters)); EnsurePositiveFinite(widthYMillimeters, nameof(widthYMillimeters)); return new ManualCoarsePathObstacle(ManualCoarsePathObstacleKind.AxisAlignedRectangle, centerXMillimeters, centerYMillimeters, lengthXMillimeters, widthYMillimeters); } private static void EnsureFinite(double value, string parameterName) { if (double.IsNaN(value) || double.IsInfinity(value)) throw new ArgumentOutOfRangeException(parameterName, "Value must be finite."); } private static void EnsurePositiveFinite(double value, string parameterName) { EnsureFinite(value, parameterName); if (value <= 0d) throw new ArgumentOutOfRangeException(parameterName, "Value must be positive."); } } /// /// Clumsy 粗路径测试的纯输入工厂。 /// 固定场景不读取 UI、传感器、定位或时钟;传入 AMR 位姿的手动入口仅在此处完成世界 mm/deg 到核心 m/rad 的转换。 /// public static class CoarsePathScenarioFactory { private const float MapXMinMillimeters = 0f; private const float MapXMaxMillimeters = 6000f; private const float MapYMinMillimeters = 0f; private const float MapYMaxMillimeters = 4000f; private const float ResolutionMillimeters = 50f; private const double MillimetersPerMeter = 1000d; private const double DegreesToRadians = Math.PI / 180d; private const double ManualMapPaddingMillimeters = 8000d; private const int MaximumManualObstacleCount = 20; /// /// 创建一个新的固定测试业务请求。 /// 返回:每次调用都返回独立的可变请求对象,供调用方安全地传入同一个长期存活的规划服务。 /// public static CoarsePathPlanningJob Create(CoarsePathTestScenario scenario) { return CreateCore(scenario, null); } /// /// 创建以当前 AMR 世界位姿为起点的固定测试业务请求。 /// 参数:X/Y 使用世界 mm,航向使用 deg;地图、目标和障碍物仅随 AMR 坐标平移,TwoLeg 朝向保持不变。 /// public static CoarsePathPlanningJob Create(CoarsePathTestScenario scenario, double amrXMillimeters, double amrYMillimeters, double amrHeadingDegrees) { return CreateCore(scenario, new FixedScenarioAnchor(amrXMillimeters, amrYMillimeters, amrHeadingDegrees)); } private static CoarsePathPlanningJob CreateCore(CoarsePathTestScenario scenario, FixedScenarioAnchor anchor) { switch (scenario) { case CoarsePathTestScenario.ExplicitEmpty: return CreateExplicitEmpty(FixedScenarioTransform.From(1000d, 2000d, 0d, anchor)); case CoarsePathTestScenario.RectangleDetour: case CoarsePathTestScenario.CacheHit: return CreateRectangleDetour(FixedScenarioTransform.From(1000d, 2000d, 0d, anchor)); case CoarsePathTestScenario.ManualAndTwoLeg: return CreateManualAndTwoLeg(FixedScenarioTransform.From(1000d, 1000d, 0d, anchor)); case CoarsePathTestScenario.ReverseGearSwitch: return CreateReverseGearSwitch(FixedScenarioTransform.From(1000d, 2000d, 0d, anchor)); case CoarsePathTestScenario.NoFeasiblePath: return CreateNoFeasiblePath(FixedScenarioTransform.From(1000d, 2000d, 0d, anchor)); default: throw new ArgumentOutOfRangeException(nameof(scenario)); } } /// /// 创建传入 AMR 世界位姿和手动世界终点的空图演示请求。 /// 参数:X/Y 使用世界 mm,航向使用 deg;返回请求中的 使用世界 m/rad。 /// 注意:这是坐标、路径和取消流程的演示空图,不能表示现场不存在障碍物。 /// public static CoarsePathPlanningJob CreateManualGoalDemo( double startXMillimeters, double startYMillimeters, double startHeadingDegrees, double goalXMillimeters, double goalYMillimeters, double goalHeadingDegrees) { return CreateManualObstacleDemo(startXMillimeters, startYMillimeters, startHeadingDegrees, goalXMillimeters, goalYMillimeters, goalHeadingDegrees, Array.Empty(), 0L); } /// /// 创建传入 AMR 世界位姿、手动世界终点和手动障碍物快照的测试请求。 /// 参数:位姿 X/Y、障碍物中心和尺寸使用世界 mm,航向使用 deg;返回的 使用 m/rad。 /// 障碍物非空时 obstacleSnapshotVersion 必须为正数,以避免长期服务错误复用旧地图;零障碍物才创建显式空图演示。 /// public static CoarsePathPlanningJob CreateManualObstacleDemo( double startXMillimeters, double startYMillimeters, double startHeadingDegrees, double goalXMillimeters, double goalYMillimeters, double goalHeadingDegrees, IReadOnlyList obstacles, long obstacleSnapshotVersion) { EnsureFinite(startXMillimeters, nameof(startXMillimeters)); EnsureFinite(startYMillimeters, nameof(startYMillimeters)); EnsureFinite(startHeadingDegrees, nameof(startHeadingDegrees)); EnsureFinite(goalXMillimeters, nameof(goalXMillimeters)); EnsureFinite(goalYMillimeters, nameof(goalYMillimeters)); EnsureFinite(goalHeadingDegrees, nameof(goalHeadingDegrees)); if (obstacles == null) throw new ArgumentNullException(nameof(obstacles)); if (obstacles.Count > MaximumManualObstacleCount) throw new ArgumentOutOfRangeException(nameof(obstacles), "Manual obstacle count exceeds the supported limit."); if (obstacles.Count != 0 && obstacleSnapshotVersion <= 0L) throw new ArgumentOutOfRangeException(nameof(obstacleSnapshotVersion), "Obstacle snapshots require a positive version."); return CreateJob( CreateManualDemoMap(startXMillimeters, startYMillimeters, goalXMillimeters, goalYMillimeters, obstacles, obstacleSnapshotVersion), ToPose(startXMillimeters, startYMillimeters, startHeadingDegrees), ToPose(goalXMillimeters, goalYMillimeters, goalHeadingDegrees), null, GoalDirectionConstraint.Any); } private static CoarsePathPlanningJob CreateExplicitEmpty(FixedScenarioTransform transform) { return CreateJob( CreateMap(true, Array.Empty(), transform), transform.Pose(1d, 2d, 0d), transform.Pose(5d, 2d, 0d), null, GoalDirectionConstraint.Forward); } private static CoarsePathPlanningJob CreateRectangleDetour(FixedScenarioTransform transform) { IMapObstacleSource[] sources = { new ManualObstacleSource("manual", 1L, true, new IMapObstacle[] { new AxisAlignedRectangleObstacle(transform.X(2700f), transform.X(3300f), transform.Y(1200f), transform.Y(2800f)), }), }; CoarsePathPlanningJob job = CreateJob(CreateMap(false, sources, transform), transform.Pose(1d, 2d, 0d), transform.Pose(5d, 2d, 0d), null, GoalDirectionConstraint.Forward); // 固定绕行场景保留最优启发式;30 秒覆盖较慢测试环境,UI 仍可随时取消。 job.Configuration.SearchTimeout = TimeSpan.FromSeconds(30d); return job; } private static CoarsePathPlanningJob CreateManualAndTwoLeg(FixedScenarioTransform transform) { IMapObstacleSource[] sources = { new ManualObstacleSource("manual", 2L, true, new IMapObstacle[] { new CircleObstacle(transform.X(2400f), transform.Y(1300f), 220f), new AxisAlignedRectangleObstacle(transform.X(3000f), transform.X(3600f), transform.Y(2000f), transform.Y(2600f)), }), new TwoLegObstacleSource("two-leg", 1L, true, new TwoLegProjectionInput(true, transform.X(3900f), transform.Y(2500f), 0d, -180f, -180f, -180f, 180f, 140f, "P1 fixed TwoLeg snapshot.")), }; CoarsePathPlanningJob job = CreateJob(CreateMap(false, sources, transform), transform.Pose(1d, 1d, 0d), transform.Pose(5d, 3d, 0d), null, GoalDirectionConstraint.Forward); // 多来源场景的最优绕行会受机器负载影响;放宽演示总预算但保留全部碰撞与目标判定。 job.Configuration.SearchTimeout = TimeSpan.FromSeconds(15d); return job; } private static CoarsePathPlanningJob CreateReverseGearSwitch(FixedScenarioTransform transform) { return CreateJob( CreateMap(true, Array.Empty(), transform), transform.Pose(1d, 2d, 0d), transform.Pose(4d, 2d, 0d), TravelDirection.Forward, GoalDirectionConstraint.Reverse); } private static CoarsePathPlanningJob CreateNoFeasiblePath(FixedScenarioTransform transform) { IMapObstacleSource[] sources = { new ManualObstacleSource("manual", 3L, true, new IMapObstacle[] { new AxisAlignedRectangleObstacle(transform.X(2900f), transform.X(3100f), transform.Y(0f), transform.Y(4000f)), }), }; return CreateJob(CreateMap(false, sources, transform), transform.Pose(1d, 2d, 0d), transform.Pose(5d, 2d, 0d), null, GoalDirectionConstraint.Forward); } private static CoarsePathPlanningJob CreateJob(PlanningMapRequest mapRequest, Pose2D start, Pose2D goal, TravelDirection? startDirection, GoalDirectionConstraint goalDirection) { return new CoarsePathPlanningJob { MapRequest = mapRequest, Start = start, Goal = goal, Vehicle = new VehicleParameters { LengthMeters = 0.80d, WidthMeters = 0.60d, SafetyMarginMeters = 0.05d, MaximumCurvaturePerMeter = 1d / 1.20d, }, Configuration = new HybridAStarConfiguration(), StartDirection = startDirection, GoalDirection = goalDirection, }; } private static PlanningMapRequest CreateMap(bool allowExplicitEmptyMap, IReadOnlyList sources, FixedScenarioTransform transform) { return new PlanningMapRequest { Bounds = new MapBoundsMm(transform.X(MapXMinMillimeters), transform.X(MapXMaxMillimeters), transform.Y(MapYMinMillimeters), transform.Y(MapYMaxMillimeters)), ResolutionMm = ResolutionMillimeters, ObstacleSources = sources, AllowExplicitEmptyMap = allowExplicitEmptyMap, }; } private sealed class FixedScenarioAnchor { public FixedScenarioAnchor(double xMillimeters, double yMillimeters, double headingDegrees) { EnsureFinite(xMillimeters, nameof(xMillimeters)); EnsureFinite(yMillimeters, nameof(yMillimeters)); EnsureFinite(headingDegrees, nameof(headingDegrees)); XMillimeters = xMillimeters; YMillimeters = yMillimeters; HeadingRadians = NormalizeRadians((headingDegrees % 360d) * DegreesToRadians); } public double XMillimeters { get; } public double YMillimeters { get; } public double HeadingRadians { get; } } private sealed class FixedScenarioTransform { private FixedScenarioTransform(double deltaXMillimeters, double deltaYMillimeters, double headingDeltaRadians) { DeltaXMillimeters = deltaXMillimeters; DeltaYMillimeters = deltaYMillimeters; HeadingDeltaRadians = headingDeltaRadians; } public double DeltaXMillimeters { get; } public double DeltaYMillimeters { get; } public double HeadingDeltaRadians { get; } public static FixedScenarioTransform From(double baselineStartXMillimeters, double baselineStartYMillimeters, double baselineStartHeadingRadians, FixedScenarioAnchor anchor) { if (anchor == null) return new FixedScenarioTransform(0d, 0d, 0d); return new FixedScenarioTransform(anchor.XMillimeters - baselineStartXMillimeters, anchor.YMillimeters - baselineStartYMillimeters, NormalizeRadians(anchor.HeadingRadians - baselineStartHeadingRadians)); } public float X(float value) { return ToFiniteFloat(value + DeltaXMillimeters, nameof(value)); } public float Y(float value) { return ToFiniteFloat(value + DeltaYMillimeters, nameof(value)); } public Pose2D Pose(double xMeters, double yMeters, double headingRadians) { return new Pose2D((xMeters * MillimetersPerMeter + DeltaXMillimeters) / MillimetersPerMeter, (yMeters * MillimetersPerMeter + DeltaYMillimeters) / MillimetersPerMeter, NormalizeRadians(headingRadians + HeadingDeltaRadians)); } } private static PlanningMapRequest CreateManualDemoMap(double startXMillimeters, double startYMillimeters, double goalXMillimeters, double goalYMillimeters, IReadOnlyList obstacles, long obstacleSnapshotVersion) { double minimumX = Math.Min(startXMillimeters, goalXMillimeters); double maximumX = Math.Max(startXMillimeters, goalXMillimeters); double minimumY = Math.Min(startYMillimeters, goalYMillimeters); double maximumY = Math.Max(startYMillimeters, goalYMillimeters); for (int index = 0; index < obstacles.Count; index++) { ManualCoarsePathObstacle obstacle = obstacles[index] ?? throw new ArgumentException("Manual obstacle entries cannot be null.", nameof(obstacles)); double halfX; double halfY; switch (obstacle.Kind) { case ManualCoarsePathObstacleKind.Circle: EnsurePositiveFinite(obstacle.SizeXMillimeters, nameof(obstacles)); halfX = obstacle.SizeXMillimeters; halfY = obstacle.SizeYMillimeters; break; case ManualCoarsePathObstacleKind.AxisAlignedRectangle: EnsurePositiveFinite(obstacle.SizeXMillimeters, nameof(obstacles)); EnsurePositiveFinite(obstacle.SizeYMillimeters, nameof(obstacles)); halfX = obstacle.SizeXMillimeters / 2d; halfY = obstacle.SizeYMillimeters / 2d; break; default: throw new ArgumentOutOfRangeException(nameof(obstacles), "Manual obstacle kind is not supported."); } minimumX = Math.Min(minimumX, obstacle.CenterXMillimeters - halfX); maximumX = Math.Max(maximumX, obstacle.CenterXMillimeters + halfX); minimumY = Math.Min(minimumY, obstacle.CenterYMillimeters - halfY); maximumY = Math.Max(maximumY, obstacle.CenterYMillimeters + halfY); } float xMin = ToGridLowerBound(minimumX - ManualMapPaddingMillimeters); float xMax = ToGridUpperBound(maximumX + ManualMapPaddingMillimeters); float yMin = ToGridLowerBound(minimumY - ManualMapPaddingMillimeters); float yMax = ToGridUpperBound(maximumY + ManualMapPaddingMillimeters); bool isExplicitEmptyMap = obstacles.Count == 0; return new PlanningMapRequest { Bounds = new MapBoundsMm(xMin, xMax, yMin, yMax), ResolutionMm = ResolutionMillimeters, ObstacleSources = isExplicitEmptyMap ? Array.Empty() : new IMapObstacleSource[] { new ManualObstacleSource("manual-user-input", obstacleSnapshotVersion, true, ConvertManualObstacles(obstacles)), }, AllowExplicitEmptyMap = isExplicitEmptyMap, }; } private static IMapObstacle[] ConvertManualObstacles(IReadOnlyList obstacles) { var result = new IMapObstacle[obstacles.Count]; for (int index = 0; index < obstacles.Count; index++) { ManualCoarsePathObstacle obstacle = obstacles[index] ?? throw new ArgumentException("Manual obstacle entries cannot be null.", nameof(obstacles)); switch (obstacle.Kind) { case ManualCoarsePathObstacleKind.Circle: result[index] = new CircleObstacle(ToFiniteFloat(obstacle.CenterXMillimeters, nameof(obstacles)), ToFiniteFloat(obstacle.CenterYMillimeters, nameof(obstacles)), ToFiniteFloat(obstacle.SizeXMillimeters, nameof(obstacles))); break; case ManualCoarsePathObstacleKind.AxisAlignedRectangle: double halfX = obstacle.SizeXMillimeters / 2d; double halfY = obstacle.SizeYMillimeters / 2d; result[index] = new AxisAlignedRectangleObstacle( ToFiniteFloat(obstacle.CenterXMillimeters - halfX, nameof(obstacles)), ToFiniteFloat(obstacle.CenterXMillimeters + halfX, nameof(obstacles)), ToFiniteFloat(obstacle.CenterYMillimeters - halfY, nameof(obstacles)), ToFiniteFloat(obstacle.CenterYMillimeters + halfY, nameof(obstacles))); break; default: throw new ArgumentOutOfRangeException(nameof(obstacles), "Manual obstacle kind is not supported."); } } return result; } private static Pose2D ToPose(double xMillimeters, double yMillimeters, double headingDegrees) { return new Pose2D(xMillimeters / MillimetersPerMeter, yMillimeters / MillimetersPerMeter, headingDegrees * DegreesToRadians); } private static double NormalizeRadians(double angle) { double normalized = angle % (2d * Math.PI); if (normalized <= -Math.PI) return normalized + 2d * Math.PI; return normalized > Math.PI ? normalized - 2d * Math.PI : normalized; } private static float ToGridLowerBound(double millimeters) { double rounded = Math.Floor(millimeters / ResolutionMillimeters) * ResolutionMillimeters; return ToFiniteFloat(rounded, nameof(millimeters)); } private static float ToGridUpperBound(double millimeters) { double rounded = Math.Ceiling(millimeters / ResolutionMillimeters) * ResolutionMillimeters; return ToFiniteFloat(rounded, nameof(millimeters)); } private static float ToFiniteFloat(double value, string parameterName) { if (double.IsNaN(value) || double.IsInfinity(value) || value < float.MinValue || value > float.MaxValue) throw new ArgumentOutOfRangeException(parameterName, "Value cannot be represented as a finite millimeter coordinate."); return (float)value; } private static void EnsureFinite(double value, string parameterName) { if (double.IsNaN(value) || double.IsInfinity(value)) throw new ArgumentOutOfRangeException(parameterName, "Value must be finite."); } private static void EnsurePositiveFinite(double value, string parameterName) { EnsureFinite(value, parameterName); if (value <= 0d) throw new ArgumentOutOfRangeException(parameterName, "Value must be positive."); } }