1use crate::units::{Eccentricity, Km, KmPerSec, Mu, Radians, Seconds};
3use crate::vec3::Vec3;
4
5#[derive(Debug, Clone, Copy)]
10pub struct OrbitalElements {
11 pub semi_major_axis: Km,
13 pub eccentricity: Eccentricity,
15 pub inclination: Radians,
17 pub raan: Radians,
19 pub arg_periapsis: Radians,
21 pub true_anomaly: Radians,
23}
24
25#[derive(Debug, Clone, Copy)]
27pub struct StateVector {
28 pub position: Vec3<Km>,
30 pub velocity: Vec3<KmPerSec>,
32}
33
34impl StateVector {
35 pub fn new(position: Vec3<Km>, velocity: Vec3<KmPerSec>) -> Self {
36 Self { position, velocity }
37 }
38
39 pub fn radius(&self) -> Km {
41 Km(self.position.norm_raw())
42 }
43
44 pub fn speed(&self) -> KmPerSec {
46 KmPerSec(self.velocity.norm_raw())
47 }
48}
49
50pub fn vis_viva(mu: Mu, r: Km, a: Km) -> KmPerSec {
55 KmPerSec((mu.value() * (2.0 / r.value() - 1.0 / a.value())).sqrt())
56}
57
58pub fn hohmann_transfer_dv(mu: Mu, r1: Km, r2: Km) -> (KmPerSec, KmPerSec) {
63 let a_transfer = Km((r1.value() + r2.value()) / 2.0);
64
65 let v_circular_1 = KmPerSec((mu.value() / r1.value()).sqrt());
66 let v_transfer_1 = vis_viva(mu, r1, a_transfer);
67 let dv1 = (v_transfer_1 - v_circular_1).abs();
68
69 let v_circular_2 = KmPerSec((mu.value() / r2.value()).sqrt());
70 let v_transfer_2 = vis_viva(mu, r2, a_transfer);
71 let dv2 = (v_circular_2 - v_transfer_2).abs();
72
73 (dv1, dv2)
74}
75
76pub fn orbital_period(mu: Mu, a: Km) -> crate::units::Seconds {
78 let a_val = a.value();
79 crate::units::Seconds(std::f64::consts::TAU * (a_val.powi(3) / mu.value()).sqrt())
80}
81
82pub fn specific_energy(mu: Mu, a: Km) -> f64 {
84 -mu.value() / (2.0 * a.value())
85}
86
87pub fn specific_angular_momentum(mu: Mu, a: Km, e: Eccentricity) -> f64 {
90 (mu.value() * a.value() * (1.0 - e.value().powi(2))).sqrt()
91}
92
93pub fn brachistochrone_accel(distance: Km, time: Seconds) -> f64 {
102 (4.0 * distance.value()) / (time.value() * time.value())
103}
104
105pub fn brachistochrone_dv(distance: Km, time: Seconds) -> KmPerSec {
113 KmPerSec((4.0 * distance.value()) / time.value())
114}
115
116pub fn brachistochrone_max_distance(accel_km_s2: f64, time: Seconds) -> Km {
122 Km(accel_km_s2 * time.value() * time.value() / 4.0)
123}
124
125pub fn brachistochrone_time(distance: Km, accel_km_s2: f64) -> Seconds {
133 Seconds(2.0 * (distance.value() / accel_km_s2).sqrt())
134}
135
136pub fn exhaust_velocity(isp_s: f64) -> KmPerSec {
144 assert!(isp_s > 0.0, "Isp must be positive");
145 KmPerSec(isp_s * crate::constants::G0_M_S2 / 1000.0)
146}
147
148pub fn mass_ratio(delta_v: KmPerSec, ve: KmPerSec) -> f64 {
156 assert!(ve.value() > 0.0, "exhaust velocity must be positive");
157 assert!(delta_v.value() >= 0.0, "delta-v must be non-negative");
158 (delta_v.value() / ve.value()).exp()
159}
160
161pub fn propellant_fraction(delta_v: KmPerSec, ve: KmPerSec) -> f64 {
166 let mr = mass_ratio(delta_v, ve);
167 if mr.is_infinite() {
168 1.0
169 } else {
170 1.0 - 1.0 / mr
171 }
172}
173
174pub fn required_propellant_mass(dry_mass_kg: f64, delta_v: KmPerSec, ve: KmPerSec) -> f64 {
178 dry_mass_kg * (mass_ratio(delta_v, ve) - 1.0)
179}
180
181pub fn initial_mass(dry_mass_kg: f64, delta_v: KmPerSec, ve: KmPerSec) -> f64 {
185 dry_mass_kg * mass_ratio(delta_v, ve)
186}
187
188pub fn mass_flow_rate(thrust_n: f64, ve: KmPerSec) -> f64 {
192 assert!(ve.value() > 0.0, "exhaust velocity must be positive");
193 thrust_n / (ve.value() * 1000.0)
194}
195
196pub fn jet_power(thrust_n: f64, ve: KmPerSec) -> f64 {
200 0.5 * thrust_n * ve.value() * 1000.0
201}
202
203pub fn oberth_dv_gain(mu: Mu, r_periapsis: Km, v_inf: KmPerSec, burn_dv: KmPerSec) -> KmPerSec {
222 let v_inf_val = v_inf.value();
223 let mu_val = mu.value();
224 let r_p = r_periapsis.value();
225 let burn = burn_dv.value();
226
227 let v_peri = (v_inf_val * v_inf_val + 2.0 * mu_val / r_p).sqrt();
229 let v_peri_after = v_peri + burn;
231 let v_inf_after_sq = v_peri_after * v_peri_after - 2.0 * mu_val / r_p;
233 let v_inf_after = if v_inf_after_sq > 0.0 {
234 v_inf_after_sq.sqrt()
235 } else {
236 0.0
238 };
239
240 KmPerSec((v_inf_after - v_inf_val) - burn)
242}
243
244pub fn elements_to_state_vector(mu: Mu, elements: &OrbitalElements) -> StateVector {
249 let a = elements.semi_major_axis.value();
250 let e = elements.eccentricity.value();
251 let i = elements.inclination.value();
252 let raan = elements.raan.value();
253 let w = elements.arg_periapsis.value();
254 let nu = elements.true_anomaly.value();
255
256 let p = a * (1.0 - e * e);
258
259 let r = p / (1.0 + e * nu.cos());
261
262 let x_pqw = r * nu.cos();
264 let y_pqw = r * nu.sin();
265
266 let mu_over_p = (mu.value() / p).sqrt();
268 let vx_pqw = -mu_over_p * nu.sin();
269 let vy_pqw = mu_over_p * (e + nu.cos());
270
271 let cos_w = w.cos();
273 let sin_w = w.sin();
274 let cos_i = i.cos();
275 let sin_i = i.sin();
276 let cos_raan = raan.cos();
277 let sin_raan = raan.sin();
278
279 let r11 = cos_raan * cos_w - sin_raan * sin_w * cos_i;
281 let r21 = sin_raan * cos_w + cos_raan * sin_w * cos_i;
282 let r31 = sin_w * sin_i;
283
284 let r12 = -cos_raan * sin_w - sin_raan * cos_w * cos_i;
286 let r22 = -sin_raan * sin_w + cos_raan * cos_w * cos_i;
287 let r32 = cos_w * sin_i;
288
289 let pos = Vec3::new(
290 Km(r11 * x_pqw + r12 * y_pqw),
291 Km(r21 * x_pqw + r22 * y_pqw),
292 Km(r31 * x_pqw + r32 * y_pqw),
293 );
294
295 let vel = Vec3::new(
296 KmPerSec(r11 * vx_pqw + r12 * vy_pqw),
297 KmPerSec(r21 * vx_pqw + r22 * vy_pqw),
298 KmPerSec(r31 * vx_pqw + r32 * vy_pqw),
299 );
300
301 StateVector::new(pos, vel)
302}
303
304pub fn plane_change_dv(v: KmPerSec, delta_i: Radians) -> KmPerSec {
309 KmPerSec(2.0 * v.value() * (delta_i.value() / 2.0).sin().abs())
310}
311
312pub fn oberth_efficiency(mu: Mu, r_periapsis: Km, v_inf: KmPerSec, burn_dv: KmPerSec) -> f64 {
317 let v_inf_val = v_inf.value();
318 let mu_val = mu.value();
319 let r_p = r_periapsis.value();
320 let burn = burn_dv.value();
321
322 if burn <= 0.0 {
323 return 0.0;
324 }
325
326 let v_peri = (v_inf_val * v_inf_val + 2.0 * mu_val / r_p).sqrt();
327 let v_peri_after = v_peri + burn;
328 let v_inf_after_sq = v_peri_after * v_peri_after - 2.0 * mu_val / r_p;
329 let v_inf_after = if v_inf_after_sq > 0.0 {
330 v_inf_after_sq.sqrt()
331 } else {
332 0.0
333 };
334
335 let delta_v_inf = v_inf_after - v_inf_val;
336 (delta_v_inf / burn) - 1.0
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::constants::{mu, orbit_radius, reference_orbits};
343
344 #[test]
345 fn test_vis_viva_circular_orbit() {
346 let r = Km(6_778.0); let v = vis_viva(mu::EARTH, r, r);
349 let expected = (mu::EARTH.value() / r.value()).sqrt();
350 assert!(
351 (v.value() - expected).abs() < 1e-10,
352 "v={}, expected={}",
353 v.value(),
354 expected
355 );
356 }
357
358 #[test]
359 fn test_hohmann_leo_to_geo() {
360 let r1 = reference_orbits::LEO_RADIUS;
361 let r2 = reference_orbits::GEO_RADIUS;
362
363 let (dv1, dv2) = hohmann_transfer_dv(mu::EARTH, r1, r2);
364 let total_dv = dv1.value() + dv2.value();
365
366 assert!(
368 (total_dv - 3.935).abs() < 0.05,
369 "total ΔV = {} km/s, expected ~3.935 km/s",
370 total_dv
371 );
372 }
373
374 #[test]
375 fn test_hohmann_earth_to_mars() {
376 let r_earth = orbit_radius::EARTH;
377 let r_mars = orbit_radius::MARS;
378
379 let (dv1, dv2) = hohmann_transfer_dv(mu::SUN, r_earth, r_mars);
380
381 assert!(
382 (dv1.value() - 2.94).abs() < 0.1,
383 "departure ΔV = {} km/s, expected ~2.94 km/s",
384 dv1.value()
385 );
386
387 assert!(
388 (dv2.value() - 2.65).abs() < 0.1,
389 "arrival ΔV = {} km/s, expected ~2.65 km/s",
390 dv2.value()
391 );
392 }
393
394 #[test]
395 fn test_orbital_period_earth() {
396 let period = orbital_period(mu::SUN, orbit_radius::EARTH);
398 let days = period.value() / 86400.0;
399 assert!(
400 (days - 365.25).abs() < 0.5,
401 "Earth orbital period = {} days, expected ~365.25",
402 days
403 );
404 }
405
406 #[test]
407 fn test_orbital_period_leo() {
408 let period = orbital_period(mu::EARTH, reference_orbits::LEO_RADIUS);
410 let minutes = period.value() / 60.0;
411 assert!(
412 (minutes - 88.5).abs() < 1.0,
413 "LEO orbital period = {} min, expected ~88.5 min",
414 minutes
415 );
416 }
417
418 #[test]
419 fn test_specific_energy_bound_orbit() {
420 let energy = specific_energy(mu::EARTH, reference_orbits::LEO_RADIUS);
422 assert!(energy < 0.0, "LEO specific energy should be negative");
423 }
424
425 #[test]
426 fn test_specific_angular_momentum() {
427 let e = Eccentricity::elliptical(0.0).unwrap();
429 let r = reference_orbits::LEO_RADIUS;
430 let h = specific_angular_momentum(mu::EARTH, r, e);
431 let expected = (mu::EARTH.value() * r.value()).sqrt();
432 assert!(
433 (h - expected).abs() < 1e-6,
434 "h = {}, expected = {}",
435 h,
436 expected
437 );
438 }
439
440 #[test]
441 fn test_state_vector_radius_speed() {
442 let pos = Vec3::new(Km(6786.0), Km(0.0), Km(0.0));
444 let vel = Vec3::new(KmPerSec(0.0), KmPerSec(7.66), KmPerSec(0.0));
445 let state = StateVector::new(pos, vel);
446
447 assert!((state.radius().value() - 6786.0).abs() < 1e-10);
448 assert!((state.speed().value() - 7.66).abs() < 1e-10);
449 }
450
451 #[test]
452 fn test_brachistochrone_accel() {
453 let d = Km(149_597_870.7);
457 let t = crate::units::Seconds(259_200.0);
458 let a = brachistochrone_accel(d, t);
459 let expected = 4.0 * 149_597_870.7 / (259_200.0 * 259_200.0);
460 assert!(
461 (a - expected).abs() < 1e-12,
462 "brachistochrone accel = {}, expected = {}",
463 a,
464 expected
465 );
466 }
467
468 #[test]
469 fn test_brachistochrone_dv() {
470 let d = Km(149_597_870.7);
472 let t = crate::units::Seconds(259_200.0);
473 let dv = brachistochrone_dv(d, t);
474 let expected = 4.0 * 149_597_870.7 / 259_200.0;
475 assert!(
476 (dv.value() - expected).abs() < 1e-8,
477 "brachistochrone ΔV = {} km/s, expected = {} km/s",
478 dv.value(),
479 expected
480 );
481 }
482
483 #[test]
484 fn test_brachistochrone_identity() {
485 let d = Km(550_630_800.0); let t = crate::units::Seconds(72.0 * 3600.0);
488 let a = brachistochrone_accel(d, t);
489 let dv = brachistochrone_dv(d, t);
490 assert!(
491 (dv.value() - a * t.value()).abs() < 1e-6,
492 "ΔV ({}) should equal a*t ({})",
493 dv.value(),
494 a * t.value()
495 );
496 }
497
498 #[test]
499 fn test_brachistochrone_max_distance() {
500 let d = Km(550_630_800.0);
502 let t = crate::units::Seconds(72.0 * 3600.0);
503 let a = brachistochrone_accel(d, t);
504 let d_max = brachistochrone_max_distance(a, t);
505 assert!(
506 (d_max.value() - d.value()).abs() < 1.0,
507 "round-trip distance: {} vs {}",
508 d_max.value(),
509 d.value()
510 );
511 }
512
513 #[test]
514 fn test_brachistochrone_time() {
515 let d = Km(550_630_800.0);
517 let t = crate::units::Seconds(72.0 * 3600.0);
518 let a = brachistochrone_accel(d, t);
519 let t_back = brachistochrone_time(d, a);
520 assert!(
521 (t_back.value() - t.value()).abs() < 1e-6,
522 "round-trip time: {} vs {}",
523 t_back.value(),
524 t.value()
525 );
526 }
527
528 #[test]
529 fn test_brachistochrone_time_1g_55m_km() {
530 let d = Km(55_000_000.0);
532 let a = 9.81e-3; let t = brachistochrone_time(d, a);
534 let hours = t.value() / 3600.0;
535 assert!(
536 (hours - 41.6).abs() < 1.0,
537 "Mars at 1G should be ~41.6h, got {:.1}h",
538 hours
539 );
540 }
541
542 #[test]
545 fn test_exhaust_velocity_chemical() {
546 let ve = exhaust_velocity(450.0);
548 let expected = 450.0 * 9.80665 / 1000.0; assert!(
550 (ve.value() - expected).abs() < 1e-6,
551 "ve = {} km/s, expected = {} km/s",
552 ve.value(),
553 expected
554 );
555 }
556
557 #[test]
558 fn test_exhaust_velocity_fusion() {
559 let ve = exhaust_velocity(100_000.0);
561 assert!(
562 (ve.value() - 980.665).abs() < 0.001,
563 "ve = {} km/s, expected 980.665 km/s",
564 ve.value()
565 );
566 }
567
568 #[test]
569 fn test_mass_ratio_zero_dv() {
570 let mr = mass_ratio(KmPerSec(0.0), KmPerSec(1.0));
572 assert!((mr - 1.0).abs() < 1e-15);
573 }
574
575 #[test]
576 fn test_mass_ratio_equal_dv_ve() {
577 let mr = mass_ratio(KmPerSec(10.0), KmPerSec(10.0));
579 assert!(
580 (mr - std::f64::consts::E).abs() < 1e-10,
581 "mass ratio = {}, expected e = {}",
582 mr,
583 std::f64::consts::E
584 );
585 }
586
587 #[test]
588 fn test_mass_ratio_chemical_leo() {
589 let ve = exhaust_velocity(450.0);
592 let mr = mass_ratio(KmPerSec(9.4), ve);
593 assert!(
594 (mr - 8.37).abs() < 0.1,
595 "mass ratio = {}, expected ~8.37",
596 mr
597 );
598 }
599
600 #[test]
601 fn test_mass_ratio_overflow() {
602 let mr = mass_ratio(KmPerSec(1e10), KmPerSec(1.0));
604 assert!(mr.is_infinite(), "expected INFINITY for extreme mass ratio");
605 }
606
607 #[test]
608 fn test_propellant_fraction_zero_dv() {
609 let pf = propellant_fraction(KmPerSec(0.0), KmPerSec(1.0));
610 assert!(pf.abs() < 1e-15, "zero ΔV → zero propellant fraction");
611 }
612
613 #[test]
614 fn test_propellant_fraction_moderate() {
615 let pf = propellant_fraction(KmPerSec(10.0), KmPerSec(10.0));
617 let expected = 1.0 - 1.0 / std::f64::consts::E;
618 assert!(
619 (pf - expected).abs() < 1e-10,
620 "propellant fraction = {}, expected = {}",
621 pf,
622 expected
623 );
624 }
625
626 #[test]
627 fn test_propellant_fraction_overflow() {
628 let pf = propellant_fraction(KmPerSec(1e10), KmPerSec(1.0));
629 assert!(
630 (pf - 1.0).abs() < 1e-15,
631 "extreme ΔV → propellant fraction = 1.0"
632 );
633 }
634
635 #[test]
636 fn test_required_propellant_mass() {
637 let prop = required_propellant_mass(1000.0, KmPerSec(10.0), KmPerSec(10.0));
639 let expected = 1000.0 * (std::f64::consts::E - 1.0);
640 assert!(
641 (prop - expected).abs() < 0.01,
642 "propellant = {} kg, expected = {} kg",
643 prop,
644 expected
645 );
646 }
647
648 #[test]
649 fn test_initial_mass() {
650 let m0 = initial_mass(1000.0, KmPerSec(10.0), KmPerSec(10.0));
652 let expected = 1000.0 * std::f64::consts::E;
653 assert!(
654 (m0 - expected).abs() < 0.01,
655 "initial mass = {} kg, expected = {} kg",
656 m0,
657 expected
658 );
659 }
660
661 #[test]
662 fn test_initial_mass_consistency() {
663 let dry = 500.0;
665 let dv = KmPerSec(20.0);
666 let ve = KmPerSec(10.0);
667 let m0 = initial_mass(dry, dv, ve);
668 let prop = required_propellant_mass(dry, dv, ve);
669 assert!(
670 (m0 - (dry + prop)).abs() < 1e-6,
671 "m0 = {}, dry + prop = {}",
672 m0,
673 dry + prop
674 );
675 }
676
677 #[test]
678 fn test_mass_flow_rate() {
679 let ve = exhaust_velocity(100_000.0);
682 let mdot = mass_flow_rate(9.8e6, ve);
683 let expected = 9.8e6 / (980.665 * 1000.0);
684 assert!(
685 (mdot - expected).abs() < 0.01,
686 "mdot = {} kg/s, expected = {} kg/s",
687 mdot,
688 expected
689 );
690 }
691
692 #[test]
693 fn test_jet_power() {
694 let ve = exhaust_velocity(100_000.0);
697 let p = jet_power(9.8e6, ve);
698 let expected = 0.5 * 9.8e6 * 980.665 * 1000.0;
699 assert!(
700 (p - expected).abs() < 1.0,
701 "P_jet = {} W, expected = {} W",
702 p,
703 expected
704 );
705 }
706
707 #[test]
708 fn test_kestrel_propellant_budget_ep01() {
709 let ve = exhaust_velocity(1_000_000.0);
714 let dv = KmPerSec(8497.0);
715 let pf = propellant_fraction(dv, ve);
716 assert!(
717 pf > 0.5 && pf < 0.7,
718 "EP01 propellant fraction = {} (expected 0.5-0.7 at Isp 10⁶ s)",
719 pf
720 );
721 let ve_low = exhaust_velocity(100_000.0);
724 let pf_low = propellant_fraction(dv, ve_low);
725 assert!(
726 pf_low > 0.999,
727 "EP01 at Isp 10⁵ s → propellant fraction = {} (>99.9%, impractical)",
728 pf_low
729 );
730 }
731
732 #[test]
735 fn test_oberth_gain_zero_burn() {
736 let gain = oberth_dv_gain(mu::JUPITER, Km(71_492.0), KmPerSec(10.0), KmPerSec(0.0));
738 assert!(
739 gain.value().abs() < 1e-10,
740 "zero burn should give zero gain, got {}",
741 gain.value()
742 );
743 }
744
745 #[test]
746 fn test_oberth_gain_positive() {
747 let gain = oberth_dv_gain(mu::JUPITER, Km(71_492.0), KmPerSec(10.0), KmPerSec(1.0));
750 assert!(
751 gain.value() > 0.0,
752 "Oberth gain should be positive at Jupiter periapsis, got {}",
753 gain.value()
754 );
755 }
756
757 #[test]
758 fn test_oberth_gain_stronger_at_lower_periapsis() {
759 let gain_close = oberth_dv_gain(mu::JUPITER, Km(71_492.0), KmPerSec(10.0), KmPerSec(1.0));
761 let gain_far = oberth_dv_gain(
762 mu::JUPITER,
763 Km(71_492.0 * 5.0),
764 KmPerSec(10.0),
765 KmPerSec(1.0),
766 );
767 assert!(
768 gain_close.value() > gain_far.value(),
769 "closer periapsis should give larger gain: {} vs {}",
770 gain_close.value(),
771 gain_far.value()
772 );
773 }
774
775 #[test]
776 fn test_oberth_gain_negligible_at_high_v_inf() {
777 let gain = oberth_dv_gain(
780 mu::JUPITER,
781 Km(71_492.0 * 3.0), KmPerSec(1500.0),
783 KmPerSec(50.0), );
785 let efficiency = oberth_efficiency(
786 mu::JUPITER,
787 Km(71_492.0 * 3.0),
788 KmPerSec(1500.0),
789 KmPerSec(50.0),
790 );
791 assert!(
795 efficiency.abs() < 0.10,
796 "Oberth efficiency at 1500 km/s should be small, got {}",
797 efficiency
798 );
799 assert!(
801 gain.value() > 0.0,
802 "Oberth gain should still be positive: {}",
803 gain.value()
804 );
805 }
806
807 #[test]
808 fn test_oberth_ep05_jupiter_flyby_3_percent() {
809 let r_j = 71_492.0;
816 let v_inf = KmPerSec(1500.0);
817
818 let eff_3rj = oberth_efficiency(mu::JUPITER, Km(r_j * 3.0), v_inf, KmPerSec(50.0));
820
821 let eff_1rj = oberth_efficiency(mu::JUPITER, Km(r_j), v_inf, KmPerSec(100.0));
826
827 assert!(
829 eff_1rj > eff_3rj,
830 "1 RJ should give higher efficiency than 3 RJ"
831 );
832 }
833
834 #[test]
835 fn test_oberth_efficiency_low_v_inf() {
836 let eff = oberth_efficiency(
838 mu::JUPITER,
839 Km(71_492.0), KmPerSec(5.0),
841 KmPerSec(1.0),
842 );
843 assert!(
847 eff > 0.10,
848 "Oberth efficiency at low v_inf should be large, got {}",
849 eff
850 );
851 }
852
853 #[test]
856 fn test_elements_to_state_circular_equatorial() {
857 let e = Eccentricity::elliptical(0.0).unwrap();
860 let elements = OrbitalElements {
861 semi_major_axis: Km(6778.0), eccentricity: e,
863 inclination: Radians(0.0),
864 raan: Radians(0.0),
865 arg_periapsis: Radians(0.0),
866 true_anomaly: Radians(0.0),
867 };
868
869 let sv = elements_to_state_vector(mu::EARTH, &elements);
870
871 assert!(
873 (sv.position.x.value() - 6778.0).abs() < 1e-6,
874 "x = {}, expected 6778",
875 sv.position.x.value()
876 );
877 assert!(
878 sv.position.y.value().abs() < 1e-6,
879 "y = {}, expected 0",
880 sv.position.y.value()
881 );
882 assert!(
883 sv.position.z.value().abs() < 1e-6,
884 "z = {}, expected 0",
885 sv.position.z.value()
886 );
887
888 let v_circ = (mu::EARTH.value() / 6778.0).sqrt();
890 assert!(
891 sv.velocity.x.value().abs() < 1e-6,
892 "vx = {}, expected 0",
893 sv.velocity.x.value()
894 );
895 assert!(
896 (sv.velocity.y.value() - v_circ).abs() < 1e-4,
897 "vy = {}, expected {}",
898 sv.velocity.y.value(),
899 v_circ
900 );
901 assert!(
902 sv.velocity.z.value().abs() < 1e-6,
903 "vz = {}, expected 0",
904 sv.velocity.z.value()
905 );
906 }
907
908 #[test]
909 fn test_elements_to_state_inclined_orbit() {
910 let e = Eccentricity::elliptical(0.0).unwrap();
912 let elements = OrbitalElements {
913 semi_major_axis: Km(7000.0),
914 eccentricity: e,
915 inclination: Radians(std::f64::consts::FRAC_PI_2), raan: Radians(0.0),
917 arg_periapsis: Radians(0.0),
918 true_anomaly: Radians(std::f64::consts::FRAC_PI_2), };
920
921 let sv = elements_to_state_vector(mu::EARTH, &elements);
922
923 assert!(
926 sv.position.x.value().abs() < 1e-4,
927 "x = {}, expected ~0",
928 sv.position.x.value()
929 );
930 assert!(
931 sv.position.y.value().abs() < 1e-4,
932 "y = {}, expected ~0",
933 sv.position.y.value()
934 );
935 assert!(
936 (sv.position.z.value() - 7000.0).abs() < 1e-4,
937 "z = {}, expected ~7000",
938 sv.position.z.value()
939 );
940 }
941
942 #[test]
943 fn test_elements_to_state_energy_conservation() {
944 let e = Eccentricity::elliptical(0.1).unwrap();
947 let elements = OrbitalElements {
948 semi_major_axis: Km(10000.0),
949 eccentricity: e,
950 inclination: Radians(0.5),
951 raan: Radians(1.0),
952 arg_periapsis: Radians(2.0),
953 true_anomaly: Radians(1.5),
954 };
955
956 let sv = elements_to_state_vector(mu::EARTH, &elements);
957 let r = sv.radius().value();
958 let v = sv.speed().value();
959
960 let eps_sv = v * v / 2.0 - mu::EARTH.value() / r;
962 let eps_elem = specific_energy(mu::EARTH, elements.semi_major_axis);
964
965 assert!(
966 (eps_sv - eps_elem).abs() / eps_elem.abs() < 1e-10,
967 "energy mismatch: state_vector = {:.6}, elements = {:.6}",
968 eps_sv,
969 eps_elem
970 );
971 }
972
973 #[test]
974 fn test_elements_to_state_angular_momentum() {
975 let e_val = 0.2;
977 let e = Eccentricity::elliptical(e_val).unwrap();
978 let a = Km(8000.0);
979 let elements = OrbitalElements {
980 semi_major_axis: a,
981 eccentricity: e,
982 inclination: Radians(0.3),
983 raan: Radians(0.7),
984 arg_periapsis: Radians(1.2),
985 true_anomaly: Radians(0.8),
986 };
987
988 let sv = elements_to_state_vector(mu::EARTH, &elements);
989
990 let h_vec = sv.position.cross_raw_with(sv.velocity);
992 let h_magnitude = h_vec.norm_raw();
993
994 let h_expected = specific_angular_momentum(mu::EARTH, a, e);
996
997 assert!(
998 (h_magnitude - h_expected).abs() / h_expected < 1e-10,
999 "angular momentum: computed = {:.6}, expected = {:.6}",
1000 h_magnitude,
1001 h_expected
1002 );
1003 }
1004
1005 #[test]
1008 fn test_plane_change_dv_zero() {
1009 let dv = plane_change_dv(KmPerSec(10.0), Radians(0.0));
1010 assert!(dv.value().abs() < 1e-15, "zero plane change = zero ΔV");
1011 }
1012
1013 #[test]
1014 fn test_plane_change_dv_90_degrees() {
1015 let dv = plane_change_dv(KmPerSec(7.0), Radians(std::f64::consts::FRAC_PI_2));
1017 let expected = 2.0 * 7.0 * (std::f64::consts::FRAC_PI_4).sin();
1018 assert!(
1019 (dv.value() - expected).abs() < 1e-10,
1020 "90° plane change: {} km/s, expected {} km/s",
1021 dv.value(),
1022 expected
1023 );
1024 }
1025
1026 #[test]
1027 fn test_plane_change_dv_small_angle() {
1028 let v = KmPerSec(30.0);
1030 let di = Radians(0.01); let dv = plane_change_dv(v, di);
1032 let approx = v.value() * di.value();
1034 assert!(
1035 (dv.value() - approx).abs() / approx < 0.001,
1036 "small angle: exact = {}, approx = {}",
1037 dv.value(),
1038 approx
1039 );
1040 }
1041}