1use glam::DVec3;
13use serde::{Deserialize, Serialize};
14
15use crate::error::CoreError;
16use crate::geodesy::{Ellipsoid, Geodetic, ecef_from_enu_rotation};
17
18pub const STANDARD_GRAVITY_MPS2: f64 = 9.806_65;
22
23pub const WGS84_GM_M3_S2: f64 = 3.986_004_418e14;
25
26pub const WGS84_ANGULAR_VELOCITY_RAD_S: f64 = 7.292_115e-5;
28
29fn 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 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
73fn 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#[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 q0: f64,
94 m: f64,
96 gamma_e: f64,
98 gamma_p: f64,
100 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 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 #[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 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 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; let ratio = m * ep * q0_prime / q0;
195 let gamma_e = gm / (a * b) * (1.0 - m - ratio / 6.0); let gamma_p = gm / (a * a) * (1.0 + ratio / 3.0); let k = (b * gamma_p - a * gamma_e) / (a * gamma_e); 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 #[must_use]
212 pub fn ellipsoid(&self) -> Ellipsoid {
213 self.ellipsoid
214 }
215
216 #[must_use]
218 pub fn gm_m3_s2(&self) -> f64 {
219 self.gm_m3_s2
220 }
221
222 #[must_use]
224 pub fn angular_velocity_rad_s(&self) -> f64 {
225 self.omega_rad_s
226 }
227
228 #[must_use]
230 pub fn equatorial_gravity_mps2(&self) -> f64 {
231 self.gamma_e
232 }
233
234 #[must_use]
236 pub fn polar_gravity_mps2(&self) -> f64 {
237 self.gamma_p
238 }
239
240 #[must_use]
242 pub fn somigliana_constant(&self) -> f64 {
243 self.k
244 }
245
246 #[must_use]
248 pub fn m(&self) -> f64 {
249 self.m
250 }
251
252 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 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 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 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 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 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 #[test]
438 fn somigliana_matches_published_values() {
439 let field = NormalGravity::wgs84();
440 let fixture = fixture();
441
442 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 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 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 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 #[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 #[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 #[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 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 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 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}