1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
// SPDX-License-Identifier: AGPL-3.0-only
//! Planetary gravity fields from the **PDS SHADR** spherical-harmonic tables: the published
//! high-degree Mars (GMM-3, degree/order 120) and Moon (GRAIL GL0660B, degree/order 660) fields,
//! and any other body whose field the Planetary Data System (PDS) distributes in that format.
//!
//! The SHADR ("spherical harmonic ASCII data record") product is a comma-separated ASCII table:
//!
//! * one **header record**: reference radius (km), GM (km³/s²), the uncertainty of GM, the degree
//! and order of the field, the normalization state (`1` = fully normalized), and the reference
//! longitude and latitude of the expansion (both normally 0);
//! * one **coefficient record** per `(n, m)`: `n, m, C̄_nm, S̄_nm, σC̄_nm, σS̄_nm`.
//!
//! [`SphericalHarmonicField::from_shadr`] reads it into the crate's
//! [`SphericalHarmonicField`], converting km → m and km³/s² → m³/s², and sets `C̄00 = 1` when the
//! table carries no degree-0 record (PDS gravity tables start at degree 1; the central term is
//! the header's GM). Records are fixed-length with blank padding and CR/LF terminators; both
//! are tolerated, as are Fortran `D` exponents.
//!
//! [`Body::with_gravity_field`] attaches any such field to a [`Body`] (opt-in; the default bodies
//! are unchanged), so the propagator's central-gravity path evaluates it in the body-fixed frame.
//!
//! ## Validation
//!
//! The reader and the existing evaluator ([`SphericalHarmonicField::acceleration`]) are compared
//! with an independent implementation, pyshtools, which reads the same SHADR files itself, at
//! pre-registered points from low orbit to areostationary radius and up to 0.1° from the poles
//! (`xval/planetary-gravity-pyshtools/`). The committed degree-20 crops of GMM-3 and GL0660B are
//! checked in CI; the full-degree files are checked when `KSHANA_GMM3_SHA` / `KSHANA_GRAIL_SHA`
//! point at them. The parsed GM, radius, coefficient count and selected coefficients are checked
//! against the values printed in the PDS products.
//!
//! ## Scope (honest)
//!
//! * Only fully normalized tables (normalization state 1) with a zero reference longitude and
//! latitude are accepted; anything else is an error, not a silent mis-read.
//! * The uncertainty columns are read past, not stored.
//! * **Frames.** A field is defined in its producer's body-fixed frame: GMM-3 in the Konopliv et
//! al. (2006) Mars frame (the PDS label stresses it is *not* the IAU system), GL0660B in the
//! DE421 lunar principal-axis frame. The crate's body-fixed rotation for a [`Body`] is its IAU
//! pole and prime meridian ([`crate::mars_frame`]), so a field attached with
//! [`Body::with_gravity_field`] is evaluated in the IAU frame; the frame offset is not
//! modelled here.
//! * **Degree limit of the evaluator.** [`SphericalHarmonicField::acceleration`] uses an unscaled
//! sectoral recurrence (`P̄_mm ∝ cos^m φ`), which underflows double precision when `cos^n φ`
//! falls below ~1e-308: for degree 660 above about 70° latitude. The pre-registered degree-660
//! GL0660B comparison covers |latitude| ≤ 60° (worst relative error 5.0e-14 of the total,
//! 1.0e-10 of the non-central part). Degree 120 (GMM-3) was compared over the whole sphere,
//! including 0.1° from the pole (worst 3.7e-15 and 6.3e-12). Results are in
//! `xval/planetary-gravity-pyshtools/RESULTS.md`.
use crate::body::Body;
use crate::gravity_sh::SphericalHarmonicField;
/// The header record of a PDS SHADR table, in the units the table states (km, km³/s², degrees).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ShadrHeader {
/// Reference radius of the expansion (km).
pub reference_radius_km: f64,
/// Gravitational parameter GM (km³/s²).
pub gm_km3_s2: f64,
/// Uncertainty of GM (km³/s²) as the table states it.
pub gm_sigma_km3_s2: f64,
/// Degree of the field.
pub degree: usize,
/// Order of the field.
pub order: usize,
/// Normalization state: 0 unnormalized, 1 fully normalized, 2 other.
pub normalization: u32,
/// Reference longitude of the expansion (degrees east); normally 0.
pub reference_lon_deg: f64,
/// Reference latitude of the expansion (degrees); normally 0.
pub reference_lat_deg: f64,
}
/// Parse a SHADR real field (`E` or Fortran `D` exponent).
fn real(t: &str) -> Result<f64, String> {
let t = t.trim();
t.replace(['d', 'D'], "e")
.parse::<f64>()
.map_err(|_| format!("bad real '{t}'"))
}
/// Parse a SHADR integer field (tolerating a real written for an integer, such as `120.0`).
fn int(t: &str) -> Result<i64, String> {
let t = t.trim();
if let Ok(v) = t.parse::<i64>() {
return Ok(v);
}
let v = real(t)?;
if v.fract() == 0.0 && v.abs() < 1e9 {
Ok(v as i64)
} else {
Err(format!("bad integer '{t}'"))
}
}
/// The non-blank records of a SHADR table, with padding and CR stripped.
fn records(text: &str) -> impl Iterator<Item = &str> {
text.lines()
.map(|l| l.trim_end_matches(['\r', ' ', '\t']))
.filter(|l| !l.trim().is_empty())
}
impl ShadrHeader {
/// Parse the header (first non-blank) record of a SHADR table.
pub fn parse(text: &str) -> Result<Self, String> {
let line = records(text).next().ok_or("empty SHADR table")?;
let f: Vec<&str> = line.split(',').collect();
if f.len() < 6 {
return Err(format!(
"SHADR header has {} fields, expected at least 6",
f.len()
));
}
let deg = int(f[3])?;
let ord = int(f[4])?;
let norm = int(f[5])?;
if deg < 0 || ord < 0 || !(0..=2).contains(&norm) {
return Err("SHADR header: negative degree/order or unknown normalization".into());
}
Ok(Self {
reference_radius_km: real(f[0])?,
gm_km3_s2: real(f[1])?,
gm_sigma_km3_s2: real(f[2])?,
degree: deg as usize,
order: ord as usize,
normalization: norm as u32,
reference_lon_deg: f.get(6).map(|t| real(t)).transpose()?.unwrap_or(0.0),
reference_lat_deg: f.get(7).map(|t| real(t)).transpose()?.unwrap_or(0.0),
})
}
}
impl SphericalHarmonicField {
/// Parse a fully-normalized **PDS SHADR** gravity table (e.g. GMM-3 `gmm3_120_sha.tab`,
/// GRAIL GL0660B `jggrx_0660b_sha.tab`) to degree/order `nmax`.
///
/// GM and the reference radius come from the header record and are converted to SI
/// (`× 1e9`, `× 1e3`). `C̄00` is set to 1 unless the table carries its own degree-0 record.
/// The resulting field's degree is the highest degree read, capped at `nmax`. Errors: a
/// header that is not normalization state 1, a non-zero reference longitude/latitude, a
/// non-physical GM or radius, a malformed record, an order above its degree, or a missing
/// coefficient for some `2 ≤ n ≤ degree`, `0 ≤ m ≤ n` (a truncated download).
pub fn from_shadr(text: &str, nmax: usize) -> Result<Self, String> {
let h = ShadrHeader::parse(text)?;
if h.normalization != 1 {
return Err(format!(
"SHADR normalization state {} (only fully normalized, state 1, is supported)",
h.normalization
));
}
if h.reference_lon_deg != 0.0 || h.reference_lat_deg != 0.0 {
return Err("SHADR reference longitude/latitude must be 0".into());
}
let gm = h.gm_km3_s2 * 1.0e9;
let re = h.reference_radius_km * 1.0e3;
if !(gm.is_finite() && gm > 0.0) {
return Err("non-physical GM (must be finite and positive)".into());
}
if !(re.is_finite() && re > 0.0) {
return Err("non-physical reference radius (must be finite and positive)".into());
}
let cap = nmax.min(h.degree);
let mut rows: Vec<(usize, usize, f64, f64)> = Vec::new();
let mut max_n = 0usize;
let mut have_c00 = false;
for (k, line) in records(text).enumerate().skip(1) {
let f: Vec<&str> = line.split(',').collect();
if f.len() < 4 {
return Err(format!("SHADR record {}: {} fields", k + 1, f.len()));
}
let n = int(f[0])?;
let m = int(f[1])?;
if n < 0 || m < 0 || m > n {
return Err(format!("SHADR record {}: bad (n, m) = ({n}, {m})", k + 1));
}
let (n, m) = (n as usize, m as usize);
if n > cap {
continue;
}
let c = real(f[2])?;
let s = real(f[3])?;
if !(c.is_finite() && s.is_finite()) {
return Err(format!("SHADR record {}: non-finite coefficient", k + 1));
}
if n == 0 {
have_c00 = true;
}
max_n = max_n.max(n);
rows.push((n, m, c, s));
}
if rows.is_empty() {
return Err("no SHADR coefficient records".into());
}
// Completeness: every (n, m) with 2 ≤ n ≤ max_n must be present.
let mut seen = vec![vec![false; max_n + 1]; max_n + 1];
for &(n, m, _, _) in &rows {
seen[n][m] = true;
}
for (n, row) in seen.iter().enumerate().skip(2) {
if let Some(m) = row[..=n].iter().position(|&b| !b) {
return Err(format!(
"SHADR table incomplete: missing (n, m) = ({n}, {m})"
));
}
}
let mut f = Self::zeros(gm, re, max_n);
if !have_c00 {
f.set(0, 0, 1.0, 0.0);
}
for (n, m, c, s) in rows {
f.set(n, m, c, s);
}
Ok(f)
}
}
impl Body {
/// This body with a full spherical-harmonic gravity field attached in its
/// [`gravity`](Body::gravity) slot (opt-in; replaces any field already there). The
/// propagator's central-gravity path then evaluates the field in the body-fixed frame
/// ([`crate::mars_frame`], the body's IAU pole and prime meridian) using the field's own GM
/// and reference radius; the body's `mu`, `re` and `zonals` are left as they are for every
/// other use. Typical use: `Body::mars().with_gravity_field(SphericalHarmonicField::from_shadr(
/// &gmm3_text, 120)?)`. See the module notes on the frame each published field is defined in.
pub fn with_gravity_field(mut self, field: SphericalHarmonicField) -> Self {
self.gravity = Some(field);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
const HDR: &str = " 0.1738000000000000E+04, 0.4902800305555400E+04, 0.1096570000000000E-04, 2, 2, 1, 0.0000000000000000E+00, 0.0000000000000000E+00 \r\n";
fn table(rows: &str) -> String {
format!("{HDR}{rows}")
}
const ROWS2: &str = " 1, 0, 0.0E+00, 0.0E+00, 0.0E+00, 0.0E+00\r\n 1, 1, 0.0E+00, 0.0E+00, 0.0E+00, 0.0E+00\r\n 2, 0,-0.9088083466222001E-04, 0.0E+00, 0.1E-10, 0.0E+00\r\n 2, 1, 0.1D-09, 0.2D-08, 0.0E+00, 0.0E+00\r\n 2, 2, 0.3467379822995000E-04,-0.2489004351630000E-09, 0.0E+00, 0.0E+00 \r\n";
#[test]
#[allow(clippy::excessive_precision)] // literals as published
fn header_and_units() {
let h = ShadrHeader::parse(HDR).unwrap();
assert_eq!(h.degree, 2);
assert_eq!(h.normalization, 1);
let f = SphericalHarmonicField::from_shadr(&table(ROWS2), 10).unwrap();
assert_eq!(f.gm, 4.902_800_305_555_4e3 * 1e9);
assert_eq!(f.re, 1_738_000.0);
assert_eq!(f.nmax, 2);
assert_eq!(f.cbar(0, 0), Some(1.0), "C00 defaults to 1");
assert_eq!(f.cbar(2, 0), Some(-0.908_808_346_622_200_1e-4));
assert_eq!(f.sbar(2, 1), Some(0.2e-8), "Fortran D exponent");
assert_eq!(f.sbar(2, 2), Some(-0.248_900_435_163e-9));
}
#[test]
fn truncation_and_explicit_c00() {
let f = SphericalHarmonicField::from_shadr(&table(ROWS2), 1).unwrap();
assert_eq!(f.nmax, 1);
let t = table(&format!(" 0, 0, 0.5E+00, 0.0E+00\n{ROWS2}"));
let f = SphericalHarmonicField::from_shadr(&t, 2).unwrap();
assert_eq!(f.cbar(0, 0), Some(0.5), "an explicit C00 record is kept");
}
#[test]
fn point_mass_table_is_exact_two_body() {
let t = table(" 1, 0, 0.0, 0.0\n 1, 1, 0.0, 0.0\n");
let f = SphericalHarmonicField::from_shadr(&t, 2).unwrap();
let r = [2.0e6, 0.0, 0.0];
let a = f.acceleration(r);
let want = -f.gm / 4.0e12;
assert!((a[0] - want).abs() <= 1e-15 * want.abs());
}
#[test]
fn rejects_malformed_tables() {
let bad_norm = HDR.replacen(", 1, 0.0", ", 0, 0.0", 1);
assert!(SphericalHarmonicField::from_shadr(&format!("{bad_norm}{ROWS2}"), 2).is_err());
let bad_ref = HDR.replacen("0.0000000000000000E+00 ", "1.0E+00", 1);
assert!(SphericalHarmonicField::from_shadr(&format!("{bad_ref}{ROWS2}"), 2).is_err());
let neg_gm = HDR.replacen(" 0.4902800305555400E+04", "-0.4902800305555400E+04", 1);
assert!(SphericalHarmonicField::from_shadr(&format!("{neg_gm}{ROWS2}"), 2).is_err());
// Missing (2, 1): a truncated table is an error, not zeros.
let missing = ROWS2.replace(" 2, 1, 0.1D-09, 0.2D-08, 0.0E+00, 0.0E+00\r\n", "");
let e = SphericalHarmonicField::from_shadr(&table(&missing), 2).unwrap_err();
assert!(e.contains("(2, 1)"), "{e}");
assert!(SphericalHarmonicField::from_shadr(&table(" 2, 3, 0.0, 0.0\n"), 2).is_err());
assert!(SphericalHarmonicField::from_shadr(&table(" 2, 0, x, 0.0\n"), 2).is_err());
assert!(
SphericalHarmonicField::from_shadr(HDR, 2).is_err(),
"no records"
);
assert!(SphericalHarmonicField::from_shadr("", 2).is_err());
}
#[test]
fn with_gravity_field_is_opt_in() {
let f = SphericalHarmonicField::from_shadr(&table(ROWS2), 2).unwrap();
assert!(Body::moon().gravity.is_none(), "default Moon unchanged");
let b = Body::moon().with_gravity_field(f);
assert_eq!(b.gravity.as_ref().map(|g| g.nmax), Some(2));
assert_eq!(b.mu, Body::moon().mu, "body constants untouched");
}
}