Skip to main content

satkit/frametransform/
dispatch.rs

1//! Frame-to-frame dispatch: take any two [`Frame`]s and return the
2//! quaternion (or state transform) between them.
3//!
4//! Catches up to the convention that SPICE, Orekit, Astropy, and ANISE all
5//! settled on long ago: a single function `rotation(from, to, t)` instead of
6//! the per-pair named functions ([`qitrf2gcrf`], [`qteme2itrf`], …). The
7//! per-pair functions remain canonical; this layer just dispatches into them.
8//!
9//! # Shortest-path dispatch
10//!
11//! Naive implementations pivot every transform through GCRF, paying the full
12//! IERS 2010 precession/nutation reduction even for cheap ITRF↔TIRS pairs.
13//! This module instead hand-codes the shortest path for each pair, so e.g.
14//! `rotation(Frame::ITRF, Frame::TIRS, t)` only does polar motion, and
15//! `rotation(Frame::ITRF, Frame::CIRS, t)` composes polar motion with the
16//! Earth-rotation angle but skips the precession/nutation step.
17//!
18//! # Frame graph
19//!
20//! ```text
21//! ICRF — GCRF — EME2000
22//!         |
23//!        CIRS
24//!         |
25//!        TIRS
26//!         |
27//!        ITRF — TEME
28//! ```
29//!
30//! [`Frame::LVLH`], [`Frame::RTN`], and [`Frame::NTW`] are orbit-dependent
31//! and not handled here — use [`to_gcrf`](super::to_gcrf) /
32//! [`from_gcrf`](super::from_gcrf) for those.
33//!
34//! [`qitrf2gcrf`]: super::qitrf2gcrf
35//! [`qteme2itrf`]: super::qteme2itrf
36
37use std::f64::consts::PI;
38
39use super::{
40    gcrf_to_itrf_state, gcrf_to_itrf_state_approx, itrf_to_gcrf_state, itrf_to_gcrf_state_approx,
41};
42use super::{
43    qcirs2gcrs, qitrf2gcrf, qitrf2gcrf_approx, qitrf2tirs, qteme2itrf, qtirs2cirs, Error, Result,
44};
45use crate::frames::Frame;
46use crate::mathtypes::{Quaternion, Vector3};
47use crate::TimeLike;
48
49const ASEC2RAD: f64 = PI / 180.0 / 3600.0;
50
51// ───── EME2000 frame bias ────────────────────────────────────────────────
52//
53// Constant rotation between EME2000 (J2000 mean dynamical equator + equinox)
54// and GCRF (= GCRS). The three IERS 2010 canonical small Euler angles
55// (Conventions 2010 §5.32):
56//
57//   dα0 = -0.014600 ± 0.000100 arcsec   RA offset of J2000 mean equinox
58//   ξ0  = -0.016617 ± 0.000010 arcsec   obliquity-direction bias
59//   η0  = -0.006819 ± 0.000010 arcsec   azimuth-direction bias
60//
61// The IERS bias matrix (eq. 5.36) is B = R1(-η0) · R2(ξ0) · R3(dα0), where
62// R1/R2/R3 are *passive* (component-transformation) rotations. B transforms
63// GCRS components to EME2000 components: v_EME2000 = B · v_GCRS.
64//
65// `numeris::Quaternion::rot{x,y,z}(θ)` is the *active* right-hand-rule
66// rotation by +θ, which equals the passive R_i(−θ). So expressing IERS B
67// in numeris terms requires negating each angle, and we further want the
68// inverse B^T = R3(-dα0) · R2(-ξ0) · R1(η0) for EME2000 → GCRF:
69const FRAME_BIAS_DALPHA0_AS: f64 = -0.014600;
70const FRAME_BIAS_XI0_AS: f64 = -0.016617;
71const FRAME_BIAS_ETA0_AS: f64 = -0.006819;
72
73/// Constant quaternion: EME2000 → GCRF (≈ 17 milliarcsec frame bias).
74///
75/// Implements `B^T = R3(-dα0) · R2(-ξ0) · R1(η0)` in IERS notation. In
76/// numeris' active-rotation convention this is `rotz(dα0) · roty(ξ0) ·
77/// rotx(-η0)` (each axis-angle negated relative to the passive form).
78fn qeme2000_to_gcrf() -> Quaternion {
79    let dalpha0 = FRAME_BIAS_DALPHA0_AS * ASEC2RAD;
80    let xi0 = FRAME_BIAS_XI0_AS * ASEC2RAD;
81    let eta0 = FRAME_BIAS_ETA0_AS * ASEC2RAD;
82    Quaternion::rotz(dalpha0) * Quaternion::roty(xi0) * Quaternion::rotx(-eta0)
83}
84
85// ───── Frame classification ──────────────────────────────────────────────
86
87/// True for frames that rotate with Earth (state transforms to/from these
88/// pick up an `ω⊕ × r` sweep term). Polar motion between ITRF and TIRS is
89/// slow (~1.7e-9 rad/s) and treated as a static rotation.
90fn is_earth_rotating(f: Frame) -> bool {
91    match f {
92        Frame::ITRF | Frame::TIRS => true,
93        Frame::CIRS
94        | Frame::GCRF
95        | Frame::TEME
96        | Frame::EME2000
97        | Frame::ICRF
98        | Frame::LVLH
99        | Frame::RTN
100        | Frame::NTW => false,
101    }
102}
103
104/// True for frames whose axes are defined by an orbit's instantaneous
105/// position and velocity — not handled by the time-only dispatch in this
106/// module. Use [`to_gcrf`](super::to_gcrf) / [`from_gcrf`](super::from_gcrf).
107fn is_orbit_dependent(f: Frame) -> bool {
108    match f {
109        Frame::LVLH | Frame::RTN | Frame::NTW => true,
110        Frame::ITRF
111        | Frame::TIRS
112        | Frame::CIRS
113        | Frame::GCRF
114        | Frame::TEME
115        | Frame::EME2000
116        | Frame::ICRF => false,
117    }
118}
119
120// ───── canonical ordering ────────────────────────────────────────────────
121
122/// Position of each [`Frame`] in the canonical ordering used to normalise
123/// the (from, to) pair so each unordered pair appears in the match once.
124/// Adding a new variant forces this match to be updated.
125fn frame_order(f: Frame) -> u8 {
126    match f {
127        Frame::ITRF => 0,
128        Frame::TIRS => 1,
129        Frame::CIRS => 2,
130        Frame::GCRF => 3,
131        Frame::TEME => 4,
132        Frame::EME2000 => 5,
133        Frame::ICRF => 6,
134        Frame::LVLH => 7,
135        Frame::RTN => 8,
136        Frame::NTW => 9,
137    }
138}
139
140/// Normalise `(from, to)` to a canonical ordered pair plus a `reversed` flag.
141fn canonicalise(from: Frame, to: Frame) -> (Frame, Frame, bool) {
142    if frame_order(from) <= frame_order(to) {
143        (from, to, false)
144    } else {
145        (to, from, true)
146    }
147}
148
149// ───── public API ────────────────────────────────────────────────────────
150
151/// Quaternion rotating a vector from `from` to `to` at time `t`.
152///
153/// Uses the full IERS 2010 reduction for the Earth-frame chain. Every
154/// time-parameterised pair is supported via the shortest path through the
155/// frame graph; orbit-dependent frames ([`Frame::LVLH`], [`Frame::RTN`],
156/// [`Frame::NTW`]) are not supported here — use [`to_gcrf`](super::to_gcrf)
157/// for those.
158///
159/// # Examples
160///
161/// ```ignore
162/// use satkit::{Frame, Instant};
163/// use satkit::frametransform::rotation;
164///
165/// let t = Instant::from_datetime(2026, 5, 22, 12, 0, 0.0).unwrap();
166/// let q = rotation(Frame::ITRF, Frame::GCRF, &t)?;
167/// ```
168pub fn rotation<T: TimeLike>(from: Frame, to: Frame, t: &T) -> Result<Quaternion> {
169    if from == to {
170        return Ok(Quaternion::identity());
171    }
172    let (a, b, reversed) = canonicalise(from, to);
173    let q = canonical_rotation(a, b, t)?;
174    Ok(if reversed { q.conjugate() } else { q })
175}
176
177/// Quaternion rotating a vector from `from` to `to` using the
178/// IAU-76/FK5 approximate reduction (~1 arcsec, much cheaper than full
179/// IERS 2010).
180///
181/// Only defined for pairs at the endpoints of the FK5 chain: [`Frame::ITRF`]
182/// and the inertial cluster ([`Frame::GCRF`], [`Frame::EME2000`],
183/// [`Frame::ICRF`], [`Frame::TEME`]). [`Frame::TIRS`] and [`Frame::CIRS`] are
184/// defined by the IERS 2010 reduction and have no FK5 analogue — requests
185/// involving them return [`Error::ApproxNotSupportedForFrame`].
186pub fn rotation_approx<T: TimeLike>(from: Frame, to: Frame, t: &T) -> Result<Quaternion> {
187    if from == to {
188        return Ok(Quaternion::identity());
189    }
190    reject_for_approx(from)?;
191    reject_for_approx(to)?;
192    let (a, b, reversed) = canonicalise(from, to);
193    let q = canonical_rotation_approx(a, b, t)?;
194    Ok(if reversed { q.conjugate() } else { q })
195}
196
197/// State (position + velocity) transform from `from` to `to` at time `t`.
198///
199/// Uses the full IERS 2010 reduction. Properly handles the Earth-rotation
200/// sweep term `ω⊕ × r` when transitioning between rotating ([`Frame::ITRF`],
201/// [`Frame::TIRS`]) and inertial ([`Frame::GCRF`], [`Frame::EME2000`],
202/// [`Frame::ICRF`], [`Frame::CIRS`], [`Frame::TEME`]) frames. ITRF↔TIRS is
203/// treated as a static rotation — polar motion contributes ~1 mm/s at LEO
204/// altitudes and is neglected here, matching the existing
205/// [`itrf_to_gcrf_state`](super::itrf_to_gcrf_state) convention.
206///
207/// Orbit-dependent frames ([`Frame::LVLH`], [`Frame::RTN`], [`Frame::NTW`])
208/// require orbit state to define their axes and are not handled here — use
209/// [`to_gcrf`](super::to_gcrf) / [`from_gcrf`](super::from_gcrf) for those.
210pub fn transform_state<T: TimeLike>(
211    from: Frame,
212    to: Frame,
213    t: &T,
214    pos: &Vector3,
215    vel: &Vector3,
216) -> Result<(Vector3, Vector3)> {
217    if from == to {
218        return Ok((*pos, *vel));
219    }
220    state_dispatch(from, to, t, pos, vel, /* approx = */ false)
221}
222
223/// State (position + velocity) transform using the IAU-76/FK5 approximate
224/// reduction (~1 arcsec). Same domain restrictions as
225/// [`rotation_approx`]: [`Frame::TIRS`] and [`Frame::CIRS`] are rejected
226/// (no FK5 analogue); valid pairs are between [`Frame::ITRF`] and the
227/// inertial cluster ([`Frame::GCRF`], [`Frame::EME2000`], [`Frame::ICRF`],
228/// [`Frame::TEME`]), or within the inertial cluster.
229pub fn transform_state_approx<T: TimeLike>(
230    from: Frame,
231    to: Frame,
232    t: &T,
233    pos: &Vector3,
234    vel: &Vector3,
235) -> Result<(Vector3, Vector3)> {
236    if from == to {
237        return Ok((*pos, *vel));
238    }
239    reject_for_approx(from)?;
240    reject_for_approx(to)?;
241    state_dispatch(from, to, t, pos, vel, /* approx = */ true)
242}
243
244// ───── internal dispatch ─────────────────────────────────────────────────
245
246/// Reject TIRS / CIRS for approx-mode operations. Orbit frames are rejected
247/// downstream by [`canonical_rotation_approx`] / [`state_dispatch`].
248fn reject_for_approx(frame: Frame) -> Result<()> {
249    match frame {
250        Frame::TIRS | Frame::CIRS => Err(Error::ApproxNotSupportedForFrame { frame }),
251        _ => Ok(()),
252    }
253}
254
255/// Canonical-direction rotation for the full IERS 2010 reduction.
256/// `from < to` per [`frame_order`].
257fn canonical_rotation<T: TimeLike>(from: Frame, to: Frame, t: &T) -> Result<Quaternion> {
258    use Frame::*;
259    let q = match (from, to) {
260        // ── 1-step direct edges ────────────────────────────────────────
261        (ITRF, TIRS) => qitrf2tirs(t),
262        (TIRS, CIRS) => qtirs2cirs(t),
263        (CIRS, GCRF) => qcirs2gcrs(t),
264        (ITRF, TEME) => qteme2itrf(t).conjugate(),
265        (GCRF, EME2000) => qeme2000_to_gcrf().conjugate(),
266        (GCRF, ICRF) => Quaternion::identity(),
267
268        // ── existing amortised direct function ─────────────────────────
269        (ITRF, GCRF) => qitrf2gcrf(t),
270
271        // ── 2-step compositions (shortest path) ────────────────────────
272        (ITRF, CIRS) => qtirs2cirs(t) * qitrf2tirs(t),
273        (TIRS, GCRF) => qcirs2gcrs(t) * qtirs2cirs(t),
274        // (TIRS, TEME): canonical pair wants q_{TIRS→TEME}. The expression
275        // `qitrf2tirs * qteme2itrf` composes (applied to v) as TEME → ITRF →
276        // TIRS, which is q_{TEME→TIRS}; conjugate to flip direction.
277        (TIRS, TEME) => (qitrf2tirs(t) * qteme2itrf(t)).conjugate(),
278        (TIRS, ICRF) => qcirs2gcrs(t) * qtirs2cirs(t),
279        (CIRS, ICRF) => qcirs2gcrs(t),
280        (EME2000, ICRF) => qeme2000_to_gcrf(),
281
282        // ── 3-step compositions ────────────────────────────────────────
283        // (CIRS, TEME): canonical pair wants q_{CIRS→TEME}. Same direction
284        // flip as (TIRS, TEME) above.
285        (CIRS, TEME) => (qtirs2cirs(t) * qitrf2tirs(t) * qteme2itrf(t)).conjugate(),
286        (ITRF, EME2000) => qeme2000_to_gcrf().conjugate() * qitrf2gcrf(t),
287        (ITRF, ICRF) => qitrf2gcrf(t),
288        (TIRS, EME2000) => qeme2000_to_gcrf().conjugate() * qcirs2gcrs(t) * qtirs2cirs(t),
289        (CIRS, EME2000) => qeme2000_to_gcrf().conjugate() * qcirs2gcrs(t),
290        // (GCRF, TEME): canonical pair wants q_{GCRF→TEME}. We compose
291        // through ITRF for full IERS 2010 (the existing `qteme2gcrf` uses
292        // `qitrf2gcrf_approx` internally — that flavour belongs in
293        // `rotation_approx`). The natural expression `qitrf2gcrf *
294        // qteme2itrf` is q_{TEME→GCRF}; conjugate to flip direction.
295        (GCRF, TEME) => (qitrf2gcrf(t) * qteme2itrf(t)).conjugate(),
296
297        // ── 4+-step compositions ───────────────────────────────────────
298        (TEME, EME2000) => qeme2000_to_gcrf().conjugate() * qitrf2gcrf(t) * qteme2itrf(t),
299        (TEME, ICRF) => qitrf2gcrf(t) * qteme2itrf(t),
300
301        // ── orbit-dependent frames need state ──────────────────────────
302        (LVLH | RTN | NTW, _) | (_, LVLH | RTN | NTW) => {
303            return Err(Error::OrbitFrameRequiresState { from, to });
304        }
305
306        // ── all remaining (from, to) pairs are not in canonical order ──
307        // (canonicalise() guarantees from_order <= to_order)
308        _ => unreachable!("non-canonical pair reached canonical_rotation: ({from}, {to})"),
309    };
310    Ok(q)
311}
312
313/// Canonical-direction rotation for the FK5 approximate reduction.
314/// Only inertial-cluster + ITRF + TEME pairs are valid.
315fn canonical_rotation_approx<T: TimeLike>(from: Frame, to: Frame, t: &T) -> Result<Quaternion> {
316    use Frame::*;
317    let q = match (from, to) {
318        (ITRF, GCRF) => qitrf2gcrf_approx(t),
319        (ITRF, TEME) => qteme2itrf(t).conjugate(),
320        (ITRF, EME2000) => qeme2000_to_gcrf().conjugate() * qitrf2gcrf_approx(t),
321        (ITRF, ICRF) => qitrf2gcrf_approx(t),
322
323        (GCRF, EME2000) => qeme2000_to_gcrf().conjugate(),
324        (GCRF, ICRF) => Quaternion::identity(),
325        // (GCRF, TEME): same direction flip as in `canonical_rotation`.
326        (GCRF, TEME) => (qitrf2gcrf_approx(t) * qteme2itrf(t)).conjugate(),
327
328        (EME2000, ICRF) => qeme2000_to_gcrf(),
329        (TEME, EME2000) => qeme2000_to_gcrf().conjugate() * qitrf2gcrf_approx(t) * qteme2itrf(t),
330        (TEME, ICRF) => qitrf2gcrf_approx(t) * qteme2itrf(t),
331
332        // TIRS / CIRS already rejected by reject_for_approx().
333        // Orbit frames:
334        (LVLH | RTN | NTW, _) | (_, LVLH | RTN | NTW) => {
335            return Err(Error::OrbitFrameRequiresState { from, to });
336        }
337
338        _ => unreachable!("non-canonical pair reached canonical_rotation_approx: ({from}, {to})"),
339    };
340    Ok(q)
341}
342
343/// Common implementation for [`transform_state`] / [`transform_state_approx`].
344///
345/// Frame classification for state transforms:
346///   - **Rotating** (relative to inertial space at Earth rotation rate):
347///     [`Frame::ITRF`], [`Frame::TIRS`]. Polar motion between ITRF and TIRS
348///     is treated as a static rotation (rate ~ 1.7e-9 rad/s × r is
349///     sub-mm/s at LEO and is neglected, matching the existing
350///     [`itrf_to_gcrf_state`] convention).
351///   - **Inertial** (for state-transform purposes): [`Frame::GCRF`],
352///     [`Frame::EME2000`], [`Frame::ICRF`], [`Frame::CIRS`],
353///     [`Frame::TEME`]. CIRS's precession rate (~50"/year ≈ 7.7e-12 rad/s)
354///     is negligible.
355///
356/// Dispatch:
357///   - inertial ↔ inertial: just rotate pos and vel.
358///   - rotating ↔ rotating (ITRF ↔ TIRS): just rotate (no sweep).
359///   - rotating ↔ inertial: route via ITRF↔GCRF using the existing state
360///     functions (which evaluate the `ω⊕ × r` sweep in TIRS where ω⊕ is
361///     exactly along +ẑ), then chain a rotation on each side as needed.
362fn state_dispatch<T: TimeLike>(
363    from: Frame,
364    to: Frame,
365    t: &T,
366    pos: &Vector3,
367    vel: &Vector3,
368    approx: bool,
369) -> Result<(Vector3, Vector3)> {
370    use Frame::*;
371
372    // Orbit-dependent frames need orbit state to define their axes — not
373    // handled by this state dispatch.
374    if is_orbit_dependent(from) || is_orbit_dependent(to) {
375        return Err(Error::OrbitFrameRequiresState { from, to });
376    }
377
378    let is_rotating = is_earth_rotating;
379
380    // Case A: both inertial — straight rotation, no sweep term.
381    if !is_rotating(from) && !is_rotating(to) {
382        let q = if approx {
383            rotation_approx(from, to, t)?
384        } else {
385            rotation(from, to, t)?
386        };
387        return Ok((q * *pos, q * *vel));
388    }
389
390    // Case B: both rotating (ITRF ↔ TIRS) — polar motion only, treated as
391    // static, no sweep term.
392    if is_rotating(from) && is_rotating(to) {
393        // No approx variant: ITRF/TIRS aren't part of the FK5 chain. If
394        // approx was requested for one of these, reject_for_approx() would
395        // already have caught TIRS upstream.
396        let q = rotation(from, to, t)?;
397        return Ok((q * *pos, q * *vel));
398    }
399
400    // Case C: rotating ↔ inertial — route via ITRF↔GCRF.
401    if is_rotating(from) {
402        // Step 1: move pos/vel into ITRF basis (no sweep change; ITRF and
403        // TIRS share angular velocity to the precision we model).
404        let (p_itrf, v_itrf) = if from == ITRF {
405            (*pos, *vel)
406        } else {
407            // from == TIRS
408            let q = rotation(TIRS, ITRF, t)?;
409            (q * *pos, q * *vel)
410        };
411        // Step 2: ITRF → GCRF, with the sweep term added in TIRS.
412        let (p_gcrf, v_gcrf) = if approx {
413            itrf_to_gcrf_state_approx(&p_itrf, &v_itrf, t)
414        } else {
415            itrf_to_gcrf_state(&p_itrf, &v_itrf, t)
416        };
417        // Step 3: rotate GCRF → target inertial frame.
418        let q = if approx {
419            rotation_approx(GCRF, to, t)?
420        } else {
421            rotation(GCRF, to, t)?
422        };
423        return Ok((q * p_gcrf, q * v_gcrf));
424    }
425    // Symmetric: inertial source, rotating target.
426    // Step 1: rotate from source inertial frame to GCRF.
427    let q = if approx {
428        rotation_approx(from, GCRF, t)?
429    } else {
430        rotation(from, GCRF, t)?
431    };
432    let p_gcrf = q * *pos;
433    let v_gcrf = q * *vel;
434    // Step 2: GCRF → ITRF, with the sweep term subtracted in TIRS.
435    let (p_itrf, v_itrf) = if approx {
436        gcrf_to_itrf_state_approx(&p_gcrf, &v_gcrf, t)
437    } else {
438        gcrf_to_itrf_state(&p_gcrf, &v_gcrf, t)
439    };
440    // Step 3: move into target rotating basis.
441    if to == ITRF {
442        Ok((p_itrf, v_itrf))
443    } else {
444        // to == TIRS
445        let q_itrf_to_tirs = rotation(ITRF, TIRS, t)?;
446        Ok((q_itrf_to_tirs * p_itrf, q_itrf_to_tirs * v_itrf))
447    }
448}
449
450// ───── tests ─────────────────────────────────────────────────────────────
451
452#[cfg(test)]
453mod tests {
454    use super::super::qgcrf2itrf;
455    use super::*;
456    use crate::Instant;
457
458    fn t() -> Instant {
459        Instant::from_datetime(2026, 5, 22, 12, 0, 0.0).unwrap()
460    }
461
462    #[test]
463    fn identity_pairs() {
464        let tm = t();
465        for f in [
466            Frame::ITRF,
467            Frame::TIRS,
468            Frame::CIRS,
469            Frame::GCRF,
470            Frame::TEME,
471            Frame::EME2000,
472            Frame::ICRF,
473        ] {
474            let q = rotation(f, f, &tm).unwrap();
475            assert!((q.w - 1.0).abs() < 1e-15, "{f}: w={}", q.w);
476        }
477    }
478
479    #[test]
480    fn matches_qitrf2gcrf() {
481        let tm = t();
482        let q_dispatch = rotation(Frame::ITRF, Frame::GCRF, &tm).unwrap();
483        let q_direct = qitrf2gcrf(&tm);
484        let v = numeris::vector![1000.0, 2000.0, 3000.0];
485        let v_dispatch = q_dispatch * v;
486        let v_direct = q_direct * v;
487        assert!(
488            (v_dispatch - v_direct).norm() < 1e-9,
489            "dispatch={v_dispatch:?} direct={v_direct:?}"
490        );
491    }
492
493    #[test]
494    fn matches_qgcrf2itrf() {
495        let tm = t();
496        let q_dispatch = rotation(Frame::GCRF, Frame::ITRF, &tm).unwrap();
497        let q_direct = qgcrf2itrf(&tm);
498        let v = numeris::vector![1000.0, 2000.0, 3000.0];
499        let v_dispatch = q_dispatch * v;
500        let v_direct = q_direct * v;
501        assert!((v_dispatch - v_direct).norm() < 1e-9);
502    }
503
504    #[test]
505    fn matches_qitrf2tirs_direct_path() {
506        // ITRF→TIRS is a single direct edge; should not pay precession cost.
507        let tm = t();
508        let q_dispatch = rotation(Frame::ITRF, Frame::TIRS, &tm).unwrap();
509        let q_direct = qitrf2tirs(&tm);
510        let v = numeris::vector![1000.0, 2000.0, 3000.0];
511        assert!((q_dispatch * v - q_direct * v).norm() < 1e-12);
512    }
513
514    #[test]
515    fn matches_qteme2itrf() {
516        let tm = t();
517        let q_dispatch = rotation(Frame::TEME, Frame::ITRF, &tm).unwrap();
518        let q_direct = qteme2itrf(&tm);
519        let v = numeris::vector![1000.0, 2000.0, 3000.0];
520        assert!((q_dispatch * v - q_direct * v).norm() < 1e-12);
521    }
522
523    /// Direction pin for every TEME-involving pair. The roundtrip test
524    /// passes regardless of direction (because `rotation(b, a)` is just
525    /// the conjugate of `rotation(a, b)`), so this test pins the absolute
526    /// direction by composing dispatch with a known-good reference. If a
527    /// future change flips a sign, this fails.
528    #[test]
529    fn dispatch_teme_pairs_have_correct_direction() {
530        use super::super::qteme2gcrf;
531        let tm = t();
532        let v = numeris::vector![7000e3_f64, 1000e3, 2000e3];
533
534        // `qteme2gcrf` is the approximate TEME → GCRF rotation. Use it as
535        // the reference for `rotation_approx` (which composes with the
536        // same approximate ITRF↔GCRF). For full `rotation`, allow ~10 m
537        // tolerance because dispatch uses the full IERS 2010 reduction
538        // and qteme2gcrf is FK5-approx.
539        let q_teme_to_gcrf_ref = qteme2gcrf(&tm);
540
541        // rotation_approx(TEME, GCRF) should match qteme2gcrf to float
542        // precision (both are the approximate reduction).
543        let q_dispatch = rotation_approx(Frame::TEME, Frame::GCRF, &tm).unwrap();
544        let lhs = q_dispatch * v;
545        let rhs = q_teme_to_gcrf_ref * v;
546        assert!(
547            (lhs - rhs).norm() / v.norm() < 1e-12,
548            "rotation_approx(TEME,GCRF) direction mismatch: dispatch={lhs:?} ref={rhs:?}"
549        );
550
551        // rotation_approx(GCRF, TEME) is the inverse.
552        let q_dispatch = rotation_approx(Frame::GCRF, Frame::TEME, &tm).unwrap();
553        let lhs = q_dispatch * v;
554        let rhs = q_teme_to_gcrf_ref.conjugate() * v;
555        assert!(
556            (lhs - rhs).norm() / v.norm() < 1e-12,
557            "rotation_approx(GCRF,TEME) direction mismatch: dispatch={lhs:?} ref={rhs:?}"
558        );
559
560        // Full rotation(TEME, GCRF): differs from qteme2gcrf by the
561        // approx-vs-full reduction error (~1 arcsec). At |v|≈7300 km
562        // that's ~35 m of position; check direction is right with a
563        // loose tolerance.
564        let q_dispatch = rotation(Frame::TEME, Frame::GCRF, &tm).unwrap();
565        let lhs = q_dispatch * v;
566        let rhs = q_teme_to_gcrf_ref * v;
567        assert!(
568            (lhs - rhs).norm() < 100.0,
569            "rotation(TEME,GCRF) direction mismatch (>100 m): \
570             dispatch={lhs:?} approx_ref={rhs:?}"
571        );
572
573        // Full rotation(GCRF, TEME): inverse direction, same tolerance.
574        let q_dispatch = rotation(Frame::GCRF, Frame::TEME, &tm).unwrap();
575        let lhs = q_dispatch * v;
576        let rhs = q_teme_to_gcrf_ref.conjugate() * v;
577        assert!(
578            (lhs - rhs).norm() < 100.0,
579            "rotation(GCRF,TEME) direction mismatch (>100 m): \
580             dispatch={lhs:?} approx_ref={rhs:?}"
581        );
582
583        // (TIRS, TEME) and (CIRS, TEME): compose dispatch with the
584        // direct functions to recover v_TEME from v_TEME and check
585        // identity. Concretely: rotation(TIRS, TEME) ∘ rotation(TEME, TIRS)
586        // = identity is the roundtrip (already tested) — but it doesn't
587        // pin direction. Instead, take v_TEME, apply rotation(TEME, TIRS),
588        // then qitrf2tirs(t) * qteme2itrf(t) * v_TEME should give the same
589        // TIRS vector if both go TEME → ITRF → TIRS.
590        let v_teme = v;
591        let lhs = rotation(Frame::TEME, Frame::TIRS, &tm).unwrap() * v_teme;
592        let rhs = qitrf2tirs(&tm) * (qteme2itrf(&tm) * v_teme);
593        assert!(
594            (lhs - rhs).norm() / v.norm() < 1e-12,
595            "rotation(TEME,TIRS) direction mismatch: dispatch={lhs:?} ref={rhs:?}"
596        );
597
598        let lhs = rotation(Frame::TEME, Frame::CIRS, &tm).unwrap() * v_teme;
599        let rhs = qtirs2cirs(&tm) * (qitrf2tirs(&tm) * (qteme2itrf(&tm) * v_teme));
600        assert!(
601            (lhs - rhs).norm() / v.norm() < 1e-12,
602            "rotation(TEME,CIRS) direction mismatch: dispatch={lhs:?} ref={rhs:?}"
603        );
604    }
605
606    #[test]
607    fn roundtrip_all_pairs() {
608        let tm = t();
609        let v = numeris::vector![6378.0, 2000.0, 3000.0];
610        let frames = [
611            Frame::ITRF,
612            Frame::TIRS,
613            Frame::CIRS,
614            Frame::GCRF,
615            Frame::TEME,
616            Frame::EME2000,
617            Frame::ICRF,
618        ];
619        for &a in &frames {
620            for &b in &frames {
621                let q_ab = rotation(a, b, &tm).unwrap();
622                let q_ba = rotation(b, a, &tm).unwrap();
623                let v_round = q_ba * (q_ab * v);
624                let err = (v_round - v).norm() / v.norm();
625                assert!(err < 1e-12, "({a} → {b} → {a}) error {err}");
626            }
627        }
628    }
629
630    #[test]
631    fn approx_rejects_intermediate_frames() {
632        let tm = t();
633        for f in [Frame::TIRS, Frame::CIRS] {
634            let err = rotation_approx(f, Frame::GCRF, &tm).unwrap_err();
635            assert!(matches!(err, Error::ApproxNotSupportedForFrame { frame } if frame == f));
636            let err = rotation_approx(Frame::GCRF, f, &tm).unwrap_err();
637            assert!(matches!(err, Error::ApproxNotSupportedForFrame { frame } if frame == f));
638        }
639    }
640
641    #[test]
642    fn approx_matches_qitrf2gcrf_approx() {
643        let tm = t();
644        let q_dispatch = rotation_approx(Frame::ITRF, Frame::GCRF, &tm).unwrap();
645        let q_direct = qitrf2gcrf_approx(&tm);
646        let v = numeris::vector![1000.0, 2000.0, 3000.0];
647        assert!((q_dispatch * v - q_direct * v).norm() < 1e-9);
648    }
649
650    #[test]
651    fn orbit_frames_rejected() {
652        let tm = t();
653        for of in [Frame::LVLH, Frame::RTN, Frame::NTW] {
654            assert!(matches!(
655                rotation(of, Frame::GCRF, &tm),
656                Err(Error::OrbitFrameRequiresState { .. })
657            ));
658            assert!(matches!(
659                rotation(Frame::GCRF, of, &tm),
660                Err(Error::OrbitFrameRequiresState { .. })
661            ));
662        }
663    }
664
665    #[test]
666    fn icrf_eme2000_constant_bias() {
667        // ICRF↔EME2000 should be time-independent; check at two epochs.
668        let t1 = Instant::from_datetime(2000, 1, 1, 0, 0, 0.0).unwrap();
669        let t2 = Instant::from_datetime(2026, 5, 22, 0, 0, 0.0).unwrap();
670        let q1 = rotation(Frame::ICRF, Frame::EME2000, &t1).unwrap();
671        let q2 = rotation(Frame::ICRF, Frame::EME2000, &t2).unwrap();
672        assert!((q1.w - q2.w).abs() < 1e-15);
673    }
674
675    #[test]
676    fn eme2000_bias_matches_iers_2010() {
677        // Pin the EME2000 → GCRF bias matrix to the IERS Conventions 2010
678        // §5.32 small-Euler-angle reference (ξ0, η0, dα0). The matrix is
679        // time-independent so a single epoch suffices.
680        let t = Instant::from_datetime(2000, 1, 1, 12, 0, 0.0).unwrap();
681        let q = rotation(Frame::EME2000, Frame::GCRF, &t).unwrap();
682        let e1 = numeris::vector![1.0_f64, 0.0, 0.0];
683        let e2 = numeris::vector![0.0_f64, 1.0, 0.0];
684        let e3 = numeris::vector![0.0_f64, 0.0, 1.0];
685        // Reference values from the IERS 2010 reference matrix (computed
686        // off-line in numpy from the small-angle formula B^T = R3(-dα0) ·
687        // R2(-ξ0) · R1(η0) with ξ0 = -0.016617", η0 = -0.006819",
688        // dα0 = -0.014600"). See module-level doc comment.
689        let c0 = q * e1;
690        let c1 = q * e2;
691        let c2 = q * e3;
692        // First column: (1, dα0_rad, -ξ0_rad) to first order.
693        assert!((c0[0] - 1.0).abs() < 1e-14);
694        assert!((c0[1] - (-7.07827974e-8)).abs() < 1e-15);
695        assert!((c0[2] - 8.05614894e-8).abs() < 1e-15);
696        // Second column: (-dα0_rad, 1, η0_rad).
697        assert!((c1[0] - 7.07827948e-8).abs() < 1e-15);
698        assert!((c1[1] - 1.0).abs() < 1e-14);
699        assert!((c1[2] - 3.30594449e-8).abs() < 1e-15);
700        // Third column: (ξ0_rad, -η0_rad, 1).
701        assert!((c2[0] - (-8.05614917e-8)).abs() < 1e-15);
702        assert!((c2[1] - (-3.30594392e-8)).abs() < 1e-15);
703        assert!((c2[2] - 1.0).abs() < 1e-14);
704    }
705
706    #[test]
707    fn transform_state_itrf_to_gcrf_matches_direct() {
708        let tm = t();
709        let p_itrf = numeris::vector![6378137.0, 0.0, 0.0];
710        let v_itrf = numeris::vector![0.0, 0.0, 0.0];
711        let (p_dispatch, v_dispatch) =
712            transform_state(Frame::ITRF, Frame::GCRF, &tm, &p_itrf, &v_itrf).unwrap();
713        let (p_direct, v_direct) = itrf_to_gcrf_state(&p_itrf, &v_itrf, &tm);
714        assert!((p_dispatch - p_direct).norm() < 1e-9);
715        assert!((v_dispatch - v_direct).norm() < 1e-12);
716    }
717
718    #[test]
719    fn transform_state_roundtrip_itrf_gcrf() {
720        let tm = t();
721        let p = numeris::vector![6378137.0, 0.0, 0.0];
722        let v = numeris::vector![0.0, 7600.0, 0.0];
723        let (p2, v2) = transform_state(Frame::ITRF, Frame::GCRF, &tm, &p, &v).unwrap();
724        let (p3, v3) = transform_state(Frame::GCRF, Frame::ITRF, &tm, &p2, &v2).unwrap();
725        assert!((p3 - p).norm() / p.norm() < 1e-10);
726        assert!((v3 - v).norm() / v.norm() < 1e-10);
727    }
728
729    #[test]
730    fn transform_state_all_non_orbit_pairs_roundtrip() {
731        // Every pair of {ITRF, TIRS, CIRS, GCRF, EME2000, ICRF, TEME}
732        // should roundtrip via transform_state in both directions.
733        let tm = t();
734        let p = numeris::vector![7000000.0, 0.0, 0.0];
735        let v = numeris::vector![0.0, 7600.0, 0.0];
736        let frames = [
737            Frame::ITRF,
738            Frame::TIRS,
739            Frame::CIRS,
740            Frame::GCRF,
741            Frame::TEME,
742            Frame::EME2000,
743            Frame::ICRF,
744        ];
745        for &a in &frames {
746            for &b in &frames {
747                let (pa, va) = transform_state(a, b, &tm, &p, &v).unwrap();
748                let (pr, vr) = transform_state(b, a, &tm, &pa, &va).unwrap();
749                let pos_err = (pr - p).norm() / p.norm();
750                let vel_err = (vr - v).norm() / v.norm();
751                assert!(pos_err < 1e-10, "({a}↔{b}) pos roundtrip err {pos_err}");
752                assert!(vel_err < 1e-10, "({a}↔{b}) vel roundtrip err {vel_err}");
753            }
754        }
755    }
756
757    #[test]
758    fn transform_state_inertial_pair_no_sweep() {
759        // GCRF ↔ TEME: both inertial. Should be a pure rotation — v
760        // magnitude preserved exactly (no sweep added or removed).
761        let tm = t();
762        let p = numeris::vector![7000000.0, 0.0, 0.0];
763        let v = numeris::vector![0.0, 7600.0, 0.0];
764        let (_, v_teme) = transform_state(Frame::GCRF, Frame::TEME, &tm, &p, &v).unwrap();
765        assert!(
766            (v_teme.norm() - v.norm()).abs() < 1e-9,
767            "inertial pair preserved |v|: |v|={}, |v_teme|={}",
768            v.norm(),
769            v_teme.norm()
770        );
771    }
772
773    #[test]
774    fn transform_state_tirs_via_itrf_chain() {
775        // TIRS → GCRF should equal (ITRF → GCRF after rotating pos/vel
776        // from TIRS into ITRF first). The dispatch routes via that path.
777        let tm = t();
778        let p_tirs = numeris::vector![7000000.0, 0.0, 0.0];
779        let v_tirs = numeris::vector![0.0, 0.0, 0.0];
780        let (p_a, v_a) = transform_state(Frame::TIRS, Frame::GCRF, &tm, &p_tirs, &v_tirs).unwrap();
781        // Reference: do it by hand.
782        let q_tirs_to_itrf = rotation(Frame::TIRS, Frame::ITRF, &tm).unwrap();
783        let p_itrf = q_tirs_to_itrf * p_tirs;
784        let v_itrf = q_tirs_to_itrf * v_tirs;
785        let (p_b, v_b) = itrf_to_gcrf_state(&p_itrf, &v_itrf, &tm);
786        assert!((p_a - p_b).norm() < 1e-9);
787        assert!((v_a - v_b).norm() < 1e-12);
788    }
789
790    #[test]
791    fn transform_state_itrf_tirs_no_sweep() {
792        // ITRF ↔ TIRS is treated as static (polar motion only; no sweep).
793        // |v| is preserved by the rotation.
794        let tm = t();
795        let p = numeris::vector![7000000.0, 0.0, 0.0];
796        let v = numeris::vector![0.0, 7600.0, 0.0];
797        let (_, v_tirs) = transform_state(Frame::ITRF, Frame::TIRS, &tm, &p, &v).unwrap();
798        assert!((v_tirs.norm() - v.norm()).abs() < 1e-9);
799    }
800
801    #[test]
802    fn transform_state_approx_rejects_intermediates() {
803        let tm = t();
804        let p = numeris::vector![7000000.0, 0.0, 0.0];
805        let v = numeris::vector![0.0, 7600.0, 0.0];
806        for f in [Frame::TIRS, Frame::CIRS] {
807            assert!(matches!(
808                transform_state_approx(f, Frame::GCRF, &tm, &p, &v),
809                Err(Error::ApproxNotSupportedForFrame { .. })
810            ));
811        }
812    }
813
814    #[test]
815    fn transform_state_orbit_frames_rejected() {
816        let tm = t();
817        let p = numeris::vector![7000000.0, 0.0, 0.0];
818        let v = numeris::vector![0.0, 7600.0, 0.0];
819        for of in [Frame::LVLH, Frame::RTN, Frame::NTW] {
820            assert!(matches!(
821                transform_state(of, Frame::GCRF, &tm, &p, &v),
822                Err(Error::OrbitFrameRequiresState { .. })
823            ));
824        }
825    }
826}