Skip to main content

rust_robotics_control/
rocket_landing.rs

1//! Rocket Powered Landing trajectory optimization.
2//!
3//! Implements fuel-optimal powered descent guidance using convex optimization,
4//! inspired by the lossless convexification approach to rocket landing.
5//!
6//! # Problem formulation (2D)
7//!
8//! - State: `[x, y, vx, vy]` (position and velocity)
9//! - Control: `[Tx, Ty]` (thrust vector components)
10//! - Dynamics: double integrator with gravity
11//!   - `x_dot = vx`, `y_dot = vy`
12//!   - `vx_dot = Tx / m`, `vy_dot = Ty / m - g`
13//! - Constraints: `|T| <= T_max` (second-order cone constraint on thrust magnitude)
14//! - Terminal: land at target position with zero velocity
15//! - Objective: minimize total thrust (fuel proxy)
16
17use clarabel::algebra::CscMatrix;
18use clarabel::solver::{
19    DefaultSettings, DefaultSettingsBuilder, DefaultSolver, IPSolver, SolverStatus,
20};
21use nalgebra::Vector2;
22
23/// Gravity acceleration (m/s^2).
24const GRAVITY: f64 = 9.81;
25
26/// Configuration for the rocket landing problem.
27#[derive(Debug, Clone)]
28pub struct RocketLandingConfig {
29    /// Number of time steps in the trajectory.
30    pub n_steps: usize,
31    /// Time step duration (seconds).
32    pub dt: f64,
33    /// Maximum thrust magnitude (N).
34    pub thrust_max: f64,
35    /// Rocket mass (kg), assumed constant.
36    pub mass: f64,
37    /// Gravity acceleration (m/s^2).
38    pub gravity: f64,
39    /// Weight on terminal position error in cost.
40    pub w_terminal_pos: f64,
41    /// Weight on terminal velocity error in cost.
42    pub w_terminal_vel: f64,
43    /// Weight on thrust (fuel) in cost.
44    pub w_thrust: f64,
45}
46
47impl Default for RocketLandingConfig {
48    fn default() -> Self {
49        Self {
50            n_steps: 40,
51            dt: 0.1,
52            thrust_max: 24.0,
53            mass: 1.0,
54            gravity: GRAVITY,
55            w_terminal_pos: 1000.0,
56            w_terminal_vel: 1000.0,
57            w_thrust: 1.0,
58        }
59    }
60}
61
62/// Result of the trajectory optimization.
63#[derive(Debug, Clone)]
64pub struct RocketTrajectory {
65    /// Position at each time step: `[(x, y); n_steps + 1]`.
66    pub positions: Vec<Vector2<f64>>,
67    /// Velocity at each time step: `[(vx, vy); n_steps + 1]`.
68    pub velocities: Vec<Vector2<f64>>,
69    /// Thrust at each time step: `[(Tx, Ty); n_steps]`.
70    pub thrusts: Vec<Vector2<f64>>,
71    /// Total fuel cost (sum of thrust magnitudes * dt).
72    pub fuel_cost: f64,
73}
74
75/// Initial condition for the rocket.
76#[derive(Debug, Clone)]
77pub struct RocketState {
78    /// Position (x, y) in meters.
79    pub position: Vector2<f64>,
80    /// Velocity (vx, vy) in m/s.
81    pub velocity: Vector2<f64>,
82}
83
84/// Target landing condition.
85#[derive(Debug, Clone)]
86pub struct LandingTarget {
87    /// Target position (x, y) in meters.
88    pub position: Vector2<f64>,
89    /// Target velocity (vx, vy) in m/s (typically zero).
90    pub velocity: Vector2<f64>,
91}
92
93impl Default for LandingTarget {
94    fn default() -> Self {
95        Self {
96            position: Vector2::zeros(),
97            velocity: Vector2::zeros(),
98        }
99    }
100}
101
102/// Solve the rocket powered landing trajectory optimization problem.
103///
104/// Uses a QP formulation with second-order cone constraints on thrust magnitude.
105/// The dynamics are discretized as a linear system (double integrator + gravity)
106/// and embedded as equality constraints.
107///
108/// Returns `None` if the solver fails to find a solution.
109pub fn solve_landing_trajectory(
110    initial: &RocketState,
111    target: &LandingTarget,
112    config: &RocketLandingConfig,
113) -> Option<RocketTrajectory> {
114    use clarabel::solver::SupportedConeT;
115
116    let n = config.n_steps;
117    let dt = config.dt;
118    let m = config.mass;
119    let g = config.gravity;
120    let t_max = config.thrust_max;
121
122    // Decision variables layout:
123    //   u: [Tx_0, Ty_0, Tx_1, Ty_1, ..., Tx_{n-1}, Ty_{n-1}]  (2*n variables)
124    //   x: [x_0, y_0, vx_0, vy_0, ..., x_n, y_n, vx_n, vy_n]  (4*(n+1) variables)
125    let n_u = 2 * n;
126    let n_x = 4 * (n + 1);
127    let n_vars = n_u + n_x;
128
129    let u_idx = |t: usize, k: usize| -> usize { 2 * t + k };
130    let x_idx = |t: usize, k: usize| -> usize { n_u + 4 * t + k };
131
132    // --- Cost: min sum_t w_thrust * (Tx_t^2 + Ty_t^2)
133    //         + w_pos * ((x_n - x_target)^2 + (y_n - y_target)^2)
134    //         + w_vel * ((vx_n - vx_target)^2 + (vy_n - vy_target)^2)
135    // P matrix (upper triangular, factor of 2 included)
136    let mut p_rows = Vec::new();
137    let mut p_cols = Vec::new();
138    let mut p_vals = Vec::new();
139
140    // Thrust cost
141    for t in 0..n {
142        for k in 0..2 {
143            let idx = u_idx(t, k);
144            p_rows.push(idx);
145            p_cols.push(idx);
146            p_vals.push(2.0 * config.w_thrust);
147        }
148    }
149
150    // Terminal position cost
151    for k in 0..2 {
152        let idx = x_idx(n, k);
153        p_rows.push(idx);
154        p_cols.push(idx);
155        p_vals.push(2.0 * config.w_terminal_pos);
156    }
157
158    // Terminal velocity cost
159    for k in 2..4 {
160        let idx = x_idx(n, k);
161        p_rows.push(idx);
162        p_cols.push(idx);
163        p_vals.push(2.0 * config.w_terminal_vel);
164    }
165
166    let p = CscMatrix::new_from_triplets(n_vars, n_vars, p_rows, p_cols, p_vals);
167
168    // q vector (linear cost from terminal target)
169    let mut q_vec = vec![0.0; n_vars];
170    // -2 * w * target for terminal state
171    q_vec[x_idx(n, 0)] = -2.0 * config.w_terminal_pos * target.position[0];
172    q_vec[x_idx(n, 1)] = -2.0 * config.w_terminal_pos * target.position[1];
173    q_vec[x_idx(n, 2)] = -2.0 * config.w_terminal_vel * target.velocity[0];
174    q_vec[x_idx(n, 3)] = -2.0 * config.w_terminal_vel * target.velocity[1];
175
176    // --- Constraints ---
177    // 1) Equality: initial state (4 rows)
178    // 2) Equality: dynamics x_{t+1} = A*x_t + B*u_t + c for t=0..n-1 (4*n rows)
179    //    Total equality: 4 + 4*n = 4*(n+1)
180    // 3) SOC: ||[Tx_t, Ty_t]|| <= t_max for each t (cone dim 3, n cones)
181    //    Represented as: [t_max; -Tx_t; -Ty_t] in SecondOrderCone(3)
182    //    Clarabel convention: ||z[1:]|| <= z[0], so we need rows for [s; u1; u2]
183    //    where s = t_max (constant, free slack) and u1 = Tx_t, u2 = Ty_t
184
185    let n_eq = 4 * (n + 1);
186    let n_soc_rows = 3 * n; // n cones of dimension 3
187
188    let total_constraints = n_eq + n_soc_rows;
189
190    let mut a_rows = Vec::new();
191    let mut a_cols = Vec::new();
192    let mut a_vals = Vec::new();
193    let mut b_vec = vec![0.0; total_constraints];
194
195    // Equality: initial state x_0 = initial
196    // A * x = b  =>  x_0 = initial  (ZeroCone means A*x - b = 0)
197    for k in 0..2 {
198        let row = k;
199        a_rows.push(row);
200        a_cols.push(x_idx(0, k));
201        a_vals.push(1.0);
202        b_vec[row] = initial.position[k];
203    }
204    for k in 0..2 {
205        let row = 2 + k;
206        a_rows.push(row);
207        a_cols.push(x_idx(0, k + 2));
208        a_vals.push(1.0);
209        b_vec[row] = initial.velocity[k];
210    }
211
212    // Dynamics constraints: x_{t+1} = A*x_t + B*u_t + c
213    // Discretized double integrator:
214    //   x_{t+1}  = x_t  + dt * vx_t
215    //   y_{t+1}  = y_t  + dt * vy_t
216    //   vx_{t+1} = vx_t + dt * Tx_t / m
217    //   vy_{t+1} = vy_t + dt * Ty_t / m - dt * g
218    //
219    // Rewrite as: -x_{t+1} + A*x_t + B*u_t = -c
220    //   A = [[1, 0, dt, 0],
221    //        [0, 1, 0, dt],
222    //        [0, 0, 1,  0],
223    //        [0, 0, 0,  1]]
224    //   B = [[0, 0],
225    //        [0, 0],
226    //        [dt/m, 0],
227    //        [0, dt/m]]
228    //   c = [0, 0, 0, -dt*g]
229
230    for t in 0..n {
231        let base_row = 4 + 4 * t;
232
233        // -x_{t+1}
234        for k in 0..4 {
235            a_rows.push(base_row + k);
236            a_cols.push(x_idx(t + 1, k));
237            a_vals.push(-1.0);
238        }
239
240        // A * x_t
241        // x component: +x_t + dt*vx_t
242        a_rows.push(base_row);
243        a_cols.push(x_idx(t, 0));
244        a_vals.push(1.0);
245        a_rows.push(base_row);
246        a_cols.push(x_idx(t, 2));
247        a_vals.push(dt);
248
249        // y component: +y_t + dt*vy_t
250        a_rows.push(base_row + 1);
251        a_cols.push(x_idx(t, 1));
252        a_vals.push(1.0);
253        a_rows.push(base_row + 1);
254        a_cols.push(x_idx(t, 3));
255        a_vals.push(dt);
256
257        // vx component: +vx_t
258        a_rows.push(base_row + 2);
259        a_cols.push(x_idx(t, 2));
260        a_vals.push(1.0);
261
262        // vy component: +vy_t
263        a_rows.push(base_row + 3);
264        a_cols.push(x_idx(t, 3));
265        a_vals.push(1.0);
266
267        // B * u_t
268        // vx: dt/m * Tx_t
269        a_rows.push(base_row + 2);
270        a_cols.push(u_idx(t, 0));
271        a_vals.push(dt / m);
272
273        // vy: dt/m * Ty_t
274        a_rows.push(base_row + 3);
275        a_cols.push(u_idx(t, 1));
276        a_vals.push(dt / m);
277
278        // RHS: -c = [0, 0, 0, dt*g]
279        b_vec[base_row + 3] = dt * g;
280    }
281
282    // SOC constraints: ||[Tx_t, Ty_t]|| <= t_max
283    // Clarabel convention for SecondOrderConeT(dim):
284    //   the constraint rows represent s such that ||s[1:]|| <= s[0]
285    //   A*x + s = b, s in SOC
286    //   We want: s[0] = b[0] - (A*x)[0] = t_max (constant, no x dependency)
287    //            s[1] = b[1] - (A*x)[1] => we want s[1] = Tx_t => A row = -Tx_t, b=0
288    //            s[2] = b[2] - (A*x)[2] => we want s[2] = Ty_t => A row = -Ty_t, b=0
289    //   So: s = [t_max, -Tx_t, -Ty_t] but we need ||s[1:]|| <= s[0]
290    //   which gives ||(Tx_t, Ty_t)|| <= t_max. But signs:
291    //   s[1] = -Tx_t means ||(-Tx, -Ty)|| = ||(Tx, Ty)||, so this is fine.
292    //
293    //   Row for s[0]: no A entries, b = t_max
294    //   Row for s[1]: A coefficient on Tx_t = -1, b = 0
295    //   Row for s[2]: A coefficient on Ty_t = -1, b = 0
296
297    for t in 0..n {
298        let soc_base = n_eq + 3 * t;
299
300        // s[0] = t_max (no A entries needed, just b)
301        b_vec[soc_base] = t_max;
302
303        // s[1]: -Tx_t => A has -1 on Tx_t column
304        a_rows.push(soc_base + 1);
305        a_cols.push(u_idx(t, 0));
306        a_vals.push(-1.0);
307
308        // s[2]: -Ty_t => A has -1 on Ty_t column
309        a_rows.push(soc_base + 2);
310        a_cols.push(u_idx(t, 1));
311        a_vals.push(-1.0);
312    }
313
314    let a_csc = CscMatrix::new_from_triplets(total_constraints, n_vars, a_rows, a_cols, a_vals);
315
316    // Cones
317    let mut cones: Vec<SupportedConeT<f64>> = Vec::new();
318    cones.push(SupportedConeT::ZeroConeT(n_eq));
319    for _ in 0..n {
320        cones.push(SupportedConeT::SecondOrderConeT(3));
321    }
322
323    let settings: DefaultSettings<f64> = DefaultSettingsBuilder::default()
324        .verbose(false)
325        .max_iter(500)
326        .build()
327        .unwrap();
328
329    let mut solver = DefaultSolver::new(&p, &q_vec, &a_csc, &b_vec, &cones, settings).ok()?;
330    solver.solve();
331
332    if solver.solution.status != SolverStatus::Solved {
333        return None;
334    }
335
336    let z = &solver.solution.x;
337
338    // Extract solution
339    let mut positions = Vec::with_capacity(n + 1);
340    let mut velocities = Vec::with_capacity(n + 1);
341    for t in 0..=n {
342        positions.push(Vector2::new(z[x_idx(t, 0)], z[x_idx(t, 1)]));
343        velocities.push(Vector2::new(z[x_idx(t, 2)], z[x_idx(t, 3)]));
344    }
345
346    let mut thrusts = Vec::with_capacity(n);
347    let mut fuel_cost = 0.0;
348    for t in 0..n {
349        let thrust = Vector2::new(z[u_idx(t, 0)], z[u_idx(t, 1)]);
350        fuel_cost += thrust.norm() * dt;
351        thrusts.push(thrust);
352    }
353
354    Some(RocketTrajectory {
355        positions,
356        velocities,
357        thrusts,
358        fuel_cost,
359    })
360}
361
362/// Simulate free-fall trajectory (no thrust) for comparison.
363pub fn simulate_freefall(
364    initial: &RocketState,
365    n_steps: usize,
366    dt: f64,
367    gravity: f64,
368) -> (Vec<Vector2<f64>>, Vec<Vector2<f64>>) {
369    let mut positions = Vec::with_capacity(n_steps + 1);
370    let mut velocities = Vec::with_capacity(n_steps + 1);
371
372    let mut pos = initial.position;
373    let mut vel = initial.velocity;
374
375    positions.push(pos);
376    velocities.push(vel);
377
378    for _ in 0..n_steps {
379        pos += vel * dt;
380        vel += Vector2::new(0.0, -gravity) * dt;
381        positions.push(pos);
382        velocities.push(vel);
383    }
384
385    (positions, velocities)
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    /// Verify that free-fall simulation matches expected physics.
393    #[test]
394    fn test_freefall_no_thrust() {
395        let initial = RocketState {
396            position: Vector2::new(0.0, 100.0),
397            velocity: Vector2::new(0.0, 0.0),
398        };
399
400        let dt = 0.1;
401        let n_steps = 50;
402        let (positions, velocities) = simulate_freefall(&initial, n_steps, dt, GRAVITY);
403
404        assert_eq!(positions.len(), n_steps + 1);
405        assert_eq!(velocities.len(), n_steps + 1);
406
407        // Check initial
408        assert!((positions[0] - initial.position).norm() < 1e-10);
409        assert!((velocities[0] - initial.velocity).norm() < 1e-10);
410
411        // After t seconds of free-fall: y = y0 - 0.5*g*t^2, vy = -g*t
412        let t_final = n_steps as f64 * dt; // 5.0 seconds
413        let expected_y = 100.0 - 0.5 * GRAVITY * t_final * t_final;
414        let expected_vy = -GRAVITY * t_final;
415
416        // Euler integration accumulates error; use a generous tolerance
417        assert!(
418            (positions[n_steps][1] - expected_y).abs() < 3.0,
419            "Final y: {}, expected: {}",
420            positions[n_steps][1],
421            expected_y
422        );
423        assert!(
424            (velocities[n_steps][1] - expected_vy).abs() < 1.0,
425            "Final vy: {}, expected: {}",
426            velocities[n_steps][1],
427            expected_vy
428        );
429
430        // x should remain zero
431        assert!((positions[n_steps][0]).abs() < 1e-10);
432    }
433
434    /// Vertical landing: start above origin, come down and land at origin with zero velocity.
435    #[test]
436    fn test_vertical_landing() {
437        let initial = RocketState {
438            position: Vector2::new(0.0, 50.0),
439            velocity: Vector2::new(0.0, -5.0),
440        };
441        let target = LandingTarget::default(); // origin, zero velocity
442
443        let config = RocketLandingConfig {
444            n_steps: 60,
445            dt: 0.1,
446            thrust_max: 20.0,
447            mass: 1.0,
448            gravity: GRAVITY,
449            w_terminal_pos: 1e4,
450            w_terminal_vel: 1e4,
451            w_thrust: 1.0,
452        };
453
454        let result = solve_landing_trajectory(&initial, &target, &config);
455        assert!(result.is_some(), "Solver should find a solution");
456
457        let traj = result.unwrap();
458        assert_eq!(traj.positions.len(), config.n_steps + 1);
459        assert_eq!(traj.thrusts.len(), config.n_steps);
460
461        // Check terminal conditions are approximately met
462        let final_pos = traj.positions.last().unwrap();
463        let final_vel = traj.velocities.last().unwrap();
464
465        assert!(
466            final_pos.norm() < 1.0,
467            "Final position should be near origin, got: {:?}",
468            final_pos
469        );
470        assert!(
471            final_vel.norm() < 1.0,
472            "Final velocity should be near zero, got: {:?}",
473            final_vel
474        );
475
476        // All thrust magnitudes should respect the constraint
477        for (t, thrust) in traj.thrusts.iter().enumerate() {
478            assert!(
479                thrust.norm() <= config.thrust_max + 1e-6,
480                "Thrust at step {} exceeds limit: {}",
481                t,
482                thrust.norm()
483            );
484        }
485
486        // Fuel cost should be positive
487        assert!(traj.fuel_cost > 0.0, "Fuel cost should be positive");
488    }
489
490    /// Angled approach: start offset to the side and above, land at origin.
491    #[test]
492    fn test_angled_approach_landing() {
493        let initial = RocketState {
494            position: Vector2::new(30.0, 80.0),
495            velocity: Vector2::new(-5.0, -10.0),
496        };
497        let target = LandingTarget::default();
498
499        let config = RocketLandingConfig {
500            n_steps: 80,
501            dt: 0.1,
502            thrust_max: 30.0,
503            mass: 1.0,
504            gravity: GRAVITY,
505            w_terminal_pos: 1e4,
506            w_terminal_vel: 1e4,
507            w_thrust: 1.0,
508        };
509
510        let result = solve_landing_trajectory(&initial, &target, &config);
511        assert!(result.is_some(), "Solver should find a solution");
512
513        let traj = result.unwrap();
514
515        let final_pos = traj.positions.last().unwrap();
516        let final_vel = traj.velocities.last().unwrap();
517
518        assert!(
519            final_pos.norm() < 2.0,
520            "Final position should be near origin, got: {:?}",
521            final_pos
522        );
523        assert!(
524            final_vel.norm() < 2.0,
525            "Final velocity should be near zero, got: {:?}",
526            final_vel
527        );
528
529        // Thrust constraints
530        for (t, thrust) in traj.thrusts.iter().enumerate() {
531            assert!(
532                thrust.norm() <= config.thrust_max + 1e-6,
533                "Thrust at step {} exceeds limit: {}",
534                t,
535                thrust.norm()
536            );
537        }
538
539        // The trajectory should have some horizontal thrust to redirect
540        let has_horizontal_thrust = traj.thrusts.iter().any(|t| t[0].abs() > 0.1);
541        assert!(
542            has_horizontal_thrust,
543            "Angled approach should use horizontal thrust"
544        );
545
546        // y should remain non-negative throughout (no underground trajectory)
547        // With soft constraints this may not be strictly guaranteed, but should be close
548        for (t, pos) in traj.positions.iter().enumerate() {
549            assert!(
550                pos[1] > -5.0,
551                "Position y at step {} is too negative: {}",
552                t,
553                pos[1]
554            );
555        }
556    }
557
558    /// Verify solver returns None for infeasible problems.
559    #[test]
560    fn test_insufficient_thrust() {
561        let initial = RocketState {
562            position: Vector2::new(0.0, 100.0),
563            velocity: Vector2::new(0.0, -50.0), // very fast downward
564        };
565        let target = LandingTarget::default();
566
567        // Very low thrust and short horizon -- likely infeasible or poor solution
568        let config = RocketLandingConfig {
569            n_steps: 10,
570            dt: 0.05,
571            thrust_max: 5.0, // barely above gravity
572            mass: 1.0,
573            gravity: GRAVITY,
574            w_terminal_pos: 1e4,
575            w_terminal_vel: 1e4,
576            w_thrust: 1.0,
577        };
578
579        // This problem is a QP so it will always return a solution, but the terminal
580        // error will be large. Verify the solver at least runs.
581        let result = solve_landing_trajectory(&initial, &target, &config);
582        assert!(
583            result.is_some(),
584            "Solver should still return a solution (soft constraints)"
585        );
586
587        let traj = result.unwrap();
588        // With such a short horizon and low thrust, terminal error will be large
589        let final_pos = traj.positions.last().unwrap();
590        assert!(
591            final_pos.norm() > 5.0,
592            "With insufficient thrust/time, landing should not be precise: {:?}",
593            final_pos
594        );
595    }
596}