Skip to main content

rust_robotics_control/
lqr_control.rs

1#![allow(
2    dead_code,
3    clippy::needless_borrows_for_generic_args,
4    clippy::new_without_default
5)]
6
7//
8// Inverted Pendulum LQR control
9// author: Trung Kien - letrungkien.k53.hut@gmail.com
10// Ported to Rust by: rust_robotics team
11//
12// Visualization style based on PythonRobotics by AtsushiSakai
13//
14
15#[cfg(feature = "viz")]
16use gnuplot::{AxesCommon, Coordinate, Figure, PlotOption};
17use nalgebra::{Matrix1, Matrix1x4, Matrix4, Vector4};
18
19// Model parameters
20const L_BAR: f64 = 2.0; // length of bar
21const M: f64 = 1.0; // [kg] cart mass
22const MASS: f64 = 0.3; // [kg] pendulum mass
23const G: f64 = 9.8; // [m/s^2] gravity
24
25const DELTA_T: f64 = 0.1; // time tick [s]
26const SIM_TIME: f64 = 5.0; // simulation time [s]
27
28// Cart dimensions (matching PythonRobotics)
29const CART_WIDTH: f64 = 1.0;
30const CART_HEIGHT: f64 = 0.5;
31const WHEEL_RADIUS: f64 = 0.1;
32
33pub struct InvertedPendulumLQR {
34    pub q: Matrix4<f64>,                      // state cost matrix
35    pub r: Matrix1<f64>,                      // input cost matrix
36    pub trajectory: Vec<(f64, Vector4<f64>)>, // time, state history
37}
38
39impl InvertedPendulumLQR {
40    pub fn new() -> Self {
41        let mut q = Matrix4::<f64>::zeros();
42        q[(1, 1)] = 1.0; // velocity cost
43        q[(2, 2)] = 1.0; // angle cost
44
45        let r = Matrix1::<f64>::from_element(0.01); // input cost
46
47        InvertedPendulumLQR {
48            q,
49            r,
50            trajectory: Vec::new(),
51        }
52    }
53
54    pub fn simulate(&mut self, x0: Vector4<f64>) -> bool {
55        let mut x = x0;
56        let mut time = 0.0;
57
58        self.trajectory.clear();
59        self.trajectory.push((time, x));
60
61        while time < SIM_TIME {
62            time += DELTA_T;
63
64            // Calculate control input
65            let u = self.lqr_control(&x);
66
67            // Simulate inverted pendulum cart
68            x = self.simulation_step(&x, u);
69
70            self.trajectory.push((time, x));
71        }
72
73        println!("Simulation finished");
74        println!(
75            "Final state: x={:.2} [m], theta={:.2} [deg]",
76            x[0],
77            x[2].to_degrees()
78        );
79
80        true
81    }
82
83    fn simulation_step(&self, x: &Vector4<f64>, u: Matrix1<f64>) -> Vector4<f64> {
84        let (a, b) = self.get_model_matrix();
85        a * x + b * u[0]
86    }
87
88    fn lqr_control(&self, x: &Vector4<f64>) -> Matrix1<f64> {
89        let (a, b) = self.get_model_matrix();
90        let (k, _, _) = self.dlqr(&a, &b, &self.q, &self.r);
91        Matrix1::from_element(-(k * x)[0])
92    }
93
94    fn get_model_matrix(&self) -> (Matrix4<f64>, Vector4<f64>) {
95        let mut a = Matrix4::<f64>::new(
96            0.0,
97            1.0,
98            0.0,
99            0.0,
100            0.0,
101            0.0,
102            MASS * G / M,
103            0.0,
104            0.0,
105            0.0,
106            0.0,
107            1.0,
108            0.0,
109            0.0,
110            G * (M + MASS) / (L_BAR * M),
111            0.0,
112        );
113        a = Matrix4::<f64>::identity() + DELTA_T * a;
114
115        let b = Vector4::new(0.0, 1.0 / M, 0.0, 1.0 / (L_BAR * M)) * DELTA_T;
116
117        (a, b)
118    }
119
120    fn solve_dare(
121        &self,
122        a: &Matrix4<f64>,
123        b: &Vector4<f64>,
124        q: &Matrix4<f64>,
125        r: &Matrix1<f64>,
126    ) -> Matrix4<f64> {
127        let mut p = *q;
128        let max_iter = 150;
129        let eps = 0.01;
130
131        for _ in 0..max_iter {
132            let bt_p = b.transpose() * p;
133            let denominator = r + bt_p * b;
134            if denominator[0].abs() < 1e-10 {
135                break;
136            }
137
138            let pn = a.transpose() * p * a
139                - a.transpose() * p * b * (1.0 / denominator[0]) * bt_p * a
140                + q;
141
142            if (pn - p).abs().max() < eps {
143                break;
144            }
145            p = pn;
146        }
147        p
148    }
149
150    fn dlqr(
151        &self,
152        a: &Matrix4<f64>,
153        b: &Vector4<f64>,
154        q: &Matrix4<f64>,
155        r: &Matrix1<f64>,
156    ) -> (Matrix1x4<f64>, Matrix4<f64>, Vector4<f64>) {
157        let p = self.solve_dare(a, b, q, r);
158
159        let bt_p = b.transpose() * p;
160        let denominator = r + bt_p * b;
161        let k = (1.0 / denominator[0]) * bt_p * a;
162
163        let closed_loop = a - b * k;
164        // Matrix4 is always square, so eigenvalue computation cannot fail
165        let eigenvalues = closed_loop
166            .eigenvalues()
167            .expect("eigenvalue computation failed on 4x4 matrix");
168
169        (k, p, eigenvalues)
170    }
171}
172
173// Visualization methods gated behind the "viz" feature
174#[cfg(feature = "viz")]
175impl InvertedPendulumLQR {
176    /// Generate cart polygon points for drawing
177    fn get_cart_polygon(&self, x: f64) -> (Vec<f64>, Vec<f64>) {
178        let y_offset = WHEEL_RADIUS * 2.0;
179        let half_w = CART_WIDTH / 2.0;
180
181        // Rectangle corners (closed polygon)
182        let xs = vec![x - half_w, x + half_w, x + half_w, x - half_w, x - half_w];
183        let ys = vec![
184            y_offset,
185            y_offset,
186            y_offset + CART_HEIGHT,
187            y_offset + CART_HEIGHT,
188            y_offset,
189        ];
190        (xs, ys)
191    }
192
193    /// Generate wheel circle points
194    fn get_wheel_points(&self, cx: f64, cy: f64) -> (Vec<f64>, Vec<f64>) {
195        let n = 20;
196        let mut xs = Vec::with_capacity(n + 1);
197        let mut ys = Vec::with_capacity(n + 1);
198        for i in 0..=n {
199            let angle = 2.0 * std::f64::consts::PI * (i as f64) / (n as f64);
200            xs.push(cx + WHEEL_RADIUS * angle.cos());
201            ys.push(cy + WHEEL_RADIUS * angle.sin());
202        }
203        (xs, ys)
204    }
205
206    /// Generate pendulum bar endpoints
207    fn get_pendulum_points(&self, x: f64, theta: f64) -> (Vec<f64>, Vec<f64>) {
208        let y_offset = WHEEL_RADIUS * 2.0 + CART_HEIGHT;
209        let bar_x = x + L_BAR * theta.sin();
210        let bar_y = y_offset + L_BAR * theta.cos();
211        (vec![x, bar_x], vec![y_offset, bar_y])
212    }
213
214    /// Visualize cart-pendulum animation (PythonRobotics style)
215    pub fn visualize(&self, filename: &str) {
216        // Create summary image with multiple frames
217        let num_frames = 6;
218        let total_steps = self.trajectory.len();
219        let step_interval = total_steps / num_frames;
220
221        let mut fg = Figure::new();
222        {
223            let axes = fg
224                .axes2d()
225                .set_title("Inverted Pendulum LQR Control", &[])
226                .set_x_label("x [m]", &[])
227                .set_y_label("y [m]", &[])
228                .set_aspect_ratio(gnuplot::AutoOption::Fix(1.0))
229                .set_x_range(
230                    gnuplot::AutoOption::Fix(-3.0),
231                    gnuplot::AutoOption::Fix(3.0),
232                )
233                .set_y_range(
234                    gnuplot::AutoOption::Fix(-1.0),
235                    gnuplot::AutoOption::Fix(4.0),
236                );
237
238            // Draw ground line
239            axes.lines(
240                &[-4.0, 4.0],
241                &[0.0, 0.0],
242                &[PlotOption::Color("gray"), PlotOption::LineWidth(2.0)],
243            );
244
245            // Draw multiple frames with transparency effect (lighter colors for older frames)
246            let colors = [
247                "#CCCCFF", "#AAAAFF", "#8888FF", "#6666FF", "#4444FF", "#0000FF",
248            ];
249            let pendulum_colors = [
250                "#CCCCCC", "#AAAAAA", "#888888", "#666666", "#444444", "#000000",
251            ];
252
253            for (frame_idx, color_idx) in (0..num_frames).zip(0..num_frames) {
254                let step = if frame_idx == num_frames - 1 {
255                    total_steps - 1
256                } else {
257                    frame_idx * step_interval
258                };
259
260                let (time, state) = &self.trajectory[step];
261                let x_pos = state[0];
262                let theta = state[2];
263
264                // Draw cart
265                let (cart_x, cart_y) = self.get_cart_polygon(x_pos);
266                axes.lines(
267                    &cart_x,
268                    &cart_y,
269                    &[
270                        PlotOption::Color(colors[color_idx]),
271                        PlotOption::LineWidth(2.0),
272                    ],
273                );
274
275                // Draw wheels
276                let y_offset = WHEEL_RADIUS;
277                let (w1x, w1y) = self.get_wheel_points(x_pos - CART_WIDTH / 4.0, y_offset);
278                let (w2x, w2y) = self.get_wheel_points(x_pos + CART_WIDTH / 4.0, y_offset);
279                axes.lines(
280                    &w1x,
281                    &w1y,
282                    &[
283                        PlotOption::Color(pendulum_colors[color_idx]),
284                        PlotOption::LineWidth(1.5),
285                    ],
286                );
287                axes.lines(
288                    &w2x,
289                    &w2y,
290                    &[
291                        PlotOption::Color(pendulum_colors[color_idx]),
292                        PlotOption::LineWidth(1.5),
293                    ],
294                );
295
296                // Draw pendulum
297                let (pend_x, pend_y) = self.get_pendulum_points(x_pos, theta);
298                axes.lines(
299                    &pend_x,
300                    &pend_y,
301                    &[
302                        PlotOption::Color(pendulum_colors[color_idx]),
303                        PlotOption::LineWidth(3.0),
304                    ],
305                );
306
307                // Draw pendulum mass (circle at the end)
308                let (mass_x, mass_y) = self.get_wheel_points(pend_x[1], pend_y[1]);
309                axes.lines(
310                    &mass_x,
311                    &mass_y,
312                    &[
313                        PlotOption::Color(pendulum_colors[color_idx]),
314                        PlotOption::LineWidth(2.0),
315                    ],
316                );
317
318                // Add time label for the last frame
319                if frame_idx == num_frames - 1 {
320                    axes.label(
321                        &format!("t={:.1}s", time),
322                        Coordinate::Graph(0.02),
323                        Coordinate::Graph(0.95),
324                        &[],
325                    );
326                }
327            }
328
329            // Add initial condition label
330            let (_, initial_state) = &self.trajectory[0];
331            axes.label(
332                &format!("Initial angle: {:.1} deg", initial_state[2].to_degrees()),
333                Coordinate::Graph(0.02),
334                Coordinate::Graph(0.88),
335                &[],
336            );
337        }
338
339        let output_path = format!("img/inverted_pendulum/{}", filename);
340        std::fs::create_dir_all("img/inverted_pendulum")
341            .expect("failed to create img/inverted_pendulum directory");
342        fg.set_terminal("pngcairo size 800,600", &output_path);
343        fg.show()
344            .expect("failed to render gnuplot figure for inverted pendulum");
345        println!("Inverted pendulum visualization saved to: {}", output_path);
346    }
347
348    /// Visualize single frame of cart-pendulum (for animation frames)
349    pub fn visualize_frame(&self, step: usize, filename: &str) {
350        if step >= self.trajectory.len() {
351            return;
352        }
353
354        let (time, state) = &self.trajectory[step];
355        let x_pos = state[0];
356        let theta = state[2];
357
358        let mut fg = Figure::new();
359        {
360            let axes = fg
361                .axes2d()
362                .set_title(
363                    &format!("Inverted Pendulum LQR Control  t={:.2}s", time),
364                    &[],
365                )
366                .set_x_label("x [m]", &[])
367                .set_y_label("y [m]", &[])
368                .set_aspect_ratio(gnuplot::AutoOption::Fix(1.0))
369                .set_x_range(
370                    gnuplot::AutoOption::Fix(-3.0),
371                    gnuplot::AutoOption::Fix(3.0),
372                )
373                .set_y_range(
374                    gnuplot::AutoOption::Fix(-1.0),
375                    gnuplot::AutoOption::Fix(4.0),
376                );
377
378            // Draw ground line
379            axes.lines(
380                &[-4.0, 4.0],
381                &[0.0, 0.0],
382                &[PlotOption::Color("gray"), PlotOption::LineWidth(2.0)],
383            );
384
385            // Draw cart (blue, matching PythonRobotics)
386            let (cart_x, cart_y) = self.get_cart_polygon(x_pos);
387            axes.lines(
388                &cart_x,
389                &cart_y,
390                &[PlotOption::Color("blue"), PlotOption::LineWidth(2.0)],
391            );
392
393            // Draw wheels (black)
394            let y_offset = WHEEL_RADIUS;
395            let (w1x, w1y) = self.get_wheel_points(x_pos - CART_WIDTH / 4.0, y_offset);
396            let (w2x, w2y) = self.get_wheel_points(x_pos + CART_WIDTH / 4.0, y_offset);
397            axes.lines(
398                &w1x,
399                &w1y,
400                &[PlotOption::Color("black"), PlotOption::LineWidth(1.5)],
401            );
402            axes.lines(
403                &w2x,
404                &w2y,
405                &[PlotOption::Color("black"), PlotOption::LineWidth(1.5)],
406            );
407
408            // Draw pendulum (black, matching PythonRobotics)
409            let (pend_x, pend_y) = self.get_pendulum_points(x_pos, theta);
410            axes.lines(
411                &pend_x,
412                &pend_y,
413                &[PlotOption::Color("black"), PlotOption::LineWidth(3.0)],
414            );
415
416            // Draw pendulum mass
417            let (mass_x, mass_y) = self.get_wheel_points(pend_x[1], pend_y[1]);
418            axes.lines(
419                &mass_x,
420                &mass_y,
421                &[PlotOption::Color("black"), PlotOption::LineWidth(2.0)],
422            );
423
424            // Add state info
425            axes.label(
426                &format!("x={:.2}m, θ={:.1}°", x_pos, theta.to_degrees()),
427                Coordinate::Graph(0.02),
428                Coordinate::Graph(0.95),
429                &[],
430            );
431        }
432
433        let output_path = format!("img/inverted_pendulum/{}", filename);
434        fg.set_terminal("pngcairo size 640,480", &output_path);
435        fg.show()
436            .expect("failed to render gnuplot frame for inverted pendulum");
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn test_lqr_simulation_completes() {
446        let mut controller = InvertedPendulumLQR::new();
447        let x0 = Vector4::new(0.0, 0.0, 0.3, 0.0);
448        assert!(controller.simulate(x0));
449        assert!(!controller.trajectory.is_empty());
450    }
451
452    #[test]
453    fn test_lqr_stabilizes_pendulum() {
454        let mut controller = InvertedPendulumLQR::new();
455        let x0 = Vector4::new(0.0, 0.0, 0.3, 0.0);
456        controller.simulate(x0);
457
458        let (_, final_state) = controller.trajectory.last().unwrap();
459        // Pendulum angle should be close to 0 (upright)
460        assert!(
461            final_state[2].abs() < 0.1,
462            "Pendulum did not stabilize: angle = {}",
463            final_state[2]
464        );
465    }
466}