Skip to main content

rust_robotics_control/
drone_3d_trajectory.rs

1//! 3D drone trajectory following with quadrotor dynamics.
2//!
3//! Implements a quadrotor dynamics model and a PD trajectory tracking controller
4//! that follows quintic polynomial trajectories through 3D waypoints.
5//!
6//! Based on PythonRobotics `AerialNavigation/drone_3d_trajectory_following`.
7//!
8//! # Overview
9//!
10//! 1. **Quintic trajectory generation**: Given start/end positions (with velocity and
11//!    acceleration boundary conditions), solve for 6 polynomial coefficients per axis
12//!    so that position, velocity, and acceleration are continuous at segment boundaries.
13//!
14//! 2. **PD controller**: Computes thrust and torques from the tracking error between
15//!    desired and actual states.
16//!
17//! 3. **Quadrotor dynamics**: Euler-integrated rigid body with roll/pitch/yaw, driven
18//!    by thrust and body torques.
19
20use nalgebra::{Matrix3, Matrix6, Vector3, Vector6};
21
22/// Gravity acceleration \[m/s^2\].
23const GRAVITY: f64 = 9.81;
24
25// ---------------------------------------------------------------------------
26// Quintic trajectory generation
27// ---------------------------------------------------------------------------
28
29/// Coefficients of a quintic polynomial for one axis: `c0*t^5 + c1*t^4 + ... + c5`.
30#[derive(Debug, Clone)]
31pub struct QuinticCoeffs {
32    /// Coefficients `[c0, c1, c2, c3, c4, c5]` (highest power first).
33    pub c: [f64; 6],
34}
35
36impl QuinticCoeffs {
37    /// Evaluate position at time `t`.
38    pub fn position(&self, t: f64) -> f64 {
39        let c = &self.c;
40        c[0] * t.powi(5) + c[1] * t.powi(4) + c[2] * t.powi(3) + c[3] * t.powi(2) + c[4] * t + c[5]
41    }
42
43    /// Evaluate velocity at time `t`.
44    pub fn velocity(&self, t: f64) -> f64 {
45        let c = &self.c;
46        5.0 * c[0] * t.powi(4)
47            + 4.0 * c[1] * t.powi(3)
48            + 3.0 * c[2] * t.powi(2)
49            + 2.0 * c[3] * t
50            + c[4]
51    }
52
53    /// Evaluate acceleration at time `t`.
54    pub fn acceleration(&self, t: f64) -> f64 {
55        let c = &self.c;
56        20.0 * c[0] * t.powi(3) + 12.0 * c[1] * t.powi(2) + 6.0 * c[2] * t + 2.0 * c[3]
57    }
58
59    /// Evaluate jerk at time `t`.
60    pub fn jerk(&self, t: f64) -> f64 {
61        let c = &self.c;
62        60.0 * c[0] * t.powi(2) + 24.0 * c[1] * t + 6.0 * c[2]
63    }
64
65    /// Evaluate snap at time `t`.
66    pub fn snap(&self, t: f64) -> f64 {
67        let c = &self.c;
68        120.0 * c[0] * t + 24.0 * c[1]
69    }
70}
71
72/// A 3D quintic trajectory segment (one per axis).
73#[derive(Debug, Clone)]
74pub struct TrajectorySegment {
75    pub x: QuinticCoeffs,
76    pub y: QuinticCoeffs,
77    pub z: QuinticCoeffs,
78}
79
80impl TrajectorySegment {
81    /// Evaluate jerk at time `t`.
82    pub fn jerk(&self, t: f64) -> Vector3<f64> {
83        Vector3::new(self.x.jerk(t), self.y.jerk(t), self.z.jerk(t))
84    }
85
86    /// Evaluate snap at time `t`.
87    pub fn snap(&self, t: f64) -> Vector3<f64> {
88        Vector3::new(self.x.snap(t), self.y.snap(t), self.z.snap(t))
89    }
90}
91
92/// Boundary conditions for one axis of a trajectory segment.
93struct AxisBoundary {
94    start_pos: f64,
95    end_pos: f64,
96    start_vel: f64,
97    end_vel: f64,
98    start_acc: f64,
99    end_acc: f64,
100}
101
102/// Solve the quintic polynomial coefficients for one axis.
103///
104/// The 6x6 system enforces position, velocity, and acceleration at `t=0` and `t=T`.
105fn solve_quintic_axis(b: &AxisBoundary, duration: f64) -> QuinticCoeffs {
106    let big_t = duration;
107    let t2 = big_t * big_t;
108    let t3 = t2 * big_t;
109    let t4 = t3 * big_t;
110    let t5 = t4 * big_t;
111
112    // A matrix (row-major):
113    //   pos(0)=start:  [0,  0,  0,  0, 0, 1]
114    //   pos(T)=end:    [T^5, T^4, T^3, T^2, T, 1]
115    //   vel(0)=sv:     [0,  0,  0,  0, 1, 0]
116    //   vel(T)=ev:     [5T^4, 4T^3, 3T^2, 2T, 1, 0]
117    //   acc(0)=sa:     [0,  0,  0,  2, 0, 0]
118    //   acc(T)=ea:     [20T^3, 12T^2, 6T, 2, 0, 0]
119    #[rustfmt::skip]
120    let a = Matrix6::new(
121        0.0,     0.0,    0.0,   0.0,      0.0, 1.0,
122        t5,      t4,     t3,    t2,        big_t, 1.0,
123        0.0,     0.0,    0.0,   0.0,      1.0, 0.0,
124        5.0*t4,  4.0*t3, 3.0*t2, 2.0*big_t, 1.0, 0.0,
125        0.0,     0.0,    0.0,   2.0,      0.0, 0.0,
126        20.0*t3, 12.0*t2, 6.0*big_t, 2.0, 0.0, 0.0,
127    );
128
129    let rhs = Vector6::new(
130        b.start_pos,
131        b.end_pos,
132        b.start_vel,
133        b.end_vel,
134        b.start_acc,
135        b.end_acc,
136    );
137
138    let decomp = a.lu();
139    let coeffs = decomp
140        .solve(&rhs)
141        .expect("Quintic matrix should be invertible");
142
143    QuinticCoeffs {
144        c: [
145            coeffs[0], coeffs[1], coeffs[2], coeffs[3], coeffs[4], coeffs[5],
146        ],
147    }
148}
149
150/// Generate a quintic trajectory segment between two 3D waypoints.
151///
152/// Boundary velocities and accelerations default to zero (rest-to-rest).
153pub fn generate_trajectory_segment(
154    start: &Vector3<f64>,
155    end: &Vector3<f64>,
156    duration: f64,
157) -> TrajectorySegment {
158    generate_trajectory_segment_full(
159        start,
160        end,
161        &Vector3::zeros(),
162        &Vector3::zeros(),
163        &Vector3::zeros(),
164        &Vector3::zeros(),
165        duration,
166    )
167}
168
169/// Generate a quintic trajectory segment with full boundary conditions.
170pub fn generate_trajectory_segment_full(
171    start_pos: &Vector3<f64>,
172    end_pos: &Vector3<f64>,
173    start_vel: &Vector3<f64>,
174    end_vel: &Vector3<f64>,
175    start_acc: &Vector3<f64>,
176    end_acc: &Vector3<f64>,
177    duration: f64,
178) -> TrajectorySegment {
179    let solve = |i: usize| {
180        solve_quintic_axis(
181            &AxisBoundary {
182                start_pos: start_pos[i],
183                end_pos: end_pos[i],
184                start_vel: start_vel[i],
185                end_vel: end_vel[i],
186                start_acc: start_acc[i],
187                end_acc: end_acc[i],
188            },
189            duration,
190        )
191    };
192    TrajectorySegment {
193        x: solve(0),
194        y: solve(1),
195        z: solve(2),
196    }
197}
198
199/// Generate trajectory segments for a closed loop through the given waypoints.
200///
201/// Each segment uses rest-to-rest boundary conditions and the given duration.
202pub fn generate_waypoint_trajectory(
203    waypoints: &[Vector3<f64>],
204    segment_duration: f64,
205) -> Vec<TrajectorySegment> {
206    let segment_durations = vec![segment_duration; waypoints.len()];
207    generate_waypoint_trajectory_with_durations(waypoints, &segment_durations)
208}
209
210/// Generate trajectory segments for a closed loop through the given waypoints
211/// with an explicit duration for each segment.
212pub fn generate_waypoint_trajectory_with_durations(
213    waypoints: &[Vector3<f64>],
214    segment_durations: &[f64],
215) -> Vec<TrajectorySegment> {
216    let n = waypoints.len();
217    assert!(n >= 2, "Need at least 2 waypoints");
218    assert_eq!(
219        n,
220        segment_durations.len(),
221        "Need one segment duration per waypoint-to-waypoint segment"
222    );
223    (0..n)
224        .map(|i| {
225            generate_trajectory_segment(
226                &waypoints[i],
227                &waypoints[(i + 1) % n],
228                segment_durations[i],
229            )
230        })
231        .collect()
232}
233
234/// Sample a piecewise quintic trajectory into desired states at a fixed `dt`.
235pub fn sample_trajectory_segments(
236    segments: &[TrajectorySegment],
237    segment_durations: &[f64],
238    dt: f64,
239) -> Vec<DesiredState> {
240    assert!(dt > 0.0, "dt must be positive");
241    assert_eq!(
242        segments.len(),
243        segment_durations.len(),
244        "Need one duration per trajectory segment"
245    );
246
247    let mut samples = Vec::new();
248    for (segment, duration) in segments.iter().zip(segment_durations.iter().copied()) {
249        let mut t = 0.0;
250        while t <= duration {
251            samples.push(DesiredState::from_segment(segment, t));
252            t += dt;
253        }
254    }
255    samples
256}
257
258// ---------------------------------------------------------------------------
259// Quadrotor dynamics and controller
260// ---------------------------------------------------------------------------
261
262/// Physical parameters of the quadrotor.
263#[derive(Debug, Clone)]
264pub struct QuadrotorParams {
265    /// Mass \[kg\].
266    pub mass: f64,
267    /// Moment of inertia about the x-axis \[kg m^2\].
268    pub ixx: f64,
269    /// Moment of inertia about the y-axis \[kg m^2\].
270    pub iyy: f64,
271    /// Moment of inertia about the z-axis \[kg m^2\].
272    pub izz: f64,
273    /// Gravity acceleration \[m/s^2\].
274    pub gravity: f64,
275}
276
277impl Default for QuadrotorParams {
278    fn default() -> Self {
279        Self {
280            mass: 0.2,
281            ixx: 1.0,
282            iyy: 1.0,
283            izz: 1.0,
284            gravity: GRAVITY,
285        }
286    }
287}
288
289/// PD controller gains.
290#[derive(Debug, Clone)]
291pub struct ControllerGains {
292    /// Proportional gains for position \[x, y, z\].
293    pub kp_pos: Vector3<f64>,
294    /// Derivative gains for position \[x, y, z\].
295    pub kd_pos: Vector3<f64>,
296    /// Proportional gains for attitude \[roll, pitch, yaw\].
297    pub kp_att: Vector3<f64>,
298}
299
300impl Default for ControllerGains {
301    fn default() -> Self {
302        Self {
303            kp_pos: Vector3::new(1.0, 1.0, 1.0),
304            kd_pos: Vector3::new(10.0, 10.0, 1.0),
305            kp_att: Vector3::new(25.0, 25.0, 25.0),
306        }
307    }
308}
309
310/// Full state of the quadrotor.
311#[derive(Debug, Clone)]
312pub struct QuadrotorState {
313    /// Position \[x, y, z\] in meters.
314    pub position: Vector3<f64>,
315    /// Velocity \[vx, vy, vz\] in m/s.
316    pub velocity: Vector3<f64>,
317    /// Euler angles \[roll, pitch, yaw\] in radians.
318    pub orientation: Vector3<f64>,
319    /// Angular velocity \[roll_dot, pitch_dot, yaw_dot\] in rad/s.
320    pub angular_velocity: Vector3<f64>,
321}
322
323impl QuadrotorState {
324    /// Create a new state at the given position, at rest.
325    pub fn new(position: Vector3<f64>) -> Self {
326        Self {
327            position,
328            velocity: Vector3::zeros(),
329            orientation: Vector3::zeros(),
330            angular_velocity: Vector3::zeros(),
331        }
332    }
333}
334
335/// Desired trajectory values at a single time instant.
336#[derive(Debug, Clone)]
337pub struct DesiredState {
338    pub position: Vector3<f64>,
339    pub velocity: Vector3<f64>,
340    pub acceleration: Vector3<f64>,
341    pub yaw: f64,
342}
343
344impl DesiredState {
345    /// Evaluate a trajectory segment at time `t`.
346    pub fn from_segment(seg: &TrajectorySegment, t: f64) -> Self {
347        Self {
348            position: Vector3::new(seg.x.position(t), seg.y.position(t), seg.z.position(t)),
349            velocity: Vector3::new(seg.x.velocity(t), seg.y.velocity(t), seg.z.velocity(t)),
350            acceleration: Vector3::new(
351                seg.x.acceleration(t),
352                seg.y.acceleration(t),
353                seg.z.acceleration(t),
354            ),
355            yaw: 0.0,
356        }
357    }
358}
359
360/// Control outputs: total thrust and body torques.
361#[derive(Debug, Clone)]
362pub struct ControlOutput {
363    /// Total thrust \[N\].
364    pub thrust: f64,
365    /// Torque about x-axis (roll) \[N m\].
366    pub roll_torque: f64,
367    /// Torque about y-axis (pitch) \[N m\].
368    pub pitch_torque: f64,
369    /// Torque about z-axis (yaw) \[N m\].
370    pub yaw_torque: f64,
371}
372
373/// Compute the ZYX rotation matrix from Euler angles (roll, pitch, yaw).
374pub fn rotation_matrix(roll: f64, pitch: f64, yaw: f64) -> Matrix3<f64> {
375    let (sr, cr) = roll.sin_cos();
376    let (sp, cp) = pitch.sin_cos();
377    let (sy, cy) = yaw.sin_cos();
378
379    Matrix3::new(
380        cy * cp,
381        -sy * cr + cy * sp * sr,
382        sy * sr + cy * sp * cr,
383        sy * cp,
384        cy * cr + sy * sp * sr,
385        -cy * sr + sy * sp * cr,
386        -sp,
387        cp * sr,
388        cp * cr,
389    )
390}
391
392/// Compute the PD control output for the quadrotor.
393///
394/// This follows the same controller structure as the Python reference:
395/// - Thrust compensates gravity and tracks desired z with PD.
396/// - Roll/pitch torques are proportional corrections derived from desired
397///   lateral accelerations.
398/// - Yaw torque is proportional to yaw error.
399pub fn compute_control(
400    state: &QuadrotorState,
401    desired: &DesiredState,
402    params: &QuadrotorParams,
403    gains: &ControllerGains,
404) -> ControlOutput {
405    let g = params.gravity;
406    let m = params.mass;
407
408    // Thrust (along body z-axis)
409    let thrust = m
410        * (g + desired.acceleration.z
411            + gains.kp_pos.z * (desired.position.z - state.position.z)
412            + gains.kd_pos.z * (desired.velocity.z - state.velocity.z));
413
414    let des_yaw = desired.yaw;
415    let (sy, cy) = des_yaw.sin_cos();
416
417    // Desired roll and pitch from lateral accelerations
418    let des_roll = (desired.acceleration.x * sy - desired.acceleration.y * cy) / g;
419    let des_pitch = (desired.acceleration.x * cy - desired.acceleration.y * sy) / g;
420
421    let roll_torque = gains.kp_att.x * (des_roll - state.orientation.x);
422    let pitch_torque = gains.kp_att.y * (des_pitch - state.orientation.y);
423    let yaw_torque = gains.kp_att.z * (des_yaw - state.orientation.z);
424
425    ControlOutput {
426        thrust,
427        roll_torque,
428        pitch_torque,
429        yaw_torque,
430    }
431}
432
433/// Advance the quadrotor state by one time step using Euler integration.
434pub fn step_dynamics(
435    state: &mut QuadrotorState,
436    control: &ControlOutput,
437    params: &QuadrotorParams,
438    dt: f64,
439) {
440    let m = params.mass;
441    let g = params.gravity;
442
443    // Angular acceleration -> angular velocity -> orientation
444    state.angular_velocity.x += control.roll_torque * dt / params.ixx;
445    state.angular_velocity.y += control.pitch_torque * dt / params.iyy;
446    state.angular_velocity.z += control.yaw_torque * dt / params.izz;
447
448    state.orientation += state.angular_velocity * dt;
449
450    // Linear acceleration in world frame via rotation matrix
451    let r = rotation_matrix(
452        state.orientation.x,
453        state.orientation.y,
454        state.orientation.z,
455    );
456    let thrust_body = Vector3::new(0.0, 0.0, control.thrust);
457    let gravity_world = Vector3::new(0.0, 0.0, m * g);
458    let acc = (r * thrust_body - gravity_world) / m;
459
460    state.velocity += acc * dt;
461    state.position += state.velocity * dt;
462}
463
464/// Record of the quadrotor state at each simulation step.
465#[derive(Debug, Clone)]
466pub struct SimulationRecord {
467    /// Position history.
468    pub positions: Vec<Vector3<f64>>,
469    /// Orientation (Euler angles) history.
470    pub orientations: Vec<Vector3<f64>>,
471    /// Control output history.
472    pub controls: Vec<ControlOutput>,
473}
474
475/// Configuration for the trajectory-following simulation.
476#[derive(Debug, Clone)]
477pub struct SimulationConfig {
478    /// Time step \[s\].
479    pub dt: f64,
480    /// Duration per trajectory segment \[s\].
481    pub segment_duration: f64,
482    /// Number of full loops through the waypoints.
483    pub n_loops: usize,
484    /// Quadrotor physical parameters.
485    pub params: QuadrotorParams,
486    /// Controller gains.
487    pub gains: ControllerGains,
488}
489
490impl Default for SimulationConfig {
491    fn default() -> Self {
492        Self {
493            dt: 0.1,
494            segment_duration: 5.0,
495            n_loops: 2,
496            params: QuadrotorParams::default(),
497            gains: ControllerGains::default(),
498        }
499    }
500}
501
502/// Simulate a pre-sampled desired trajectory.
503pub fn simulate_desired_states(
504    start_position: Vector3<f64>,
505    desired_states: &[DesiredState],
506    config: &SimulationConfig,
507) -> SimulationRecord {
508    let mut state = QuadrotorState::new(start_position);
509    let mut record = SimulationRecord {
510        positions: vec![state.position],
511        orientations: vec![state.orientation],
512        controls: Vec::with_capacity(desired_states.len()),
513    };
514
515    for desired in desired_states {
516        let control = compute_control(&state, desired, &config.params, &config.gains);
517        step_dynamics(&mut state, &control, &config.params, config.dt);
518
519        record.positions.push(state.position);
520        record.orientations.push(state.orientation);
521        record.controls.push(control);
522    }
523
524    record
525}
526
527/// Run the full trajectory-following simulation.
528///
529/// The quadrotor starts at the first waypoint and follows a closed loop
530/// through all waypoints for `config.n_loops` complete passes.
531///
532/// Returns a [`SimulationRecord`] containing the full state history.
533pub fn simulate_trajectory_following(
534    waypoints: &[Vector3<f64>],
535    config: &SimulationConfig,
536) -> SimulationRecord {
537    let segment_durations = vec![config.segment_duration; waypoints.len()];
538    let segments = generate_waypoint_trajectory_with_durations(waypoints, &segment_durations);
539    let mut desired_states = Vec::new();
540    for _ in 0..config.n_loops {
541        desired_states.extend(sample_trajectory_segments(
542            &segments,
543            &segment_durations,
544            config.dt,
545        ));
546    }
547    simulate_desired_states(waypoints[0], &desired_states, config)
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    const EPSILON: f64 = 1e-9;
555
556    // -----------------------------------------------------------------------
557    // Quintic trajectory tests
558    // -----------------------------------------------------------------------
559
560    #[test]
561    fn test_quintic_boundary_conditions() {
562        let start = Vector3::new(0.0, 0.0, 5.0);
563        let end = Vector3::new(10.0, 0.0, 5.0);
564        let duration = 5.0;
565        let seg = generate_trajectory_segment(&start, &end, duration);
566
567        // Position at t=0
568        assert!((seg.x.position(0.0) - 0.0).abs() < EPSILON);
569        assert!((seg.y.position(0.0) - 0.0).abs() < EPSILON);
570        assert!((seg.z.position(0.0) - 5.0).abs() < EPSILON);
571
572        // Position at t=T
573        assert!((seg.x.position(duration) - 10.0).abs() < EPSILON);
574        assert!((seg.y.position(duration) - 0.0).abs() < EPSILON);
575        assert!((seg.z.position(duration) - 5.0).abs() < EPSILON);
576
577        // Velocity at endpoints should be zero (rest-to-rest)
578        assert!(seg.x.velocity(0.0).abs() < EPSILON);
579        assert!(seg.x.velocity(duration).abs() < EPSILON);
580
581        // Acceleration at endpoints should be zero
582        assert!(seg.x.acceleration(0.0).abs() < EPSILON);
583        assert!(seg.x.acceleration(duration).abs() < EPSILON);
584    }
585
586    #[test]
587    fn test_quintic_constant_z() {
588        // When start.z == end.z, z should remain constant.
589        let seg = generate_trajectory_segment(
590            &Vector3::new(0.0, 0.0, 5.0),
591            &Vector3::new(10.0, 10.0, 5.0),
592            5.0,
593        );
594        for i in 0..=50 {
595            let t = i as f64 * 0.1;
596            assert!(
597                (seg.z.position(t) - 5.0).abs() < EPSILON,
598                "z({}) = {} != 5.0",
599                t,
600                seg.z.position(t)
601            );
602        }
603    }
604
605    #[test]
606    fn test_quintic_with_initial_velocity() {
607        let start_pos = Vector3::new(0.0, 0.0, 0.0);
608        let end_pos = Vector3::new(10.0, 0.0, 0.0);
609        let start_vel = Vector3::new(2.0, 0.0, 0.0);
610        let end_vel = Vector3::new(0.0, 0.0, 0.0);
611        let duration = 5.0;
612
613        let seg = generate_trajectory_segment_full(
614            &start_pos,
615            &end_pos,
616            &start_vel,
617            &end_vel,
618            &Vector3::zeros(),
619            &Vector3::zeros(),
620            duration,
621        );
622
623        assert!((seg.x.velocity(0.0) - 2.0).abs() < EPSILON);
624        assert!(seg.x.velocity(duration).abs() < EPSILON);
625    }
626
627    #[test]
628    fn test_waypoint_trajectory_generation() {
629        let waypoints = vec![
630            Vector3::new(-5.0, -5.0, 5.0),
631            Vector3::new(5.0, -5.0, 5.0),
632            Vector3::new(5.0, 5.0, 5.0),
633            Vector3::new(-5.0, 5.0, 5.0),
634        ];
635        let segments = generate_waypoint_trajectory(&waypoints, 5.0);
636        assert_eq!(segments.len(), 4);
637
638        // Each segment should start at its waypoint and end at the next
639        for (i, seg) in segments.iter().enumerate() {
640            let next = (i + 1) % waypoints.len();
641            assert!(
642                (seg.x.position(0.0) - waypoints[i].x).abs() < EPSILON,
643                "Segment {} start x mismatch",
644                i
645            );
646            assert!(
647                (seg.x.position(5.0) - waypoints[next].x).abs() < EPSILON,
648                "Segment {} end x mismatch",
649                i
650            );
651        }
652    }
653
654    #[test]
655    fn test_waypoint_trajectory_generation_with_durations() {
656        let waypoints = vec![
657            Vector3::new(0.0, 0.0, 1.0),
658            Vector3::new(4.0, 0.0, 2.0),
659            Vector3::new(4.0, 3.0, 3.0),
660        ];
661        let durations = vec![2.0, 3.0, 4.0];
662
663        let segments = generate_waypoint_trajectory_with_durations(&waypoints, &durations);
664        assert_eq!(segments.len(), 3);
665        assert!((segments[0].x.position(2.0) - 4.0).abs() < EPSILON);
666        assert!((segments[1].y.position(3.0) - 3.0).abs() < EPSILON);
667        assert!((segments[2].z.position(4.0) - 1.0).abs() < EPSILON);
668    }
669
670    #[test]
671    fn test_sample_trajectory_segments_counts() {
672        let waypoints = vec![Vector3::new(0.0, 0.0, 0.0), Vector3::new(2.0, 0.0, 0.0)];
673        let segments = generate_waypoint_trajectory_with_durations(&waypoints, &[1.0, 2.0]);
674        let samples = sample_trajectory_segments(&segments, &[1.0, 2.0], 0.5);
675
676        assert_eq!(samples.len(), 8);
677        assert!((samples[0].position.x - 0.0).abs() < EPSILON);
678        assert!((samples[2].position.x - 2.0).abs() < 1e-6);
679    }
680
681    // -----------------------------------------------------------------------
682    // Rotation matrix test
683    // -----------------------------------------------------------------------
684
685    #[test]
686    fn test_rotation_identity() {
687        let r = rotation_matrix(0.0, 0.0, 0.0);
688        let identity = Matrix3::identity();
689        assert!((r - identity).norm() < EPSILON);
690    }
691
692    #[test]
693    fn test_rotation_orthogonal() {
694        let r = rotation_matrix(0.3, 0.5, 1.0);
695        let should_be_identity = r * r.transpose();
696        assert!(
697            (should_be_identity - Matrix3::identity()).norm() < 1e-12,
698            "Rotation matrix not orthogonal"
699        );
700    }
701
702    // -----------------------------------------------------------------------
703    // Controller test
704    // -----------------------------------------------------------------------
705
706    #[test]
707    fn test_hover_control() {
708        // Quadrotor at desired position with zero error => thrust = m*g, no torques.
709        let params = QuadrotorParams::default();
710        let gains = ControllerGains::default();
711        let pos = Vector3::new(0.0, 0.0, 5.0);
712        let state = QuadrotorState::new(pos);
713        let desired = DesiredState {
714            position: pos,
715            velocity: Vector3::zeros(),
716            acceleration: Vector3::zeros(),
717            yaw: 0.0,
718        };
719
720        let ctrl = compute_control(&state, &desired, &params, &gains);
721        let expected_thrust = params.mass * params.gravity;
722        assert!(
723            (ctrl.thrust - expected_thrust).abs() < EPSILON,
724            "Hover thrust should be m*g, got {}",
725            ctrl.thrust
726        );
727        assert!(ctrl.roll_torque.abs() < EPSILON);
728        assert!(ctrl.pitch_torque.abs() < EPSILON);
729        assert!(ctrl.yaw_torque.abs() < EPSILON);
730    }
731
732    #[test]
733    fn test_control_z_error_increases_thrust() {
734        let params = QuadrotorParams::default();
735        let gains = ControllerGains::default();
736        let state = QuadrotorState::new(Vector3::new(0.0, 0.0, 4.0));
737        let desired = DesiredState {
738            position: Vector3::new(0.0, 0.0, 5.0),
739            velocity: Vector3::zeros(),
740            acceleration: Vector3::zeros(),
741            yaw: 0.0,
742        };
743
744        let ctrl = compute_control(&state, &desired, &params, &gains);
745        let hover_thrust = params.mass * params.gravity;
746        assert!(
747            ctrl.thrust > hover_thrust,
748            "Below desired z => thrust should exceed hover"
749        );
750    }
751
752    // -----------------------------------------------------------------------
753    // Dynamics step test
754    // -----------------------------------------------------------------------
755
756    #[test]
757    fn test_dynamics_freefall() {
758        let params = QuadrotorParams::default();
759        let mut state = QuadrotorState::new(Vector3::new(0.0, 0.0, 10.0));
760        let zero_control = ControlOutput {
761            thrust: 0.0,
762            roll_torque: 0.0,
763            pitch_torque: 0.0,
764            yaw_torque: 0.0,
765        };
766
767        let dt = 0.01;
768        for _ in 0..100 {
769            step_dynamics(&mut state, &zero_control, &params, dt);
770        }
771
772        // After 1s of freefall: z ~ 10 - 0.5*g*1^2 ~ 5.095
773        let expected_z = 10.0 - 0.5 * GRAVITY * 1.0;
774        assert!(
775            (state.position.z - expected_z).abs() < 0.5,
776            "Freefall z: {}, expected ~{}",
777            state.position.z,
778            expected_z
779        );
780        // Velocity should be ~ -g*t = -9.81
781        assert!(
782            (state.velocity.z - (-GRAVITY)).abs() < 0.2,
783            "Freefall vz: {}, expected ~{}",
784            state.velocity.z,
785            -GRAVITY
786        );
787        // x, y should stay zero
788        assert!(state.position.x.abs() < EPSILON);
789        assert!(state.position.y.abs() < EPSILON);
790    }
791
792    #[test]
793    fn test_dynamics_hover() {
794        let params = QuadrotorParams::default();
795        let mut state = QuadrotorState::new(Vector3::new(0.0, 0.0, 5.0));
796        let hover_control = ControlOutput {
797            thrust: params.mass * params.gravity,
798            roll_torque: 0.0,
799            pitch_torque: 0.0,
800            yaw_torque: 0.0,
801        };
802
803        let dt = 0.01;
804        for _ in 0..1000 {
805            step_dynamics(&mut state, &hover_control, &params, dt);
806        }
807
808        // After 10s of perfect hover, position should be unchanged.
809        assert!(
810            (state.position.z - 5.0).abs() < 0.01,
811            "Hover z drifted to {}",
812            state.position.z
813        );
814        assert!(state.velocity.z.abs() < 0.01);
815    }
816
817    // -----------------------------------------------------------------------
818    // Full simulation tests
819    // -----------------------------------------------------------------------
820
821    #[test]
822    fn test_simulate_square_trajectory() {
823        let waypoints = vec![
824            Vector3::new(-5.0, -5.0, 5.0),
825            Vector3::new(5.0, -5.0, 5.0),
826            Vector3::new(5.0, 5.0, 5.0),
827            Vector3::new(-5.0, 5.0, 5.0),
828        ];
829
830        let config = SimulationConfig {
831            n_loops: 2,
832            ..Default::default()
833        };
834
835        let record = simulate_trajectory_following(&waypoints, &config);
836
837        // Should have recorded steps
838        assert!(record.positions.len() > 100);
839        assert_eq!(record.positions.len(), record.orientations.len());
840        // Controls are one fewer than states (initial state has no control)
841        assert_eq!(record.controls.len(), record.positions.len() - 1);
842
843        // Altitude should stay roughly around 5.0 (constant z trajectory)
844        let z_values: Vec<f64> = record.positions.iter().map(|p| p.z).collect();
845        let z_min = z_values.iter().cloned().fold(f64::INFINITY, f64::min);
846        let z_max = z_values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
847        assert!(
848            z_min > 2.0 && z_max < 8.0,
849            "Altitude should stay near 5.0, got range [{}, {}]",
850            z_min,
851            z_max
852        );
853    }
854
855    #[test]
856    fn test_simulate_ascending_trajectory() {
857        let waypoints = vec![Vector3::new(0.0, 0.0, 0.0), Vector3::new(0.0, 0.0, 10.0)];
858
859        let config = SimulationConfig {
860            n_loops: 1,
861            segment_duration: 5.0,
862            ..Default::default()
863        };
864
865        let record = simulate_trajectory_following(&waypoints, &config);
866
867        // The drone should ascend: final z should be above start
868        let final_z = record.positions.last().unwrap().z;
869        assert!(
870            final_z > 0.0,
871            "Drone should have ascended, final z = {}",
872            final_z
873        );
874    }
875
876    #[test]
877    fn test_simulation_record_consistency() {
878        let waypoints = vec![
879            Vector3::new(0.0, 0.0, 5.0),
880            Vector3::new(5.0, 0.0, 5.0),
881            Vector3::new(5.0, 5.0, 5.0),
882        ];
883
884        let config = SimulationConfig {
885            dt: 0.05,
886            segment_duration: 3.0,
887            n_loops: 1,
888            ..Default::default()
889        };
890
891        let record = simulate_trajectory_following(&waypoints, &config);
892
893        // First recorded position should be the starting waypoint
894        let start = &record.positions[0];
895        assert!((start.x - 0.0).abs() < EPSILON);
896        assert!((start.y - 0.0).abs() < EPSILON);
897        assert!((start.z - 5.0).abs() < EPSILON);
898
899        // All positions should be finite
900        for (i, p) in record.positions.iter().enumerate() {
901            assert!(
902                p.x.is_finite() && p.y.is_finite() && p.z.is_finite(),
903                "Non-finite position at step {}: {:?}",
904                i,
905                p
906            );
907        }
908    }
909
910    #[test]
911    fn test_simulate_desired_states_hover() {
912        let config = SimulationConfig {
913            dt: 0.05,
914            ..Default::default()
915        };
916        let desired_states = vec![
917            DesiredState {
918                position: Vector3::new(0.0, 0.0, 3.0),
919                velocity: Vector3::zeros(),
920                acceleration: Vector3::zeros(),
921                yaw: 0.0,
922            };
923            20
924        ];
925
926        let record = simulate_desired_states(Vector3::new(0.0, 0.0, 3.0), &desired_states, &config);
927        let final_z = record.positions.last().unwrap().z;
928        assert!((final_z - 3.0).abs() < 0.1);
929        assert_eq!(record.controls.len(), desired_states.len());
930    }
931}