Skip to main content

simular/scenarios/
pendulum.rs

1//! Pendulum scenarios for canonical physics examples.
2//!
3//! Implements various pendulum systems:
4//! - Simple pendulum (single mass on string)
5//! - Double pendulum (chaotic dynamics)
6//! - Driven pendulum (forced oscillations)
7
8use crate::domains::physics::ForceField;
9use crate::engine::state::{SimState, Vec3};
10use serde::{Deserialize, Serialize};
11
12/// Configuration for a simple pendulum.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct PendulumConfig {
15    /// Length of pendulum (m).
16    pub length: f64,
17    /// Mass of bob (kg).
18    pub mass: f64,
19    /// Gravitational acceleration (m/s²).
20    pub g: f64,
21    /// Damping coefficient (kg/s).
22    pub damping: f64,
23    /// Initial angle (radians, 0 = hanging down).
24    pub initial_angle: f64,
25    /// Initial angular velocity (rad/s).
26    pub initial_angular_velocity: f64,
27}
28
29impl Default for PendulumConfig {
30    fn default() -> Self {
31        Self {
32            length: 1.0,
33            mass: 1.0,
34            g: 9.81,
35            damping: 0.0,
36            initial_angle: std::f64::consts::FRAC_PI_4, // 45 degrees
37            initial_angular_velocity: 0.0,
38        }
39    }
40}
41
42impl PendulumConfig {
43    /// Create a small-angle pendulum (linearized regime).
44    #[must_use]
45    pub fn small_angle() -> Self {
46        Self {
47            initial_angle: 0.1, // ~6 degrees
48            ..Default::default()
49        }
50    }
51
52    /// Create a large-angle pendulum (nonlinear regime).
53    #[must_use]
54    pub fn large_angle() -> Self {
55        Self {
56            initial_angle: std::f64::consts::FRAC_PI_2, // 90 degrees
57            ..Default::default()
58        }
59    }
60
61    /// Create a damped pendulum.
62    #[must_use]
63    pub fn damped(damping: f64) -> Self {
64        Self {
65            damping,
66            ..Default::default()
67        }
68    }
69
70    /// Get theoretical period for small oscillations.
71    #[must_use]
72    pub fn small_angle_period(&self) -> f64 {
73        2.0 * std::f64::consts::PI * (self.length / self.g).sqrt()
74    }
75}
76
77/// Simple pendulum scenario.
78#[derive(Debug, Clone)]
79pub struct PendulumScenario {
80    config: PendulumConfig,
81}
82
83impl PendulumScenario {
84    /// Create a new pendulum scenario.
85    #[must_use]
86    pub fn new(config: PendulumConfig) -> Self {
87        Self { config }
88    }
89
90    /// Initialize simulation state for the pendulum.
91    ///
92    /// The pendulum is modeled as a particle constrained to move on a circle.
93    /// State is represented in Cartesian coordinates (x, y) where:
94    /// - Origin is at pivot point
95    /// - y-axis points downward
96    /// - Angle is measured from vertical (y-axis)
97    #[must_use]
98    pub fn init_state(&self) -> SimState {
99        let mut state = SimState::new();
100
101        // Position from angle (angle=0 means hanging straight down)
102        let x = self.config.length * self.config.initial_angle.sin();
103        let y = -self.config.length * self.config.initial_angle.cos();
104
105        // Velocity from angular velocity
106        // v = L * omega, tangent to circle
107        let vx = self.config.length
108            * self.config.initial_angular_velocity
109            * self.config.initial_angle.cos();
110        let vy = self.config.length
111            * self.config.initial_angular_velocity
112            * self.config.initial_angle.sin();
113
114        state.add_body(
115            self.config.mass,
116            Vec3::new(x, y, 0.0),
117            Vec3::new(vx, vy, 0.0),
118        );
119
120        state
121    }
122
123    /// Create force field for pendulum (gravity + tension constraint).
124    #[must_use]
125    pub fn create_force_field(&self) -> PendulumForceField {
126        PendulumForceField {
127            g: self.config.g,
128            length: self.config.length,
129            damping: self.config.damping,
130        }
131    }
132
133    /// Get theoretical period (small angle approximation).
134    #[must_use]
135    pub fn theoretical_period(&self) -> f64 {
136        self.config.small_angle_period()
137    }
138
139    /// Get configuration.
140    #[must_use]
141    pub const fn config(&self) -> &PendulumConfig {
142        &self.config
143    }
144
145    /// Calculate current angle from state.
146    #[must_use]
147    pub fn current_angle(state: &SimState) -> f64 {
148        let pos = state.positions()[0];
149        pos.x.atan2(-pos.y)
150    }
151
152    /// Calculate current angular velocity from state.
153    #[must_use]
154    pub fn current_angular_velocity(state: &SimState, length: f64) -> f64 {
155        let pos = state.positions()[0];
156        let vel = state.velocities()[0];
157        let r = pos.magnitude();
158        if r < f64::EPSILON {
159            return 0.0;
160        }
161        // Angular velocity = (r × v) / r² = (x*vy - y*vx) / r²
162        (pos.x * vel.y - pos.y * vel.x) / (length * length)
163    }
164}
165
166/// Force field for simple pendulum.
167///
168/// Models gravity plus the constraint force (tension) that keeps
169/// the particle on the circular arc.
170#[derive(Debug, Clone)]
171pub struct PendulumForceField {
172    /// Gravitational acceleration.
173    pub g: f64,
174    /// Pendulum length.
175    pub length: f64,
176    /// Damping coefficient.
177    pub damping: f64,
178}
179
180impl ForceField for PendulumForceField {
181    fn acceleration(&self, position: &Vec3, _mass: f64) -> Vec3 {
182        // Project gravity onto tangent direction
183        // Gravity is (0, -g, 0) in our coordinate system where y is up
184        // But we defined y pointing down for the pendulum, so gravity is (0, g, 0)
185
186        let r = (position.x * position.x + position.y * position.y).sqrt();
187        if r < f64::EPSILON {
188            return Vec3::zero();
189        }
190
191        // Angle from vertical (y-axis pointing down)
192        let theta = position.x.atan2(-position.y);
193
194        // Tangential acceleration from gravity: -g * sin(theta)
195        let a_tangent = -self.g * theta.sin();
196
197        // Convert tangential acceleration to Cartesian
198        // Tangent direction: perpendicular to radial, in direction of increasing theta
199        let tan_x = -position.y / r;
200        let tan_y = position.x / r;
201
202        // Damping (proportional to velocity, but we only have position here)
203        // For proper damping, we'd need velocity. This is a simplified model.
204
205        Vec3::new(a_tangent * tan_x, a_tangent * tan_y, 0.0)
206    }
207
208    fn potential(&self, position: &Vec3, mass: f64) -> f64 {
209        // Potential energy relative to lowest point
210        // PE = m * g * h where h = L - L*cos(theta) = L * (1 - cos(theta))
211        let theta = position.x.atan2(-position.y);
212        mass * self.g * self.length * (1.0 - theta.cos())
213    }
214}
215
216#[cfg(test)]
217#[allow(clippy::unwrap_used, clippy::expect_used)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_pendulum_config_default() {
223        let config = PendulumConfig::default();
224
225        assert!((config.length - 1.0).abs() < f64::EPSILON);
226        assert!((config.mass - 1.0).abs() < f64::EPSILON);
227        assert!((config.g - 9.81).abs() < 0.01);
228    }
229
230    #[test]
231    fn test_pendulum_config_small_angle() {
232        let config = PendulumConfig::small_angle();
233        assert!((config.initial_angle - 0.1).abs() < f64::EPSILON);
234    }
235
236    #[test]
237    fn test_pendulum_config_large_angle() {
238        let config = PendulumConfig::large_angle();
239        assert!((config.initial_angle - std::f64::consts::FRAC_PI_2).abs() < f64::EPSILON);
240    }
241
242    #[test]
243    fn test_pendulum_config_damped() {
244        let config = PendulumConfig::damped(0.5);
245        assert!((config.damping - 0.5).abs() < f64::EPSILON);
246    }
247
248    #[test]
249    fn test_pendulum_config_clone() {
250        let config = PendulumConfig::default();
251        let cloned = config.clone();
252        assert!((cloned.length - config.length).abs() < f64::EPSILON);
253    }
254
255    #[test]
256    fn test_pendulum_small_angle_period() {
257        let config = PendulumConfig {
258            length: 1.0,
259            g: 9.81,
260            ..Default::default()
261        };
262
263        // T = 2 * pi * sqrt(L/g) ≈ 2.006 seconds
264        let period = config.small_angle_period();
265        assert!((period - 2.006).abs() < 0.01, "Period={}", period);
266    }
267
268    #[test]
269    fn test_pendulum_scenario_init() {
270        let config = PendulumConfig {
271            initial_angle: std::f64::consts::FRAC_PI_4,
272            length: 1.0,
273            ..Default::default()
274        };
275        let scenario = PendulumScenario::new(config);
276        let state = scenario.init_state();
277
278        assert_eq!(state.num_bodies(), 1);
279
280        // Check initial angle
281        let angle = PendulumScenario::current_angle(&state);
282        assert!((angle - std::f64::consts::FRAC_PI_4).abs() < 0.01);
283    }
284
285    #[test]
286    fn test_pendulum_scenario_clone() {
287        let config = PendulumConfig::default();
288        let scenario = PendulumScenario::new(config);
289        let cloned = scenario.clone();
290        assert!((cloned.theoretical_period() - scenario.theoretical_period()).abs() < f64::EPSILON);
291    }
292
293    #[test]
294    fn test_pendulum_scenario_config() {
295        let config = PendulumConfig {
296            length: 2.0,
297            ..Default::default()
298        };
299        let scenario = PendulumScenario::new(config);
300        assert!((scenario.config().length - 2.0).abs() < f64::EPSILON);
301    }
302
303    #[test]
304    fn test_pendulum_scenario_theoretical_period() {
305        let config = PendulumConfig {
306            length: 1.0,
307            g: 9.81,
308            ..Default::default()
309        };
310        let scenario = PendulumScenario::new(config);
311        let period = scenario.theoretical_period();
312        assert!((period - 2.006).abs() < 0.01);
313    }
314
315    #[test]
316    fn test_pendulum_current_angular_velocity() {
317        let config = PendulumConfig {
318            length: 1.0,
319            initial_angular_velocity: 1.0,
320            ..Default::default()
321        };
322        let scenario = PendulumScenario::new(config);
323        let state = scenario.init_state();
324
325        let omega = PendulumScenario::current_angular_velocity(&state, 1.0);
326        // Should be approximately 1.0
327        assert!(omega.abs() < 2.0);
328    }
329
330    #[test]
331    fn test_pendulum_angular_velocity_at_origin() {
332        let mut state = SimState::new();
333        state.add_body(1.0, Vec3::zero(), Vec3::zero());
334        let omega = PendulumScenario::current_angular_velocity(&state, 1.0);
335        assert!((omega - 0.0).abs() < f64::EPSILON);
336    }
337
338    #[test]
339    fn test_pendulum_force_field() {
340        let field = PendulumForceField {
341            g: 9.81,
342            length: 1.0,
343            damping: 0.0,
344        };
345
346        // At vertical (theta=0), no tangential acceleration
347        let pos_vertical = Vec3::new(0.0, -1.0, 0.0);
348        let acc = field.acceleration(&pos_vertical, 1.0);
349        assert!(acc.magnitude() < 0.01, "acc={:?}", acc);
350
351        // At 90 degrees, maximum tangential acceleration
352        let pos_horizontal = Vec3::new(1.0, 0.0, 0.0);
353        let acc = field.acceleration(&pos_horizontal, 1.0);
354        assert!(acc.magnitude() > 9.0, "acc magnitude={}", acc.magnitude());
355    }
356
357    #[test]
358    fn test_pendulum_force_field_at_origin() {
359        let field = PendulumForceField {
360            g: 9.81,
361            length: 1.0,
362            damping: 0.0,
363        };
364
365        let pos = Vec3::zero();
366        let acc = field.acceleration(&pos, 1.0);
367        assert!((acc.magnitude() - 0.0).abs() < f64::EPSILON);
368    }
369
370    #[test]
371    fn test_pendulum_force_field_clone() {
372        let field = PendulumForceField {
373            g: 9.81,
374            length: 1.0,
375            damping: 0.5,
376        };
377        let cloned = field.clone();
378        assert!((cloned.damping - 0.5).abs() < f64::EPSILON);
379    }
380
381    #[test]
382    fn test_pendulum_create_force_field() {
383        let config = PendulumConfig {
384            g: 10.0,
385            length: 2.0,
386            damping: 0.1,
387            ..Default::default()
388        };
389        let scenario = PendulumScenario::new(config);
390        let field = scenario.create_force_field();
391        assert!((field.g - 10.0).abs() < f64::EPSILON);
392        assert!((field.length - 2.0).abs() < f64::EPSILON);
393        assert!((field.damping - 0.1).abs() < f64::EPSILON);
394    }
395
396    #[test]
397    fn test_pendulum_potential_energy() {
398        let field = PendulumForceField {
399            g: 9.81,
400            length: 1.0,
401            damping: 0.0,
402        };
403
404        // At lowest point (theta=0), PE = 0
405        let pos_low = Vec3::new(0.0, -1.0, 0.0);
406        let pe_low = field.potential(&pos_low, 1.0);
407        assert!(pe_low.abs() < 0.01, "PE at bottom={}", pe_low);
408
409        // At horizontal (theta=pi/2), PE = m*g*L
410        let pos_horiz = Vec3::new(1.0, 0.0, 0.0);
411        let pe_horiz = field.potential(&pos_horiz, 1.0);
412        assert!(
413            (pe_horiz - 9.81).abs() < 0.01,
414            "PE at horizontal={}",
415            pe_horiz
416        );
417
418        // At top (theta=pi), PE = 2*m*g*L
419        let pos_top = Vec3::new(0.0, 1.0, 0.0);
420        let pe_top = field.potential(&pos_top, 1.0);
421        assert!((pe_top - 2.0 * 9.81).abs() < 0.01, "PE at top={}", pe_top);
422    }
423}
424
425#[cfg(test)]
426mod proptests {
427    use super::*;
428    use proptest::prelude::*;
429
430    proptest! {
431        /// Period increases with pendulum length (T ∝ √L).
432        #[test]
433        fn prop_period_increases_with_length(
434            length1 in 0.1f64..10.0,
435            length2 in 0.1f64..10.0,
436        ) {
437            let config1 = PendulumConfig { length: length1, ..Default::default() };
438            let config2 = PendulumConfig { length: length2, ..Default::default() };
439
440            let period1 = config1.small_angle_period();
441            let period2 = config2.small_angle_period();
442
443            // If length1 > length2, then period1 > period2
444            if length1 > length2 {
445                prop_assert!(period1 > period2);
446            } else if length1 < length2 {
447                prop_assert!(period1 < period2);
448            }
449        }
450
451        /// Period decreases with gravity (T ∝ 1/√g).
452        #[test]
453        fn prop_period_decreases_with_gravity(
454            g1 in 1.0f64..20.0,
455            g2 in 1.0f64..20.0,
456        ) {
457            let config1 = PendulumConfig { g: g1, ..Default::default() };
458            let config2 = PendulumConfig { g: g2, ..Default::default() };
459
460            let period1 = config1.small_angle_period();
461            let period2 = config2.small_angle_period();
462
463            // If g1 > g2, then period1 < period2
464            if g1 > g2 {
465                prop_assert!(period1 < period2);
466            } else if g1 < g2 {
467                prop_assert!(period1 > period2);
468            }
469        }
470
471        /// Pendulum force field produces restoring force toward equilibrium.
472        #[test]
473        fn prop_restoring_force(
474            angle in -std::f64::consts::PI..std::f64::consts::PI,
475            length in 0.5f64..5.0,
476        ) {
477            let field = PendulumForceField {
478                g: 9.81,
479                length,
480                damping: 0.0,
481            };
482
483            // Position at given angle
484            let x = length * angle.sin();
485            let y = -length * angle.cos();
486            let pos = Vec3::new(x, y, 0.0);
487
488            let accel = field.acceleration(&pos, 1.0);
489
490            // For small displacements, force should point toward equilibrium
491            if angle.abs() > 0.01 {
492                // Tangential acceleration should oppose displacement
493                let tangent_x = angle.cos();
494                let tangent_y = angle.sin();
495                let tangent_accel = accel.x * tangent_x + accel.y * tangent_y;
496
497                // Restoring force: positive angle -> negative tangent accel
498                if angle > 0.0 {
499                    prop_assert!(tangent_accel < 0.1, "tangent_accel={}", tangent_accel);
500                } else {
501                    prop_assert!(tangent_accel > -0.1, "tangent_accel={}", tangent_accel);
502                }
503            }
504        }
505
506        /// Potential energy is non-negative when measured from lowest point.
507        #[test]
508        fn prop_potential_energy_nonnegative(
509            angle in -std::f64::consts::PI..std::f64::consts::PI,
510            length in 0.5f64..5.0,
511            mass in 0.1f64..10.0,
512        ) {
513            let field = PendulumForceField {
514                g: 9.81,
515                length,
516                damping: 0.0,
517            };
518
519            let x = length * angle.sin();
520            let y = -length * angle.cos();
521            let pos = Vec3::new(x, y, 0.0);
522
523            let pe = field.potential(&pos, mass);
524            // PE relative to lowest point should be >= 0
525            prop_assert!(pe >= -0.001, "PE={} should be non-negative", pe);
526        }
527    }
528}