Initial commit

This commit is contained in:
2026-07-13 09:25:40 +07:00
parent c08ff54676
commit bccfb156d7
1938 changed files with 641646 additions and 0 deletions

View File

@@ -0,0 +1,874 @@
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<SafetySpeed, double> SafetySpeedMap { get; set; } = [];
/// <summary>
/// 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
/// </summary>
public double OvershootAcceptanceRadius { get; set; } = 0.15;
/// <summary>
/// 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
/// </summary>
public double MovingOvershootDetectionRadius { get; set; } = 0.5;
/// <summary>
/// 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
/// </summary>
public double DecelerationDistance { get; set; } = 5.0;
/// <summary>
/// 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).
/// </summary>
public double LoadedMaxLinearVelocity { get; set; } = 0.3;
/// <summary>
/// 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.
/// </summary>
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<NavigationNode>? 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<NavigationNode>? DockWaypoints => DockToController?.Waypoints_Value;
public event Action<NavigationState>? OnNavigationFinished;
private readonly ILocalization Localization;
public readonly IVelocityController VelController;
private readonly INavigationConfig NavigationConfig;
private readonly ILogger<CSharpNavigation> 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<CSharpNavigation>? 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<string> 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<ILocalization>();
VelController = ServiceProvider.GetRequiredService<IVelocityController>();
NavigationConfig = ServiceProvider.GetRequiredService<INavigationConfig>();
Logger = ServiceProvider.GetRequiredService<ILogger<CSharpNavigation>>();
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Tìm node có IsWaitingRotate đầu tiên trong path từ currentNode đến currentGoal
/// </summary>
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,
};
}
}