1use rand::{Rng, SeedableRng, rngs::StdRng};
44
45use crate::env::{Environment, SpaceInfo, SpaceType, StepInfo, StepResult};
46
47const POWER: f32 = 0.0015;
49
50const GRAVITY: f32 = 0.0025;
52
53const MAX_SPEED: f32 = 0.07;
55
56const MIN_POS: f32 = -1.2;
58
59const MAX_POS: f32 = 0.6;
61
62const GOAL_POSITION: f32 = 0.45;
64
65const DEFAULT_MAX_STEPS: usize = 999;
67
68const DEFAULT_SEED: u64 = 0;
70
71#[derive(Debug, Clone)]
80pub struct MountainCarState {
81 pub position: f32,
83 pub velocity: f32,
85 pub steps: usize,
87}
88
89#[derive(Debug, Clone)]
94pub struct MountainCarContinuous {
95 position: f32,
97
98 velocity: f32,
100
101 steps: usize,
103
104 max_steps: usize,
106
107 rng: StdRng,
109}
110
111impl MountainCarContinuous {
112 pub fn new() -> Self {
114 Self::with_seed(DEFAULT_SEED)
115 }
116
117 pub fn with_seed(seed: u64) -> Self {
122 Self::with_seed_and_max_steps(seed, DEFAULT_MAX_STEPS)
123 }
124
125 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 pub fn position(&self) -> f32 {
140 self.position
141 }
142
143 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 type Action = Vec<f32>;
160
161 type State = MountainCarState;
165
166 fn reset(&mut self) {
167 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 let force = action.first().copied().unwrap_or(0.0).clamp(-1.0, 1.0);
182
183 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 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 SpaceInfo { shape: vec![2], space_type: SpaceType::Box }
212 }
213
214 fn action_space(&self) -> SpaceInfo {
215 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}