Skip to main content

sidereon_core/astro/
iod.rs

1//! Initial orbit determination (IOD) from position or angle observations.
2//!
3//! Authoritative implementations of the classical IOD methods; language
4//! bindings are thin marshaling layers over these functions.
5//!
6//! - [`gibbs`] - velocity at the middle of three coplanar position vectors
7//!   (Algorithm 54, Vallado 2022, pp. 460-467).
8//! - [`hgibbs`] - Herrick-Gibbs velocity from three closely-spaced timed
9//!   positions (Algorithm 55, Vallado 2022, pp. 467-472).
10//! - [`gauss_angles`] - angles-only orbit from three optical sightings
11//!   (Algorithm 52, Vallado 2022, pp. 448-459).
12//!
13//! ## Reference constants
14//!
15//! The constants prefixed `VALLADO_` below are reference-suite values, NOT the
16//! WGS84/EGM datum. They match the Vallado worked examples and the `valladopy`
17//! reference suite the unit tests validate against (`VALLADO_MU = 398600.4415`,
18//! `VALLADO_RE = 6378.1363`), and are kept local so the methods stay bit-exact
19//! with that published reference rather than drifting to the WGS84/GM values in
20//! [`crate::astro::constants`]. Callers needing the WGS84/GM datum must use the
21//! constants module, not these.
22
23/// Earth gravitational parameter (km^3/s^2), Vallado reference suite value (not
24/// the WGS84/GM datum in [`crate::astro::constants`]).
25const VALLADO_MU: f64 = 398600.4415;
26/// Earth equatorial radius (km), Vallado reference suite value (not the WGS84
27/// value in [`crate::astro::constants`]).
28const VALLADO_RE: f64 = 6378.1363;
29/// Canonical time unit (seconds) for the Gauss canonical-unit formulation,
30/// Vallado reference suite value.
31const VALLADO_TUSEC: f64 = 806.8109913067327;
32/// Seconds per day.
33const DAY2SEC: f64 = 86400.0;
34const SMALL: f64 = 1e-10;
35/// Maximum coplanarity deviation (radians) tolerated by the Gibbs methods. The
36/// three position vectors must lie in a common plane to define an orbit; this
37/// is the standard few-degree IOD acceptance bound (here 5 degrees).
38const COPLANAR_TOL_RAD: f64 = 5.0 * std::f64::consts::PI / 180.0;
39
40/// Error returned by the initial-orbit-determination methods.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
42pub enum IodError {
43    /// The line-of-sight matrix determinant is too small to invert (degenerate
44    /// geometry).
45    #[error("line-of-sight determinant too small")]
46    DeterminantTooSmall,
47    /// The position vectors do not admit an orbit solution (degenerate D or N
48    /// vector in the Gibbs construction).
49    #[error("orbit determination not possible from the given geometry")]
50    OrbitNotPossible,
51    /// A supplied position vector has (near) zero magnitude, so it cannot be
52    /// normalized.
53    #[error("position vector has near-zero magnitude")]
54    ZeroVector,
55    /// The two position vectors whose cross product is normalized for the
56    /// coplanarity check are collinear, leaving the coplanarity angle undefined.
57    #[error("position vectors are collinear")]
58    CollinearVectors,
59    /// The three position vectors are not sufficiently coplanar to define a
60    /// single orbit.
61    #[error("position vectors are not coplanar")]
62    NotCoplanar,
63    /// The observation times are equal or near-equal, so the time geometry is
64    /// degenerate (zero denominators).
65    #[error("observation times are equal or near-equal")]
66    InvalidTimeGeometry,
67    /// The Gauss radius polynomial's root is non-positive or outside the
68    /// supported geocentric-radius range (see [`gauss_angles`]).
69    #[error("no positive real root for the slant-range polynomial")]
70    NoPositiveRoot,
71    /// The Gauss radius root solver failed numerically (degenerate Halley
72    /// denominator, non-finite iterate, or no convergence within the iteration
73    /// cap), so no trustworthy root is available.
74    #[error("slant-range root solver did not converge")]
75    RootSolveFailed,
76    /// An intermediate or output value was not finite (NaN or infinity).
77    #[error("non-finite value encountered")]
78    NonFiniteValue,
79}
80
81/// True when every component of a 3-vector is finite.
82fn all_finite(a: &[f64; 3]) -> bool {
83    a.iter().all(|x| x.is_finite())
84}
85
86fn cross(a: &[f64; 3], b: &[f64; 3]) -> [f64; 3] {
87    [
88        a[1] * b[2] - a[2] * b[1],
89        a[2] * b[0] - a[0] * b[2],
90        a[0] * b[1] - a[1] * b[0],
91    ]
92}
93
94fn dot(a: &[f64; 3], b: &[f64; 3]) -> f64 {
95    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
96}
97
98fn mag(a: &[f64; 3]) -> f64 {
99    dot(a, a).sqrt()
100}
101
102fn unit(a: &[f64; 3]) -> [f64; 3] {
103    let m = mag(a);
104    [a[0] / m, a[1] / m, a[2] / m]
105}
106
107fn vadd(a: &[f64; 3], b: &[f64; 3]) -> [f64; 3] {
108    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
109}
110
111fn smul(s: f64, a: &[f64; 3]) -> [f64; 3] {
112    [s * a[0], s * a[1], s * a[2]]
113}
114
115/// 3x3 matrix determinant.
116fn det3(m: &[[f64; 3]; 3]) -> f64 {
117    m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
118        - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
119        + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
120}
121
122/// 3x3 matrix inverse.
123fn inv3(m: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
124    let d = det3(m);
125    let id = 1.0 / d;
126    [
127        [
128            id * (m[1][1] * m[2][2] - m[1][2] * m[2][1]),
129            id * (m[0][2] * m[2][1] - m[0][1] * m[2][2]),
130            id * (m[0][1] * m[1][2] - m[0][2] * m[1][1]),
131        ],
132        [
133            id * (m[1][2] * m[2][0] - m[1][0] * m[2][2]),
134            id * (m[0][0] * m[2][2] - m[0][2] * m[2][0]),
135            id * (m[0][2] * m[1][0] - m[0][0] * m[1][2]),
136        ],
137        [
138            id * (m[1][0] * m[2][1] - m[1][1] * m[2][0]),
139            id * (m[0][1] * m[2][0] - m[0][0] * m[2][1]),
140            id * (m[0][0] * m[1][1] - m[0][1] * m[1][0]),
141        ],
142    ]
143}
144
145/// 3x3 matrix-vector multiply.
146fn mat3_vec3(m: &[[f64; 3]; 3], v: &[f64; 3]) -> [f64; 3] {
147    [
148        m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
149        m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
150        m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
151    ]
152}
153
154/// 3x3 matrix multiply.
155fn mat3_mat3(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
156    let mut r = [[0.0; 3]; 3];
157    for i in 0..3 {
158        for j in 0..3 {
159            r[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j];
160        }
161    }
162    r
163}
164
165/// Line-of-sight unit vector from right ascension and declination (radians).
166fn los(ra: f64, dec: f64) -> [f64; 3] {
167    [dec.cos() * ra.cos(), dec.cos() * ra.sin(), dec.sin()]
168}
169
170/// Gibbs method: determine the velocity at `r2` from three coplanar position
171/// vectors (km).
172///
173/// Algorithm 54, Vallado 2022, pp. 460-467.
174///
175/// Returns `(v2, theta12_rad, theta23_rad, copa_rad)`: the velocity at `r2`
176/// (km/s), the angles between successive position vectors, and the coplanarity
177/// angle.
178pub fn gibbs(
179    r1: &[f64; 3],
180    r2: &[f64; 3],
181    r3: &[f64; 3],
182) -> Result<([f64; 3], f64, f64, f64), IodError> {
183    // Validate inputs before any normalization or division so a degenerate
184    // input surfaces as a typed error instead of a NaN inside `Ok`.
185    if !all_finite(r1) || !all_finite(r2) || !all_finite(r3) {
186        return Err(IodError::NonFiniteValue);
187    }
188
189    let magr1 = mag(r1);
190    let magr2 = mag(r2);
191    let magr3 = mag(r3);
192
193    // A finite input whose magnitude overflows to infinity would silently
194    // collapse later normalizations to zero and fabricate a finite result, so
195    // reject non-finite magnitudes outright.
196    if !magr1.is_finite() || !magr2.is_finite() || !magr3.is_finite() {
197        return Err(IodError::NonFiniteValue);
198    }
199
200    if magr1 < SMALL || magr2 < SMALL || magr3 < SMALL {
201        return Err(IodError::ZeroVector);
202    }
203
204    // Cross products
205    let p = cross(r2, r3);
206    let q = cross(r3, r1);
207    let w = cross(r1, r2);
208
209    // Only `p` is normalized (for the coplanarity angle), so only r2/r3
210    // collinearity is fatal here. `q` or `w` vanishing on its own (e.g.
211    // anti-parallel r1/r2 at the apsides) is a legitimate geometry; the
212    // degenerate-orbit case is caught by the `D`/`N` magnitude check below.
213    let magp = mag(&p);
214    if !magp.is_finite() {
215        return Err(IodError::NonFiniteValue);
216    }
217    if magp < SMALL {
218        return Err(IodError::CollinearVectors);
219    }
220
221    // Coplanarity angle (now safe: `p` and `r1` are nonzero). Clamp the dot
222    // product into asin's domain so floating-point spill past +-1 cannot yield
223    // a NaN that slips through the `> tol` test below.
224    let copa = dot(&unit(&p), &unit(r1)).clamp(-1.0, 1.0).asin();
225    if copa.abs() > COPLANAR_TOL_RAD {
226        return Err(IodError::NotCoplanar);
227    }
228
229    // D = P + Q + W
230    let d = vadd(&vadd(&p, &q), &w);
231    let magd = mag(&d);
232
233    // N = |r1|*P + |r2|*Q + |r3|*W
234    let n = vadd(&vadd(&smul(magr1, &p), &smul(magr2, &q)), &smul(magr3, &w));
235    let magn = mag(&n);
236
237    if !magd.is_finite() || !magn.is_finite() {
238        return Err(IodError::NonFiniteValue);
239    }
240    if magd < 1e-6 || magn < 1e-6 {
241        return Err(IodError::OrbitNotPossible);
242    }
243
244    // Angles between position vectors
245    let theta12 = (dot(r1, r2) / (magr1 * magr2)).clamp(-1.0, 1.0).acos();
246    let theta23 = (dot(r2, r3) / (magr2 * magr3)).clamp(-1.0, 1.0).acos();
247
248    // S vector
249    let r1mr2 = magr1 - magr2;
250    let r3mr1 = magr3 - magr1;
251    let r2mr3 = magr2 - magr3;
252    let s = vadd(&vadd(&smul(r1mr2, r3), &smul(r3mr1, r2)), &smul(r2mr3, r1));
253
254    // B = D x r2
255    let b = cross(&d, r2);
256
257    // Scaling factor
258    let lg = (VALLADO_MU / (magd * magn)).sqrt();
259
260    // v2 = (lg / |r2|) * B + lg * S
261    let v2 = vadd(&smul(lg / magr2, &b), &smul(lg, &s));
262
263    // Guard against non-finite results that can arise from finite-but-extreme
264    // inputs (e.g. magnitudes that overflow to infinity).
265    if !all_finite(&v2) || !theta12.is_finite() || !theta23.is_finite() || !copa.is_finite() {
266        return Err(IodError::NonFiniteValue);
267    }
268
269    Ok((v2, theta12, theta23, copa))
270}
271
272/// Herrick-Gibbs method: determine the velocity at `r2` from three
273/// closely-spaced position vectors (km) with timestamps.
274///
275/// Algorithm 55, Vallado 2022, pp. 467-472.
276///
277/// `jd1`, `jd2`, `jd3` are the observation epochs in Julian days. The method
278/// converts epoch differences to seconds via the `* DAY2SEC` factor below, so
279/// the inputs must be Julian days (not seconds or an arbitrary unit); the
280/// epochs must be distinct.
281///
282/// Returns `(v2, theta12_rad, theta23_rad, copa_rad)`.
283pub fn hgibbs(
284    r1: &[f64; 3],
285    r2: &[f64; 3],
286    r3: &[f64; 3],
287    jd1: f64,
288    jd2: f64,
289    jd3: f64,
290) -> Result<([f64; 3], f64, f64, f64), IodError> {
291    // Validate inputs before any normalization or division so a degenerate
292    // input surfaces as a typed error instead of a NaN inside `Ok`.
293    if !all_finite(r1)
294        || !all_finite(r2)
295        || !all_finite(r3)
296        || !jd1.is_finite()
297        || !jd2.is_finite()
298        || !jd3.is_finite()
299    {
300        return Err(IodError::NonFiniteValue);
301    }
302
303    let magr1 = mag(r1);
304    let magr2 = mag(r2);
305    let magr3 = mag(r3);
306
307    // Reject magnitudes that overflowed to infinity (finite but huge inputs),
308    // which would otherwise collapse the normalization below to a fake result.
309    if !magr1.is_finite() || !magr2.is_finite() || !magr3.is_finite() {
310        return Err(IodError::NonFiniteValue);
311    }
312
313    if magr1 < SMALL || magr2 < SMALL || magr3 < SMALL {
314        return Err(IodError::ZeroVector);
315    }
316
317    // Time differences (seconds; inputs are Julian days).
318    let dt21 = (jd2 - jd1) * DAY2SEC;
319    let dt31 = (jd3 - jd1) * DAY2SEC;
320    let dt32 = (jd3 - jd2) * DAY2SEC;
321
322    // Equal or near-equal epochs make the divisors below blow up.
323    if dt21.abs() < SMALL || dt31.abs() < SMALL || dt32.abs() < SMALL {
324        return Err(IodError::InvalidTimeGeometry);
325    }
326
327    // Cross product for coplanarity check; must be finite and nonzero to
328    // normalize.
329    let p = cross(r2, r3);
330    let magp = mag(&p);
331    if !magp.is_finite() {
332        return Err(IodError::NonFiniteValue);
333    }
334    if magp < SMALL {
335        return Err(IodError::CollinearVectors);
336    }
337
338    // Coplanarity angle (now safe: `p` and `r1` are nonzero). Clamp the dot
339    // product into asin's domain so floating-point spill past +-1 cannot yield
340    // a NaN that slips through the `> tol` test below.
341    let copa = dot(&unit(&p), &unit(r1)).clamp(-1.0, 1.0).asin();
342    if copa.abs() > COPLANAR_TOL_RAD {
343        return Err(IodError::NotCoplanar);
344    }
345
346    // Angles between position vectors
347    let theta12 = (dot(r1, r2) / (magr1 * magr2)).clamp(-1.0, 1.0).acos();
348    let theta23 = (dot(r2, r3) / (magr2 * magr3)).clamp(-1.0, 1.0).acos();
349
350    // Herrick-Gibbs velocity approximation
351    let term1 = smul(
352        -dt32 * (1.0 / (dt21 * dt31) + VALLADO_MU / (12.0 * magr1.powi(3))),
353        r1,
354    );
355    let term2 = smul(
356        (dt32 - dt21) * (1.0 / (dt21 * dt32) + VALLADO_MU / (12.0 * magr2.powi(3))),
357        r2,
358    );
359    let term3 = smul(
360        dt21 * (1.0 / (dt32 * dt31) + VALLADO_MU / (12.0 * magr3.powi(3))),
361        r3,
362    );
363
364    let v2 = vadd(&vadd(&term1, &term2), &term3);
365
366    // Guard against non-finite results from finite-but-extreme inputs (e.g.
367    // epoch differences large enough to overflow the second-scale divisors).
368    if !all_finite(&v2) || !theta12.is_finite() || !theta23.is_finite() || !copa.is_finite() {
369        return Err(IodError::NonFiniteValue);
370    }
371
372    Ok((v2, theta12, theta23, copa))
373}
374
375/// Halley iteration to refine the 8th-order Gauss polynomial root.
376///
377/// Returns `None` if the Halley denominator vanishes, the iterate becomes
378/// non-finite, or the iteration does not converge within the cap, so the caller
379/// can surface a typed error rather than propagate a NaN/Inf or non-root value.
380fn halley_iteration(poly: &[f64; 9]) -> Option<f64> {
381    // Initial guess at roughly GPS altitude in canonical (Earth-radii) units.
382    let mut bigr2c = 20000.0 / VALLADO_RE;
383    let mut bigr2 = 100.0;
384    let mut converged = false;
385
386    for _ in 0..15 {
387        if (bigr2 - bigr2c).abs() < 8e-5 {
388            converged = true;
389            break;
390        }
391        bigr2 = bigr2c;
392        let x = bigr2;
393        let f = x.powi(8) + poly[2] * x.powi(6) + poly[5] * x.powi(3) + poly[8];
394        let f1 = 8.0 * x.powi(7) + 6.0 * poly[2] * x.powi(5) + 3.0 * poly[5] * x.powi(2);
395        let f2 = 56.0 * x.powi(6) + 30.0 * poly[2] * x.powi(4) + 6.0 * poly[5] * x;
396        let denom = 2.0 * f1 * f1 - f * f2;
397        if denom.abs() < SMALL {
398            return None;
399        }
400        bigr2c = bigr2 - (2.0 * f * f1) / denom;
401        if !bigr2c.is_finite() {
402            return None;
403        }
404    }
405
406    // Convergence is decided by the final step size, not the loop count, so an
407    // iterate that converges on the last allowed pass still counts; an iterate
408    // that merely ran out of iterations without settling is rejected rather than
409    // returned as a fabricated root.
410    if !converged {
411        converged = (bigr2 - bigr2c).abs() < 8e-5;
412    }
413
414    if !converged || !bigr2c.is_finite() {
415        return None;
416    }
417
418    // A zero Halley step also occurs at a stationary point of f (f1 == 0) that
419    // is not a root, which the step-size test alone would accept. Confirm the
420    // polynomial actually vanishes there via a scale-relative residual.
421    let x = bigr2c;
422    let t0 = x.powi(8);
423    let t2 = poly[2] * x.powi(6);
424    let t5 = poly[5] * x.powi(3);
425    let t8 = poly[8];
426    let residual = t0 + t2 + t5 + t8;
427    let scale = t0.abs() + t2.abs() + t5.abs() + t8.abs();
428    if !residual.is_finite() || !scale.is_finite() || residual.abs() > 1e-9 * scale {
429        return None;
430    }
431
432    Some(bigr2c)
433}
434
435/// Gauss angles-only orbit determination.
436///
437/// Given three angular observations (right ascension / declination, radians)
438/// with split Julian dates (`jd` whole part, `jdf` fraction) and the observer
439/// site ECI positions (km), determine the orbit at the middle observation.
440///
441/// Algorithm 52, Vallado 2022, pp. 448-459. Returns `(r2, v2)`: the position
442/// (km) and velocity (km/s) at the middle epoch.
443///
444/// Domain: the radius root solver is seeded near GPS altitude and accepts a
445/// geocentric radius in the near-Earth-through-GEO regime (positive and up to
446/// ~50,000 km). A converged root outside that range yields
447/// [`IodError::NoPositiveRoot`]; a numerical failure of the solver yields
448/// [`IodError::RootSolveFailed`].
449pub fn gauss_angles(
450    decl: &[f64; 3],
451    rtasc: &[f64; 3],
452    jd: &[f64; 3],
453    jdf: &[f64; 3],
454    rseci: &[[f64; 3]; 3],
455) -> Result<([f64; 3], [f64; 3]), IodError> {
456    // Reject non-finite inputs up front: a NaN angle would slip past the
457    // determinant guard below (NaN comparisons are always false).
458    if !decl.iter().all(|x| x.is_finite())
459        || !rtasc.iter().all(|x| x.is_finite())
460        || !jd.iter().all(|x| x.is_finite())
461        || !jdf.iter().all(|x| x.is_finite())
462        || !rseci.iter().all(all_finite)
463    {
464        return Err(IodError::NonFiniteValue);
465    }
466
467    // Time intervals (seconds)
468    let tau12 = ((jd[0] - jd[1]) + (jdf[0] - jdf[1])) * DAY2SEC;
469    let _tau13 = ((jd[0] - jd[2]) + (jdf[0] - jdf[2])) * DAY2SEC;
470    let tau32 = ((jd[2] - jd[1]) + (jdf[2] - jdf[1])) * DAY2SEC;
471
472    // Equal or near-equal observation times make the polynomial-coefficient
473    // divisors below degenerate.
474    if tau12.abs() < SMALL || tau32.abs() < SMALL || (tau32 - tau12).abs() < SMALL {
475        return Err(IodError::InvalidTimeGeometry);
476    }
477
478    // Line-of-sight vectors
479    let l1 = los(rtasc[0], decl[0]);
480    let l2 = los(rtasc[1], decl[1]);
481    let l3 = los(rtasc[2], decl[2]);
482
483    // Canonical units
484    let tau12c = tau12 / VALLADO_TUSEC;
485    let tau32c = tau32 / VALLADO_TUSEC;
486    let rseci1c = smul(1.0 / VALLADO_RE, &rseci[0]);
487    let rseci2c = smul(1.0 / VALLADO_RE, &rseci[1]);
488    let rseci3c = smul(1.0 / VALLADO_RE, &rseci[2]);
489
490    // L-matrix (columns = LOS vectors)
491    let lmat = [
492        [l1[0], l2[0], l3[0]],
493        [l1[1], l2[1], l3[1]],
494        [l1[2], l2[2], l3[2]],
495    ];
496
497    let d = det3(&lmat);
498    if d.abs() < SMALL {
499        return Err(IodError::DeterminantTooSmall);
500    }
501
502    let lmati = inv3(&lmat);
503
504    // Range-site matrix (columns = site vectors in canonical units)
505    let rsmatc = [
506        [rseci1c[0], rseci2c[0], rseci3c[0]],
507        [rseci1c[1], rseci2c[1], rseci3c[1]],
508        [rseci1c[2], rseci2c[2], rseci3c[2]],
509    ];
510
511    let lir = mat3_mat3(&lmati, &rsmatc);
512
513    // Polynomial coefficients
514    let a1 = tau32c / (tau32c - tau12c);
515    let a1u = (tau32c * ((tau32c - tau12c).powi(2) - tau32c.powi(2))) / (6.0 * (tau32c - tau12c));
516    let a3 = -tau12c / (tau32c - tau12c);
517    let a3u = -(tau12c * ((tau32c - tau12c).powi(2) - tau12c.powi(2))) / (6.0 * (tau32c - tau12c));
518
519    let d1c = lir[1][0] * a1 - lir[1][1] + lir[1][2] * a3;
520    let d2c = lir[1][0] * a1u + lir[1][2] * a3u;
521    let magrs2 = mag(&rseci2c);
522    let l2dotrs = dot(&l2, &rseci2c);
523
524    // 8th-order polynomial
525    let mut poly = [0.0; 9];
526    poly[0] = 1.0;
527    poly[2] = -(d1c.powi(2) + 2.0 * d1c * l2dotrs + magrs2.powi(2));
528    poly[5] = -2.0 * (l2dotrs * d2c + d1c * d2c);
529    poly[8] = -(d2c.powi(2));
530
531    // Solve for radius. Accept only a converged root in the supported physical
532    // range; surface a typed error rather than substitute a fabricated orbit.
533    // Distinguish a solver failure (`None`) from a converged-but-out-of-range
534    // root so callers can tell the two apart.
535    let bigr2c = match halley_iteration(&poly) {
536        Some(r) if r > 0.0 && r * VALLADO_RE <= 50000.0 => r,
537        Some(_) => return Err(IodError::NoPositiveRoot),
538        None => return Err(IodError::RootSolveFailed),
539    };
540
541    let bigr2 = bigr2c * VALLADO_RE;
542    let a1u_sec = a1u * VALLADO_TUSEC.powi(2);
543    let a3u_sec = a3u * VALLADO_TUSEC.powi(2);
544
545    // Solve for f and g series
546    let u = VALLADO_MU / bigr2.powi(3);
547    let c1 = a1 + a1u_sec * u;
548    let c2 = -1.0;
549    let c3 = a3 + a3u_sec * u;
550
551    // The reconstructed positions divide by c1 and c3; a vanishing coefficient
552    // means the geometry does not yield an orbit.
553    if c1.abs() < SMALL || c3.abs() < SMALL {
554        return Err(IodError::OrbitNotPossible);
555    }
556
557    // Range-site matrix (non-canonical)
558    let rsmat = [
559        [rseci[0][0], rseci[1][0], rseci[2][0]],
560        [rseci[0][1], rseci[1][1], rseci[2][1]],
561        [rseci[0][2], rseci[1][2], rseci[2][2]],
562    ];
563    let lir_full = mat3_mat3(&lmati, &rsmat);
564    let cmat = [-c1, -c2, -c3];
565    let rhomat = mat3_vec3(&lir_full, &cmat);
566
567    // Form position vectors
568    let r1 = vadd(&smul(rhomat[0] / c1, &l1), &rseci[0]);
569    let r2 = vadd(&smul(rhomat[1] / c2, &l2), &rseci[1]);
570    let r3 = vadd(&smul(rhomat[2] / c3, &l3), &rseci[2]);
571
572    // Use Gibbs to recover the velocity at the middle epoch.
573    let (v2, _, _, _) = gibbs(&r1, &r2, &r3)?;
574
575    if !all_finite(&r2) || !all_finite(&v2) {
576        return Err(IodError::NonFiniteValue);
577    }
578
579    Ok((r2, v2))
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use std::f64::consts::PI;
586
587    fn assert_rel(actual: f64, expected: f64, label: &str) {
588        let rel = ((actual - expected) / expected).abs();
589        assert!(rel < 1e-12, "{label}: relative error {rel:e} exceeds 1e-12");
590    }
591
592    // Bit-for-bit reference: valladopy. Gibbs/Herrick-Gibbs velocities are
593    // exact; the reported angles match to a few ULP.
594    fn assert_ulp(actual: f64, expected: f64, max_ulps: i64, label: &str) {
595        if actual == expected {
596            return;
597        }
598        let a = actual.to_bits() as i64;
599        let b = expected.to_bits() as i64;
600        let ulps = (a - b).abs();
601        assert!(
602            ulps <= max_ulps,
603            "{label}: {ulps} ulps exceeds {max_ulps} (got {actual}, expected {expected})"
604        );
605    }
606
607    #[test]
608    fn gibbs_example_7_3() {
609        let r1 = [0.0, 0.0, 6378.1363];
610        let r2 = [0.0, -4464.696, -5102.509];
611        let r3 = [0.0, 5740.323, 3189.068];
612
613        let (v2, theta12, theta23, copa) = gibbs(&r1, &r2, &r3).unwrap();
614        assert_ulp(v2[0], 0.0, 0, "v2_x");
615        assert_ulp(v2[1], 5.5311472050176125, 0, "v2_y");
616        assert_ulp(v2[2], -5.191806413494606, 0, "v2_z");
617        assert_ulp(theta12 * 180.0 / PI, 138.81407085944375, 2, "theta12");
618        assert_ulp(theta23 * 180.0 / PI, 160.24053069723146, 2, "theta23");
619        assert_ulp(copa, 0.0, 0, "copa");
620    }
621
622    #[test]
623    fn hgibbs_example_7_4() {
624        let r1 = [3419.85564, 6019.82602, 2784.60022];
625        let r2 = [2935.91195, 6326.18324, 2660.59584];
626        let r3 = [2434.95202, 6597.38674, 2521.52311];
627        let jd1 = 0.0;
628        let jd2 = (60.0 + 16.48) / 86400.0;
629        let jd3 = (120.0 + 33.04) / 86400.0;
630
631        let (v2, theta12, theta23, _copa) = hgibbs(&r1, &r2, &r3, jd1, jd2, jd3).unwrap();
632        assert_ulp(v2[0], -6.441557227511062, 0, "v2_x");
633        assert_ulp(v2[1], 3.777559606719521, 0, "v2_y");
634        assert_ulp(v2[2], -1.7205675602414345, 0, "v2_z");
635        assert_ulp(theta12 * 180.0 / PI, 4.499996147374992, 2, "theta12");
636        assert_ulp(theta23 * 180.0 / PI, 4.499998402168982, 2, "theta23");
637    }
638
639    #[test]
640    fn gauss_example_7_2() {
641        let d2r = |d: f64| d * PI / 180.0;
642        let decl = [d2r(18.667717), d2r(35.664741), d2r(36.996583)];
643        let rtasc = [d2r(0.939913), d2r(45.025748), d2r(67.886655)];
644        let jd = [2_456_159.5, 2_456_159.5, 2_456_159.5];
645        let jdf = [0.4864351851851852, 0.49199074074074073, 0.4947685185185185];
646        let rseci = [
647            [4054.881, 2748.195, 4074.237],
648            [3956.224, 2888.232, 4074.364],
649            [3905.073, 2956.935, 4074.430],
650        ];
651
652        let (r2, v2) = gauss_angles(&decl, &rtasc, &jd, &jdf, &rseci).unwrap();
653        assert_rel(r2[0], 6313.378130210396, "r2_x");
654        assert_rel(r2[1], 5247.50563344895, "r2_y");
655        assert_rel(r2[2], 6467.707164431651, "r2_z");
656        assert_rel(v2[0], -4.185488280436629, "v2_x");
657        assert_rel(v2[1], 4.7884929168898145, "v2_y");
658        assert_rel(v2[2], 1.721714659663034, "v2_z");
659    }
660
661    // --- Degenerate-input rejection (no NaN/Inf or fabricated value in Ok). ---
662
663    #[test]
664    fn gibbs_rejects_zero_vector() {
665        let r2 = [0.0, -4464.696, -5102.509];
666        let r3 = [0.0, 5740.323, 3189.068];
667        assert_eq!(
668            gibbs(&[0.0, 0.0, 0.0], &r2, &r3).unwrap_err(),
669            IodError::ZeroVector
670        );
671    }
672
673    #[test]
674    fn gibbs_rejects_collinear_vectors() {
675        // r2 and r3 are parallel: cross(r2, r3) vanishes.
676        let r1 = [0.0, 0.0, 6378.1363];
677        let r2 = [0.0, 1000.0, 0.0];
678        let r3 = [0.0, 2000.0, 0.0];
679        assert_eq!(
680            gibbs(&r1, &r2, &r3).unwrap_err(),
681            IodError::CollinearVectors
682        );
683    }
684
685    #[test]
686    fn gibbs_rejects_noncoplanar_vectors() {
687        // Push r1 well out of the r2/r3 plane.
688        let r1 = [6378.1363, 6378.1363, 6378.1363];
689        let r2 = [0.0, -4464.696, -5102.509];
690        let r3 = [0.0, 5740.323, 3189.068];
691        assert_eq!(gibbs(&r1, &r2, &r3).unwrap_err(), IodError::NotCoplanar);
692    }
693
694    #[test]
695    fn gibbs_rejects_nonfinite_input() {
696        let r2 = [0.0, -4464.696, -5102.509];
697        let r3 = [0.0, 5740.323, 3189.068];
698        assert_eq!(
699            gibbs(&[f64::NAN, 0.0, 6378.1363], &r2, &r3).unwrap_err(),
700            IodError::NonFiniteValue
701        );
702    }
703
704    #[test]
705    fn gibbs_accepts_antiparallel_endpoints() {
706        // r1 and r3 are anti-parallel (q = r3 x r1 = 0), but this is a valid
707        // coplanar geometry: three points 90 degrees apart on a circular orbit.
708        // The solver must not reject it as collinear; the middle velocity is the
709        // circular speed, tangent to r2.
710        let r1 = [7000.0, 0.0, 0.0];
711        let r2 = [0.0, 7000.0, 0.0];
712        let r3 = [-7000.0, 0.0, 0.0];
713        let (v2, _, _, copa) = gibbs(&r1, &r2, &r3).unwrap();
714        let vcirc = (VALLADO_MU / 7000.0).sqrt();
715        assert!((v2[0] + vcirc).abs() < 1e-9, "v2_x {} vs {}", v2[0], -vcirc);
716        assert!(v2[1].abs() < 1e-9 && v2[2].abs() < 1e-9);
717        assert!(copa.abs() < 1e-12);
718    }
719
720    #[test]
721    fn gibbs_rejects_overflowing_magnitude() {
722        // Finite but astronomically large inputs whose magnitudes/cross products
723        // overflow to infinity must not collapse into a fabricated finite Ok.
724        let r1 = [1e160, 0.0, 0.0];
725        let r2 = [0.0, 1e160, 0.0];
726        let r3 = [0.0, 0.0, 1e160];
727        assert_eq!(gibbs(&r1, &r2, &r3).unwrap_err(), IodError::NonFiniteValue);
728    }
729
730    #[test]
731    fn halley_iteration_reports_stationary_nonroot() {
732        // Coefficients chosen so f'(x0) == 0 at the seed x0 = 20000/RE while
733        // f(x0) != 0: the Halley step is zero, which the step-size test alone
734        // would accept. The residual check must reject this stationary non-root.
735        let x0 = 20000.0 / VALLADO_RE;
736        let mut poly = [0.0; 9];
737        poly[0] = 1.0;
738        poly[2] = -x0.powi(2);
739        poly[5] = -(2.0 / 3.0) * x0.powi(5);
740        poly[8] = -x0.powi(8) / 9.0;
741        assert_eq!(halley_iteration(&poly), None);
742    }
743
744    #[test]
745    fn halley_iteration_reports_nonconvergence() {
746        // f(x) = x^8 has its only root at 0; from the GPS-altitude seed the
747        // Halley step shrinks geometrically and does not reach the tolerance
748        // within the iteration cap, so the solver reports failure (None) rather
749        // than returning a non-root iterate.
750        let poly = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
751        assert_eq!(halley_iteration(&poly), None);
752    }
753
754    #[test]
755    fn hgibbs_rejects_equal_times() {
756        let r1 = [3419.85564, 6019.82602, 2784.60022];
757        let r2 = [2935.91195, 6326.18324, 2660.59584];
758        let r3 = [2434.95202, 6597.38674, 2521.52311];
759        // jd1 == jd2 -> zero time difference.
760        let jd = 0.0;
761        assert_eq!(
762            hgibbs(&r1, &r2, &r3, jd, jd, (120.0 + 33.04) / 86400.0).unwrap_err(),
763            IodError::InvalidTimeGeometry
764        );
765    }
766
767    #[test]
768    fn hgibbs_rejects_zero_vector() {
769        let r2 = [2935.91195, 6326.18324, 2660.59584];
770        let r3 = [2434.95202, 6597.38674, 2521.52311];
771        let jd1 = 0.0;
772        let jd2 = (60.0 + 16.48) / 86400.0;
773        let jd3 = (120.0 + 33.04) / 86400.0;
774        assert_eq!(
775            hgibbs(&[0.0, 0.0, 0.0], &r2, &r3, jd1, jd2, jd3).unwrap_err(),
776            IodError::ZeroVector
777        );
778    }
779
780    #[test]
781    fn hgibbs_rejects_nonfinite_output() {
782        // Epoch differences large enough to overflow the second-scale divisors
783        // would yield a non-finite velocity; surface it instead of returning
784        // NaN/Inf inside Ok.
785        let r1 = [3419.85564, 6019.82602, 2784.60022];
786        let r2 = [2935.91195, 6326.18324, 2660.59584];
787        let r3 = [2434.95202, 6597.38674, 2521.52311];
788        let err = hgibbs(&r1, &r2, &r3, 0.0, 1e306, 2e306).unwrap_err();
789        assert_eq!(err, IodError::NonFiniteValue);
790    }
791
792    #[test]
793    fn gauss_rejects_equal_times() {
794        let d2r = |d: f64| d * PI / 180.0;
795        let decl = [d2r(18.667717), d2r(35.664741), d2r(36.996583)];
796        let rtasc = [d2r(0.939913), d2r(45.025748), d2r(67.886655)];
797        let jd = [2_456_159.5, 2_456_159.5, 2_456_159.5];
798        // First two epochs identical -> tau12 == 0.
799        let jdf = [0.49199074074074073, 0.49199074074074073, 0.4947685185185185];
800        let rseci = [
801            [4054.881, 2748.195, 4074.237],
802            [3956.224, 2888.232, 4074.364],
803            [3905.073, 2956.935, 4074.430],
804        ];
805        assert_eq!(
806            gauss_angles(&decl, &rtasc, &jd, &jdf, &rseci).unwrap_err(),
807            IodError::InvalidTimeGeometry
808        );
809    }
810
811    #[test]
812    fn gauss_rejects_out_of_range_root() {
813        let d2r = |d: f64| d * PI / 180.0;
814        let decl = [d2r(18.667717), d2r(35.664741), d2r(36.996583)];
815        let rtasc = [d2r(0.939913), d2r(45.025748), d2r(67.886655)];
816        let jd = [2_456_159.5, 2_456_159.5, 2_456_159.5];
817        let rseci = [
818            [4054.881, 2748.195, 4074.237],
819            [3956.224, 2888.232, 4074.364],
820            [3905.073, 2956.935, 4074.430],
821        ];
822        // Stretch the time gaps 100x past the method's validity so the implied
823        // middle range leaves the physical bracket: no positive real root.
824        let base = [0.4864351851851852, 0.49199074074074073, 0.4947685185185185];
825        let mid = base[1];
826        let jdf = [
827            mid + (base[0] - mid) * 100.0,
828            mid,
829            mid + (base[2] - mid) * 100.0,
830        ];
831        assert_eq!(
832            gauss_angles(&decl, &rtasc, &jd, &jdf, &rseci).unwrap_err(),
833            IodError::NoPositiveRoot
834        );
835    }
836
837    #[test]
838    fn gauss_rejects_nonfinite_input() {
839        let d2r = |d: f64| d * PI / 180.0;
840        let decl = [f64::NAN, d2r(35.664741), d2r(36.996583)];
841        let rtasc = [d2r(0.939913), d2r(45.025748), d2r(67.886655)];
842        let jd = [2_456_159.5, 2_456_159.5, 2_456_159.5];
843        let jdf = [0.4864351851851852, 0.49199074074074073, 0.4947685185185185];
844        let rseci = [
845            [4054.881, 2748.195, 4074.237],
846            [3956.224, 2888.232, 4074.364],
847            [3905.073, 2956.935, 4074.430],
848        ];
849        assert_eq!(
850            gauss_angles(&decl, &rtasc, &jd, &jdf, &rseci).unwrap_err(),
851            IodError::NonFiniteValue
852        );
853    }
854}