use rand::{Rng, SeedableRng, rngs::StdRng};
use crate::env::{Environment, SpaceInfo, SpaceType, StepInfo, StepResult};
const POWER: f32 = 0.0015;
const GRAVITY: f32 = 0.0025;
const MAX_SPEED: f32 = 0.07;
const MIN_POS: f32 = -1.2;
const MAX_POS: f32 = 0.6;
const GOAL_POSITION: f32 = 0.45;
const DEFAULT_MAX_STEPS: usize = 999;
const DEFAULT_SEED: u64 = 0;
#[derive(Debug, Clone)]
pub struct MountainCarState {
pub position: f32,
pub velocity: f32,
pub steps: usize,
}
#[derive(Debug, Clone)]
pub struct MountainCarContinuous {
position: f32,
velocity: f32,
steps: usize,
max_steps: usize,
rng: StdRng,
}
impl MountainCarContinuous {
pub fn new() -> Self {
Self::with_seed(DEFAULT_SEED)
}
pub fn with_seed(seed: u64) -> Self {
Self::with_seed_and_max_steps(seed, DEFAULT_MAX_STEPS)
}
pub fn with_seed_and_max_steps(seed: u64, max_steps: usize) -> Self {
let mut env = Self {
position: 0.0,
velocity: 0.0,
steps: 0,
max_steps,
rng: StdRng::seed_from_u64(seed),
};
env.reset();
env
}
pub fn position(&self) -> f32 {
self.position
}
pub fn velocity(&self) -> f32 {
self.velocity
}
}
impl Default for MountainCarContinuous {
fn default() -> Self {
Self::new()
}
}
impl Environment for MountainCarContinuous {
type Action = Vec<f32>;
type State = MountainCarState;
fn reset(&mut self) {
self.position = self.rng.random_range(-0.6..-0.4);
self.velocity = 0.0;
self.steps = 0;
}
fn get_observation(&self) -> Vec<f32> {
vec![self.position, self.velocity]
}
fn step(&mut self, action: Vec<f32>) -> StepResult {
let force = action.first().copied().unwrap_or(0.0).clamp(-1.0, 1.0);
self.velocity = (self.velocity + force * POWER - (3.0 * self.position).cos() * GRAVITY)
.clamp(-MAX_SPEED, MAX_SPEED);
self.position = (self.position + self.velocity).clamp(MIN_POS, MAX_POS);
if self.position == MIN_POS && self.velocity < 0.0 {
self.velocity = 0.0;
}
let terminated = self.position >= GOAL_POSITION;
let reward = (if terminated { 100.0 } else { 0.0 }) - 0.1 * force * force;
self.steps += 1;
let truncated = self.steps >= self.max_steps;
StepResult {
observation: self.get_observation(),
reward,
terminated,
truncated,
info: StepInfo::default(),
}
}
fn observation_space(&self) -> SpaceInfo {
SpaceInfo { shape: vec![2], space_type: SpaceType::Box }
}
fn action_space(&self) -> SpaceInfo {
SpaceInfo { shape: vec![1], space_type: SpaceType::Box }
}
fn render(&self) -> Vec<u8> {
Vec::new()
}
fn close(&mut self) {}
fn clone_state(&self) -> MountainCarState {
MountainCarState { position: self.position, velocity: self.velocity, steps: self.steps }
}
fn restore_state(&mut self, state: &MountainCarState) {
self.position = state.position;
self.velocity = state.velocity;
self.steps = state.steps;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn observation_is_length_two() {
let mut env = MountainCarContinuous::new();
env.reset();
let obs = env.get_observation();
assert_eq!(obs.len(), 2, "observation must be 2-dimensional");
let result = env.step(vec![0.5]);
assert_eq!(result.observation.len(), 2);
}
#[test]
fn force_is_clamped() {
let pos = 0.0_f32;
let baseline_grav = (3.0 * pos).cos() * GRAVITY;
let mut env = MountainCarContinuous::new();
env.restore_state(&MountainCarState { position: pos, velocity: 0.0, steps: 0 });
env.step(vec![1000.0]);
let expected_pos = POWER - baseline_grav;
assert!(
(env.velocity() - expected_pos).abs() < 1e-6,
"velocity should reflect clamped +1 force, got {}",
env.velocity()
);
let mut env2 = MountainCarContinuous::new();
env2.restore_state(&MountainCarState { position: pos, velocity: 0.0, steps: 0 });
env2.step(vec![-1000.0]);
let expected_neg = -POWER - baseline_grav;
assert!(
(env2.velocity() - expected_neg).abs() < 1e-6,
"velocity should reflect clamped -1 force, got {}",
env2.velocity()
);
}
#[test]
fn velocity_is_clamped_to_max_speed() {
let mut env = MountainCarContinuous::new();
env.restore_state(&MountainCarState { position: -0.5, velocity: MAX_SPEED, steps: 0 });
env.step(vec![1.0]);
assert!(
env.velocity() <= MAX_SPEED + 1e-9 && env.velocity() >= -MAX_SPEED - 1e-9,
"velocity must stay within bounds, got {}",
env.velocity()
);
let mut env2 = MountainCarContinuous::new();
env2.restore_state(&MountainCarState { position: 0.5, velocity: -MAX_SPEED, steps: 0 });
env2.step(vec![-1.0]);
assert!(
env2.velocity() >= -MAX_SPEED - 1e-9,
"velocity must stay within bounds, got {}",
env2.velocity()
);
}
#[test]
fn position_is_clamped_and_left_wall_zeroes_velocity() {
let mut env = MountainCarContinuous::new();
env.restore_state(&MountainCarState {
position: MIN_POS + 0.01,
velocity: -MAX_SPEED,
steps: 0,
});
env.step(vec![-1.0]);
assert!(env.position() >= MIN_POS - 1e-9, "position must not drop below MIN_POS");
assert_eq!(env.position(), MIN_POS, "should be pinned at left wall");
assert_eq!(env.velocity(), 0.0, "left-wall contact zeroes velocity");
}
#[test]
fn reaching_goal_terminates_with_bonus() {
let mut env = MountainCarContinuous::new();
env.restore_state(&MountainCarState {
position: GOAL_POSITION - 0.01,
velocity: MAX_SPEED,
steps: 0,
});
let r = env.step(vec![0.0]);
assert!(r.terminated, "crossing the goal must set terminated");
assert!(!r.truncated, "single goal step is not a truncation");
assert!(
(r.reward - 100.0).abs() < 1e-6,
"goal step should yield +100 reward, got {}",
r.reward
);
}
#[test]
fn below_goal_does_not_terminate() {
let mut env = MountainCarContinuous::new();
env.restore_state(&MountainCarState { position: -0.5, velocity: 0.0, steps: 0 });
let r = env.step(vec![0.0]);
assert!(!r.terminated, "mid-valley step must not terminate");
assert!(r.reward <= 0.0, "non-goal reward should be non-positive, got {}", r.reward);
}
#[test]
fn control_effort_is_penalized() {
let mut env = MountainCarContinuous::new();
env.restore_state(&MountainCarState { position: -0.5, velocity: 0.0, steps: 0 });
let r = env.step(vec![1.0]);
assert!((r.reward - (-0.1)).abs() < 1e-6, "force=1 should cost 0.1, got {}", r.reward);
}
#[test]
fn truncates_after_max_steps() {
let mut env = MountainCarContinuous::new();
env.reset();
for i in 0..(DEFAULT_MAX_STEPS - 1) {
let r = env.step(Vec::new());
assert!(!r.truncated, "should not truncate before max_steps (step {i})");
assert!(!r.terminated, "passive car should not reach the goal (step {i})");
}
let r = env.step(Vec::new());
assert!(r.truncated, "episode should truncate after {DEFAULT_MAX_STEPS} steps");
assert!(!r.terminated, "truncation at the limit is not termination");
}
#[test]
fn clone_restore_round_trips_next_step() {
let mut env = MountainCarContinuous::new();
env.reset();
env.step(vec![0.3]);
env.step(vec![-0.7]);
let snapshot = env.clone_state();
let result_a = env.step(vec![1.0]);
env.restore_state(&snapshot);
let result_b = env.step(vec![1.0]);
assert_eq!(result_a.observation, result_b.observation, "obs must reproduce bit-for-bit");
assert_eq!(result_a.reward, result_b.reward, "reward must reproduce bit-for-bit");
assert_eq!(result_a.truncated, result_b.truncated);
assert_eq!(result_a.terminated, result_b.terminated);
}
#[test]
fn seeded_reset_is_reproducible() {
let mut a = MountainCarContinuous::with_seed(42);
let mut b = MountainCarContinuous::with_seed(42);
a.reset();
b.reset();
assert_eq!(a.get_observation(), b.get_observation(), "same seed -> same initial obs");
let mut c = MountainCarContinuous::with_seed(7);
c.reset();
assert_ne!(
a.get_observation(),
c.get_observation(),
"different seeds should give different initial states"
);
a.reset();
b.reset();
for _ in 0..20 {
let ra = a.step(vec![0.5]);
let rb = b.step(vec![0.5]);
assert_eq!(ra.observation, rb.observation);
assert_eq!(ra.reward, rb.reward);
}
}
#[test]
fn action_space_is_box() {
let env = MountainCarContinuous::new();
let space = env.action_space();
assert_eq!(space.shape, vec![1]);
assert!(matches!(space.space_type, SpaceType::Box));
}
#[test]
fn observation_space_is_box() {
let env = MountainCarContinuous::new();
let space = env.observation_space();
assert_eq!(space.shape, vec![2]);
assert!(matches!(space.space_type, SpaceType::Box));
}
#[test]
fn empty_action_behaves_like_zero_force() {
let mut env = MountainCarContinuous::new();
env.restore_state(&MountainCarState { position: -0.5, velocity: 0.0, steps: 0 });
let empty = env.step(Vec::new());
let mut env2 = MountainCarContinuous::new();
env2.restore_state(&MountainCarState { position: -0.5, velocity: 0.0, steps: 0 });
let zero = env2.step(vec![0.0]);
assert_eq!(empty.observation, zero.observation, "empty action == zero force");
assert_eq!(empty.reward, zero.reward, "empty action == zero force reward");
}
}