Skip to main content

sidereon_core/astro/frames/
orientation.rs

1//! Cacheable Earth-orientation evaluation for the precise GCRF/ITRF chain.
2//!
3//! Provenance: IERS Conventions (2010) TN36 and IAU 2006/2000A are consumed
4//! through the existing precession, nutation, sidereal-time, and polar-motion
5//! routines in [`crate::astro::frames::transforms`]. This module does not
6//! reimplement any series; it evaluates the established chain once per epoch and
7//! exposes reusable direction-cosine matrices and state transforms.
8
9use crate::astro::constants::earth::OMEGA_E_DOT_RAD_S;
10use crate::astro::frames::transforms::{
11    gcrs_to_itrs_matrix_with_polar_motion, mat3_vec3_mul, polar_motion_matrix, FrameTransformError,
12    PolarMotion,
13};
14use crate::astro::math::mat3::{inline_rxr, inline_tr, Mat3};
15use crate::astro::time::civil::{civil_from_j2000_seconds, j2000_seconds_from_split};
16use crate::astro::time::model::{Instant, InstantRepr, TimeScale};
17use crate::astro::time::scales::TimeScales;
18
19/// A single evaluated Earth-orientation state for one epoch.
20///
21/// The stored direction-cosine matrix maps GCRF/GCRS inertial coordinates to
22/// ITRF/ITRS Earth-fixed coordinates using the existing IAU 2006/2000A
23/// precession-nutation chain, apparent sidereal rotation, and optional polar
24/// motion. The inverse matrix is cached as the transpose so callers can evaluate
25/// the frame once and reuse it across many satellite states.
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct EarthOrientation {
28    time_scales: TimeScales,
29    polar_motion: PolarMotion,
30    gcrf_to_itrf: Mat3,
31    itrf_to_gcrf: Mat3,
32    earth_rotation_vector_itrf_rad_s: [f64; 3],
33}
34
35impl EarthOrientation {
36    /// Evaluate the full GCRF to ITRF rotation with zero polar motion.
37    pub fn from_time_scales(ts: &TimeScales) -> Result<Self, FrameTransformError> {
38        Self::from_time_scales_with_polar_motion(ts, PolarMotion::ZERO)
39    }
40
41    /// Evaluate the full GCRF to ITRF rotation with caller-supplied polar
42    /// motion.
43    pub fn from_time_scales_with_polar_motion(
44        ts: &TimeScales,
45        polar_motion: PolarMotion,
46    ) -> Result<Self, FrameTransformError> {
47        let gcrf_to_itrf = gcrs_to_itrs_matrix_with_polar_motion(ts, polar_motion)?;
48        let itrf_to_gcrf = inline_tr(&gcrf_to_itrf);
49        let polar = polar_motion_matrix(polar_motion)?;
50        let earth_rotation_vector_itrf_rad_s =
51            mat3_vec3_mul(&polar, &[0.0, 0.0, OMEGA_E_DOT_RAD_S])?;
52        Ok(Self {
53            time_scales: *ts,
54            polar_motion,
55            gcrf_to_itrf,
56            itrf_to_gcrf,
57            earth_rotation_vector_itrf_rad_s,
58        })
59    }
60
61    /// Evaluate the full GCRF to ITRF rotation from UTC calendar fields with
62    /// zero polar motion.
63    pub fn from_utc(
64        year: i32,
65        month: i32,
66        day: i32,
67        hour: i32,
68        minute: i32,
69        second: f64,
70    ) -> Result<Self, FrameTransformError> {
71        Self::from_utc_with_polar_motion(year, month, day, hour, minute, second, PolarMotion::ZERO)
72    }
73
74    /// Evaluate the full GCRF to ITRF rotation from UTC calendar fields with
75    /// caller-supplied polar motion.
76    pub fn from_utc_with_polar_motion(
77        year: i32,
78        month: i32,
79        day: i32,
80        hour: i32,
81        minute: i32,
82        second: f64,
83        polar_motion: PolarMotion,
84    ) -> Result<Self, FrameTransformError> {
85        let ts = TimeScales::from_utc(year, month, day, hour, minute, second)
86            .map_err(|_| invalid_input("utc", "time-scale conversion failed"))?;
87        Self::from_time_scales_with_polar_motion(&ts, polar_motion)
88    }
89
90    /// Evaluate the full GCRF to ITRF rotation from a scale-tagged instant with
91    /// zero polar motion.
92    pub fn from_instant(epoch: Instant) -> Result<Self, FrameTransformError> {
93        Self::from_instant_with_polar_motion(epoch, PolarMotion::ZERO)
94    }
95
96    /// Evaluate the full GCRF to ITRF rotation from a scale-tagged instant with
97    /// caller-supplied polar motion.
98    pub fn from_instant_with_polar_motion(
99        epoch: Instant,
100        polar_motion: PolarMotion,
101    ) -> Result<Self, FrameTransformError> {
102        let ts = time_scales_from_instant(epoch)?;
103        Self::from_time_scales_with_polar_motion(&ts, polar_motion)
104    }
105
106    /// Time scales used to evaluate this orientation.
107    pub fn time_scales(&self) -> TimeScales {
108        self.time_scales
109    }
110
111    /// Polar-motion coordinates used to evaluate this orientation.
112    pub fn polar_motion(&self) -> PolarMotion {
113        self.polar_motion
114    }
115
116    /// Earth rotation vector in ITRF axes, radians per second.
117    pub fn earth_rotation_vector_itrf_rad_s(&self) -> [f64; 3] {
118        self.earth_rotation_vector_itrf_rad_s
119    }
120
121    /// GCRF to ITRF direction-cosine matrix.
122    pub fn gcrf_to_itrf_matrix(&self) -> Mat3 {
123        self.gcrf_to_itrf
124    }
125
126    /// ITRF to GCRF direction-cosine matrix.
127    pub fn itrf_to_gcrf_matrix(&self) -> Mat3 {
128        self.itrf_to_gcrf
129    }
130
131    /// Time derivative of the GCRF to ITRF matrix from Earth rotation, with
132    /// precession, nutation, and polar motion frozen at this evaluation point.
133    pub fn gcrf_to_itrf_rotation_rate_matrix(&self) -> Mat3 {
134        let neg_skew = neg_skew_matrix(self.earth_rotation_vector_itrf_rad_s);
135        inline_rxr(&neg_skew, &self.gcrf_to_itrf)
136    }
137
138    /// Time derivative of the ITRF to GCRF matrix from Earth rotation, with
139    /// precession, nutation, and polar motion frozen at this evaluation point.
140    pub fn itrf_to_gcrf_rotation_rate_matrix(&self) -> Mat3 {
141        let skew = skew_matrix(self.earth_rotation_vector_itrf_rad_s);
142        inline_rxr(&self.itrf_to_gcrf, &skew)
143    }
144
145    /// Rotate a GCRF position vector in kilometers into ITRF.
146    pub fn gcrf_to_itrf_position_km(
147        &self,
148        position_gcrf_km: [f64; 3],
149    ) -> Result<[f64; 3], FrameTransformError> {
150        validate_vec3("position_gcrf_km", &position_gcrf_km)?;
151        mat3_vec3_mul(&self.gcrf_to_itrf, &position_gcrf_km)
152    }
153
154    /// Rotate an ITRF position vector in kilometers into GCRF.
155    pub fn itrf_to_gcrf_position_km(
156        &self,
157        position_itrf_km: [f64; 3],
158    ) -> Result<[f64; 3], FrameTransformError> {
159        validate_vec3("position_itrf_km", &position_itrf_km)?;
160        mat3_vec3_mul(&self.itrf_to_gcrf, &position_itrf_km)
161    }
162
163    /// Transform a GCRF state in kilometers and kilometers per second into ITRF.
164    ///
165    /// The velocity includes the rotating-frame term
166    /// `v_itrf = R v_gcrf - omega_itrf x r_itrf`.
167    pub fn gcrf_to_itrf_state_km(
168        &self,
169        position_gcrf_km: [f64; 3],
170        velocity_gcrf_km_s: [f64; 3],
171    ) -> Result<([f64; 3], [f64; 3]), FrameTransformError> {
172        validate_vec3("position_gcrf_km", &position_gcrf_km)?;
173        validate_vec3("velocity_gcrf_km_s", &velocity_gcrf_km_s)?;
174        let position_itrf_km = mat3_vec3_mul(&self.gcrf_to_itrf, &position_gcrf_km)?;
175        let rotated_velocity = mat3_vec3_mul(&self.gcrf_to_itrf, &velocity_gcrf_km_s)?;
176        let transport = cross(self.earth_rotation_vector_itrf_rad_s, position_itrf_km);
177        Ok((position_itrf_km, sub(rotated_velocity, transport)))
178    }
179
180    /// Transform an ITRF state in kilometers and kilometers per second into GCRF.
181    ///
182    /// The velocity includes the inertial transport term
183    /// `v_gcrf = R^T (v_itrf + omega_itrf x r_itrf)`.
184    pub fn itrf_to_gcrf_state_km(
185        &self,
186        position_itrf_km: [f64; 3],
187        velocity_itrf_km_s: [f64; 3],
188    ) -> Result<([f64; 3], [f64; 3]), FrameTransformError> {
189        validate_vec3("position_itrf_km", &position_itrf_km)?;
190        validate_vec3("velocity_itrf_km_s", &velocity_itrf_km_s)?;
191        let position_gcrf_km = mat3_vec3_mul(&self.itrf_to_gcrf, &position_itrf_km)?;
192        let transport = cross(self.earth_rotation_vector_itrf_rad_s, position_itrf_km);
193        let velocity_rotating = add(velocity_itrf_km_s, transport);
194        let velocity_gcrf_km_s = mat3_vec3_mul(&self.itrf_to_gcrf, &velocity_rotating)?;
195        Ok((position_gcrf_km, velocity_gcrf_km_s))
196    }
197}
198
199/// Supplies Earth orientation at a propagator integration epoch.
200///
201/// Force models that need body-fixed coordinates can request this provider from
202/// [`crate::astro::propagator::PropagationContext`]. The default propagation
203/// context carries no provider, so existing forces do not change behavior.
204pub trait EarthOrientationProvider: Send + Sync {
205    /// Evaluate Earth orientation at absolute TDB seconds since J2000.
206    fn orientation_at_tdb_seconds(
207        &self,
208        epoch_tdb_seconds: f64,
209    ) -> Result<EarthOrientation, FrameTransformError>;
210}
211
212/// Earth-orientation provider for propagator epochs expressed as TDB seconds
213/// since J2000.
214#[derive(Debug, Clone, Copy, PartialEq)]
215pub struct TdbEarthOrientationProvider {
216    polar_motion: PolarMotion,
217}
218
219/// One polar-motion series sample for [`PolarMotionSeriesEarthOrientationProvider`].
220#[derive(Debug, Clone, Copy, PartialEq)]
221pub struct PolarMotionSample {
222    /// Sample epoch, TDB seconds since J2000.
223    pub epoch_tdb_seconds: f64,
224    /// Polar-motion coordinates at the sample epoch.
225    pub polar_motion: PolarMotion,
226}
227
228impl PolarMotionSample {
229    /// Construct a sample from polar-motion coordinates in radians.
230    pub fn from_radians(
231        epoch_tdb_seconds: f64,
232        xp_rad: f64,
233        yp_rad: f64,
234    ) -> Result<Self, FrameTransformError> {
235        if !epoch_tdb_seconds.is_finite() {
236            return Err(invalid_input("epoch_tdb_seconds", "must be finite"));
237        }
238        Ok(Self {
239            epoch_tdb_seconds,
240            polar_motion: PolarMotion::from_radians(xp_rad, yp_rad)?,
241        })
242    }
243
244    /// Construct a sample from polar-motion coordinates in arcseconds.
245    pub fn from_arcseconds(
246        epoch_tdb_seconds: f64,
247        xp_arcsec: f64,
248        yp_arcsec: f64,
249    ) -> Result<Self, FrameTransformError> {
250        if !epoch_tdb_seconds.is_finite() {
251            return Err(invalid_input("epoch_tdb_seconds", "must be finite"));
252        }
253        Ok(Self {
254            epoch_tdb_seconds,
255            polar_motion: PolarMotion::from_arcseconds(xp_arcsec, yp_arcsec)?,
256        })
257    }
258}
259
260/// Earth-orientation provider backed by a time-ordered polar-motion series.
261///
262/// The embedded UT1 table supplies Earth-rotation timing. This provider adds
263/// caller-supplied `xp`/`yp` EOP samples and linearly interpolates polar motion
264/// at propagation epochs. Queries outside the sample coverage return an error
265/// instead of silently clamping.
266#[derive(Debug, Clone, PartialEq)]
267pub struct PolarMotionSeriesEarthOrientationProvider {
268    samples: Box<[PolarMotionSample]>,
269}
270
271impl PolarMotionSeriesEarthOrientationProvider {
272    /// Build a provider from at least two strictly increasing samples.
273    pub fn new(samples: Vec<PolarMotionSample>) -> Result<Self, FrameTransformError> {
274        if samples.len() < 2 {
275            return Err(invalid_input(
276                "samples",
277                "must contain at least two polar-motion samples",
278            ));
279        }
280        for window in samples.windows(2) {
281            if window[0].epoch_tdb_seconds >= window[1].epoch_tdb_seconds {
282                return Err(invalid_input(
283                    "samples",
284                    "epochs must be strictly increasing",
285                ));
286            }
287        }
288        Ok(Self {
289            samples: samples.into_boxed_slice(),
290        })
291    }
292
293    /// Interpolate polar motion at a TDB epoch.
294    pub fn polar_motion_at_tdb_seconds(
295        &self,
296        epoch_tdb_seconds: f64,
297    ) -> Result<PolarMotion, FrameTransformError> {
298        if !epoch_tdb_seconds.is_finite() {
299            return Err(invalid_input("epoch_tdb_seconds", "must be finite"));
300        }
301        let first = self.samples.first().expect("validated non-empty samples");
302        let last = self.samples.last().expect("validated non-empty samples");
303        if epoch_tdb_seconds < first.epoch_tdb_seconds || epoch_tdb_seconds > last.epoch_tdb_seconds
304        {
305            return Err(invalid_input(
306                "epoch_tdb_seconds",
307                "outside polar-motion series coverage",
308            ));
309        }
310
311        match self.samples.binary_search_by(|sample| {
312            sample
313                .epoch_tdb_seconds
314                .partial_cmp(&epoch_tdb_seconds)
315                .expect("validated finite epoch")
316        }) {
317            Ok(index) => Ok(self.samples[index].polar_motion),
318            Err(index) => {
319                let before = self.samples[index - 1];
320                let after = self.samples[index];
321                let span = after.epoch_tdb_seconds - before.epoch_tdb_seconds;
322                let alpha = (epoch_tdb_seconds - before.epoch_tdb_seconds) / span;
323                PolarMotion::from_radians(
324                    before.polar_motion.xp_rad
325                        + alpha * (after.polar_motion.xp_rad - before.polar_motion.xp_rad),
326                    before.polar_motion.yp_rad
327                        + alpha * (after.polar_motion.yp_rad - before.polar_motion.yp_rad),
328                )
329            }
330        }
331    }
332}
333
334impl TdbEarthOrientationProvider {
335    /// Build a provider with zero polar motion.
336    pub const fn new() -> Self {
337        Self {
338            polar_motion: PolarMotion::ZERO,
339        }
340    }
341
342    /// Build a provider with fixed polar motion applied at every epoch.
343    pub const fn with_polar_motion(polar_motion: PolarMotion) -> Self {
344        Self { polar_motion }
345    }
346
347    /// Polar-motion coordinates used by this provider.
348    pub fn polar_motion(&self) -> PolarMotion {
349        self.polar_motion
350    }
351}
352
353impl Default for TdbEarthOrientationProvider {
354    fn default() -> Self {
355        Self::new()
356    }
357}
358
359impl EarthOrientationProvider for TdbEarthOrientationProvider {
360    fn orientation_at_tdb_seconds(
361        &self,
362        epoch_tdb_seconds: f64,
363    ) -> Result<EarthOrientation, FrameTransformError> {
364        let ts = time_scales_from_scale_j2000_seconds(TimeScale::Tdb, epoch_tdb_seconds)?;
365        EarthOrientation::from_time_scales_with_polar_motion(&ts, self.polar_motion)
366    }
367}
368
369impl EarthOrientationProvider for PolarMotionSeriesEarthOrientationProvider {
370    fn orientation_at_tdb_seconds(
371        &self,
372        epoch_tdb_seconds: f64,
373    ) -> Result<EarthOrientation, FrameTransformError> {
374        let ts = time_scales_from_scale_j2000_seconds(TimeScale::Tdb, epoch_tdb_seconds)?;
375        let polar_motion = self.polar_motion_at_tdb_seconds(epoch_tdb_seconds)?;
376        EarthOrientation::from_time_scales_with_polar_motion(&ts, polar_motion)
377    }
378}
379
380fn time_scales_from_instant(epoch: Instant) -> Result<TimeScales, FrameTransformError> {
381    let seconds = match epoch.repr {
382        InstantRepr::JulianDate(jd) => j2000_seconds_from_split(jd.jd_whole, jd.fraction),
383        InstantRepr::Nanos(_) => {
384            return Err(invalid_input("epoch", "must be a split Julian date"));
385        }
386    };
387    time_scales_from_scale_j2000_seconds(epoch.scale, seconds)
388}
389
390fn time_scales_from_scale_j2000_seconds(
391    scale: TimeScale,
392    epoch_j2000_s: f64,
393) -> Result<TimeScales, FrameTransformError> {
394    if !epoch_j2000_s.is_finite() {
395        return Err(invalid_input("epoch_j2000_s", "must be finite"));
396    }
397    let whole = epoch_j2000_s.floor();
398    if whole < i64::MIN as f64 || whole > i64::MAX as f64 {
399        return Err(invalid_input(
400            "epoch_j2000_s",
401            "whole seconds are out of range",
402        ));
403    }
404    let fraction = epoch_j2000_s - whole;
405    let (year, month, day, hour, minute, second) = civil_from_j2000_seconds(whole as i64);
406    TimeScales::from_scale(
407        scale,
408        year as i32,
409        month as i32,
410        day as i32,
411        hour as i32,
412        minute as i32,
413        second as f64 + fraction,
414    )
415    .map_err(|_| invalid_input("epoch_j2000_s", "time-scale conversion failed"))
416}
417
418fn invalid_input(field: &'static str, reason: &'static str) -> FrameTransformError {
419    FrameTransformError::InvalidInput { field, reason }
420}
421
422fn validate_vec3(field: &'static str, value: &[f64; 3]) -> Result<(), FrameTransformError> {
423    if value.iter().all(|component| component.is_finite()) {
424        Ok(())
425    } else {
426        Err(invalid_input(field, "components must be finite"))
427    }
428}
429
430fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
431    [
432        a[1] * b[2] - a[2] * b[1],
433        a[2] * b[0] - a[0] * b[2],
434        a[0] * b[1] - a[1] * b[0],
435    ]
436}
437
438fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
439    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
440}
441
442fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
443    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
444}
445
446fn skew_matrix(omega: [f64; 3]) -> Mat3 {
447    [
448        [0.0, -omega[2], omega[1]],
449        [omega[2], 0.0, -omega[0]],
450        [-omega[1], omega[0], 0.0],
451    ]
452}
453
454fn neg_skew_matrix(omega: [f64; 3]) -> Mat3 {
455    [
456        [0.0, omega[2], -omega[1]],
457        [-omega[2], 0.0, omega[0]],
458        [omega[1], -omega[0], 0.0],
459    ]
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    #[test]
467    fn polar_motion_series_interpolates_and_builds_orientation() {
468        let provider = PolarMotionSeriesEarthOrientationProvider::new(vec![
469            PolarMotionSample::from_arcseconds(0.0, 0.10, -0.20).expect("sample"),
470            PolarMotionSample::from_arcseconds(10.0, 0.30, -0.10).expect("sample"),
471        ])
472        .expect("series provider");
473
474        let interpolated = provider
475            .polar_motion_at_tdb_seconds(5.0)
476            .expect("interpolated pole");
477        let expected = PolarMotion::from_arcseconds(0.20, -0.15).expect("expected pole");
478        assert!((interpolated.xp_rad - expected.xp_rad).abs() <= 1.0e-21);
479        assert!((interpolated.yp_rad - expected.yp_rad).abs() <= 1.0e-21);
480
481        let orientation = provider
482            .orientation_at_tdb_seconds(5.0)
483            .expect("series-backed orientation");
484        assert!((orientation.polar_motion().xp_rad - expected.xp_rad).abs() <= 1.0e-21);
485        assert!((orientation.polar_motion().yp_rad - expected.yp_rad).abs() <= 1.0e-21);
486    }
487
488    #[test]
489    fn polar_motion_series_rejects_bad_order_and_coverage() {
490        let unordered = PolarMotionSeriesEarthOrientationProvider::new(vec![
491            PolarMotionSample::from_arcseconds(10.0, 0.10, 0.20).expect("sample"),
492            PolarMotionSample::from_arcseconds(10.0, 0.20, 0.30).expect("sample"),
493        ]);
494        assert!(unordered.is_err());
495
496        let provider = PolarMotionSeriesEarthOrientationProvider::new(vec![
497            PolarMotionSample::from_arcseconds(0.0, 0.10, 0.20).expect("sample"),
498            PolarMotionSample::from_arcseconds(10.0, 0.20, 0.30).expect("sample"),
499        ])
500        .expect("series provider");
501        assert!(provider.polar_motion_at_tdb_seconds(-1.0).is_err());
502        assert!(provider.polar_motion_at_tdb_seconds(11.0).is_err());
503    }
504}