Files
nrlmsise00/tests/standard_cases.rs
T

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);
}
}