1use glam::DVec3;
13use serde::{Deserialize, Serialize};
14
15use crate::error::CoreError;
16use crate::frames::LaunchFrame;
17use crate::geodesy::{Geodetic, first_non_finite};
18use crate::gravity::NormalGravity;
19
20#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
22#[serde(tag = "kind", rename_all = "snake_case")]
23#[non_exhaustive]
24pub enum GravityModel {
25 Constant {
27 g_mps2: f64,
30 },
31 VerticalTaylor,
42 Vertical,
45 #[default]
49 Ellipsoidal,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55#[non_exhaustive]
56pub enum EarthRotation {
57 Ignore,
60 #[default]
62 Coriolis,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
67#[serde(try_from = "EarthData", into = "EarthData")]
68pub struct Earth {
69 field: NormalGravity,
70 frame: LaunchFrame,
71 gravity: GravityModel,
72 rotation: EarthRotation,
73 omega_enu_rad_s: DVec3,
75}
76
77#[derive(Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79struct EarthData {
80 field: NormalGravity,
81 site: Geodetic,
82 gravity: GravityModel,
83 rotation: EarthRotation,
84}
85
86impl TryFrom<EarthData> for Earth {
87 type Error = CoreError;
88
89 fn try_from(data: EarthData) -> Result<Self, CoreError> {
90 Earth::new(data.field, data.site, data.gravity, data.rotation)
91 }
92}
93
94impl From<Earth> for EarthData {
95 fn from(earth: Earth) -> Self {
96 EarthData {
97 field: earth.field,
98 site: earth.frame.origin(),
99 gravity: earth.gravity,
100 rotation: earth.rotation,
101 }
102 }
103}
104
105impl Earth {
106 pub fn new(
113 field: NormalGravity,
114 site: Geodetic,
115 gravity: GravityModel,
116 rotation: EarthRotation,
117 ) -> Result<Self, CoreError> {
118 if let GravityModel::Constant { g_mps2 } = gravity
119 && !(g_mps2.is_finite() && g_mps2 >= 0.0)
120 {
121 return Err(CoreError::Domain {
122 what: "constant gravity magnitude (m/s²), which must be finite and not negative",
123 value: g_mps2,
124 });
125 }
126 let frame = LaunchFrame::new(field.ellipsoid(), site)?;
127 Ok(Self {
128 field,
129 frame,
130 gravity,
131 rotation,
132 omega_enu_rad_s: frame.earth_rotation_enu_rad_s(field.angular_velocity_rad_s()),
133 })
134 }
135
136 pub fn wgs84(site: Geodetic) -> Result<Self, CoreError> {
142 Self::new(
143 NormalGravity::wgs84(),
144 site,
145 GravityModel::default(),
146 EarthRotation::default(),
147 )
148 }
149
150 #[must_use]
152 pub fn field(&self) -> &NormalGravity {
153 &self.field
154 }
155
156 #[must_use]
158 pub fn frame(&self) -> &LaunchFrame {
159 &self.frame
160 }
161
162 #[must_use]
164 pub fn gravity_model(&self) -> GravityModel {
165 self.gravity
166 }
167
168 #[must_use]
170 pub fn rotation(&self) -> EarthRotation {
171 self.rotation
172 }
173
174 #[must_use]
177 pub fn angular_velocity_enu_rad_s(&self) -> DVec3 {
178 self.omega_enu_rad_s
179 }
180
181 pub fn gravity_enu_mps2(&self, position_enu_m: DVec3) -> Result<DVec3, CoreError> {
188 if let Some(value) = first_non_finite(position_enu_m) {
189 return Err(CoreError::Domain {
190 what: "launch-frame position component (m)",
191 value,
192 });
193 }
194 let site = self.frame.origin();
195 let height_m = site.height_m + position_enu_m.z;
196 match self.gravity {
197 GravityModel::Constant { g_mps2 } => Ok(DVec3::new(0.0, 0.0, -g_mps2)),
198 GravityModel::VerticalTaylor => Ok(DVec3::new(
199 0.0,
200 0.0,
201 -self.field.taylor_mps2(site.latitude_rad, height_m)?,
202 )),
203 GravityModel::Vertical => {
204 let above = Geodetic { height_m, ..site };
205 Ok(DVec3::new(
206 0.0,
207 0.0,
208 -self.field.enu_at_mps2(above)?.length(),
209 ))
210 }
211 GravityModel::Ellipsoidal => {
212 let gamma = self
213 .field
214 .ecef_mps2(self.frame.ecef_from_enu(position_enu_m))?;
215 Ok(self.frame.ecef_from_enu_rotation().transpose() * gamma)
216 }
217 }
218 }
219
220 #[must_use]
223 pub fn rotation_acceleration_enu_mps2(&self, velocity_enu_m_s: DVec3) -> DVec3 {
224 match self.rotation {
225 EarthRotation::Ignore => DVec3::ZERO,
226 EarthRotation::Coriolis => -2.0 * self.omega_enu_rad_s.cross(velocity_enu_m_s),
227 }
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::gravity::STANDARD_GRAVITY_MPS2;
235
236 fn site() -> Geodetic {
237 Geodetic::from_degrees(32.99, -106.97, 1400.0).unwrap()
238 }
239
240 fn earth(gravity: GravityModel) -> Earth {
241 Earth::new(
242 NormalGravity::wgs84(),
243 site(),
244 gravity,
245 EarthRotation::Coriolis,
246 )
247 .unwrap()
248 }
249
250 #[test]
251 fn gravity_models_agree_at_the_pad_and_differ_as_documented_aloft() {
252 let field = NormalGravity::wgs84();
253 let pad = DVec3::ZERO;
254 let exact = field.enu_at_mps2(site()).unwrap();
255
256 let constant = earth(GravityModel::Constant {
257 g_mps2: STANDARD_GRAVITY_MPS2,
258 });
259 assert_eq!(
260 constant
261 .gravity_enu_mps2(DVec3::new(1e4, -3e3, 2e4))
262 .unwrap(),
263 DVec3::new(0.0, 0.0, -9.806_65)
264 );
265
266 let vertical = earth(GravityModel::Vertical).gravity_enu_mps2(pad).unwrap();
268 assert_eq!(vertical, DVec3::new(0.0, 0.0, -exact.length()));
269 let taylor = earth(GravityModel::VerticalTaylor)
270 .gravity_enu_mps2(pad)
271 .unwrap();
272 assert!((taylor.z - vertical.z).abs() < 1e-7);
273 let full = earth(GravityModel::Ellipsoidal)
274 .gravity_enu_mps2(pad)
275 .unwrap();
276 assert!((full - exact).length() < 1e-12);
277
278 let up = DVec3::new(0.0, 0.0, 3.0e4);
280 let above = Geodetic {
281 height_m: site().height_m + 3.0e4,
282 ..site()
283 };
284 let exact_up = field.enu_at_mps2(above).unwrap();
285 let full_up = earth(GravityModel::Ellipsoidal)
286 .gravity_enu_mps2(up)
287 .unwrap();
288 assert!((full_up - exact_up).length() < 1e-9);
289 let vertical_up = earth(GravityModel::Vertical).gravity_enu_mps2(up).unwrap();
290 assert!((vertical_up.z + exact_up.length()).abs() < 1e-12);
291 }
292
293 #[test]
296 fn ellipsoidal_gravity_turns_with_the_vertical_downrange() {
297 let e = earth(GravityModel::Ellipsoidal);
298 let d = 20_000.0;
299 let ellipsoid = e.frame().ellipsoid();
300 let lat = site().latitude_rad;
301 let h0 = site().height_m;
302 let n = ellipsoid.prime_vertical_radius_m(lat);
305 let e2 = ellipsoid.eccentricity_squared();
306 let m =
307 ellipsoid.semi_major_axis_m() * (1.0 - e2) / (1.0 - e2 * lat.sin().powi(2)).powf(1.5);
308
309 let east = e.gravity_enu_mps2(DVec3::new(d, 0.0, 0.0)).unwrap();
310 let east_tilt = (-east.x).atan2(-east.z);
311 let east_expected = d / (n + h0);
312 assert!(east.x < 0.0, "gravity leans back toward the pad");
313 assert!(
314 (east_tilt - east_expected).abs() < 1e-4 * east_expected,
315 "east: {east_tilt} vs {east_expected}"
316 );
317
318 let north = e.gravity_enu_mps2(DVec3::new(0.0, d, 0.0)).unwrap();
321 let north_tilt = (-north.y).atan2(-north.z);
322 let north_expected = d / (m + h0);
323 assert!(north.y < 0.0, "gravity leans back toward the pad");
324 assert!(
325 (north_tilt - north_expected).abs() < 1e-3 * north_expected,
326 "north: {north_tilt} vs {north_expected}"
327 );
328 assert!((north_tilt - d / (n + h0)).abs() > 3e-3 * north_expected);
330 }
331
332 #[test]
333 fn coriolis_is_minus_two_omega_cross_v() {
334 let e = earth(GravityModel::Ellipsoidal);
335 let lat = site().latitude_rad;
336 let omega = crate::gravity::WGS84_ANGULAR_VELOCITY_RAD_S;
337 assert_eq!(
338 e.angular_velocity_enu_rad_s(),
339 DVec3::new(0.0, omega * lat.cos(), omega * lat.sin())
340 );
341 let v_north = DVec3::new(0.0, 100.0, 0.0);
344 let a = e.rotation_acceleration_enu_mps2(v_north);
345 assert!((a - DVec3::new(2.0 * omega * lat.sin() * 100.0, 0.0, 0.0)).length() < 1e-15);
346 let a_up = e.rotation_acceleration_enu_mps2(DVec3::new(0.0, 0.0, 300.0));
348 assert!(a_up.x < 0.0 && a_up.y == 0.0 && a_up.z == 0.0);
349
350 let still = Earth::new(
351 NormalGravity::wgs84(),
352 site(),
353 GravityModel::Ellipsoidal,
354 EarthRotation::Ignore,
355 )
356 .unwrap();
357 assert_eq!(still.rotation_acceleration_enu_mps2(v_north), DVec3::ZERO);
358 }
359
360 #[test]
361 fn rejects_bad_inputs_and_round_trips_through_serde() {
362 for g_mps2 in [f64::NAN, f64::INFINITY, -9.81] {
363 let bad_g = Earth::new(
364 NormalGravity::wgs84(),
365 site(),
366 GravityModel::Constant { g_mps2 },
367 EarthRotation::Ignore,
368 );
369 assert!(bad_g.is_err(), "g = {g_mps2}");
370 }
371 let bad_site = Geodetic {
372 latitude_rad: 2.0,
373 ..site()
374 };
375 assert!(Earth::wgs84(bad_site).is_err());
376 let e = earth(GravityModel::Ellipsoidal);
377 assert!(e.gravity_enu_mps2(DVec3::new(f64::NAN, 0.0, 0.0)).is_err());
378
379 let json = serde_json::to_string(&e).unwrap();
380 let back: Earth = serde_json::from_str(&json).unwrap();
381 assert_eq!(back, e);
382 let constant = earth(GravityModel::Constant { g_mps2: 9.81 });
383 let json = serde_json::to_string(&constant).unwrap();
384 assert!(
385 json.contains(r#""gravity":{"kind":"constant","g_mps2":9.81}"#),
386 "{json}"
387 );
388 }
389}