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(crate) 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(crate) 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(crate) 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
173#[derive(Debug, Clone, Copy, PartialEq)]
180pub struct ItrfVelocityMS {
181 pub vx_m_s: f64,
183 pub vy_m_s: f64,
185 pub vz_m_s: f64,
187}
188
189impl ItrfVelocityMS {
190 pub const fn new(vx_m_s: f64, vy_m_s: f64, vz_m_s: f64) -> Result<Self, FrameValueError> {
192 if !vx_m_s.is_finite() {
193 return Err(invalid_input("vx_m_s", "must be finite"));
194 }
195 if !vy_m_s.is_finite() {
196 return Err(invalid_input("vy_m_s", "must be finite"));
197 }
198 if !vz_m_s.is_finite() {
199 return Err(invalid_input("vz_m_s", "must be finite"));
200 }
201 Ok(Self {
202 vx_m_s,
203 vy_m_s,
204 vz_m_s,
205 })
206 }
207
208 pub const fn as_array(self) -> [f64; 3] {
210 [self.vx_m_s, self.vy_m_s, self.vz_m_s]
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use crate::astro::math::vec3::{norm3, scale3};
218
219 fn bits(v: [f64; 3]) -> [u64; 3] {
220 [v[0].to_bits(), v[1].to_bits(), v[2].to_bits()]
221 }
222
223 fn reference_neu(position_ecef_m: [f64; 3]) -> ([f64; 3], [f64; 3], [f64; 3]) {
226 let up = match norm3(position_ecef_m) {
227 n if n > 0.0 => scale3(position_ecef_m, 1.0 / n),
228 _ => [0.0, 0.0, 1.0],
229 };
230 let cross = cross3([0.0, 0.0, 1.0], up);
231 let east = if cross == [0.0, 0.0, 0.0] {
232 [1.0, 0.0, 0.0]
233 } else {
234 match norm3(cross) {
235 n if n > 0.0 => scale3(cross, 1.0 / n),
236 _ => [1.0, 0.0, 0.0],
237 }
238 };
239 let north = match norm3(cross3(up, east)) {
240 n if n > 0.0 => scale3(cross3(up, east), 1.0 / n),
241 _ => [0.0, 0.0, 1.0],
242 };
243 (north, east, up)
244 }
245
246 fn assert_close3(actual: [f64; 3], expected: [f64; 3], tol: f64) {
247 for (a, e) in actual.into_iter().zip(expected) {
248 assert!(
249 (a - e).abs() <= tol,
250 "component {a:?} differs from {e:?} by more than {tol}"
251 );
252 }
253 }
254
255 #[test]
256 fn constructors_reject_invalid_frame_values() {
257 assert_eq!(
258 ItrfPositionM::new(f64::NAN, 0.0, 0.0),
259 Err(FrameValueError::InvalidInput {
260 field: "x_m",
261 reason: "must be finite"
262 })
263 );
264 assert_eq!(
265 ItrfVelocityMS::new(0.0, f64::INFINITY, 0.0),
266 Err(FrameValueError::InvalidInput {
267 field: "vy_m_s",
268 reason: "must be finite"
269 })
270 );
271 assert_eq!(
272 Wgs84Geodetic::new(core::f64::consts::FRAC_PI_2 + 1.0e-12, 0.0, 0.0),
273 Err(FrameValueError::InvalidInput {
274 field: "lat_rad",
275 reason: "must be in [-pi/2, pi/2]"
276 })
277 );
278 assert_eq!(
279 Wgs84Geodetic::new(0.0, -core::f64::consts::PI - 1.0e-12, 0.0),
280 Err(FrameValueError::InvalidInput {
281 field: "lon_rad",
282 reason: "must be in [-pi, pi]"
283 })
284 );
285 assert_eq!(
286 Wgs84Geodetic::new(0.0, 0.0, f64::NAN),
287 Err(FrameValueError::InvalidInput {
288 field: "height_m",
289 reason: "must be finite"
290 })
291 );
292 }
293
294 #[test]
295 fn constructors_preserve_valid_frame_values() {
296 let position = ItrfPositionM::new(1.0, -2.0, 3.5).expect("valid ITRF position");
297 assert_eq!(position.as_array(), [1.0, -2.0, 3.5]);
298
299 let velocity = ItrfVelocityMS::new(0.1, -0.2, 0.3).expect("valid ITRF velocity");
300 assert_eq!(velocity.as_array(), [0.1, -0.2, 0.3]);
301
302 let geodetic =
303 Wgs84Geodetic::new(-0.5, core::f64::consts::PI, -12.0).expect("valid geodetic");
304 assert_eq!(geodetic.lat_rad.to_bits(), (-0.5_f64).to_bits());
305 assert_eq!(geodetic.lon_rad.to_bits(), core::f64::consts::PI.to_bits());
306 assert_eq!(geodetic.height_m.to_bits(), (-12.0_f64).to_bits());
307
308 let west_antimeridian =
309 Wgs84Geodetic::new(0.25, -core::f64::consts::PI, 42.0).expect("valid geodetic");
310 assert_eq!(
311 west_antimeridian.lon_rad.to_bits(),
312 (-core::f64::consts::PI).to_bits()
313 );
314 }
315
316 #[test]
317 fn geocentric_basis_matches_reference_recipe_to_the_bit() {
318 let position = [4_027_894.0, 307_045.0, 4_919_474.0];
320 let (north, east, up) = geocentric_neu_basis(position);
321 let (rn, re, ru) = reference_neu(position);
322 assert_eq!(bits(north), bits(rn));
323 assert_eq!(bits(east), bits(re));
324 assert_eq!(bits(up), bits(ru));
325 assert_eq!(bits(up), bits(geocentric_up(position)));
327 assert_eq!(bits(east), bits(geocentric_east(up)));
328 }
329
330 #[test]
331 fn geocentric_basis_is_right_handed_enu_and_points_north() {
332 let (north, east, up) = geocentric_neu_basis([6_378_137.0, 0.0, 0.0]);
333 assert_close3(up, [1.0, 0.0, 0.0], 1.0e-15);
334 assert_close3(east, [0.0, 1.0, 0.0], 1.0e-15);
335 assert_close3(north, [0.0, 0.0, 1.0], 1.0e-15);
336
337 let (north, east, up) = geocentric_neu_basis([4_027_894.0, 307_045.0, 4_919_474.0]);
338 assert_close3(cross3(up, east), north, 1.0e-15);
339 assert_close3(cross3(east, north), up, 1.0e-15);
340 }
341
342 #[test]
343 fn geocentric_basis_handles_degenerate_positions() {
344 let (north, east, up) = geocentric_neu_basis([0.0, 0.0, 0.0]);
346 assert_eq!(up, [0.0, 0.0, 1.0]);
347 assert_eq!(east, [1.0, 0.0, 0.0]);
348 assert_eq!(north, [0.0, 1.0, 0.0]);
350 assert_eq!(geocentric_east([0.0, 0.0, 1.0]), [1.0, 0.0, 0.0]);
352 }
353}