Skip to main content

sidereon_core/
frame.rs

1//! Frame-tagged position types.
2//!
3//! Every position value encodes its reference frame and datum in the **type
4//! name**, never a bare `position_m` that hides the frame. SP3 products give
5//! satellite states
6//! in the ITRF/IGS-realization ECEF frame, in meters; that fact is carried by
7//! [`ItrfPositionM`] so a consumer cannot accidentally mix it with, say, a
8//! GCRS/TEME state from the core crate (which is in kilometers).
9
10use core::fmt;
11
12use crate::astro::math::vec3::{cross3, unit3};
13
14/// Error returned when constructing frame-tagged values from invalid inputs.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
16pub enum FrameValueError {
17    /// A frame value component was non-finite or outside its documented domain.
18    #[error("invalid frame value {field}: {reason}")]
19    InvalidInput {
20        field: &'static str,
21        reason: &'static str,
22    },
23}
24
25const fn invalid_input(field: &'static str, reason: &'static str) -> FrameValueError {
26    FrameValueError::InvalidInput { field, reason }
27}
28
29/// Geocentric local vertical at a receiver ECEF position: the position vector
30/// normalized (the spherical radial direction, out from the geocenter).
31///
32/// PARITY / FRAME: this is the GEOCENTRIC up (`position / |position|`), **not**
33/// the geodetic ellipsoid normal. The two differ by up to ~0.19 deg, which
34/// shifts low-elevation variance weighting and antenna projections; the GNSS
35/// baseline/PPP code paths were captured against the geocentric definition
36/// (Elixir `local_up/1`), so this is deliberately the geocentric variant. A
37/// geodetic ENU rotation (built from geodetic latitude/longitude) is a separate
38/// construction and stays with its caller. Normalization is reciprocal-multiply
39/// (`scale3(v, 1/n)` via [`unit3`]) to preserve the callers' 0-ULP goldens; a
40/// zero-length position degenerates to `+Z`.
41pub fn geocentric_up(position_ecef_m: [f64; 3]) -> [f64; 3] {
42    unit3(position_ecef_m).unwrap_or([0.0, 0.0, 1.0])
43}
44
45/// East unit of the geocentric local frame for a given local `up`:
46/// `normalize(Z x up)`, degenerating to `+X` when `up` is parallel to `Z`.
47pub fn geocentric_east(up: [f64; 3]) -> [f64; 3] {
48    unit3(cross3([0.0, 0.0, 1.0], up)).unwrap_or([1.0, 0.0, 0.0])
49}
50
51/// Geocentric local North-East-Up basis at a receiver ECEF position, returned as
52/// `(north, east, up)`.
53///
54/// `up` is [`geocentric_up`], `east` is [`geocentric_east`], and
55/// `north = normalize(up x east)`. This is the single shared geocentric basis
56/// builder: the PPP correction tables, the static PPP solver, and the RTK
57/// baseline-filter antenna model all consume it (each previously kept a
58/// byte-identical copy). See [`geocentric_up`] for the geocentric-vs-geodetic
59/// distinction.
60pub fn geocentric_neu_basis(position_ecef_m: [f64; 3]) -> ([f64; 3], [f64; 3], [f64; 3]) {
61    let up = geocentric_up(position_ecef_m);
62    let east = geocentric_east(up);
63    let north = unit3(cross3(up, east)).unwrap_or([0.0, 0.0, 1.0]);
64    (north, east, up)
65}
66
67/// A position in the ITRF / IGS-realization Earth-Centered-Earth-Fixed frame,
68/// expressed in **meters**.
69///
70/// SP3 ephemerides are published in an IGS realization of the ITRF (e.g.
71/// `IGS14`, `IGS20`); the exact realization string is carried on the SP3
72/// header ([`crate::sp3::Sp3Header::coordinate_system`]), while this type fixes
73/// the *kind* of frame (ITRF/IGS ECEF) and the *unit* (meters) in the type
74/// system. There is intentionally no implicit conversion to the core crate's
75/// kilometer states; conversion happens explicitly at an API boundary.
76#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct ItrfPositionM {
78    /// ECEF X coordinate in meters.
79    pub x_m: f64,
80    /// ECEF Y coordinate in meters.
81    pub y_m: f64,
82    /// ECEF Z coordinate in meters.
83    pub z_m: f64,
84}
85
86impl ItrfPositionM {
87    /// Construct an ITRF ECEF position from meter components.
88    pub const fn new(x_m: f64, y_m: f64, z_m: f64) -> Result<Self, FrameValueError> {
89        if !x_m.is_finite() {
90            return Err(invalid_input("x_m", "must be finite"));
91        }
92        if !y_m.is_finite() {
93            return Err(invalid_input("y_m", "must be finite"));
94        }
95        if !z_m.is_finite() {
96            return Err(invalid_input("z_m", "must be finite"));
97        }
98        Ok(Self { x_m, y_m, z_m })
99    }
100
101    /// The components as a `[x, y, z]` meter array.
102    pub const fn as_array(self) -> [f64; 3] {
103        [self.x_m, self.y_m, self.z_m]
104    }
105}
106
107impl fmt::Display for ItrfPositionM {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        write!(
110            f,
111            "ITRF[{:.3}, {:.3}, {:.3}] m",
112            self.x_m, self.y_m, self.z_m
113        )
114    }
115}
116
117/// A geodetic (ellipsoidal) position on the WGS84 datum.
118///
119/// Latitude and longitude are geodetic angles in **radians**; height is the
120/// ellipsoidal height in **meters**. This is the receiver-position type the
121/// atmospheric (ionosphere/troposphere) models take: they need the geodetic
122/// latitude/longitude of the antenna and, for the troposphere, the height.
123///
124/// The frame and units are fixed in the type so a caller cannot mix this with
125/// the ECEF [`ItrfPositionM`] or pass degrees where radians are expected.
126/// Longitude is positive east; latitude is positive north.
127#[derive(Debug, Clone, Copy, PartialEq)]
128pub struct Wgs84Geodetic {
129    /// Geodetic latitude in radians, positive north, range `[-pi/2, pi/2]`.
130    pub lat_rad: f64,
131    /// Geodetic longitude in radians, positive east, range `[-pi, pi]`.
132    pub lon_rad: f64,
133    /// Ellipsoidal height above the WGS84 ellipsoid in meters.
134    pub height_m: f64,
135}
136
137impl Wgs84Geodetic {
138    /// Construct a WGS84 geodetic position from radians and meters.
139    pub const fn new(lat_rad: f64, lon_rad: f64, height_m: f64) -> Result<Self, FrameValueError> {
140        if !lat_rad.is_finite() {
141            return Err(invalid_input("lat_rad", "must be finite"));
142        }
143        if !lon_rad.is_finite() {
144            return Err(invalid_input("lon_rad", "must be finite"));
145        }
146        if !height_m.is_finite() {
147            return Err(invalid_input("height_m", "must be finite"));
148        }
149        if lat_rad < -core::f64::consts::FRAC_PI_2 || lat_rad > core::f64::consts::FRAC_PI_2 {
150            return Err(invalid_input("lat_rad", "must be in [-pi/2, pi/2]"));
151        }
152        if lon_rad < -core::f64::consts::PI || lon_rad > core::f64::consts::PI {
153            return Err(invalid_input("lon_rad", "must be in [-pi, pi]"));
154        }
155        Ok(Self {
156            lat_rad,
157            lon_rad,
158            height_m,
159        })
160    }
161}
162
163impl fmt::Display for Wgs84Geodetic {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        write!(
166            f,
167            "WGS84[lat {:.6} rad, lon {:.6} rad, h {:.3} m]",
168            self.lat_rad, self.lon_rad, self.height_m
169        )
170    }
171}
172
173/// Convert a WGS84 geodetic position to an ITRF/ECEF position.
174///
175/// Free-function wrapper over the crate's single validated forward converter
176/// [`crate::astro::frames::transforms::geodetic_to_itrs`] (the same WGS84 math
177/// the positioning paths use); this only bridges the typed frame values and
178/// their units (the frame types carry radians + meters, the internal converter
179/// works in degrees + kilometers). No conversion math is reimplemented here.
180pub fn geodetic_to_itrf(
181    geodetic: Wgs84Geodetic,
182) -> Result<ItrfPositionM, crate::astro::frames::transforms::FrameTransformError> {
183    let (x_km, y_km, z_km) = crate::astro::frames::transforms::geodetic_to_itrs(
184        geodetic.lat_rad.to_degrees(),
185        geodetic.lon_rad.to_degrees(),
186        geodetic.height_m / 1000.0,
187    )?;
188    ItrfPositionM::new(x_km * 1000.0, y_km * 1000.0, z_km * 1000.0)
189        .map_err(frame_value_to_transform)
190}
191
192/// Convert an ITRF/ECEF position to a WGS84 geodetic position.
193///
194/// Free-function wrapper over the crate's single validated inverse converter
195/// [`crate::astro::frames::transforms::itrs_to_geodetic_compute`]; like
196/// [`geodetic_to_itrf`] it only bridges the typed frame values and their units.
197/// No conversion math is reimplemented here.
198pub fn itrf_to_geodetic(
199    position: ItrfPositionM,
200) -> Result<Wgs84Geodetic, crate::astro::frames::transforms::FrameTransformError> {
201    let (lat_deg, lon_deg, alt_km) = crate::astro::frames::transforms::itrs_to_geodetic_compute(
202        position.x_m / 1000.0,
203        position.y_m / 1000.0,
204        position.z_m / 1000.0,
205    )?;
206    Wgs84Geodetic::new(lat_deg.to_radians(), lon_deg.to_radians(), alt_km * 1000.0)
207        .map_err(frame_value_to_transform)
208}
209
210fn frame_value_to_transform(
211    error: FrameValueError,
212) -> crate::astro::frames::transforms::FrameTransformError {
213    match error {
214        FrameValueError::InvalidInput { field, reason } => {
215            crate::astro::frames::transforms::FrameTransformError::InvalidInput { field, reason }
216        }
217    }
218}
219
220/// A velocity in the ITRF / IGS-realization ECEF frame, in **meters per
221/// second**.
222///
223/// Present only when the SP3 file is a velocity product (`#?V...` header /
224/// `V`-records). The frame/unit are fixed in the type for the same reason as
225/// [`ItrfPositionM`].
226#[derive(Debug, Clone, Copy, PartialEq)]
227pub struct ItrfVelocityMS {
228    /// ECEF X velocity in meters per second.
229    pub vx_m_s: f64,
230    /// ECEF Y velocity in meters per second.
231    pub vy_m_s: f64,
232    /// ECEF Z velocity in meters per second.
233    pub vz_m_s: f64,
234}
235
236impl ItrfVelocityMS {
237    /// Construct an ITRF ECEF velocity from meter-per-second components.
238    pub const fn new(vx_m_s: f64, vy_m_s: f64, vz_m_s: f64) -> Result<Self, FrameValueError> {
239        if !vx_m_s.is_finite() {
240            return Err(invalid_input("vx_m_s", "must be finite"));
241        }
242        if !vy_m_s.is_finite() {
243            return Err(invalid_input("vy_m_s", "must be finite"));
244        }
245        if !vz_m_s.is_finite() {
246            return Err(invalid_input("vz_m_s", "must be finite"));
247        }
248        Ok(Self {
249            vx_m_s,
250            vy_m_s,
251            vz_m_s,
252        })
253    }
254
255    /// The components as a `[vx, vy, vz]` m/s array.
256    pub const fn as_array(self) -> [f64; 3] {
257        [self.vx_m_s, self.vy_m_s, self.vz_m_s]
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::astro::math::vec3::{norm3, scale3};
265
266    fn bits(v: [f64; 3]) -> [u64; 3] {
267        [v[0].to_bits(), v[1].to_bits(), v[2].to_bits()]
268    }
269
270    /// The explicit clamp-and-match recipe the PPP/RTK modules previously
271    /// copy-pasted. The shared builder must reproduce it bit-for-bit.
272    fn reference_neu(position_ecef_m: [f64; 3]) -> ([f64; 3], [f64; 3], [f64; 3]) {
273        let up = match norm3(position_ecef_m) {
274            n if n > 0.0 => scale3(position_ecef_m, 1.0 / n),
275            _ => [0.0, 0.0, 1.0],
276        };
277        let cross = cross3([0.0, 0.0, 1.0], up);
278        let east = if cross == [0.0, 0.0, 0.0] {
279            [1.0, 0.0, 0.0]
280        } else {
281            match norm3(cross) {
282                n if n > 0.0 => scale3(cross, 1.0 / n),
283                _ => [1.0, 0.0, 0.0],
284            }
285        };
286        let north = match norm3(cross3(up, east)) {
287            n if n > 0.0 => scale3(cross3(up, east), 1.0 / n),
288            _ => [0.0, 0.0, 1.0],
289        };
290        (north, east, up)
291    }
292
293    fn assert_close3(actual: [f64; 3], expected: [f64; 3], tol: f64) {
294        for (a, e) in actual.into_iter().zip(expected) {
295            assert!(
296                (a - e).abs() <= tol,
297                "component {a:?} differs from {e:?} by more than {tol}"
298            );
299        }
300    }
301
302    #[test]
303    fn constructors_reject_invalid_frame_values() {
304        assert_eq!(
305            ItrfPositionM::new(f64::NAN, 0.0, 0.0),
306            Err(FrameValueError::InvalidInput {
307                field: "x_m",
308                reason: "must be finite"
309            })
310        );
311        assert_eq!(
312            ItrfVelocityMS::new(0.0, f64::INFINITY, 0.0),
313            Err(FrameValueError::InvalidInput {
314                field: "vy_m_s",
315                reason: "must be finite"
316            })
317        );
318        assert_eq!(
319            Wgs84Geodetic::new(core::f64::consts::FRAC_PI_2 + 1.0e-12, 0.0, 0.0),
320            Err(FrameValueError::InvalidInput {
321                field: "lat_rad",
322                reason: "must be in [-pi/2, pi/2]"
323            })
324        );
325        assert_eq!(
326            Wgs84Geodetic::new(0.0, -core::f64::consts::PI - 1.0e-12, 0.0),
327            Err(FrameValueError::InvalidInput {
328                field: "lon_rad",
329                reason: "must be in [-pi, pi]"
330            })
331        );
332        assert_eq!(
333            Wgs84Geodetic::new(0.0, 0.0, f64::NAN),
334            Err(FrameValueError::InvalidInput {
335                field: "height_m",
336                reason: "must be finite"
337            })
338        );
339    }
340
341    #[test]
342    fn constructors_preserve_valid_frame_values() {
343        let position = ItrfPositionM::new(1.0, -2.0, 3.5).expect("valid ITRF position");
344        assert_eq!(position.as_array(), [1.0, -2.0, 3.5]);
345
346        let velocity = ItrfVelocityMS::new(0.1, -0.2, 0.3).expect("valid ITRF velocity");
347        assert_eq!(velocity.as_array(), [0.1, -0.2, 0.3]);
348
349        let geodetic =
350            Wgs84Geodetic::new(-0.5, core::f64::consts::PI, -12.0).expect("valid geodetic");
351        assert_eq!(geodetic.lat_rad.to_bits(), (-0.5_f64).to_bits());
352        assert_eq!(geodetic.lon_rad.to_bits(), core::f64::consts::PI.to_bits());
353        assert_eq!(geodetic.height_m.to_bits(), (-12.0_f64).to_bits());
354
355        let west_antimeridian =
356            Wgs84Geodetic::new(0.25, -core::f64::consts::PI, 42.0).expect("valid geodetic");
357        assert_eq!(
358            west_antimeridian.lon_rad.to_bits(),
359            (-core::f64::consts::PI).to_bits()
360        );
361    }
362
363    #[test]
364    fn geocentric_basis_matches_reference_recipe_to_the_bit() {
365        // A representative mid-latitude ECEF receiver position (metres).
366        let position = [4_027_894.0, 307_045.0, 4_919_474.0];
367        let (north, east, up) = geocentric_neu_basis(position);
368        let (rn, re, ru) = reference_neu(position);
369        assert_eq!(bits(north), bits(rn));
370        assert_eq!(bits(east), bits(re));
371        assert_eq!(bits(up), bits(ru));
372        // up = normalized position; east and north are orthogonal unit vectors.
373        assert_eq!(bits(up), bits(geocentric_up(position)));
374        assert_eq!(bits(east), bits(geocentric_east(up)));
375    }
376
377    #[test]
378    fn geocentric_basis_is_right_handed_enu_and_points_north() {
379        let (north, east, up) = geocentric_neu_basis([6_378_137.0, 0.0, 0.0]);
380        assert_close3(up, [1.0, 0.0, 0.0], 1.0e-15);
381        assert_close3(east, [0.0, 1.0, 0.0], 1.0e-15);
382        assert_close3(north, [0.0, 0.0, 1.0], 1.0e-15);
383
384        let (north, east, up) = geocentric_neu_basis([4_027_894.0, 307_045.0, 4_919_474.0]);
385        assert_close3(cross3(up, east), north, 1.0e-15);
386        assert_close3(cross3(east, north), up, 1.0e-15);
387    }
388
389    #[test]
390    fn geodetic_to_itrf_hits_equatorial_prime_meridian_reference() {
391        // Sea level on the equator at the prime meridian sits on the +X axis at
392        // the WGS84 equatorial radius.
393        let origin = Wgs84Geodetic::new(0.0, 0.0, 0.0).expect("valid geodetic");
394        let ecef = geodetic_to_itrf(origin).expect("valid conversion");
395        assert!((ecef.x_m - 6_378_137.0).abs() <= 1.0e-3, "x = {}", ecef.x_m);
396        assert!(ecef.y_m.abs() <= 1.0e-6, "y = {}", ecef.y_m);
397        assert!(ecef.z_m.abs() <= 1.0e-6, "z = {}", ecef.z_m);
398    }
399
400    #[test]
401    fn geodetic_itrf_round_trip_is_tight() {
402        // A real station (roughly Philadelphia) at 100 m ellipsoidal height.
403        let lat_rad = 39.95_f64.to_radians();
404        let lon_rad = (-75.16_f64).to_radians();
405        let station = Wgs84Geodetic::new(lat_rad, lon_rad, 100.0).expect("valid geodetic");
406
407        let ecef = geodetic_to_itrf(station).expect("valid conversion");
408        let back = itrf_to_geodetic(ecef).expect("valid conversion");
409
410        // The forward converter is the closed-form WGS84 map; the inverse is
411        // Skyfield's 3-iteration reduction, so the round-trip closes to ~mm, not
412        // to the bit.
413        assert!((back.lat_rad - station.lat_rad).abs() <= 1.0e-9);
414        assert!((back.lon_rad - station.lon_rad).abs() <= 1.0e-12);
415        assert!((back.height_m - station.height_m).abs() <= 1.0e-6);
416    }
417
418    #[test]
419    fn free_converters_agree_with_internal_converters_bit_for_bit() {
420        use crate::astro::frames::transforms::{geodetic_to_itrs, itrs_to_geodetic_compute};
421
422        // Forward: the public typed converter must equal the internal degree/km
423        // converter with only the documented unit bridging applied.
424        let lat_rad = 39.95_f64.to_radians();
425        let lon_rad = (-75.16_f64).to_radians();
426        let station = Wgs84Geodetic::new(lat_rad, lon_rad, 100.0).expect("valid geodetic");
427
428        let (x_km, y_km, z_km) =
429            geodetic_to_itrs(lat_rad.to_degrees(), lon_rad.to_degrees(), 100.0 / 1000.0)
430                .expect("internal forward converter");
431        let ecef = geodetic_to_itrf(station).expect("public forward converter");
432        assert_eq!(ecef.x_m.to_bits(), (x_km * 1000.0).to_bits());
433        assert_eq!(ecef.y_m.to_bits(), (y_km * 1000.0).to_bits());
434        assert_eq!(ecef.z_m.to_bits(), (z_km * 1000.0).to_bits());
435
436        // Inverse: same bit-for-bit agreement against the internal converter.
437        let position = ItrfPositionM::new(1_113_194.0, -4_383_212.0, 4_077_985.0)
438            .expect("valid ITRF position");
439        let (lat_deg, lon_deg, alt_km) = itrs_to_geodetic_compute(
440            position.x_m / 1000.0,
441            position.y_m / 1000.0,
442            position.z_m / 1000.0,
443        )
444        .expect("internal inverse converter");
445        let geodetic = itrf_to_geodetic(position).expect("public inverse converter");
446        assert_eq!(geodetic.lat_rad.to_bits(), lat_deg.to_radians().to_bits());
447        assert_eq!(geodetic.lon_rad.to_bits(), lon_deg.to_radians().to_bits());
448        assert_eq!(geodetic.height_m.to_bits(), (alt_km * 1000.0).to_bits());
449    }
450
451    #[test]
452    fn geocentric_basis_handles_degenerate_positions() {
453        // Zero-length position degenerates to +Z up, +X east.
454        let (north, east, up) = geocentric_neu_basis([0.0, 0.0, 0.0]);
455        assert_eq!(up, [0.0, 0.0, 1.0]);
456        assert_eq!(east, [1.0, 0.0, 0.0]);
457        // north = normalize(up x east) = [0,0,1] x [1,0,0] = [0,1,0].
458        assert_eq!(north, [0.0, 1.0, 0.0]);
459        // A purely polar position (up parallel to +Z) keeps east at +X.
460        assert_eq!(geocentric_east([0.0, 0.0, 1.0]), [1.0, 0.0, 0.0]);
461    }
462}