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