334 lines
13 KiB
Rust
334 lines
13 KiB
Rust
//! Tests for gaseous attenuation.
|
|
//!
|
|
//! The expected values are those of ITU-R Recommendation P.676-12 for the
|
|
//! reference atmosphere: a dry air pressure of 1013.25 hPa, a water vapour
|
|
//! density of 7.5 g/m^3, and a temperature of 288.15 K. They were produced
|
|
//! with a line-by-line implementation of the recommendation and are recorded
|
|
//! here so that a change to the line tables or the formulae shows up as a
|
|
//! failing assertion.
|
|
|
|
use apsis_atmosphere::{
|
|
Atmosphere, equivalent_heights, specific_attenuation, specific_attenuation_dry_air,
|
|
specific_attenuation_water_vapour,
|
|
};
|
|
|
|
/// The tolerance on a specific attenuation, in decibels per kilometer.
|
|
///
|
|
/// The implementation reproduces the reference to about twelve significant
|
|
/// digits, so this allows for the rounding of the recorded values.
|
|
const TOLERANCE: f64 = 1e-6;
|
|
|
|
/// The reference atmosphere of the recommendation.
|
|
fn reference() -> Atmosphere {
|
|
Atmosphere::REFERENCE
|
|
}
|
|
|
|
#[test]
|
|
fn the_reference_atmosphere_matches_the_standard() {
|
|
let atmosphere = reference();
|
|
assert_eq!(atmosphere.pressure_hpa, 1_013.25);
|
|
assert_eq!(atmosphere.water_vapour_density, 7.5);
|
|
assert_eq!(atmosphere.temperature_k, 288.15);
|
|
assert!(atmosphere.is_valid());
|
|
}
|
|
|
|
#[test]
|
|
fn the_water_vapour_partial_pressure_follows_the_ideal_gas_law() {
|
|
let atmosphere = reference();
|
|
let expected = 7.5 * 288.15 / 216.7;
|
|
assert!((atmosphere.water_vapour_partial_pressure() - expected).abs() < 1e-12);
|
|
}
|
|
|
|
#[test]
|
|
fn dry_air_attenuation_matches_the_recommendation() {
|
|
// The recorded values are from the reference implementation of P.676-12.
|
|
let cases = [
|
|
(1.0, 0.005_388_658_168),
|
|
(10.0, 0.008_224_416_703),
|
|
(22.235, 0.013_292_678_183),
|
|
(50.0, 0.277_268_638_531),
|
|
(60.0, 14.623_474_796_486),
|
|
(70.0, 0.304_105_097_147),
|
|
(118.0, 1.134_866_201_871),
|
|
(300.0, 0.025_759_576_282),
|
|
];
|
|
for (frequency, expected) in cases {
|
|
let actual = specific_attenuation_dry_air(frequency, reference()).expect("valid");
|
|
assert!(
|
|
(actual - expected).abs() < TOLERANCE,
|
|
"{frequency} GHz: got {actual}, expected {expected}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn water_vapour_attenuation_matches_the_recommendation() {
|
|
let cases = [
|
|
(1.0, 0.000_050_904_617),
|
|
(10.0, 0.005_974_125_245),
|
|
(22.235, 0.178_977_992_373),
|
|
(60.0, 0.154_841_840_636),
|
|
(183.0, 27.665_008_314_166),
|
|
(300.0, 5.221_329_041_105),
|
|
];
|
|
for (frequency, expected) in cases {
|
|
let actual = specific_attenuation_water_vapour(frequency, reference()).expect("valid");
|
|
assert!(
|
|
(actual - expected).abs() < TOLERANCE,
|
|
"{frequency} GHz: got {actual}, expected {expected}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_two_contributions_add() {
|
|
let frequency = 22.235;
|
|
let dry = specific_attenuation_dry_air(frequency, reference()).expect("valid");
|
|
let wet = specific_attenuation_water_vapour(frequency, reference()).expect("valid");
|
|
let total = specific_attenuation(frequency, reference()).expect("valid");
|
|
assert!((total - (dry + wet)).abs() < 1e-12);
|
|
}
|
|
|
|
#[test]
|
|
fn the_oxygen_complex_dominates_near_sixty_gigahertz() {
|
|
// The 60 GHz complex is a set of more than thirty lines, so the dry air
|
|
// term is many orders of magnitude above the water vapour term there.
|
|
let frequency = 60.0;
|
|
let dry = specific_attenuation_dry_air(frequency, reference()).expect("valid");
|
|
let wet = specific_attenuation_water_vapour(frequency, reference()).expect("valid");
|
|
// The dry air value is about 14.6 dB/km against 0.15 dB/km for water
|
|
// vapour, a ratio near one hundred.
|
|
assert!(dry > 50.0 * wet, "dry {dry} wet {wet}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_water_vapour_line_dominates_at_one_hundred_and_eighty_three_gigahertz() {
|
|
let frequency = 183.0;
|
|
let dry = specific_attenuation_dry_air(frequency, reference()).expect("valid");
|
|
let wet = specific_attenuation_water_vapour(frequency, reference()).expect("valid");
|
|
assert!(wet > dry);
|
|
// The line reaches over twenty decibels per kilometer.
|
|
assert!(wet > 20.0, "wet {wet}");
|
|
}
|
|
|
|
#[test]
|
|
fn attenuation_is_small_in_the_microwave_windows() {
|
|
// Below 15 GHz the total specific attenuation is a hundredth of a
|
|
// decibel per kilometer or less, which is why those bands are used.
|
|
for frequency in [1.0, 2.0, 4.0, 6.0, 10.0, 12.0, 15.0] {
|
|
let total = specific_attenuation(frequency, reference()).expect("valid");
|
|
assert!(total < 0.05, "{frequency} GHz gave {total} dB/km");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dry_air_attenuation_peaks_inside_the_oxygen_complex() {
|
|
// The strongest line is near 60.4 GHz, and the value there must exceed
|
|
// the values on either side.
|
|
let peak = specific_attenuation_dry_air(60.434_778, reference()).expect("valid");
|
|
for frequency in [50.0, 55.0, 65.0, 70.0] {
|
|
let value = specific_attenuation_dry_air(frequency, reference()).expect("valid");
|
|
assert!(value < peak, "{frequency} GHz gave {value} against {peak}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn water_vapour_attenuation_grows_with_the_vapour_density() {
|
|
let dry_air = Atmosphere::new(1_013.25, 0.0, 288.15);
|
|
let wet_air = Atmosphere::new(1_013.25, 15.0, 288.15);
|
|
let dry = specific_attenuation_water_vapour(22.235, dry_air).expect("valid");
|
|
let wet = specific_attenuation_water_vapour(22.235, wet_air).expect("valid");
|
|
assert_eq!(dry, 0.0);
|
|
assert!(wet > 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn equivalent_heights_match_the_recommendation() {
|
|
let cases = [
|
|
(1.0, 5.551_003_440, 1.696_760_106),
|
|
(22.235, 4.867_310_088, 2.358_922_817),
|
|
(60.0, 10.731_486_113, 1.694_560_689),
|
|
(118.0, 13.446_411_089, 1.695_050_659),
|
|
(183.0, 5.581_437_611, 2.522_911_251),
|
|
];
|
|
for (frequency, dry_expected, wet_expected) in cases {
|
|
let heights = equivalent_heights(frequency, reference()).expect("valid");
|
|
assert!(
|
|
(heights.dry_km - dry_expected).abs() < 1e-6,
|
|
"{frequency} GHz dry height: got {}, expected {dry_expected}",
|
|
heights.dry_km
|
|
);
|
|
assert!(
|
|
(heights.wet_km - wet_expected).abs() < 1e-6,
|
|
"{frequency} GHz wet height: got {}, expected {wet_expected}",
|
|
heights.wet_km
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_dry_height_is_capped_below_seventy_gigahertz() {
|
|
// Equation 30 limits the dry air height to a pressure dependent bound at
|
|
// the lower frequencies. The bound is `10.7 rp^0.3`, where `rp` is the
|
|
// ratio of the total barometric pressure, dry air plus water vapour, to
|
|
// the reference pressure.
|
|
let atmosphere = reference();
|
|
let total = atmosphere.pressure_hpa + atmosphere.water_vapour_partial_pressure();
|
|
let rp = total / 1_013.25;
|
|
let bound = 10.7 * rp.powf(0.3);
|
|
|
|
// At 60 GHz the uncapped value is above the bound, so the bound applies.
|
|
let heights = equivalent_heights(60.0, atmosphere).expect("valid");
|
|
assert!((heights.dry_km - bound).abs() < 1e-9, "{}", heights.dry_km);
|
|
|
|
// Above 70 GHz no cap is applied, and the value exceeds it.
|
|
let high = equivalent_heights(118.0, atmosphere).expect("valid");
|
|
assert!(high.dry_km > bound);
|
|
}
|
|
|
|
#[test]
|
|
fn zenith_attenuation_matches_the_recommendation() {
|
|
let cases = [
|
|
(1.0, 0.029_998_832_95),
|
|
(12.0, 0.058_856_728_55),
|
|
(22.235, 0.486_894_856_6),
|
|
(60.0, 157.194_005_6),
|
|
(183.0, 69.867_434_26),
|
|
];
|
|
for (frequency, expected) in cases {
|
|
let dry = specific_attenuation_dry_air(frequency, reference()).expect("valid");
|
|
let wet = specific_attenuation_water_vapour(frequency, reference()).expect("valid");
|
|
let heights = equivalent_heights(frequency, reference()).expect("valid");
|
|
let zenith = dry * heights.dry_km + wet * heights.wet_km;
|
|
assert!(
|
|
(zenith - expected).abs() < 1e-6,
|
|
"{frequency} GHz: got {zenith}, expected {expected}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_inputs_are_rejected() {
|
|
assert_eq!(specific_attenuation(0.0, reference()), None);
|
|
assert_eq!(specific_attenuation(-1.0, reference()), None);
|
|
assert_eq!(specific_attenuation(f64::NAN, reference()), None);
|
|
assert_eq!(
|
|
specific_attenuation_dry_air(12.0, Atmosphere::new(0.0, 7.5, 288.15)),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
specific_attenuation_water_vapour(12.0, Atmosphere::new(1_013.25, -1.0, 288.15)),
|
|
None
|
|
);
|
|
assert_eq!(equivalent_heights(0.0, reference()), None);
|
|
assert_eq!(
|
|
equivalent_heights(12.0, Atmosphere::new(1_013.25, 7.5, 0.0)),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_denser_atmosphere_attenuates_more() {
|
|
let sea_level = Atmosphere::new(1_013.25, 7.5, 288.15);
|
|
let high_altitude = Atmosphere::new(700.0, 3.0, 280.0);
|
|
let low = specific_attenuation_dry_air(60.0, sea_level).expect("valid");
|
|
let high = specific_attenuation_dry_air(60.0, high_altitude).expect("valid");
|
|
assert!(low > high);
|
|
}
|
|
|
|
#[test]
|
|
fn slant_path_attenuation_scales_with_the_elevation() {
|
|
use apsis_atmosphere::{slant_path_attenuation, zenith_attenuation};
|
|
let frequency = 12.0;
|
|
let zenith = zenith_attenuation(frequency, reference()).expect("valid");
|
|
// At thirty degrees elevation the path is twice the zenith path.
|
|
let thirty =
|
|
slant_path_attenuation(frequency, core::f64::consts::PI / 6.0, reference()).expect("valid");
|
|
assert!((thirty - 2.0 * zenith).abs() < 1e-9);
|
|
assert!(thirty > zenith);
|
|
}
|
|
|
|
#[test]
|
|
fn slant_path_attenuation_rejects_an_elevation_at_the_horizon() {
|
|
use apsis_atmosphere::slant_path_attenuation;
|
|
assert_eq!(slant_path_attenuation(12.0, 0.0, reference()), None);
|
|
assert_eq!(slant_path_attenuation(12.0, -0.1, reference()), None);
|
|
assert_eq!(slant_path_attenuation(12.0, f64::NAN, reference()), None);
|
|
}
|
|
|
|
#[test]
|
|
fn the_exact_slant_path_matches_the_recommendation() {
|
|
use apsis_atmosphere::slant_path_attenuation_exact;
|
|
// These values are from a reference implementation of the exact method
|
|
// of ITU-R Recommendation P.676-12.
|
|
let cases: [(f64, f64, f64); 10] = [
|
|
(12.0, 90.0, 0.059926619268),
|
|
(12.0, 30.0, 0.119683280157),
|
|
(12.0, 10.0, 0.340072667215),
|
|
(12.0, 5.0, 0.651451990239),
|
|
(12.0, 1.0, 2.054182412438),
|
|
(20.0, 30.0, 0.504703085886),
|
|
(20.0, 5.0, 2.793084848959),
|
|
(30.0, 10.0, 1.319753394647),
|
|
(60.0, 30.0, 309.245091571274),
|
|
(100.0, 5.0, 10.109978745794),
|
|
];
|
|
for (frequency, elevation_degrees, expected) in cases {
|
|
let actual =
|
|
slant_path_attenuation_exact(frequency, elevation_degrees.to_radians(), reference())
|
|
.expect("valid arguments");
|
|
let relative = (actual - expected).abs() / expected;
|
|
assert!(
|
|
relative < 1e-6,
|
|
"{frequency} GHz at {elevation_degrees} degrees: got {actual}, expected {expected}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_exact_and_approximate_methods_agree_at_high_elevation() {
|
|
use apsis_atmosphere::{slant_path_attenuation, slant_path_attenuation_exact};
|
|
// The two methods use different formulations, so they agree to a few
|
|
// per cent rather than exactly even at the zenith, where the plane
|
|
// parallel geometry is exact. The recommendation notes that the
|
|
// approximate method is intended only for the elevation range above
|
|
// about five degrees and for rough work.
|
|
let exact = slant_path_attenuation_exact(12.0, core::f64::consts::FRAC_PI_2, reference())
|
|
.expect("valid");
|
|
let approximate =
|
|
slant_path_attenuation(12.0, core::f64::consts::FRAC_PI_2, reference()).expect("valid");
|
|
let relative = (exact - approximate).abs() / approximate;
|
|
assert!(relative < 0.05, "exact {exact}, approximate {approximate}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_exact_method_exceeds_the_approximate_one_at_low_elevation() {
|
|
use apsis_atmosphere::{slant_path_attenuation, slant_path_attenuation_exact};
|
|
// At a low elevation the curved path through the thinning atmosphere is
|
|
// shorter than the plane parallel estimate, and the refraction bends
|
|
// the ray downward, so the two methods diverge.
|
|
let elevation = 2.0_f64.to_radians();
|
|
let exact = slant_path_attenuation_exact(12.0, elevation, reference()).expect("valid");
|
|
let approximate = slant_path_attenuation(12.0, elevation, reference()).expect("valid");
|
|
assert!(
|
|
(exact - approximate).abs() / approximate > 0.001,
|
|
"exact {exact}, approximate {approximate}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_exact_method_rejects_unusable_arguments() {
|
|
use apsis_atmosphere::slant_path_attenuation_exact;
|
|
assert_eq!(slant_path_attenuation_exact(0.0, 1.0, reference()), None);
|
|
assert_eq!(slant_path_attenuation_exact(12.0, 0.0, reference()), None);
|
|
assert_eq!(slant_path_attenuation_exact(12.0, -0.1, reference()), None);
|
|
assert_eq!(
|
|
slant_path_attenuation_exact(12.0, f64::NAN, reference()),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
slant_path_attenuation_exact(12.0, 1.0, Atmosphere::new(0.0, 7.5, 288.15)),
|
|
None
|
|
);
|
|
}
|