Skip to main content

simular/orbit/
units.rs

1//! Type-safe physical units (Poka-Yoke).
2//!
3//! Implements Kennedy's dimensional analysis principles [28] to eliminate
4//! dimensional errors at compile time. All physical quantities use newtype
5//! wrappers from the `uom` crate.
6//!
7//! # Toyota Way Alignment
8//!
9//! - **Poka-Yoke (ポカヨケ)**: Mistake-proofing through design constraints
10//! - Compile-time dimensional analysis prevents position + velocity errors
11//!
12//! # References
13//!
14//! [28] A. J. Kennedy, "Dimension Types," ESOP '94, LNCS vol. 788, pp. 348-362, 1994.
15
16use serde::{Deserialize, Serialize};
17use std::ops::{Add, Div, Mul, Neg, Sub};
18use uom::si::acceleration::meter_per_second_squared;
19use uom::si::f64::{Acceleration, Length, Mass, Time, Velocity};
20use uom::si::length::meter;
21use uom::si::mass::kilogram;
22use uom::si::time::second;
23use uom::si::velocity::meter_per_second;
24
25/// Gravitational constant (m³ kg⁻¹ s⁻²).
26pub const G: f64 = 6.674_30e-11;
27
28/// Astronomical unit in meters.
29pub const AU: f64 = 1.495_978_707e11;
30
31/// Solar mass in kilograms.
32pub const SOLAR_MASS: f64 = 1.988_92e30;
33
34/// Earth mass in kilograms.
35pub const EARTH_MASS: f64 = 5.972_2e24;
36
37/// Type-safe 3D position vector with dimensional safety.
38#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
39pub struct Position3D {
40    pub x: Length,
41    pub y: Length,
42    pub z: Length,
43}
44
45impl Position3D {
46    /// Create a new position vector from meter values.
47    #[must_use]
48    pub fn from_meters(x: f64, y: f64, z: f64) -> Self {
49        Self {
50            x: Length::new::<meter>(x),
51            y: Length::new::<meter>(y),
52            z: Length::new::<meter>(z),
53        }
54    }
55
56    /// Create a new position vector from AU values.
57    #[must_use]
58    pub fn from_au(x: f64, y: f64, z: f64) -> Self {
59        Self::from_meters(x * AU, y * AU, z * AU)
60    }
61
62    /// Get the zero position.
63    #[must_use]
64    pub fn zero() -> Self {
65        Self::from_meters(0.0, 0.0, 0.0)
66    }
67
68    /// Calculate the magnitude (distance from origin).
69    #[must_use]
70    pub fn magnitude(&self) -> Length {
71        let x = self.x.get::<meter>();
72        let y = self.y.get::<meter>();
73        let z = self.z.get::<meter>();
74        Length::new::<meter>((x * x + y * y + z * z).sqrt())
75    }
76
77    /// Calculate squared magnitude (avoids sqrt).
78    #[must_use]
79    pub fn magnitude_squared(&self) -> f64 {
80        let x = self.x.get::<meter>();
81        let y = self.y.get::<meter>();
82        let z = self.z.get::<meter>();
83        x * x + y * y + z * z
84    }
85
86    /// Normalize to unit vector (returns raw f64 components).
87    #[must_use]
88    pub fn normalize(&self) -> (f64, f64, f64) {
89        let mag = self.magnitude().get::<meter>();
90        if mag < f64::EPSILON {
91            return (0.0, 0.0, 0.0);
92        }
93        (
94            self.x.get::<meter>() / mag,
95            self.y.get::<meter>() / mag,
96            self.z.get::<meter>() / mag,
97        )
98    }
99
100    /// Dot product (returns Length² as f64 in m²).
101    #[must_use]
102    pub fn dot(&self, other: &Self) -> f64 {
103        self.x.get::<meter>() * other.x.get::<meter>()
104            + self.y.get::<meter>() * other.y.get::<meter>()
105            + self.z.get::<meter>() * other.z.get::<meter>()
106    }
107
108    /// Cross product.
109    #[must_use]
110    pub fn cross(&self, other: &Self) -> Self {
111        let ax = self.x.get::<meter>();
112        let ay = self.y.get::<meter>();
113        let az = self.z.get::<meter>();
114        let bx = other.x.get::<meter>();
115        let by = other.y.get::<meter>();
116        let bz = other.z.get::<meter>();
117
118        Self::from_meters(ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx)
119    }
120
121    /// Scale by a dimensionless factor.
122    #[must_use]
123    pub fn scale(&self, factor: f64) -> Self {
124        Self {
125            x: self.x * factor,
126            y: self.y * factor,
127            z: self.z * factor,
128        }
129    }
130
131    /// Check if all components are finite.
132    #[must_use]
133    pub fn is_finite(&self) -> bool {
134        self.x.get::<meter>().is_finite()
135            && self.y.get::<meter>().is_finite()
136            && self.z.get::<meter>().is_finite()
137    }
138
139    /// Get raw meter values as tuple.
140    #[must_use]
141    pub fn as_meters(&self) -> (f64, f64, f64) {
142        (
143            self.x.get::<meter>(),
144            self.y.get::<meter>(),
145            self.z.get::<meter>(),
146        )
147    }
148}
149
150impl Add for Position3D {
151    type Output = Self;
152
153    fn add(self, other: Self) -> Self {
154        Self {
155            x: self.x + other.x,
156            y: self.y + other.y,
157            z: self.z + other.z,
158        }
159    }
160}
161
162impl Sub for Position3D {
163    type Output = Self;
164
165    fn sub(self, other: Self) -> Self {
166        Self {
167            x: self.x - other.x,
168            y: self.y - other.y,
169            z: self.z - other.z,
170        }
171    }
172}
173
174impl Neg for Position3D {
175    type Output = Self;
176
177    fn neg(self) -> Self {
178        Self {
179            x: -self.x,
180            y: -self.y,
181            z: -self.z,
182        }
183    }
184}
185
186/// Type-safe 3D velocity vector with dimensional safety.
187#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
188pub struct Velocity3D {
189    pub x: Velocity,
190    pub y: Velocity,
191    pub z: Velocity,
192}
193
194impl Velocity3D {
195    /// Create a new velocity vector from m/s values.
196    #[must_use]
197    pub fn from_mps(x: f64, y: f64, z: f64) -> Self {
198        Self {
199            x: Velocity::new::<meter_per_second>(x),
200            y: Velocity::new::<meter_per_second>(y),
201            z: Velocity::new::<meter_per_second>(z),
202        }
203    }
204
205    /// Get the zero velocity.
206    #[must_use]
207    pub fn zero() -> Self {
208        Self::from_mps(0.0, 0.0, 0.0)
209    }
210
211    /// Calculate the magnitude (speed).
212    #[must_use]
213    pub fn magnitude(&self) -> Velocity {
214        let x = self.x.get::<meter_per_second>();
215        let y = self.y.get::<meter_per_second>();
216        let z = self.z.get::<meter_per_second>();
217        Velocity::new::<meter_per_second>((x * x + y * y + z * z).sqrt())
218    }
219
220    /// Calculate squared magnitude (avoids sqrt).
221    #[must_use]
222    pub fn magnitude_squared(&self) -> f64 {
223        let x = self.x.get::<meter_per_second>();
224        let y = self.y.get::<meter_per_second>();
225        let z = self.z.get::<meter_per_second>();
226        x * x + y * y + z * z
227    }
228
229    /// Scale by a dimensionless factor.
230    #[must_use]
231    pub fn scale(&self, factor: f64) -> Self {
232        Self {
233            x: self.x * factor,
234            y: self.y * factor,
235            z: self.z * factor,
236        }
237    }
238
239    /// Check if all components are finite.
240    #[must_use]
241    pub fn is_finite(&self) -> bool {
242        self.x.get::<meter_per_second>().is_finite()
243            && self.y.get::<meter_per_second>().is_finite()
244            && self.z.get::<meter_per_second>().is_finite()
245    }
246
247    /// Get raw m/s values as tuple.
248    #[must_use]
249    pub fn as_mps(&self) -> (f64, f64, f64) {
250        (
251            self.x.get::<meter_per_second>(),
252            self.y.get::<meter_per_second>(),
253            self.z.get::<meter_per_second>(),
254        )
255    }
256}
257
258impl Add for Velocity3D {
259    type Output = Self;
260
261    fn add(self, other: Self) -> Self {
262        Self {
263            x: self.x + other.x,
264            y: self.y + other.y,
265            z: self.z + other.z,
266        }
267    }
268}
269
270impl Sub for Velocity3D {
271    type Output = Self;
272
273    fn sub(self, other: Self) -> Self {
274        Self {
275            x: self.x - other.x,
276            y: self.y - other.y,
277            z: self.z - other.z,
278        }
279    }
280}
281
282impl Neg for Velocity3D {
283    type Output = Self;
284
285    fn neg(self) -> Self {
286        Self {
287            x: -self.x,
288            y: -self.y,
289            z: -self.z,
290        }
291    }
292}
293
294/// Type-safe 3D acceleration vector with dimensional safety.
295#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
296pub struct Acceleration3D {
297    pub x: Acceleration,
298    pub y: Acceleration,
299    pub z: Acceleration,
300}
301
302impl Acceleration3D {
303    /// Create a new acceleration vector from m/s² values.
304    #[must_use]
305    pub fn from_mps2(x: f64, y: f64, z: f64) -> Self {
306        Self {
307            x: Acceleration::new::<meter_per_second_squared>(x),
308            y: Acceleration::new::<meter_per_second_squared>(y),
309            z: Acceleration::new::<meter_per_second_squared>(z),
310        }
311    }
312
313    /// Get the zero acceleration.
314    #[must_use]
315    pub fn zero() -> Self {
316        Self::from_mps2(0.0, 0.0, 0.0)
317    }
318
319    /// Calculate the magnitude.
320    #[must_use]
321    pub fn magnitude(&self) -> Acceleration {
322        let x = self.x.get::<meter_per_second_squared>();
323        let y = self.y.get::<meter_per_second_squared>();
324        let z = self.z.get::<meter_per_second_squared>();
325        Acceleration::new::<meter_per_second_squared>((x * x + y * y + z * z).sqrt())
326    }
327
328    /// Scale by a dimensionless factor.
329    #[must_use]
330    pub fn scale(&self, factor: f64) -> Self {
331        Self {
332            x: self.x * factor,
333            y: self.y * factor,
334            z: self.z * factor,
335        }
336    }
337
338    /// Get raw m/s² values as tuple.
339    #[must_use]
340    pub fn as_mps2(&self) -> (f64, f64, f64) {
341        (
342            self.x.get::<meter_per_second_squared>(),
343            self.y.get::<meter_per_second_squared>(),
344            self.z.get::<meter_per_second_squared>(),
345        )
346    }
347}
348
349impl Add for Acceleration3D {
350    type Output = Self;
351
352    fn add(self, other: Self) -> Self {
353        Self {
354            x: self.x + other.x,
355            y: self.y + other.y,
356            z: self.z + other.z,
357        }
358    }
359}
360
361impl Neg for Acceleration3D {
362    type Output = Self;
363
364    fn neg(self) -> Self {
365        Self {
366            x: -self.x,
367            y: -self.y,
368            z: -self.z,
369        }
370    }
371}
372
373/// Type-safe mass wrapper.
374#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
375pub struct OrbitMass(pub Mass);
376
377impl OrbitMass {
378    /// Create from kilograms.
379    #[must_use]
380    pub fn from_kg(kg: f64) -> Self {
381        Self(Mass::new::<kilogram>(kg))
382    }
383
384    /// Create from solar masses.
385    #[must_use]
386    pub fn from_solar_masses(m: f64) -> Self {
387        Self::from_kg(m * SOLAR_MASS)
388    }
389
390    /// Create from earth masses.
391    #[must_use]
392    pub fn from_earth_masses(m: f64) -> Self {
393        Self::from_kg(m * EARTH_MASS)
394    }
395
396    /// Get value in kilograms.
397    #[must_use]
398    pub fn as_kg(&self) -> f64 {
399        self.0.get::<kilogram>()
400    }
401}
402
403/// Type-safe time wrapper.
404#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
405pub struct OrbitTime(pub Time);
406
407impl OrbitTime {
408    /// Create from seconds.
409    #[must_use]
410    pub fn from_seconds(s: f64) -> Self {
411        Self(Time::new::<second>(s))
412    }
413
414    /// Create from days.
415    #[must_use]
416    pub fn from_days(d: f64) -> Self {
417        Self::from_seconds(d * 86400.0)
418    }
419
420    /// Create from years (Julian years).
421    #[must_use]
422    pub fn from_years(y: f64) -> Self {
423        Self::from_days(y * 365.25)
424    }
425
426    /// Get value in seconds.
427    #[must_use]
428    pub fn as_seconds(&self) -> f64 {
429        self.0.get::<second>()
430    }
431
432    /// Get value in days.
433    #[must_use]
434    pub fn as_days(&self) -> f64 {
435        self.as_seconds() / 86400.0
436    }
437}
438
439/// Velocity = Position / Time (dimensional operation).
440impl Div<OrbitTime> for Position3D {
441    type Output = Velocity3D;
442
443    fn div(self, dt: OrbitTime) -> Velocity3D {
444        let t = dt.as_seconds();
445        Velocity3D::from_mps(
446            self.x.get::<meter>() / t,
447            self.y.get::<meter>() / t,
448            self.z.get::<meter>() / t,
449        )
450    }
451}
452
453/// Position = Velocity * Time (dimensional operation).
454impl Mul<OrbitTime> for Velocity3D {
455    type Output = Position3D;
456
457    fn mul(self, dt: OrbitTime) -> Position3D {
458        let t = dt.as_seconds();
459        Position3D::from_meters(
460            self.x.get::<meter_per_second>() * t,
461            self.y.get::<meter_per_second>() * t,
462            self.z.get::<meter_per_second>() * t,
463        )
464    }
465}
466
467/// Velocity = Acceleration * Time (dimensional operation).
468impl Mul<OrbitTime> for Acceleration3D {
469    type Output = Velocity3D;
470
471    fn mul(self, dt: OrbitTime) -> Velocity3D {
472        let t = dt.as_seconds();
473        Velocity3D::from_mps(
474            self.x.get::<meter_per_second_squared>() * t,
475            self.y.get::<meter_per_second_squared>() * t,
476            self.z.get::<meter_per_second_squared>() * t,
477        )
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    const EPSILON: f64 = 1e-10;
486
487    #[test]
488    fn test_position_from_meters() {
489        let pos = Position3D::from_meters(1.0, 2.0, 3.0);
490        let (x, y, z) = pos.as_meters();
491        assert!((x - 1.0).abs() < EPSILON);
492        assert!((y - 2.0).abs() < EPSILON);
493        assert!((z - 3.0).abs() < EPSILON);
494    }
495
496    #[test]
497    fn test_position_from_au() {
498        let pos = Position3D::from_au(1.0, 0.0, 0.0);
499        let (x, _, _) = pos.as_meters();
500        assert!((x - AU).abs() < 1.0); // 1 meter tolerance
501    }
502
503    #[test]
504    fn test_position_magnitude() {
505        let pos = Position3D::from_meters(3.0, 4.0, 0.0);
506        let mag = pos.magnitude().get::<meter>();
507        assert!((mag - 5.0).abs() < EPSILON);
508    }
509
510    #[test]
511    fn test_position_normalize() {
512        let pos = Position3D::from_meters(3.0, 4.0, 0.0);
513        let (nx, ny, nz) = pos.normalize();
514        assert!((nx - 0.6).abs() < EPSILON);
515        assert!((ny - 0.8).abs() < EPSILON);
516        assert!(nz.abs() < EPSILON);
517    }
518
519    #[test]
520    fn test_position_dot_product() {
521        let a = Position3D::from_meters(1.0, 2.0, 3.0);
522        let b = Position3D::from_meters(4.0, 5.0, 6.0);
523        let dot = a.dot(&b);
524        assert!((dot - 32.0).abs() < EPSILON); // 1*4 + 2*5 + 3*6 = 32
525    }
526
527    #[test]
528    fn test_position_cross_product() {
529        let a = Position3D::from_meters(1.0, 0.0, 0.0);
530        let b = Position3D::from_meters(0.0, 1.0, 0.0);
531        let c = a.cross(&b);
532        let (x, y, z) = c.as_meters();
533        assert!(x.abs() < EPSILON);
534        assert!(y.abs() < EPSILON);
535        assert!((z - 1.0).abs() < EPSILON); // i × j = k
536    }
537
538    #[test]
539    fn test_position_add_sub() {
540        let a = Position3D::from_meters(1.0, 2.0, 3.0);
541        let b = Position3D::from_meters(4.0, 5.0, 6.0);
542        let sum = a + b;
543        let (x, y, z) = sum.as_meters();
544        assert!((x - 5.0).abs() < EPSILON);
545        assert!((y - 7.0).abs() < EPSILON);
546        assert!((z - 9.0).abs() < EPSILON);
547    }
548
549    #[test]
550    fn test_velocity_from_mps() {
551        let vel = Velocity3D::from_mps(10.0, 20.0, 30.0);
552        let (x, y, z) = vel.as_mps();
553        assert!((x - 10.0).abs() < EPSILON);
554        assert!((y - 20.0).abs() < EPSILON);
555        assert!((z - 30.0).abs() < EPSILON);
556    }
557
558    #[test]
559    fn test_velocity_magnitude() {
560        let vel = Velocity3D::from_mps(3.0, 4.0, 0.0);
561        let mag = vel.magnitude().get::<meter_per_second>();
562        assert!((mag - 5.0).abs() < EPSILON);
563    }
564
565    #[test]
566    fn test_acceleration_from_mps2() {
567        let acc = Acceleration3D::from_mps2(1.0, 2.0, 3.0);
568        let (x, y, z) = acc.as_mps2();
569        assert!((x - 1.0).abs() < EPSILON);
570        assert!((y - 2.0).abs() < EPSILON);
571        assert!((z - 3.0).abs() < EPSILON);
572    }
573
574    #[test]
575    fn test_mass_from_kg() {
576        let m = OrbitMass::from_kg(1000.0);
577        assert!((m.as_kg() - 1000.0).abs() < EPSILON);
578    }
579
580    #[test]
581    fn test_mass_from_solar_masses() {
582        let m = OrbitMass::from_solar_masses(1.0);
583        assert!((m.as_kg() - SOLAR_MASS).abs() < 1e20);
584    }
585
586    #[test]
587    fn test_time_from_seconds() {
588        let t = OrbitTime::from_seconds(3600.0);
589        assert!((t.as_seconds() - 3600.0).abs() < EPSILON);
590    }
591
592    #[test]
593    fn test_time_from_days() {
594        let t = OrbitTime::from_days(1.0);
595        assert!((t.as_seconds() - 86400.0).abs() < EPSILON);
596    }
597
598    #[test]
599    fn test_time_from_years() {
600        let t = OrbitTime::from_years(1.0);
601        let expected = 365.25 * 86400.0;
602        assert!((t.as_seconds() - expected).abs() < 1.0);
603    }
604
605    #[test]
606    fn test_position_div_time_gives_velocity() {
607        let pos = Position3D::from_meters(1000.0, 2000.0, 3000.0);
608        let dt = OrbitTime::from_seconds(10.0);
609        let vel = pos / dt;
610        let (vx, vy, vz) = vel.as_mps();
611        assert!((vx - 100.0).abs() < EPSILON);
612        assert!((vy - 200.0).abs() < EPSILON);
613        assert!((vz - 300.0).abs() < EPSILON);
614    }
615
616    #[test]
617    fn test_velocity_mul_time_gives_position() {
618        let vel = Velocity3D::from_mps(100.0, 200.0, 300.0);
619        let dt = OrbitTime::from_seconds(10.0);
620        let pos = vel * dt;
621        let (x, y, z) = pos.as_meters();
622        assert!((x - 1000.0).abs() < EPSILON);
623        assert!((y - 2000.0).abs() < EPSILON);
624        assert!((z - 3000.0).abs() < EPSILON);
625    }
626
627    #[test]
628    fn test_acceleration_mul_time_gives_velocity() {
629        let acc = Acceleration3D::from_mps2(10.0, 20.0, 30.0);
630        let dt = OrbitTime::from_seconds(5.0);
631        let vel = acc * dt;
632        let (vx, vy, vz) = vel.as_mps();
633        assert!((vx - 50.0).abs() < EPSILON);
634        assert!((vy - 100.0).abs() < EPSILON);
635        assert!((vz - 150.0).abs() < EPSILON);
636    }
637
638    #[test]
639    fn test_is_finite() {
640        let pos = Position3D::from_meters(1.0, 2.0, 3.0);
641        assert!(pos.is_finite());
642
643        let vel = Velocity3D::from_mps(1.0, 2.0, 3.0);
644        assert!(vel.is_finite());
645    }
646
647    #[test]
648    fn test_gravitational_constant() {
649        assert!((G - 6.674_30e-11).abs() < 1e-15);
650    }
651
652    #[test]
653    fn test_au_constant() {
654        assert!((AU - 1.495_978_707e11).abs() < 1.0);
655    }
656
657    #[test]
658    fn test_position_scale() {
659        let pos = Position3D::from_meters(1.0, 2.0, 3.0);
660        let scaled = pos.scale(2.0);
661        let (x, y, z) = scaled.as_meters();
662        assert!((x - 2.0).abs() < EPSILON);
663        assert!((y - 4.0).abs() < EPSILON);
664        assert!((z - 6.0).abs() < EPSILON);
665    }
666
667    #[test]
668    fn test_position_magnitude_squared() {
669        let pos = Position3D::from_meters(3.0, 4.0, 0.0);
670        let mag_sq = pos.magnitude_squared();
671        assert!((mag_sq - 25.0).abs() < EPSILON);
672    }
673
674    #[test]
675    fn test_position_zero() {
676        let pos = Position3D::zero();
677        let (x, y, z) = pos.as_meters();
678        assert!(x.abs() < EPSILON);
679        assert!(y.abs() < EPSILON);
680        assert!(z.abs() < EPSILON);
681    }
682
683    #[test]
684    fn test_position_normalize_zero() {
685        let pos = Position3D::zero();
686        let (nx, ny, nz) = pos.normalize();
687        assert!(nx.abs() < EPSILON);
688        assert!(ny.abs() < EPSILON);
689        assert!(nz.abs() < EPSILON);
690    }
691
692    #[test]
693    fn test_position_sub() {
694        let a = Position3D::from_meters(5.0, 7.0, 9.0);
695        let b = Position3D::from_meters(1.0, 2.0, 3.0);
696        let diff = a - b;
697        let (x, y, z) = diff.as_meters();
698        assert!((x - 4.0).abs() < EPSILON);
699        assert!((y - 5.0).abs() < EPSILON);
700        assert!((z - 6.0).abs() < EPSILON);
701    }
702
703    #[test]
704    fn test_position_neg() {
705        let pos = Position3D::from_meters(1.0, 2.0, 3.0);
706        let neg = -pos;
707        let (x, y, z) = neg.as_meters();
708        assert!((x + 1.0).abs() < EPSILON);
709        assert!((y + 2.0).abs() < EPSILON);
710        assert!((z + 3.0).abs() < EPSILON);
711    }
712
713    #[test]
714    fn test_position_is_finite_with_nan() {
715        let mut pos = Position3D::from_meters(1.0, 2.0, 3.0);
716        assert!(pos.is_finite());
717        pos.x = Length::new::<meter>(f64::NAN);
718        assert!(!pos.is_finite());
719    }
720
721    #[test]
722    fn test_velocity_zero() {
723        let vel = Velocity3D::zero();
724        let (x, y, z) = vel.as_mps();
725        assert!(x.abs() < EPSILON);
726        assert!(y.abs() < EPSILON);
727        assert!(z.abs() < EPSILON);
728    }
729
730    #[test]
731    fn test_velocity_scale() {
732        let vel = Velocity3D::from_mps(1.0, 2.0, 3.0);
733        let scaled = vel.scale(3.0);
734        let (x, y, z) = scaled.as_mps();
735        assert!((x - 3.0).abs() < EPSILON);
736        assert!((y - 6.0).abs() < EPSILON);
737        assert!((z - 9.0).abs() < EPSILON);
738    }
739
740    #[test]
741    fn test_velocity_magnitude_squared() {
742        let vel = Velocity3D::from_mps(3.0, 4.0, 0.0);
743        let mag_sq = vel.magnitude_squared();
744        assert!((mag_sq - 25.0).abs() < EPSILON);
745    }
746
747    #[test]
748    fn test_velocity_add() {
749        let a = Velocity3D::from_mps(1.0, 2.0, 3.0);
750        let b = Velocity3D::from_mps(4.0, 5.0, 6.0);
751        let sum = a + b;
752        let (x, y, z) = sum.as_mps();
753        assert!((x - 5.0).abs() < EPSILON);
754        assert!((y - 7.0).abs() < EPSILON);
755        assert!((z - 9.0).abs() < EPSILON);
756    }
757
758    #[test]
759    fn test_velocity_sub() {
760        let a = Velocity3D::from_mps(5.0, 7.0, 9.0);
761        let b = Velocity3D::from_mps(1.0, 2.0, 3.0);
762        let diff = a - b;
763        let (x, y, z) = diff.as_mps();
764        assert!((x - 4.0).abs() < EPSILON);
765        assert!((y - 5.0).abs() < EPSILON);
766        assert!((z - 6.0).abs() < EPSILON);
767    }
768
769    #[test]
770    fn test_velocity_neg() {
771        let vel = Velocity3D::from_mps(1.0, 2.0, 3.0);
772        let neg = -vel;
773        let (x, y, z) = neg.as_mps();
774        assert!((x + 1.0).abs() < EPSILON);
775        assert!((y + 2.0).abs() < EPSILON);
776        assert!((z + 3.0).abs() < EPSILON);
777    }
778
779    #[test]
780    fn test_velocity_is_finite_with_inf() {
781        let mut vel = Velocity3D::from_mps(1.0, 2.0, 3.0);
782        assert!(vel.is_finite());
783        vel.y = Velocity::new::<meter_per_second>(f64::INFINITY);
784        assert!(!vel.is_finite());
785    }
786
787    #[test]
788    fn test_acceleration_zero() {
789        let acc = Acceleration3D::zero();
790        let (x, y, z) = acc.as_mps2();
791        assert!(x.abs() < EPSILON);
792        assert!(y.abs() < EPSILON);
793        assert!(z.abs() < EPSILON);
794    }
795
796    #[test]
797    fn test_acceleration_scale() {
798        let acc = Acceleration3D::from_mps2(1.0, 2.0, 3.0);
799        let scaled = acc.scale(2.0);
800        let (x, y, z) = scaled.as_mps2();
801        assert!((x - 2.0).abs() < EPSILON);
802        assert!((y - 4.0).abs() < EPSILON);
803        assert!((z - 6.0).abs() < EPSILON);
804    }
805
806    #[test]
807    fn test_acceleration_magnitude() {
808        let acc = Acceleration3D::from_mps2(3.0, 4.0, 0.0);
809        let mag = acc.magnitude().get::<meter_per_second_squared>();
810        assert!((mag - 5.0).abs() < EPSILON);
811    }
812
813    #[test]
814    fn test_acceleration_add() {
815        let a = Acceleration3D::from_mps2(1.0, 2.0, 3.0);
816        let b = Acceleration3D::from_mps2(4.0, 5.0, 6.0);
817        let sum = a + b;
818        let (x, y, z) = sum.as_mps2();
819        assert!((x - 5.0).abs() < EPSILON);
820        assert!((y - 7.0).abs() < EPSILON);
821        assert!((z - 9.0).abs() < EPSILON);
822    }
823
824    #[test]
825    fn test_acceleration_neg() {
826        let acc = Acceleration3D::from_mps2(1.0, 2.0, 3.0);
827        let neg = -acc;
828        let (x, y, z) = neg.as_mps2();
829        assert!((x + 1.0).abs() < EPSILON);
830        assert!((y + 2.0).abs() < EPSILON);
831        assert!((z + 3.0).abs() < EPSILON);
832    }
833
834    #[test]
835    fn test_mass_from_earth_masses() {
836        let m = OrbitMass::from_earth_masses(1.0);
837        assert!((m.as_kg() - EARTH_MASS).abs() < 1e18);
838    }
839
840    #[test]
841    fn test_time_as_days() {
842        let t = OrbitTime::from_seconds(172800.0); // 2 days
843        assert!((t.as_days() - 2.0).abs() < EPSILON);
844    }
845
846    #[test]
847    fn test_constants() {
848        // Sanity-check physical constants at compile time
849        const _: () = assert!(SOLAR_MASS > 1.9e30);
850        const _: () = assert!(EARTH_MASS > 5.9e24);
851    }
852}
853
854#[cfg(test)]
855mod proptests {
856    use super::*;
857    use proptest::prelude::*;
858
859    proptest! {
860        /// Position conversion round-trip preserves value.
861        #[test]
862        fn prop_position_roundtrip(
863            meters in 1e3f64..1e15,
864        ) {
865            let p = Position3D::from_meters(meters, 0.0, 0.0);
866            let (x, _, _) = p.as_meters();
867            prop_assert!((x - meters).abs() / meters < 1e-10);
868        }
869
870        /// AU to meters conversion is consistent.
871        #[test]
872        fn prop_au_conversion(
873            au in 0.1f64..100.0,
874        ) {
875            let p = Position3D::from_au(au, 0.0, 0.0);
876            let (x, _, _) = p.as_meters();
877            let expected = au * AU;
878            prop_assert!((x - expected).abs() / expected < 1e-10);
879        }
880
881        /// Velocity magnitude is non-negative.
882        #[test]
883        fn prop_velocity_magnitude_nonneg(
884            vx in -1e5f64..1e5,
885            vy in -1e5f64..1e5,
886            vz in -1e5f64..1e5,
887        ) {
888            let v = Velocity3D::from_mps(vx, vy, vz);
889            let mag = v.magnitude().get::<meter_per_second>();
890            prop_assert!(mag >= 0.0);
891        }
892
893        /// Position magnitude is non-negative.
894        #[test]
895        fn prop_position_magnitude_nonneg(
896            x in -1e12f64..1e12,
897            y in -1e12f64..1e12,
898            z in -1e12f64..1e12,
899        ) {
900            let p = Position3D::from_meters(x, y, z);
901            let mag = p.magnitude().get::<meter>();
902            prop_assert!(mag >= 0.0);
903        }
904
905        /// Mass is always positive.
906        #[test]
907        fn prop_mass_positive(
908            kg in 1.0f64..1e35,
909        ) {
910            let m = OrbitMass::from_kg(kg);
911            prop_assert!(m.as_kg() > 0.0);
912        }
913
914        /// Time conversion round-trip.
915        #[test]
916        fn prop_time_roundtrip(
917            seconds in 1.0f64..1e10,
918        ) {
919            let t = OrbitTime::from_seconds(seconds);
920            let recovered = t.as_seconds();
921            prop_assert!((recovered - seconds).abs() / seconds < 1e-10);
922        }
923
924        /// Negation preserves magnitude.
925        #[test]
926        fn prop_velocity_neg_magnitude(
927            vx in -1e5f64..1e5,
928            vy in -1e5f64..1e5,
929            vz in -1e5f64..1e5,
930        ) {
931            let v = Velocity3D::from_mps(vx, vy, vz);
932            let neg_v = -v;
933            let mag1 = v.magnitude().get::<meter_per_second>();
934            let mag2 = neg_v.magnitude().get::<meter_per_second>();
935            prop_assert!((mag1 - mag2).abs() < 1e-10);
936        }
937    }
938}