Skip to main content

thrust_rl/env/games/
pendulum.rs

1//! Pendulum swing-up continuous-control environment.
2//!
3//! [`PendulumSwingUp`] is the canonical SAC/TD3/DDPG smoke benchmark
4//! (issue #139, part of the SAC decomposition #136). It reimplements
5//! the classic Gym `Pendulum-v1` task in-tree with no external
6//! dependencies and closed-form physics.
7//!
8//! A rigid pendulum hangs from a fixed pivot. The agent applies a
9//! continuous torque at the pivot and is rewarded for swinging the
10//! pendulum upright and holding it there with minimal velocity and
11//! control effort.
12//!
13//! - **Action:** `Vec<f32>` of length 1 (torque `u`), clamped to `[-2.0, 2.0]`.
14//! - **Observation (3-dim):** `[cos θ, sin θ, θ̇]`. The angle is encoded as
15//!   `(cos, sin)` so the observation is continuous across the `±π` wrap.
16//! - **Dynamics:** standard `Pendulum-v1` physics with `g = 10`, `m = 1`, `l =
17//!   1`, `dt = 0.05`; angular velocity `θ̇` clamped to `[-8.0, 8.0]`.
18//! - **Reward:** `-(θ_norm² + 0.1·θ̇² + 0.001·u²)` where `θ_norm` is the angle
19//!   wrapped to `[-π, π]`. Reward is always `≤ 0` and is `0` only at the
20//!   upright rest state (`θ = 0`, `θ̇ = 0`, `u = 0`).
21//! - **Episode length:** 200 steps, then `truncated = true`. The env never sets
22//!   `terminated`.
23//!
24//! # Determinism contract
25//!
26//! The only source of randomness is the initial angle/velocity drawn at
27//! [`Environment::reset`], which is seeded (see
28//! [`PendulumSwingUp::with_seed`]). Reset is reproducible: two envs with
29//! the same seed produce identical episodes. The dynamics themselves are
30//! fully deterministic (no RNG), so [`Environment::restore_state`]
31//! followed by [`Environment::step`] reproduces every subsequent
32//! [`StepResult`] bit-for-bit. The [`PendulumState`] snapshot captures
33//! the simulation state (`theta`, `theta_dot`, `steps`) but not the
34//! reset RNG.
35
36use std::f32::consts::PI;
37
38use rand::{Rng, SeedableRng, rngs::StdRng};
39
40use crate::env::{Environment, SpaceInfo, SpaceType, StepInfo, StepResult};
41
42/// Gravitational acceleration used by the dynamics.
43const GRAVITY: f32 = 10.0;
44
45/// Pendulum mass.
46const MASS: f32 = 1.0;
47
48/// Pendulum length.
49const LENGTH: f32 = 1.0;
50
51/// Integration timestep.
52const DT: f32 = 0.05;
53
54/// Torque clamp range applied to the 1D action input.
55const MAX_TORQUE: f32 = 2.0;
56
57/// Angular-velocity clamp range.
58const MAX_SPEED: f32 = 8.0;
59
60/// Default episode length cap.
61const DEFAULT_MAX_STEPS: usize = 200;
62
63/// Default seed used by [`PendulumSwingUp::new`].
64const DEFAULT_SEED: u64 = 0;
65
66/// Snapshot of [`PendulumSwingUp`]'s simulation state.
67///
68/// The pendulum dynamics are fully deterministic (no RNG), so
69/// [`Environment::restore_state`] followed by [`Environment::step`]
70/// reproduces every subsequent [`StepResult`] bit-for-bit. The snapshot
71/// does *not* capture the reset RNG; restoring then calling
72/// [`Environment::reset`] will draw the next seeded initial state, not
73/// the one in this snapshot.
74#[derive(Debug, Clone)]
75pub struct PendulumState {
76    /// Pole angle in radians. `0` is upright; `±π` is hanging down.
77    pub theta: f32,
78    /// Angular velocity in radians per second.
79    pub theta_dot: f32,
80    /// Step counter for the current episode.
81    pub steps: usize,
82}
83
84/// Pendulum swing-up task with a continuous torque action.
85///
86/// See the [module docs](self) for the full observation/reward/dynamics
87/// specification and determinism contract.
88#[derive(Debug, Clone)]
89pub struct PendulumSwingUp {
90    /// Pole angle in radians. `0` is upright; `±π` is hanging down.
91    theta: f32,
92
93    /// Angular velocity in radians per second.
94    theta_dot: f32,
95
96    /// Step counter for the current episode.
97    steps: usize,
98
99    /// Maximum number of steps before truncation.
100    max_steps: usize,
101
102    /// Seeded RNG driving the initial state at [`Environment::reset`].
103    rng: StdRng,
104}
105
106impl PendulumSwingUp {
107    /// Create a new env with the default seed and episode length.
108    pub fn new() -> Self {
109        Self::with_seed(DEFAULT_SEED)
110    }
111
112    /// Create a new env with a custom reset seed.
113    ///
114    /// Two envs constructed with the same seed produce identical
115    /// episodes (same initial state on every reset).
116    pub fn with_seed(seed: u64) -> Self {
117        Self::with_seed_and_max_steps(seed, DEFAULT_MAX_STEPS)
118    }
119
120    /// Create a new env with a custom reset seed and episode length.
121    pub fn with_seed_and_max_steps(seed: u64, max_steps: usize) -> Self {
122        let mut env = Self {
123            theta: PI,
124            theta_dot: 0.0,
125            steps: 0,
126            max_steps,
127            rng: StdRng::seed_from_u64(seed),
128        };
129        env.reset();
130        env
131    }
132
133    /// Current pole angle in radians (for inspection / tests).
134    pub fn theta(&self) -> f32 {
135        self.theta
136    }
137
138    /// Current angular velocity (for inspection / tests).
139    pub fn theta_dot(&self) -> f32 {
140        self.theta_dot
141    }
142
143    /// Wrap an angle to the canonical `[-π, π]` range.
144    fn angle_normalize(angle: f32) -> f32 {
145        // `rem_euclid` maps into `[0, 2π)`; shift to `[-π, π)`.
146        ((angle + PI).rem_euclid(2.0 * PI)) - PI
147    }
148}
149
150impl Default for PendulumSwingUp {
151    fn default() -> Self {
152        Self::new()
153    }
154}
155
156impl Environment for PendulumSwingUp {
157    /// Continuous torque action. Length-1 `Vec<f32>`; values outside
158    /// `[-MAX_TORQUE, MAX_TORQUE]` are clamped. An empty vec is treated
159    /// as zero torque.
160    type Action = Vec<f32>;
161
162    /// Snapshot type. The dynamics are deterministic (no RNG), so
163    /// restore + step reproduces subsequent results exactly. The reset
164    /// RNG is not captured; see [`PendulumState`].
165    type State = PendulumState;
166
167    fn reset(&mut self) {
168        // Gym `Pendulum-v1` draws the initial angle uniformly in
169        // `[-π, π]` and the initial velocity uniformly in `[-1, 1]`.
170        self.theta = self.rng.random_range(-PI..PI);
171        self.theta_dot = self.rng.random_range(-1.0..1.0);
172        self.steps = 0;
173    }
174
175    fn get_observation(&self) -> Vec<f32> {
176        vec![self.theta.cos(), self.theta.sin(), self.theta_dot]
177    }
178
179    fn step(&mut self, action: Vec<f32>) -> StepResult {
180        // Length-1 action; an empty vec is treated as zero torque
181        // rather than panicking. Anything beyond index 0 is ignored.
182        let raw = action.first().copied().unwrap_or(0.0);
183        let torque = raw.clamp(-MAX_TORQUE, MAX_TORQUE);
184
185        // Cost is computed against the *pre-step* state (matching Gym).
186        let theta_norm = Self::angle_normalize(self.theta);
187        let cost = theta_norm * theta_norm
188            + 0.1 * self.theta_dot * self.theta_dot
189            + 0.001 * torque * torque;
190
191        // Standard Pendulum-v1 dynamics. `theta` here measures the
192        // displacement from upright, so the gravity term uses `sin`.
193        let theta_dot_dot = (3.0 * GRAVITY / (2.0 * LENGTH)) * self.theta.sin()
194            + (3.0 / (MASS * LENGTH * LENGTH)) * torque;
195
196        self.theta_dot = (self.theta_dot + theta_dot_dot * DT).clamp(-MAX_SPEED, MAX_SPEED);
197        self.theta += self.theta_dot * DT;
198
199        self.steps += 1;
200        let truncated = self.steps >= self.max_steps;
201
202        StepResult {
203            observation: self.get_observation(),
204            reward: -cost,
205            terminated: false,
206            truncated,
207            info: StepInfo::default(),
208        }
209    }
210
211    fn observation_space(&self) -> SpaceInfo {
212        // [cos θ, sin θ, θ̇], all continuous.
213        SpaceInfo { shape: vec![3], space_type: SpaceType::Box }
214    }
215
216    fn action_space(&self) -> SpaceInfo {
217        // 1D continuous torque.
218        SpaceInfo { shape: vec![1], space_type: SpaceType::Box }
219    }
220
221    fn render(&self) -> Vec<u8> {
222        Vec::new()
223    }
224
225    fn close(&mut self) {}
226
227    fn clone_state(&self) -> PendulumState {
228        PendulumState { theta: self.theta, theta_dot: self.theta_dot, steps: self.steps }
229    }
230
231    fn restore_state(&mut self, state: &PendulumState) {
232        self.theta = state.theta;
233        self.theta_dot = state.theta_dot;
234        self.steps = state.steps;
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn observation_is_length_three_and_unit_circle() {
244        let mut env = PendulumSwingUp::new();
245        env.reset();
246
247        let obs = env.get_observation();
248        assert_eq!(obs.len(), 3, "observation must be 3-dimensional");
249
250        // cos² + sin² == 1 for any angle.
251        let unit = obs[0] * obs[0] + obs[1] * obs[1];
252        assert!((unit - 1.0).abs() < 1e-5, "cos^2 + sin^2 should equal 1, got {unit}");
253
254        // Step and re-check the invariant.
255        let result = env.step(vec![0.5]);
256        assert_eq!(result.observation.len(), 3);
257        let unit2 = result.observation[0] * result.observation[0]
258            + result.observation[1] * result.observation[1];
259        assert!((unit2 - 1.0).abs() < 1e-5, "cos^2 + sin^2 should equal 1, got {unit2}");
260    }
261
262    #[test]
263    fn torque_is_clamped() {
264        // Apply huge torque from the upright rest state. With the angle
265        // at 0 the gravity term vanishes, so the velocity change is
266        // purely from the clamped torque.
267        let mut env = PendulumSwingUp::new();
268        env.restore_state(&PendulumState { theta: 0.0, theta_dot: 0.0, steps: 0 });
269
270        env.step(vec![1000.0]);
271        let max_dot = (3.0 / (MASS * LENGTH * LENGTH)) * MAX_TORQUE * DT;
272        assert!(
273            env.theta_dot() <= max_dot + 1e-6,
274            "velocity change should reflect clamped torque, got {}",
275            env.theta_dot()
276        );
277
278        // Negative direction.
279        let mut env2 = PendulumSwingUp::new();
280        env2.restore_state(&PendulumState { theta: 0.0, theta_dot: 0.0, steps: 0 });
281        env2.step(vec![-1000.0]);
282        assert!(env2.theta_dot() >= -max_dot - 1e-6);
283    }
284
285    #[test]
286    fn reward_is_nonpositive_and_zero_only_at_upright_rest() {
287        let mut env = PendulumSwingUp::new();
288
289        // From the upright rest state, a zero-torque step incurs zero
290        // cost (θ = 0, θ̇ = 0, u = 0 -> reward 0).
291        env.restore_state(&PendulumState { theta: 0.0, theta_dot: 0.0, steps: 0 });
292        let r = env.step(vec![0.0]);
293        assert!(r.reward.abs() < 1e-6, "upright rest with no torque should give reward 0");
294
295        // Any non-trivial state or torque yields strictly negative
296        // reward, and reward is never positive.
297        env.restore_state(&PendulumState { theta: 1.0, theta_dot: 0.0, steps: 0 });
298        let r = env.step(vec![0.0]);
299        assert!(r.reward < 0.0, "off-upright should give negative reward");
300
301        env.restore_state(&PendulumState { theta: 0.0, theta_dot: 0.0, steps: 0 });
302        let r = env.step(vec![1.0]);
303        assert!(r.reward < 0.0, "control effort should give negative reward");
304
305        // Sweep a range of states; reward must always be <= 0.
306        for &theta in &[-PI, -1.5, -0.3, 0.0, 0.7, 2.0, PI] {
307            for &dot in &[-8.0, -1.0, 0.0, 3.0, 8.0] {
308                for &u in &[-2.0, 0.0, 1.5] {
309                    env.restore_state(&PendulumState { theta, theta_dot: dot, steps: 0 });
310                    let r = env.step(vec![u]);
311                    assert!(
312                        r.reward <= 0.0,
313                        "reward must be <= 0 (theta={theta}, dot={dot}, u={u})"
314                    );
315                }
316            }
317        }
318    }
319
320    #[test]
321    fn truncates_after_max_steps() {
322        let mut env = PendulumSwingUp::new();
323        env.reset();
324
325        for i in 0..(DEFAULT_MAX_STEPS - 1) {
326            let r = env.step(vec![0.0]);
327            assert!(!r.truncated, "should not truncate before max_steps (step {i})");
328            assert!(!r.terminated, "pendulum never terminates");
329        }
330
331        let r = env.step(vec![0.0]);
332        assert!(r.truncated, "episode should truncate after {DEFAULT_MAX_STEPS} steps");
333        assert!(!r.terminated, "truncation is not termination");
334    }
335
336    #[test]
337    fn clone_restore_round_trips_next_step() {
338        let mut env = PendulumSwingUp::new();
339        env.reset();
340        // Advance a few steps so the snapshot is non-trivial.
341        env.step(vec![0.3]);
342        env.step(vec![-0.7]);
343
344        let snapshot = env.clone_state();
345        let result_a = env.step(vec![1.1]);
346
347        // Restore and replay the same action; results must match exactly.
348        env.restore_state(&snapshot);
349        let result_b = env.step(vec![1.1]);
350
351        assert_eq!(result_a.observation, result_b.observation, "obs must reproduce bit-for-bit");
352        assert_eq!(result_a.reward, result_b.reward, "reward must reproduce bit-for-bit");
353        assert_eq!(result_a.truncated, result_b.truncated);
354        assert_eq!(result_a.terminated, result_b.terminated);
355    }
356
357    #[test]
358    fn seeded_reset_is_reproducible() {
359        let mut a = PendulumSwingUp::with_seed(42);
360        let mut b = PendulumSwingUp::with_seed(42);
361        a.reset();
362        b.reset();
363        assert_eq!(a.get_observation(), b.get_observation(), "same seed -> same initial obs");
364
365        // Different seeds should (essentially always) differ.
366        let mut c = PendulumSwingUp::with_seed(7);
367        c.reset();
368        assert_ne!(
369            a.get_observation(),
370            c.get_observation(),
371            "different seeds should give different initial states"
372        );
373
374        // Determinism holds across a full rollout from a fresh reset.
375        a.reset();
376        b.reset();
377        for _ in 0..10 {
378            let ra = a.step(vec![0.5]);
379            let rb = b.step(vec![0.5]);
380            assert_eq!(ra.observation, rb.observation);
381            assert_eq!(ra.reward, rb.reward);
382        }
383    }
384
385    #[test]
386    fn action_space_is_box() {
387        let env = PendulumSwingUp::new();
388        let space = env.action_space();
389        assert_eq!(space.shape, vec![1]);
390        assert!(matches!(space.space_type, SpaceType::Box));
391    }
392
393    #[test]
394    fn observation_space_is_box() {
395        let env = PendulumSwingUp::new();
396        let space = env.observation_space();
397        assert_eq!(space.shape, vec![3]);
398        assert!(matches!(space.space_type, SpaceType::Box));
399    }
400
401    #[test]
402    fn empty_action_treated_as_zero() {
403        let mut env = PendulumSwingUp::new();
404        env.restore_state(&PendulumState { theta: 0.0, theta_dot: 0.0, steps: 0 });
405        // No panic on empty action; behaves like zero torque.
406        let r = env.step(Vec::new());
407        assert!(r.reward.abs() < 1e-6, "empty action at upright rest behaves like zero torque");
408    }
409}