1use core::f64::consts::PI;
16
17use crate::error::{ensure_finite, ensure_finite_output};
18use crate::math::linalg::{cross, norm, normalize};
19use crate::{PykepError, Result, Vector3};
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum LambertPath {
24 ZeroRevolution,
26 Left,
28 Right,
30}
31
32#[derive(Clone, Debug, PartialEq)]
34pub struct LambertSolution {
35 pub departure_velocity: Vector3,
37 pub arrival_velocity: Vector3,
39 pub x: f64,
41 pub iterations: usize,
43 pub revolutions: usize,
45 pub path: LambertPath,
47}
48
49#[derive(Clone, Copy, Debug, PartialEq)]
51pub struct LambertRequest {
52 pub initial_position: Vector3,
54 pub final_position: Vector3,
56 pub time: f64,
58 pub mu: f64,
60 pub clockwise: bool,
62 pub maximum_revolutions: usize,
64}
65
66impl LambertRequest {
67 #[must_use]
69 pub const fn new(
70 initial_position: Vector3,
71 final_position: Vector3,
72 time: f64,
73 mu: f64,
74 clockwise: bool,
75 maximum_revolutions: usize,
76 ) -> Self {
77 Self {
78 initial_position,
79 final_position,
80 time,
81 mu,
82 clockwise,
83 maximum_revolutions,
84 }
85 }
86
87 fn solve(&self) -> Result<LambertProblem> {
88 LambertProblem::new(
89 self.initial_position,
90 self.final_position,
91 self.time,
92 self.mu,
93 self.clockwise,
94 self.maximum_revolutions,
95 )
96 }
97}
98
99pub fn solve_lambert_batch(
109 requests: &[LambertRequest],
110 workers: usize,
111) -> Result<Vec<LambertProblem>> {
112 crate::batch::try_map(requests, workers, LambertRequest::solve)
113}
114
115#[derive(Clone, Debug, PartialEq)]
132pub struct LambertProblem {
133 initial_position: Vector3,
134 final_position: Vector3,
135 time: f64,
136 mu: f64,
137 clockwise: bool,
138 chord: f64,
139 semiperimeter: f64,
140 lambda: f64,
141 solutions: Vec<LambertSolution>,
142}
143
144impl LambertProblem {
145 pub fn new(
156 initial_position: Vector3,
157 final_position: Vector3,
158 time: f64,
159 mu: f64,
160 clockwise: bool,
161 maximum_revolutions: usize,
162 ) -> Result<Self> {
163 for &value in initial_position.iter().chain(final_position.iter()) {
164 ensure_finite("position", value)?;
165 }
166 ensure_finite("time", time)?;
167 ensure_finite("mu", mu)?;
168 if time <= 0.0 || mu <= 0.0 {
169 return Err(PykepError::InvalidInput {
170 parameter: "time/mu",
171 reason: "must be greater than zero".into(),
172 });
173 }
174 let initial_radius = norm(&initial_position)?;
175 let final_radius = norm(&final_position)?;
176 if initial_radius == 0.0 || final_radius == 0.0 {
177 return Err(PykepError::SingularGeometry {
178 operation: "LambertProblem::new",
179 });
180 }
181 let chord_vector = [
182 final_position[0] - initial_position[0],
183 final_position[1] - initial_position[1],
184 final_position[2] - initial_position[2],
185 ];
186 let chord = norm(&chord_vector)?;
187 if chord == 0.0 {
188 return Err(PykepError::SingularGeometry {
189 operation: "LambertProblem::new",
190 });
191 }
192 let initial_direction = normalize(&initial_position)?;
193 let final_direction = normalize(&final_position)?;
194 let angular = normalize(&cross(&initial_direction, &final_direction)?)?;
195 if angular[2] == 0.0 {
196 return Err(PykepError::SingularGeometry {
197 operation: "LambertProblem::automatic_direction",
198 });
199 }
200 let semiperimeter = (chord + initial_radius + final_radius) / 2.0;
201 let lambda_squared = 1.0 - chord / semiperimeter;
202 let mut lambda = lambda_squared.sqrt();
203 let mut initial_tangent = normalize(&cross(&angular, &initial_direction)?)?;
204 let mut final_tangent = normalize(&cross(&angular, &final_direction)?)?;
205 if angular[2] < 0.0 {
206 lambda = -lambda;
207 initial_tangent = initial_tangent.map(|value| -value);
208 final_tangent = final_tangent.map(|value| -value);
209 }
210 if clockwise {
211 lambda = -lambda;
212 initial_tangent = initial_tangent.map(|value| -value);
213 final_tangent = final_tangent.map(|value| -value);
214 }
215 let dimensionless_time = (2.0 * mu / semiperimeter.powi(3)).sqrt() * time;
216 let mut problem = Self {
217 initial_position,
218 final_position,
219 time,
220 mu,
221 clockwise,
222 chord,
223 semiperimeter,
224 lambda,
225 solutions: Vec::new(),
226 };
227 let mut maximum = ((dimensionless_time / PI) as usize).min(maximum_revolutions);
228 let time_at_zero = lambda.acos() + lambda * (1.0 - lambda_squared).sqrt();
229 let lambda_cubed = lambda * lambda_squared;
230 let time_at_one = 2.0 / 3.0 * (1.0 - lambda_cubed);
231 if maximum > 0 {
232 let mut minimum_time = time_at_zero + maximum as f64 * PI;
233 if dimensionless_time < minimum_time {
234 let mut old_x = 0.0;
235 for _ in 0..13 {
236 let (first, second, third) = problem.time_derivatives(old_x, minimum_time);
237 let new_x = if first != 0.0 {
238 old_x - first * second / (second * second - first * third / 2.0)
239 } else {
240 old_x
241 };
242 if (old_x - new_x).abs() < 1e-13 {
243 break;
244 }
245 minimum_time = problem.time_of_flight(new_x, maximum)?;
246 old_x = new_x;
247 }
248 if minimum_time > dimensionless_time {
249 maximum -= 1;
250 }
251 }
252 maximum = maximum.min(maximum_revolutions);
253 }
254
255 let initial_x = if dimensionless_time >= time_at_zero {
256 -(dimensionless_time - time_at_zero) / (dimensionless_time - time_at_zero + 4.0)
257 } else if dimensionless_time <= time_at_one {
258 time_at_one * (time_at_one - dimensionless_time)
259 / (0.4 * (1.0 - lambda_squared * lambda_cubed) * dimensionless_time)
260 + 1.0
261 } else {
262 (dimensionless_time / time_at_zero)
263 .powf(core::f64::consts::LN_2 / (time_at_one / time_at_zero).ln())
264 - 1.0
265 };
266 let (x, iterations) = problem.householder(dimensionless_time, initial_x, 0, 1e-5)?;
267 problem.push_solution(
268 x,
269 iterations,
270 0,
271 LambertPath::ZeroRevolution,
272 initial_radius,
273 final_radius,
274 &initial_direction,
275 &final_direction,
276 &initial_tangent,
277 &final_tangent,
278 )?;
279 for revolution in 1..=maximum {
280 let temporary =
281 (((revolution + 1) as f64 * PI) / (8.0 * dimensionless_time)).powf(2.0 / 3.0);
282 let initial_left = (temporary - 1.0) / (temporary + 1.0);
283 let (left, iterations) =
284 problem.householder(dimensionless_time, initial_left, revolution, 1e-8)?;
285 problem.push_solution(
286 left,
287 iterations,
288 revolution,
289 LambertPath::Left,
290 initial_radius,
291 final_radius,
292 &initial_direction,
293 &final_direction,
294 &initial_tangent,
295 &final_tangent,
296 )?;
297 let temporary = (8.0 * dimensionless_time / (revolution as f64 * PI)).powf(2.0 / 3.0);
298 let initial_right = (temporary - 1.0) / (temporary + 1.0);
299 let (right, iterations) =
300 problem.householder(dimensionless_time, initial_right, revolution, 1e-8)?;
301 problem.push_solution(
302 right,
303 iterations,
304 revolution,
305 LambertPath::Right,
306 initial_radius,
307 final_radius,
308 &initial_direction,
309 &final_direction,
310 &initial_tangent,
311 &final_tangent,
312 )?;
313 }
314 Ok(problem)
315 }
316
317 #[allow(clippy::too_many_arguments)]
318 fn push_solution(
319 &mut self,
320 x: f64,
321 iterations: usize,
322 revolutions: usize,
323 path: LambertPath,
324 initial_radius: f64,
325 final_radius: f64,
326 initial_direction: &Vector3,
327 final_direction: &Vector3,
328 initial_tangent: &Vector3,
329 final_tangent: &Vector3,
330 ) -> Result<()> {
331 let lambda_squared = self.lambda * self.lambda;
332 let y = (1.0 - lambda_squared + lambda_squared * x * x).sqrt();
333 let gamma = (self.mu * self.semiperimeter / 2.0).sqrt();
334 let rho = (initial_radius - final_radius) / self.chord;
335 let sigma = (1.0 - rho * rho).sqrt();
336 let radial_initial =
337 gamma * ((self.lambda * y - x) - rho * (self.lambda * y + x)) / initial_radius;
338 let radial_final =
339 -gamma * ((self.lambda * y - x) + rho * (self.lambda * y + x)) / final_radius;
340 let tangential = gamma * sigma * (y + self.lambda * x);
341 let tangential_initial = tangential / initial_radius;
342 let tangential_final = tangential / final_radius;
343 let departure_velocity = core::array::from_fn(|index| {
344 radial_initial * initial_direction[index] + tangential_initial * initial_tangent[index]
345 });
346 let arrival_velocity = core::array::from_fn(|index| {
347 radial_final * final_direction[index] + tangential_final * final_tangent[index]
348 });
349 for &value in departure_velocity.iter().chain(arrival_velocity.iter()) {
350 ensure_finite_output("LambertProblem::velocity_reconstruction", value)?;
351 }
352 self.solutions.push(LambertSolution {
353 departure_velocity,
354 arrival_velocity,
355 x,
356 iterations,
357 revolutions,
358 path,
359 });
360 Ok(())
361 }
362
363 fn householder(
364 &self,
365 target_time: f64,
366 mut x: f64,
367 revolutions: usize,
368 tolerance: f64,
369 ) -> Result<(f64, usize)> {
370 for iteration in 1..=15 {
371 let time = self.time_of_flight(x, revolutions)?;
372 let (first, second, third) = self.time_derivatives(x, time);
373 let difference = time - target_time;
374 let first_squared = first * first;
375 let next = x - difference * (first_squared - difference * second / 2.0)
376 / (first * (first_squared - difference * second)
377 + third * difference * difference / 6.0);
378 if !next.is_finite() {
379 return Err(PykepError::NumericalOverflow {
380 operation: "LambertProblem::householder",
381 });
382 }
383 let error = (x - next).abs();
384 x = next;
385 if error <= tolerance {
386 return Ok((x, iteration));
387 }
388 }
389 Err(PykepError::ConvergenceFailure {
390 operation: "LambertProblem::householder",
391 iterations: 15,
392 })
393 }
394
395 fn time_derivatives(&self, x: f64, time: f64) -> (f64, f64, f64) {
396 let lambda_squared = self.lambda * self.lambda;
397 let lambda_cubed = lambda_squared * self.lambda;
398 let one_minus_x_squared = 1.0 - x * x;
399 let y = (1.0 - lambda_squared * one_minus_x_squared).sqrt();
400 let first = (3.0 * time * x - 2.0 + 2.0 * lambda_cubed * x / y) / one_minus_x_squared;
401 let second = (3.0 * time
402 + 5.0 * x * first
403 + 2.0 * (1.0 - lambda_squared) * lambda_cubed / y.powi(3))
404 / one_minus_x_squared;
405 let third = (7.0 * x * second + 8.0 * first
406 - 6.0 * (1.0 - lambda_squared) * lambda_squared * lambda_cubed * x / y.powi(5))
407 / one_minus_x_squared;
408 (first, second, third)
409 }
410
411 fn time_of_flight(&self, x: f64, revolutions: usize) -> Result<f64> {
412 let distance = (x - 1.0).abs();
413 if (0.01..0.2).contains(&distance) {
414 return self.time_of_flight_lagrange(x, revolutions);
415 }
416 let lambda_squared = self.lambda * self.lambda;
417 let energy = x * x - 1.0;
418 let rho = energy.abs();
419 let z = (1.0 + lambda_squared * energy).sqrt();
420 let time = if distance < 0.01 {
421 let eta = z - self.lambda * x;
422 let series_argument = 0.5 * (1.0 - self.lambda - x * eta);
423 let q = 4.0 / 3.0 * hypergeometric(series_argument)?;
424 (eta.powi(3) * q + 4.0 * self.lambda * eta) / 2.0
425 + revolutions as f64 * PI / rho.powf(1.5)
426 } else {
427 let y = rho.sqrt();
428 let g = x * z - self.lambda * energy;
429 let d = if energy < 0.0 {
430 revolutions as f64 * PI + g.clamp(-1.0, 1.0).acos()
431 } else {
432 (y * (z - self.lambda * x) + g).ln()
433 };
434 (x - self.lambda * z - d / y) / energy
435 };
436 ensure_finite_output("LambertProblem::time_of_flight", time)
437 }
438
439 fn time_of_flight_lagrange(&self, x: f64, revolutions: usize) -> Result<f64> {
440 let semi_axis = 1.0 / (1.0 - x * x);
441 let time = if semi_axis > 0.0 {
442 let alpha = 2.0 * x.clamp(-1.0, 1.0).acos();
443 let mut beta = 2.0
444 * (self.lambda.abs() / semi_axis.sqrt())
445 .clamp(-1.0, 1.0)
446 .asin();
447 if self.lambda < 0.0 {
448 beta = -beta;
449 }
450 semi_axis.powf(1.5)
451 * ((alpha - alpha.sin()) - (beta - beta.sin()) + 2.0 * PI * revolutions as f64)
452 / 2.0
453 } else {
454 let alpha = 2.0 * x.acosh();
455 let mut beta = 2.0 * (-self.lambda * self.lambda / semi_axis).sqrt().asinh();
456 if self.lambda < 0.0 {
457 beta = -beta;
458 }
459 -semi_axis * (-semi_axis).sqrt() * ((beta - beta.sinh()) - (alpha - alpha.sinh())) / 2.0
460 };
461 ensure_finite_output("LambertProblem::time_of_flight_lagrange", time)
462 }
463
464 #[must_use]
466 pub const fn initial_position(&self) -> Vector3 {
467 self.initial_position
468 }
469
470 #[must_use]
472 pub const fn final_position(&self) -> Vector3 {
473 self.final_position
474 }
475
476 #[must_use]
478 pub const fn time(&self) -> f64 {
479 self.time
480 }
481
482 #[must_use]
484 pub const fn mu(&self) -> f64 {
485 self.mu
486 }
487
488 #[must_use]
490 pub const fn clockwise(&self) -> bool {
491 self.clockwise
492 }
493
494 #[must_use]
496 pub fn maximum_revolutions(&self) -> usize {
497 self.solutions
498 .last()
499 .map_or(0, |solution| solution.revolutions)
500 }
501
502 #[must_use]
504 pub fn solutions(&self) -> &[LambertSolution] {
505 &self.solutions
506 }
507}
508
509fn hypergeometric(argument: f64) -> Result<f64> {
510 let mut sum = 1.0;
511 let mut coefficient = 1.0;
512 for index in 0..10_000 {
513 let index = index as f64;
514 coefficient *= (3.0 + index) * (1.0 + index) / (2.5 + index) * argument / (index + 1.0);
515 sum += coefficient;
516 if coefficient.abs() <= 1e-11 {
517 return ensure_finite_output("LambertProblem::hypergeometric", sum);
518 }
519 }
520 Err(PykepError::ConvergenceFailure {
521 operation: "LambertProblem::hypergeometric",
522 iterations: 10_000,
523 })
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529 use crate::astro::propagation::propagate_lagrangian;
530
531 #[test]
532 fn upstream_clockwise_reference_matches() {
533 let problem = LambertProblem::new(
534 [1.0, 0.0, 0.0],
535 [0.0, 1.0, 0.0],
536 3.0 * PI / 2.0,
537 1.0,
538 true,
539 5,
540 )
541 .unwrap();
542 assert_eq!(problem.maximum_revolutions(), 0);
543 assert_eq!(problem.solutions()[0].iterations, 3);
544 assert!((problem.solutions()[0].x + 0.382_683_432_365_089_6).abs() < 1e-14);
545 for (actual, expected) in problem.solutions()[0]
546 .departure_velocity
547 .iter()
548 .zip([0.0, -1.0, 0.0])
549 {
550 assert!((actual - expected).abs() < 3e-15);
551 }
552 for (actual, expected) in problem.solutions()[0]
553 .arrival_velocity
554 .iter()
555 .zip([1.0, 0.0, 0.0])
556 {
557 assert!((actual - expected).abs() < 3e-15);
558 }
559 }
560
561 #[test]
562 fn all_solution_branches_reconstruct_the_endpoint() {
563 let problem =
564 LambertProblem::new([1.0, 0.0, 0.0], [0.2, 1.1, 0.3], 20.0, 1.0, false, 4).unwrap();
565 assert!(problem.solutions().len() >= 3);
566 for solution in problem.solutions() {
567 let initial = [
568 1.0,
569 0.0,
570 0.0,
571 solution.departure_velocity[0],
572 solution.departure_velocity[1],
573 solution.departure_velocity[2],
574 ];
575 let propagated = propagate_lagrangian(&initial, 20.0, 1.0).unwrap();
576 for (actual, expected) in propagated[..3].iter().zip([0.2, 1.1, 0.3]) {
577 assert!((actual - expected).abs() < 2e-10);
578 }
579 }
580 }
581
582 #[test]
583 fn time_of_flight_dispatches_cover_each_expression_family() {
584 for clockwise in [false, true] {
585 let problem =
586 LambertProblem::new([1.0, 0.0, 0.0], [0.2, 1.1, 0.3], 20.0, 1.0, clockwise, 0)
587 .unwrap();
588 for x in [0.995, 0.9, 0.5, 1.1, 2.0] {
589 assert!(problem.time_of_flight(x, 0).unwrap().is_finite());
590 }
591 let target = problem.time_of_flight(0.2, 0).unwrap();
592 assert!(matches!(
593 problem.householder(target, 0.2, 0, -1.0),
594 Err(PykepError::ConvergenceFailure {
595 operation: "LambertProblem::householder",
596 iterations: 15
597 })
598 ));
599 }
600 assert!(matches!(
601 hypergeometric(f64::NAN),
602 Err(PykepError::ConvergenceFailure {
603 operation: "LambertProblem::hypergeometric",
604 iterations: 10_000
605 })
606 ));
607 }
608
609 #[test]
610 fn invalid_geometries_and_scales_are_typed_errors() {
611 let valid = [1.0, 0.0, 0.0];
612 for result in [
613 LambertProblem::new([f64::NAN, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0, 1.0, false, 0),
614 LambertProblem::new(valid, [0.0, 1.0, 0.0], 0.0, 1.0, false, 0),
615 LambertProblem::new(valid, [0.0, 1.0, 0.0], 1.0, 0.0, false, 0),
616 LambertProblem::new([0.0; 3], [0.0, 1.0, 0.0], 1.0, 1.0, false, 0),
617 LambertProblem::new(valid, valid, 1.0, 1.0, false, 0),
618 LambertProblem::new(valid, [0.0, 0.0, 1.0], 1.0, 1.0, false, 0),
619 LambertProblem::new([1e308; 3], [-1e308; 3], 1.0, 1.0, false, 0),
620 ] {
621 assert!(result.is_err());
622 }
623 }
624
625 #[test]
626 fn ordered_parallel_batch_matches_scalar_problems_and_error_order() {
627 let requests = [
628 LambertRequest::new([1.0, 0.0, 0.0], [0.2, 1.1, 0.3], 20.0, 1.0, false, 2),
629 LambertRequest::new([1.1, 0.1, 0.0], [0.1, 1.2, 0.2], 22.0, 1.0, true, 1),
630 ];
631 let batch = solve_lambert_batch(&requests, 2).unwrap();
632 assert_eq!(
633 batch,
634 requests
635 .iter()
636 .map(LambertRequest::solve)
637 .collect::<Result<Vec<_>>>()
638 .unwrap()
639 );
640
641 let invalid = [
642 LambertRequest::new([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0.0, 1.0, false, 0),
643 LambertRequest::new([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0, 0.0, false, 0),
644 ];
645 assert_eq!(
646 solve_lambert_batch(&invalid, 2).unwrap_err(),
647 invalid[0].solve().unwrap_err()
648 );
649 }
650}