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}