using RobotNet.VDA5050.Order; using RobotNet10.Common; using RobotNet10.Common.Models; using RobotNet10.NavigationTune.Shared.Models; using RobotNet10.RobotApp.Services.Exceptions; using RobotNet10.RobotApp.Services.Robot; using RobotNet10.RobotApp.Services.Robot.Helper; using RobotNet10.RobotApp.Services.Robot.Models; using RobotNet10.RobotApp.Services.Simulation; using RobotDirection = RobotNet10.RobotApp.Shared.Enums.RobotDirection; namespace RobotNet10.RobotApp.Services.Navigation.CSharp; /// /// Configuration cho Pure Pursuit controller /// public class PurePursuitConfig { /// /// Lookahead distance minimum (m) /// public double LookaheadMin { get; set; } = 0.3; /// /// Hệ số tỷ lệ lookahead với vận tốc (s) /// public double Kdd { get; set; } = 1.0; /// /// Lookahead distance maximum (m) /// public double LookaheadMax { get; set; } = 2.0; /// /// [LEGACY] Gain cho curvature (nếu cần scale steering) /// Note: Not used in current implementation. /// Replaced by adaptive KCurvature for lookahead adjustment. /// Kept for backward compatibility. /// public double CurvatureGain { get; set; } = 1.0; /// /// Ngưỡng để coi như đạt waypoint (m) /// public double WaypointTolerance { get; set; } = 0.1; /// /// Maximum angular velocity during tracking (rad/s) /// public double MaxAngularVelocity { get; set; } = 1.5; /// /// Path waypoint spacing resolution (meters) /// public double ResolutionSplit { get; set; } = 0.05; #region Adaptive Lookahead Parameters /// /// Goal region distance - start reducing lookahead when closer than this (m) /// Default: 1.5m /// public double GoalRegionDistance { get; set; } = 1.5; /// /// Curvature adaptation factor (higher = more lookahead reduction on curves) /// Default: 2.0 /// public double KCurvature { get; set; } = 2.0; /// /// Minimum lookahead time ratio (seconds) - for dynamic min limit /// Default: 0.3s /// public double MinLookaheadTimeRatio { get; set; } = 0.3; /// /// Maximum lookahead time ratio (seconds) - for dynamic max limit /// Default: 2.0s /// public double MaxLookaheadTimeRatio { get; set; } = 2.0; /// /// Switch to Stanley controller when within this distance to goal (m) /// Default: 0.5m /// public double FinalApproachThreshold { get; set; } = 1; #endregion } public class PurePursuit(PurePursuitConfig PurePursuitConfig, StanleyConfig StanleyConfig) { public OrderNode[] OrderNodes = []; public OrderEdge[] OrderEdges = []; public OrderNode? LastOrderNode = null; public List Waypoints_Value = []; private Dictionary? _segmentCache; private NavigationNode? Goal; private int closesEdgeIndex = 0; private int _currentWaypointAheadIndex = 0; // For Stanley controller private bool _isApproachGoal = false; // Flag for final approach mode // Local Planner: approach curve stored as a virtual edge private int _approachWaypointCount = 0; private int _connectionEdgeIndex = 0; private OrderEdge? _approachOrderEdge; // Virtual edge for segment cache lookup private SpaceEdge? _approachSpaceEdge; // Geometry for projection in GetClosesEdges public PurePursuit WithPath(Node[] nodes, Edge[] edges, double currentTheta) { if (nodes.Length < 2) throw new SimulationException(RobotErrors.Error1002(nodes.Length)); if (edges.Length < 1) throw new SimulationException(); if (edges.Length != nodes.Length - 1) throw new SimulationException(RobotErrors.Error1004(nodes.Length, edges.Length)); (OrderNodes, OrderEdges) = OrderConverter.Validate(nodes, edges, currentTheta); Waypoints_Value = [.. PathSplit(OrderNodes, OrderEdges)]; closesEdgeIndex = 0; _currentWaypointAheadIndex = 0; _isApproachGoal = false; _approachWaypointCount = 0; _connectionEdgeIndex = 0; _approachOrderEdge = null; _approachSpaceEdge = null; BuildSegmentCache(); return this; } public void ResetTracking() { closesEdgeIndex = 0; _currentWaypointAheadIndex = 0; _isApproachGoal = false; } /// /// Sinh approach waypoints (cubic Bezier) từ vị trí robot đến path rồi prepend vào Waypoints_Value. /// Approach curve CHỈ dựa vào vị trí (X,Y) robot và tangent tại connection point — KHÔNG dùng robotTheta. /// Robot sẽ Rotate tại chỗ đến heading đầu curve trước khi Moving. /// public ApproachResult GenerateAndPrependApproachPath( double robotX, double robotY, int closestIndex, LocalPlannerConfig config) { if (!config.Enabled || Waypoints_Value.Count < 2) return ApproachResult.Disabled; // Bước 1: Tính khoảng cách đến path double distToPath = CalculateDistance(robotX, robotY, Waypoints_Value[closestIndex].X, Waypoints_Value[closestIndex].Y); if (distToPath < config.OnPathThreshold) return ApproachResult.AlreadyOnPath; if (distToPath > config.MaxApproachDistance) return ApproachResult.TooFarFromPath; // Bước 2: Tính mergeDistance (dynamic theo distToPath) double mergeDistance = config.MergeDistanceGain * distToPath; mergeDistance = Math.Clamp(mergeDistance, config.MergeDistanceMin, config.MergeDistanceMax); // Bước 3: Tìm connection point trên path int connectionIndex = closestIndex; double accDist = 0; while (accDist < mergeDistance && connectionIndex < Waypoints_Value.Count - 2) { double segLen = CalculateDistance( Waypoints_Value[connectionIndex].X, Waypoints_Value[connectionIndex].Y, Waypoints_Value[connectionIndex + 1].X, Waypoints_Value[connectionIndex + 1].Y); accDist += segLen; connectionIndex++; } var connectionPoint = Waypoints_Value[connectionIndex]; // Bước 4: Tính path tangent tại connection point double pathTangent; if (connectionIndex < Waypoints_Value.Count - 1) { double tx = Waypoints_Value[connectionIndex + 1].X - connectionPoint.X; double ty = Waypoints_Value[connectionIndex + 1].Y - connectionPoint.Y; pathTangent = Math.Atan2(ty, tx); } else if (connectionIndex > 0) { double tx = connectionPoint.X - Waypoints_Value[connectionIndex - 1].X; double ty = connectionPoint.Y - Waypoints_Value[connectionIndex - 1].Y; pathTangent = Math.Atan2(ty, tx); } else { return ApproachResult.Disabled; } // Bước 5: Tính 4 control points cho Cubic Bezier // P0 = robot position, P3 = connection point // P1 = dọc hướng P0→P3 (KHÔNG dùng robotTheta) // P2 = tiếp cận theo pathTangent double p0x = robotX, p0y = robotY; double p3x = connectionPoint.X, p3y = connectionPoint.Y; double dist = CalculateDistance(p0x, p0y, p3x, p3y); if (dist < config.ResolutionSplit) return ApproachResult.AlreadyOnPath; // Direction P0→P3 double dirX = (p3x - p0x) / dist; double dirY = (p3y - p0y) / dist; double d1 = config.ControlArmRatioStart * dist; double d2 = config.ControlArmRatioEnd * dist; double p1x = p0x + d1 * dirX; double p1y = p0y + d1 * dirY; double p2x = p3x - d2 * Math.Cos(pathTangent); double p2y = p3y - d2 * Math.Sin(pathTangent); // Bước 6: Sample waypoints trên curve var approachEdge = new SpaceEdge() { StartX = p0x, StartY = p0y, EndX = p3x, EndY = p3y, ControlPoint1X = p1x, ControlPoint1Y = p1y, ControlPoint2X = p2x, ControlPoint2Y = p2y, Degree = 3 }; double length = SpaceCompute.GetEdgeLength(approachEdge, config.ResolutionSplit); if (length < config.ResolutionSplit) return ApproachResult.AlreadyOnPath; double step = config.ResolutionSplit / length; var approachWaypoints = new List(); for (double t = 0; t < 1 - step; t += step) { (double x, double y) = SpaceCompute.BezierPoint(t, approachEdge); approachWaypoints.Add(new NavigationNode { Id = Guid.NewGuid(), NodeId = string.Empty, X = x, Y = y, Direction = connectionPoint.Direction, Speed = connectionPoint.Speed }); } if (approachWaypoints.Count == 0) return ApproachResult.AlreadyOnPath; // Bước 7a: Xác định edge chứa connectionIndex (trước khi modify Waypoints_Value) int connectionEdgeIdx = 0; if (_segmentCache is not null) { for (int i = 0; i < OrderEdges.Length; i++) { if (_segmentCache.TryGetValue(OrderEdges[i].EdgeId, out var range) && connectionIndex >= range.start && connectionIndex <= range.end) { connectionEdgeIdx = i; break; } } } // Bước 7b: Lưu approach curve như 1 virtual edge (cho projection + segment cache) _approachSpaceEdge = approachEdge; _approachOrderEdge = new OrderEdge { EdgeId = "__approach__", Degree = 3, ControlPoint1X = p1x, ControlPoint1Y = p1y, ControlPoint2X = p2x, ControlPoint2Y = p2y, Direction = connectionPoint.Direction, Speed = connectionPoint.Speed, }; // Bước 7c: Insert approach waypoints ngay trước connectionIndex + xóa phần trước _approachWaypointCount = approachWaypoints.Count; Waypoints_Value.InsertRange(connectionIndex, approachWaypoints); if (connectionIndex > 0) { Waypoints_Value.RemoveRange(0, connectionIndex); } // Kết quả: [approach_0, ..., approach_n, connectionPoint, ..., goal] // 0 _approachWaypointCount _connectionEdgeIndex = connectionEdgeIdx; closesEdgeIndex = connectionEdgeIdx; // Bước 7d: Rebuild segment cache (approach edge + real edges từ connectionEdge trở đi) BuildSegmentCache(_connectionEdgeIndex); return ApproachResult.ApproachGenerated; } /// /// Rebuild toàn bộ Waypoints_Value từ OrderNodes/OrderEdges đã lưu. /// Dùng khi cần sinh lại approach từ vị trí mới (thay vì ClearApproachWaypoints). /// Flow: RebuildPath() → check local planner → GenerateAndPrependApproachPath() nếu cần. /// public void RebuildPath() { string? goalId = Goal?.NodeId; Waypoints_Value = [.. PathSplit(OrderNodes, OrderEdges)]; _approachWaypointCount = 0; _connectionEdgeIndex = 0; _approachOrderEdge = null; _approachSpaceEdge = null; BuildSegmentCache(); if (!string.IsNullOrEmpty(goalId)) UpdateGoal(goalId); } public void UpdateGoal(string goalId) { var goal = Waypoints_Value.FirstOrDefault(n => n.NodeId == goalId); if (goal is not null) Goal = goal; } private NavigationNode[] PathSplit(OrderNode[] nodes, OrderEdge[] edges) { List navigationNode = [new() { Id = Guid.NewGuid(), NodeId = nodes[0].NodeId, X = nodes[0].X, Y = nodes[0].Y, Theta = nodes[0].Theta, Direction = edges[0].Direction, Speed = edges[0].Speed, }]; foreach (var edge in edges) { var startNode = nodes.FirstOrDefault(n => n.NodeId == edge.StartNodeId); var endNode = nodes.FirstOrDefault(n => n.NodeId == edge.EndNodeId); if (startNode is null) throw new PathPlannerException(RobotErrors.Error1008(edge.EdgeId, edge.StartNodeId)); if (endNode is null) throw new PathPlannerException(RobotErrors.Error1009(edge.EdgeId, edge.EndNodeId)); var spaceEdge = new SpaceEdge() { StartX = startNode.X, StartY = startNode.Y, EndX = endNode.X, EndY = endNode.Y, ControlPoint1X = edge.ControlPoint1X ?? 0, ControlPoint1Y = edge.ControlPoint1Y ?? 0, ControlPoint2X = edge.ControlPoint2X ?? 0, ControlPoint2Y = edge.ControlPoint2Y ?? 0, Degree = edge.Degree, }; double length = SpaceCompute.GetEdgeLength(spaceEdge, PurePursuitConfig.ResolutionSplit); if (length <= 0) continue; double step = PurePursuitConfig.ResolutionSplit / length; for (double t = step; t <= 1 - step; t += step) { (double x, double y) = SpaceCompute.BezierPoint(t, spaceEdge); navigationNode.Add(new() { Id = Guid.NewGuid(), NodeId = string.Empty, X = x, Y = y, Theta = null, Direction = edge.Direction, Speed = edge.Speed, }); } navigationNode.Add(new() { Id = Guid.NewGuid(), NodeId = endNode.NodeId, X = endNode.X, Y = endNode.Y, Theta = endNode.Theta, Direction = edge.Direction, Speed = edge.Speed, }); } return [.. navigationNode]; } /// /// Build segment cache mapping EdgeId → (startWaypointIdx, endWaypointIdx). /// Approach edge (nếu có) được thêm vào cache trước, sau đó build các real edges từ fromEdgeIndex. /// private void BuildSegmentCache(int fromEdgeIndex = 0) { _segmentCache = []; // Thêm approach edge vào cache nếu có if (_approachOrderEdge is not null && _approachWaypointCount > 0) { _segmentCache[_approachOrderEdge.EdgeId] = (0, _approachWaypointCount); } for (int i = fromEdgeIndex; i < OrderEdges.Length; i++) { var edge = OrderEdges[i]; int waypointStartIdx = Waypoints_Value.FindIndex(n => n.NodeId == edge.StartNodeId); int waypointEndIdx = Waypoints_Value.FindIndex(n => n.NodeId == edge.EndNodeId); // Edge đầu tiên: start node có thể đã bị xóa // → lấy end của approach edge nếu có, hoặc 0 if (waypointStartIdx == -1) { if (i == fromEdgeIndex) waypointStartIdx = _approachOrderEdge is not null ? _segmentCache[_approachOrderEdge.EdgeId].end : 0; else waypointStartIdx = _segmentCache[OrderEdges[i - 1].EdgeId].end; } if (waypointEndIdx == -1) waypointEndIdx = Waypoints_Value.Count - 1; if (waypointStartIdx > waypointEndIdx) throw new NavigationException($"Waypoint has invalid range for edge {edge.EdgeId}: start={waypointStartIdx}, end={waypointEndIdx}"); _segmentCache[edge.EdgeId] = (waypointStartIdx, waypointEndIdx); } } private (OrderEdge edge, double time) GetClosesEdges(double x, double y) { double minDistance = double.MaxValue; OrderEdge? edgesResult = null; double prjTime = 0; // Project lên approach edge nếu robot chưa vượt qua connection edge if (_approachSpaceEdge is not null && _approachOrderEdge is not null && closesEdgeIndex <= _connectionEdgeIndex) { (_, _, var approachDist, double approachTime) = SpaceCompute.GetProjectionOnEdge(x, y, _approachSpaceEdge); if (approachDist < minDistance) { minDistance = approachDist; edgesResult = _approachOrderEdge; prjTime = approachTime; } } // Project lên các real edges (từ closesEdgeIndex) for (int i = closesEdgeIndex; i < OrderEdges.Length; i++) { var startNode = OrderNodes.FirstOrDefault(node => node.NodeId == OrderEdges[i].StartNodeId); var endNode = OrderNodes.FirstOrDefault(node => node.NodeId == OrderEdges[i].EndNodeId); if (startNode is null || endNode is null) continue; (_, _, var distance, double time) = SpaceCompute.GetProjectionOnEdge(x, y, new() { StartX = startNode.X, StartY = startNode.Y, EndX = endNode.X, EndY = endNode.Y, Degree = OrderEdges[i].Degree, ControlPoint1X = OrderEdges[i].ControlPoint1X ?? 0, ControlPoint1Y = OrderEdges[i].ControlPoint1Y ?? 0, ControlPoint2X = OrderEdges[i].ControlPoint2X ?? 0, ControlPoint2Y = OrderEdges[i].ControlPoint2Y ?? 0, }); if (distance < minDistance) { minDistance = distance; edgesResult = OrderEdges[i]; prjTime = time; closesEdgeIndex = i; } } return (edgesResult ?? OrderEdges[closesEdgeIndex], prjTime); } public (NavigationNode node, int index) OnNode(double x, double y) { // Edge-based projection thống nhất cho cả approach edge và real edges (var closeEdge, double prjTime) = GetClosesEdges(x, y); (var startNodeIdx, var endNodeIdx) = _segmentCache is not null && _segmentCache.TryGetValue(closeEdge.EdgeId, out var cached) ? cached : (0, Waypoints_Value.Count - 1); int onNodeIndex = (int)(Math.Abs(endNodeIdx - startNodeIdx) * prjTime) + startNodeIdx; return (Waypoints_Value[onNodeIndex], onNodeIndex); } /// /// Calculate adaptive lookahead distance based on velocity, confidence, distance to goal, and path curvature /// Lookahead adapts to: /// 1. Velocity (faster = look further ahead) /// 2. Distance to goal (near goal = shorter lookahead for precision) /// 3. Path curvature (sharp curves = shorter lookahead for tighter tracking) /// 4. Confidence (low confidence = shorter lookahead for safety) /// 5. Velocity-based dynamic time limits /// private double GetLookaheadDistance(double vHybrid, double robotX, double robotY, int closestIndex) { // 1. Base lookahead from velocity double baseLookahead = PurePursuitConfig.LookaheadMin + PurePursuitConfig.Kdd * Math.Abs(vHybrid); // 2. Distance-to-goal adaptation double distanceToGoal = CalculateDistanceToGoal(robotX, robotY); double goalFactor = 1.0; if (distanceToGoal < PurePursuitConfig.GoalRegionDistance) { // Gradually reduce lookahead as we approach goal // At goal: factor = 0.5, At GoalRegionDistance: factor = 1.0 goalFactor = 0.5 + 0.5 * (distanceToGoal / PurePursuitConfig.GoalRegionDistance); } // 3. Curvature adaptation double curvature = CalculateCurvature(closestIndex); // curvatureFactor ranges from 1.0 (straight) to ~0.33 (very sharp curve with KCurvature=2.0) double curvatureFactor = 1.0 / (1.0 + PurePursuitConfig.KCurvature * curvature); // 5. Combine all factors double adaptiveLookahead = baseLookahead * goalFactor * curvatureFactor; double minLookahead = PurePursuitConfig.LookaheadMin; double maxLookahead = PurePursuitConfig.LookaheadMax; if (distanceToGoal < PurePursuitConfig.GoalRegionDistance && Math.Abs(vHybrid) > 0.0) { // 6. Apply velocity-based dynamic limits // Minimum: look at least 0.3 seconds ahead or LookaheadMin (whichever is larger) minLookahead = Math.Max(PurePursuitConfig.LookaheadMin, Math.Abs(vHybrid) * PurePursuitConfig.MinLookaheadTimeRatio); // Maximum: look at most 2 seconds ahead or LookaheadMax (whichever is smaller) maxLookahead = Math.Min(PurePursuitConfig.LookaheadMax, Math.Abs(vHybrid) * PurePursuitConfig.MaxLookaheadTimeRatio); // Ensure min < max if (minLookahead > maxLookahead) minLookahead = maxLookahead; } adaptiveLookahead = Math.Clamp(adaptiveLookahead, minLookahead, maxLookahead); if (double.IsNaN(adaptiveLookahead) || adaptiveLookahead <= 0) { adaptiveLookahead = PurePursuitConfig.LookaheadMin; } return adaptiveLookahead; } public (double linearVel, double angularVel) PurePursuit_step(double X_Ref, double Y_Ref, double Angle_Ref, double actualLinearVelocity, double maxLinearVelocity) { if (Waypoints_Value is null || Waypoints_Value.Count < 2) throw new NavigationException("NAV PP Waypoint not yet set"); // 1. Get closest waypoint (KEEP ORIGINAL LOGIC) var (onNode, index) = OnNode(X_Ref, Y_Ref); if (onNode is null || Goal is null) throw new NavigationException("NAV PP cannot get projection node"); // 2. Calculate adaptive lookahead distance (UPGRADED) double lookaheadDistance = GetLookaheadDistance(actualLinearVelocity, X_Ref, Y_Ref, index); // 3. Find target point with INTERPOLATION (UPGRADED) NavigationNode? targetPoint = FindTargetPoint(index, lookaheadDistance); targetPoint ??= Goal; // 4. Apply speed limit from target point if available double linearVel = maxLinearVelocity; double? targetSpeed = targetPoint.Speed; if (targetSpeed.HasValue && targetSpeed.Value > 0) { linearVel = Math.Min(maxLinearVelocity, targetSpeed.Value); } // 5. Check for final approach (PREPARATION FOR STANLEY) double distanceToGoal = CalculateDistanceToGoal(X_Ref, Y_Ref); if (targetPoint.Id == Goal.Id || distanceToGoal <= PurePursuitConfig.FinalApproachThreshold) { _isApproachGoal = true; } // 6. Normalize theta for backward (SIMPLIFIED) bool isBackward = onNode.Direction == RobotDirection.BACKWARD; if (isBackward) Angle_Ref += Math.PI; Angle_Ref = NormalizeAngle(Angle_Ref); // 7. Switch to Stanley when approaching goal (STANLEY INTEGRATION) if (_isApproachGoal) { var (linear, angular) = FinalApproachController(X_Ref, Y_Ref, Angle_Ref, Goal, actualLinearVelocity, linearVel, isBackward); return (linear, angular); } // 8. Calculate angle to target var dx = targetPoint.X - X_Ref; var dy = targetPoint.Y - Y_Ref; var alpha = Math.Atan2(dy, dx) - Angle_Ref; // Normalize alpha to [-π, π] (SIMPLIFIED) alpha = NormalizeAngle(alpha); // 9. Pure Pursuit formula: ω = 2 * v * sin(α) / L var angularVelocity = 2.0 * Math.Abs(actualLinearVelocity) * Math.Sin(alpha) / lookaheadDistance; // 10. Clamp to max angular velocity if (Math.Abs(angularVelocity) > PurePursuitConfig.MaxAngularVelocity) { angularVelocity = Math.Sign(angularVelocity) * PurePursuitConfig.MaxAngularVelocity; } // 11. Apply direction sign to velocities if (isBackward) { linearVel = -linearVel; } Console.WriteLine($"PP: Target=({targetPoint.X:F3},{targetPoint.Y:F3}), " + $"Pose=({X_Ref:F3},{Y_Ref:F3},{Angle_Ref * 180 / Math.PI:F2}°), " + $"Look={lookaheadDistance:F3}, Alpha={alpha * 180 / Math.PI:F2}°, " + $"LVel={linearVel:F3}, AngVel={angularVelocity:F3}"); return (linearVel, angularVelocity); } /// /// Stanley-based final approach controller /// When robot enters goal region (IsApproachGoal), uses Stanley algorithm for precise CTE-based tracking /// private (double linearVel, double angularVel) FinalApproachController( double robotX, double robotY, double robotTheta, NavigationNode goal, double actualLinearVelocity, double maxLinearVelocity, bool isBackward) { // Calculate front axle position double frontX = robotX + StanleyConfig.WheelBase * Math.Cos(robotTheta); double frontY = robotY + StanleyConfig.WheelBase * Math.Sin(robotTheta); // Find closest point on path to front axle var (closestPoint, closestIndex) = GetClosestAheadWaypoint(frontX, frontY); // Calculate heading at closest point (path direction) double pathHeading = CalculatePathHeading(closestIndex); // Calculate cross-track error (signed distance from front axle to path) double crossTrackError = CalculateStanleyCrossTrackError(frontX, frontY, closestPoint, pathHeading); if (isBackward) crossTrackError = -crossTrackError; // Calculate heading error (path heading - robot heading) double headingError = NormalizeAngle(pathHeading - robotTheta); // Calculate curvature at closest point (for feedforward) double curvature = 0; if (StanleyConfig.EnableCurvatureFeedforward) { curvature = CalculateCurvature(closestIndex); } // Adaptive K gain: increase when close to goal for tighter tracking double distanceToGoal = CalculateDistance(robotX, robotY, goal.X, goal.Y); double adaptiveK = StanleyConfig.K; if (distanceToGoal < StanleyConfig.GoalApproachDistance) { // Linearly increase K from K to K*GoalGainMultiplier as distance decreases double approachRatio = 1.0 - (distanceToGoal / StanleyConfig.GoalApproachDistance); adaptiveK = StanleyConfig.K * (1.0 + approachRatio * (StanleyConfig.GoalGainMultiplier - 1.0)); } // Stanley formula: δ = ψ + arctan(K × e / (v + Ks)) double crossTrackTerm = Math.Atan2(adaptiveK * crossTrackError, Math.Abs(actualLinearVelocity) + StanleyConfig.Ks); // Add curvature feedforward if enabled double curvatureTerm = 0; if (StanleyConfig.EnableCurvatureFeedforward && curvature != 0) { curvatureTerm = StanleyConfig.KCurvatureFF * Math.Atan(curvature * StanleyConfig.WheelBase); } // Total steering angle double steeringAngle = headingError + crossTrackTerm + curvatureTerm; // Clamp to maximum steering angle steeringAngle = Math.Clamp(steeringAngle, -StanleyConfig.MaxSteeringAngle, StanleyConfig.MaxSteeringAngle); // Convert steering angle to angular velocity using bicycle model double angularVelocity = (actualLinearVelocity * Math.Tan(steeringAngle)) / StanleyConfig.WheelBase; // Clamp angular velocity for final approach angularVelocity = Math.Clamp(angularVelocity, -StanleyConfig.MaxAngularVelocity, StanleyConfig.MaxAngularVelocity); // Apply direction to velocities double linearVel = maxLinearVelocity; if (isBackward) { linearVel = -linearVel; } // Debug output Console.WriteLine($"FA-Stanley: Front=({frontX:F3},{frontY:F3}), Closest=({closestPoint.X:F3},{closestPoint.Y:F3}), " + $"Pose=({robotX:F3},{robotY:F3},{robotTheta * 180 / Math.PI:F1}°), " + $"CTE={crossTrackError:F3}m, HeadErr={headingError * 180 / Math.PI:F1}°, " + $"Curv={curvature:F3}, SteerAng={steeringAngle * 180 / Math.PI:F1}°, " + $"K={adaptiveK:F2}, DTG={distanceToGoal:F3}m, " + $"LVel={linearVel:F3}, AnVel={angularVelocity:F3}"); return (linearVel, angularVelocity); } /// /// Find target point at lookahead distance from current index with interpolation /// This provides smooth target point selection instead of discrete waypoints /// Speed information is interpolated to provide accurate future speed limit /// private NavigationNode? FindTargetPoint(int startIndex, double lookaheadDistance) { if (startIndex >= Waypoints_Value.Count - 1) return Goal; double accumulatedDistance = 0; for (int i = startIndex; i < Waypoints_Value.Count - 1; i++) { double dx = Waypoints_Value[i + 1].X - Waypoints_Value[i].X; double dy = Waypoints_Value[i + 1].Y - Waypoints_Value[i].Y; double segmentLength = Math.Sqrt(dx * dx + dy * dy); if (accumulatedDistance + segmentLength >= lookaheadDistance) { // Interpolate within this segment double t = (lookaheadDistance - accumulatedDistance) / segmentLength; // Interpolate speed if both waypoints have speed info double? interpolatedSpeed; if (Waypoints_Value[i].Speed is { } speed1 && Waypoints_Value[i + 1].Speed is { } speed2) { // Linear interpolation of speed limit interpolatedSpeed = speed1 + t * (speed2 - speed1); } else { // Use next waypoint's speed if available (upcoming constraint) interpolatedSpeed = Waypoints_Value[i + 1].Speed ?? Waypoints_Value[i].Speed; } return new NavigationNode { Id = Guid.NewGuid(), NodeId = string.Empty, X = Waypoints_Value[i].X + t * dx, Y = Waypoints_Value[i].Y + t * dy, Theta = null, Direction = Waypoints_Value[i].Direction, Speed = interpolatedSpeed // Preserve speed limit for lookahead }; } accumulatedDistance += segmentLength; } return Goal; } #region Helper Methods /// /// Calculate distance between two points /// private static double CalculateDistance(double x1, double y1, double x2, double y2) { double dx = x2 - x1; double dy = y2 - y1; return Math.Sqrt(dx * dx + dy * dy); } /// /// Normalize angle to [-π, π] /// private static double NormalizeAngle(double angle) { while (angle > Math.PI) angle -= 2 * Math.PI; while (angle < -Math.PI) angle += 2 * Math.PI; return angle; } /// /// Calculate distance from robot to goal point /// private double CalculateDistanceToGoal(double robotX, double robotY) { if (Goal == null) return double.MaxValue; double dx = Goal.X - robotX; double dy = Goal.Y - robotY; return Math.Sqrt(dx * dx + dy * dy); } /// /// Calculate path curvature at given waypoint index using 3-point circle fitting (Menger curvature) /// Returns curvature in 1/meters (larger value = sharper curve) /// private double CalculateCurvature(int index) { // Need at least 3 points for curvature calculation if (Waypoints_Value.Count < 3 || index <= 0 || index >= Waypoints_Value.Count - 1) return 0.0; var p1 = Waypoints_Value[index - 1]; var p2 = Waypoints_Value[index]; var p3 = Waypoints_Value[index + 1]; // Calculate vectors double dx1 = p2.X - p1.X; double dy1 = p2.Y - p1.Y; double dx2 = p3.X - p2.X; double dy2 = p3.Y - p2.Y; // Cross product magnitude (2 * triangle area) double cross = Math.Abs(dx1 * dy2 - dy1 * dx2); // Side lengths of triangle double a = Math.Sqrt(dx1 * dx1 + dy1 * dy1); double b = Math.Sqrt(dx2 * dx2 + dy2 * dy2); double c = Math.Sqrt((p3.X - p1.X) * (p3.X - p1.X) + (p3.Y - p1.Y) * (p3.Y - p1.Y)); // Menger curvature formula: k = 4 * Area / (a * b * c) // Area of triangle = cross / 2, so k = 2 * cross / (a * b * c) double curvature = 2.0 * cross / (a * b * c + 1e-9); // Add small epsilon to avoid division by zero return curvature; } #endregion #region Stanley Helper Methods (for FinalApproachController) /// /// Get closest waypoint ahead to given position (for Stanley front axle tracking) /// private (NavigationNode point, int index) GetClosestAheadWaypoint(double x, double y) { if (Waypoints_Value.Count == 0) throw new InvalidOperationException("Path not set"); double minDistance = double.MaxValue; int closestIndex = 0; // Start search from current index for efficiency for (int i = _currentWaypointAheadIndex; i < Waypoints_Value.Count; i++) { double distance = CalculateDistance(x, y, Waypoints_Value[i].X, Waypoints_Value[i].Y); if (distance < minDistance) { minDistance = distance; closestIndex = i; } } // Also check previous waypoints in case robot moved backwards for (int i = 0; i < _currentWaypointAheadIndex; i++) { double distance = CalculateDistance(x, y, Waypoints_Value[i].X, Waypoints_Value[i].Y); if (distance < minDistance) { minDistance = distance; closestIndex = i; } } _currentWaypointAheadIndex = closestIndex; return (Waypoints_Value[closestIndex], closestIndex); } /// /// Calculate path heading at given waypoint index (for Stanley) /// Uses current point and next point to determine direction /// private double CalculatePathHeading(int index) { if (index >= Waypoints_Value.Count - 1) { // Last point - use previous segment direction if (index > 0) { double dx = Waypoints_Value[index].X - Waypoints_Value[index - 1].X; double dy = Waypoints_Value[index].Y - Waypoints_Value[index - 1].Y; return Math.Atan2(dy, dx); } return 0; } // Use current to next point double dxNext = Waypoints_Value[index + 1].X - Waypoints_Value[index].X; double dyNext = Waypoints_Value[index + 1].Y - Waypoints_Value[index].Y; return Math.Atan2(dyNext, dxNext); } /// /// Calculate signed cross-track error (for Stanley) /// Positive: front axle is to the left of path /// Negative: front axle is to the right of path /// private static double CalculateStanleyCrossTrackError(double frontX, double frontY, NavigationNode closestPoint, double pathHeading) { // Vector from closest point to front axle double dx = frontX - closestPoint.X; double dy = frontY - closestPoint.Y; // Path direction vector double pathDx = Math.Cos(pathHeading); double pathDy = Math.Sin(pathHeading); // Cross product to get signed perpendicular distance // positive = left, negative = right double crossTrackError = dx * pathDy - dy * pathDx; return crossTrackError; } #endregion }