312 lines
11 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|