Skip to main content

thrust_rl/env/games/
mountain_car_continuous.rs

1//! MountainCarContinuous classic-control environment.
2//!
3//! [`MountainCarContinuous`] reimplements the Gym
4//! `MountainCarContinuous-v0` task in-tree with no external dependencies
5//! and closed-form dynamics (issue #166, part of the SAC env-pool epic
6//! #163). It is a second, qualitatively different continuous-control
7//! benchmark alongside [`PendulumSwingUp`](super::pendulum::PendulumSwingUp):
8//! a sparse / deceptive-reward problem where the agent must build up
9//! momentum by rocking back and forth before it can climb the hill.
10//!
11//! An underpowered car sits in a valley between two hills. The agent
12//! applies a continuous engine force and is rewarded for reaching the
13//! flag on top of the right hill while spending as little control effort
14//! as possible.
15//!
16//! - **Action:** `Vec<f32>` of length 1 (engine force), clamped to `[-1.0,
17//!   1.0]`. An empty vec is treated as zero force.
18//! - **Observation (2-dim):** `[position, velocity]`.
19//! - **Dynamics:** closed-form classic-control physics with `power = 0.0015`,
20//!   `gravity = 0.0025`. Velocity is updated by `velocity += force·power −
21//!   cos(3·position)·gravity` and clamped to `[-0.07, 0.07]`; position is
22//!   updated by `position += velocity` and clamped to `[-1.2, 0.6]`. Hitting
23//!   the left wall (`position == -1.2` with negative velocity) zeroes the
24//!   velocity.
25//! - **Reward:** `+100` on the step that reaches the goal (`position ≥ 0.45`),
26//!   minus a control-effort penalty `0.1·force²` every step.
27//! - **Termination:** reaching the goal sets `terminated = true`; this is a
28//!   *real* terminal state (unlike Pendulum, which only ever truncates).
29//!   Episodes also `truncated = true` after 999 steps.
30//!
31//! # Determinism contract
32//!
33//! The only source of randomness is the initial position drawn at
34//! [`Environment::reset`], which is seeded (see
35//! [`MountainCarContinuous::with_seed`]). Reset is reproducible: two envs
36//! with the same seed produce identical episodes. The dynamics themselves
37//! are fully deterministic (no RNG), so [`Environment::restore_state`]
38//! followed by [`Environment::step`] reproduces every subsequent
39//! [`StepResult`] bit-for-bit. The [`MountainCarState`] snapshot captures
40//! the simulation state (`position`, `velocity`, `steps`) but not the
41//! reset RNG.
42
43use rand::{Rng, SeedableRng, rngs::StdRng};
44
45use crate::env::{Environment, SpaceInfo, SpaceType, StepInfo, StepResult};
46
47/// Engine power coefficient applied to the action force.
48const POWER: f32 = 0.0015;
49
50/// Gravity coefficient driving the car back toward the valley.
51const GRAVITY: f32 = 0.0025;
52
53/// Velocity clamp range.
54const MAX_SPEED: f32 = 0.07;
55
56/// Minimum (leftmost) position.
57const MIN_POS: f32 = -1.2;
58
59/// Maximum (rightmost) position.
60const MAX_POS: f32 = 0.6;
61
62/// Position at which the goal flag sits; reaching it terminates.
63const GOAL_POSITION: f32 = 0.45;
64
65/// Default episode length cap.
66const DEFAULT_MAX_STEPS: usize = 999;
67
68/// Default seed used by [`MountainCarContinuous::new`].
69const DEFAULT_SEED: u64 = 0;
70
71/// Snapshot of [`MountainCarContinuous`]'s simulation state.
72///
73/// The dynamics are fully deterministic (no RNG), so
74/// [`Environment::restore_state`] followed by [`Environment::step`]
75/// reproduces every subsequent [`StepResult`] bit-for-bit. The snapshot
76/// does *not* capture the reset RNG; restoring then calling
77/// [`Environment::reset`] will draw the next seeded initial state, not
78/// the one in this snapshot.
79#[derive(Debug, Clone)]
80pub struct MountainCarState {
81    /// Car position along the track.
82    pub position: f32,
83    /// Car velocity.
84    pub velocity: f32,
85    /// Step counter for the current episode.
86    pub steps: usize,
87}
88
89/// MountainCarContinuous task with a continuous engine-force action.
90///
91/// See the [module docs](self) for the full observation/reward/dynamics
92/// specification and determinism contract.
93#[derive(Debug, Clone)]
94pub struct MountainCarContinuous {
95    /// Car position along the track.
96    position: f32,
97
98    /// Car velocity.
99    velocity: f32,
100
101    /// Step counter for the current episode.
102    steps: usize,
103
104    /// Maximum number of steps before truncation.
105    max_steps: usize,
106
107    /// Seeded RNG driving the initial state at [`Environment::reset`].
108    rng: StdRng,
109}
110
111impl MountainCarContinuous {
112    /// Create a new env with the default seed and episode length.
113    pub fn new() -> Self {
114        Self::with_seed(DEFAULT_SEED)
115    }
116
117    /// Create a new env with a custom reset seed.
118    ///
119    /// Two envs constructed with the same seed produce identical
120    /// episodes (same initial state on every reset).
121    pub fn with_seed(seed: u64) -> Self {
122        Self::with_seed_and_max_steps(seed, DEFAULT_MAX_STEPS)
123    }
124
125    /// Create a new env with a custom reset seed and episode length.
126    pub fn with_seed_and_max_steps(seed: u64, max_steps: usize) -> Self {
127        let mut env = Self {
128            position: 0.0,
129            velocity: 0.0,
130            steps: 0,
131            max_steps,
132            rng: StdRng::seed_from_u64(seed),
133        };
134        env.reset();
135        env
136    }
137
138    /// Current car position (for inspection / tests).
139    pub fn position(&self) -> f32 {
140        self.position
141    }
142
143    /// Current car velocity (for inspection / tests).
144    pub fn velocity(&self) -> f32 {
145        self.velocity
146    }
147}
148
149impl Default for MountainCarContinuous {
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155impl Environment for MountainCarContinuous {
156    /// Continuous engine-force action. Length-1 `Vec<f32>`; values
157    /// outside `[-1.0, 1.0]` are clamped. An empty vec is treated as zero
158    /// force.
159    type Action = Vec<f32>;
160
161    /// Snapshot type. The dynamics are deterministic (no RNG), so
162    /// restore + step reproduces subsequent results exactly. The reset
163    /// RNG is not captured; see [`MountainCarState`].
164    type State = MountainCarState;
165
166    fn reset(&mut self) {
167        // Gym `MountainCarContinuous-v0` draws the initial position
168        // uniformly in `[-0.6, -0.4]` with zero initial velocity.
169        self.position = self.rng.random_range(-0.6..-0.4);
170        self.velocity = 0.0;
171        self.steps = 0;
172    }
173
174    fn get_observation(&self) -> Vec<f32> {
175        vec![self.position, self.velocity]
176    }
177
178    fn step(&mut self, action: Vec<f32>) -> StepResult {
179        // Length-1 action; an empty vec is treated as zero force rather
180        // than panicking. Anything beyond index 0 is ignored.
181        let force = action.first().copied().unwrap_or(0.0).clamp(-1.0, 1.0);
182
183        // Closed-form classic-control dynamics.
184        self.velocity = (self.velocity + force * POWER - (3.0 * self.position).cos() * GRAVITY)
185            .clamp(-MAX_SPEED, MAX_SPEED);
186        self.position = (self.position + self.velocity).clamp(MIN_POS, MAX_POS);
187
188        // Gym left-wall behavior: a car pinned at the leftmost position
189        // moving left has its velocity zeroed.
190        if self.position == MIN_POS && self.velocity < 0.0 {
191            self.velocity = 0.0;
192        }
193
194        let terminated = self.position >= GOAL_POSITION;
195        let reward = (if terminated { 100.0 } else { 0.0 }) - 0.1 * force * force;
196
197        self.steps += 1;
198        let truncated = self.steps >= self.max_steps;
199
200        StepResult {
201            observation: self.get_observation(),
202            reward,
203            terminated,
204            truncated,
205            info: StepInfo::default(),
206        }
207    }
208
209    fn observation_space(&self) -> SpaceInfo {
210        // [position, velocity], both continuous.
211        SpaceInfo { shape: vec![2], space_type: SpaceType::Box }
212    }
213
214    fn action_space(&self) -> SpaceInfo {
215        // 1D continuous engine force.
216        SpaceInfo { shape: vec![1], space_type: SpaceType::Box }
217    }
218
219    fn render(&self) -> Vec<u8> {
220        Vec::new()
221    }
222
223    fn close(&mut self) {}
224
225    fn clone_state(&self) -> MountainCarState {
226        MountainCarState { position: self.position, velocity: self.velocity, steps: self.steps }
227    }
228
229    fn restore_state(&mut self, state: &MountainCarState) {
230        self.position = state.position;
231        self.velocity = state.velocity;
232        self.steps = state.steps;
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn observation_is_length_two() {
242        let mut env = MountainCarContinuous::new();
243        env.reset();
244
245        let obs = env.get_observation();
246        assert_eq!(obs.len(), 2, "observation must be 2-dimensional");
247
248        let result = env.step(vec![0.5]);
249        assert_eq!(result.observation.len(), 2);
250    }
251
252    #[test]
253    fn force_is_clamped() {
254        // Apply a huge positive force from a known state. The velocity
255        // delta from the force term saturates at the clamped force.
256        let pos = 0.0_f32;
257        let baseline_grav = (3.0 * pos).cos() * GRAVITY;
258
259        let mut env = MountainCarContinuous::new();
260        env.restore_state(&MountainCarState { position: pos, velocity: 0.0, steps: 0 });
261        env.step(vec![1000.0]);
262        // Expected velocity using the clamped force of +1.0.
263        let expected_pos = POWER - baseline_grav;
264        assert!(
265            (env.velocity() - expected_pos).abs() < 1e-6,
266            "velocity should reflect clamped +1 force, got {}",
267            env.velocity()
268        );
269
270        // Negative direction.
271        let mut env2 = MountainCarContinuous::new();
272        env2.restore_state(&MountainCarState { position: pos, velocity: 0.0, steps: 0 });
273        env2.step(vec![-1000.0]);
274        let expected_neg = -POWER - baseline_grav;
275        assert!(
276            (env2.velocity() - expected_neg).abs() < 1e-6,
277            "velocity should reflect clamped -1 force, got {}",
278            env2.velocity()
279        );
280    }
281
282    #[test]
283    fn velocity_is_clamped_to_max_speed() {
284        // Start already at max speed; even a maximal force step must keep
285        // velocity within `[-MAX_SPEED, MAX_SPEED]`.
286        let mut env = MountainCarContinuous::new();
287        env.restore_state(&MountainCarState { position: -0.5, velocity: MAX_SPEED, steps: 0 });
288        env.step(vec![1.0]);
289        assert!(
290            env.velocity() <= MAX_SPEED + 1e-9 && env.velocity() >= -MAX_SPEED - 1e-9,
291            "velocity must stay within bounds, got {}",
292            env.velocity()
293        );
294
295        let mut env2 = MountainCarContinuous::new();
296        env2.restore_state(&MountainCarState { position: 0.5, velocity: -MAX_SPEED, steps: 0 });
297        env2.step(vec![-1.0]);
298        assert!(
299            env2.velocity() >= -MAX_SPEED - 1e-9,
300            "velocity must stay within bounds, got {}",
301            env2.velocity()
302        );
303    }
304
305    #[test]
306    fn position_is_clamped_and_left_wall_zeroes_velocity() {
307        // Drive into the left wall with strong negative velocity.
308        let mut env = MountainCarContinuous::new();
309        env.restore_state(&MountainCarState {
310            position: MIN_POS + 0.01,
311            velocity: -MAX_SPEED,
312            steps: 0,
313        });
314        env.step(vec![-1.0]);
315        assert!(env.position() >= MIN_POS - 1e-9, "position must not drop below MIN_POS");
316        assert_eq!(env.position(), MIN_POS, "should be pinned at left wall");
317        assert_eq!(env.velocity(), 0.0, "left-wall contact zeroes velocity");
318    }
319
320    #[test]
321    fn reaching_goal_terminates_with_bonus() {
322        // Place the car just below the goal with enough velocity to cross
323        // it in one step.
324        let mut env = MountainCarContinuous::new();
325        env.restore_state(&MountainCarState {
326            position: GOAL_POSITION - 0.01,
327            velocity: MAX_SPEED,
328            steps: 0,
329        });
330        let r = env.step(vec![0.0]);
331        assert!(r.terminated, "crossing the goal must set terminated");
332        assert!(!r.truncated, "single goal step is not a truncation");
333        // Reward = +100 minus zero control effort (force == 0).
334        assert!(
335            (r.reward - 100.0).abs() < 1e-6,
336            "goal step should yield +100 reward, got {}",
337            r.reward
338        );
339    }
340
341    #[test]
342    fn below_goal_does_not_terminate() {
343        let mut env = MountainCarContinuous::new();
344        env.restore_state(&MountainCarState { position: -0.5, velocity: 0.0, steps: 0 });
345        let r = env.step(vec![0.0]);
346        assert!(!r.terminated, "mid-valley step must not terminate");
347        // Control-effort penalty only (no bonus).
348        assert!(r.reward <= 0.0, "non-goal reward should be non-positive, got {}", r.reward);
349    }
350
351    #[test]
352    fn control_effort_is_penalized() {
353        let mut env = MountainCarContinuous::new();
354        env.restore_state(&MountainCarState { position: -0.5, velocity: 0.0, steps: 0 });
355        let r = env.step(vec![1.0]);
356        // Reward == -0.1 * 1.0^2 == -0.1.
357        assert!((r.reward - (-0.1)).abs() < 1e-6, "force=1 should cost 0.1, got {}", r.reward);
358    }
359
360    #[test]
361    fn truncates_after_max_steps() {
362        // Hold the car in the valley with no force so it never reaches
363        // the goal; it must run to the truncation limit.
364        let mut env = MountainCarContinuous::new();
365        env.reset();
366
367        for i in 0..(DEFAULT_MAX_STEPS - 1) {
368            let r = env.step(Vec::new());
369            assert!(!r.truncated, "should not truncate before max_steps (step {i})");
370            assert!(!r.terminated, "passive car should not reach the goal (step {i})");
371        }
372
373        let r = env.step(Vec::new());
374        assert!(r.truncated, "episode should truncate after {DEFAULT_MAX_STEPS} steps");
375        assert!(!r.terminated, "truncation at the limit is not termination");
376    }
377
378    #[test]
379    fn clone_restore_round_trips_next_step() {
380        let mut env = MountainCarContinuous::new();
381        env.reset();
382        // Advance a few steps so the snapshot is non-trivial.
383        env.step(vec![0.3]);
384        env.step(vec![-0.7]);
385
386        let snapshot = env.clone_state();
387        let result_a = env.step(vec![1.0]);
388
389        // Restore and replay the same action; results must match exactly.
390        env.restore_state(&snapshot);
391        let result_b = env.step(vec![1.0]);
392
393        assert_eq!(result_a.observation, result_b.observation, "obs must reproduce bit-for-bit");
394        assert_eq!(result_a.reward, result_b.reward, "reward must reproduce bit-for-bit");
395        assert_eq!(result_a.truncated, result_b.truncated);
396        assert_eq!(result_a.terminated, result_b.terminated);
397    }
398
399    #[test]
400    fn seeded_reset_is_reproducible() {
401        let mut a = MountainCarContinuous::with_seed(42);
402        let mut b = MountainCarContinuous::with_seed(42);
403        a.reset();
404        b.reset();
405        assert_eq!(a.get_observation(), b.get_observation(), "same seed -> same initial obs");
406
407        // Different seeds should (essentially always) differ.
408        let mut c = MountainCarContinuous::with_seed(7);
409        c.reset();
410        assert_ne!(
411            a.get_observation(),
412            c.get_observation(),
413            "different seeds should give different initial states"
414        );
415
416        // Determinism holds across a full rollout from a fresh reset.
417        a.reset();
418        b.reset();
419        for _ in 0..20 {
420            let ra = a.step(vec![0.5]);
421            let rb = b.step(vec![0.5]);
422            assert_eq!(ra.observation, rb.observation);
423            assert_eq!(ra.reward, rb.reward);
424        }
425    }
426
427    #[test]
428    fn action_space_is_box() {
429        let env = MountainCarContinuous::new();
430        let space = env.action_space();
431        assert_eq!(space.shape, vec![1]);
432        assert!(matches!(space.space_type, SpaceType::Box));
433    }
434
435    #[test]
436    fn observation_space_is_box() {
437        let env = MountainCarContinuous::new();
438        let space = env.observation_space();
439        assert_eq!(space.shape, vec![2]);
440        assert!(matches!(space.space_type, SpaceType::Box));
441    }
442
443    #[test]
444    fn empty_action_behaves_like_zero_force() {
445        let mut env = MountainCarContinuous::new();
446        env.restore_state(&MountainCarState { position: -0.5, velocity: 0.0, steps: 0 });
447        let empty = env.step(Vec::new());
448
449        let mut env2 = MountainCarContinuous::new();
450        env2.restore_state(&MountainCarState { position: -0.5, velocity: 0.0, steps: 0 });
451        let zero = env2.step(vec![0.0]);
452
453        assert_eq!(empty.observation, zero.observation, "empty action == zero force");
454        assert_eq!(empty.reward, zero.reward, "empty action == zero force reward");
455    }
456}