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
219impl TdbEarthOrientationProvider {
220    /// Build a provider with zero polar motion.
221    pub const fn new() -> Self {
222        Self {
223            polar_motion: PolarMotion::ZERO,
224        }
225    }
226
227    /// Build a provider with fixed polar motion applied at every epoch.
228    pub const fn with_polar_motion(polar_motion: PolarMotion) -> Self {
229        Self { polar_motion }
230    }
231
232    /// Polar-motion coordinates used by this provider.
233    pub fn polar_motion(&self) -> PolarMotion {
234        self.polar_motion
235    }
236}
237
238impl Default for TdbEarthOrientationProvider {
239    fn default() -> Self {
240        Self::new()
241    }
242}
243
244impl EarthOrientationProvider for TdbEarthOrientationProvider {
245    fn orientation_at_tdb_seconds(
246        &self,
247        epoch_tdb_seconds: f64,
248    ) -> Result<EarthOrientation, FrameTransformError> {
249        let ts = time_scales_from_scale_j2000_seconds(TimeScale::Tdb, epoch_tdb_seconds)?;
250        EarthOrientation::from_time_scales_with_polar_motion(&ts, self.polar_motion)
251    }
252}
253
254fn time_scales_from_instant(epoch: Instant) -> Result<TimeScales, FrameTransformError> {
255    let seconds = match epoch.repr {
256        InstantRepr::JulianDate(jd) => j2000_seconds_from_split(jd.jd_whole, jd.fraction),
257        InstantRepr::Nanos(_) => {
258            return Err(invalid_input("epoch", "must be a split Julian date"));
259        }
260    };
261    time_scales_from_scale_j2000_seconds(epoch.scale, seconds)
262}
263
264fn time_scales_from_scale_j2000_seconds(
265    scale: TimeScale,
266    epoch_j2000_s: f64,
267) -> Result<TimeScales, FrameTransformError> {
268    if !epoch_j2000_s.is_finite() {
269        return Err(invalid_input("epoch_j2000_s", "must be finite"));
270    }
271    let whole = epoch_j2000_s.floor();
272    if whole < i64::MIN as f64 || whole > i64::MAX as f64 {
273        return Err(invalid_input(
274            "epoch_j2000_s",
275            "whole seconds are out of range",
276        ));
277    }
278    let fraction = epoch_j2000_s - whole;
279    let (year, month, day, hour, minute, second) = civil_from_j2000_seconds(whole as i64);
280    TimeScales::from_scale(
281        scale,
282        year as i32,
283        month as i32,
284        day as i32,
285        hour as i32,
286        minute as i32,
287        second as f64 + fraction,
288    )
289    .map_err(|_| invalid_input("epoch_j2000_s", "time-scale conversion failed"))
290}
291
292fn invalid_input(field: &'static str, reason: &'static str) -> FrameTransformError {
293    FrameTransformError::InvalidInput { field, reason }
294}
295
296fn validate_vec3(field: &'static str, value: &[f64; 3]) -> Result<(), FrameTransformError> {
297    if value.iter().all(|component| component.is_finite()) {
298        Ok(())
299    } else {
300        Err(invalid_input(field, "components must be finite"))
301    }
302}
303
304fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
305    [
306        a[1] * b[2] - a[2] * b[1],
307        a[2] * b[0] - a[0] * b[2],
308        a[0] * b[1] - a[1] * b[0],
309    ]
310}
311
312fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
313    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
314}
315
316fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
317    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
318}
319
320fn skew_matrix(omega: [f64; 3]) -> Mat3 {
321    [
322        [0.0, -omega[2], omega[1]],
323        [omega[2], 0.0, -omega[0]],
324        [-omega[1], omega[0], 0.0],
325    ]
326}
327
328fn neg_skew_matrix(omega: [f64; 3]) -> Mat3 {
329    [
330        [0.0, omega[2], -omega[1]],
331        [-omega[2], 0.0, omega[0]],
332        [omega[1], -omega[0], 0.0],
333    ]
334}