Files
nrlmsise00/tests/grid.rs
T

312 lines
11 KiB
Rust

//! The model over a wide sweep of its inputs.
//!
//! The seventeen standard cases vary one input at a time from one baseline, which leaves
//! most of the input space untouched. This fixture sweeps fifty altitudes — dense through
//! the lower thermosphere, and either side of every altitude at which the model is
//! discontinuous — against sixty combinations of day, time, position, flux and magnetic
//! activity, under eight switch settings.
//!
//! Every case is compared bit for bit. That is what makes a later performance change safe
//! to make: three thousand exact comparisons will notice a reordered expression, which a
//! test to a few significant figures would not.
use nrlmsise00::{Input, MagneticActivity, Model, Switch, Switches, Units};
use std::collections::HashMap;
/// One row of the fixture.
struct Case {
switches: Switches,
input: Input<f64>,
densities: [f64; 9],
exospheric_temperature: f64,
temperature: f64,
}
/// Parses the fixture. The columns are named in its header, so the order is read from
/// there rather than assumed.
fn cases() -> Vec<Case> {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/grid-cases.csv");
let text = std::fs::read_to_string(path).expect("the fixture is committed");
let mut lines = text.lines().filter(|line| !line.starts_with('#'));
let header: Vec<&str> = lines.next().expect("a header").split(',').collect();
let column: HashMap<&str, usize> = header
.iter()
.enumerate()
.map(|(index, name)| (*name, index))
.collect();
lines
.map(|line| {
let fields: Vec<&str> = line.split(',').collect();
assert_eq!(fields.len(), header.len(), "ragged row: {line}");
let at = |name: &str| -> f64 {
fields[column[name]]
.parse()
.unwrap_or_else(|e| panic!("{name} is {}: {e}", fields[column[name]]))
};
let raw: Vec<char> = fields[column["switches"]].chars().collect();
assert_eq!(raw.len(), 24, "the switch string should hold twenty-four");
Case {
switches: switches_of(&raw),
input: Input {
day_of_year: at("day_of_year") as i32,
seconds_of_day: at("seconds_of_day"),
altitude_km: at("altitude_km"),
latitude_deg: at("latitude_deg"),
longitude_deg: at("longitude_deg"),
local_solar_time_h: at("local_solar_time_h"),
f107_average: at("f107_average"),
f107: at("f107"),
magnetic: magnetic_of(&raw, &at),
},
densities: [
at("he"),
at("o"),
at("n2"),
at("o2"),
at("ar"),
at("total_mass"),
at("h"),
at("n"),
at("anomalous_o"),
],
exospheric_temperature: at("exospheric_temperature"),
temperature: at("temperature"),
}
})
.collect()
}
/// The reference's raw switch array, as the model's own type.
fn switches_of(raw: &[char]) -> Switches {
let at = |index: usize| match raw[index] {
'0' => Switch::Off,
'1' => Switch::On,
'2' => Switch::CrossTermsOnly,
other => panic!("switch {index} is {other}"),
};
Switches {
// The fixture is generated at the reference's standard switch 0, so native units.
units: Units::Native,
f107_mean: at(1),
time_independent: at(2),
symmetrical_annual: at(3),
symmetrical_semiannual: at(4),
asymmetrical_annual: at(5),
asymmetrical_semiannual: at(6),
diurnal: at(7),
semidiurnal: at(8),
ut_and_longitude: at(10),
longitudinal: at(11),
ut_and_mixed_ut_longitude: at(12),
mixed_ap_ut_longitude: at(13),
terdiurnal: at(14),
departures_from_diffusive_equilibrium: at(15),
exospheric_temperature: at(16),
boundary_temperature: at(17),
lower_thermosphere_temperature: at(18),
temperature_gradient: at(19),
mesosphere_temperature: at(20),
boundary_density: at(21),
troposphere_temperature: at(22),
turbopause_scale_height: at(23),
}
}
/// Switch 9 and the ap values it selects. The fixture writes `h` for the history mode.
fn magnetic_of(raw: &[char], at: &dyn Fn(&str) -> f64) -> MagneticActivity<f64> {
match raw[9] {
'0' => MagneticActivity::Off {
daily_ap: at("ap_daily"),
},
'1' => MagneticActivity::Daily { ap: at("ap_daily") },
'h' => MagneticActivity::History {
ap: [
at("ap1"),
at("ap2"),
at("ap3"),
at("ap4"),
at("ap5"),
at("ap6"),
at("ap7"),
],
},
other => panic!("switch 9 is {other}"),
}
}
#[test]
fn test_the_grid_matches_the_reference() {
let cases = cases();
assert_eq!(cases.len(), 2880, "unexpected fixture size");
let names = [
"He",
"O",
"N2",
"O2",
"Ar",
"total mass",
"H",
"N",
"anomalous O",
];
// One model per switch setting rather than one per case; there are eight of them
// among three thousand cases.
let mut model = Model::with_switches(cases[0].switches);
let mut mismatches = Vec::new();
for (index, case) in cases.iter().enumerate() {
if model.switches() != case.switches {
model = Model::with_switches(case.switches);
}
let output = model.gtd7(&case.input);
for ((got, want), name) in output
.densities()
.iter()
.zip(case.densities.iter())
.zip(names)
{
if got.to_bits() != want.to_bits() {
mismatches.push(format!(
"row {index} at {} km: {name} is {got:.17e}, reference {want:.17e} \
({} ulp)",
case.input.altitude_km,
(got.to_bits() as i64 - want.to_bits() as i64).abs()
));
}
}
for (got, want, what) in [
(
output.exospheric_temperature,
case.exospheric_temperature,
"exospheric temperature",
),
(output.temperature, case.temperature, "temperature"),
] {
if got.to_bits() != want.to_bits() {
mismatches.push(format!(
"row {index} at {} km: {what} is {got:.17e}, reference {want:.17e}",
case.input.altitude_km
));
}
}
}
assert!(
mismatches.is_empty(),
"{} of {} rows disagree with the reference:\n{}",
mismatches.len(),
cases.len() * 11,
mismatches
.iter()
.take(20)
.cloned()
.collect::<Vec<_>>()
.join("\n")
);
}
/// The physical checks, which the fixture cannot make for itself: a wrong model that
/// agreed with a wrong reference would pass everything above and fail these.
#[test]
fn test_the_atmosphere_behaves_the_way_it_should() {
let model = Model::new();
let at = |altitude_km: f64, local_solar_time_h: f64, f107: f64, ap: f64| {
model
.gtd7d(&Input {
day_of_year: 172,
seconds_of_day: 29000.0,
altitude_km,
latitude_deg: 30.0,
longitude_deg: 0.0,
local_solar_time_h,
f107_average: f107,
f107,
magnetic: MagneticActivity::Daily { ap },
})
.total_mass
};
// Day is denser than night at the same altitude, because the Sun raises a bulge.
for altitude in [300.0, 400.0, 600.0] {
let noon = at(altitude, 14.0, 150.0, 4.0);
let midnight = at(altitude, 2.0, 150.0, 4.0);
assert!(
noon > midnight,
"at {altitude} km, {noon:e} at 14h against {midnight:e} at 2h"
);
}
// High solar flux is denser than low, and a storm is denser than quiet conditions.
for altitude in [300.0, 400.0, 600.0] {
assert!(at(altitude, 14.0, 220.0, 4.0) > at(altitude, 14.0, 70.0, 4.0));
assert!(at(altitude, 14.0, 150.0, 200.0) > at(altitude, 14.0, 150.0, 4.0));
}
// The surface density is a kilogram and a bit per cubic meter.
let surface = at(0.0, 12.0, 150.0, 4.0);
assert!(
(1.0..1.4).contains(&surface),
"the surface density is {surface} kg/m^3"
);
}
/// The two places the port departs from the reference's control flow, checked to give a
/// defined answer where the reference gives one that depends on what was asked before it.
///
/// Both are cases where the reference reads a value its own call never wrote. Neither can
/// be compared against the reference, because the reference has no stable answer there;
/// what can be checked is that the answer here is finite, and that it is close to the
/// neighboring altitudes and switch settings it should be close to.
#[test]
fn test_the_two_departures_from_the_reference_are_defined() {
let at = |altitude_km: f64, switches: Switches| {
Model::with_switches(switches).gtd7d(&Input {
day_of_year: 100,
seconds_of_day: 43200.0,
altitude_km,
latitude_deg: -45.0,
longitude_deg: 120.0,
local_solar_time_h: 20.0,
f107_average: 120.0,
f107: 130.0,
magnetic: MagneticActivity::Daily { ap: 20.0 },
})
};
// At exactly 32.5 km, where the reference splines through unfilled nodes. The answer
// should sit between its neighbors, since nothing physical happens at 32.5 km.
let below = at(32.4, Switches::default()).total_mass;
let seam = at(32.5, Switches::default()).total_mass;
let above = at(32.6, Switches::default()).total_mass;
assert!(seam.is_finite(), "the density at 32.5 km is {seam}");
assert!(
seam < below && seam > above,
"at 32.5 km the density is {seam:e}, outside its neighbors {below:e} and {above:e}"
);
// With switch 15 off below 72.5 km, where the reference scales from a mixed nitrogen
// density it never computed. Turning the switch off drops a small correction, so the
// answer should be finite and within a few percent of leaving it on.
let off = Switches {
departures_from_diffusive_equilibrium: Switch::Off,
..Switches::default()
};
for altitude in [0.0, 20.0, 50.0, 70.0] {
let with = at(altitude, Switches::default()).total_mass;
let without = at(altitude, off).total_mass;
assert!(
without.is_finite(),
"at {altitude} km with switch 15 off the density is {without}"
);
let difference = (without - with).abs() / with;
assert!(
difference < 0.05,
"at {altitude} km switch 15 changes the density by {:.1}%",
difference * 100.0
);
}
}