Skip to main content

hpr_core/
gravity.rs

1//! Normal gravity of a level ellipsoid (WGS 84 by default), and the gravity models the flight
2//! engine chooses from.
3//!
4//! Normal gravity is the gravitational attraction of the reference ellipsoid plus the centrifugal
5//! acceleration of the Earth's rotation, so it is the acceleration a body at rest relative to the
6//! Earth feels. A simulation in an Earth-fixed frame adds only the Coriolis term
7//! ([`crate::earth`]); adding a centrifugal term as well would count it twice.
8//!
9//! Source: NGA.STND.0036_1.0.0_WGS84, *Department of Defense World Geodetic System 1984*
10//! (2014-07-08), chapter 4 and appendix B. Details and tests are in `docs/physics/gravity.md`.
11
12use glam::DVec3;
13use serde::{Deserialize, Serialize};
14
15use crate::error::CoreError;
16use crate::geodesy::{Ellipsoid, Geodetic, ecef_from_enu_rotation};
17
18/// Standard acceleration of free fall, `g₀ = 9.80665 m/s²`: the conventional value adopted by
19/// the 3rd CGPM (1901) and used by the U.S. Standard Atmosphere, 1976. It is not the gravity at
20/// any particular place.
21pub const STANDARD_GRAVITY_MPS2: f64 = 9.806_65;
22
23/// WGS 84 geocentric gravitational constant `GM`, m³/s² (NGA.STND.0036, Table 3.1).
24pub const WGS84_GM_M3_S2: f64 = 3.986_004_418e14;
25
26/// WGS 84 nominal mean angular velocity of the Earth `ω`, rad/s (NGA.STND.0036, Table 3.1).
27pub const WGS84_ANGULAR_VELOCITY_RAD_S: f64 = 7.292_115e-5;
28
29/// `q` and `q′` of eqs. 4-11 and 4-13 as functions of `ε = E/u`:
30///
31/// ```text
32/// q  = ½ [(1 + 3/ε²) atan ε − 3/ε]
33/// q′ = 3 (1 + 1/ε²) (1 − atan(ε)/ε) − 1
34/// ```
35///
36/// For the Earth `ε ≤ e′ ≈ 0.082`, where both closed forms cancel about five digits. Below
37/// `ε = 0.5` the Taylor series of `atan ε` gives them without cancellation (substitute
38/// `atan ε = Σ (−1)ⁿ ε^(2n+1)/(2n+1)` and collect powers):
39///
40/// ```text
41/// q  = Σ_{n≥1} (−1)^(n+1) 2n ε^(2n+1) / ((2n+1)(2n+3))
42/// q′ = Σ_{n≥1} (−1)^(n+1) 6 ε^(2n)    / ((2n+1)(2n+3))
43/// ```
44fn q_and_q_prime(eps: f64) -> (f64, f64) {
45    if eps >= 0.5 {
46        let atan_eps = eps.atan();
47        let q = 0.5 * ((1.0 + 3.0 / (eps * eps)) * atan_eps - 3.0 / eps);
48        let q_prime = 3.0 * (1.0 + 1.0 / (eps * eps)) * (1.0 - atan_eps / eps) - 1.0;
49        return (q, q_prime);
50    }
51    let t = eps * eps;
52    let (mut q, mut q_prime) = (0.0, 0.0);
53    // `power` is (−1)^(n+1) ε^(2n). At ε < 0.5 each term is under a quarter of the last, so the
54    // sums stop changing well within 40 terms; for the Earth (ε ≤ 0.082) about 8 are needed.
55    let mut power = t;
56    for n in 1..=40 {
57        let n = f64::from(n);
58        let denominator = (2.0 * n + 1.0) * (2.0 * n + 3.0);
59        let q_term = 2.0 * n * power * eps / denominator;
60        let q_prime_term = 6.0 * power / denominator;
61        q += q_term;
62        q_prime += q_prime_term;
63        if q_term.abs() <= f64::EPSILON * 0.25 * q.abs()
64            && q_prime_term.abs() <= f64::EPSILON * 0.25 * q_prime.abs()
65        {
66            break;
67        }
68        power *= -t;
69    }
70    (q, q_prime)
71}
72
73/// Rejects a latitude outside `[−π/2, π/2]` or NaN.
74fn check_latitude(latitude_rad: f64) -> Result<(), CoreError> {
75    if latitude_rad.is_nan() || latitude_rad.abs() > std::f64::consts::FRAC_PI_2 {
76        return Err(CoreError::Domain {
77            what: "geodetic latitude (rad)",
78            value: latitude_rad,
79        });
80    }
81    Ok(())
82}
83
84/// The normal gravity field of a level ellipsoid, fixed by four defining parameters: `a`, `1/f`,
85/// `GM` and `ω`. Everything else is derived from them (NGA.STND.0036 appendix B).
86#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
87#[serde(try_from = "NormalGravityData", into = "NormalGravityData")]
88pub struct NormalGravity {
89    ellipsoid: Ellipsoid,
90    gm_m3_s2: f64,
91    omega_rad_s: f64,
92    /// `q₀` (eq. B-18).
93    q0: f64,
94    /// `m = ω²a²b/GM` (eq. B-20).
95    m: f64,
96    /// Normal gravity at the equator `γ_e`, m/s² (eq. B-24).
97    gamma_e: f64,
98    /// Normal gravity at the poles `γ_p`, m/s² (eq. B-25).
99    gamma_p: f64,
100    /// Somigliana's constant `k` (eq. B-26).
101    k: f64,
102}
103
104#[derive(Serialize, Deserialize)]
105#[serde(deny_unknown_fields)]
106struct NormalGravityData {
107    ellipsoid: Ellipsoid,
108    gm_m3_s2: f64,
109    angular_velocity_rad_s: f64,
110}
111
112impl TryFrom<NormalGravityData> for NormalGravity {
113    type Error = CoreError;
114
115    fn try_from(data: NormalGravityData) -> Result<Self, CoreError> {
116        NormalGravity::new(data.ellipsoid, data.gm_m3_s2, data.angular_velocity_rad_s)
117    }
118}
119
120impl From<NormalGravity> for NormalGravityData {
121    fn from(field: NormalGravity) -> Self {
122        NormalGravityData {
123            ellipsoid: field.ellipsoid,
124            gm_m3_s2: field.gm_m3_s2,
125            angular_velocity_rad_s: field.omega_rad_s,
126        }
127    }
128}
129
130impl NormalGravity {
131    /// The field of `ellipsoid` with geocentric gravitational constant `gm_m3_s2` and angular
132    /// velocity `omega_rad_s`.
133    ///
134    /// # Errors
135    ///
136    /// [`CoreError::Domain`] unless the ellipsoid is oblate (the ellipsoidal-harmonic formulas
137    /// divide by its linear eccentricity), `GM` is finite and positive, `ω` is finite and not
138    /// negative, and the derived constants are usable: `q₀` positive (it underflows for a
139    /// flattening below about 1e-200), `k` finite, and `γ_e`, `γ_p` finite and positive (the
140    /// equator does not spin faster than orbit).
141    pub fn new(ellipsoid: Ellipsoid, gm_m3_s2: f64, omega_rad_s: f64) -> Result<Self, CoreError> {
142        if ellipsoid.flattening() <= 0.0 {
143            return Err(CoreError::Domain {
144                what: "flattening of a normal gravity ellipsoid",
145                value: ellipsoid.flattening(),
146            });
147        }
148        if !(gm_m3_s2.is_finite() && gm_m3_s2 > 0.0) {
149            return Err(CoreError::Domain {
150                what: "GM (m³/s²)",
151                value: gm_m3_s2,
152            });
153        }
154        if !(omega_rad_s.is_finite() && omega_rad_s >= 0.0) {
155            return Err(CoreError::Domain {
156                what: "angular velocity (rad/s)",
157                value: omega_rad_s,
158            });
159        }
160        let field = Self::derive(ellipsoid, gm_m3_s2, omega_rad_s);
161        let usable = field.q0 > 0.0
162            && field.k.is_finite()
163            && field.gamma_e.is_finite()
164            && field.gamma_e > 0.0
165            && field.gamma_p.is_finite()
166            && field.gamma_p > 0.0;
167        if !usable {
168            return Err(CoreError::Domain {
169                what: "equatorial normal gravity derived from the defining parameters (m/s²)",
170                value: field.gamma_e,
171            });
172        }
173        Ok(field)
174    }
175
176    /// The WGS 84 normal gravity field.
177    #[must_use]
178    pub fn wgs84() -> Self {
179        Self::derive(
180            Ellipsoid::WGS84,
181            WGS84_GM_M3_S2,
182            WGS84_ANGULAR_VELOCITY_RAD_S,
183        )
184    }
185
186    /// The derived constants of appendix B, from validated defining parameters.
187    fn derive(ellipsoid: Ellipsoid, gm: f64, omega: f64) -> Self {
188        let a = ellipsoid.semi_major_axis_m();
189        let b = ellipsoid.semi_minor_axis_m();
190        // Second eccentricity e′ = E/b; (B-18) and (B-19) are q and q′ at u = b.
191        let ep = ellipsoid.linear_eccentricity_m() / b;
192        let (q0, q0_prime) = q_and_q_prime(ep);
193        let m = omega * omega * a * a * b / gm; // (B-20)
194        let ratio = m * ep * q0_prime / q0;
195        let gamma_e = gm / (a * b) * (1.0 - m - ratio / 6.0); // (B-24)
196        let gamma_p = gm / (a * a) * (1.0 + ratio / 3.0); // (B-25)
197        let k = (b * gamma_p - a * gamma_e) / (a * gamma_e); // (B-26)
198        Self {
199            ellipsoid,
200            gm_m3_s2: gm,
201            omega_rad_s: omega,
202            q0,
203            m,
204            gamma_e,
205            gamma_p,
206            k,
207        }
208    }
209
210    /// The reference ellipsoid.
211    #[must_use]
212    pub fn ellipsoid(&self) -> Ellipsoid {
213        self.ellipsoid
214    }
215
216    /// Geocentric gravitational constant `GM`, m³/s².
217    #[must_use]
218    pub fn gm_m3_s2(&self) -> f64 {
219        self.gm_m3_s2
220    }
221
222    /// The Earth's angular velocity `ω`, rad/s.
223    #[must_use]
224    pub fn angular_velocity_rad_s(&self) -> f64 {
225        self.omega_rad_s
226    }
227
228    /// Normal gravity at the equator on the ellipsoid, `γ_e`, m/s² (eq. B-24).
229    #[must_use]
230    pub fn equatorial_gravity_mps2(&self) -> f64 {
231        self.gamma_e
232    }
233
234    /// Normal gravity at the poles on the ellipsoid, `γ_p`, m/s² (eq. B-25).
235    #[must_use]
236    pub fn polar_gravity_mps2(&self) -> f64 {
237        self.gamma_p
238    }
239
240    /// Somigliana's constant `k = bγ_p/(aγ_e) − 1` (eq. B-26).
241    #[must_use]
242    pub fn somigliana_constant(&self) -> f64 {
243        self.k
244    }
245
246    /// `m = ω²a²b/GM` (eq. B-20).
247    #[must_use]
248    pub fn m(&self) -> f64 {
249        self.m
250    }
251
252    /// Normal gravity on the ellipsoid at geodetic latitude `φ`, by Somigliana's closed formula
253    /// (eq. 4-1), m/s²:
254    ///
255    /// ```text
256    /// γ = γ_e (1 + k sin²φ) / √(1 − e² sin²φ)
257    /// ```
258    ///
259    /// # Errors
260    ///
261    /// [`CoreError::Domain`] if `|φ| > π/2` or `φ` is NaN, which catches degrees passed as
262    /// radians for most launch sites.
263    pub fn surface_mps2(&self, latitude_rad: f64) -> Result<f64, CoreError> {
264        check_latitude(latitude_rad)?;
265        let s2 = latitude_rad.sin().powi(2);
266        Ok(self.gamma_e * (1.0 + self.k * s2)
267            / (1.0 - self.ellipsoid.eccentricity_squared() * s2).sqrt())
268    }
269
270    /// Magnitude of normal gravity at geodetic latitude `φ` and ellipsoidal height `h` by the
271    /// truncated Taylor series (eq. 4-3), m/s²:
272    ///
273    /// ```text
274    /// γ_h = γ [1 − (2/a)(1 + f + m − 2f sin²φ) h + (3/a²) h²]
275    /// ```
276    ///
277    /// RocketPy's gravity formula has this form. It drifts from the exact field with height:
278    /// about 3e-7 relative at 30 km and 1.4e-5 at 100 km (`docs/physics/gravity.md`). Prefer
279    /// [`NormalGravity::enu_at_mps2`] unless matching an oracle that uses it.
280    ///
281    /// # Errors
282    ///
283    /// As [`NormalGravity::surface_mps2`], and [`CoreError::Domain`] for a non-finite height.
284    pub fn taylor_mps2(&self, latitude_rad: f64, height_m: f64) -> Result<f64, CoreError> {
285        if !height_m.is_finite() {
286            return Err(CoreError::Domain {
287                what: "ellipsoidal height (m)",
288                value: height_m,
289            });
290        }
291        let a = self.ellipsoid.semi_major_axis_m();
292        let f = self.ellipsoid.flattening();
293        let s2 = latitude_rad.sin().powi(2);
294        Ok(self.surface_mps2(latitude_rad)?
295            * (1.0 - 2.0 / a * (1.0 + f + self.m - 2.0 * f * s2) * height_m
296                + 3.0 / (a * a) * height_m * height_m))
297    }
298
299    /// The normal gravity vector at an ECEF position, resolved in ECEF, m/s². Exact closed form
300    /// in ellipsoidal-harmonic coordinates `(u, β)` (eqs. 4-5 to 4-13), rotated to Cartesian
301    /// components by `R₁` (eq. 4-18):
302    ///
303    /// ```text
304    /// u² = ½ [s + √(s² + 4E²z²)],   s = x² + y² + z² − E²                     (4-8)
305    /// β  = atan2(z √(u² + E²), u √(x² + y²))                                   (4-9)
306    /// w  = √((u² + E² sin²β)/(u² + E²))                                         (4-10)
307    /// q  = ½ [(1 + 3u²/E²) atan(E/u) − 3u/E]                                    (4-11)
308    /// q′ = 3 (1 + u²/E²) [1 − (u/E) atan(E/u)] − 1                              (4-13)
309    /// γ_u = −(1/w) [GM/(u² + E²) + ω²a²E/(u² + E²) (q′/q₀)(½ sin²β − 1/6)] + (1/w) ω² u cos²β
310    /// γ_β = (1/w) ω²a²/√(u² + E²) (q/q₀) sin β cos β − (1/w) ω² √(u² + E²) sin β cos β
311    /// ```
312    ///
313    /// Equation 4-8 is written in the algebraically equivalent form above, which has no
314    /// division by `s`.
315    ///
316    /// # Errors
317    ///
318    /// [`CoreError::Domain`] if the position is not finite or lies on the focal disc (`u = 0`:
319    /// the equatorial plane within `E`, about 522 km, of the center).
320    pub fn ecef_mps2(&self, position_ecef_m: DVec3) -> Result<DVec3, CoreError> {
321        let a = self.ellipsoid.semi_major_axis_m();
322        let big_e = self.ellipsoid.linear_eccentricity_m();
323        let e2_lin = big_e * big_e;
324        let DVec3 { x, y, z } = position_ecef_m;
325        let p = x.hypot(y);
326        let s = x * x + y * y + z * z - e2_lin;
327        let u2 = 0.5 * (s + (s * s + 4.0 * e2_lin * z * z).sqrt());
328        let u = u2.sqrt();
329        if !(u > 0.0 && u.is_finite() && p.is_finite()) {
330            return Err(CoreError::Domain {
331                what: "distance from the Earth's center for normal gravity (m)",
332                value: position_ecef_m.length(),
333            });
334        }
335        let big_u2 = u2 + e2_lin;
336        let big_u = big_u2.sqrt();
337        let beta = (z * big_u).atan2(u * p);
338        let (sin_b, cos_b) = beta.sin_cos();
339        let w = ((u2 + e2_lin * sin_b * sin_b) / big_u2).sqrt();
340        let (q, q_prime) = q_and_q_prime(big_e / u);
341        let omega2 = self.omega_rad_s * self.omega_rad_s;
342
343        let gamma_u = -(self.gm_m3_s2 / big_u2
344            + omega2 * a * a * big_e / big_u2
345                * (q_prime / self.q0)
346                * (0.5 * sin_b * sin_b - 1.0 / 6.0))
347            / w
348            + omega2 * u * cos_b * cos_b / w;
349        let gamma_beta =
350            (omega2 * a * a / big_u * (q / self.q0) - omega2 * big_u) * sin_b * cos_b / w;
351
352        // Longitude direction; arbitrary on the axis, where γ_β vanishes.
353        let (cos_l, sin_l) = if p > 0.0 { (x / p, y / p) } else { (1.0, 0.0) };
354        let c = u / (w * big_u);
355        let gamma = DVec3::new(
356            c * cos_b * cos_l * gamma_u - sin_b * cos_l / w * gamma_beta,
357            c * cos_b * sin_l * gamma_u - sin_b * sin_l / w * gamma_beta,
358            sin_b / w * gamma_u + c * cos_b * gamma_beta,
359        );
360        Ok(gamma)
361    }
362
363    /// The normal gravity vector at a geodetic position, resolved in that point's local
364    /// East-North-Up axes, m/s². `−z` is the exact normal component `γ_h` (eq. 4-16), `y` is
365    /// `γ_φ` (eq. 4-23, positive north) and `x` is zero up to rounding; the length is
366    /// `|γ_total|` (eq. 4-4).
367    ///
368    /// # Errors
369    ///
370    /// [`CoreError::Domain`] if `point` fails [`Geodetic::new`]'s checks, or as
371    /// [`NormalGravity::ecef_mps2`], which cannot fail for heights above −5800 km (the focal
372    /// disc lies 5856 km below the equator).
373    pub fn enu_at_mps2(&self, point: Geodetic) -> Result<DVec3, CoreError> {
374        let point = point.validated()?;
375        let gamma = self.ecef_mps2(self.ellipsoid.ecef_from_geodetic(point))?;
376        Ok(ecef_from_enu_rotation(point).transpose() * gamma)
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use serde::Deserialize;
383
384    use super::*;
385
386    #[derive(Deserialize)]
387    struct Fixture {
388        constants: Constants,
389        cases: Vec<Case>,
390    }
391
392    #[derive(Deserialize)]
393    struct Constants {
394        gamma_e_mps2: f64,
395        gamma_p_mps2: f64,
396        k: f64,
397        m: f64,
398    }
399
400    #[derive(Deserialize)]
401    struct Case {
402        latitude_deg: f64,
403        longitude_deg: f64,
404        height_m: f64,
405        surface_mps2: f64,
406        taylor_mps2: f64,
407        magnitude_mps2: f64,
408        down_mps2: f64,
409        north_mps2: f64,
410        ecef_mps2: [f64; 3],
411    }
412
413    /// Values of the published formulas at 40 digits, from
414    /// `validation/oracles/wgs84/normal_gravity.py`.
415    fn fixture() -> Fixture {
416        serde_json::from_str(include_str!(
417            "../../../validation/fixtures/earth/wgs84-normal-gravity.json"
418        ))
419        .unwrap()
420    }
421
422    fn relative_error(value: f64, reference: f64) -> f64 {
423        ((value - reference) / reference).abs()
424    }
425
426    fn point(case: &Case) -> Geodetic {
427        Geodetic::from_degrees(case.latitude_deg, case.longitude_deg, case.height_m).unwrap()
428    }
429
430    /// Loft lesson L1 and the M1.1 *done when*: WGS 84 Somigliana gravity with altitude matches
431    /// the published formula values at 11 latitude/height points to 1e-6 relative. The
432    /// implementation actually agrees to about 1e-13; the tighter bounds below pin that.
433    ///
434    /// References: `γ_e` and `γ_p` as printed in NGA.STND.0036 Table 3.6, and the published
435    /// formulas (eqs. 4-1, 4-3, 4-4, 4-16, 4-23) evaluated at 40 digits by an independent script
436    /// that also checks the vector against the gradient of the normal potential.
437    #[test]
438    fn somigliana_matches_published_values() {
439        let field = NormalGravity::wgs84();
440        let fixture = fixture();
441
442        // Table 3.6, to its printed digits.
443        assert!((field.equatorial_gravity_mps2() - 9.780_325_335_9).abs() < 5e-11);
444        assert!((field.polar_gravity_mps2() - 9.832_184_937_9).abs() < 5e-11);
445        assert!((field.somigliana_constant() - 1.931_852_652_458e-3).abs() < 5e-16);
446        assert!((field.m() - 3.449_786_506_841e-3).abs() < 5e-16);
447        // The 40-digit derivation.
448        let c = &fixture.constants;
449        assert!(relative_error(field.equatorial_gravity_mps2(), c.gamma_e_mps2) < 1e-14);
450        assert!(relative_error(field.polar_gravity_mps2(), c.gamma_p_mps2) < 1e-14);
451        assert!(relative_error(field.somigliana_constant(), c.k) < 1e-12);
452        assert!(relative_error(field.m(), c.m) < 1e-14);
453
454        assert!(fixture.cases.len() >= 6);
455        for case in &fixture.cases {
456            let at = format!(
457                "({}°, {}°, {} m)",
458                case.latitude_deg, case.longitude_deg, case.height_m
459            );
460            let g = point(case);
461            let surface = field.surface_mps2(g.latitude_rad).unwrap();
462            let taylor = field.taylor_mps2(g.latitude_rad, g.height_m).unwrap();
463            let enu = field.enu_at_mps2(g).unwrap();
464            let ecef = field
465                .ecef_mps2(field.ellipsoid().ecef_from_geodetic(g))
466                .unwrap();
467
468            // The done-when bound first, then the tighter implementation bound.
469            for (name, value, reference) in [
470                ("surface (4-1)", surface, case.surface_mps2),
471                ("Taylor (4-3)", taylor, case.taylor_mps2),
472                ("|γ| (4-4)", enu.length(), case.magnitude_mps2),
473                ("γ_h (4-16)", -enu.z, case.down_mps2),
474            ] {
475                let error = relative_error(value, reference);
476                assert!(error <= 1e-6, "{name} at {at}: {value} vs {reference}");
477                assert!(error <= 2e-14, "{name} at {at}: relative error {error:e}");
478            }
479            // The horizontal component is tiny, so compare it absolutely.
480            assert!(
481                (enu.y - case.north_mps2).abs() < 1e-12,
482                "γ_φ at {at}: {}",
483                enu.y
484            );
485            assert!(enu.x.abs() < 1e-12, "east component at {at}: {}", enu.x);
486            for (value, reference) in ecef.to_array().into_iter().zip(case.ecef_mps2) {
487                assert!(
488                    (value - reference).abs() < 2e-13,
489                    "ECEF at {at}: {value} vs {reference}"
490                );
491            }
492        }
493    }
494
495    /// The Taylor series agrees with RocketPy 1.13.0's formula, which uses the same equation with
496    /// Table 3.6 constants rounded to 13 digits (`validation/oracles/rocketpy/gravity.py`).
497    #[test]
498    fn taylor_series_matches_the_rocketpy_oracle() {
499        #[derive(Deserialize)]
500        struct Oracle {
501            cases: Vec<OracleCase>,
502        }
503        #[derive(Deserialize)]
504        struct OracleCase {
505            latitude_deg: f64,
506            height_m: f64,
507            formula_mps2: f64,
508        }
509        let oracle: Oracle = serde_json::from_str(include_str!(
510            "../../../validation/fixtures/earth/rocketpy-gravity.json"
511        ))
512        .unwrap();
513        let field = NormalGravity::wgs84();
514        assert!(oracle.cases.len() >= 6);
515        for case in &oracle.cases {
516            let value = field
517                .taylor_mps2(case.latitude_deg.to_radians(), case.height_m)
518                .unwrap();
519            let error = relative_error(value, case.formula_mps2);
520            assert!(
521                error < 1e-12,
522                "({}°, {} m): {value} vs {}",
523                case.latitude_deg,
524                case.height_m,
525                case.formula_mps2
526            );
527        }
528    }
529
530    /// On the ellipsoid the exact field reduces to Somigliana's formula and points straight down.
531    #[test]
532    fn exact_field_reduces_to_somigliana_on_the_ellipsoid() {
533        let field = NormalGravity::wgs84();
534        for k in -18..=18 {
535            let lat = f64::from(k) * 5.0;
536            let g = Geodetic::from_degrees(lat, 17.0 * f64::from(k), 0.0).unwrap();
537            let enu = field.enu_at_mps2(g).unwrap();
538            let surface = field.surface_mps2(g.latitude_rad).unwrap();
539            assert!(relative_error(-enu.z, surface) < 1e-14, "lat {lat}");
540            assert!(enu.truncate().length() < 1e-12, "lat {lat}: {enu}");
541        }
542    }
543
544    /// The series and closed forms of `q` and `q′` agree where they meet, and the series
545    /// reproduces the printed values of `q₀` and `q₀′` (NGA.STND.0036 eqs. B-18 and B-19).
546    #[test]
547    fn q_functions_match_appendix_b() {
548        let ellipsoid = Ellipsoid::WGS84;
549        let ep = ellipsoid.linear_eccentricity_m() / ellipsoid.semi_minor_axis_m();
550        let (q0, q0_prime) = q_and_q_prime(ep);
551        assert!((q0 - 7.334_625_787_083e-5).abs() < 5e-18, "q0 = {q0:e}");
552        assert!(
553            (q0_prime - 2.688_041_300_461e-3).abs() < 5e-16,
554            "q0' = {q0_prime:e}"
555        );
556        let (below, below_prime) = q_and_q_prime(0.5 - 1e-15);
557        let (above, above_prime) = q_and_q_prime(0.5);
558        assert!(relative_error(below, above) < 1e-13);
559        assert!(relative_error(below_prime, above_prime) < 1e-13);
560    }
561
562    #[test]
563    fn rejects_invalid_fields_and_positions() {
564        let sphere = Ellipsoid::new(6.371e6, f64::INFINITY).unwrap();
565        assert!(NormalGravity::new(sphere, WGS84_GM_M3_S2, 0.0).is_err());
566        assert!(NormalGravity::new(Ellipsoid::WGS84, -1.0, 0.0).is_err());
567        assert!(NormalGravity::new(Ellipsoid::WGS84, WGS84_GM_M3_S2, f64::NAN).is_err());
568        // q₀ underflows for a vanishing flattening; a huge ellipsoid overflows.
569        let nearly_round = Ellipsoid::new(6.4e6, 1e300).unwrap();
570        assert!(NormalGravity::new(nearly_round, WGS84_GM_M3_S2, 7e-5).is_err());
571        let huge = Ellipsoid::new(1e300, 298.0).unwrap();
572        assert!(NormalGravity::new(huge, WGS84_GM_M3_S2, 7e-5).is_err());
573        // Spinning faster than orbital speed at the equator.
574        assert!(NormalGravity::new(Ellipsoid::WGS84, WGS84_GM_M3_S2, 2e-3).is_err());
575        assert_eq!(
576            NormalGravity::new(
577                Ellipsoid::WGS84,
578                WGS84_GM_M3_S2,
579                WGS84_ANGULAR_VELOCITY_RAD_S
580            ),
581            Ok(NormalGravity::wgs84())
582        );
583        let field = NormalGravity::wgs84();
584        // Degrees passed as radians.
585        assert!(field.surface_mps2(32.99).is_err());
586        assert!(field.taylor_mps2(32.99, 1400.0).is_err());
587        assert!(field.taylor_mps2(0.5, f64::NAN).is_err());
588        let degrees = Geodetic {
589            latitude_rad: 32.99,
590            longitude_rad: -106.97,
591            height_m: 1400.0,
592        };
593        assert!(field.enu_at_mps2(degrees).is_err());
594        assert!(
595            field
596                .enu_at_mps2(Geodetic::from_degrees(0.0, 0.0, -5.7e6).unwrap())
597                .is_ok()
598        );
599        assert!(
600            field
601                .enu_at_mps2(Geodetic::from_degrees(0.0, 0.0, -5.9e6).unwrap())
602                .is_err()
603        );
604        assert!(field.ecef_mps2(DVec3::ZERO).is_err());
605        assert!(field.ecef_mps2(DVec3::new(1.0e5, 0.0, 0.0)).is_err());
606        assert!(
607            field
608                .ecef_mps2(DVec3::new(f64::INFINITY, 0.0, 0.0))
609                .is_err()
610        );
611    }
612
613    #[test]
614    fn serde_round_trip_keeps_the_defining_parameters() {
615        let field = NormalGravity::wgs84();
616        let json = serde_json::to_string(&field).unwrap();
617        let back: NormalGravity = serde_json::from_str(&json).unwrap();
618        assert_eq!(back, field);
619    }
620}