1use pleiades_types::{EclipticCoordinates, Latitude, Longitude};
4
5pub 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
18pub 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
32pub 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#[derive(Clone, Copy, Debug)]
49pub struct SphericalState {
50 pub lon_rad: f64,
52 pub lat_rad: f64,
54 pub dist_au: f64,
56 pub lon_rate_rad_per_day: f64,
58 pub lat_rate_rad_per_day: f64,
60 pub dist_rate_au_per_day: f64,
62}
63
64#[derive(Clone, Copy, Debug)]
66pub struct CartesianState {
67 pub pos_au: [f64; 3],
69 pub vel_au_per_day: [f64; 3],
71}
72
73pub 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
93pub 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 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
131pub 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 #[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 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 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 fn lon_gap(a: f64, b: f64) -> f64 {
274 (a - b).rem_euclid(360.0)
275 }
276
277 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, 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, 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}