1use crate::domains::physics::ForceField;
9use crate::engine::state::{SimState, Vec3};
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct PendulumConfig {
15 pub length: f64,
17 pub mass: f64,
19 pub g: f64,
21 pub damping: f64,
23 pub initial_angle: f64,
25 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, initial_angular_velocity: 0.0,
38 }
39 }
40}
41
42impl PendulumConfig {
43 #[must_use]
45 pub fn small_angle() -> Self {
46 Self {
47 initial_angle: 0.1, ..Default::default()
49 }
50 }
51
52 #[must_use]
54 pub fn large_angle() -> Self {
55 Self {
56 initial_angle: std::f64::consts::FRAC_PI_2, ..Default::default()
58 }
59 }
60
61 #[must_use]
63 pub fn damped(damping: f64) -> Self {
64 Self {
65 damping,
66 ..Default::default()
67 }
68 }
69
70 #[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#[derive(Debug, Clone)]
79pub struct PendulumScenario {
80 config: PendulumConfig,
81}
82
83impl PendulumScenario {
84 #[must_use]
86 pub fn new(config: PendulumConfig) -> Self {
87 Self { config }
88 }
89
90 #[must_use]
98 pub fn init_state(&self) -> SimState {
99 let mut state = SimState::new();
100
101 let x = self.config.length * self.config.initial_angle.sin();
103 let y = -self.config.length * self.config.initial_angle.cos();
104
105 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 #[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 #[must_use]
135 pub fn theoretical_period(&self) -> f64 {
136 self.config.small_angle_period()
137 }
138
139 #[must_use]
141 pub const fn config(&self) -> &PendulumConfig {
142 &self.config
143 }
144
145 #[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 #[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 (pos.x * vel.y - pos.y * vel.x) / (length * length)
163 }
164}
165
166#[derive(Debug, Clone)]
171pub struct PendulumForceField {
172 pub g: f64,
174 pub length: f64,
176 pub damping: f64,
178}
179
180impl ForceField for PendulumForceField {
181 fn acceleration(&self, position: &Vec3, _mass: f64) -> Vec3 {
182 let r = (position.x * position.x + position.y * position.y).sqrt();
187 if r < f64::EPSILON {
188 return Vec3::zero();
189 }
190
191 let theta = position.x.atan2(-position.y);
193
194 let a_tangent = -self.g * theta.sin();
196
197 let tan_x = -position.y / r;
200 let tan_y = position.x / r;
201
202 Vec3::new(a_tangent * tan_x, a_tangent * tan_y, 0.0)
206 }
207
208 fn potential(&self, position: &Vec3, mass: f64) -> f64 {
209 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 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 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 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 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 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 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 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 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 #[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 {
445 prop_assert!(period1 > period2);
446 } else if length1 < length2 {
447 prop_assert!(period1 < period2);
448 }
449 }
450
451 #[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 {
465 prop_assert!(period1 < period2);
466 } else if g1 < g2 {
467 prop_assert!(period1 > period2);
468 }
469 }
470
471 #[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 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 if angle.abs() > 0.01 {
492 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 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 #[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 prop_assert!(pe >= -0.001, "PE={} should be non-negative", pe);
526 }
527 }
528}