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