232 lines
8.2 KiB
Rust
232 lines
8.2 KiB
Rust
//! The seventeen standard NRLMSISE-00 cases.
|
|
//!
|
|
//! The reference distribution's own test cases, which vary one input at a time from a
|
|
//! common baseline and between them reach from the surface to 1000 km, the equator, local
|
|
//! midnight, low and high solar flux, a geomagnetic storm, and the two cases that use the
|
|
//! seven-element ap history rather than a daily value.
|
|
//!
|
|
//! The fixture holds the reference's outputs at seventeen significant digits, so these are
|
|
//! compared bit for bit. An approximate comparison would not catch a term dropped from an
|
|
//! expansion whose contribution happens to be small at the point being sampled, and the
|
|
//! whole reason to have a port rather than a rewrite is that it agrees exactly.
|
|
|
|
use nrlmsise00::{Input, MagneticActivity, Model, Switches, Units};
|
|
use serde_json::Value;
|
|
|
|
/// The fixture's cases.
|
|
fn cases() -> Vec<Value> {
|
|
let path = concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/tests/fixtures/nrlmsise00-cases.json"
|
|
);
|
|
let text = std::fs::read_to_string(path).expect("the fixture is committed");
|
|
let parsed: Value = serde_json::from_str(&text).expect("the fixture is JSON");
|
|
parsed["cases"]
|
|
.as_array()
|
|
.expect("an array of cases")
|
|
.clone()
|
|
}
|
|
|
|
/// A fixture number, parsed from its own digits rather than by `serde_json`'s float
|
|
/// parser, which reads some values back one ulp high.
|
|
fn number(case: &Value, field: &str) -> f64 {
|
|
case[field]
|
|
.as_number()
|
|
.unwrap_or_else(|| panic!("{field} missing"))
|
|
.as_str()
|
|
.parse()
|
|
.expect("an f64")
|
|
}
|
|
|
|
fn numbers(case: &Value, field: &str) -> Vec<f64> {
|
|
case[field]
|
|
.as_array()
|
|
.unwrap_or_else(|| panic!("{field} missing"))
|
|
.iter()
|
|
.map(|v| {
|
|
v.as_number()
|
|
.expect("a number")
|
|
.as_str()
|
|
.parse()
|
|
.expect("an f64")
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn input_of(case: &Value) -> Input<f64> {
|
|
let magnetic = if case["ap_history"].is_null() {
|
|
MagneticActivity::Daily {
|
|
ap: number(case, "ap_daily"),
|
|
}
|
|
} else {
|
|
let history = numbers(case, "ap_history");
|
|
let mut ap = [0.0; 7];
|
|
ap.copy_from_slice(&history);
|
|
MagneticActivity::History { ap }
|
|
};
|
|
Input {
|
|
day_of_year: number(case, "day_of_year") as i32,
|
|
seconds_of_day: number(case, "seconds_of_day"),
|
|
altitude_km: number(case, "altitude_km"),
|
|
latitude_deg: number(case, "latitude_deg"),
|
|
longitude_deg: number(case, "longitude_deg"),
|
|
local_solar_time_h: number(case, "local_solar_time_h"),
|
|
f107_average: number(case, "f107_average"),
|
|
f107: number(case, "f107"),
|
|
magnetic,
|
|
}
|
|
}
|
|
|
|
#[track_caller]
|
|
fn assert_bits(got: f64, want: f64, what: &str) {
|
|
if got.to_bits() == want.to_bits() {
|
|
return;
|
|
}
|
|
let ulps = (got.to_bits() as i64 - want.to_bits() as i64).abs();
|
|
panic!(
|
|
"{what}: got {got:.17e}, reference {want:.17e} ({ulps} ulp, relative {:e})",
|
|
(got - want).abs() / want.abs().max(f64::MIN_POSITIVE)
|
|
);
|
|
}
|
|
|
|
/// The reference's own units, since that is what the fixture is quoted in.
|
|
fn reference_model() -> Model {
|
|
Model::with_switches(Switches::reference())
|
|
}
|
|
|
|
#[test]
|
|
fn test_the_seventeen_standard_cases_match_the_reference() {
|
|
let cases = cases();
|
|
assert_eq!(cases.len(), 17, "the fixture should hold seventeen cases");
|
|
let model = reference_model();
|
|
let names = [
|
|
"He",
|
|
"O",
|
|
"N2",
|
|
"O2",
|
|
"Ar",
|
|
"total mass",
|
|
"H",
|
|
"N",
|
|
"anomalous O",
|
|
];
|
|
|
|
for case in cases {
|
|
let index = case["case"].as_u64().expect("a case number");
|
|
let output = model.gtd7(&input_of(&case));
|
|
let want = numbers(&case, "densities");
|
|
let at = format!("case {index} at {} km", number(&case, "altitude_km"));
|
|
|
|
for ((got, want), name) in output.densities().iter().zip(&want).zip(names) {
|
|
assert_bits(*got, *want, &format!("{at}: {name}"));
|
|
}
|
|
let temperatures = numbers(&case, "temperatures");
|
|
assert_bits(
|
|
output.exospheric_temperature,
|
|
temperatures[0],
|
|
&format!("{at}: exospheric temperature"),
|
|
);
|
|
assert_bits(
|
|
output.temperature,
|
|
temperatures[1],
|
|
&format!("{at}: temperature"),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `gtd7d` differs from `gtd7` in the total mass density alone, by exactly the mass of the
|
|
/// anomalous oxygen the fixture gives.
|
|
#[test]
|
|
fn test_gtd7d_adds_anomalous_oxygen_to_the_total() {
|
|
let model = reference_model();
|
|
for case in cases() {
|
|
let input = input_of(&case);
|
|
let plain = model.gtd7(&input);
|
|
let for_drag = model.gtd7d(&input);
|
|
let densities = numbers(&case, "densities");
|
|
let anomalous = densities[8];
|
|
|
|
// The reference's own expression for the drag total, from the fixture's densities.
|
|
let want = 1.66e-24
|
|
* (4.0 * densities[0]
|
|
+ 16.0 * densities[1]
|
|
+ 28.0 * densities[2]
|
|
+ 32.0 * densities[3]
|
|
+ 40.0 * densities[4]
|
|
+ densities[6]
|
|
+ 14.0 * densities[7]
|
|
+ 16.0 * anomalous);
|
|
assert_bits(
|
|
for_drag.total_mass,
|
|
want,
|
|
&format!("case {} drag total", case["case"]),
|
|
);
|
|
|
|
// Everything else is untouched.
|
|
assert_eq!(plain.densities()[..5], for_drag.densities()[..5]);
|
|
assert_eq!(plain.densities()[6..], for_drag.densities()[6..]);
|
|
assert_eq!(plain.temperature, for_drag.temperature);
|
|
|
|
// Adding a species can only add mass, but it does not always change the number:
|
|
// at 100 km the anomalous oxygen is around 1e-42 cm^-3, and sixteen nucleons of
|
|
// that is far below one ulp of a total of 3.6e-10 g/cm^3.
|
|
assert!(
|
|
for_drag.total_mass >= plain.total_mass,
|
|
"case {}: the drag total cannot be the smaller",
|
|
case["case"]
|
|
);
|
|
let share = (for_drag.total_mass - plain.total_mass) / for_drag.total_mass;
|
|
if number(&case, "altitude_km") >= 1000.0 {
|
|
// This is why the distinction is worth a separate entry point: at 1000 km
|
|
// anomalous oxygen is about a fifth of what a satellite runs into, and a drag
|
|
// calculation that used `gtd7` would be short by that much.
|
|
assert!(
|
|
(0.15..0.25).contains(&share),
|
|
"case {}: anomalous oxygen is {:.1}% of the drag mass at 1000 km",
|
|
case["case"],
|
|
share * 100.0
|
|
);
|
|
} else if number(&case, "altitude_km") >= 400.0 {
|
|
assert!(share < 0.001, "{share} at 400 km");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// SI is the model's own metric switch, so the two unit systems differ by exactly the
|
|
/// factors that switch applies.
|
|
#[test]
|
|
fn test_si_is_the_models_own_metric_switch() {
|
|
let native = reference_model();
|
|
let si = Model::new();
|
|
assert_eq!(si.switches().units, Units::Si);
|
|
|
|
for case in cases() {
|
|
let input = input_of(&case);
|
|
let in_native = native.gtd7(&input);
|
|
let in_si = si.gtd7(&input);
|
|
for (index, (native, si)) in in_native
|
|
.densities()
|
|
.iter()
|
|
.zip(in_si.densities().iter())
|
|
.enumerate()
|
|
{
|
|
let want = if index == 5 {
|
|
native * 1.0e6 / 1000.0
|
|
} else {
|
|
native * 1.0e6
|
|
};
|
|
if number(&case, "altitude_km") >= 72.5 {
|
|
assert_bits(*si, want, &format!("density {index} in SI"));
|
|
} else {
|
|
// Below 72.5 km the conversion is not applied to a finished answer: the
|
|
// mixed nitrogen density is converted first and every other species is
|
|
// scaled from it, so the factor lands earlier in the arithmetic and the
|
|
// last bits need not agree. Four ulp is the observed spread.
|
|
let ulps = (si.to_bits() as i64 - want.to_bits() as i64).abs();
|
|
assert!(ulps <= 4, "density {index} in SI is {ulps} ulp from native");
|
|
}
|
|
}
|
|
assert_eq!(in_native.temperature, in_si.temperature);
|
|
}
|
|
}
|