use std::f32::consts::PI;
use rand::{Rng, SeedableRng, rngs::StdRng};
use crate::env::{Environment, SpaceInfo, SpaceType, StepInfo, StepResult};
const GRAVITY: f32 = 10.0;
const MASS: f32 = 1.0;
const LENGTH: f32 = 1.0;
const DT: f32 = 0.05;
const MAX_TORQUE: f32 = 2.0;
const MAX_SPEED: f32 = 8.0;
const DEFAULT_MAX_STEPS: usize = 200;
const DEFAULT_SEED: u64 = 0;
#[derive(Debug, Clone)]
pub struct PendulumState {
pub theta: f32,
pub theta_dot: f32,
pub steps: usize,
}
#[derive(Debug, Clone)]
pub struct PendulumSwingUp {
theta: f32,
theta_dot: f32,
steps: usize,
max_steps: usize,
rng: StdRng,
}
impl PendulumSwingUp {
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 {
theta: PI,
theta_dot: 0.0,
steps: 0,
max_steps,
rng: StdRng::seed_from_u64(seed),
};
env.reset();
env
}
pub fn theta(&self) -> f32 {
self.theta
}
pub fn theta_dot(&self) -> f32 {
self.theta_dot
}
fn angle_normalize(angle: f32) -> f32 {
((angle + PI).rem_euclid(2.0 * PI)) - PI
}
}
impl Default for PendulumSwingUp {
fn default() -> Self {
Self::new()
}
}
impl Environment for PendulumSwingUp {
type Action = Vec<f32>;
type State = PendulumState;
fn reset(&mut self) {
self.theta = self.rng.random_range(-PI..PI);
self.theta_dot = self.rng.random_range(-1.0..1.0);
self.steps = 0;
}
fn get_observation(&self) -> Vec<f32> {
vec![self.theta.cos(), self.theta.sin(), self.theta_dot]
}
fn step(&mut self, action: Vec<f32>) -> StepResult {
let raw = action.first().copied().unwrap_or(0.0);
let torque = raw.clamp(-MAX_TORQUE, MAX_TORQUE);
let theta_norm = Self::angle_normalize(self.theta);
let cost = theta_norm * theta_norm
+ 0.1 * self.theta_dot * self.theta_dot
+ 0.001 * torque * torque;
let theta_dot_dot = (3.0 * GRAVITY / (2.0 * LENGTH)) * self.theta.sin()
+ (3.0 / (MASS * LENGTH * LENGTH)) * torque;
self.theta_dot = (self.theta_dot + theta_dot_dot * DT).clamp(-MAX_SPEED, MAX_SPEED);
self.theta += self.theta_dot * DT;
self.steps += 1;
let truncated = self.steps >= self.max_steps;
StepResult {
observation: self.get_observation(),
reward: -cost,
terminated: false,
truncated,
info: StepInfo::default(),
}
}
fn observation_space(&self) -> SpaceInfo {
SpaceInfo { shape: vec![3], 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) -> PendulumState {
PendulumState { theta: self.theta, theta_dot: self.theta_dot, steps: self.steps }
}
fn restore_state(&mut self, state: &PendulumState) {
self.theta = state.theta;
self.theta_dot = state.theta_dot;
self.steps = state.steps;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn observation_is_length_three_and_unit_circle() {
let mut env = PendulumSwingUp::new();
env.reset();
let obs = env.get_observation();
assert_eq!(obs.len(), 3, "observation must be 3-dimensional");
let unit = obs[0] * obs[0] + obs[1] * obs[1];
assert!((unit - 1.0).abs() < 1e-5, "cos^2 + sin^2 should equal 1, got {unit}");
let result = env.step(vec![0.5]);
assert_eq!(result.observation.len(), 3);
let unit2 = result.observation[0] * result.observation[0]
+ result.observation[1] * result.observation[1];
assert!((unit2 - 1.0).abs() < 1e-5, "cos^2 + sin^2 should equal 1, got {unit2}");
}
#[test]
fn torque_is_clamped() {
let mut env = PendulumSwingUp::new();
env.restore_state(&PendulumState { theta: 0.0, theta_dot: 0.0, steps: 0 });
env.step(vec![1000.0]);
let max_dot = (3.0 / (MASS * LENGTH * LENGTH)) * MAX_TORQUE * DT;
assert!(
env.theta_dot() <= max_dot + 1e-6,
"velocity change should reflect clamped torque, got {}",
env.theta_dot()
);
let mut env2 = PendulumSwingUp::new();
env2.restore_state(&PendulumState { theta: 0.0, theta_dot: 0.0, steps: 0 });
env2.step(vec![-1000.0]);
assert!(env2.theta_dot() >= -max_dot - 1e-6);
}
#[test]
fn reward_is_nonpositive_and_zero_only_at_upright_rest() {
let mut env = PendulumSwingUp::new();
env.restore_state(&PendulumState { theta: 0.0, theta_dot: 0.0, steps: 0 });
let r = env.step(vec![0.0]);
assert!(r.reward.abs() < 1e-6, "upright rest with no torque should give reward 0");
env.restore_state(&PendulumState { theta: 1.0, theta_dot: 0.0, steps: 0 });
let r = env.step(vec![0.0]);
assert!(r.reward < 0.0, "off-upright should give negative reward");
env.restore_state(&PendulumState { theta: 0.0, theta_dot: 0.0, steps: 0 });
let r = env.step(vec![1.0]);
assert!(r.reward < 0.0, "control effort should give negative reward");
for &theta in &[-PI, -1.5, -0.3, 0.0, 0.7, 2.0, PI] {
for &dot in &[-8.0, -1.0, 0.0, 3.0, 8.0] {
for &u in &[-2.0, 0.0, 1.5] {
env.restore_state(&PendulumState { theta, theta_dot: dot, steps: 0 });
let r = env.step(vec![u]);
assert!(
r.reward <= 0.0,
"reward must be <= 0 (theta={theta}, dot={dot}, u={u})"
);
}
}
}
}
#[test]
fn truncates_after_max_steps() {
let mut env = PendulumSwingUp::new();
env.reset();
for i in 0..(DEFAULT_MAX_STEPS - 1) {
let r = env.step(vec![0.0]);
assert!(!r.truncated, "should not truncate before max_steps (step {i})");
assert!(!r.terminated, "pendulum never terminates");
}
let r = env.step(vec![0.0]);
assert!(r.truncated, "episode should truncate after {DEFAULT_MAX_STEPS} steps");
assert!(!r.terminated, "truncation is not termination");
}
#[test]
fn clone_restore_round_trips_next_step() {
let mut env = PendulumSwingUp::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.1]);
env.restore_state(&snapshot);
let result_b = env.step(vec![1.1]);
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 = PendulumSwingUp::with_seed(42);
let mut b = PendulumSwingUp::with_seed(42);
a.reset();
b.reset();
assert_eq!(a.get_observation(), b.get_observation(), "same seed -> same initial obs");
let mut c = PendulumSwingUp::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..10 {
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 = PendulumSwingUp::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 = PendulumSwingUp::new();
let space = env.observation_space();
assert_eq!(space.shape, vec![3]);
assert!(matches!(space.space_type, SpaceType::Box));
}
#[test]
fn empty_action_treated_as_zero() {
let mut env = PendulumSwingUp::new();
env.restore_state(&PendulumState { theta: 0.0, theta_dot: 0.0, steps: 0 });
let r = env.step(Vec::new());
assert!(r.reward.abs() < 1e-6, "empty action at upright rest behaves like zero torque");
}
}