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