1use crate::units::{Km, KmPerSec, Mu};
16use crate::vec3::Vec3;
17
18#[derive(Debug, Clone, Copy)]
20pub struct PropState {
21 pub pos: Vec3<Km>,
23 pub vel: Vec3<KmPerSec>,
25 pub time: f64,
27}
28
29impl PropState {
30 pub fn new(pos: Vec3<Km>, vel: Vec3<KmPerSec>) -> Self {
31 Self {
32 pos,
33 vel,
34 time: 0.0,
35 }
36 }
37
38 pub fn radius(&self) -> f64 {
40 self.pos.norm_raw()
41 }
42
43 pub fn speed(&self) -> f64 {
45 self.vel.norm_raw()
46 }
47
48 pub fn specific_energy(&self, mu: Mu) -> f64 {
50 let v = self.speed();
51 let r = self.radius();
52 0.5 * v * v - mu.value() / r
53 }
54
55 pub fn angular_momentum(&self) -> Vec3<f64> {
57 let r = &self.pos;
58 let v = &self.vel;
59 Vec3::new(
60 r.y.value() * v.z.value() - r.z.value() * v.y.value(),
61 r.z.value() * v.x.value() - r.x.value() * v.z.value(),
62 r.x.value() * v.y.value() - r.y.value() * v.x.value(),
63 )
64 }
65}
66
67#[derive(Debug, Clone, Copy)]
69pub enum ThrustProfile {
70 None,
72 ConstantPrograde {
76 accel_km_s2: f64,
78 },
79 Brachistochrone {
82 accel_km_s2: f64,
84 flip_time: f64,
86 },
87}
88
89#[derive(Debug, Clone, Copy)]
91pub struct IntegratorConfig {
92 pub dt: f64,
94 pub mu: Mu,
96 pub thrust: ThrustProfile,
98}
99
100fn derivatives(
103 pos: [f64; 3],
104 vel: [f64; 3],
105 time: f64,
106 mu_val: f64,
107 thrust: &ThrustProfile,
108) -> ([f64; 3], [f64; 3]) {
109 let drdt = vel;
111
112 let r_sq = pos[0] * pos[0] + pos[1] * pos[1] + pos[2] * pos[2];
114 let r = r_sq.sqrt();
115 let r_cubed = r * r_sq;
116 let grav_factor = -mu_val / r_cubed;
117
118 let mut ax = grav_factor * pos[0];
119 let mut ay = grav_factor * pos[1];
120 let mut az = grav_factor * pos[2];
121
122 match thrust {
124 ThrustProfile::None => {}
125 ThrustProfile::ConstantPrograde { accel_km_s2 } => {
126 let speed = (vel[0] * vel[0] + vel[1] * vel[1] + vel[2] * vel[2]).sqrt();
127 if speed > 1e-15 {
128 let factor = accel_km_s2 / speed;
129 ax += factor * vel[0];
130 ay += factor * vel[1];
131 az += factor * vel[2];
132 }
133 }
134 ThrustProfile::Brachistochrone {
135 accel_km_s2,
136 flip_time,
137 } => {
138 let speed = (vel[0] * vel[0] + vel[1] * vel[1] + vel[2] * vel[2]).sqrt();
139 if speed > 1e-15 {
140 let sign = if time < *flip_time { 1.0 } else { -1.0 };
142 let factor = sign * accel_km_s2 / speed;
143 ax += factor * vel[0];
144 ay += factor * vel[1];
145 az += factor * vel[2];
146 }
147 }
148 }
149
150 let dvdt = [ax, ay, az];
151 (drdt, dvdt)
152}
153
154fn rk4_step(
159 pos: [f64; 3],
160 vel: [f64; 3],
161 time: f64,
162 dt: f64,
163 mu_val: f64,
164 thrust: &ThrustProfile,
165) -> ([f64; 3], [f64; 3]) {
166 let (k1r, k1v) = derivatives(pos, vel, time, mu_val, thrust);
168
169 let pos2 = [
171 pos[0] + 0.5 * dt * k1r[0],
172 pos[1] + 0.5 * dt * k1r[1],
173 pos[2] + 0.5 * dt * k1r[2],
174 ];
175 let vel2 = [
176 vel[0] + 0.5 * dt * k1v[0],
177 vel[1] + 0.5 * dt * k1v[1],
178 vel[2] + 0.5 * dt * k1v[2],
179 ];
180 let (k2r, k2v) = derivatives(pos2, vel2, time + 0.5 * dt, mu_val, thrust);
181
182 let pos3 = [
184 pos[0] + 0.5 * dt * k2r[0],
185 pos[1] + 0.5 * dt * k2r[1],
186 pos[2] + 0.5 * dt * k2r[2],
187 ];
188 let vel3 = [
189 vel[0] + 0.5 * dt * k2v[0],
190 vel[1] + 0.5 * dt * k2v[1],
191 vel[2] + 0.5 * dt * k2v[2],
192 ];
193 let (k3r, k3v) = derivatives(pos3, vel3, time + 0.5 * dt, mu_val, thrust);
194
195 let pos4 = [
197 pos[0] + dt * k3r[0],
198 pos[1] + dt * k3r[1],
199 pos[2] + dt * k3r[2],
200 ];
201 let vel4 = [
202 vel[0] + dt * k3v[0],
203 vel[1] + dt * k3v[1],
204 vel[2] + dt * k3v[2],
205 ];
206 let (k4r, k4v) = derivatives(pos4, vel4, time + dt, mu_val, thrust);
207
208 let new_pos = [
210 pos[0] + dt / 6.0 * (k1r[0] + 2.0 * k2r[0] + 2.0 * k3r[0] + k4r[0]),
211 pos[1] + dt / 6.0 * (k1r[1] + 2.0 * k2r[1] + 2.0 * k3r[1] + k4r[1]),
212 pos[2] + dt / 6.0 * (k1r[2] + 2.0 * k2r[2] + 2.0 * k3r[2] + k4r[2]),
213 ];
214 let new_vel = [
215 vel[0] + dt / 6.0 * (k1v[0] + 2.0 * k2v[0] + 2.0 * k3v[0] + k4v[0]),
216 vel[1] + dt / 6.0 * (k1v[1] + 2.0 * k2v[1] + 2.0 * k3v[1] + k4v[1]),
217 vel[2] + dt / 6.0 * (k1v[2] + 2.0 * k2v[2] + 2.0 * k3v[2] + k4v[2]),
218 ];
219
220 (new_pos, new_vel)
221}
222
223pub fn propagate(initial: &PropState, config: &IntegratorConfig, duration: f64) -> Vec<PropState> {
227 let n_steps = (duration / config.dt).ceil() as usize;
228 let mu_val = config.mu.value();
229 let dt = config.dt;
230
231 let mut states = Vec::with_capacity(n_steps + 1);
232 states.push(*initial);
233
234 let mut pos = [
235 initial.pos.x.value(),
236 initial.pos.y.value(),
237 initial.pos.z.value(),
238 ];
239 let mut vel = [
240 initial.vel.x.value(),
241 initial.vel.y.value(),
242 initial.vel.z.value(),
243 ];
244 let mut t = initial.time;
245
246 for i in 0..n_steps {
247 let step_dt = if i == n_steps - 1 {
249 let remaining = duration - (t - initial.time);
250 if remaining < dt {
251 remaining
252 } else {
253 dt
254 }
255 } else {
256 dt
257 };
258
259 let (new_pos, new_vel) = rk4_step(pos, vel, t, step_dt, mu_val, &config.thrust);
260 pos = new_pos;
261 vel = new_vel;
262 t += step_dt;
263
264 states.push(PropState {
265 pos: Vec3::new(Km(pos[0]), Km(pos[1]), Km(pos[2])),
266 vel: Vec3::new(KmPerSec(vel[0]), KmPerSec(vel[1]), KmPerSec(vel[2])),
267 time: t,
268 });
269 }
270
271 states
272}
273
274pub fn propagate_final(initial: &PropState, config: &IntegratorConfig, duration: f64) -> PropState {
276 let n_steps = (duration / config.dt).ceil() as usize;
277 let mu_val = config.mu.value();
278 let dt = config.dt;
279
280 let mut pos = [
281 initial.pos.x.value(),
282 initial.pos.y.value(),
283 initial.pos.z.value(),
284 ];
285 let mut vel = [
286 initial.vel.x.value(),
287 initial.vel.y.value(),
288 initial.vel.z.value(),
289 ];
290 let mut t = initial.time;
291
292 for i in 0..n_steps {
293 let step_dt = if i == n_steps - 1 {
294 let remaining = duration - (t - initial.time);
295 if remaining < dt {
296 remaining
297 } else {
298 dt
299 }
300 } else {
301 dt
302 };
303
304 let (new_pos, new_vel) = rk4_step(pos, vel, t, step_dt, mu_val, &config.thrust);
305 pos = new_pos;
306 vel = new_vel;
307 t += step_dt;
308 }
309
310 PropState {
311 pos: Vec3::new(Km(pos[0]), Km(pos[1]), Km(pos[2])),
312 vel: Vec3::new(KmPerSec(vel[0]), KmPerSec(vel[1]), KmPerSec(vel[2])),
313 time: t,
314 }
315}
316
317pub fn energy_drift(states: &[PropState], mu: Mu) -> (f64, f64) {
322 if states.is_empty() {
323 return (0.0, 0.0);
324 }
325
326 let e0 = states[0].specific_energy(mu);
327 let mut max_rel = 0.0;
328
329 for state in &states[1..] {
330 let e = state.specific_energy(mu);
331 let rel = if e0.abs() > 1e-30 {
332 ((e - e0) / e0).abs()
333 } else {
334 (e - e0).abs()
335 };
336 if rel > max_rel {
337 max_rel = rel;
338 }
339 }
340
341 let e_final = states.last().unwrap().specific_energy(mu);
342 let final_rel = if e0.abs() > 1e-30 {
343 ((e_final - e0) / e0).abs()
344 } else {
345 (e_final - e0).abs()
346 };
347
348 (max_rel, final_rel)
349}
350
351const DP_C: [f64; 7] = [0.0, 1.0 / 5.0, 3.0 / 10.0, 4.0 / 5.0, 8.0 / 9.0, 1.0, 1.0];
358
359const DP_A21: f64 = 1.0 / 5.0;
361const DP_A31: f64 = 3.0 / 40.0;
362const DP_A32: f64 = 9.0 / 40.0;
363const DP_A41: f64 = 44.0 / 45.0;
364const DP_A42: f64 = -56.0 / 15.0;
365const DP_A43: f64 = 32.0 / 9.0;
366const DP_A51: f64 = 19372.0 / 6561.0;
367const DP_A52: f64 = -25360.0 / 2187.0;
368const DP_A53: f64 = 64448.0 / 6561.0;
369const DP_A54: f64 = -212.0 / 729.0;
370const DP_A61: f64 = 9017.0 / 3168.0;
371const DP_A62: f64 = -355.0 / 33.0;
372const DP_A63: f64 = 46732.0 / 5247.0;
373const DP_A64: f64 = 49.0 / 176.0;
374const DP_A65: f64 = -5103.0 / 18656.0;
375
376const DP_B: [f64; 7] = [
378 35.0 / 384.0,
379 0.0,
380 500.0 / 1113.0,
381 125.0 / 192.0,
382 -2187.0 / 6784.0,
383 11.0 / 84.0,
384 0.0,
385];
386
387const DP_E: [f64; 7] = [
389 35.0 / 384.0 - 5179.0 / 57600.0,
390 0.0,
391 500.0 / 1113.0 - 7571.0 / 16695.0,
392 125.0 / 192.0 - 393.0 / 640.0,
393 -2187.0 / 6784.0 + 92097.0 / 339200.0,
394 11.0 / 84.0 - 187.0 / 2100.0,
395 0.0 - 1.0 / 40.0,
396];
397
398#[derive(Debug, Clone, Copy)]
400pub struct AdaptiveConfig {
401 pub mu: Mu,
403 pub thrust: ThrustProfile,
405 pub rtol: f64,
407 pub atol: f64,
409 pub h_init: f64,
411 pub h_min: f64,
413 pub h_max: f64,
415 pub max_steps: usize,
417}
418
419impl AdaptiveConfig {
420 pub fn heliocentric(mu: Mu, thrust: ThrustProfile) -> Self {
422 Self {
423 mu,
424 thrust,
425 rtol: 1e-8,
426 atol: 1e-10,
427 h_init: 0.0, h_min: 1e-6,
429 h_max: 86400.0, max_steps: 10_000_000,
431 }
432 }
433
434 pub fn planetocentric(mu: Mu, thrust: ThrustProfile) -> Self {
436 Self {
437 mu,
438 thrust,
439 rtol: 1e-8,
440 atol: 1e-10,
441 h_init: 0.0,
442 h_min: 1e-6,
443 h_max: 3600.0, max_steps: 10_000_000,
445 }
446 }
447}
448
449#[derive(Debug)]
451pub struct AdaptiveResult {
452 pub states: Vec<PropState>,
454 pub n_eval: usize,
456 pub n_accept: usize,
458 pub n_reject: usize,
460}
461
462const SAFETY: f64 = 0.9;
464const FAC_MIN: f64 = 0.2;
465const FAC_MAX: f64 = 5.0;
466const PI_EXPONENT_I: f64 = -0.20; const PI_EXPONENT_P: f64 = 0.08; fn estimate_initial_step(
471 pos: [f64; 3],
472 vel: [f64; 3],
473 mu_val: f64,
474 thrust: &ThrustProfile,
475 rtol: f64,
476 atol: f64,
477) -> f64 {
478 let (_, dvdt) = derivatives(pos, vel, 0.0, mu_val, thrust);
480
481 let mut d0_sq = 0.0;
483 let mut d1_sq = 0.0;
484 for i in 0..3 {
485 let sc_r = atol + rtol * pos[i].abs();
486 let sc_v = atol + rtol * vel[i].abs();
487 d0_sq += (pos[i] / sc_r).powi(2) + (vel[i] / sc_v).powi(2);
488 d1_sq += (vel[i] / sc_r).powi(2) + (dvdt[i] / sc_v).powi(2);
489 }
490 let d0 = (d0_sq / 6.0).sqrt();
491 let d1 = (d1_sq / 6.0).sqrt();
492
493 if d0 < 1e-5 || d1 < 1e-5 {
495 1e-6
496 } else {
497 0.01 * d0 / d1
498 }
499}
500
501#[allow(clippy::too_many_arguments)]
504fn scaled_error_norm(
505 err_pos: [f64; 3],
506 err_vel: [f64; 3],
507 y_old_pos: [f64; 3],
508 y_old_vel: [f64; 3],
509 y_new_pos: [f64; 3],
510 y_new_vel: [f64; 3],
511 rtol: f64,
512 atol: f64,
513) -> f64 {
514 let mut sum_sq = 0.0;
515 for i in 0..3 {
516 let sc_r = atol + rtol * y_old_pos[i].abs().max(y_new_pos[i].abs());
517 let sc_v = atol + rtol * y_old_vel[i].abs().max(y_new_vel[i].abs());
518 sum_sq += (err_pos[i] / sc_r).powi(2);
519 sum_sq += (err_vel[i] / sc_v).powi(2);
520 }
521 (sum_sq / 6.0).sqrt()
522}
523
524fn dopri45_step(
527 pos: [f64; 3],
528 vel: [f64; 3],
529 time: f64,
530 h: f64,
531 mu_val: f64,
532 thrust: &ThrustProfile,
533) -> ([f64; 3], [f64; 3], [f64; 3], [f64; 3]) {
534 let (k1r, k1v) = derivatives(pos, vel, time, mu_val, thrust);
536
537 let mut p2 = [0.0; 3];
539 let mut v2 = [0.0; 3];
540 for i in 0..3 {
541 p2[i] = pos[i] + h * DP_A21 * k1r[i];
542 v2[i] = vel[i] + h * DP_A21 * k1v[i];
543 }
544 let (k2r, k2v) = derivatives(p2, v2, time + DP_C[1] * h, mu_val, thrust);
545
546 let mut p3 = [0.0; 3];
548 let mut v3 = [0.0; 3];
549 for i in 0..3 {
550 p3[i] = pos[i] + h * (DP_A31 * k1r[i] + DP_A32 * k2r[i]);
551 v3[i] = vel[i] + h * (DP_A31 * k1v[i] + DP_A32 * k2v[i]);
552 }
553 let (k3r, k3v) = derivatives(p3, v3, time + DP_C[2] * h, mu_val, thrust);
554
555 let mut p4 = [0.0; 3];
557 let mut v4 = [0.0; 3];
558 for i in 0..3 {
559 p4[i] = pos[i] + h * (DP_A41 * k1r[i] + DP_A42 * k2r[i] + DP_A43 * k3r[i]);
560 v4[i] = vel[i] + h * (DP_A41 * k1v[i] + DP_A42 * k2v[i] + DP_A43 * k3v[i]);
561 }
562 let (k4r, k4v) = derivatives(p4, v4, time + DP_C[3] * h, mu_val, thrust);
563
564 let mut p5 = [0.0; 3];
566 let mut v5 = [0.0; 3];
567 for i in 0..3 {
568 p5[i] =
569 pos[i] + h * (DP_A51 * k1r[i] + DP_A52 * k2r[i] + DP_A53 * k3r[i] + DP_A54 * k4r[i]);
570 v5[i] =
571 vel[i] + h * (DP_A51 * k1v[i] + DP_A52 * k2v[i] + DP_A53 * k3v[i] + DP_A54 * k4v[i]);
572 }
573 let (k5r, k5v) = derivatives(p5, v5, time + DP_C[4] * h, mu_val, thrust);
574
575 let mut p6 = [0.0; 3];
577 let mut v6 = [0.0; 3];
578 for i in 0..3 {
579 p6[i] = pos[i]
580 + h * (DP_A61 * k1r[i]
581 + DP_A62 * k2r[i]
582 + DP_A63 * k3r[i]
583 + DP_A64 * k4r[i]
584 + DP_A65 * k5r[i]);
585 v6[i] = vel[i]
586 + h * (DP_A61 * k1v[i]
587 + DP_A62 * k2v[i]
588 + DP_A63 * k3v[i]
589 + DP_A64 * k4v[i]
590 + DP_A65 * k5v[i]);
591 }
592 let (k6r, k6v) = derivatives(p6, v6, time + DP_C[5] * h, mu_val, thrust);
593
594 let mut new_pos = [0.0; 3];
596 let mut new_vel = [0.0; 3];
597 for i in 0..3 {
598 new_pos[i] = pos[i]
599 + h * (DP_B[0] * k1r[i]
600 + DP_B[2] * k3r[i]
601 + DP_B[3] * k4r[i]
602 + DP_B[4] * k5r[i]
603 + DP_B[5] * k6r[i]);
604 new_vel[i] = vel[i]
605 + h * (DP_B[0] * k1v[i]
606 + DP_B[2] * k3v[i]
607 + DP_B[3] * k4v[i]
608 + DP_B[4] * k5v[i]
609 + DP_B[5] * k6v[i]);
610 }
611
612 let mut err_pos = [0.0; 3];
614 let mut err_vel = [0.0; 3];
615 let (k7r, k7v) = derivatives(new_pos, new_vel, time + h, mu_val, thrust);
617 for i in 0..3 {
618 err_pos[i] = h
619 * (DP_E[0] * k1r[i]
620 + DP_E[2] * k3r[i]
621 + DP_E[3] * k4r[i]
622 + DP_E[4] * k5r[i]
623 + DP_E[5] * k6r[i]
624 + DP_E[6] * k7r[i]);
625 err_vel[i] = h
626 * (DP_E[0] * k1v[i]
627 + DP_E[2] * k3v[i]
628 + DP_E[3] * k4v[i]
629 + DP_E[4] * k5v[i]
630 + DP_E[5] * k6v[i]
631 + DP_E[6] * k7v[i]);
632 }
633
634 (new_pos, new_vel, err_pos, err_vel)
635}
636
637pub fn propagate_adaptive(
642 initial: &PropState,
643 config: &AdaptiveConfig,
644 duration: f64,
645) -> AdaptiveResult {
646 let mu_val = config.mu.value();
647 let thrust = &config.thrust;
648
649 let mut pos = [
650 initial.pos.x.value(),
651 initial.pos.y.value(),
652 initial.pos.z.value(),
653 ];
654 let mut vel = [
655 initial.vel.x.value(),
656 initial.vel.y.value(),
657 initial.vel.z.value(),
658 ];
659 let mut t = initial.time;
660 let t_end = initial.time + duration;
661
662 let mut states = vec![*initial];
663 let mut n_eval: usize = 0;
664 let mut n_accept: usize = 0;
665 let mut n_reject: usize = 0;
666
667 let mut h = if config.h_init > 0.0 {
669 config.h_init
670 } else {
671 estimate_initial_step(pos, vel, mu_val, thrust, config.rtol, config.atol)
672 .min(config.h_max)
673 .max(config.h_min)
674 };
675
676 let mut err_prev = 1.0_f64; let event_times = match thrust {
680 ThrustProfile::Brachistochrone { flip_time, .. } => vec![*flip_time],
681 _ => vec![],
682 };
683
684 let mut step_count = 0;
685 while t < t_end - 1e-12 {
686 step_count += 1;
687 if step_count > config.max_steps {
688 break;
689 }
690
691 if t + h > t_end {
693 h = t_end - t;
694 }
695
696 let mut hit_event = false;
698 for &te in &event_times {
699 if t < te && t + h > te {
700 h = te - t;
701 hit_event = true;
702 break;
703 }
704 }
705
706 let (new_pos, new_vel, err_pos, err_vel) = dopri45_step(pos, vel, t, h, mu_val, thrust);
708 n_eval += 7; let err = scaled_error_norm(
712 err_pos,
713 err_vel,
714 pos,
715 vel,
716 new_pos,
717 new_vel,
718 config.rtol,
719 config.atol,
720 );
721
722 if err <= 1.0 {
723 pos = new_pos;
725 vel = new_vel;
726 t += h;
727 n_accept += 1;
728
729 states.push(PropState {
730 pos: Vec3::new(Km(pos[0]), Km(pos[1]), Km(pos[2])),
731 vel: Vec3::new(KmPerSec(vel[0]), KmPerSec(vel[1]), KmPerSec(vel[2])),
732 time: t,
733 });
734
735 if err > 1e-30 {
737 let factor = SAFETY * err.powf(PI_EXPONENT_I) * err_prev.powf(PI_EXPONENT_P);
738 h *= factor.clamp(FAC_MIN, FAC_MAX);
739 } else {
740 h *= FAC_MAX;
741 }
742 err_prev = err;
743
744 if hit_event {
746 h = h.min(config.h_max);
747 }
748 } else {
749 n_reject += 1;
751 let factor = SAFETY * err.powf(PI_EXPONENT_I);
752 h *= factor.max(0.1);
753 }
754
755 h = h.clamp(config.h_min, config.h_max);
757 }
758
759 AdaptiveResult {
760 states,
761 n_eval,
762 n_accept,
763 n_reject,
764 }
765}
766
767pub fn propagate_adaptive_final(
769 initial: &PropState,
770 config: &AdaptiveConfig,
771 duration: f64,
772) -> (PropState, usize) {
773 let mu_val = config.mu.value();
774 let thrust = &config.thrust;
775
776 let mut pos = [
777 initial.pos.x.value(),
778 initial.pos.y.value(),
779 initial.pos.z.value(),
780 ];
781 let mut vel = [
782 initial.vel.x.value(),
783 initial.vel.y.value(),
784 initial.vel.z.value(),
785 ];
786 let mut t = initial.time;
787 let t_end = initial.time + duration;
788
789 let mut n_eval: usize = 0;
790
791 let mut h = if config.h_init > 0.0 {
792 config.h_init
793 } else {
794 estimate_initial_step(pos, vel, mu_val, thrust, config.rtol, config.atol)
795 .min(config.h_max)
796 .max(config.h_min)
797 };
798
799 let mut err_prev = 1.0_f64;
800
801 let event_times = match thrust {
802 ThrustProfile::Brachistochrone { flip_time, .. } => vec![*flip_time],
803 _ => vec![],
804 };
805
806 let mut step_count = 0;
807 while t < t_end - 1e-12 {
808 step_count += 1;
809 if step_count > config.max_steps {
810 break;
811 }
812
813 if t + h > t_end {
814 h = t_end - t;
815 }
816
817 let mut hit_event = false;
818 for &te in &event_times {
819 if t < te && t + h > te {
820 h = te - t;
821 hit_event = true;
822 break;
823 }
824 }
825
826 let (new_pos, new_vel, err_pos, err_vel) = dopri45_step(pos, vel, t, h, mu_val, thrust);
827 n_eval += 7;
828
829 let err = scaled_error_norm(
830 err_pos,
831 err_vel,
832 pos,
833 vel,
834 new_pos,
835 new_vel,
836 config.rtol,
837 config.atol,
838 );
839
840 if err <= 1.0 {
841 pos = new_pos;
842 vel = new_vel;
843 t += h;
844
845 if err > 1e-30 {
846 let factor = SAFETY * err.powf(PI_EXPONENT_I) * err_prev.powf(PI_EXPONENT_P);
847 h *= factor.clamp(FAC_MIN, FAC_MAX);
848 } else {
849 h *= FAC_MAX;
850 }
851 err_prev = err;
852
853 if hit_event {
854 h = h.min(config.h_max);
855 }
856 } else {
857 let factor = SAFETY * err.powf(PI_EXPONENT_I);
858 h *= factor.max(0.1);
859 }
860
861 h = h.clamp(config.h_min, config.h_max);
862 }
863
864 let final_state = PropState {
865 pos: Vec3::new(Km(pos[0]), Km(pos[1]), Km(pos[2])),
866 vel: Vec3::new(KmPerSec(vel[0]), KmPerSec(vel[1]), KmPerSec(vel[2])),
867 time: t,
868 };
869 (final_state, n_eval)
870}
871
872pub fn circular_orbit_state(mu: Mu, radius: Km) -> PropState {
877 let v_circ = (mu.value() / radius.value()).sqrt();
878 PropState::new(
879 Vec3::new(radius, Km(0.0), Km(0.0)),
880 Vec3::new(KmPerSec(0.0), KmPerSec(v_circ), KmPerSec(0.0)),
881 )
882}
883
884pub fn elliptical_orbit_state_at_periapsis(
890 mu: Mu,
891 semi_major_axis: Km,
892 eccentricity: f64,
893) -> PropState {
894 let a = semi_major_axis.value();
895 let r_p = a * (1.0 - eccentricity);
896 let v_p = (mu.value() * (2.0 / r_p - 1.0 / a)).sqrt();
897 PropState::new(
898 Vec3::new(Km(r_p), Km(0.0), Km(0.0)),
899 Vec3::new(KmPerSec(0.0), KmPerSec(v_p), KmPerSec(0.0)),
900 )
901}
902
903fn gravity_accel(pos: [f64; 3], mu_val: f64) -> [f64; 3] {
918 let r_sq = pos[0] * pos[0] + pos[1] * pos[1] + pos[2] * pos[2];
919 let r = r_sq.sqrt();
920 let r_cubed = r * r_sq;
921 let factor = -mu_val / r_cubed;
922 [factor * pos[0], factor * pos[1], factor * pos[2]]
923}
924
925#[derive(Debug, Clone)]
927pub struct SymplecticResult {
928 pub states: Vec<PropState>,
930 pub n_steps: usize,
932}
933
934pub fn propagate_symplectic(
945 initial: &PropState,
946 mu: Mu,
947 dt: f64,
948 duration: f64,
949) -> SymplecticResult {
950 let n_steps = (duration / dt).ceil() as usize;
951 let mu_val = mu.value();
952
953 let mut states = Vec::with_capacity(n_steps + 1);
954
955 let mut pos = [
956 initial.pos.x.value(),
957 initial.pos.y.value(),
958 initial.pos.z.value(),
959 ];
960 let mut vel = [
961 initial.vel.x.value(),
962 initial.vel.y.value(),
963 initial.vel.z.value(),
964 ];
965 let mut time = initial.time;
966
967 states.push(*initial);
968
969 for step in 0..n_steps {
970 let h = if step == n_steps - 1 {
971 duration - (step as f64) * dt
973 } else {
974 dt
975 };
976
977 let a = gravity_accel(pos, mu_val);
980 let half_h = 0.5 * h;
981 vel[0] += half_h * a[0];
982 vel[1] += half_h * a[1];
983 vel[2] += half_h * a[2];
984
985 pos[0] += h * vel[0];
987 pos[1] += h * vel[1];
988 pos[2] += h * vel[2];
989
990 let a2 = gravity_accel(pos, mu_val);
992 vel[0] += half_h * a2[0];
993 vel[1] += half_h * a2[1];
994 vel[2] += half_h * a2[2];
995
996 time += h;
997
998 states.push(PropState {
999 pos: Vec3::new(Km(pos[0]), Km(pos[1]), Km(pos[2])),
1000 vel: Vec3::new(KmPerSec(vel[0]), KmPerSec(vel[1]), KmPerSec(vel[2])),
1001 time,
1002 });
1003 }
1004
1005 SymplecticResult { n_steps, states }
1006}
1007
1008pub fn propagate_symplectic_final(
1011 initial: &PropState,
1012 mu: Mu,
1013 dt: f64,
1014 duration: f64,
1015) -> PropState {
1016 let n_steps = (duration / dt).ceil() as usize;
1017 let mu_val = mu.value();
1018
1019 let mut pos = [
1020 initial.pos.x.value(),
1021 initial.pos.y.value(),
1022 initial.pos.z.value(),
1023 ];
1024 let mut vel = [
1025 initial.vel.x.value(),
1026 initial.vel.y.value(),
1027 initial.vel.z.value(),
1028 ];
1029 let mut time = initial.time;
1030
1031 for step in 0..n_steps {
1032 let h = if step == n_steps - 1 {
1033 duration - (step as f64) * dt
1034 } else {
1035 dt
1036 };
1037
1038 let a = gravity_accel(pos, mu_val);
1039 let half_h = 0.5 * h;
1040 vel[0] += half_h * a[0];
1041 vel[1] += half_h * a[1];
1042 vel[2] += half_h * a[2];
1043
1044 pos[0] += h * vel[0];
1045 pos[1] += h * vel[1];
1046 pos[2] += h * vel[2];
1047
1048 let a2 = gravity_accel(pos, mu_val);
1049 vel[0] += half_h * a2[0];
1050 vel[1] += half_h * a2[1];
1051 vel[2] += half_h * a2[2];
1052
1053 time += h;
1054 }
1055
1056 PropState {
1057 pos: Vec3::new(Km(pos[0]), Km(pos[1]), Km(pos[2])),
1058 vel: Vec3::new(KmPerSec(vel[0]), KmPerSec(vel[1]), KmPerSec(vel[2])),
1059 time,
1060 }
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065 use super::*;
1066 use crate::constants::{mu, orbit_radius, reference_orbits};
1067
1068 #[test]
1071 fn test_circular_orbit_state_properties() {
1072 let state = circular_orbit_state(mu::EARTH, reference_orbits::LEO_RADIUS);
1074 let r = reference_orbits::LEO_RADIUS.value();
1075 let v_expected = (mu::EARTH.value() / r).sqrt();
1076
1077 assert!(
1078 (state.pos.x.value() - r).abs() < 1e-10,
1079 "pos.x should be r: got {}, expected {}",
1080 state.pos.x.value(),
1081 r
1082 );
1083 assert!(state.pos.y.value().abs() < 1e-15, "pos.y should be 0");
1084 assert!(state.pos.z.value().abs() < 1e-15, "pos.z should be 0");
1085 assert!(state.vel.x.value().abs() < 1e-15, "vel.x should be 0");
1086 assert!(
1087 (state.vel.y.value() - v_expected).abs() < 1e-10,
1088 "vel.y should be v_circ: got {}, expected {}",
1089 state.vel.y.value(),
1090 v_expected
1091 );
1092 assert!(state.vel.z.value().abs() < 1e-15, "vel.z should be 0");
1093 assert!((state.time - 0.0).abs() < 1e-15, "time should be 0");
1094 }
1095
1096 #[test]
1097 fn test_elliptical_orbit_state_periapsis_properties() {
1098 let a = reference_orbits::GEO_RADIUS.value();
1100 let e = 0.5;
1101 let state = elliptical_orbit_state_at_periapsis(mu::EARTH, reference_orbits::GEO_RADIUS, e);
1102
1103 let r_p_expected = a * (1.0 - e);
1104 let v_p_expected = (mu::EARTH.value() * (2.0 / r_p_expected - 1.0 / a)).sqrt();
1105
1106 assert!(
1107 (state.pos.x.value() - r_p_expected).abs() < 1e-8,
1108 "periapsis radius: got {}, expected {}",
1109 state.pos.x.value(),
1110 r_p_expected
1111 );
1112 assert!(
1113 (state.vel.y.value() - v_p_expected).abs() < 1e-8,
1114 "periapsis velocity: got {}, expected {}",
1115 state.vel.y.value(),
1116 v_p_expected
1117 );
1118 let energy = state.specific_energy(mu::EARTH);
1120 let energy_expected = -mu::EARTH.value() / (2.0 * a);
1121 let rel_err = ((energy - energy_expected) / energy_expected).abs();
1122 assert!(
1123 rel_err < 1e-10,
1124 "specific energy: got {}, expected {}, rel_err={}",
1125 energy,
1126 energy_expected,
1127 rel_err
1128 );
1129 }
1130
1131 #[test]
1132 fn test_angular_momentum_conservation_elliptical() {
1133 let state =
1135 elliptical_orbit_state_at_periapsis(mu::EARTH, reference_orbits::GEO_RADIUS, 0.7);
1136 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::GEO_RADIUS);
1137
1138 let config = IntegratorConfig {
1139 dt: 5.0,
1140 mu: mu::EARTH,
1141 thrust: ThrustProfile::None,
1142 };
1143
1144 let states = propagate(&state, &config, period.value());
1145 let h0 = states[0].angular_momentum();
1146 let h0_mag = (h0.x * h0.x + h0.y * h0.y + h0.z * h0.z).sqrt();
1147
1148 for s in &states[1..] {
1149 let h = s.angular_momentum();
1150 let h_mag = (h.x * h.x + h.y * h.y + h.z * h.z).sqrt();
1151 let rel = ((h_mag - h0_mag) / h0_mag).abs();
1152 assert!(
1153 rel < 1e-8,
1154 "Elliptical (e=0.7) angular momentum drift = {:.2e} (should be <1e-8)",
1155 rel
1156 );
1157 }
1158 }
1159
1160 #[test]
1163 fn test_energy_conservation_circular_leo() {
1164 let state = circular_orbit_state(mu::EARTH, reference_orbits::LEO_RADIUS);
1167 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::LEO_RADIUS);
1168
1169 let config = IntegratorConfig {
1170 dt: 1.0, mu: mu::EARTH,
1172 thrust: ThrustProfile::None,
1173 };
1174
1175 let states = propagate(&state, &config, period.value());
1176 let (max_err, final_err) = energy_drift(&states, mu::EARTH);
1177
1178 assert!(
1179 max_err < 1e-10,
1180 "LEO 1-period max energy drift = {:.2e} (should be <1e-10)",
1181 max_err
1182 );
1183 assert!(
1184 final_err < 1e-10,
1185 "LEO 1-period final energy drift = {:.2e} (should be <1e-10)",
1186 final_err
1187 );
1188 }
1189
1190 #[test]
1191 fn test_energy_conservation_circular_leo_100_orbits() {
1192 let state = circular_orbit_state(mu::EARTH, reference_orbits::LEO_RADIUS);
1194 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::LEO_RADIUS);
1195 let duration = period.value() * 100.0;
1196
1197 let config = IntegratorConfig {
1198 dt: 5.0, mu: mu::EARTH,
1200 thrust: ThrustProfile::None,
1201 };
1202
1203 let final_state = propagate_final(&state, &config, duration);
1205 let e0 = state.specific_energy(mu::EARTH);
1206 let ef = final_state.specific_energy(mu::EARTH);
1207 let rel_err = ((ef - e0) / e0).abs();
1208
1209 assert!(
1210 rel_err < 1e-9,
1211 "LEO 100-orbit energy drift = {:.2e} (should be <1e-9)",
1212 rel_err
1213 );
1214 }
1215
1216 #[test]
1217 fn test_energy_conservation_elliptical_orbit() {
1218 let state =
1221 elliptical_orbit_state_at_periapsis(mu::EARTH, reference_orbits::GEO_RADIUS, 0.5);
1222 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::GEO_RADIUS);
1223
1224 let config = IntegratorConfig {
1225 dt: 5.0, mu: mu::EARTH,
1227 thrust: ThrustProfile::None,
1228 };
1229
1230 let states = propagate(&state, &config, period.value());
1231 let (max_err, final_err) = energy_drift(&states, mu::EARTH);
1232
1233 assert!(
1234 max_err < 1e-9,
1235 "Elliptical (e=0.5) 1-period max energy drift = {:.2e} (should be <1e-9)",
1236 max_err
1237 );
1238 assert!(
1239 final_err < 1e-9,
1240 "Elliptical (e=0.5) 1-period final energy drift = {:.2e} (should be <1e-9)",
1241 final_err
1242 );
1243 }
1244
1245 #[test]
1246 fn test_energy_conservation_elliptical_high_ecc() {
1247 let state =
1250 elliptical_orbit_state_at_periapsis(mu::EARTH, reference_orbits::GEO_RADIUS, 0.9);
1251 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::GEO_RADIUS);
1252
1253 let config = IntegratorConfig {
1254 dt: 1.0, mu: mu::EARTH,
1256 thrust: ThrustProfile::None,
1257 };
1258
1259 let states = propagate(&state, &config, period.value());
1260 let (max_err, _final_err) = energy_drift(&states, mu::EARTH);
1261
1262 assert!(
1263 max_err < 1e-7,
1264 "Highly elliptical (e=0.9) 1-period max energy drift = {:.2e} (should be <1e-7)",
1265 max_err
1266 );
1267 }
1268
1269 #[test]
1270 fn test_energy_conservation_heliocentric_earth() {
1271 let state = circular_orbit_state(mu::SUN, orbit_radius::EARTH);
1273 let period = crate::orbits::orbital_period(mu::SUN, orbit_radius::EARTH);
1274
1275 let config = IntegratorConfig {
1276 dt: 3600.0, mu: mu::SUN,
1278 thrust: ThrustProfile::None,
1279 };
1280
1281 let states = propagate(&state, &config, period.value());
1282 let (max_err, final_err) = energy_drift(&states, mu::SUN);
1283
1284 assert!(
1285 max_err < 1e-10,
1286 "Heliocentric Earth 1-period max energy drift = {:.2e} (should be <1e-10)",
1287 max_err
1288 );
1289 assert!(
1290 final_err < 1e-10,
1291 "Heliocentric Earth 1-period final energy drift = {:.2e} (should be <1e-10)",
1292 final_err
1293 );
1294 }
1295
1296 #[test]
1297 fn test_energy_conservation_heliocentric_1000_orbits() {
1298 let state = circular_orbit_state(mu::SUN, orbit_radius::EARTH);
1300 let period = crate::orbits::orbital_period(mu::SUN, orbit_radius::EARTH);
1301 let duration = period.value() * 1000.0;
1302
1303 let config = IntegratorConfig {
1304 dt: 3600.0, mu: mu::SUN,
1306 thrust: ThrustProfile::None,
1307 };
1308
1309 let final_state = propagate_final(&state, &config, duration);
1310 let e0 = state.specific_energy(mu::SUN);
1311 let ef = final_state.specific_energy(mu::SUN);
1312 let rel_err = ((ef - e0) / e0).abs();
1313
1314 assert!(
1315 rel_err < 1e-8,
1316 "1000 heliocentric orbits energy drift = {:.2e} (should be <1e-8)",
1317 rel_err
1318 );
1319 }
1320
1321 #[test]
1324 fn test_angular_momentum_conservation_circular() {
1325 let state = circular_orbit_state(mu::EARTH, reference_orbits::LEO_RADIUS);
1327 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::LEO_RADIUS);
1328
1329 let config = IntegratorConfig {
1330 dt: 1.0,
1331 mu: mu::EARTH,
1332 thrust: ThrustProfile::None,
1333 };
1334
1335 let states = propagate(&state, &config, period.value());
1336
1337 let h0 = states[0].angular_momentum();
1338 let h0_mag = (h0.x * h0.x + h0.y * h0.y + h0.z * h0.z).sqrt();
1339
1340 for state in &states[1..] {
1341 let h = state.angular_momentum();
1342 let h_mag = (h.x * h.x + h.y * h.y + h.z * h.z).sqrt();
1343 let rel = ((h_mag - h0_mag) / h0_mag).abs();
1344 assert!(
1345 rel < 1e-10,
1346 "Angular momentum drift = {:.2e} (should be <1e-10)",
1347 rel
1348 );
1349 }
1350 }
1351
1352 #[test]
1355 fn test_circular_orbit_returns_to_start() {
1356 let state = circular_orbit_state(mu::EARTH, reference_orbits::LEO_RADIUS);
1358 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::LEO_RADIUS);
1359
1360 let config = IntegratorConfig {
1361 dt: 1.0,
1362 mu: mu::EARTH,
1363 thrust: ThrustProfile::None,
1364 };
1365
1366 let final_state = propagate_final(&state, &config, period.value());
1367
1368 let dx = (final_state.pos.x.value() - state.pos.x.value()).abs();
1370 let dy = (final_state.pos.y.value() - state.pos.y.value()).abs();
1371 let dz = (final_state.pos.z.value() - state.pos.z.value()).abs();
1372 let pos_err = (dx * dx + dy * dy + dz * dz).sqrt();
1373 let radius = state.radius();
1374
1375 assert!(
1376 pos_err / radius < 1e-8,
1377 "Position error after 1 period = {:.2e} km ({:.2e} relative to radius)",
1378 pos_err,
1379 pos_err / radius
1380 );
1381
1382 let dvx = (final_state.vel.x.value() - state.vel.x.value()).abs();
1384 let dvy = (final_state.vel.y.value() - state.vel.y.value()).abs();
1385 let dvz = (final_state.vel.z.value() - state.vel.z.value()).abs();
1386 let vel_err = (dvx * dvx + dvy * dvy + dvz * dvz).sqrt();
1387 let speed = state.speed();
1388
1389 assert!(
1390 vel_err / speed < 1e-8,
1391 "Velocity error after 1 period = {:.2e} km/s ({:.2e} relative to speed)",
1392 vel_err,
1393 vel_err / speed
1394 );
1395 }
1396
1397 #[test]
1398 fn test_elliptical_orbit_returns_to_start() {
1399 let state =
1401 elliptical_orbit_state_at_periapsis(mu::EARTH, reference_orbits::GEO_RADIUS, 0.5);
1402 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::GEO_RADIUS);
1403
1404 let config = IntegratorConfig {
1405 dt: 5.0,
1406 mu: mu::EARTH,
1407 thrust: ThrustProfile::None,
1408 };
1409
1410 let final_state = propagate_final(&state, &config, period.value());
1411
1412 let dx = (final_state.pos.x.value() - state.pos.x.value()).abs();
1413 let dy = (final_state.pos.y.value() - state.pos.y.value()).abs();
1414 let pos_err = (dx * dx + dy * dy).sqrt();
1415 let r_p = state.radius();
1416
1417 assert!(
1418 pos_err / r_p < 1e-6,
1419 "Elliptical orbit position error after 1 period = {:.2e} km ({:.2e} relative)",
1420 pos_err,
1421 pos_err / r_p
1422 );
1423 }
1424
1425 #[test]
1428 fn test_constant_thrust_increases_energy() {
1429 let state = circular_orbit_state(mu::EARTH, reference_orbits::LEO_RADIUS);
1431 let e0 = state.specific_energy(mu::EARTH);
1432
1433 let config = IntegratorConfig {
1434 dt: 1.0,
1435 mu: mu::EARTH,
1436 thrust: ThrustProfile::ConstantPrograde {
1437 accel_km_s2: 1e-4, },
1439 };
1440
1441 let final_state = propagate_final(&state, &config, 600.0); let ef = final_state.specific_energy(mu::EARTH);
1443
1444 assert!(
1445 ef > e0,
1446 "Energy should increase with prograde thrust: e0={:.4}, ef={:.4}",
1447 e0,
1448 ef
1449 );
1450 }
1451
1452 #[test]
1453 fn test_brachistochrone_deceleration_phase() {
1454 let state = circular_orbit_state(mu::SUN, orbit_radius::EARTH);
1457 let duration = 72.0 * 3600.0; let flip = duration / 2.0;
1459
1460 let config_to_flip = IntegratorConfig {
1461 dt: 60.0,
1462 mu: mu::SUN,
1463 thrust: ThrustProfile::Brachistochrone {
1464 accel_km_s2: 1e-4,
1465 flip_time: flip,
1466 },
1467 };
1468
1469 let mid_state = propagate_final(&state, &config_to_flip, flip);
1471 let speed_at_flip = mid_state.speed();
1472
1473 let config_full = IntegratorConfig {
1475 dt: 60.0,
1476 mu: mu::SUN,
1477 thrust: ThrustProfile::Brachistochrone {
1478 accel_km_s2: 1e-4,
1479 flip_time: flip,
1480 },
1481 };
1482 let final_state = propagate_final(&state, &config_full, duration);
1483 let speed_final = final_state.speed();
1484
1485 assert!(
1487 speed_at_flip > state.speed(),
1488 "Speed at flip ({:.2} km/s) should exceed initial ({:.2} km/s)",
1489 speed_at_flip,
1490 state.speed()
1491 );
1492
1493 assert!(
1495 speed_final < speed_at_flip,
1496 "Final speed ({:.2} km/s) should be less than flip speed ({:.2} km/s)",
1497 speed_final,
1498 speed_at_flip
1499 );
1500 }
1501
1502 #[test]
1505 fn test_rk4_convergence_order() {
1506 let state = circular_orbit_state(mu::EARTH, reference_orbits::LEO_RADIUS);
1508 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::LEO_RADIUS);
1509
1510 let config_coarse = IntegratorConfig {
1512 dt: 100.0,
1513 mu: mu::EARTH,
1514 thrust: ThrustProfile::None,
1515 };
1516 let final_coarse = propagate_final(&state, &config_coarse, period.value());
1517 let err_coarse = (final_coarse.pos.x.value() - state.pos.x.value()).powi(2)
1518 + (final_coarse.pos.y.value() - state.pos.y.value()).powi(2);
1519 let err_coarse = err_coarse.sqrt();
1520
1521 let config_fine = IntegratorConfig {
1523 dt: 50.0,
1524 mu: mu::EARTH,
1525 thrust: ThrustProfile::None,
1526 };
1527 let final_fine = propagate_final(&state, &config_fine, period.value());
1528 let err_fine = (final_fine.pos.x.value() - state.pos.x.value()).powi(2)
1529 + (final_fine.pos.y.value() - state.pos.y.value()).powi(2);
1530 let err_fine = err_fine.sqrt();
1531
1532 if err_coarse > 1e-12 && err_fine > 1e-12 {
1535 let ratio = err_coarse / err_fine;
1536 assert!(
1537 ratio > 8.0,
1538 "RK4 convergence ratio = {:.1} (expected >8 for 4th-order method). \
1539 err_coarse={:.2e}, err_fine={:.2e}",
1540 ratio,
1541 err_coarse,
1542 err_fine
1543 );
1544 }
1545 }
1547
1548 #[test]
1551 fn test_heliocentric_mars_orbit_stability() {
1552 let state = circular_orbit_state(mu::SUN, orbit_radius::MARS);
1554 let period = crate::orbits::orbital_period(mu::SUN, orbit_radius::MARS);
1555 let duration = period.value() * 10.0;
1556
1557 let config = IntegratorConfig {
1558 dt: 3600.0,
1559 mu: mu::SUN,
1560 thrust: ThrustProfile::None,
1561 };
1562
1563 let final_state = propagate_final(&state, &config, duration);
1564 let e0 = state.specific_energy(mu::SUN);
1565 let ef = final_state.specific_energy(mu::SUN);
1566 let rel_err = ((ef - e0) / e0).abs();
1567
1568 assert!(
1569 rel_err < 1e-10,
1570 "Mars 10-period energy drift = {:.2e}",
1571 rel_err
1572 );
1573 }
1574
1575 #[test]
1576 fn test_heliocentric_jupiter_orbit_stability() {
1577 let state = circular_orbit_state(mu::SUN, orbit_radius::JUPITER);
1579 let period = crate::orbits::orbital_period(mu::SUN, orbit_radius::JUPITER);
1580 let duration = period.value() * 5.0;
1581
1582 let config = IntegratorConfig {
1583 dt: 7200.0, mu: mu::SUN,
1585 thrust: ThrustProfile::None,
1586 };
1587
1588 let final_state = propagate_final(&state, &config, duration);
1589 let e0 = state.specific_energy(mu::SUN);
1590 let ef = final_state.specific_energy(mu::SUN);
1591 let rel_err = ((ef - e0) / e0).abs();
1592
1593 assert!(
1594 rel_err < 1e-10,
1595 "Jupiter 5-period energy drift = {:.2e}",
1596 rel_err
1597 );
1598 }
1599
1600 #[test]
1603 fn test_ep01_brachistochrone_mars_to_jupiter_72h() {
1604 let distance_target = 550_630_800.0_f64; let duration = 72.0 * 3600.0; let accel = 4.0 * distance_target / (duration * duration); let flip_time = duration / 2.0;
1617
1618 let r_mars = orbit_radius::MARS.value();
1622 let v_mars_circ = (mu::SUN.value() / r_mars).sqrt();
1623
1624 let state = PropState::new(
1626 Vec3::new(Km(r_mars), Km(0.0), Km(0.0)),
1627 Vec3::new(KmPerSec(0.0), KmPerSec(v_mars_circ), KmPerSec(0.0)),
1628 );
1629
1630 let config = IntegratorConfig {
1632 dt: 60.0, mu: mu::SUN,
1634 thrust: ThrustProfile::Brachistochrone {
1635 accel_km_s2: accel,
1636 flip_time,
1637 },
1638 };
1639
1640 let final_state = propagate_final(&state, &config, duration);
1641
1642 let dx = final_state.pos.x.value() - state.pos.x.value();
1644 let dy = final_state.pos.y.value() - state.pos.y.value();
1645 let dz = final_state.pos.z.value() - state.pos.z.value();
1646 let distance_traveled = (dx * dx + dy * dy + dz * dz).sqrt();
1647
1648 let ratio = distance_traveled / distance_target;
1654 assert!(
1655 (0.5..2.0).contains(&ratio),
1656 "EP01: Propagated distance = {:.0} km, target = {:.0} km, ratio = {:.2}. \
1657 The propagation should produce a distance in the same order of magnitude.",
1658 distance_traveled,
1659 distance_target,
1660 ratio
1661 );
1662
1663 let mid_state = propagate_final(&state, &config, flip_time);
1667 let speed_at_flip = mid_state.speed();
1668 let speed_final = final_state.speed();
1669
1670 assert!(
1671 speed_final < speed_at_flip,
1672 "EP01: Final speed ({:.1} km/s) should be less than flip speed ({:.1} km/s)",
1673 speed_final,
1674 speed_at_flip
1675 );
1676
1677 let r_jupiter = orbit_radius::JUPITER.value();
1680 let final_r = final_state.radius();
1681 let r_ratio = final_r / r_jupiter;
1682 assert!(
1683 (0.3..3.0).contains(&r_ratio),
1684 "EP01: Final heliocentric distance = {:.0} km ({:.2} of Jupiter orbit). \
1685 Should be in the Jupiter neighborhood.",
1686 final_r,
1687 r_ratio
1688 );
1689 }
1690
1691 #[test]
1692 fn test_ep01_brachistochrone_desk_vs_propagation_dv() {
1693 let distance = 550_630_800.0_f64;
1696 let duration = 72.0 * 3600.0;
1697 let accel = 4.0 * distance / (duration * duration);
1698
1699 let desk_dv =
1701 crate::orbits::brachistochrone_dv(Km(distance), crate::units::Seconds(duration));
1702
1703 let r_mars = orbit_radius::MARS.value();
1705 let v_mars = (mu::SUN.value() / r_mars).sqrt();
1706 let state = PropState::new(
1707 Vec3::new(Km(r_mars), Km(0.0), Km(0.0)),
1708 Vec3::new(KmPerSec(0.0), KmPerSec(v_mars), KmPerSec(0.0)),
1709 );
1710
1711 let config = IntegratorConfig {
1712 dt: 60.0,
1713 mu: mu::SUN,
1714 thrust: ThrustProfile::Brachistochrone {
1715 accel_km_s2: accel,
1716 flip_time: duration / 2.0,
1717 },
1718 };
1719
1720 let actual_dv = accel * duration;
1724 assert!(
1725 (actual_dv - desk_dv.value()).abs() < 1e-3,
1726 "ΔV from propagation ({:.2} km/s) should match desk ({:.2} km/s)",
1727 actual_dv,
1728 desk_dv.value()
1729 );
1730
1731 let final_state = propagate_final(&state, &config, duration);
1735 let final_r = final_state.radius();
1736
1737 assert!(
1739 final_r > orbit_radius::MARS.value(),
1740 "EP01: Ship should have moved outward from Mars"
1741 );
1742 }
1743
1744 #[test]
1747 fn test_ep02_ballistic_jupiter_to_saturn_455d() {
1748 let duration = 455.0 * 24.0 * 3600.0; let r_jupiter = orbit_radius::JUPITER.value();
1770 let r_saturn = orbit_radius::SATURN.value();
1771 let v_depart_total = 18.99_f64; let a_transfer = (r_jupiter + r_saturn) / 2.0;
1777 let v_tang = (mu::SUN.value() * (2.0 / r_jupiter - 1.0 / a_transfer)).sqrt();
1781
1782 let v_radial = if v_depart_total > v_tang {
1785 (v_depart_total * v_depart_total - v_tang * v_tang).sqrt()
1786 } else {
1787 0.0
1788 };
1789
1790 let state = PropState::new(
1792 Vec3::new(Km(r_jupiter), Km(0.0), Km(0.0)),
1793 Vec3::new(KmPerSec(v_radial), KmPerSec(v_tang), KmPerSec(0.0)),
1794 );
1795
1796 let config = IntegratorConfig {
1797 dt: 3600.0, mu: mu::SUN,
1799 thrust: ThrustProfile::None,
1800 };
1801
1802 let final_state = propagate_final(&state, &config, duration);
1804 let e0 = state.specific_energy(mu::SUN);
1805 let ef = final_state.specific_energy(mu::SUN);
1806 let rel_err = ((ef - e0) / e0).abs();
1807 assert!(
1808 rel_err < 1e-9,
1809 "EP02 ballistic 455d energy drift = {:.2e} (should be <1e-9)",
1810 rel_err
1811 );
1812
1813 let final_r = final_state.radius();
1815 let r_ratio = final_r / r_saturn;
1816 assert!(
1817 (0.5..2.0).contains(&r_ratio),
1818 "EP02: Final heliocentric r = {:.0} km ({:.2}× Saturn orbit). \
1819 Should be in Saturn's neighborhood.",
1820 final_r,
1821 r_ratio
1822 );
1823
1824 assert!(
1826 final_r > r_jupiter,
1827 "EP02: Ship should have moved outward from Jupiter orbit"
1828 );
1829
1830 let final_v = final_state.speed();
1832 let v_visviva = (2.0 * (e0 + mu::SUN.value() / final_r)).sqrt();
1833 let v_ratio = final_v / v_visviva;
1834 assert!(
1835 (0.95..1.05).contains(&v_ratio),
1836 "EP02: Final speed {:.2} km/s vs vis-viva predicted {:.2} km/s (ratio {:.4})",
1837 final_v,
1838 v_visviva,
1839 v_ratio
1840 );
1841 }
1842
1843 #[test]
1844 fn test_ep02_trajectory_is_elliptic_not_hyperbolic() {
1845 let r_jupiter = orbit_radius::JUPITER.value();
1854 let v_depart = 18.99;
1855 let v_esc_sun = (2.0 * mu::SUN.value() / r_jupiter).sqrt();
1856
1857 assert!(
1859 v_depart > v_esc_sun,
1860 "EP02: v_depart ({:.2}) should exceed solar escape at Jupiter ({:.2})",
1861 v_depart,
1862 v_esc_sun
1863 );
1864
1865 let state = PropState::new(
1866 Vec3::new(Km(r_jupiter), Km(0.0), Km(0.0)),
1867 Vec3::new(KmPerSec(0.0), KmPerSec(v_depart), KmPerSec(0.0)),
1868 );
1869
1870 let energy = state.specific_energy(mu::SUN);
1871 assert!(
1873 energy > 0.0,
1874 "EP02: Specific energy {:.4} should be positive (solar-hyperbolic)",
1875 energy
1876 );
1877
1878 let v_inf_solar = (2.0 * energy).sqrt();
1880 assert!(
1881 v_inf_solar < 5.0,
1882 "EP02: Solar v∞ = {:.2} km/s should be small (barely escaping)",
1883 v_inf_solar
1884 );
1885
1886 }
1890
1891 #[test]
1892 fn test_ep02_saturn_capture_delta_v() {
1893 let r_enceladus = 238_020.0; let v_inf = 4.69; let v_esc = (2.0 * mu::SATURN.value() / r_enceladus).sqrt();
1905 let v_circ = (mu::SATURN.value() / r_enceladus).sqrt();
1907
1908 let v_hyp = (v_inf * v_inf + v_esc * v_esc).sqrt();
1910
1911 let dv_capture = v_hyp - v_circ;
1913
1914 assert!(
1917 dv_capture > 0.5,
1918 "EP02: Circular capture ΔV = {:.2} km/s should be > 0.5",
1919 dv_capture
1920 );
1921
1922 let dv_min_capture = v_hyp - v_esc;
1928 let report_dv = 0.61;
1929 assert!(
1930 (dv_min_capture - report_dv).abs() < 0.2,
1931 "EP02: Min capture ΔV = {:.2} km/s vs report {:.2} km/s",
1932 dv_min_capture,
1933 report_dv
1934 );
1935 }
1936
1937 #[test]
1940 fn test_ep03_brachistochrone_saturn_to_uranus_143h() {
1941 let distance_target = 1_438_930_000.0_f64; let duration = 515_520.0; let accel = 4.0 * distance_target / (duration * duration); let flip_time = duration / 2.0;
1951
1952 let accel_ms2 = accel * 1000.0;
1954 assert!(
1955 (accel_ms2 - 21.66).abs() < 1.0,
1956 "EP03: Acceleration {:.2} m/s² should be ~21.66 m/s²",
1957 accel_ms2
1958 );
1959
1960 let r_saturn = orbit_radius::SATURN.value();
1962 let v_saturn_circ = (mu::SUN.value() / r_saturn).sqrt();
1963
1964 let state = PropState::new(
1965 Vec3::new(Km(r_saturn), Km(0.0), Km(0.0)),
1966 Vec3::new(KmPerSec(0.0), KmPerSec(v_saturn_circ), KmPerSec(0.0)),
1967 );
1968
1969 let config = IntegratorConfig {
1970 dt: 60.0, mu: mu::SUN,
1972 thrust: ThrustProfile::Brachistochrone {
1973 accel_km_s2: accel,
1974 flip_time,
1975 },
1976 };
1977
1978 let final_state = propagate_final(&state, &config, duration);
1979
1980 let dx = final_state.pos.x.value() - state.pos.x.value();
1982 let dy = final_state.pos.y.value() - state.pos.y.value();
1983 let dz = final_state.pos.z.value() - state.pos.z.value();
1984 let distance_traveled = (dx * dx + dy * dy + dz * dz).sqrt();
1985
1986 let ratio = distance_traveled / distance_target;
1988 assert!(
1989 (0.5..2.0).contains(&ratio),
1990 "EP03: Propagated distance = {:.0} km, target = {:.0} km, ratio = {:.2}",
1991 distance_traveled,
1992 distance_target,
1993 ratio
1994 );
1995
1996 let r_uranus = orbit_radius::URANUS.value();
1998 let final_r = final_state.radius();
1999 let r_ratio = final_r / r_uranus;
2000 assert!(
2001 (0.5..2.0).contains(&r_ratio),
2002 "EP03: Final heliocentric r = {:.0} km ({:.2}× Uranus orbit)",
2003 final_r,
2004 r_ratio
2005 );
2006
2007 let mid_state = propagate_final(&state, &config, flip_time);
2009 let speed_at_flip = mid_state.speed();
2010 let _expected_cruise = accel * flip_time; assert!(
2015 speed_at_flip > 1000.0,
2016 "EP03: Speed at flip = {:.0} km/s should be > 1000 km/s (cruise ~3000 km/s)",
2017 speed_at_flip
2018 );
2019
2020 let speed_final = final_state.speed();
2022 assert!(
2023 speed_final < speed_at_flip,
2024 "EP03: Final speed ({:.0} km/s) < flip speed ({:.0} km/s)",
2025 speed_final,
2026 speed_at_flip
2027 );
2028 }
2029
2030 #[test]
2031 fn test_ep03_desk_dv_matches_accel_times_duration() {
2032 let distance = 1_438_930_000.0_f64;
2035 let duration = 515_520.0;
2036
2037 let desk_dv =
2038 crate::orbits::brachistochrone_dv(Km(distance), crate::units::Seconds(duration));
2039
2040 assert!(
2042 (desk_dv.value() - 11_165.0).abs() < 50.0,
2043 "EP03: Desk ΔV = {:.0} km/s, expected ~11,165",
2044 desk_dv.value()
2045 );
2046
2047 let thrust_n = 9.8e6;
2050 let mass_kg = 452_000.0;
2051 let a_check = thrust_n / mass_kg / 1000.0; let a_desk = 4.0 * distance / (duration * duration);
2053 assert!(
2054 (a_check - a_desk).abs() / a_desk < 0.01,
2055 "EP03: Mass boundary accel {:.4e} vs desk {:.4e} km/s²",
2056 a_check,
2057 a_desk
2058 );
2059 }
2060
2061 #[test]
2064 fn test_ep05_brachistochrone_uranus_to_earth_at_300t() {
2065 let distance_target = 2_722_861_977.0_f64; let duration = 8.3 * 24.0 * 3600.0; let accel = 4.0 * distance_target / (duration * duration); let flip_time = duration / 2.0;
2075
2076 let desk_dv =
2078 crate::orbits::brachistochrone_dv(Km(distance_target), crate::units::Seconds(duration));
2079 assert!(
2080 (desk_dv.value() - 15_207.0).abs() < 100.0,
2081 "EP05: Desk ΔV = {:.0} km/s, expected ~15,207",
2082 desk_dv.value()
2083 );
2084
2085 let r_uranus = orbit_radius::URANUS.value();
2095
2096 let state = PropState::new(
2097 Vec3::new(Km(r_uranus), Km(0.0), Km(0.0)),
2098 Vec3::new(KmPerSec(-10.0), KmPerSec(0.0), KmPerSec(0.0)),
2100 );
2101
2102 let config = IntegratorConfig {
2103 dt: 60.0,
2104 mu: mu::SUN,
2105 thrust: ThrustProfile::Brachistochrone {
2106 accel_km_s2: accel,
2107 flip_time,
2108 },
2109 };
2110
2111 let final_state = propagate_final(&state, &config, duration);
2112
2113 let final_r = final_state.radius();
2115
2116 assert!(
2118 final_r < r_uranus * 0.5,
2119 "EP05 @300t: Ship should move well inward from Uranus. \
2120 Final r = {:.0} km ({:.2} AU)",
2121 final_r,
2122 final_r / orbit_radius::EARTH.value()
2123 );
2124
2125 let distance_from_start = (final_state.pos.x.value() - state.pos.x.value()).abs();
2129 assert!(
2130 distance_from_start > distance_target * 0.3,
2131 "EP05 @300t: Distance traveled {:.0} km should be significant fraction \
2132 of {:.0} km (desk estimate)",
2133 distance_from_start,
2134 distance_target
2135 );
2136 }
2137
2138 #[test]
2139 fn test_ep05_nozzle_lifespan_margin() {
2140 let nozzle_life_s = 55.0 * 3600.0 + 38.0 * 60.0; let burn_time_s = 55.0 * 3600.0 + 12.0 * 60.0; let margin_s: f64 = nozzle_life_s - burn_time_s;
2150 let margin_pct: f64 = margin_s / nozzle_life_s * 100.0;
2151
2152 assert!(
2153 (margin_s - 1560.0).abs() < 1.0,
2154 "EP05: Nozzle margin = {:.0} s (expected 1560 s = 26 min)",
2155 margin_s
2156 );
2157 assert!(
2158 (margin_pct - 0.78).abs() < 0.01,
2159 "EP05: Nozzle margin = {:.2}% (expected 0.78%)",
2160 margin_pct
2161 );
2162
2163 let burn_without_oberth = 56.0 * 3600.0 + 51.0 * 60.0; assert!(
2167 burn_without_oberth > nozzle_life_s,
2168 "EP05: Without Jupiter Oberth, burn time {:.0}s > nozzle life {:.0}s — nozzle destroyed en route",
2169 burn_without_oberth,
2170 nozzle_life_s
2171 );
2172 }
2173
2174 #[test]
2175 fn test_ep05_cruise_velocity_propagation() {
2176 let coast_duration = 375.0 * 3600.0; let v_cruise = 1_500.0; let r_start = 10.0 * orbit_radius::EARTH.value(); let state = PropState::new(
2191 Vec3::new(Km(r_start), Km(0.0), Km(0.0)),
2192 Vec3::new(KmPerSec(0.0), KmPerSec(-v_cruise), KmPerSec(0.0)),
2194 );
2195
2196 let config = IntegratorConfig {
2197 dt: 3600.0, mu: mu::SUN,
2199 thrust: ThrustProfile::None,
2200 };
2201
2202 let final_state = propagate_final(&state, &config, coast_duration);
2203 let final_speed = final_state.speed();
2204
2205 let speed_change = (final_speed - v_cruise).abs();
2209 assert!(
2210 speed_change < 5.0,
2211 "EP05: Speed change during 375h coast = {:.2} km/s (expected < 5 km/s gravity loss)",
2212 speed_change
2213 );
2214
2215 let e0 = state.specific_energy(mu::SUN);
2217 let ef = final_state.specific_energy(mu::SUN);
2218 let rel_err = ((ef - e0) / e0).abs();
2219 assert!(rel_err < 1e-9, "EP05 coast energy drift = {:.2e}", rel_err);
2220 }
2221
2222 #[test]
2223 fn test_ep05_earth_capture_delta_v() {
2224 let r_leo = 6_378.137 + 400.0; let v_circ = (mu::EARTH.value() / r_leo).sqrt();
2235
2236 assert!(
2237 (v_circ - 7.67).abs() < 0.1,
2238 "EP05: LEO 400km circular velocity = {:.2} km/s (expected ~7.67)",
2239 v_circ
2240 );
2241
2242 let v_esc_leo = (2.0 * mu::EARTH.value() / r_leo).sqrt();
2245 let dv_capture = v_esc_leo - v_circ;
2246
2247 assert!(
2248 (dv_capture - 3.18).abs() < 0.2,
2249 "EP05: Capture ΔV from parabolic = {:.2} km/s (expected ~3.18)",
2250 dv_capture
2251 );
2252 }
2253
2254 #[test]
2259 fn test_adaptive_energy_conservation_circular_leo() {
2260 let state = circular_orbit_state(mu::EARTH, reference_orbits::LEO_RADIUS);
2262 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::LEO_RADIUS);
2263
2264 let mut config = AdaptiveConfig::planetocentric(mu::EARTH, ThrustProfile::None);
2265 config.rtol = 1e-12;
2266 config.atol = 1e-14;
2267 let result = propagate_adaptive(&state, &config, period.value());
2268 let (max_err, final_err) = energy_drift(&result.states, mu::EARTH);
2269
2270 assert!(
2271 max_err < 1e-10,
2272 "Adaptive LEO 1-period max energy drift = {:.2e} (should be <1e-10)",
2273 max_err
2274 );
2275 assert!(
2276 final_err < 1e-10,
2277 "Adaptive LEO 1-period final energy drift = {:.2e} (should be <1e-10)",
2278 final_err
2279 );
2280 }
2281
2282 #[test]
2283 fn test_adaptive_energy_conservation_elliptical_e09() {
2284 let state =
2288 elliptical_orbit_state_at_periapsis(mu::EARTH, reference_orbits::GEO_RADIUS, 0.9);
2289 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::GEO_RADIUS);
2290
2291 let config = AdaptiveConfig::planetocentric(mu::EARTH, ThrustProfile::None);
2292 let result = propagate_adaptive(&state, &config, period.value());
2293 let (max_err, _) = energy_drift(&result.states, mu::EARTH);
2294
2295 assert!(
2296 max_err < 1e-7,
2297 "Adaptive elliptical (e=0.9) max energy drift = {:.2e} (should be <1e-7)",
2298 max_err
2299 );
2300
2301 let fixed_rk4_evals = (period.value() / 1.0).ceil() as usize * 4;
2304 assert!(
2305 result.n_eval < fixed_rk4_evals,
2306 "Adaptive should use fewer evals ({}) than fixed RK4 ({}) for e=0.9",
2307 result.n_eval,
2308 fixed_rk4_evals
2309 );
2310 }
2311
2312 #[test]
2313 fn test_adaptive_energy_conservation_heliocentric_1000_orbits() {
2314 let state = circular_orbit_state(mu::SUN, orbit_radius::EARTH);
2316 let period = crate::orbits::orbital_period(mu::SUN, orbit_radius::EARTH);
2317 let duration = period.value() * 1000.0;
2318
2319 let mut config = AdaptiveConfig::heliocentric(mu::SUN, ThrustProfile::None);
2320 config.rtol = 1e-10; let (final_state, n_eval) = propagate_adaptive_final(&state, &config, duration);
2323 let e0 = state.specific_energy(mu::SUN);
2324 let ef = final_state.specific_energy(mu::SUN);
2325 let rel_err = ((ef - e0) / e0).abs();
2326
2327 assert!(
2328 rel_err < 1e-8,
2329 "Adaptive 1000 heliocentric orbits energy drift = {:.2e} (should be <1e-8)",
2330 rel_err
2331 );
2332
2333 assert!(
2335 n_eval > 0,
2336 "Adaptive should have taken some steps (took {} evals)",
2337 n_eval
2338 );
2339 }
2340
2341 #[test]
2342 fn test_adaptive_orbit_return_circular() {
2343 let state = circular_orbit_state(mu::EARTH, reference_orbits::LEO_RADIUS);
2345 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::LEO_RADIUS);
2346
2347 let mut config = AdaptiveConfig::planetocentric(mu::EARTH, ThrustProfile::None);
2348 config.rtol = 1e-12;
2349 config.atol = 1e-14;
2350 let (final_state, _) = propagate_adaptive_final(&state, &config, period.value());
2351
2352 let dx = (final_state.pos.x.value() - state.pos.x.value()).abs();
2353 let dy = (final_state.pos.y.value() - state.pos.y.value()).abs();
2354 let dz = (final_state.pos.z.value() - state.pos.z.value()).abs();
2355 let pos_err = (dx * dx + dy * dy + dz * dz).sqrt();
2356 let radius = state.radius();
2357
2358 assert!(
2359 pos_err / radius < 1e-8,
2360 "Adaptive position error after 1 period = {:.2e} (relative {:.2e})",
2361 pos_err,
2362 pos_err / radius
2363 );
2364 }
2365
2366 #[test]
2367 fn test_adaptive_brachistochrone_handles_flip() {
2368 let state = circular_orbit_state(mu::SUN, orbit_radius::EARTH);
2371 let duration = 72.0 * 3600.0;
2372 let flip = duration / 2.0;
2373 let accel = 1e-4; let config = AdaptiveConfig::heliocentric(
2376 mu::SUN,
2377 ThrustProfile::Brachistochrone {
2378 accel_km_s2: accel,
2379 flip_time: flip,
2380 },
2381 );
2382
2383 let result = propagate_adaptive(&state, &config, duration);
2384
2385 let mut speed_at_flip = 0.0_f64;
2387 for s in &result.states {
2388 if (s.time - flip).abs() < 120.0 {
2389 speed_at_flip = s.speed();
2390 break;
2391 }
2392 }
2393
2394 let final_speed = result.states.last().unwrap().speed();
2395
2396 assert!(
2398 speed_at_flip > state.speed(),
2399 "Adaptive: speed at flip ({:.1}) > initial ({:.1})",
2400 speed_at_flip,
2401 state.speed()
2402 );
2403
2404 assert!(
2406 final_speed < speed_at_flip,
2407 "Adaptive: final speed ({:.1}) < flip speed ({:.1})",
2408 final_speed,
2409 speed_at_flip
2410 );
2411 }
2412
2413 #[test]
2414 fn test_adaptive_vs_rk4_accuracy_comparison() {
2415 let state =
2418 elliptical_orbit_state_at_periapsis(mu::EARTH, reference_orbits::GEO_RADIUS, 0.5);
2419 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::GEO_RADIUS);
2420
2421 let config_rk4 = IntegratorConfig {
2423 dt: 1.0, mu: mu::EARTH,
2425 thrust: ThrustProfile::None,
2426 };
2427 let rk4_final = propagate_final(&state, &config_rk4, period.value());
2428
2429 let config_adaptive = AdaptiveConfig::planetocentric(mu::EARTH, ThrustProfile::None);
2431 let (adaptive_final, n_eval) =
2432 propagate_adaptive_final(&state, &config_adaptive, period.value());
2433
2434 let dx = (rk4_final.pos.x.value() - adaptive_final.pos.x.value()).abs();
2436 let dy = (rk4_final.pos.y.value() - adaptive_final.pos.y.value()).abs();
2437 let dz = (rk4_final.pos.z.value() - adaptive_final.pos.z.value()).abs();
2438 let pos_diff = (dx * dx + dy * dy + dz * dz).sqrt();
2439 let radius = state.radius();
2440
2441 assert!(
2442 pos_diff / radius < 1e-6,
2443 "Adaptive vs RK4 position difference = {:.2e} km ({:.2e} relative)",
2444 pos_diff,
2445 pos_diff / radius
2446 );
2447
2448 let rk4_evals = (period.value() / 1.0).ceil() as usize * 4;
2450 assert!(
2451 n_eval < rk4_evals,
2452 "Adaptive ({} evals) should be more efficient than RK4 dt=1s ({} evals)",
2453 n_eval,
2454 rk4_evals
2455 );
2456 }
2457
2458 #[test]
2459 fn test_adaptive_efficiency_high_eccentricity() {
2460 let state =
2464 elliptical_orbit_state_at_periapsis(mu::EARTH, reference_orbits::GEO_RADIUS, 0.9);
2465 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::GEO_RADIUS);
2466
2467 let config = AdaptiveConfig::planetocentric(mu::EARTH, ThrustProfile::None);
2468 let result = propagate_adaptive(&state, &config, period.value());
2469
2470 let mut min_dt = f64::MAX;
2473 let mut max_dt = 0.0_f64;
2474 for i in 1..result.states.len() {
2475 let dt = result.states[i].time - result.states[i - 1].time;
2476 if dt < min_dt {
2477 min_dt = dt;
2478 }
2479 if dt > max_dt {
2480 max_dt = dt;
2481 }
2482 }
2483
2484 let ratio = max_dt / min_dt;
2486 assert!(
2487 ratio > 10.0,
2488 "Adaptive step size ratio (max/min) = {:.1} (expected >10 for e=0.9). \
2489 min_dt={:.2e}s, max_dt={:.2e}s",
2490 ratio,
2491 min_dt,
2492 max_dt
2493 );
2494 }
2495
2496 #[test]
2497 fn test_adaptive_heliocentric_mars_10_periods() {
2498 let state = circular_orbit_state(mu::SUN, orbit_radius::MARS);
2500 let period = crate::orbits::orbital_period(mu::SUN, orbit_radius::MARS);
2501 let duration = period.value() * 10.0;
2502
2503 let config = AdaptiveConfig::heliocentric(mu::SUN, ThrustProfile::None);
2504 let (final_state, n_eval) = propagate_adaptive_final(&state, &config, duration);
2505 let e0 = state.specific_energy(mu::SUN);
2506 let ef = final_state.specific_energy(mu::SUN);
2507 let rel_err = ((ef - e0) / e0).abs();
2508
2509 assert!(
2510 rel_err < 1e-10,
2511 "Adaptive Mars 10-period energy drift = {:.2e}",
2512 rel_err
2513 );
2514
2515 assert!(
2517 n_eval > 0,
2518 "Adaptive Mars 10-period should have taken steps"
2519 );
2520 }
2521
2522 #[test]
2523 fn test_adaptive_ep01_brachistochrone_mars_to_jupiter() {
2524 let distance_target = 550_630_800.0_f64;
2527 let duration = 72.0 * 3600.0;
2528 let accel = 4.0 * distance_target / (duration * duration);
2529 let flip_time = duration / 2.0;
2530
2531 let r_mars = orbit_radius::MARS.value();
2532 let v_mars_circ = (mu::SUN.value() / r_mars).sqrt();
2533
2534 let state = PropState::new(
2535 Vec3::new(Km(r_mars), Km(0.0), Km(0.0)),
2536 Vec3::new(KmPerSec(0.0), KmPerSec(v_mars_circ), KmPerSec(0.0)),
2537 );
2538
2539 let config = AdaptiveConfig::heliocentric(
2540 mu::SUN,
2541 ThrustProfile::Brachistochrone {
2542 accel_km_s2: accel,
2543 flip_time,
2544 },
2545 );
2546
2547 let (final_state, _n_eval) = propagate_adaptive_final(&state, &config, duration);
2548
2549 let dx = final_state.pos.x.value() - state.pos.x.value();
2551 let dy = final_state.pos.y.value() - state.pos.y.value();
2552 let dz = final_state.pos.z.value() - state.pos.z.value();
2553 let distance_traveled = (dx * dx + dy * dy + dz * dz).sqrt();
2554
2555 let ratio = distance_traveled / distance_target;
2556 assert!(
2557 (0.5..2.0).contains(&ratio),
2558 "Adaptive EP01: distance = {:.0} km, target = {:.0} km, ratio = {:.2}",
2559 distance_traveled,
2560 distance_target,
2561 ratio
2562 );
2563
2564 let config_rk4 = IntegratorConfig {
2566 dt: 60.0,
2567 mu: mu::SUN,
2568 thrust: ThrustProfile::Brachistochrone {
2569 accel_km_s2: accel,
2570 flip_time,
2571 },
2572 };
2573 let rk4_final = propagate_final(&state, &config_rk4, duration);
2574
2575 let dx2 = (final_state.pos.x.value() - rk4_final.pos.x.value()).abs();
2577 let dy2 = (final_state.pos.y.value() - rk4_final.pos.y.value()).abs();
2578 let diff = (dx2 * dx2 + dy2 * dy2).sqrt();
2579 let scale = distance_traveled;
2580
2581 assert!(
2582 diff / scale < 0.01,
2583 "Adaptive vs RK4 EP01 position difference = {:.2e} km ({:.4}%)",
2584 diff,
2585 diff / scale * 100.0
2586 );
2587 }
2588
2589 #[test]
2590 fn test_adaptive_ep02_ballistic_455d() {
2591 let duration = 455.0 * 24.0 * 3600.0;
2594 let r_jupiter = orbit_radius::JUPITER.value();
2595 let r_saturn = orbit_radius::SATURN.value();
2596 let v_depart_total = 18.99_f64;
2597
2598 let a_transfer = (r_jupiter + r_saturn) / 2.0;
2599 let v_tang = (mu::SUN.value() * (2.0 / r_jupiter - 1.0 / a_transfer)).sqrt();
2600 let v_radial = if v_depart_total > v_tang {
2601 (v_depart_total * v_depart_total - v_tang * v_tang).sqrt()
2602 } else {
2603 0.0
2604 };
2605
2606 let state = PropState::new(
2607 Vec3::new(Km(r_jupiter), Km(0.0), Km(0.0)),
2608 Vec3::new(KmPerSec(v_radial), KmPerSec(v_tang), KmPerSec(0.0)),
2609 );
2610
2611 let config = AdaptiveConfig::heliocentric(mu::SUN, ThrustProfile::None);
2612 let (final_state, n_eval) = propagate_adaptive_final(&state, &config, duration);
2613
2614 let e0 = state.specific_energy(mu::SUN);
2616 let ef = final_state.specific_energy(mu::SUN);
2617 let rel_err = ((ef - e0) / e0).abs();
2618 assert!(
2619 rel_err < 1e-9,
2620 "Adaptive EP02 energy drift = {:.2e}",
2621 rel_err
2622 );
2623
2624 let final_r = final_state.radius();
2626 let r_ratio = final_r / r_saturn;
2627 assert!(
2628 (0.5..2.0).contains(&r_ratio),
2629 "Adaptive EP02: final r = {:.0} km ({:.2}× Saturn)",
2630 final_r,
2631 r_ratio
2632 );
2633
2634 assert!(n_eval > 0, "Should have taken steps (n_eval={})", n_eval);
2636 }
2637
2638 #[test]
2639 fn test_adaptive_convergence_with_tolerance() {
2640 let state =
2642 elliptical_orbit_state_at_periapsis(mu::EARTH, reference_orbits::GEO_RADIUS, 0.5);
2643 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::GEO_RADIUS);
2644
2645 let mut config_loose = AdaptiveConfig::planetocentric(mu::EARTH, ThrustProfile::None);
2647 config_loose.rtol = 1e-4;
2648 config_loose.atol = 1e-6;
2649 let (final_loose, _) = propagate_adaptive_final(&state, &config_loose, period.value());
2650
2651 let mut config_tight = AdaptiveConfig::planetocentric(mu::EARTH, ThrustProfile::None);
2653 config_tight.rtol = 1e-10;
2654 config_tight.atol = 1e-12;
2655 let (final_tight, _) = propagate_adaptive_final(&state, &config_tight, period.value());
2656
2657 let err_loose = (final_loose.pos.x.value() - state.pos.x.value()).powi(2)
2659 + (final_loose.pos.y.value() - state.pos.y.value()).powi(2);
2660 let err_loose = err_loose.sqrt();
2661
2662 let err_tight = (final_tight.pos.x.value() - state.pos.x.value()).powi(2)
2663 + (final_tight.pos.y.value() - state.pos.y.value()).powi(2);
2664 let err_tight = err_tight.sqrt();
2665
2666 assert!(
2667 err_tight < err_loose,
2668 "Tight tolerance error ({:.2e}) should be less than loose ({:.2e})",
2669 err_tight,
2670 err_loose
2671 );
2672 }
2673
2674 #[test]
2677 fn test_symplectic_energy_conservation_circular_leo() {
2678 let state = circular_orbit_state(mu::EARTH, reference_orbits::LEO_RADIUS);
2680 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::LEO_RADIUS);
2681
2682 let result = propagate_symplectic(&state, mu::EARTH, 1.0, period.value());
2683 let (max_err, _) = energy_drift(&result.states, mu::EARTH);
2684
2685 assert!(
2686 max_err < 1e-10,
2687 "Symplectic LEO 1-period max energy drift = {:.2e} (should be <1e-10)",
2688 max_err
2689 );
2690 }
2691
2692 #[test]
2693 fn test_symplectic_energy_no_secular_drift_1000_orbits() {
2694 let state = circular_orbit_state(mu::EARTH, reference_orbits::LEO_RADIUS);
2698 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::LEO_RADIUS);
2699
2700 let total = period.value() * 1000.0;
2701 let result = propagate_symplectic(&state, mu::EARTH, 10.0, total);
2702 let (max_err, _) = energy_drift(&result.states, mu::EARTH);
2703
2704 assert!(
2707 max_err < 1e-8,
2708 "Symplectic 1000-orbit max energy drift = {:.2e} (should be <1e-8)",
2709 max_err
2710 );
2711 }
2712
2713 #[test]
2714 fn test_symplectic_orbit_return_circular() {
2715 let state = circular_orbit_state(mu::EARTH, reference_orbits::LEO_RADIUS);
2717 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::LEO_RADIUS);
2718
2719 let final_state = propagate_symplectic_final(&state, mu::EARTH, 1.0, period.value());
2720
2721 let dx = (final_state.pos.x.value() - state.pos.x.value()).abs();
2722 let dy = (final_state.pos.y.value() - state.pos.y.value()).abs();
2723 let pos_err_km = (dx * dx + dy * dy).sqrt();
2724 let rel_err = pos_err_km / state.radius();
2725
2726 assert!(
2727 rel_err < 1e-5,
2728 "Symplectic 1-period position return error: {:.2e} relative (should be <1e-5)",
2729 rel_err
2730 );
2731 }
2732
2733 #[test]
2734 fn test_symplectic_elliptical_high_eccentricity() {
2735 let state =
2738 elliptical_orbit_state_at_periapsis(mu::EARTH, reference_orbits::GEO_RADIUS, 0.9);
2739 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::GEO_RADIUS);
2740
2741 let result = propagate_symplectic(&state, mu::EARTH, 0.1, period.value());
2742 let (max_err, _) = energy_drift(&result.states, mu::EARTH);
2743
2744 assert!(
2745 max_err < 1e-6,
2746 "Symplectic e=0.9 1-period max energy drift = {:.2e} (should be <1e-6)",
2747 max_err
2748 );
2749 }
2750
2751 #[test]
2752 fn test_symplectic_vs_rk4_energy_comparison() {
2753 let state =
2756 elliptical_orbit_state_at_periapsis(mu::EARTH, reference_orbits::GEO_RADIUS, 0.5);
2757 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::GEO_RADIUS);
2758 let total = period.value() * 100.0;
2759 let dt = 10.0;
2760
2761 let config_rk4 = IntegratorConfig {
2763 dt,
2764 mu: mu::EARTH,
2765 thrust: ThrustProfile::None,
2766 };
2767 let rk4_states = propagate(&state, &config_rk4, total);
2768 let (rk4_max_err, _) = energy_drift(&rk4_states, mu::EARTH);
2769
2770 let symp_result = propagate_symplectic(&state, mu::EARTH, dt, total);
2772 let (symp_max_err, _) = energy_drift(&symp_result.states, mu::EARTH);
2773
2774 assert!(
2776 symp_max_err < 1e-4,
2777 "Symplectic e=0.5 100-orbit max energy drift = {:.2e}",
2778 symp_max_err
2779 );
2780 assert!(
2781 rk4_max_err < 1e-4,
2782 "RK4 e=0.5 100-orbit max energy drift = {:.2e}",
2783 rk4_max_err
2784 );
2785
2786 eprintln!(
2788 "100 orbits (e=0.5, dt=10s): Symplectic={:.2e}, RK4={:.2e}",
2789 symp_max_err, rk4_max_err
2790 );
2791 }
2792
2793 #[test]
2794 fn test_symplectic_ep02_ballistic_455d() {
2795 let r_jup = orbit_radius::JUPITER.value();
2798 let v_jup = (mu::SUN.value() / r_jup).sqrt();
2799
2800 let v_dep = v_jup + 2.0;
2802 let state = PropState::new(
2803 Vec3::new(Km(r_jup), Km(0.0), Km(0.0)),
2804 Vec3::new(KmPerSec(0.0), KmPerSec(v_dep), KmPerSec(0.0)),
2805 );
2806
2807 let duration = 455.0 * 86400.0; let result = propagate_symplectic(&state, mu::SUN, 60.0, duration);
2809 let (max_err, _) = energy_drift(&result.states, mu::SUN);
2810
2811 assert!(
2812 max_err < 1e-9,
2813 "Symplectic EP02 455d max energy drift = {:.2e} (should be <1e-9)",
2814 max_err
2815 );
2816
2817 let final_r = result.states.last().unwrap().radius();
2819 let r_saturn = orbit_radius::SATURN.value();
2820 let r_ratio = final_r / r_saturn;
2821 assert!(
2822 r_ratio > 0.5 && r_ratio < 2.0,
2823 "Symplectic EP02: final r = {:.0} km ({:.2}× Saturn)",
2824 final_r,
2825 r_ratio
2826 );
2827 }
2828
2829 #[test]
2830 fn test_symplectic_final_matches_full() {
2831 let state = circular_orbit_state(mu::EARTH, reference_orbits::LEO_RADIUS);
2833 let period = crate::orbits::orbital_period(mu::EARTH, reference_orbits::LEO_RADIUS);
2834
2835 let full = propagate_symplectic(&state, mu::EARTH, 1.0, period.value());
2836 let final_only = propagate_symplectic_final(&state, mu::EARTH, 1.0, period.value());
2837
2838 let full_last = full.states.last().unwrap();
2839 let dx = (full_last.pos.x.value() - final_only.pos.x.value()).abs();
2840 let dy = (full_last.pos.y.value() - final_only.pos.y.value()).abs();
2841 let dz = (full_last.pos.z.value() - final_only.pos.z.value()).abs();
2842
2843 assert!(
2844 dx < 1e-10 && dy < 1e-10 && dz < 1e-10,
2845 "Final-only should match full trajectory: dx={:.2e}, dy={:.2e}, dz={:.2e}",
2846 dx,
2847 dy,
2848 dz
2849 );
2850 }
2851}