Skip to main content

rust_robotics_control/
feedback_linearization.rs

1//! Feedback Linearization controller for a unicycle robot.
2//!
3//! Places a virtual point P at distance `L` ahead of the robot and applies
4//! proportional control to drive P to the goal, then transforms the
5//! Cartesian velocity commands back into (v, ω) for the unicycle.
6use rust_robotics_core::{ControlInput, Path2D, Point2D, Pose2D};
7
8/// Configuration for the Feedback Linearization controller.
9#[derive(Debug, Clone, Copy)]
10pub struct FeedbackLinearizationConfig {
11    /// Proportional gain on x-error of the virtual point \[1/s\].
12    pub k1: f64,
13    /// Proportional gain on y-error of the virtual point \[1/s\].
14    pub k2: f64,
15    /// Distance of the virtual point ahead of the robot \[m\].
16    pub offset_distance: f64,
17    /// Integration time-step \[s\].
18    pub dt: f64,
19    /// Distance at which convergence is declared \[m\].
20    pub goal_tolerance: f64,
21    /// Maximum simulation steps before giving up.
22    pub max_steps: usize,
23}
24
25impl Default for FeedbackLinearizationConfig {
26    fn default() -> Self {
27        Self {
28            k1: 1.0,
29            k2: 1.0,
30            offset_distance: 0.1,
31            dt: 0.01,
32            goal_tolerance: 0.05,
33            max_steps: 10_000,
34        }
35    }
36}
37
38/// One recorded step of the simulation.
39#[derive(Debug, Clone, Copy)]
40pub struct FeedbackLinearizationStep {
41    pub pose: Pose2D,
42    pub control: ControlInput,
43    pub distance_to_goal: f64,
44}
45
46/// Full simulation result.
47#[derive(Debug, Clone)]
48pub struct FeedbackLinearizationResult {
49    pub steps: Vec<FeedbackLinearizationStep>,
50    pub converged: bool,
51}
52
53impl FeedbackLinearizationResult {
54    pub fn final_pose(&self) -> Pose2D {
55        self.steps
56            .last()
57            .map(|s| s.pose)
58            .unwrap_or_else(Pose2D::origin)
59    }
60
61    pub fn iterations(&self) -> usize {
62        self.steps.len().saturating_sub(1)
63    }
64
65    pub fn path(&self) -> Path2D {
66        Path2D::from_points(
67            self.steps
68                .iter()
69                .map(|s| Point2D::new(s.pose.x, s.pose.y))
70                .collect(),
71        )
72    }
73}
74
75/// Feedback Linearization controller.
76pub struct FeedbackLinearizationController {
77    config: FeedbackLinearizationConfig,
78}
79
80impl FeedbackLinearizationController {
81    pub fn new(config: FeedbackLinearizationConfig) -> Self {
82        Self { config }
83    }
84
85    pub fn config(&self) -> FeedbackLinearizationConfig {
86        self.config
87    }
88
89    /// Simulate the controller from `start` to `goal`.
90    pub fn simulate(&self, start: Pose2D, goal: Pose2D) -> FeedbackLinearizationResult {
91        let mut pose = start;
92        let mut steps = vec![FeedbackLinearizationStep {
93            pose,
94            control: ControlInput::zero(),
95            distance_to_goal: dist(pose, goal),
96        }];
97
98        for _ in 0..self.config.max_steps {
99            // Convergence is declared when the virtual point P (L ahead of the
100            // robot) is within goal_tolerance of the goal.  This avoids the
101            // inherent steady-state offset of L between the robot centre and P.
102            let l = self.config.offset_distance;
103            let px = pose.x + l * pose.yaw.cos();
104            let py = pose.y + l * pose.yaw.sin();
105            let p_dist = ((goal.x - px).powi(2) + (goal.y - py).powi(2)).sqrt();
106            if p_dist <= self.config.goal_tolerance {
107                return FeedbackLinearizationResult {
108                    steps,
109                    converged: true,
110                };
111            }
112
113            let control = self.compute_control(pose, goal);
114            pose = integrate_pose(pose, control, self.config.dt);
115            steps.push(FeedbackLinearizationStep {
116                pose,
117                control,
118                distance_to_goal: dist(pose, goal),
119            });
120        }
121
122        FeedbackLinearizationResult {
123            steps,
124            converged: false,
125        }
126    }
127
128    fn compute_control(&self, pose: Pose2D, goal: Pose2D) -> ControlInput {
129        let l = self.config.offset_distance;
130        let (cos_t, sin_t) = (pose.yaw.cos(), pose.yaw.sin());
131
132        // Virtual point P ahead of robot
133        let px = pose.x + l * cos_t;
134        let py = pose.y + l * sin_t;
135
136        // Desired Cartesian velocity for P
137        let ux = -self.config.k1 * (px - goal.x);
138        let uy = -self.config.k2 * (py - goal.y);
139
140        // Transform back to unicycle (v, ω)
141        let v = ux * cos_t + uy * sin_t;
142        let omega = (-ux * sin_t + uy * cos_t) / l;
143
144        ControlInput::new(v, omega)
145    }
146}
147
148/// Convenience function.
149pub fn feedback_linearization(
150    start: Pose2D,
151    goal: Pose2D,
152    config: FeedbackLinearizationConfig,
153) -> FeedbackLinearizationResult {
154    FeedbackLinearizationController::new(config).simulate(start, goal)
155}
156
157fn integrate_pose(pose: Pose2D, control: ControlInput, dt: f64) -> Pose2D {
158    let yaw = pose.yaw + control.omega * dt;
159    Pose2D::new(
160        pose.x + control.v * yaw.cos() * dt,
161        pose.y + control.v * yaw.sin() * dt,
162        yaw,
163    )
164}
165
166fn dist(pose: Pose2D, goal: Pose2D) -> f64 {
167    ((goal.x - pose.x).powi(2) + (goal.y - pose.y).powi(2)).sqrt()
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn test_config_defaults() {
176        let cfg = FeedbackLinearizationConfig::default();
177        assert_eq!(cfg.k1, 1.0);
178        assert_eq!(cfg.k2, 1.0);
179        assert_eq!(cfg.offset_distance, 0.1);
180        assert_eq!(cfg.dt, 0.01);
181        assert_eq!(cfg.goal_tolerance, 0.05);
182        assert_eq!(cfg.max_steps, 10_000);
183    }
184
185    #[test]
186    fn test_feedback_linearization_converges() {
187        let start = Pose2D::origin();
188        let goal = Pose2D::new(5.0, 5.0, 0.0);
189        let result = feedback_linearization(start, goal, FeedbackLinearizationConfig::default());
190        let fp = result.final_pose();
191
192        let l = FeedbackLinearizationConfig::default().offset_distance;
193        let px = fp.x + l * fp.yaw.cos();
194        let py = fp.y + l * fp.yaw.sin();
195        assert!(
196            result.converged,
197            "controller did not converge (virtual point distance: {:.4})",
198            ((goal.x - px).powi(2) + (goal.y - py).powi(2)).sqrt()
199        );
200        // Virtual point P is at goal; robot body is at most L away.
201        assert!((px - goal.x).abs() < 0.1);
202        assert!((py - goal.y).abs() < 0.1);
203    }
204
205    #[test]
206    fn test_feedback_linearization_non_empty_path() {
207        let controller =
208            FeedbackLinearizationController::new(FeedbackLinearizationConfig::default());
209        let result = controller.simulate(Pose2D::origin(), Pose2D::new(2.0, 1.0, 0.0));
210        let path = result.path();
211
212        assert!(
213            path.points.len() > 1,
214            "path should have more than one point"
215        );
216        assert_eq!(path.points[0].x, 0.0);
217        assert_eq!(path.points[0].y, 0.0);
218    }
219}