Skip to main content

pleiades_compression/
frame_recombine.rs

1//! Ecliptic spherical ↔ Cartesian (AU) and geocentric/heliocentric recombination.
2
3use pleiades_types::{EclipticCoordinates, Latitude, Longitude};
4
5/// Converts ecliptic spherical (deg, deg, AU) to ecliptic Cartesian (AU).
6/// Returns `None` when distance is absent — recombination requires a radius.
7pub fn ecliptic_to_cartesian_au(coords: &EclipticCoordinates) -> Option<[f64; 3]> {
8    let r = coords.distance_au?;
9    let lon = coords.longitude.degrees().to_radians();
10    let lat = coords.latitude.degrees().to_radians();
11    Some([
12        r * lat.cos() * lon.cos(),
13        r * lat.cos() * lon.sin(),
14        r * lat.sin(),
15    ])
16}
17
18/// Converts ecliptic Cartesian (AU) back to ecliptic spherical. Longitude is
19/// normalized to [0, 360) by `Longitude::from_degrees`.
20pub fn cartesian_au_to_ecliptic(v: [f64; 3]) -> EclipticCoordinates {
21    let [x, y, z] = v;
22    let radius = (x * x + y * y + z * z).sqrt();
23    let longitude = Longitude::from_degrees(y.atan2(x).to_degrees());
24    let latitude = if radius == 0.0 {
25        Latitude::from_degrees(0.0)
26    } else {
27        Latitude::from_degrees((z / radius).clamp(-1.0, 1.0).asin().to_degrees())
28    };
29    EclipticCoordinates::new(longitude, latitude, Some(radius))
30}
31
32/// Reconstructs geocentric ecliptic from a planet's heliocentric ecliptic and
33/// the geocentric Sun: `P_geo = P_helio + S_geo` (vector add in ecliptic-of-date).
34pub fn geocentric_from_heliocentric(
35    planet_helio: &EclipticCoordinates,
36    sun_geo: &EclipticCoordinates,
37) -> Option<EclipticCoordinates> {
38    let p = ecliptic_to_cartesian_au(planet_helio)?;
39    let s = ecliptic_to_cartesian_au(sun_geo)?;
40    Some(cartesian_au_to_ecliptic([
41        p[0] + s[0],
42        p[1] + s[1],
43        p[2] + s[2],
44    ]))
45}
46
47/// Spherical ecliptic state: position (lon, lat, dist) plus velocity rates (all in AU and rad/day).
48#[derive(Clone, Copy, Debug)]
49pub struct SphericalState {
50    /// Ecliptic longitude, in radians.
51    pub lon_rad: f64,
52    /// Ecliptic latitude, in radians.
53    pub lat_rad: f64,
54    /// Radial distance, in astronomical units.
55    pub dist_au: f64,
56    /// Rate of change of ecliptic longitude, in radians per day.
57    pub lon_rate_rad_per_day: f64,
58    /// Rate of change of ecliptic latitude, in radians per day.
59    pub lat_rate_rad_per_day: f64,
60    /// Rate of change of distance, in astronomical units per day.
61    pub dist_rate_au_per_day: f64,
62}
63
64/// Cartesian ecliptic state: position and velocity (all in AU and AU/day).
65#[derive(Clone, Copy, Debug)]
66pub struct CartesianState {
67    /// Position vector `[x, y, z]` in the ecliptic frame, in astronomical units.
68    pub pos_au: [f64; 3],
69    /// Velocity vector `[vx, vy, vz]` in the ecliptic frame, in astronomical units per day.
70    pub vel_au_per_day: [f64; 3],
71}
72
73/// Converts a spherical ecliptic state to Cartesian using the chain rule.
74pub fn spherical_state_to_cartesian(s: SphericalState) -> CartesianState {
75    let (sl, cl) = s.lon_rad.sin_cos();
76    let (sb, cb) = s.lat_rad.sin_cos();
77    let r = s.dist_au;
78    let pos = [r * cb * cl, r * cb * sl, r * sb];
79    let dr = s.dist_rate_au_per_day;
80    let dl = s.lon_rate_rad_per_day;
81    let db = s.lat_rate_rad_per_day;
82    let vel = [
83        dr * cb * cl - r * sb * cl * db - r * cb * sl * dl,
84        dr * cb * sl - r * sb * sl * db + r * cb * cl * dl,
85        dr * sb + r * cb * db,
86    ];
87    CartesianState {
88        pos_au: pos,
89        vel_au_per_day: vel,
90    }
91}
92
93/// Converts a Cartesian ecliptic state back to spherical using the inverse chain rule.
94pub fn cartesian_state_to_spherical(c: CartesianState) -> SphericalState {
95    let [x, y, z] = c.pos_au;
96    let [vx, vy, vz] = c.vel_au_per_day;
97    let rho2 = x * x + y * y;
98    let rho = rho2.sqrt();
99    let r = (rho2 + z * z).sqrt();
100    let dr = if r == 0.0 {
101        0.0
102    } else {
103        (x * vx + y * vy + z * vz) / r
104    };
105    let dl = if rho2 == 0.0 {
106        0.0
107    } else {
108        (x * vy - y * vx) / rho2
109    };
110    // β = atan2(z, ρ); dβ/dt = (ρ·vz − z·ρ̇)/r², where ρ̇ = (x·vx + y·vy)/ρ
111    let drho = if rho == 0.0 {
112        0.0
113    } else {
114        (x * vx + y * vy) / rho
115    };
116    let db = if r == 0.0 {
117        0.0
118    } else {
119        (rho * vz - z * drho) / (r * r)
120    };
121    SphericalState {
122        lon_rad: y.atan2(x),
123        lat_rad: z.atan2(rho),
124        dist_au: r,
125        lon_rate_rad_per_day: dl,
126        lat_rate_rad_per_day: db,
127        dist_rate_au_per_day: dr,
128    }
129}
130
131/// Derives a planet's heliocentric ecliptic from its geocentric ecliptic and
132/// the geocentric Sun: `P_helio = P_geo − S_geo` (vector subtract in ecliptic-of-date).
133pub fn heliocentric_from_geocentric(
134    planet_geo: &EclipticCoordinates,
135    sun_geo: &EclipticCoordinates,
136) -> Option<EclipticCoordinates> {
137    let p = ecliptic_to_cartesian_au(planet_geo)?;
138    let s = ecliptic_to_cartesian_au(sun_geo)?;
139    Some(cartesian_au_to_ecliptic([
140        p[0] - s[0],
141        p[1] - s[1],
142        p[2] - s[2],
143    ]))
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use pleiades_types::{EclipticCoordinates, Latitude, Longitude};
150
151    #[test]
152    fn velocity_round_trips_through_cartesian() {
153        let s = SphericalState {
154            lon_rad: 0.7,
155            lat_rad: 0.2,
156            dist_au: 1.5,
157            lon_rate_rad_per_day: 0.01,
158            lat_rate_rad_per_day: -0.003,
159            dist_rate_au_per_day: 0.002,
160        };
161        let c = spherical_state_to_cartesian(s);
162        let back = cartesian_state_to_spherical(c);
163        assert!((back.lon_rad - s.lon_rad).abs() < 1e-10);
164        assert!((back.lat_rad - s.lat_rad).abs() < 1e-10);
165        assert!((back.dist_au - s.dist_au).abs() < 1e-10);
166        assert!((back.lon_rate_rad_per_day - s.lon_rate_rad_per_day).abs() < 1e-10);
167        assert!((back.lat_rate_rad_per_day - s.lat_rate_rad_per_day).abs() < 1e-10);
168        assert!((back.dist_rate_au_per_day - s.dist_rate_au_per_day).abs() < 1e-10);
169    }
170
171    /// Forward-conversion ground-truth test: hand-derived Cartesian velocity for
172    /// lon=0.7 rad, lat=0.2 rad, r=1.5 AU, dλ=0.01 rad/day, dβ=−0.003 rad/day, dr=0.002 AU/day.
173    ///
174    /// Chain-rule derivation:
175    ///   vx = dr·cb·cl − r·sb·cl·dβ − r·cb·sl·dλ
176    ///      = 0.002·0.980067·0.764842 − 1.5·0.198669·0.764842·(−0.003) − 1.5·0.980067·0.644218·0.01
177    ///      ≈ −0.007287672746774
178    ///   vy = dr·cb·sl − r·sb·sl·dβ + r·cb·cl·dλ
179    ///      ≈  0.013082634760084
180    ///   vz = dr·sb + r·cb·dβ
181    ///      ≈ −0.004012960938695
182    #[test]
183    fn forward_conversion_matches_hand_derived_velocity() {
184        let s = SphericalState {
185            lon_rad: 0.7,
186            lat_rad: 0.2,
187            dist_au: 1.5,
188            lon_rate_rad_per_day: 0.01,
189            lat_rate_rad_per_day: -0.003,
190            dist_rate_au_per_day: 0.002,
191        };
192        let c = spherical_state_to_cartesian(s);
193        assert!(
194            (c.vel_au_per_day[0] - (-0.007_287_672_746_774_f64)).abs() < 1e-10,
195            "vx={} expected≈-0.007287672746774",
196            c.vel_au_per_day[0]
197        );
198        assert!(
199            (c.vel_au_per_day[1] - 0.013_082_634_760_084_f64).abs() < 1e-10,
200            "vy={} expected≈0.013082634760084",
201            c.vel_au_per_day[1]
202        );
203        assert!(
204            (c.vel_au_per_day[2] - (-0.004_012_960_938_695_f64)).abs() < 1e-10,
205            "vz={} expected≈-0.004012960938695",
206            c.vel_au_per_day[2]
207        );
208    }
209
210    fn ec(lon: f64, lat: f64, r: f64) -> EclipticCoordinates {
211        EclipticCoordinates::new(
212            Longitude::from_degrees(lon),
213            Latitude::from_degrees(lat),
214            Some(r),
215        )
216    }
217
218    #[test]
219    fn cartesian_round_trips_within_tolerance() {
220        let original = ec(123.456, -4.321, 9.87);
221        let v = ecliptic_to_cartesian_au(&original).unwrap();
222        let back = cartesian_au_to_ecliptic(v);
223        assert!((back.longitude.degrees() - 123.456).abs() < 1e-9);
224        assert!((back.latitude.degrees() - (-4.321)).abs() < 1e-9);
225        assert!((back.distance_au.unwrap() - 9.87).abs() < 1e-9);
226    }
227
228    #[test]
229    fn helio_and_geo_are_inverse_via_sun() {
230        // Known truth: planet geocentric, Sun geocentric. Heliocentric = geo - sun;
231        // reconstructing geo = helio + sun must return the original geocentric value.
232        let planet_geo = ec(200.0, 1.5, 19.2);
233        let sun_geo = ec(95.0, 0.0, 1.0);
234        let helio = heliocentric_from_geocentric(&planet_geo, &sun_geo).unwrap();
235        let geo_back = geocentric_from_heliocentric(&helio, &sun_geo).unwrap();
236        assert!((geo_back.longitude.degrees() - 200.0).abs() < 1e-9);
237        assert!((geo_back.latitude.degrees() - 1.5).abs() < 1e-9);
238        assert!((geo_back.distance_au.unwrap() - 19.2).abs() < 1e-9);
239    }
240
241    #[test]
242    fn missing_distance_yields_none() {
243        let no_dist = EclipticCoordinates::new(
244            Longitude::from_degrees(10.0),
245            Latitude::from_degrees(0.0),
246            None,
247        );
248        assert!(ecliptic_to_cartesian_au(&no_dist).is_none());
249    }
250
251    #[test]
252    fn spherical_to_cartesian_is_publicly_reachable_and_round_trips() {
253        // Reach it through the crate root to prove the re-export exists.
254        let s = crate::SphericalState {
255            lon_rad: 1.0,
256            lat_rad: 0.1,
257            dist_au: 0.0025,
258            lon_rate_rad_per_day: 0.2,
259            lat_rate_rad_per_day: -0.01,
260            dist_rate_au_per_day: 1e-6,
261        };
262        let c = crate::spherical_state_to_cartesian(s);
263        let back = crate::cartesian_state_to_spherical(c);
264        assert!((back.lon_rad - s.lon_rad).abs() < 1e-12);
265        assert!((back.dist_au - s.dist_au).abs() < 1e-15);
266    }
267
268    mod properties {
269        use super::*;
270        use proptest::prelude::*;
271
272        // Small positive helper: circular longitude difference in [0, 360).
273        fn lon_gap(a: f64, b: f64) -> f64 {
274            (a - b).rem_euclid(360.0)
275        }
276
277        // Small positive helper: circular longitude difference in [0, TAU) radians.
278        fn lon_gap_rad(a: f64, b: f64) -> f64 {
279            (a - b).rem_euclid(std::f64::consts::TAU)
280        }
281
282        proptest! {
283            #[test]
284            fn ecliptic_cartesian_roundtrips(
285                lon in 0.0f64..360.0,
286                lat in -85.0f64..85.0,   // away from the poles: longitude is ill-conditioned near ±90°
287                dist in 0.1f64..100.0,
288            ) {
289                let v = ecliptic_to_cartesian_au(&ec(lon, lat, dist)).unwrap();
290                let back = cartesian_au_to_ecliptic(v);
291                let g = lon_gap(back.longitude.degrees(), lon);
292                prop_assert!(!(1e-7..=360.0 - 1e-7).contains(&g), "lon {lon} -> {}", back.longitude.degrees());
293                prop_assert!((back.latitude.degrees() - lat).abs() < 1e-7);
294                prop_assert!((back.distance_au.unwrap() - dist).abs() < 1e-7 * dist);
295            }
296
297            #[test]
298            fn spherical_cartesian_state_roundtrips(
299                lon in 0.0f64..std::f64::consts::TAU,
300                lat in -1.4f64..1.4,     // radians, away from ±π/2
301                dist in 0.1f64..100.0,
302                dlon in -0.1f64..0.1,
303                dlat in -0.1f64..0.1,
304                ddist in -0.1f64..0.1,
305            ) {
306                let s = SphericalState {
307                    lon_rad: lon, lat_rad: lat, dist_au: dist,
308                    lon_rate_rad_per_day: dlon, lat_rate_rad_per_day: dlat, dist_rate_au_per_day: ddist,
309                };
310                let back = cartesian_state_to_spherical(spherical_state_to_cartesian(s));
311                let g = lon_gap_rad(back.lon_rad, lon);
312                prop_assert!(
313                    !(1e-9..=std::f64::consts::TAU - 1e-9).contains(&g),
314                    "lon {lon} -> {}",
315                    back.lon_rad
316                );
317                prop_assert!((back.lat_rad - lat).abs() < 1e-9);
318                prop_assert!((back.dist_au - dist).abs() < 1e-9 * dist);
319                prop_assert!((back.lon_rate_rad_per_day - dlon).abs() < 1e-9);
320                prop_assert!((back.lat_rate_rad_per_day - dlat).abs() < 1e-9);
321                prop_assert!((back.dist_rate_au_per_day - ddist).abs() < 1e-9);
322            }
323
324            #[test]
325            fn helio_geo_inverse_via_sun(
326                plon in 0.0f64..360.0, plat in -85.0f64..85.0, pdist in 0.5f64..50.0,
327                slon in 0.0f64..360.0, slat in -85.0f64..85.0, sdist in 0.5f64..2.0,
328            ) {
329                let planet_geo = ec(plon, plat, pdist);
330                let sun_geo = ec(slon, slat, sdist);
331                let helio = heliocentric_from_geocentric(&planet_geo, &sun_geo).unwrap();
332                let back = geocentric_from_heliocentric(&helio, &sun_geo).unwrap();
333                let g = lon_gap(back.longitude.degrees(), plon);
334                prop_assert!(!(1e-6..=360.0 - 1e-6).contains(&g), "plon {plon} -> {}", back.longitude.degrees());
335                prop_assert!((back.latitude.degrees() - plat).abs() < 1e-6);
336                prop_assert!((back.distance_au.unwrap() - pdist).abs() < 1e-6 * pdist);
337            }
338        }
339    }
340}