1use core::fmt;
11
12use crate::astro::math::vec3::{cross3, unit3};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
16pub enum FrameValueError {
17 #[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
29pub 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
45pub 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
51pub 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#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct ItrfPositionM {
78 pub x_m: f64,
80 pub y_m: f64,
82 pub z_m: f64,
84}
85
86impl ItrfPositionM {
87 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 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#[derive(Debug, Clone, Copy, PartialEq)]
128pub struct Wgs84Geodetic {
129 pub lat_rad: f64,
131 pub lon_rad: f64,
133 pub height_m: f64,
135}
136
137impl Wgs84Geodetic {
138 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
173pub 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
192pub 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#[derive(Debug, Clone, Copy, PartialEq)]
227pub struct ItrfVelocityMS {
228 pub vx_m_s: f64,
230 pub vy_m_s: f64,
232 pub vz_m_s: f64,
234}
235
236impl ItrfVelocityMS {
237 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(north, [0.0, 1.0, 0.0]);
459 assert_eq!(geocentric_east([0.0, 0.0, 1.0]), [1.0, 0.0, 0.0]);
461 }
462}