pamoja_sim/robot.rs
1//! A hardware-free mobile robot you can drive and watch move.
2
3use pamoja_core::{Actuator, Result, Sensor};
4use pamoja_kit::{Odometry, Pose, Twist};
5
6/// A simulated differential-drive robot: drive it with a [`Twist`], read back its [`Pose`].
7///
8/// This stands in for a real rover in a hardware-free test or demo. It is both an
9/// [`Actuator`] whose command is a body twist and a [`Sensor`] whose reading is the pose:
10/// each command advances the robot one time step at the commanded velocity, integrating the
11/// motion with the same exact-arc odometry a real robot would use, and a read returns where it
12/// has reached. A control loop can therefore be developed and tested end to end with no robot.
13///
14/// # Examples
15///
16/// ```
17/// use pamoja_core::{Actuator, Sensor};
18/// use pamoja_kit::Twist;
19/// use pamoja_sim::SimRobot;
20///
21/// # async fn demo() -> pamoja_core::Result<()> {
22/// let mut robot = SimRobot::new(0.1); // 0.1 s per command
23/// // Drive straight at 1 m/s for ten steps: about one metre forward.
24/// for _ in 0..10 {
25/// robot.apply(Twist::planar(1.0, 0.0)).await?;
26/// }
27/// let pose = robot.read().await?;
28/// assert!((pose.x - 1.0).abs() < 1e-5 && pose.y.abs() < 1e-5);
29/// # Ok(())
30/// # }
31/// ```
32#[derive(Clone, Copy, Debug)]
33pub struct SimRobot {
34 odometry: Odometry,
35 dt: f32,
36}
37
38impl SimRobot {
39 /// Creates a robot at the origin that advances `dt` seconds per command.
40 ///
41 /// # Arguments
42 ///
43 /// * `dt` - the time each [`apply`](SimRobot::apply) advances the robot; its magnitude is used.
44 ///
45 /// # Returns
46 ///
47 /// The simulated robot.
48 pub fn new(dt: f32) -> Self {
49 Self {
50 odometry: Odometry::at_origin(),
51 dt: dt.abs(),
52 }
53 }
54
55 /// Creates a robot starting from a known pose.
56 ///
57 /// # Arguments
58 ///
59 /// * `pose` - the starting pose.
60 /// * `dt` - the time each command advances the robot; its magnitude is used.
61 ///
62 /// # Returns
63 ///
64 /// The simulated robot.
65 pub fn starting_at(pose: Pose, dt: f32) -> Self {
66 Self {
67 odometry: Odometry::new(pose),
68 dt: dt.abs(),
69 }
70 }
71
72 /// Returns the robot's current pose.
73 ///
74 /// # Returns
75 ///
76 /// The pose reached so far.
77 pub fn pose(&self) -> Pose {
78 self.odometry.pose()
79 }
80}
81
82impl Actuator for SimRobot {
83 type Command = Twist;
84
85 async fn apply(&mut self, command: Twist) -> Result<()> {
86 self.odometry.integrate(command.vx, command.omega, self.dt);
87 Ok(())
88 }
89}
90
91impl Sensor for SimRobot {
92 type Reading = Pose;
93
94 async fn read(&mut self) -> Result<Pose> {
95 Ok(self.odometry.pose())
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use core::f32::consts::FRAC_PI_2;
103
104 #[tokio::test]
105 async fn driving_straight_advances_along_x() {
106 let mut robot = SimRobot::new(0.1);
107 for _ in 0..10 {
108 robot.apply(Twist::planar(1.0, 0.0)).await.unwrap();
109 }
110 let pose = robot.read().await.unwrap();
111 assert!((pose.x - 1.0).abs() < 1e-5);
112 assert!(pose.y.abs() < 1e-5);
113 }
114
115 #[tokio::test]
116 async fn turning_in_place_changes_only_the_heading() {
117 let mut robot = SimRobot::new(0.5);
118 // Spin at 1 rad/s for two half-second steps: about one radian, no translation.
119 robot.apply(Twist::planar(0.0, 1.0)).await.unwrap();
120 robot.apply(Twist::planar(0.0, 1.0)).await.unwrap();
121 let pose = robot.read().await.unwrap();
122 assert!(pose.x.abs() < 1e-6 && pose.y.abs() < 1e-6);
123 assert!((pose.theta - 1.0).abs() < 1e-6);
124 }
125
126 #[tokio::test]
127 async fn a_quarter_circle_lands_at_the_arc_corner() {
128 let mut robot = SimRobot::new(FRAC_PI_2);
129 // One step of forward 1 m/s and 1 rad/s over pi/2 s traces a quarter circle of radius 1.
130 robot.apply(Twist::planar(1.0, 1.0)).await.unwrap();
131 let pose = robot.read().await.unwrap();
132 assert!((pose.x - 1.0).abs() < 1e-5 && (pose.y - 1.0).abs() < 1e-5);
133 }
134}