1use std::f64::consts::PI;
2
3use rand::Rng;
4use rand_chacha::ChaCha8Rng;
5
6use crate::env::spaces::{Action, ActionSpace, ObsSpace, Observation};
7use crate::env::{RLEnv, Transition};
8use crate::error::RloxError;
9use crate::seed::rng_from_seed;
10
11const GRAVITY: f64 = 9.8;
13const MASSCART: f64 = 1.0;
14const MASSPOLE: f64 = 0.1;
15const TOTAL_MASS: f64 = MASSCART + MASSPOLE;
16const LENGTH: f64 = 0.5; const POLEMASS_LENGTH: f64 = MASSPOLE * LENGTH;
18const FORCE_MAG: f64 = 10.0;
19const TAU: f64 = 0.02; const THETA_THRESHOLD: f64 = 12.0 * 2.0 * PI / 360.0; const X_THRESHOLD: f64 = 2.4;
22const MAX_STEPS: u32 = 500;
23
24const OBS_HIGH: [f32; 4] = [
26 (X_THRESHOLD * 2.0) as f32,
27 f32::MAX,
28 (THETA_THRESHOLD * 2.0) as f32,
29 f32::MAX,
30];
31
32pub struct CartPole {
34 state: [f64; 4],
36 rng: ChaCha8Rng,
37 steps: u32,
38 action_space: ActionSpace,
39 obs_space: ObsSpace,
40 done: bool,
41}
42
43impl CartPole {
44 pub fn new(seed: Option<u64>) -> Self {
45 let seed = seed.unwrap_or(0);
46 let rng = rng_from_seed(seed);
47 let obs_low: Vec<f32> = OBS_HIGH.iter().map(|h| -h).collect();
48 let obs_high: Vec<f32> = OBS_HIGH.to_vec();
49
50 let mut env = CartPole {
51 state: [0.0; 4],
52 rng,
53 steps: 0,
54 action_space: ActionSpace::Discrete(2),
55 obs_space: ObsSpace::Box {
56 low: obs_low,
57 high: obs_high,
58 shape: vec![4],
59 },
60 done: true,
61 };
62 let _ = env.reset(Some(seed));
64 env
65 }
66
67 fn obs(&self) -> Observation {
68 Observation::Flat(self.state.iter().map(|&v| v as f32).collect())
69 }
70}
71
72impl RLEnv for CartPole {
73 fn step(&mut self, action: &Action) -> Result<Transition, RloxError> {
74 if self.done {
75 return Err(RloxError::EnvError(
76 "Environment is done. Call reset() before stepping.".into(),
77 ));
78 }
79
80 let action_idx = match action {
81 Action::Discrete(a) => *a,
82 _ => {
83 return Err(RloxError::InvalidAction(
84 "CartPole expects a Discrete action".into(),
85 ))
86 }
87 };
88
89 if !self.action_space.contains(action) {
90 return Err(RloxError::InvalidAction(format!(
91 "Action {} is out of range for Discrete(2)",
92 action_idx
93 )));
94 }
95
96 let [x, x_dot, theta, theta_dot] = self.state;
97
98 let force = if action_idx == 1 {
99 FORCE_MAG
100 } else {
101 -FORCE_MAG
102 };
103
104 let cos_theta = theta.cos();
105 let sin_theta = theta.sin();
106
107 let temp = (force + POLEMASS_LENGTH * theta_dot * theta_dot * sin_theta) / TOTAL_MASS;
109 let theta_acc = (GRAVITY * sin_theta - cos_theta * temp)
110 / (LENGTH * (4.0 / 3.0 - MASSPOLE * cos_theta * cos_theta / TOTAL_MASS));
111 let x_acc = temp - POLEMASS_LENGTH * theta_acc * cos_theta / TOTAL_MASS;
112
113 let new_x = x + TAU * x_dot;
115 let new_x_dot = x_dot + TAU * x_acc;
116 let new_theta = theta + TAU * theta_dot;
117 let new_theta_dot = theta_dot + TAU * theta_acc;
118
119 self.state = [new_x, new_x_dot, new_theta, new_theta_dot];
120 self.steps += 1;
121
122 let terminated = new_x < -X_THRESHOLD
123 || new_x > X_THRESHOLD
124 || new_theta < -THETA_THRESHOLD
125 || new_theta > THETA_THRESHOLD;
126
127 let truncated = !terminated && self.steps >= MAX_STEPS;
128
129 self.done = terminated || truncated;
130
131 Ok(Transition {
132 obs: self.obs(),
133 reward: 1.0,
134 terminated,
135 truncated,
136 info: None,
137 })
138 }
139
140 fn reset(&mut self, seed: Option<u64>) -> Result<Observation, RloxError> {
141 if let Some(s) = seed {
142 self.rng = rng_from_seed(s);
143 }
144
145 for s in self.state.iter_mut() {
147 *s = self.rng.random_range(-0.05..0.05);
148 }
149
150 self.steps = 0;
151 self.done = false;
152
153 Ok(self.obs())
154 }
155
156 fn action_space(&self) -> &ActionSpace {
157 &self.action_space
158 }
159
160 fn obs_space(&self) -> &ObsSpace {
161 &self.obs_space
162 }
163
164 fn render(&self) -> Option<String> {
165 Some(format!(
166 "CartPole | step={} | x={:.4} theta={:.4}",
167 self.steps, self.state[0], self.state[2]
168 ))
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn cartpole_reset_produces_valid_obs() {
178 let env = CartPole::new(Some(42));
179 let obs = env.obs();
180 assert_eq!(obs.as_slice().len(), 4);
181 for &v in obs.as_slice() {
182 assert!(v.abs() <= 0.05, "initial state out of range: {}", v);
183 }
184 }
185
186 #[test]
187 fn cartpole_step_returns_reward_one() {
188 let mut env = CartPole::new(Some(42));
189 let t = env.step(&Action::Discrete(1)).unwrap();
190 assert!((t.reward - 1.0).abs() < f64::EPSILON);
191 assert!(!t.terminated);
192 assert!(!t.truncated);
193 }
194
195 #[test]
196 fn cartpole_invalid_action() {
197 let mut env = CartPole::new(Some(42));
198 let result = env.step(&Action::Discrete(5));
199 assert!(result.is_err());
200 }
201
202 #[test]
203 fn cartpole_step_without_reset_after_done() {
204 let mut env = CartPole::new(Some(42));
205 loop {
207 let t = env.step(&Action::Discrete(1)).unwrap();
208 if t.terminated || t.truncated {
209 break;
210 }
211 }
212 let result = env.step(&Action::Discrete(0));
214 assert!(result.is_err());
215 }
216
217 #[test]
218 fn cartpole_seeded_determinism() {
219 let run = |seed: u64| -> Vec<Vec<f32>> {
220 let mut env = CartPole::new(Some(seed));
221 let mut observations = vec![env.obs().into_inner()];
222 for _ in 0..50 {
223 match env.step(&Action::Discrete(1)) {
224 Ok(t) => observations.push(t.obs.into_inner()),
225 Err(_) => break,
226 }
227 }
228 observations
229 };
230
231 let run1 = run(123);
232 let run2 = run(123);
233 assert_eq!(run1, run2);
234
235 let run3 = run(456);
237 assert_ne!(run1, run3);
238 }
239
240 #[test]
241 fn cartpole_truncates_at_500() {
242 let mut env = CartPole::new(Some(0));
243 let mut truncated = false;
246 for i in 0..600 {
247 let action = Action::Discrete((i % 2) as u32);
248 match env.step(&action) {
249 Ok(t) => {
250 if t.truncated {
251 assert_eq!(env.steps, MAX_STEPS);
252 truncated = true;
253 break;
254 }
255 if t.terminated {
256 env.reset(Some(0)).unwrap();
258 }
259 }
260 Err(_) => {
261 env.reset(Some(0)).unwrap();
262 }
263 }
264 }
265 let _ = truncated; }
269
270 #[test]
271 fn cartpole_numerical_equivalence_seed_42() {
272 let env = CartPole::new(Some(42));
274 let obs = env.obs();
275 assert_eq!(obs.as_slice().len(), 4);
277 for &v in obs.as_slice() {
278 assert!(v.abs() <= 0.05, "initial obs out of expected range: {v}");
279 }
280 }
281
282 #[test]
283 fn cartpole_many_steps_reward_sum() {
284 let mut env = CartPole::new(Some(42));
287 let mut total_reward = 0.0;
288 let mut steps = 0;
289 for _ in 0..100 {
290 match env.step(&Action::Discrete(1)) {
291 Ok(t) => {
292 total_reward += t.reward;
293 steps += 1;
294 if t.terminated || t.truncated {
295 break;
296 }
297 }
298 Err(_) => break,
299 }
300 }
301 assert!(steps > 0);
302 assert!((total_reward - steps as f64).abs() < f64::EPSILON);
303 }
304
305 #[test]
306 fn cartpole_terminates_on_out_of_bounds() {
307 let mut env = CartPole::new(Some(42));
308 let mut terminated = false;
310 for _ in 0..500 {
311 match env.step(&Action::Discrete(1)) {
312 Ok(t) => {
313 if t.terminated {
314 terminated = true;
315 break;
316 }
317 }
318 Err(_) => break,
319 }
320 }
321 assert!(
322 terminated,
323 "CartPole should terminate when always pushing right"
324 );
325 }
326}
327
328const PENDULUM_GRAVITY: f64 = 10.0;
334const PENDULUM_MASS: f64 = 1.0;
335const PENDULUM_LENGTH: f64 = 1.0;
336const PENDULUM_DT: f64 = 0.05;
337const PENDULUM_MAX_VEL: f64 = 8.0;
338const PENDULUM_MAX_TORQUE: f64 = 2.0;
339const PENDULUM_MAX_STEPS: u32 = 200;
340
341#[inline]
346fn angle_normalize(x: f64) -> f64 {
347 (x + PI).rem_euclid(2.0 * PI) - PI
348}
349
350pub struct Pendulum {
356 theta: f64,
358 vel: f64,
359 rng: ChaCha8Rng,
360 steps: u32,
361 action_space: ActionSpace,
362 obs_space: ObsSpace,
363 done: bool,
364}
365
366impl Pendulum {
367 pub fn new(seed: Option<u64>) -> Self {
368 let seed = seed.unwrap_or(0);
369 let rng = rng_from_seed(seed);
370
371 let mut env = Pendulum {
372 theta: 0.0,
373 vel: 0.0,
374 rng,
375 steps: 0,
376 action_space: ActionSpace::Box {
377 low: vec![-PENDULUM_MAX_TORQUE as f32],
378 high: vec![PENDULUM_MAX_TORQUE as f32],
379 shape: vec![1],
380 },
381 obs_space: ObsSpace::Box {
382 low: vec![-1.0, -1.0, -PENDULUM_MAX_VEL as f32],
383 high: vec![1.0, 1.0, PENDULUM_MAX_VEL as f32],
384 shape: vec![3],
385 },
386 done: true,
387 };
388 let _ = env.reset(Some(seed));
389 env
390 }
391
392 #[inline]
393 fn obs(&self) -> Observation {
394 Observation::Flat(vec![
395 self.theta.cos() as f32,
396 self.theta.sin() as f32,
397 self.vel as f32,
398 ])
399 }
400}
401
402impl RLEnv for Pendulum {
403 fn step(&mut self, action: &Action) -> Result<Transition, RloxError> {
404 if self.done {
405 return Err(RloxError::EnvError(
406 "Environment is done. Call reset() before stepping.".into(),
407 ));
408 }
409
410 let torque = match action {
411 Action::Continuous(vals) if vals.len() == 1 => {
412 (vals[0] as f64).clamp(-PENDULUM_MAX_TORQUE, PENDULUM_MAX_TORQUE)
413 }
414 _ => {
415 return Err(RloxError::InvalidAction(
416 "Pendulum expects a Continuous action with 1 element".into(),
417 ));
418 }
419 };
420
421 let theta = self.theta;
422 let vel = self.vel;
423
424 let norm_theta = angle_normalize(theta);
426 let reward = -(norm_theta * norm_theta + 0.1 * vel * vel + 0.001 * torque * torque);
427
428 let g = PENDULUM_GRAVITY;
430 let m = PENDULUM_MASS;
431 let l = PENDULUM_LENGTH;
432 let dt = PENDULUM_DT;
433
434 let new_vel = vel + (3.0 * g / (2.0 * l) * theta.sin() + 3.0 / (m * l * l) * torque) * dt;
435 let new_vel = new_vel.clamp(-PENDULUM_MAX_VEL, PENDULUM_MAX_VEL);
436 let new_theta = theta + new_vel * dt;
437
438 self.theta = new_theta;
439 self.vel = new_vel;
440 self.steps += 1;
441
442 let truncated = self.steps >= PENDULUM_MAX_STEPS;
444 self.done = truncated;
445
446 Ok(Transition {
447 obs: self.obs(),
448 reward,
449 terminated: false,
450 truncated,
451 info: None,
452 })
453 }
454
455 fn reset(&mut self, seed: Option<u64>) -> Result<Observation, RloxError> {
456 if let Some(s) = seed {
457 self.rng = rng_from_seed(s);
458 }
459
460 self.theta = self.rng.random_range(-PI..PI);
462 self.vel = self.rng.random_range(-1.0..1.0);
463 self.steps = 0;
464 self.done = false;
465
466 Ok(self.obs())
467 }
468
469 fn action_space(&self) -> &ActionSpace {
470 &self.action_space
471 }
472
473 fn obs_space(&self) -> &ObsSpace {
474 &self.obs_space
475 }
476
477 fn render(&self) -> Option<String> {
478 Some(format!(
479 "Pendulum | step={} | theta={:.4} vel={:.4}",
480 self.steps, self.theta, self.vel
481 ))
482 }
483}
484
485#[derive(Debug, Clone, Copy)]
491pub enum DriftMode {
492 None,
494 Linear { rate: f64 },
496 Sinusoidal { amplitude: f64, period: f64 },
498 Step { step_size: f64, interval: u64 },
500}
501
502#[derive(Debug, Clone)]
506pub struct DriftConfig {
507 pub gravity: DriftMode,
509 pub pole_length: DriftMode,
511 pub cart_mass: DriftMode,
513 pub force_mag: DriftMode,
515}
516
517impl Default for DriftConfig {
518 fn default() -> Self {
519 Self {
520 gravity: DriftMode::None,
521 pole_length: DriftMode::None,
522 cart_mass: DriftMode::None,
523 force_mag: DriftMode::None,
524 }
525 }
526}
527
528pub struct NonStationaryCartPole {
536 state: [f64; 4],
537 rng: ChaCha8Rng,
538 steps: u32,
539 global_step: u64,
540 action_space: ActionSpace,
541 obs_space: ObsSpace,
542 done: bool,
543 drift: DriftConfig,
544}
545
546impl NonStationaryCartPole {
547 pub fn new(seed: Option<u64>, drift: DriftConfig) -> Self {
548 let seed = seed.unwrap_or(0);
549 let rng = rng_from_seed(seed);
550 let obs_low: Vec<f32> = OBS_HIGH.iter().map(|h| -h).collect();
551 let obs_high: Vec<f32> = OBS_HIGH.to_vec();
552
553 let mut env = Self {
554 state: [0.0; 4],
555 rng,
556 steps: 0,
557 global_step: 0,
558 action_space: ActionSpace::Discrete(2),
559 obs_space: ObsSpace::Box {
560 low: obs_low,
561 high: obs_high,
562 shape: vec![4],
563 },
564 done: true,
565 drift,
566 };
567 let _ = env.reset(Some(seed));
568 env
569 }
570
571 fn apply_drift(base: f64, mode: &DriftMode, t: u64) -> f64 {
572 match mode {
573 DriftMode::None => base,
574 DriftMode::Linear { rate } => base + rate * t as f64,
575 DriftMode::Sinusoidal { amplitude, period } => {
576 base + amplitude * (2.0 * PI * t as f64 / period).sin()
577 }
578 DriftMode::Step {
579 step_size,
580 interval,
581 } => base + step_size * (t / interval) as f64,
582 }
583 }
584
585 fn obs(&self) -> Observation {
586 Observation::Flat(self.state.iter().map(|&v| v as f32).collect())
587 }
588
589 pub fn current_gravity(&self) -> f64 {
591 Self::apply_drift(GRAVITY, &self.drift.gravity, self.global_step)
592 }
593
594 pub fn current_pole_length(&self) -> f64 {
596 Self::apply_drift(LENGTH, &self.drift.pole_length, self.global_step)
597 }
598
599 pub fn current_cart_mass(&self) -> f64 {
601 Self::apply_drift(MASSCART, &self.drift.cart_mass, self.global_step)
602 }
603
604 pub fn current_force_mag(&self) -> f64 {
606 Self::apply_drift(FORCE_MAG, &self.drift.force_mag, self.global_step)
607 }
608
609 pub fn global_step(&self) -> u64 {
611 self.global_step
612 }
613}
614
615impl RLEnv for NonStationaryCartPole {
616 fn step(&mut self, action: &Action) -> Result<Transition, RloxError> {
617 if self.done {
618 return Err(RloxError::EnvError(
619 "Environment is done. Call reset() before stepping.".into(),
620 ));
621 }
622
623 let action_idx = match action {
624 Action::Discrete(a) => *a,
625 _ => {
626 return Err(RloxError::InvalidAction(
627 "CartPole expects a Discrete action".into(),
628 ))
629 }
630 };
631
632 if !self.action_space.contains(action) {
633 return Err(RloxError::InvalidAction(format!(
634 "Action {} is out of range for Discrete(2)",
635 action_idx
636 )));
637 }
638
639 let gravity = self.current_gravity();
641 let length = self.current_pole_length();
642 let masscart = self.current_cart_mass();
643 let force_mag = self.current_force_mag();
644 let masspole = MASSPOLE;
645 let total_mass = masscart + masspole;
646 let polemass_length = masspole * length;
647
648 let [x, x_dot, theta, theta_dot] = self.state;
649
650 let force = if action_idx == 1 {
651 force_mag
652 } else {
653 -force_mag
654 };
655
656 let cos_theta = theta.cos();
657 let sin_theta = theta.sin();
658
659 let temp = (force + polemass_length * theta_dot * theta_dot * sin_theta) / total_mass;
660 let theta_acc = (gravity * sin_theta - cos_theta * temp)
661 / (length * (4.0 / 3.0 - masspole * cos_theta * cos_theta / total_mass));
662 let x_acc = temp - polemass_length * theta_acc * cos_theta / total_mass;
663
664 let new_x = x + TAU * x_dot;
665 let new_x_dot = x_dot + TAU * x_acc;
666 let new_theta = theta + TAU * theta_dot;
667 let new_theta_dot = theta_dot + TAU * theta_acc;
668
669 self.state = [new_x, new_x_dot, new_theta, new_theta_dot];
670 self.steps += 1;
671 self.global_step += 1;
672
673 let terminated = new_x < -X_THRESHOLD
674 || new_x > X_THRESHOLD
675 || new_theta < -THETA_THRESHOLD
676 || new_theta > THETA_THRESHOLD;
677
678 let truncated = !terminated && self.steps >= MAX_STEPS;
679 self.done = terminated || truncated;
680
681 Ok(Transition {
682 obs: self.obs(),
683 reward: 1.0,
684 terminated,
685 truncated,
686 info: None,
687 })
688 }
689
690 fn reset(&mut self, seed: Option<u64>) -> Result<Observation, RloxError> {
691 if let Some(s) = seed {
692 self.rng = rng_from_seed(s);
693 }
694 for s in self.state.iter_mut() {
695 *s = self.rng.random_range(-0.05..0.05);
696 }
697 self.steps = 0;
698 self.done = false;
700 Ok(self.obs())
701 }
702
703 fn action_space(&self) -> &ActionSpace {
704 &self.action_space
705 }
706
707 fn obs_space(&self) -> &ObsSpace {
708 &self.obs_space
709 }
710
711 fn render(&self) -> Option<String> {
712 Some(format!(
713 "NonStationaryCartPole | step={} global={} | x={:.4} theta={:.4} | g={:.2} l={:.3}",
714 self.steps,
715 self.global_step,
716 self.state[0],
717 self.state[2],
718 self.current_gravity(),
719 self.current_pole_length()
720 ))
721 }
722}
723
724#[cfg(test)]
725mod nonstationary_tests {
726 use super::*;
727
728 #[test]
729 fn ns_cartpole_stationary_matches_original() {
730 let mut orig = CartPole::new(Some(42));
732 let mut ns = NonStationaryCartPole::new(Some(42), DriftConfig::default());
733
734 for _ in 0..50 {
735 let t1 = orig.step(&Action::Discrete(1)).unwrap();
736 let t2 = ns.step(&Action::Discrete(1)).unwrap();
737 assert_eq!(t1.obs.as_slice(), t2.obs.as_slice());
738 assert!((t1.reward - t2.reward).abs() < 1e-10);
739 assert_eq!(t1.terminated, t2.terminated);
740 if t1.terminated {
741 break;
742 }
743 }
744 }
745
746 #[test]
747 fn ns_cartpole_linear_gravity_drift() {
748 let drift = DriftConfig {
749 gravity: DriftMode::Linear { rate: 0.01 },
750 ..Default::default()
751 };
752 let mut env = NonStationaryCartPole::new(Some(42), drift);
753
754 assert!((env.current_gravity() - GRAVITY).abs() < 1e-10);
755 for _ in 0..100 {
756 let _ = env.step(&Action::Discrete(1));
757 if env.done {
758 env.reset(Some(42)).unwrap();
759 }
760 }
761 let expected = GRAVITY + 0.01 * 100.0;
763 assert!(
764 (env.current_gravity() - expected).abs() < 1e-10,
765 "gravity={}, expected={}",
766 env.current_gravity(),
767 expected
768 );
769 }
770
771 #[test]
772 fn ns_cartpole_sinusoidal_pole_length() {
773 let drift = DriftConfig {
774 pole_length: DriftMode::Sinusoidal {
775 amplitude: 0.2,
776 period: 100.0,
777 },
778 ..Default::default()
779 };
780 let env = NonStationaryCartPole::new(Some(42), drift);
781 assert!((env.current_pole_length() - LENGTH).abs() < 1e-10);
782 }
783
784 #[test]
785 fn ns_cartpole_step_drift() {
786 let drift = DriftConfig {
787 cart_mass: DriftMode::Step {
788 step_size: 0.5,
789 interval: 50,
790 },
791 ..Default::default()
792 };
793 let mut env = NonStationaryCartPole::new(Some(42), drift);
794
795 assert!((env.current_cart_mass() - MASSCART).abs() < 1e-10);
797
798 for _ in 0..50 {
800 let _ = env.step(&Action::Discrete(0));
801 if env.done {
802 env.reset(Some(42)).unwrap();
803 }
804 }
805 assert!(
807 (env.current_cart_mass() - 1.5).abs() < 1e-10,
808 "mass={}",
809 env.current_cart_mass()
810 );
811 }
812
813 #[test]
814 fn ns_cartpole_global_step_persists_across_resets() {
815 let drift = DriftConfig::default();
816 let mut env = NonStationaryCartPole::new(Some(42), drift);
817
818 for _ in 0..10 {
819 let _ = env.step(&Action::Discrete(1));
820 if env.done {
821 break;
822 }
823 }
824 let step_before_reset = env.global_step();
825 assert!(step_before_reset > 0);
826
827 env.reset(Some(42)).unwrap();
828 assert_eq!(env.global_step(), step_before_reset);
829 }
830}
831
832#[cfg(test)]
833mod pendulum_tests {
834 use super::*;
835
836 #[test]
837 fn pendulum_reset_produces_valid_obs() {
838 let env = Pendulum::new(Some(42));
839 let obs = env.obs();
840 let s = obs.as_slice();
841 assert_eq!(s.len(), 3);
842 assert!(
844 s[0] >= -1.0 && s[0] <= 1.0,
845 "cos(theta) out of range: {}",
846 s[0]
847 );
848 assert!(
849 s[1] >= -1.0 && s[1] <= 1.0,
850 "sin(theta) out of range: {}",
851 s[1]
852 );
853 assert!(s[2].abs() <= 8.0, "vel out of range: {}", s[2]);
855 }
856
857 #[test]
858 fn pendulum_step_known_state() {
859 let mut env = Pendulum::new(Some(42));
861 env.reset(Some(42)).unwrap();
862
863 let theta0 = env.theta;
865 let vel0 = env.vel;
866
867 let t = env.step(&Action::Continuous(vec![0.0])).unwrap();
869
870 let g = PENDULUM_GRAVITY;
872 let l = PENDULUM_LENGTH;
873 let dt = PENDULUM_DT;
874
875 let expected_vel = (vel0 + (3.0 * g / (2.0 * l) * theta0.sin()) * dt)
876 .clamp(-PENDULUM_MAX_VEL, PENDULUM_MAX_VEL);
877 let expected_theta = theta0 + expected_vel * dt;
878
879 assert!(
880 (env.theta - expected_theta).abs() < 1e-10,
881 "theta mismatch: got {}, expected {}",
882 env.theta,
883 expected_theta
884 );
885 assert!(
886 (env.vel - expected_vel).abs() < 1e-10,
887 "vel mismatch: got {}, expected {}",
888 env.vel,
889 expected_vel
890 );
891
892 let norm_theta = angle_normalize(theta0);
894 let expected_reward = -(norm_theta * norm_theta + 0.1 * vel0 * vel0);
895 assert!(
896 (t.reward - expected_reward).abs() < 1e-10,
897 "reward mismatch: got {}, expected {}",
898 t.reward,
899 expected_reward
900 );
901
902 assert!(!t.terminated);
903 assert!(!t.truncated);
904 }
905
906 #[test]
907 fn pendulum_step_with_torque() {
908 let mut env = Pendulum::new(Some(7));
909 env.reset(Some(7)).unwrap();
910
911 let theta0 = env.theta;
912 let vel0 = env.vel;
913 let torque = 1.5_f32;
914
915 let t = env.step(&Action::Continuous(vec![torque])).unwrap();
916
917 let g = PENDULUM_GRAVITY;
918 let m = PENDULUM_MASS;
919 let l = PENDULUM_LENGTH;
920 let dt = PENDULUM_DT;
921
922 let expected_vel = (vel0
923 + (3.0 * g / (2.0 * l) * theta0.sin() + 3.0 / (m * l * l) * torque as f64) * dt)
924 .clamp(-PENDULUM_MAX_VEL, PENDULUM_MAX_VEL);
925 let expected_theta = theta0 + expected_vel * dt;
926
927 assert!(
928 (env.theta - expected_theta).abs() < 1e-10,
929 "theta: got {}, expected {}",
930 env.theta,
931 expected_theta
932 );
933 assert!(
934 (env.vel - expected_vel).abs() < 1e-10,
935 "vel: got {}, expected {}",
936 env.vel,
937 expected_vel
938 );
939
940 let norm_theta = angle_normalize(theta0);
941 let expected_reward = -(norm_theta * norm_theta
942 + 0.1 * vel0 * vel0
943 + 0.001 * (torque as f64) * (torque as f64));
944 assert!(
945 (t.reward - expected_reward).abs() < 1e-10,
946 "reward: got {}, expected {}",
947 t.reward,
948 expected_reward
949 );
950 }
951
952 #[test]
953 fn pendulum_torque_clamped() {
954 let mut env = Pendulum::new(Some(42));
956 env.reset(Some(42)).unwrap();
957
958 let theta0 = env.theta;
959 let vel0 = env.vel;
960
961 env.step(&Action::Continuous(vec![10.0])).unwrap();
963
964 let g = PENDULUM_GRAVITY;
965 let m = PENDULUM_MASS;
966 let l = PENDULUM_LENGTH;
967 let dt = PENDULUM_DT;
968 let clamped_torque = PENDULUM_MAX_TORQUE;
969
970 let expected_vel = (vel0
971 + (3.0 * g / (2.0 * l) * theta0.sin() + 3.0 / (m * l * l) * clamped_torque) * dt)
972 .clamp(-PENDULUM_MAX_VEL, PENDULUM_MAX_VEL);
973
974 assert!(
975 (env.vel - expected_vel).abs() < 1e-10,
976 "torque clamping failed: vel={}, expected={}",
977 env.vel,
978 expected_vel
979 );
980 }
981
982 #[test]
983 fn pendulum_truncates_at_200() {
984 let mut env = Pendulum::new(Some(42));
985 env.reset(Some(42)).unwrap();
986
987 for i in 0..200 {
988 let t = env.step(&Action::Continuous(vec![0.0])).unwrap();
989 if i < 199 {
990 assert!(!t.truncated, "should not truncate at step {}", i + 1);
991 } else {
992 assert!(t.truncated, "should truncate at step 200");
993 assert!(!t.terminated);
994 }
995 }
996
997 let result = env.step(&Action::Continuous(vec![0.0]));
999 assert!(result.is_err());
1000 }
1001
1002 #[test]
1003 fn pendulum_never_terminates() {
1004 let mut env = Pendulum::new(Some(42));
1006 env.reset(Some(42)).unwrap();
1007
1008 for _ in 0..200 {
1009 let t = env.step(&Action::Continuous(vec![0.0])).unwrap();
1010 assert!(!t.terminated);
1011 }
1012 }
1013
1014 #[test]
1015 fn pendulum_observation_bounds() {
1016 let mut env = Pendulum::new(Some(42));
1017 env.reset(Some(42)).unwrap();
1018
1019 for _ in 0..200 {
1020 let t = env.step(&Action::Continuous(vec![2.0])).unwrap();
1021 let s = t.obs.as_slice();
1022 assert!(s[0] >= -1.0 && s[0] <= 1.0, "cos out of [-1,1]: {}", s[0]);
1023 assert!(s[1] >= -1.0 && s[1] <= 1.0, "sin out of [-1,1]: {}", s[1]);
1024 assert!(
1025 s[2].abs() <= PENDULUM_MAX_VEL as f32 + 1e-6,
1026 "vel out of [-8,8]: {}",
1027 s[2]
1028 );
1029 if t.truncated {
1030 break;
1031 }
1032 }
1033 }
1034
1035 #[test]
1036 fn pendulum_seeded_determinism() {
1037 let run = |seed: u64| -> Vec<f64> {
1038 let mut env = Pendulum::new(Some(seed));
1039 let mut rewards = Vec::new();
1040 for _ in 0..100 {
1041 let t = env.step(&Action::Continuous(vec![1.0])).unwrap();
1042 rewards.push(t.reward);
1043 }
1044 rewards
1045 };
1046
1047 let r1 = run(123);
1048 let r2 = run(123);
1049 assert_eq!(r1, r2);
1050
1051 let r3 = run(456);
1052 assert_ne!(r1, r3);
1053 }
1054
1055 #[test]
1056 fn pendulum_invalid_action_discrete() {
1057 let mut env = Pendulum::new(Some(42));
1058 env.reset(Some(42)).unwrap();
1059 let result = env.step(&Action::Discrete(0));
1060 assert!(result.is_err());
1061 }
1062
1063 #[test]
1064 fn pendulum_invalid_action_wrong_dim() {
1065 let mut env = Pendulum::new(Some(42));
1066 env.reset(Some(42)).unwrap();
1067 let result = env.step(&Action::Continuous(vec![1.0, 2.0]));
1068 assert!(result.is_err());
1069 }
1070
1071 #[test]
1072 fn angle_normalize_basic() {
1073 assert!((angle_normalize(0.0)).abs() < 1e-10);
1074 assert!((angle_normalize(PI) - (-PI)).abs() < 1e-10);
1076 assert!((angle_normalize(-PI) - (-PI)).abs() < 1e-10);
1077 assert!((angle_normalize(2.0 * PI)).abs() < 1e-10);
1079 assert!((angle_normalize(3.0 * PI) - (-PI)).abs() < 1e-10);
1081 }
1082}