Skip to main content

hpr_core/
earth.rs

1//! The Earth as the flight engine sees it: a launch frame, a gravity model and the rotation
2//! terms, all resolved in the launch frame `L`.
3//!
4//! `L` is fixed to the rotating Earth, so a body in it feels normal gravity (gravitation plus
5//! centrifugal, [`crate::gravity`]) and, when it moves, the Coriolis acceleration `−2 Ω × v`.
6//! The centrifugal term is already inside normal gravity and is never added separately.
7//! Equations and choices are in `docs/physics/gravity.md` and the decision record on frames,
8//! geodesy and gravity, [ADR-003][adr-003].
9//!
10//! [adr-003]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-003-frames-attitude-geodesy-and-the-gravity-model-2026-09-17
11
12use 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/// How gravity is evaluated along the trajectory.
21#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
22#[serde(tag = "kind", rename_all = "snake_case")]
23#[non_exhaustive]
24pub enum GravityModel {
25    /// A uniform field `(0, 0, −g)` in the launch frame.
26    Constant {
27        /// Gravity magnitude, m/s², not negative: the field points down whatever the sign
28        /// convention of the source, and [`Earth::new`] rejects a negative value.
29        g_mps2: f64,
30    },
31    /// `(0, 0, −γ)` with `γ` from the Taylor series (eq. 4-3) at the launch latitude and height
32    /// `h₀ + z`: RocketPy's gravity formula, for like-for-like comparisons. A RocketPy flight
33    /// differs from the formula in three ways a case must reproduce itself: it evaluates it at
34    /// height above sea level rather than above the ellipsoid; it holds the value constant above
35    /// `max_expected_height` (80 km by default; at 100 km that is 0.6% high); and it does not fly
36    /// the formula at all but a 100-point cubic spline through it
37    /// (`Environment.set_gravity_model` → `Function.set_discrete`), which over the 0 to 4.4 km of
38    /// the parachute descents of [M1.7a][m1-7a] differs from the formula by at most 4.6e-8 m/s².
39    ///
40    /// [m1-7a]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-7a
41    VerticalTaylor,
42    /// `(0, 0, −|γ|)` with the exact magnitude (eq. 4-4) at the launch latitude and longitude and
43    /// height `h₀ + z`: altitude-dependent, but always along the launch site's vertical.
44    Vertical,
45    /// The full normal gravity vector at the body's actual position, resolved in the launch
46    /// frame. It includes the turn of the vertical over long ranges (about 0.9 mrad per 5.7 km)
47    /// and the small deflection above the ellipsoid.
48    #[default]
49    Ellipsoidal,
50}
51
52/// Which Earth-rotation terms the launch-frame equations of motion include.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55#[non_exhaustive]
56pub enum EarthRotation {
57    /// No rotation terms: `L` is treated as inertial apart from the centrifugal part already in
58    /// normal gravity.
59    Ignore,
60    /// The Coriolis acceleration `−2 Ω × v`.
61    #[default]
62    Coriolis,
63}
64
65/// The Earth model for one launch site.
66#[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    /// `Ω` resolved in the launch frame, rad/s.
74    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    /// The Earth model for a launch site at `site` (on the field's ellipsoid).
107    ///
108    /// # Errors
109    ///
110    /// [`CoreError::Domain`] if the site is not a valid geodetic position, or a constant gravity
111    /// magnitude is negative or not finite.
112    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    /// The WGS 84 Earth at `site`, with the default models: ellipsoidal gravity and Coriolis.
137    ///
138    /// # Errors
139    ///
140    /// As [`Earth::new`].
141    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    /// The normal gravity field.
151    #[must_use]
152    pub fn field(&self) -> &NormalGravity {
153        &self.field
154    }
155
156    /// The launch frame.
157    #[must_use]
158    pub fn frame(&self) -> &LaunchFrame {
159        &self.frame
160    }
161
162    /// The gravity model.
163    #[must_use]
164    pub fn gravity_model(&self) -> GravityModel {
165        self.gravity
166    }
167
168    /// The rotation terms included.
169    #[must_use]
170    pub fn rotation(&self) -> EarthRotation {
171        self.rotation
172    }
173
174    /// The Earth's angular velocity `Ω = ω (0, cos φ₀, sin φ₀)` resolved in the launch frame,
175    /// rad/s, whichever terms are enabled.
176    #[must_use]
177    pub fn angular_velocity_enu_rad_s(&self) -> DVec3 {
178        self.omega_enu_rad_s
179    }
180
181    /// Gravity at a launch-frame position, resolved in the launch frame, m/s².
182    ///
183    /// # Errors
184    ///
185    /// [`CoreError::Domain`] for a non-finite position, or (for the ellipsoidal model) a position
186    /// hundreds of kilometers inside the Earth.
187    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    /// The Earth-rotation acceleration on a body moving at `velocity_enu_m_s` relative to the
221    /// launch frame: `−2 Ω × v` with Coriolis enabled, zero otherwise, m/s².
222    #[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        // At the pad the vertical models all give the exact magnitude (Taylor to its 1e-8).
267        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        // 30 km straight up: the models share the height dependence.
279        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    /// Far downrange the ellipsoidal model tilts gravity back toward the pad by the angle the
294    /// vertical turns through: about d/R, with R the local radius of curvature.
295    #[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        // East-west the vertical turns with the prime-vertical radius N; north-south with the
303        // meridian radius M = a(1 − e²)/(1 − e² sin²φ)^(3/2).
304        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        // Northward the ellipsoid's curvature varies along the path and the normal gravity
319        // deflection above the ellipsoid adds a little, so the agreement is looser.
320        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        // And not with N, which differs from M by about 0.5% at this latitude.
329        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        // For v northward, Ω × ŷ = (−Ω_z, 0, 0), so −2Ω×v = (2Ω_z v, 0, 0): eastward, the
342        // northern-hemisphere deflection to the right.
343        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        // A vertical launch drifts west.
347        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}