1use std::f32::consts::PI;
37
38use rand::{Rng, SeedableRng, rngs::StdRng};
39
40use crate::env::{Environment, SpaceInfo, SpaceType, StepInfo, StepResult};
41
42const GRAVITY: f32 = 10.0;
44
45const MASS: f32 = 1.0;
47
48const LENGTH: f32 = 1.0;
50
51const DT: f32 = 0.05;
53
54const MAX_TORQUE: f32 = 2.0;
56
57const MAX_SPEED: f32 = 8.0;
59
60const DEFAULT_MAX_STEPS: usize = 200;
62
63const DEFAULT_SEED: u64 = 0;
65
66#[derive(Debug, Clone)]
75pub struct PendulumState {
76 pub theta: f32,
78 pub theta_dot: f32,
80 pub steps: usize,
82}
83
84#[derive(Debug, Clone)]
89pub struct PendulumSwingUp {
90 theta: f32,
92
93 theta_dot: f32,
95
96 steps: usize,
98
99 max_steps: usize,
101
102 rng: StdRng,
104}
105
106impl PendulumSwingUp {
107 pub fn new() -> Self {
109 Self::with_seed(DEFAULT_SEED)
110 }
111
112 pub fn with_seed(seed: u64) -> Self {
117 Self::with_seed_and_max_steps(seed, DEFAULT_MAX_STEPS)
118 }
119
120 pub fn with_seed_and_max_steps(seed: u64, max_steps: usize) -> Self {
122 let mut env = Self {
123 theta: PI,
124 theta_dot: 0.0,
125 steps: 0,
126 max_steps,
127 rng: StdRng::seed_from_u64(seed),
128 };
129 env.reset();
130 env
131 }
132
133 pub fn theta(&self) -> f32 {
135 self.theta
136 }
137
138 pub fn theta_dot(&self) -> f32 {
140 self.theta_dot
141 }
142
143 fn angle_normalize(angle: f32) -> f32 {
145 ((angle + PI).rem_euclid(2.0 * PI)) - PI
147 }
148}
149
150impl Default for PendulumSwingUp {
151 fn default() -> Self {
152 Self::new()
153 }
154}
155
156impl Environment for PendulumSwingUp {
157 type Action = Vec<f32>;
161
162 type State = PendulumState;
166
167 fn reset(&mut self) {
168 self.theta = self.rng.random_range(-PI..PI);
171 self.theta_dot = self.rng.random_range(-1.0..1.0);
172 self.steps = 0;
173 }
174
175 fn get_observation(&self) -> Vec<f32> {
176 vec![self.theta.cos(), self.theta.sin(), self.theta_dot]
177 }
178
179 fn step(&mut self, action: Vec<f32>) -> StepResult {
180 let raw = action.first().copied().unwrap_or(0.0);
183 let torque = raw.clamp(-MAX_TORQUE, MAX_TORQUE);
184
185 let theta_norm = Self::angle_normalize(self.theta);
187 let cost = theta_norm * theta_norm
188 + 0.1 * self.theta_dot * self.theta_dot
189 + 0.001 * torque * torque;
190
191 let theta_dot_dot = (3.0 * GRAVITY / (2.0 * LENGTH)) * self.theta.sin()
194 + (3.0 / (MASS * LENGTH * LENGTH)) * torque;
195
196 self.theta_dot = (self.theta_dot + theta_dot_dot * DT).clamp(-MAX_SPEED, MAX_SPEED);
197 self.theta += self.theta_dot * DT;
198
199 self.steps += 1;
200 let truncated = self.steps >= self.max_steps;
201
202 StepResult {
203 observation: self.get_observation(),
204 reward: -cost,
205 terminated: false,
206 truncated,
207 info: StepInfo::default(),
208 }
209 }
210
211 fn observation_space(&self) -> SpaceInfo {
212 SpaceInfo { shape: vec![3], space_type: SpaceType::Box }
214 }
215
216 fn action_space(&self) -> SpaceInfo {
217 SpaceInfo { shape: vec![1], space_type: SpaceType::Box }
219 }
220
221 fn render(&self) -> Vec<u8> {
222 Vec::new()
223 }
224
225 fn close(&mut self) {}
226
227 fn clone_state(&self) -> PendulumState {
228 PendulumState { theta: self.theta, theta_dot: self.theta_dot, steps: self.steps }
229 }
230
231 fn restore_state(&mut self, state: &PendulumState) {
232 self.theta = state.theta;
233 self.theta_dot = state.theta_dot;
234 self.steps = state.steps;
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn observation_is_length_three_and_unit_circle() {
244 let mut env = PendulumSwingUp::new();
245 env.reset();
246
247 let obs = env.get_observation();
248 assert_eq!(obs.len(), 3, "observation must be 3-dimensional");
249
250 let unit = obs[0] * obs[0] + obs[1] * obs[1];
252 assert!((unit - 1.0).abs() < 1e-5, "cos^2 + sin^2 should equal 1, got {unit}");
253
254 let result = env.step(vec![0.5]);
256 assert_eq!(result.observation.len(), 3);
257 let unit2 = result.observation[0] * result.observation[0]
258 + result.observation[1] * result.observation[1];
259 assert!((unit2 - 1.0).abs() < 1e-5, "cos^2 + sin^2 should equal 1, got {unit2}");
260 }
261
262 #[test]
263 fn torque_is_clamped() {
264 let mut env = PendulumSwingUp::new();
268 env.restore_state(&PendulumState { theta: 0.0, theta_dot: 0.0, steps: 0 });
269
270 env.step(vec![1000.0]);
271 let max_dot = (3.0 / (MASS * LENGTH * LENGTH)) * MAX_TORQUE * DT;
272 assert!(
273 env.theta_dot() <= max_dot + 1e-6,
274 "velocity change should reflect clamped torque, got {}",
275 env.theta_dot()
276 );
277
278 let mut env2 = PendulumSwingUp::new();
280 env2.restore_state(&PendulumState { theta: 0.0, theta_dot: 0.0, steps: 0 });
281 env2.step(vec![-1000.0]);
282 assert!(env2.theta_dot() >= -max_dot - 1e-6);
283 }
284
285 #[test]
286 fn reward_is_nonpositive_and_zero_only_at_upright_rest() {
287 let mut env = PendulumSwingUp::new();
288
289 env.restore_state(&PendulumState { theta: 0.0, theta_dot: 0.0, steps: 0 });
292 let r = env.step(vec![0.0]);
293 assert!(r.reward.abs() < 1e-6, "upright rest with no torque should give reward 0");
294
295 env.restore_state(&PendulumState { theta: 1.0, theta_dot: 0.0, steps: 0 });
298 let r = env.step(vec![0.0]);
299 assert!(r.reward < 0.0, "off-upright should give negative reward");
300
301 env.restore_state(&PendulumState { theta: 0.0, theta_dot: 0.0, steps: 0 });
302 let r = env.step(vec![1.0]);
303 assert!(r.reward < 0.0, "control effort should give negative reward");
304
305 for &theta in &[-PI, -1.5, -0.3, 0.0, 0.7, 2.0, PI] {
307 for &dot in &[-8.0, -1.0, 0.0, 3.0, 8.0] {
308 for &u in &[-2.0, 0.0, 1.5] {
309 env.restore_state(&PendulumState { theta, theta_dot: dot, steps: 0 });
310 let r = env.step(vec![u]);
311 assert!(
312 r.reward <= 0.0,
313 "reward must be <= 0 (theta={theta}, dot={dot}, u={u})"
314 );
315 }
316 }
317 }
318 }
319
320 #[test]
321 fn truncates_after_max_steps() {
322 let mut env = PendulumSwingUp::new();
323 env.reset();
324
325 for i in 0..(DEFAULT_MAX_STEPS - 1) {
326 let r = env.step(vec![0.0]);
327 assert!(!r.truncated, "should not truncate before max_steps (step {i})");
328 assert!(!r.terminated, "pendulum never terminates");
329 }
330
331 let r = env.step(vec![0.0]);
332 assert!(r.truncated, "episode should truncate after {DEFAULT_MAX_STEPS} steps");
333 assert!(!r.terminated, "truncation is not termination");
334 }
335
336 #[test]
337 fn clone_restore_round_trips_next_step() {
338 let mut env = PendulumSwingUp::new();
339 env.reset();
340 env.step(vec![0.3]);
342 env.step(vec![-0.7]);
343
344 let snapshot = env.clone_state();
345 let result_a = env.step(vec![1.1]);
346
347 env.restore_state(&snapshot);
349 let result_b = env.step(vec![1.1]);
350
351 assert_eq!(result_a.observation, result_b.observation, "obs must reproduce bit-for-bit");
352 assert_eq!(result_a.reward, result_b.reward, "reward must reproduce bit-for-bit");
353 assert_eq!(result_a.truncated, result_b.truncated);
354 assert_eq!(result_a.terminated, result_b.terminated);
355 }
356
357 #[test]
358 fn seeded_reset_is_reproducible() {
359 let mut a = PendulumSwingUp::with_seed(42);
360 let mut b = PendulumSwingUp::with_seed(42);
361 a.reset();
362 b.reset();
363 assert_eq!(a.get_observation(), b.get_observation(), "same seed -> same initial obs");
364
365 let mut c = PendulumSwingUp::with_seed(7);
367 c.reset();
368 assert_ne!(
369 a.get_observation(),
370 c.get_observation(),
371 "different seeds should give different initial states"
372 );
373
374 a.reset();
376 b.reset();
377 for _ in 0..10 {
378 let ra = a.step(vec![0.5]);
379 let rb = b.step(vec![0.5]);
380 assert_eq!(ra.observation, rb.observation);
381 assert_eq!(ra.reward, rb.reward);
382 }
383 }
384
385 #[test]
386 fn action_space_is_box() {
387 let env = PendulumSwingUp::new();
388 let space = env.action_space();
389 assert_eq!(space.shape, vec![1]);
390 assert!(matches!(space.space_type, SpaceType::Box));
391 }
392
393 #[test]
394 fn observation_space_is_box() {
395 let env = PendulumSwingUp::new();
396 let space = env.observation_space();
397 assert_eq!(space.shape, vec![3]);
398 assert!(matches!(space.space_type, SpaceType::Box));
399 }
400
401 #[test]
402 fn empty_action_treated_as_zero() {
403 let mut env = PendulumSwingUp::new();
404 env.restore_state(&PendulumState { theta: 0.0, theta_dot: 0.0, steps: 0 });
405 let r = env.step(Vec::new());
407 assert!(r.reward.abs() < 1e-6, "empty action at upright rest behaves like zero torque");
408 }
409}