Skip to main content

thrust_rl/env/games/
cartpole.rs

1//! CartPole-v1 environment
2//!
3//! A classic reinforcement learning benchmark where a pole is balanced on a
4//! cart. The goal is to prevent the pole from falling over by applying forces
5//! to the cart.
6//!
7//! # Physics
8//!
9//! The cart-pole system follows these dynamics:
10//! - State: [x, x_dot, theta, theta_dot] (cart position, cart velocity, pole
11//!   angle, pole angular velocity)
12//! - Actions: 0 (push left) or 1 (push right)
13//! - Reward: +1 for each timestep the pole stays upright
14//! - Termination: Pole angle > 12° or cart position > 2.4
15//!
16//! # Reference
17//!
18//! Based on OpenAI Gym CartPole-v1:
19//! <https://github.com/openai/gym/blob/master/gym/envs/classic_control/cartpole.py>
20
21use rand::Rng;
22
23use crate::env::{Environment, SpaceInfo, SpaceType, StepInfo, StepResult};
24
25/// Snapshot of CartPole's simulation state.
26///
27/// CartPole is fully deterministic on a per-step basis (the only RNG use is
28/// inside `reset_state`, which is not called during stepping), so
29/// `restore_state` followed by `step(a)` reproduces the same [`StepResult`]
30/// as the original step at the time of snapshot.
31#[derive(Debug, Clone)]
32pub struct CartPoleState {
33    /// Cart position
34    pub x: f32,
35    /// Cart velocity
36    pub x_dot: f32,
37    /// Pole angle (radians)
38    pub theta: f32,
39    /// Pole angular velocity
40    pub theta_dot: f32,
41    /// Step counter
42    pub steps: usize,
43}
44
45/// CartPole-v1 environment
46///
47/// A pole is attached to a cart moving along a frictionless track.
48/// The goal is to balance the pole by applying forces to the cart.
49///
50/// # Snapshot semantics
51///
52/// CartPole's [`Environment::clone_state`] / [`Environment::restore_state`]
53/// pair is **fully deterministic**: restoring a snapshot and calling
54/// `step(action)` produces the exact same [`StepResult`] as the original
55/// step. CartPole only consumes RNG during `reset()`, not during stepping.
56#[derive(Debug)]
57pub struct CartPole {
58    // State variables
59    x: f32,         // Cart position
60    x_dot: f32,     // Cart velocity
61    theta: f32,     // Pole angle (radians)
62    theta_dot: f32, // Pole angular velocity
63
64    // Episode tracking
65    steps: usize,
66    max_steps: usize,
67
68    // Physics constants (matching Gym CartPole-v1)
69    gravity: f32,
70    #[allow(dead_code)]
71    mass_cart: f32,
72    mass_pole: f32,
73    total_mass: f32,
74    length: f32,           // Half-length of pole
75    pole_mass_length: f32, // pole_mass * length
76    force_mag: f32,
77    tau: f32, // Time step
78
79    // Thresholds
80    theta_threshold: f32,
81    x_threshold: f32,
82}
83
84impl CartPole {
85    /// Create a new CartPole environment with default parameters
86    ///
87    /// Physics constants match OpenAI Gym CartPole-v1:
88    /// - gravity = 9.8 m/s²
89    /// - cart mass = 1.0 kg
90    /// - pole mass = 0.1 kg
91    /// - pole half-length = 0.5 m
92    /// - force magnitude = 10.0 N
93    /// - timestep = 0.02 s
94    pub fn new() -> Self {
95        let gravity = 9.8;
96        let mass_cart = 1.0;
97        let mass_pole = 0.1;
98        let total_mass = mass_cart + mass_pole;
99        let length = 0.5; // Half-length of pole
100        let pole_mass_length = mass_pole * length;
101        let force_mag = 10.0;
102        let tau = 0.02;
103        let theta_threshold = 12.0 * 2.0 * std::f32::consts::PI / 360.0; // ~0.2094 radians
104        let x_threshold = 2.4;
105        let max_steps = 500;
106
107        Self {
108            x: 0.0,
109            x_dot: 0.0,
110            theta: 0.0,
111            theta_dot: 0.0,
112            steps: 0,
113            max_steps,
114            gravity,
115            mass_cart,
116            mass_pole,
117            total_mass,
118            length,
119            pole_mass_length,
120            force_mag,
121            tau,
122            theta_threshold,
123            x_threshold,
124        }
125    }
126
127    /// Reset state to random initial conditions
128    ///
129    /// All state variables are initialized with small random perturbations
130    /// around equilibrium (uniform distribution in [-0.05, 0.05])
131    fn reset_state(&mut self) {
132        let mut rng = rand::rng();
133
134        // Initialize with small random perturbations
135        self.x = rng.random_range(-0.05..0.05);
136        self.x_dot = rng.random_range(-0.05..0.05);
137        self.theta = rng.random_range(-0.05..0.05);
138        self.theta_dot = rng.random_range(-0.05..0.05);
139    }
140
141    /// Perform one physics simulation step using Euler integration
142    ///
143    /// Implements the cart-pole dynamics equations:
144    /// ```text
145    /// temp = (force + pole_mass_length * theta_dot² * sin(theta)) / total_mass
146    /// theta_acc = (g * sin(theta) - cos(theta) * temp) /
147    ///             (length * (4/3 - mass_pole * cos²(theta) / total_mass))
148    /// x_acc = temp - pole_mass_length * theta_acc * cos(theta) / total_mass
149    /// ```
150    fn physics_step(&mut self, action: i64) {
151        // Convert action to force: 0 = push left (-10N), 1 = push right (+10N)
152        let force = if action == 1 {
153            self.force_mag
154        } else {
155            -self.force_mag
156        };
157
158        let cos_theta = self.theta.cos();
159        let sin_theta = self.theta.sin();
160
161        // Compute accelerations using cart-pole dynamics
162        let temp = (force + self.pole_mass_length * self.theta_dot * self.theta_dot * sin_theta)
163            / self.total_mass;
164        let theta_acc = (self.gravity * sin_theta - cos_theta * temp)
165            / (self.length
166                * (4.0 / 3.0 - self.mass_pole * cos_theta * cos_theta / self.total_mass));
167        let x_acc = temp - self.pole_mass_length * theta_acc * cos_theta / self.total_mass;
168
169        // Euler integration
170        self.x_dot += self.tau * x_acc;
171        self.x += self.tau * self.x_dot;
172        self.theta_dot += self.tau * theta_acc;
173        self.theta += self.tau * self.theta_dot;
174    }
175
176    /// Check if episode should terminate
177    ///
178    /// Termination conditions:
179    /// - Cart position exceeds ±2.4
180    /// - Pole angle exceeds ±12°
181    fn is_terminated(&self) -> bool {
182        self.x < -self.x_threshold
183            || self.x > self.x_threshold
184            || self.theta < -self.theta_threshold
185            || self.theta > self.theta_threshold
186    }
187
188    /// Check if episode should be truncated (max steps reached)
189    fn is_truncated(&self) -> bool {
190        self.steps >= self.max_steps
191    }
192
193    /// Get current observation [x, x_dot, theta, theta_dot]
194    fn get_observation(&self) -> Vec<f32> {
195        vec![self.x, self.x_dot, self.theta, self.theta_dot]
196    }
197
198    /// Get the current state for rendering (public for WASM)
199    pub fn get_state(&self) -> [f32; 4] {
200        [self.x, self.x_dot, self.theta, self.theta_dot]
201    }
202}
203
204impl Default for CartPole {
205    fn default() -> Self {
206        Self::new()
207    }
208}
209
210impl Environment for CartPole {
211    type Action = i64;
212    type State = CartPoleState;
213
214    fn reset(&mut self) {
215        self.reset_state();
216        self.steps = 0;
217    }
218
219    fn get_observation(&self) -> Vec<f32> {
220        self.get_observation()
221    }
222
223    fn step(&mut self, action: i64) -> StepResult {
224        // Perform physics step
225        self.physics_step(action);
226
227        // Increment step counter
228        self.steps += 1;
229
230        // Check termination conditions
231        let terminated = self.is_terminated();
232        let truncated = self.is_truncated();
233
234        // Reward is 1.0 for each step the pole stays upright
235        // Episode ends when terminated or truncated
236        let reward = if terminated || truncated { 0.0 } else { 1.0 };
237
238        StepResult {
239            observation: self.get_observation(),
240            reward,
241            terminated,
242            truncated,
243            info: StepInfo::default(),
244        }
245    }
246
247    fn observation_space(&self) -> SpaceInfo {
248        SpaceInfo { shape: vec![4], space_type: SpaceType::Box }
249    }
250
251    fn action_space(&self) -> SpaceInfo {
252        SpaceInfo { shape: vec![2], space_type: SpaceType::Discrete(2) }
253    }
254
255    fn render(&self) -> Vec<u8> {
256        vec![] // Not implemented for cartpole
257    }
258
259    fn close(&mut self) {
260        // Nothing to clean up
261    }
262
263    fn clone_state(&self) -> CartPoleState {
264        CartPoleState {
265            x: self.x,
266            x_dot: self.x_dot,
267            theta: self.theta,
268            theta_dot: self.theta_dot,
269            steps: self.steps,
270        }
271    }
272
273    fn restore_state(&mut self, state: &CartPoleState) {
274        self.x = state.x;
275        self.x_dot = state.x_dot;
276        self.theta = state.theta;
277        self.theta_dot = state.theta_dot;
278        self.steps = state.steps;
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_cartpole_init() {
288        let env = CartPole::new();
289        assert_eq!(env.max_steps, 500);
290        assert_eq!(env.gravity, 9.8);
291        assert_eq!(env.mass_cart, 1.0);
292        assert_eq!(env.mass_pole, 0.1);
293    }
294
295    #[test]
296    fn test_cartpole_reset() {
297        let mut env = CartPole::new();
298        env.reset();
299        let obs = env.get_observation();
300
301        assert_eq!(obs.len(), 4, "Observation should have 4 elements");
302        assert_eq!(env.steps, 0, "Steps should be reset to 0");
303
304        // Check that initial state is small (close to equilibrium)
305        for &val in &obs {
306            assert!(val.abs() < 0.1, "Initial state should be small perturbation, got {}", val);
307        }
308    }
309
310    #[test]
311    fn test_cartpole_step() {
312        let mut env = CartPole::new();
313        env.reset();
314
315        let result = env.step(1);
316
317        assert_eq!(result.observation.len(), 4, "Observation should have 4 elements");
318        assert!(result.reward == 0.0 || result.reward == 1.0, "Reward should be 0 or 1");
319    }
320
321    #[test]
322    fn test_cartpole_termination() {
323        let mut env = CartPole::new();
324        env.reset();
325
326        // Manually set state to exceed position threshold
327        env.x = 3.0; // Exceeds x_threshold of 2.4
328
329        let result = env.step(0);
330        assert!(
331            result.terminated,
332            "Episode should terminate when cart exceeds position threshold"
333        );
334
335        // Reset and test angle threshold
336        env.reset();
337        env.theta = 0.5; // Exceeds theta_threshold of ~0.2094 radians
338
339        let result = env.step(0);
340        assert!(result.terminated, "Episode should terminate when pole exceeds angle threshold");
341    }
342
343    #[test]
344    fn test_cartpole_truncation() {
345        let mut env = CartPole::new();
346        env.reset();
347
348        // Manually set steps to max
349        env.steps = env.max_steps - 1;
350
351        let result = env.step(0);
352        assert!(result.truncated, "Episode should truncate at max steps");
353    }
354
355    #[test]
356    fn test_cartpole_rewards() {
357        let mut env = CartPole::new();
358        env.reset();
359
360        // First step should give reward
361        let result = env.step(1);
362        if !result.terminated && !result.truncated {
363            assert_eq!(result.reward, 1.0, "Should receive reward of 1.0 per step");
364        }
365    }
366
367    #[test]
368    fn test_cartpole_action_left() {
369        let mut env = CartPole::new();
370        env.reset();
371
372        let x_before = env.x;
373        env.step(0); // Action 0 = push left
374
375        // With leftward force, cart should generally move left (though dynamics are
376        // complex) Just verify physics ran without panicking
377        assert_ne!(env.x, x_before, "State should change after step");
378    }
379
380    #[test]
381    fn test_cartpole_action_right() {
382        let mut env = CartPole::new();
383        env.reset();
384
385        let x_before = env.x;
386        env.step(1); // Action 1 = push right
387
388        // Just verify physics ran without panicking
389        assert_ne!(env.x, x_before, "State should change after step");
390    }
391
392    #[test]
393    fn test_cartpole_observation_space() {
394        let env = CartPole::new();
395        let obs_space = env.observation_space();
396
397        assert_eq!(obs_space.shape, vec![4]);
398        assert!(matches!(obs_space.space_type, SpaceType::Box));
399    }
400
401    #[test]
402    fn test_cartpole_action_space() {
403        let env = CartPole::new();
404        let action_space = env.action_space();
405
406        // CartPole action space currently reports shape `[2]` alongside
407        // `SpaceType::Discrete(2)`. The `space_type` is the source of truth
408        // for the action count; the `shape` field semantics for discrete
409        // spaces are not strictly defined.
410        assert_eq!(action_space.shape, vec![2]);
411        assert!(matches!(action_space.space_type, SpaceType::Discrete(2)));
412    }
413
414    #[test]
415    fn clone_restore_round_trips() {
416        let mut env = CartPole::new();
417        env.reset();
418
419        // Step a few times to a non-initial state.
420        for i in 0..5 {
421            env.step((i % 2) as i64);
422        }
423        let snap = env.clone_state();
424
425        // Take an experimental step.
426        let r1 = env.step(1);
427
428        // Restore and take the same step again.
429        env.restore_state(&snap);
430        let r2 = env.step(1);
431
432        // CartPole is fully deterministic — observation and reward must match.
433        assert_eq!(r1.observation, r2.observation);
434        assert_eq!(r1.reward, r2.reward);
435        assert_eq!(r1.terminated, r2.terminated);
436        assert_eq!(r1.truncated, r2.truncated);
437    }
438
439    #[test]
440    fn test_cartpole_episode() {
441        let mut env = CartPole::new();
442        env.reset();
443
444        let mut steps = 0;
445
446        // Run episode with random actions
447        for _ in 0..1000 {
448            let action = steps % 2; // Alternate actions
449            let result = env.step(action);
450            steps += 1;
451
452            if result.terminated || result.truncated {
453                break;
454            }
455        }
456
457        assert!(steps > 0, "Episode should run at least one step");
458        assert!(steps <= 500, "Episode should not exceed max_steps");
459    }
460}