Skip to main content

scirs2_core/physics/
classical.rs

1//! Classical mechanics: kinematics, dynamics, projectile motion, and oscillators.
2//!
3//! All functions operate in SI units unless otherwise stated.
4//!
5//! # Reference
6//!
7//! * Goldstein, Poole & Safko — *Classical Mechanics* (3rd ed.)
8//! * Kibble & Berkshire — *Classical Mechanics* (5th ed.)
9
10use std::f64::consts::PI;
11
12use super::error::{PhysicsError, PhysicsResult};
13
14// ─── Result types ────────────────────────────────────────────────────────────
15
16/// Result of a projectile motion calculation.
17#[derive(Debug, Clone, PartialEq)]
18pub struct ProjectileResult {
19    /// Maximum height above the launch point (m).
20    pub max_height: f64,
21    /// Horizontal range (m) — distance from launch to landing on level ground.
22    pub range: f64,
23    /// Total time of flight (s).
24    pub time_of_flight: f64,
25    /// Horizontal velocity component (m/s).
26    pub vx: f64,
27    /// Initial vertical velocity component (m/s).
28    pub vy0: f64,
29}
30
31/// Result of a simple harmonic oscillator characterisation.
32#[derive(Debug, Clone, PartialEq)]
33pub struct OscillatorResult {
34    /// Angular frequency ω (rad/s).
35    pub angular_frequency: f64,
36    /// Period T (s).
37    pub period: f64,
38    /// Frequency f (Hz).
39    pub frequency: f64,
40}
41
42// ─── Kinematic quantities ────────────────────────────────────────────────────
43
44/// Kinetic energy of a non-relativistic particle.
45///
46/// `KE = ½mv²`
47///
48/// # Arguments
49///
50/// * `mass`     – mass in kg (must be > 0)
51/// * `velocity` – speed in m/s
52///
53/// # Errors
54///
55/// Returns [`PhysicsError::InvalidParameter`] if `mass` is not positive.
56pub fn kinetic_energy(mass: f64, velocity: f64) -> PhysicsResult<f64> {
57    if mass <= 0.0 {
58        return Err(PhysicsError::InvalidParameter {
59            param: "mass",
60            reason: format!("mass must be positive, got {mass}"),
61        });
62    }
63    Ok(0.5 * mass * velocity * velocity)
64}
65
66/// Gravitational potential energy near Earth's surface (or any uniform field).
67///
68/// `PE = mgh`
69///
70/// # Arguments
71///
72/// * `mass`   – mass in kg (must be > 0)
73/// * `height` – height above the reference level in m
74/// * `g`      – gravitational acceleration in m/s² (must be > 0)
75///
76/// # Errors
77///
78/// Returns [`PhysicsError::InvalidParameter`] if `mass` or `g` is not positive.
79pub fn potential_energy_gravity(mass: f64, height: f64, g: f64) -> PhysicsResult<f64> {
80    if mass <= 0.0 {
81        return Err(PhysicsError::InvalidParameter {
82            param: "mass",
83            reason: format!("mass must be positive, got {mass}"),
84        });
85    }
86    if g <= 0.0 {
87        return Err(PhysicsError::InvalidParameter {
88            param: "g",
89            reason: format!("gravitational acceleration must be positive, got {g}"),
90        });
91    }
92    Ok(mass * g * height)
93}
94
95/// Linear momentum `p = mv`.
96///
97/// # Arguments
98///
99/// * `mass`     – mass in kg (must be > 0)
100/// * `velocity` – velocity in m/s
101///
102/// # Errors
103///
104/// Returns [`PhysicsError::InvalidParameter`] if `mass` is not positive.
105pub fn momentum(mass: f64, velocity: f64) -> PhysicsResult<f64> {
106    if mass <= 0.0 {
107        return Err(PhysicsError::InvalidParameter {
108            param: "mass",
109            reason: format!("mass must be positive, got {mass}"),
110        });
111    }
112    Ok(mass * velocity)
113}
114
115/// Angular momentum `L = mvr` for circular motion.
116///
117/// # Arguments
118///
119/// * `mass`     – mass in kg (must be > 0)
120/// * `velocity` – tangential speed in m/s
121/// * `radius`   – orbital radius in m (must be > 0)
122///
123/// # Errors
124///
125/// Returns [`PhysicsError::InvalidParameter`] if `mass` or `radius` is not positive.
126pub fn angular_momentum(mass: f64, velocity: f64, radius: f64) -> PhysicsResult<f64> {
127    if mass <= 0.0 {
128        return Err(PhysicsError::InvalidParameter {
129            param: "mass",
130            reason: format!("mass must be positive, got {mass}"),
131        });
132    }
133    if radius <= 0.0 {
134        return Err(PhysicsError::InvalidParameter {
135            param: "radius",
136            reason: format!("radius must be positive, got {radius}"),
137        });
138    }
139    Ok(mass * velocity * radius)
140}
141
142/// Centripetal acceleration `a = v²/r`.
143///
144/// # Arguments
145///
146/// * `velocity` – tangential speed in m/s
147/// * `radius`   – radius of curvature in m (must be > 0)
148///
149/// # Errors
150///
151/// Returns [`PhysicsError::InvalidParameter`] if `radius` is not positive.
152pub fn centripetal_acceleration(velocity: f64, radius: f64) -> PhysicsResult<f64> {
153    if radius <= 0.0 {
154        return Err(PhysicsError::InvalidParameter {
155            param: "radius",
156            reason: format!("radius must be positive, got {radius}"),
157        });
158    }
159    Ok(velocity * velocity / radius)
160}
161
162// ─── Projectile motion ───────────────────────────────────────────────────────
163
164/// Full projectile-motion analysis on a flat surface.
165///
166/// The launch point is taken as the origin; the projectile is fired with initial
167/// speed `v0` at an angle `angle_deg` above the horizontal in a uniform gravitational
168/// field `g`.  Air resistance is neglected.
169///
170/// # Arguments
171///
172/// * `v0`        – initial speed in m/s (must be ≥ 0)
173/// * `angle_deg` – launch angle in degrees above the horizontal (0–90)
174/// * `g`         – gravitational acceleration in m/s² (must be > 0)
175///
176/// # Errors
177///
178/// * [`PhysicsError::InvalidParameter`] if `g` ≤ 0 or `v0` < 0.
179/// * [`PhysicsError::DomainError`] if `angle_deg` is outside [0, 90].
180pub fn projectile_motion(v0: f64, angle_deg: f64, g: f64) -> PhysicsResult<ProjectileResult> {
181    if v0 < 0.0 {
182        return Err(PhysicsError::InvalidParameter {
183            param: "v0",
184            reason: format!("initial speed must be non-negative, got {v0}"),
185        });
186    }
187    if g <= 0.0 {
188        return Err(PhysicsError::InvalidParameter {
189            param: "g",
190            reason: format!("gravitational acceleration must be positive, got {g}"),
191        });
192    }
193    if !(0.0..=90.0).contains(&angle_deg) {
194        return Err(PhysicsError::DomainError(format!(
195            "launch angle must be in [0, 90] degrees, got {angle_deg}"
196        )));
197    }
198
199    let angle_rad = angle_deg * PI / 180.0;
200    let vx = v0 * angle_rad.cos();
201    let vy0 = v0 * angle_rad.sin();
202
203    // Time of flight: projectile returns to y = 0  →  t = 2·vy0/g
204    let time_of_flight = 2.0 * vy0 / g;
205    // Maximum height: reached at t = vy0/g
206    let max_height = vy0 * vy0 / (2.0 * g);
207    // Horizontal range
208    let range = vx * time_of_flight;
209
210    Ok(ProjectileResult {
211        max_height,
212        range,
213        time_of_flight,
214        vx,
215        vy0,
216    })
217}
218
219/// Position of a projectile at time `t` after launch.
220///
221/// Returns `(x, y)` in meters.
222///
223/// # Arguments
224///
225/// * `v0`        – initial speed (m/s, must be ≥ 0)
226/// * `angle_deg` – launch angle in degrees (0–90)
227/// * `g`         – gravitational acceleration (m/s², must be > 0)
228/// * `t`         – elapsed time in seconds (must be ≥ 0)
229///
230/// # Errors
231///
232/// Propagates errors from [`projectile_motion`]; additionally returns
233/// [`PhysicsError::InvalidParameter`] if `t` < 0.
234pub fn projectile_position(v0: f64, angle_deg: f64, g: f64, t: f64) -> PhysicsResult<(f64, f64)> {
235    if t < 0.0 {
236        return Err(PhysicsError::InvalidParameter {
237            param: "t",
238            reason: format!("time must be non-negative, got {t}"),
239        });
240    }
241    let proj = projectile_motion(v0, angle_deg, g)?;
242    let x = proj.vx * t;
243    let y = proj.vy0 * t - 0.5 * g * t * t;
244    Ok((x, y))
245}
246
247// ─── Simple harmonic oscillator ──────────────────────────────────────────────
248
249/// Characterise a mass-spring simple harmonic oscillator.
250///
251/// `ω = √(k/m)`,  `T = 2π/ω`,  `f = ω/(2π)`
252///
253/// # Arguments
254///
255/// * `mass`           – mass in kg (must be > 0)
256/// * `spring_constant` – spring constant in N/m (must be > 0)
257///
258/// # Errors
259///
260/// Returns [`PhysicsError::InvalidParameter`] if either argument is not positive.
261pub fn simple_harmonic_oscillator(
262    mass: f64,
263    spring_constant: f64,
264) -> PhysicsResult<OscillatorResult> {
265    if mass <= 0.0 {
266        return Err(PhysicsError::InvalidParameter {
267            param: "mass",
268            reason: format!("mass must be positive, got {mass}"),
269        });
270    }
271    if spring_constant <= 0.0 {
272        return Err(PhysicsError::InvalidParameter {
273            param: "spring_constant",
274            reason: format!("spring constant must be positive, got {spring_constant}"),
275        });
276    }
277    let angular_frequency = (spring_constant / mass).sqrt();
278    let period = 2.0 * PI / angular_frequency;
279    let frequency = angular_frequency / (2.0 * PI);
280    Ok(OscillatorResult {
281        angular_frequency,
282        period,
283        frequency,
284    })
285}
286
287/// Displacement of a simple harmonic oscillator at time `t`.
288///
289/// `x(t) = A · cos(ωt + φ)`
290///
291/// # Arguments
292///
293/// * `amplitude`     – amplitude in m (must be ≥ 0)
294/// * `angular_freq`  – angular frequency ω in rad/s (must be > 0)
295/// * `time`          – time in s (must be ≥ 0)
296/// * `phase`         – initial phase φ in radians
297///
298/// # Errors
299///
300/// Returns [`PhysicsError::InvalidParameter`] for non-positive `angular_freq`, negative `amplitude`, or negative `time`.
301pub fn sho_displacement(
302    amplitude: f64,
303    angular_freq: f64,
304    time: f64,
305    phase: f64,
306) -> PhysicsResult<f64> {
307    if amplitude < 0.0 {
308        return Err(PhysicsError::InvalidParameter {
309            param: "amplitude",
310            reason: format!("amplitude must be non-negative, got {amplitude}"),
311        });
312    }
313    if angular_freq <= 0.0 {
314        return Err(PhysicsError::InvalidParameter {
315            param: "angular_freq",
316            reason: format!("angular frequency must be positive, got {angular_freq}"),
317        });
318    }
319    if time < 0.0 {
320        return Err(PhysicsError::InvalidParameter {
321            param: "time",
322            reason: format!("time must be non-negative, got {time}"),
323        });
324    }
325    Ok(amplitude * (angular_freq * time + phase).cos())
326}
327
328/// Gravitational force between two point masses (Newton's law of gravitation).
329///
330/// `F = G·m₁·m₂/r²`  where `G` is Newton's gravitational constant.
331///
332/// # Arguments
333///
334/// * `m1`       – first mass in kg (must be > 0)
335/// * `m2`       – second mass in kg (must be > 0)
336/// * `distance` – separation in m (must be > 0)
337///
338/// # Errors
339///
340/// Returns [`PhysicsError::InvalidParameter`] if any argument is not positive.
341pub fn gravitational_force(m1: f64, m2: f64, distance: f64) -> PhysicsResult<f64> {
342    use crate::constants::physical::GRAVITATIONAL_CONSTANT;
343    if m1 <= 0.0 {
344        return Err(PhysicsError::InvalidParameter {
345            param: "m1",
346            reason: format!("mass m1 must be positive, got {m1}"),
347        });
348    }
349    if m2 <= 0.0 {
350        return Err(PhysicsError::InvalidParameter {
351            param: "m2",
352            reason: format!("mass m2 must be positive, got {m2}"),
353        });
354    }
355    if distance <= 0.0 {
356        return Err(PhysicsError::InvalidParameter {
357            param: "distance",
358            reason: format!("distance must be positive, got {distance}"),
359        });
360    }
361    Ok(GRAVITATIONAL_CONSTANT * m1 * m2 / (distance * distance))
362}
363
364/// Escape velocity from the surface of a spherical body.
365///
366/// `v_esc = √(2GM/R)`
367///
368/// # Arguments
369///
370/// * `mass`   – mass of the body in kg (must be > 0)
371/// * `radius` – radius of the body in m (must be > 0)
372///
373/// # Errors
374///
375/// Returns [`PhysicsError::InvalidParameter`] if `mass` or `radius` is not positive.
376pub fn escape_velocity(mass: f64, radius: f64) -> PhysicsResult<f64> {
377    use crate::constants::physical::GRAVITATIONAL_CONSTANT;
378    if mass <= 0.0 {
379        return Err(PhysicsError::InvalidParameter {
380            param: "mass",
381            reason: format!("mass must be positive, got {mass}"),
382        });
383    }
384    if radius <= 0.0 {
385        return Err(PhysicsError::InvalidParameter {
386            param: "radius",
387            reason: format!("radius must be positive, got {radius}"),
388        });
389    }
390    Ok((2.0 * GRAVITATIONAL_CONSTANT * mass / radius).sqrt())
391}
392
393/// Orbital period of a circular orbit (Kepler's third law).
394///
395/// `T = 2π · √(r³ / (GM))`
396///
397/// # Arguments
398///
399/// * `orbital_radius` – semi-major axis / orbital radius in m (must be > 0)
400/// * `central_mass`   – mass of the central body in kg (must be > 0)
401///
402/// # Errors
403///
404/// Returns [`PhysicsError::InvalidParameter`] if either argument is not positive.
405pub fn orbital_period(orbital_radius: f64, central_mass: f64) -> PhysicsResult<f64> {
406    use crate::constants::physical::GRAVITATIONAL_CONSTANT;
407    if orbital_radius <= 0.0 {
408        return Err(PhysicsError::InvalidParameter {
409            param: "orbital_radius",
410            reason: format!("orbital radius must be positive, got {orbital_radius}"),
411        });
412    }
413    if central_mass <= 0.0 {
414        return Err(PhysicsError::InvalidParameter {
415            param: "central_mass",
416            reason: format!("central mass must be positive, got {central_mass}"),
417        });
418    }
419    Ok(2.0 * PI * (orbital_radius.powi(3) / (GRAVITATIONAL_CONSTANT * central_mass)).sqrt())
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    const TOL: f64 = 1e-9;
427
428    #[test]
429    fn test_kinetic_energy_basic() {
430        // KE = 0.5 * 2.0 * 3.0^2 = 9.0
431        let ke = kinetic_energy(2.0, 3.0).expect("should succeed");
432        assert!((ke - 9.0).abs() < TOL, "KE = {ke}");
433    }
434
435    #[test]
436    fn test_kinetic_energy_zero_velocity() {
437        let ke = kinetic_energy(5.0, 0.0).expect("should succeed");
438        assert_eq!(ke, 0.0);
439    }
440
441    #[test]
442    fn test_kinetic_energy_invalid_mass() {
443        assert!(kinetic_energy(-1.0, 1.0).is_err());
444        assert!(kinetic_energy(0.0, 1.0).is_err());
445    }
446
447    #[test]
448    fn test_potential_energy_gravity() {
449        // PE = 2 * 9.81 * 10 = 196.2
450        let pe = potential_energy_gravity(2.0, 10.0, 9.81).expect("should succeed");
451        assert!((pe - 196.2).abs() < 1e-10);
452    }
453
454    #[test]
455    fn test_potential_energy_negative_height() {
456        // Negative height (below reference) is allowed — gives negative PE.
457        let pe = potential_energy_gravity(1.0, -5.0, 9.81).expect("should succeed");
458        assert!(pe < 0.0);
459    }
460
461    #[test]
462    fn test_momentum() {
463        let p = momentum(3.0, 4.0).expect("should succeed");
464        assert!((p - 12.0).abs() < TOL);
465    }
466
467    #[test]
468    fn test_angular_momentum() {
469        // L = 1.0 * 2.0 * 3.0 = 6.0
470        let l = angular_momentum(1.0, 2.0, 3.0).expect("should succeed");
471        assert!((l - 6.0).abs() < TOL);
472    }
473
474    #[test]
475    fn test_centripetal_acceleration() {
476        // a = v^2/r = 4 / 2 = 2
477        let a = centripetal_acceleration(2.0, 2.0).expect("should succeed");
478        assert!((a - 2.0).abs() < TOL);
479    }
480
481    #[test]
482    fn test_projectile_45_degrees() {
483        // At 45° launch angle, range is maximised: R = v0²/g
484        let v0 = 20.0;
485        let g = 9.81;
486        let result = projectile_motion(v0, 45.0, g).expect("should succeed");
487        let expected_range = v0 * v0 / g;
488        assert!(
489            (result.range - expected_range).abs() < 1e-10,
490            "range = {}, expected = {}",
491            result.range,
492            expected_range
493        );
494    }
495
496    #[test]
497    fn test_projectile_90_degrees_zero_range() {
498        // Straight up: range = 0, max height = v0²/(2g)
499        let v0 = 10.0;
500        let g = 9.81;
501        let result = projectile_motion(v0, 90.0, g).expect("should succeed");
502        assert!(result.range.abs() < 1e-10, "range should be ~0");
503        let expected_height = v0 * v0 / (2.0 * g);
504        assert!((result.max_height - expected_height).abs() < 1e-10);
505    }
506
507    #[test]
508    fn test_projectile_invalid_angle() {
509        assert!(projectile_motion(10.0, 95.0, 9.81).is_err());
510        assert!(projectile_motion(10.0, -1.0, 9.81).is_err());
511    }
512
513    #[test]
514    fn test_simple_harmonic_oscillator() {
515        // k=4, m=1  => ω=2, T=π, f=1/π
516        let result = simple_harmonic_oscillator(1.0, 4.0).expect("should succeed");
517        assert!((result.angular_frequency - 2.0).abs() < TOL);
518        assert!((result.period - PI).abs() < TOL);
519        assert!((result.frequency - 1.0 / PI).abs() < TOL);
520    }
521
522    #[test]
523    fn test_sho_displacement_at_zero() {
524        // x(0) = A * cos(φ)
525        let d = sho_displacement(3.0, 2.0, 0.0, 0.0).expect("should succeed");
526        assert!((d - 3.0).abs() < TOL);
527    }
528
529    #[test]
530    fn test_escape_velocity_earth() {
531        use crate::constants::physical::{EARTH_MASS, EARTH_RADIUS};
532        let ve = escape_velocity(EARTH_MASS, EARTH_RADIUS).expect("should succeed");
533        // Expected ~11.2 km/s
534        assert!(
535            (ve - 11_186.0).abs() < 200.0,
536            "escape velocity = {ve:.1} m/s"
537        );
538    }
539
540    #[test]
541    fn test_gravitational_force_known() {
542        use crate::constants::physical::GRAVITATIONAL_CONSTANT;
543        // Two 1 kg masses 1 m apart: F = G
544        let f = gravitational_force(1.0, 1.0, 1.0).expect("should succeed");
545        assert!((f - GRAVITATIONAL_CONSTANT).abs() < 1e-20);
546    }
547
548    #[test]
549    fn test_orbital_period_earth_approx() {
550        use crate::constants::physical::{EARTH_RADIUS, SOLAR_MASS};
551        // Earth at 1 AU around the Sun (use AU constant)
552        use crate::constants::physical::ASTRONOMICAL_UNIT;
553        let period = orbital_period(ASTRONOMICAL_UNIT, SOLAR_MASS).expect("should succeed");
554        // One year ≈ 3.156e7 s
555        let one_year = 3.156e7_f64;
556        assert!(
557            (period - one_year).abs() / one_year < 0.01,
558            "orbital period = {period:.3e} s"
559        );
560        // Ensure radius/mass validation works
561        assert!(orbital_period(0.0, SOLAR_MASS).is_err());
562        assert!(orbital_period(EARTH_RADIUS, 0.0).is_err());
563    }
564}