using RobotNet.VDA5050.Order; using RobotNet10.Common; using RobotNet10.RobotApp.Detection; using RobotNet10.RobotApp.Interfaces; using RobotNet10.RobotApp.Services.ConfigManager; using RobotNet10.RobotApp.Services.Exceptions; using RobotNet10.RobotApp.Services.Navigation.CSharp; using RobotNet10.RobotApp.Services.Robot.Models; using RobotNet10.RobotApp.Services.Simulation; using RobotNet10.RobotApp.Shared.Enums; using RobotNet10.Shared.Geometry; namespace RobotNet10.RobotApp.Services.Navigation; public class NavigationConfig { public double MaxLinearVelocity { get; set; } public double MaxAngularVelocity { get; set; } public double MinLinearVelocity { get; set; } public double RotateAngularVelocity { get; set; } public double Acceleration { get; set; } = 0.5; public double Deceleration { get; set; } = 0.5; public double ReachedRadius { get; set; } = 0.03; public double HeadingTolerance { get; set; } = 3.0; public double InitialRotationThreshold { get; set; } = 5.0; public double DockToMaxSpeed { get; set; } = 0.3; public double DockToRetrySpeed { get; set; } = 0.05; public double DockToRotateSpeed { get; set; } = 0.05; public Dictionary SafetySpeedMap { get; set; } = []; /// /// Maximum distance (meters) from goal at which a Moving overshoot is still accepted as Completed. /// Default: 0.15m /// /// Meaning: When overshoot is detected during Moving, if robot is within this radius of the goal, /// navigation proceeds to final rotation → Completed. Otherwise → Error. /// /// ↑ Increase (0.2-0.3): /// ✓ More tolerant of overshoot — fewer Error states /// ✗ Robot may report Completed at a position far from goal /// /// ↓ Decrease (0.05-0.1): /// ✓ Higher positional accuracy requirement /// ✗ More likely to trigger Error on minor overshoot /// /// Tuning Tips: /// - Should be ≥ ReachedRadius /// - Must be ≤ MovingOvershootDetectionRadius to be meaningful /// - For high-precision tasks: 0.05-0.1 /// - For general navigation: 0.15-0.2 /// public double OvershootAcceptanceRadius { get; set; } = 0.15; /// /// Distance (meters) from goal at which overshoot detection begins during Moving. /// Default: 0.5m /// /// Meaning: Overshoot detection only activates when robot is within this radius of the final goal. /// Outside this radius, distance fluctuations are ignored. /// /// ↑ Increase (0.8-1.0): /// ✓ Earlier overshoot detection /// ✗ May false-trigger on path curvature near goal /// /// ↓ Decrease (0.2-0.3): /// ✓ Fewer false triggers /// ✗ Late detection — robot may travel further past goal before stopping /// /// Tuning Tips: /// - Should be > OvershootAcceptanceRadius /// - Typical: 2-5x the ReachedRadius /// - If robot has high inertia/speed: increase to 0.8-1.0 /// public double MovingOvershootDetectionRadius { get; set; } = 0.5; /// /// Distance (meters) from checkpoint at which PID deceleration begins during Moving. /// Default: 5.0m /// /// Meaning: When distance to checkpoint > this value, robot runs at MaxLinearVelocity. /// Below this distance, PID ramps velocity down proportionally. /// /// ↑ Increase (7-10): /// ✓ Earlier, smoother deceleration /// ✗ Slower average speed on long paths /// /// ↓ Decrease (2-3): /// ✓ Faster average speed — stays at max longer /// ✗ Sharper deceleration, may overshoot on heavy robots /// /// Tuning Tips: /// - Depends on MaxLinearVelocity and robot mass/inertia /// - Rule of thumb: stopping distance ≈ v² / (2 × deceleration) /// - Heavy/fast robot: 7-10m; Light/slow robot: 2-3m /// public double DecelerationDistance { get; set; } = 5.0; /// /// Maximum linear velocity (m/s) when robot is carrying a load during Moving. /// Default: 0.3 m/s /// /// When hasLoad=true, MaxLinearVelocity is capped at min(MaxLinearVelocity, LoadedMaxLinearVelocity). /// public double LoadedMaxLinearVelocity { get; set; } = 0.3; /// /// Maximum heading error (degrees) allowed when starting MoveStraight or Docking with a load. /// Default: 10.0 degrees /// /// When hasLoad=true, the robot cannot rotate to correct heading before MoveStraight/Docking. /// If the heading error exceeds this threshold at start, navigation transitions to Error. /// public double LoadedHeadingErrorThresholdDegrees { get; set; } = 10.0; } public partial class CSharpNavigation : INavigation, IDisposable { public bool IsReady { get; private set; } public bool Driving => NavState is NavigationState.Rotating or NavigationState.Moving or NavigationState.Docking or NavigationState.FinePositioning or NavigationState.MovingStraight or NavigationState.SafetyStop; public double VelocityX => VelController.ActualVelocity.Linear; public double VelocityY { get; private set; } public double Omega => VelController.ActualVelocity.Angular; public NavigationState State => NavState; public IReadOnlyList? CurrentWaypoints => MovePurePursuit?.Waypoints_Value ?? MoveStraightController?.Waypoints_Value ?? DockToController?.Waypoints_Value; // DockTo monitoring properties (read-only, for NavigationMonitor) public bool IsDockingActive => NavState is NavigationState.Docking or NavigationState.FinePositioning; public NavigationNode? DockGoal => DockToController?.Goal; public string DockPhase => NavState switch { NavigationState.Docking => "Approaching", NavigationState.FinePositioning when _finePositioningIsAligning => "Aligning", NavigationState.FinePositioning => "Advancing", _ => "" }; public string DockDirection => DockToController?.DockConfig?.DockToDirection.ToString() ?? ""; public int DockRetryCount => _finePositioningRetryCount; public int DockMaxRetries => _finePositioningMaxRetries; public int DockWaypointCount => DockToController?.Waypoints_Value?.Count ?? 0; public NavigationNode? DockStartNode => DockToController?.StartNode; public IReadOnlyList? DockWaypoints => DockToController?.Waypoints_Value; public event Action? OnNavigationFinished; private readonly ILocalization Localization; public readonly IVelocityController VelController; private readonly INavigationConfig NavigationConfig; private readonly ILogger Logger; private NavigationState NavState = NavigationState.Idle; private NavigationState ResumeState = NavigationState.Idle; // Safety stop: saves previous state for Refresh() to resume from private NavigationState _safetyStopPreviousState = NavigationState.Idle; private WatchThread? NavThread = null; private const int CycleHandlerMilliseconds = 30; private PID? MovePID; private PurePursuit? MovePurePursuit; private DockToController? DockToController; private DockToController? MoveStraightController; private IDetectSession? _dockSession; private OrderNode? GoalRotate; private OrderNode? CurrentBaseNode; private HashSet ProcessedRotations = []; private double TargetAngle = 0; private PID? RotatePID; private readonly NavigationConfig NavCog; private double MaxLinearVelocity = 0; // Overshoot detection private double _oldDistanceToGoal = double.MaxValue; private bool _wasApproaching = false; // Initial rotation skip private bool _isInitialRotation = false; // Fine Positioning state private int _finePositioningRetryCount = 0; private int _finePositioningMaxRetries = 3; private int _finePositioningCycleCount = 0; private int _finePositioningTimeoutMs = 6000; private bool _finePositioningIsAligning = true; private PID? _finePositionRotatePID = null; private RobotDirection _finePositioningDirection = RobotDirection.FORWARD; private bool _fpWasApproaching = false; private int _fpOvershootCounter = 0; private int _disposed = 0; // HasLoad: robot is carrying a load — affects speed limits, rotation, and FinePositioning private bool _hasLoad = false; // Tracks whether velocity (0,0) has already been sent when entering Paused/SafetyStop. // Prevents Navigation from continuously overwriting ManualControl velocity commands. private bool _idleVelocityZeroSent = false; private void ResetOvershootState() { _oldDistanceToGoal = double.MaxValue; _wasApproaching = false; _overshootCounter = 0; } public CSharpNavigation(IServiceProvider ServiceProvider) { Localization = ServiceProvider.GetRequiredService(); VelController = ServiceProvider.GetRequiredService(); NavigationConfig = ServiceProvider.GetRequiredService(); Logger = ServiceProvider.GetRequiredService>(); NavCog = NavigationConfig.GetNavigationConfig(); MaxLinearVelocity = NavCog.MaxLinearVelocity; } public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) != 0) return; HandleNavigationStop(); Clear(); OnNavigationFinished?.Invoke(NavState); GC.SuppressFinalize(this); } public void Start() { IsReady = true; } public void Stop() { Dispose(); } protected void HandleNavigationStart() { NavThread = new(CycleHandlerMilliseconds, NavigationHandler, Logger); NavThread.Start(); } protected void HandleNavigationStop() { NavThread?.Dispose(); NavThread = null; } public void CancelMovement() { NavState = NavigationState.Canceled; Dispose(); } public void Move(RobotNet.VDA5050.Order.OrderMsg order, bool hasLoad = false) { var nodes = order.Nodes; var edges = order.Edges; if (NavThread is not null) throw new NavigationException("The Navigation module is called during operation."); _hasLoad = hasLoad; NavState = NavigationState.Initializing; if (!VelController.EnsureInverseKinematicsReady(CancellationToken.None)) { NavState = NavigationState.Idle; throw new NavigationException("The Velocity Controller is not ready for inverse kinematics."); } VelController.LoadConfig(); VelController.SetAcceleration(NavCog.Acceleration); VelController.SetDeceleration(NavCog.Deceleration); MovePID = new PID(NavigationConfig.GetMovePidConfig()); var ppConfig = NavigationConfig.GetPurepursuitConfig(); MovePurePursuit = new PurePursuit(NavigationConfig.GetPurepursuitConfig(), NavigationConfig.GetStanleyConig()).WithPath(nodes, edges, Localization.Theta); // Reset overshoot detection state ResetOvershootState(); _isInitialRotation = true; (_, int index) = MovePurePursuit.OnNode(Localization.X, Localization.Y); if (index >= MovePurePursuit.Waypoints_Value.Count - 1) { NavState = NavigationState.Completed; Dispose(); return; } // === Local Planner: sinh approach path === var lpConfig = NavigationConfig.GetLocalPlannerConfig(); double heading; var approachResult = lpConfig.Enabled ? MovePurePursuit.GenerateAndPrependApproachPath( Localization.X, Localization.Y, index, lpConfig) : ApproachResult.Disabled; if (approachResult == ApproachResult.ApproachGenerated) { // Heading = hướng tiếp tuyến đầu approach curve (P0 → P1) var wp0 = MovePurePursuit.Waypoints_Value[0]; var wp1 = MovePurePursuit.Waypoints_Value[1]; heading = Math.Atan2(wp1.Y - wp0.Y, wp1.X - wp0.X); if (wp0.Direction == RobotDirection.BACKWARD) heading += Math.PI; } else if (approachResult == ApproachResult.AlreadyOnPath) { // Robot đã trên path → dùng path tangent tại closest waypoint var wp = MovePurePursuit.Waypoints_Value[index]; var wpNext = MovePurePursuit.Waypoints_Value[index + 1]; heading = Math.Atan2(wpNext.Y - wp.Y, wpNext.X - wp.X); if (wp.Direction == RobotDirection.BACKWARD) heading += Math.PI; } else { // TooFarFromPath / Disabled: heading hướng về lookahead point double lookahead = (ppConfig.LookaheadMin + ppConfig.LookaheadMax) / 2; var targetPoint = FindLookaheadTarget(index, lookahead); targetPoint ??= MovePurePursuit.Waypoints_Value[^1]; heading = Math.Atan2(targetPoint.Y - Localization.Y, targetPoint.X - Localization.X); if (targetPoint.Direction == RobotDirection.BACKWARD) heading += Math.PI; } heading = SpaceCompute.NormalizeRadianAngle(heading); // Cap speed when loaded if (_hasLoad) { MaxLinearVelocity = Math.Min(MaxLinearVelocity, NavCog.LoadedMaxLinearVelocity); } Rotate(heading); } public void MoveStraight(double x, double y, bool hasLoad = false, RobotDirection? direction = null) { if (NavThread is not null) throw new NavigationException("The Navigation module is called during operation."); _hasLoad = hasLoad; NavState = NavigationState.Initializing; if (!VelController.EnsureInverseKinematicsReady(CancellationToken.None)) { NavState = NavigationState.Idle; throw new NavigationException("The Velocity Controller is not ready for inverse kinematics."); } VelController.LoadConfig(); VelController.SetAcceleration(NavCog.Acceleration); VelController.SetDeceleration(NavCog.Deceleration); MovePID = new PID(NavigationConfig.GetMovePidConfig()); var straightCfg = NavigationConfig.GetMoveStraightConfig().Clone(); if (direction.HasValue) straightCfg.DockToDirection = direction.Value; var startNode = new NavigationNode { Id = Guid.NewGuid(), X = Localization.X, Y = Localization.Y, }; var goalNode = new NavigationNode { Id = Guid.NewGuid(), X = x, Y = y, }; MoveStraightController = new DockToController(straightCfg).WithPath(startNode, goalNode); (_, int index) = MoveStraightController.GetClosestAheadWaypoint(Localization.X, Localization.Y); if (index >= MoveStraightController.Waypoints_Value.Count - 1) { NavState = NavigationState.Completed; Dispose(); return; } double heading = Math.Atan2(y - Localization.Y, x - Localization.X); if (straightCfg.DockToDirection == RobotDirection.BACKWARD) heading += Math.PI; heading = SpaceCompute.NormalizeRadianAngle(heading); ResetOvershootState(); MaxLinearVelocity = NavCog.MaxLinearVelocity; if (_hasLoad) { // When loaded: no rotation allowed. Check heading error. double headingError = heading - Localization.Theta; if (headingError > Math.PI) headingError -= 2 * Math.PI; else if (headingError < -Math.PI) headingError += 2 * Math.PI; double thresholdRad = NavCog.LoadedHeadingErrorThresholdDegrees * Math.PI / 180.0; if (Math.Abs(headingError) > thresholdRad) { Logger.LogError($"MoveStraight hasLoad: heading error {Math.Abs(headingError) * 180 / Math.PI:F1}° exceeds threshold {NavCog.LoadedHeadingErrorThresholdDegrees:F1}°"); NavState = NavigationState.Error; Dispose(); return; } // Skip rotation, go directly to MovingStraight NavState = NavigationState.MovingStraight; HandleNavigationStart(); } else { _isInitialRotation = true; Rotate(heading); } } public void DockTo(IDetectSession session, bool hasLoad = false, RobotDirection? direction = null) { if (NavThread is not null) throw new NavigationException("The Navigation module is called during operation."); var goal = session.Goal ?? throw new NavigationException("Dock to Goal is not existed"); _hasLoad = hasLoad; _dockSession = session; NavState = NavigationState.Initializing; if (!VelController.EnsureInverseKinematicsReady(CancellationToken.None)) { NavState = NavigationState.Idle; throw new NavigationException("The Velocity Controller is not ready for inverse kinematics."); } VelController.LoadConfig(); VelController.SetAcceleration(NavCog.Acceleration); VelController.SetDeceleration(NavCog.Deceleration); MovePID = new PID(NavigationConfig.GetMovePidConfig()); var docktoConfig = NavigationConfig.GetDockToConfig().Clone(); if (direction.HasValue) docktoConfig.DockToDirection = direction.Value; var currentGoal = new NavigationNode() { Id = Guid.NewGuid(), X = goal.Pose.Position.X, Y = goal.Pose.Position.Y, Speed = NavCog.DockToMaxSpeed, Theta = goal.Pose.Orientation.ToYawRadian(), }; var startNode = GetDockToStartNode(Localization.X, Localization.Y, currentGoal.X, currentGoal.Y, currentGoal.Theta ?? 0, docktoConfig.DockToLength); DockToController = new DockToController(docktoConfig).WithPath(startNode, currentGoal); (_, int index) = DockToController.GetClosestAheadWaypoint(Localization.X, Localization.Y); if (index >= DockToController.Waypoints_Value.Count - 1) { NavState = NavigationState.Completed; Dispose(); return; } var pathAngle = Math.Atan2(currentGoal.Y - startNode.Y, currentGoal.X - startNode.X); double frontX = DockToController.Waypoints_Value[index].X + docktoConfig.WheelBase * Math.Cos(pathAngle); double frontY = DockToController.Waypoints_Value[index].Y + docktoConfig.WheelBase * Math.Sin(pathAngle); double dx = frontX - Localization.X; double dy = frontY - Localization.Y; double heading = Math.Atan2(dy, dx); if (docktoConfig.DockToDirection == RobotDirection.BACKWARD) heading += Math.PI; heading = SpaceCompute.NormalizeRadianAngle(heading); ResetOvershootState(); MaxLinearVelocity = NavCog.DockToMaxSpeed; // Load Fine Positioning config from DockToConfig _finePositioningTimeoutMs = docktoConfig.FinePositioningTimeoutMs; _finePositioningMaxRetries = docktoConfig.FinePositioningMaxRetries; // Reset Fine Positioning state _finePositioningRetryCount = 0; _finePositioningCycleCount = 0; _finePositioningIsAligning = true; _finePositionRotatePID = null; _finePositioningDirection = RobotDirection.FORWARD; _fpWasApproaching = false; _fpOvershootCounter = 0; if (_hasLoad) { // When loaded: no rotation allowed. Check heading error. double headingError = heading - Localization.Theta; if (headingError > Math.PI) headingError -= 2 * Math.PI; else if (headingError < -Math.PI) headingError += 2 * Math.PI; double thresholdRad = NavCog.LoadedHeadingErrorThresholdDegrees * Math.PI / 180.0; if (Math.Abs(headingError) > thresholdRad) { Logger.LogError($"DockTo hasLoad: heading error {Math.Abs(headingError) * 180 / Math.PI:F1}° exceeds threshold {NavCog.LoadedHeadingErrorThresholdDegrees:F1}°"); NavState = NavigationState.Error; Dispose(); return; } // Skip rotation, go directly to Docking NavState = NavigationState.Docking; HandleNavigationStart(); } else { _isInitialRotation = true; Rotate(heading); } } public void Pause() { ResumeState = NavState; _idleVelocityZeroSent = false; NavState = NavigationState.Paused; } public void SafetyStop() { _safetyStopPreviousState = NavState; _idleVelocityZeroSent = false; NavState = NavigationState.SafetyStop; } public void Refresh() { _idleVelocityZeroSent = false; if (NavState != NavigationState.SafetyStop) { NavState = NavigationState.Idle; return; } var prevState = _safetyStopPreviousState; var x = Localization.X; var y = Localization.Y; if (prevState is NavigationState.Moving && MovePurePursuit?.Waypoints_Value is { Count: > 2 }) { MovePurePursuit.ResetTracking(); MovePurePursuit.RebuildPath(); (_, int index) = MovePurePursuit.OnNode(x, y); if (index >= MovePurePursuit.Waypoints_Value.Count - 1) { NavState = NavigationState.Completed; Dispose(); return; } MovePID = new PID(NavigationConfig.GetMovePidConfig()); ResetOvershootState(); // === Local Planner (same logic as Move) === var lpConfig = NavigationConfig.GetLocalPlannerConfig(); var ppConfig = NavigationConfig.GetPurepursuitConfig(); double heading; var approachResult = lpConfig.Enabled ? MovePurePursuit.GenerateAndPrependApproachPath( x, y, index, lpConfig) : ApproachResult.Disabled; if (approachResult == ApproachResult.ApproachGenerated) { var wp0 = MovePurePursuit.Waypoints_Value[0]; var wp1 = MovePurePursuit.Waypoints_Value[1]; heading = Math.Atan2(wp1.Y - wp0.Y, wp1.X - wp0.X); if (wp0.Direction == RobotDirection.BACKWARD) heading += Math.PI; } else if (approachResult == ApproachResult.AlreadyOnPath) { var wp = MovePurePursuit.Waypoints_Value[index]; var wpNext = MovePurePursuit.Waypoints_Value[index + 1]; heading = Math.Atan2(wpNext.Y - wp.Y, wpNext.X - wp.X); if (wp.Direction == RobotDirection.BACKWARD) heading += Math.PI; } else { double lookahead = (ppConfig.LookaheadMin + ppConfig.LookaheadMax) / 2; var target = FindLookaheadTarget(index, lookahead) ?? MovePurePursuit.Waypoints_Value[^1]; heading = Math.Atan2(target.Y - y, target.X - x); if (target.Direction == RobotDirection.BACKWARD) heading += Math.PI; } heading = SpaceCompute.NormalizeRadianAngle(heading); _isInitialRotation = true; RotatePID = new PID(NavigationConfig.GetRotatePidConfig()); TargetAngle = heading; NavState = NavigationState.Rotating; } else if (prevState is NavigationState.Docking && DockToController?.Waypoints_Value is { Count: > 2 }) { DockToController.ResetTracking(); (_, int index) = DockToController.GetClosestAheadWaypoint(x, y); if (index >= DockToController.Waypoints_Value.Count - 1) { NavState = NavigationState.Completed; Dispose(); return; } MovePID = new PID(NavigationConfig.GetMovePidConfig()); ResetOvershootState(); var pathAngle = Math.Atan2( DockToController.Goal.Y - DockToController.Waypoints_Value[0].Y, DockToController.Goal.X - DockToController.Waypoints_Value[0].X); double frontX = DockToController.Waypoints_Value[index].X + DockToController.DockConfig.WheelBase * Math.Cos(pathAngle); double frontY = DockToController.Waypoints_Value[index].Y + DockToController.DockConfig.WheelBase * Math.Sin(pathAngle); double heading = Math.Atan2(frontY - y, frontX - x); if (DockToController.DockConfig.DockToDirection == RobotDirection.BACKWARD) heading += Math.PI; heading = SpaceCompute.NormalizeRadianAngle(heading); if (_hasLoad) { // When loaded: check heading error, resume Docking directly without rotation double headingError = heading - Localization.Theta; if (headingError > Math.PI) headingError -= 2 * Math.PI; else if (headingError < -Math.PI) headingError += 2 * Math.PI; double thresholdRad = NavCog.LoadedHeadingErrorThresholdDegrees * Math.PI / 180.0; if (Math.Abs(headingError) > thresholdRad) { Logger.LogError($"Refresh Docking hasLoad: heading error {Math.Abs(headingError) * 180 / Math.PI:F1}° exceeds threshold"); NavState = NavigationState.Error; Dispose(); return; } NavState = NavigationState.Docking; } else { _isInitialRotation = true; RotatePID = new PID(NavigationConfig.GetRotatePidConfig()); TargetAngle = heading; NavState = NavigationState.Rotating; } } else if (prevState is NavigationState.MovingStraight && MoveStraightController?.Waypoints_Value is { Count: > 2 }) { MoveStraightController.ResetTracking(); (_, int index) = MoveStraightController.GetClosestAheadWaypoint(x, y); if (index >= MoveStraightController.Waypoints_Value.Count - 1) { NavState = NavigationState.Completed; Dispose(); return; } MovePID = new PID(NavigationConfig.GetMovePidConfig()); ResetOvershootState(); double heading = Math.Atan2( MoveStraightController.Goal.Y - y, MoveStraightController.Goal.X - x); if (MoveStraightController.DockConfig.DockToDirection == RobotDirection.BACKWARD) heading += Math.PI; heading = SpaceCompute.NormalizeRadianAngle(heading); if (_hasLoad) { // When loaded: check heading error, resume MovingStraight directly without rotation double headingError = heading - Localization.Theta; if (headingError > Math.PI) headingError -= 2 * Math.PI; else if (headingError < -Math.PI) headingError += 2 * Math.PI; double thresholdRad = NavCog.LoadedHeadingErrorThresholdDegrees * Math.PI / 180.0; if (Math.Abs(headingError) > thresholdRad) { Logger.LogError($"Refresh MovingStraight hasLoad: heading error {Math.Abs(headingError) * 180 / Math.PI:F1}° exceeds threshold"); NavState = NavigationState.Error; Dispose(); return; } NavState = NavigationState.MovingStraight; } else { _isInitialRotation = true; RotatePID = new PID(NavigationConfig.GetRotatePidConfig()); TargetAngle = heading; NavState = NavigationState.Rotating; } } else if (prevState is NavigationState.Rotating) { RotatePID = new PID(NavigationConfig.GetRotatePidConfig()); NavState = NavigationState.Rotating; } else if (prevState is NavigationState.FinePositioning) { _finePositioningCycleCount = 0; _finePositioningIsAligning = true; _finePositionRotatePID = null; NavState = NavigationState.Docking; } else { NavState = NavigationState.Idle; } } public void RefreshOrder(Node[] nodes, Edge[] edges) { } public void Resume() { if (ResumeState == NavigationState.FinePositioning) { _finePositioningCycleCount = 0; } _idleVelocityZeroSent = false; NavState = ResumeState; } public void Rotate(double angle) { if (NavThread is not null) throw new NavigationException("The Navigation module is called during operation."); if (!VelController.EnsureInverseKinematicsReady(CancellationToken.None)) { NavState = NavigationState.Idle; throw new NavigationException("The Velocity Controller is not ready for inverse kinematics."); } RotatePID = new PID(NavigationConfig.GetRotatePidConfig()); TargetAngle = SpaceCompute.NormalizeRadianAngle(angle); NavState = NavigationState.Rotating; HandleNavigationStart(); } public void SetSpeed(double speed) { MaxLinearVelocity = _hasLoad ? Math.Min(speed, NavCog.LoadedMaxLinearVelocity) : speed; } protected void UpdateGoal(string goalId) { MovePurePursuit?.UpdateGoal(goalId); } public void UpdateOrder(string newBaseNodeId) { var newBaseNode = MovePurePursuit?.OrderNodes.FirstOrDefault(n => n.NodeId == newBaseNodeId); if (newBaseNode is not null && newBaseNode.NodeId != CurrentBaseNode?.NodeId) { CurrentBaseNode = newBaseNode; var newGoalRotate = FindNextRotateGoal(); if (newGoalRotate is not null && newGoalRotate.NodeId != GoalRotate?.NodeId) { GoalRotate = newGoalRotate; UpdateGoal(newGoalRotate.NodeId); } } } private void Clear() { VelController.SetVelocity(0, 0); CurrentBaseNode = null; MovePurePursuit = null; MovePID = null; RotatePID = null; GoalRotate = null; DockToController = null; MoveStraightController = null; _dockSession = null; ProcessedRotations = []; ResetOvershootState(); _isInitialRotation = false; _finePositioningRetryCount = 0; _finePositioningCycleCount = 0; _finePositioningIsAligning = true; _finePositionRotatePID = null; _finePositioningDirection = RobotDirection.FORWARD; _fpWasApproaching = false; _fpOvershootCounter = 0; _hasLoad = false; _idleVelocityZeroSent = false; } /// /// Tìm target point tại lookahead distance từ vị trí hiện tại trên path. /// Nội suy giữa các waypoint để tạo điểm mượt. /// private NavigationNode? FindLookaheadTarget(int startIndex, double lookaheadDistance) { if (MovePurePursuit is null || startIndex >= MovePurePursuit.Waypoints_Value.Count - 1) return null; double accumulatedDistance = 0; for (int i = startIndex; i < MovePurePursuit.Waypoints_Value.Count - 1; i++) { double dx = MovePurePursuit.Waypoints_Value[i + 1].X - MovePurePursuit.Waypoints_Value[i].X; double dy = MovePurePursuit.Waypoints_Value[i + 1].Y - MovePurePursuit.Waypoints_Value[i].Y; double segmentLength = Math.Sqrt(dx * dx + dy * dy); if (accumulatedDistance + segmentLength >= lookaheadDistance) { double t = (lookaheadDistance - accumulatedDistance) / segmentLength; return new NavigationNode { Id = Guid.NewGuid(), NodeId = string.Empty, X = MovePurePursuit.Waypoints_Value[i].X + t * dx, Y = MovePurePursuit.Waypoints_Value[i].Y + t * dy, Direction = MovePurePursuit.Waypoints_Value[i].Direction }; } accumulatedDistance += segmentLength; } return null; } /// /// Tìm node có IsWaitingRotate đầu tiên trong path từ currentNode đến currentGoal /// protected OrderNode? FindNextRotateGoal() { if (CurrentBaseNode == null || MovePurePursuit is null || MovePurePursuit.OrderNodes.Length == 0) return null; int goalIndex = Array.FindIndex(MovePurePursuit.OrderNodes, n => n.NodeId == CurrentBaseNode.NodeId); if (goalIndex == -1) return null; int lastNodeIdx = Array.FindIndex(MovePurePursuit.OrderNodes, n => n.NodeId == GoalRotate?.NodeId); lastNodeIdx = lastNodeIdx == -1 ? 0 : lastNodeIdx + 1; // Tìm từ node hiện tại đến goal for (int i = lastNodeIdx; i <= goalIndex; i++) { var node = MovePurePursuit.OrderNodes[i]; // Tìm node có IsWaitingRotate và chưa xử lý if (node.IsWaitRotating && !ProcessedRotations.Contains(node.NodeId)) { return node; } } return CurrentBaseNode; } private NavigationNode GetDockToStartNode(double x, double y, double goalX, double goalY, double goalTheta, double length) { // Hướng vuông góc với theta double perpAngle = SpaceCompute.NormalizeRadianAngle(goalTheta + Math.PI / 2); // Hai endpoint của đoạn thẳng, cách goal ±length theo hướng vuông góc double ep1X = goalX + length * Math.Cos(perpAngle); double ep1Y = goalY + length * Math.Sin(perpAngle); double ep2X = goalX - length * Math.Cos(perpAngle); double ep2Y = goalY - length * Math.Sin(perpAngle); // Chọn endpoint gần (x, y) nhất double dist1Sq = (ep1X - x) * (ep1X - x) + (ep1Y - y) * (ep1Y - y); double dist2Sq = (ep2X - x) * (ep2X - x) + (ep2Y - y) * (ep2Y - y); double startX = dist1Sq <= dist2Sq ? ep1X : ep2X; double startY = dist1Sq <= dist2Sq ? ep1Y : ep2Y; return new NavigationNode { Id = Guid.NewGuid(), X = startX, Y = startY, }; } }