1use std::f64::consts::{FRAC_PI_2, TAU};
15
16use glam::{DMat3, DQuat, DVec3};
17use serde::{Deserialize, Serialize};
18
19use crate::error::CoreError;
20use crate::geodesy::{Ellipsoid, Geodetic, ecef_from_enu_rotation};
21
22const VERTICAL_TOLERANCE: f64 = 1e-12;
25
26#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
28#[serde(try_from = "LaunchFrameData", into = "LaunchFrameData")]
29pub struct LaunchFrame {
30 ellipsoid: Ellipsoid,
31 origin: Geodetic,
32 origin_ecef_m: DVec3,
33 ecef_from_enu: DMat3,
34}
35
36#[derive(Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38struct LaunchFrameData {
39 ellipsoid: Ellipsoid,
40 origin: Geodetic,
41}
42
43impl TryFrom<LaunchFrameData> for LaunchFrame {
44 type Error = CoreError;
45
46 fn try_from(data: LaunchFrameData) -> Result<Self, CoreError> {
47 LaunchFrame::new(data.ellipsoid, data.origin)
48 }
49}
50
51impl From<LaunchFrame> for LaunchFrameData {
52 fn from(frame: LaunchFrame) -> Self {
53 LaunchFrameData {
54 ellipsoid: frame.ellipsoid,
55 origin: frame.origin,
56 }
57 }
58}
59
60impl LaunchFrame {
61 pub fn new(ellipsoid: Ellipsoid, origin: Geodetic) -> Result<Self, CoreError> {
68 let origin = origin.validated()?;
69 Ok(Self {
70 ellipsoid,
71 origin,
72 origin_ecef_m: ellipsoid.ecef_from_geodetic(origin),
73 ecef_from_enu: ecef_from_enu_rotation(origin),
74 })
75 }
76
77 pub fn wgs84(origin: Geodetic) -> Result<Self, CoreError> {
83 Self::new(Ellipsoid::WGS84, origin)
84 }
85
86 #[must_use]
88 pub fn ellipsoid(&self) -> Ellipsoid {
89 self.ellipsoid
90 }
91
92 #[must_use]
94 pub fn origin(&self) -> Geodetic {
95 self.origin
96 }
97
98 #[must_use]
100 pub fn origin_ecef_m(&self) -> DVec3 {
101 self.origin_ecef_m
102 }
103
104 #[must_use]
107 pub fn ecef_from_enu_rotation(&self) -> DMat3 {
108 self.ecef_from_enu
109 }
110
111 #[must_use]
113 pub fn ecef_from_enu(&self, position_enu_m: DVec3) -> DVec3 {
114 self.origin_ecef_m + self.ecef_from_enu * position_enu_m
115 }
116
117 #[must_use]
119 pub fn enu_from_ecef(&self, position_ecef_m: DVec3) -> DVec3 {
120 self.ecef_from_enu.transpose() * (position_ecef_m - self.origin_ecef_m)
121 }
122
123 pub fn geodetic_from_enu(&self, position_enu_m: DVec3) -> Result<Geodetic, CoreError> {
130 self.ellipsoid
131 .geodetic_from_ecef(self.ecef_from_enu(position_enu_m))
132 }
133
134 #[must_use]
136 pub fn enu_from_geodetic(&self, point: Geodetic) -> DVec3 {
137 self.enu_from_ecef(self.ellipsoid.ecef_from_geodetic(point))
138 }
139
140 #[must_use]
143 pub fn earth_rotation_enu_rad_s(&self, omega_rad_s: f64) -> DVec3 {
144 let (sin_lat, cos_lat) = self.origin.latitude_rad.sin_cos();
145 DVec3::new(0.0, omega_rad_s * cos_lat, omega_rad_s * sin_lat)
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
156pub struct LaunchAngles {
157 pub azimuth_rad: f64,
159 pub elevation_rad: f64,
161 pub roll_rad: f64,
163}
164
165impl LaunchAngles {
166 #[must_use]
168 pub fn to_quaternion(self) -> DQuat {
169 DQuat::from_rotation_z(-self.azimuth_rad)
170 * DQuat::from_rotation_x(self.elevation_rad - FRAC_PI_2)
171 * DQuat::from_rotation_z(self.roll_rad)
172 }
173
174 #[must_use]
177 pub fn from_quaternion(q: DQuat) -> Self {
178 let axis = q.mul_vec3(DVec3::Z);
179 let horizontal = axis.x.hypot(axis.y);
180 let elevation_rad = axis.z.atan2(horizontal);
181 let azimuth_rad = if horizontal < VERTICAL_TOLERANCE {
182 0.0
183 } else {
184 let a = axis.x.atan2(axis.y).rem_euclid(TAU);
186 if a < TAU { a } else { 0.0 }
187 };
188 let reference = DQuat::from_rotation_z(-azimuth_rad)
190 * DQuat::from_rotation_x(elevation_rad - FRAC_PI_2);
191 let x0 = reference.mul_vec3(DVec3::X);
192 let y0 = reference.mul_vec3(DVec3::Y);
193 let x_body = q.mul_vec3(DVec3::X);
194 let roll = x_body.dot(y0).atan2(x_body.dot(x0));
196 let roll_rad = if roll <= -std::f64::consts::PI {
197 std::f64::consts::PI
198 } else {
199 roll
200 };
201 Self {
202 azimuth_rad,
203 elevation_rad,
204 roll_rad,
205 }
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use std::f64::consts::{FRAC_PI_2, PI};
212
213 use proptest::prelude::*;
214
215 use super::*;
216
217 fn angle_between(a: DQuat, b: DQuat) -> f64 {
219 let d = a.inverse() * b;
220 2.0 * d.xyz().length().atan2(d.w.abs())
221 }
222
223 fn unit_quaternion() -> impl Strategy<Value = DQuat> {
224 prop::array::uniform4(-1.0..1.0f64)
225 .prop_filter("non-degenerate", |c| {
226 c.iter().map(|x| x * x).sum::<f64>() > 1e-6
227 })
228 .prop_map(|c| DQuat::from_array(c).normalize())
229 }
230
231 #[test]
232 fn named_attitudes() {
233 let vertical = LaunchAngles {
234 azimuth_rad: 0.0,
235 elevation_rad: FRAC_PI_2,
236 roll_rad: 0.0,
237 };
238 assert!(angle_between(vertical.to_quaternion(), DQuat::IDENTITY) < 1e-15);
239
240 let east = LaunchAngles {
243 azimuth_rad: FRAC_PI_2,
244 elevation_rad: 0.0,
245 roll_rad: 0.0,
246 }
247 .to_quaternion();
248 assert!((east.mul_vec3(DVec3::Z) - DVec3::X).length() < 1e-15);
249 assert!((east.mul_vec3(DVec3::X) - DVec3::NEG_Y).length() < 1e-15);
250 assert!((east.mul_vec3(DVec3::Y) - DVec3::NEG_Z).length() < 1e-15);
251
252 let rail = LaunchAngles {
254 azimuth_rad: 45f64.to_radians(),
255 elevation_rad: 85f64.to_radians(),
256 roll_rad: 0.3,
257 }
258 .to_quaternion();
259 let axis = rail.mul_vec3(DVec3::Z);
260 let (s5, c5) = 5f64.to_radians().sin_cos();
261 let expected = DVec3::new(s5 * 0.5f64.sqrt(), s5 * 0.5f64.sqrt(), c5);
262 assert!((axis - expected).length() < 1e-14);
263 }
264
265 #[test]
266 fn vertical_attitudes_report_zero_azimuth_and_the_whole_turn_as_roll() {
267 let q = DQuat::from_rotation_z(-1.1);
268 let angles = LaunchAngles::from_quaternion(q);
269 assert_eq!(angles.azimuth_rad, 0.0);
270 assert!((angles.elevation_rad - FRAC_PI_2).abs() < 1e-15);
271 assert!((angles.roll_rad + 1.1).abs() < 1e-15);
272
273 let down = LaunchAngles::from_quaternion(DQuat::from_xyzw(1.0, 0.0, 0.0, 0.0));
275 assert_eq!(down.azimuth_rad, 0.0);
276 assert!((down.elevation_rad + FRAC_PI_2).abs() < 1e-15);
277 assert_eq!(down.roll_rad, 0.0);
278 for sign in [1.0, -1.0] {
281 let down = LaunchAngles::from_quaternion(DQuat::from_xyzw(0.0, sign, 0.0, 0.0));
282 assert!((down.elevation_rad + FRAC_PI_2).abs() < 1e-15);
283 assert_eq!(down.roll_rad, PI);
284 }
285 }
286
287 #[test]
292 fn launch_angles_match_the_rocketpy_oracle() {
293 #[derive(serde::Deserialize)]
294 struct Oracle {
295 cases: Vec<Case>,
296 }
297 #[derive(serde::Deserialize)]
298 struct Case {
299 heading_deg: f64,
300 inclination_deg: f64,
301 rail_button_angle_deg: f64,
302 coordinate_system_orientation: String,
303 e0: f64,
304 e1: f64,
305 e2: f64,
306 e3: f64,
307 }
308 let oracle: Oracle = serde_json::from_str(include_str!(
309 "../../../validation/fixtures/earth/rocketpy-attitude.json"
310 ))
311 .unwrap();
312 assert!(oracle.cases.len() >= 6);
313 let mut orientations = std::collections::BTreeSet::new();
314 for case in &oracle.cases {
315 let button = case.rail_button_angle_deg.to_radians();
316 let roll_rad = match case.coordinate_system_orientation.as_str() {
317 "tail_to_nose" => button,
318 "nose_to_tail" => 2.0 * PI - button,
319 other => panic!("unknown orientation {other}"),
320 };
321 orientations.insert(case.coordinate_system_orientation.as_str());
322 let q = LaunchAngles {
323 azimuth_rad: case.heading_deg.to_radians(),
324 elevation_rad: case.inclination_deg.to_radians(),
325 roll_rad,
326 }
327 .to_quaternion();
328 let rocketpy = DQuat::from_xyzw(case.e1, case.e2, case.e3, case.e0);
329 assert!(
330 angle_between(q, rocketpy) < 1e-12,
331 "heading {} inclination {} buttons {}: {q} vs {rocketpy}",
332 case.heading_deg,
333 case.inclination_deg,
334 case.rail_button_angle_deg
335 );
336 }
337 assert_eq!(
338 orientations.len(),
339 2,
340 "both rocket orientations are covered"
341 );
342 }
343
344 #[test]
345 fn launch_frame_rejects_unchecked_sites() {
346 let degrees = Geodetic {
347 latitude_rad: 32.99,
348 longitude_rad: -106.97,
349 height_m: 1400.0,
350 };
351 assert!(LaunchFrame::wgs84(degrees).is_err());
352 let json = r#"{"ellipsoid": {"semi_major_axis_m": 6378137.0, "flattening": 0.0033528106647474805},
353 "origin": {"latitude_rad": 1.0, "longitude_rad": 0.0, "height_m": "NaN"}}"#;
354 assert!(serde_json::from_str::<LaunchFrame>(json).is_err());
355 }
356
357 #[test]
358 fn launch_frame_axes_and_origin() {
359 let site = Geodetic::from_degrees(32.99, -106.97, 1400.0).unwrap();
360 let frame = LaunchFrame::wgs84(site).unwrap();
361 assert_eq!(frame.enu_from_ecef(frame.origin_ecef_m()), DVec3::ZERO);
362 let back = frame.geodetic_from_enu(DVec3::ZERO).unwrap();
363 assert!((back.latitude_rad - site.latitude_rad).abs() < 1e-15);
364 assert!((back.height_m - site.height_m).abs() < 1e-8);
365 let up = frame
367 .geodetic_from_enu(DVec3::new(0.0, 0.0, 1000.0))
368 .unwrap();
369 assert!((up.height_m - 2400.0).abs() < 1e-8);
370 assert!((up.latitude_rad - site.latitude_rad).abs() < 1e-15);
371 assert!((up.longitude_rad - site.longitude_rad).abs() < 1e-15);
372 let north = frame
374 .geodetic_from_enu(DVec3::new(0.0, 1000.0, 0.0))
375 .unwrap();
376 assert!(north.latitude_rad > site.latitude_rad);
377 let east = frame
378 .geodetic_from_enu(DVec3::new(1000.0, 0.0, 0.0))
379 .unwrap();
380 assert!(east.longitude_rad > site.longitude_rad);
381 let n = frame.ellipsoid().prime_vertical_radius_m(site.latitude_rad);
383 let far = frame
384 .geodetic_from_enu(DVec3::new(1.0e4, 0.0, 0.0))
385 .unwrap();
386 let drop = far.height_m - site.height_m;
387 assert!((drop - 1.0e8 / (2.0 * (n + 1400.0))).abs() < 1e-3, "{drop}");
388
389 let json = serde_json::to_string(&frame).unwrap();
390 assert_eq!(serde_json::from_str::<LaunchFrame>(&json).unwrap(), frame);
391 }
392
393 proptest! {
394 #[test]
396 fn launch_angles_round_trip(
397 azimuth in 0.0..2.0 * PI,
398 elevation in -FRAC_PI_2 + 1e-3..FRAC_PI_2 - 1e-3,
399 roll in -PI + 1e-9..PI,
400 ) {
401 let angles = LaunchAngles { azimuth_rad: azimuth, elevation_rad: elevation, roll_rad: roll };
402 let back = LaunchAngles::from_quaternion(angles.to_quaternion());
403 let wrap = |x: f64| (x + PI).rem_euclid(2.0 * PI) - PI;
404 prop_assert!(wrap(back.azimuth_rad - azimuth).abs() < 1e-11, "azimuth {} vs {}", back.azimuth_rad, azimuth);
405 prop_assert!((back.elevation_rad - elevation).abs() < 1e-11, "elevation {} vs {}", back.elevation_rad, elevation);
406 prop_assert!(wrap(back.roll_rad - roll).abs() < 1e-11, "roll {} vs {}", back.roll_rad, roll);
407 prop_assert!((0.0..2.0 * PI).contains(&back.azimuth_rad));
408 }
409
410 #[test]
413 fn quaternion_round_trips_through_launch_angles(q in unit_quaternion(), case in 0u8..3) {
414 let turn = DQuat::from_rotation_z(2.0 * q.w.atan2(q.z));
416 let q = match case {
417 0 => q,
418 1 => turn,
419 _ => turn * DQuat::from_rotation_x(PI),
420 };
421 let angles = LaunchAngles::from_quaternion(q);
422 prop_assert!(angle_between(angles.to_quaternion(), q) < 1e-12);
423 prop_assert!((0.0..2.0 * PI).contains(&angles.azimuth_rad));
424 prop_assert!(angles.elevation_rad.abs() <= FRAC_PI_2);
425 prop_assert!(angles.roll_rad > -PI && angles.roll_rad <= PI);
426 let (sa, ca) = angles.azimuth_rad.sin_cos();
428 let (se, ce) = angles.elevation_rad.sin_cos();
429 prop_assert!((q.mul_vec3(DVec3::Z) - DVec3::new(sa * ce, ca * ce, se)).length() < 1e-12);
430 }
431
432 #[test]
435 fn launch_frame_round_trips(
436 lat in -FRAC_PI_2..=FRAC_PI_2,
437 lon in -PI..PI,
438 h0 in -500.0..5000.0f64,
439 offset in prop::array::uniform3(-2.0e6..2.0e6f64),
440 up in 0.0..1.0e6f64,
441 ) {
442 let frame = LaunchFrame::wgs84(Geodetic::new(lat, lon, h0).unwrap()).unwrap();
443 let p = DVec3::new(offset[0], offset[1], up);
444 let ecef = frame.ecef_from_enu(p);
445 prop_assert!((frame.enu_from_ecef(ecef) - p).length() < 1e-7);
446 let g = frame.geodetic_from_enu(p).unwrap();
447 prop_assert!((frame.enu_from_geodetic(g) - p).length() < 1e-7);
448 let r = frame.ecef_from_enu_rotation();
450 prop_assert!((r.transpose() * r).abs_diff_eq(DMat3::IDENTITY, 1e-15));
451 prop_assert!((r.determinant() - 1.0).abs() < 1e-15);
452 }
453 }
454}