apsis-rs/crates/rf/apsis-atmosphere/tests/rain.rs

396 lines
12 KiB
Rust

//! Tests for rain attenuation.
//!
//! The coefficient values are those of ITU-R Recommendation P.838-3,
//! produced with a reference implementation of the recommendation and
//! recorded here. The path results exercise the P.618-13 method.
use apsis_atmosphere::{
DEFAULT_RAIN_HEIGHT_KM, polarisation_tilt, rain_attenuation, rain_coefficients,
specific_rain_attenuation,
};
use core::f64::consts::{FRAC_PI_2, PI};
/// Horizontal polarisation.
const HORIZONTAL: f64 = 0.0;
/// Vertical polarisation.
const VERTICAL: f64 = FRAC_PI_2;
/// The zenith, where the two polarisations coincide.
const ZENITH: f64 = FRAC_PI_2;
/// A mid latitude elevation of thirty degrees.
const THIRTY_DEGREES: f64 = PI / 6.0;
/// The relative tolerance on the coefficient values.
const TOLERANCE: f64 = 1e-9;
fn assert_relative(actual: f64, expected: f64, context: &str) {
let relative = (actual - expected).abs() / expected.abs();
assert!(
relative < TOLERANCE,
"{context}: got {actual}, expected {expected}, relative {relative}"
);
}
#[test]
fn coefficients_match_the_recommendation_at_the_zenith() {
// At the zenith the elevation term vanishes, so horizontal and vertical
// polarisation give the same coefficients.
let cases = [
(12.0, 0.024_203_061_156_1, 1.151_599_196_3),
(20.0, 0.093_876_937_766_6, 1.019_877_631_17),
(30.0, 0.234_699_253_968, 0.931_114_875_787),
];
for (frequency, k_expected, alpha_expected) in cases {
for polarisation in [HORIZONTAL, VERTICAL] {
let (k, alpha) =
rain_coefficients(frequency * 1.0e9, polarisation, ZENITH).expect("valid");
assert_relative(k, k_expected, &format!("{frequency} GHz k"));
assert_relative(alpha, alpha_expected, &format!("{frequency} GHz alpha"));
}
}
}
#[test]
fn coefficients_match_the_recommendation_at_thirty_degrees() {
let cases = [
(12.0, 0.023_944_109_789_7, 1.174_670_745_2),
(20.0, 0.092_201_236_241_3, 1.047_387_560_19),
(30.0, 0.238_905_952_257, 0.944_198_048_818),
];
for (frequency, k_expected, alpha_expected) in cases {
let (k, alpha) =
rain_coefficients(frequency * 1.0e9, HORIZONTAL, THIRTY_DEGREES).expect("valid");
assert_relative(k, k_expected, &format!("{frequency} GHz k"));
assert_relative(alpha, alpha_expected, &format!("{frequency} GHz alpha"));
}
}
#[test]
fn horizontal_and_vertical_differ_off_the_zenith() {
// Away from the zenith the polarisation matters. At 12 GHz and low
// elevation the vertical coefficient is the larger of the two, and the
// two converge as the elevation rises and the geometry becomes
// symmetric.
let low_elevation = 10.0_f64.to_radians();
let horizontal = rain_coefficients(12.0e9, HORIZONTAL, low_elevation).expect("valid");
let vertical = rain_coefficients(12.0e9, VERTICAL, low_elevation).expect("valid");
assert_relative(horizontal.0, 0.023_868_203_786_369_1, "horizontal k");
assert_relative(vertical.0, 0.024_537_918_525_868_188, "vertical k");
// At the zenith they coincide.
let zenith_horizontal = rain_coefficients(12.0e9, HORIZONTAL, ZENITH).expect("valid");
let zenith_vertical = rain_coefficients(12.0e9, VERTICAL, ZENITH).expect("valid");
assert_relative(zenith_horizontal.0, zenith_vertical.0, "zenith k");
}
#[test]
fn circular_polarisation_lies_between_the_linear_cases() {
// A tilt of forty five degrees is circular polarisation, whose
// coefficients are the mean of the two linear cases.
let horizontal = rain_coefficients(12.0e9, HORIZONTAL, THIRTY_DEGREES).expect("valid");
let vertical = rain_coefficients(12.0e9, VERTICAL, THIRTY_DEGREES).expect("valid");
let circular =
rain_coefficients(12.0e9, polarisation_tilt(45.0), THIRTY_DEGREES).expect("valid");
let mean = 0.5 * (horizontal.0 + vertical.0);
assert_relative(circular.0, mean, "circular k");
}
#[test]
fn the_coefficients_are_smooth_across_the_band() {
// The curve fits are continuous, so a small change in frequency gives a
// small change in the coefficients.
let mut previous = rain_coefficients(10.0e9, HORIZONTAL, ZENITH).expect("valid");
for tenth in 101..=200 {
let frequency = tenth as f64 * 0.1e9;
let current = rain_coefficients(frequency, HORIZONTAL, ZENITH).expect("valid");
assert!(
(current.0 - previous.0).abs() / previous.0 < 0.05,
"jump at {frequency} GHz"
);
previous = current;
}
}
#[test]
fn a_frequency_outside_the_recommendation_is_rejected() {
assert_eq!(rain_coefficients(0.5e9, HORIZONTAL, ZENITH), None);
assert_eq!(rain_coefficients(2_000.0e9, HORIZONTAL, ZENITH), None);
assert_eq!(rain_coefficients(f64::NAN, HORIZONTAL, ZENITH), None);
assert_eq!(rain_coefficients(12.0e9, f64::NAN, ZENITH), None);
assert_eq!(rain_coefficients(12.0e9, HORIZONTAL, f64::NAN), None);
}
#[test]
fn specific_attenuation_follows_the_power_law() {
let rain_rate = 20.0;
let frequency = 12.0e9;
let specific =
specific_rain_attenuation(rain_rate, frequency, HORIZONTAL, THIRTY_DEGREES).expect("valid");
let (k, alpha) = rain_coefficients(frequency, HORIZONTAL, THIRTY_DEGREES).expect("valid");
assert_relative(specific, k * rain_rate.powf(alpha), "specific attenuation");
// At 12 GHz, thirty degrees elevation, and 20 mm/h this is 0.808 dB/km.
assert!(
(specific - 0.808_128_772_7).abs() < 1e-6,
"specific {specific}"
);
}
#[test]
fn a_higher_rain_rate_gives_more_attenuation() {
let light = specific_rain_attenuation(1.0, 12.0e9, HORIZONTAL, THIRTY_DEGREES).expect("valid");
let heavy = specific_rain_attenuation(50.0, 12.0e9, HORIZONTAL, THIRTY_DEGREES).expect("valid");
assert!(heavy > light);
let (_, alpha) = rain_coefficients(12.0e9, HORIZONTAL, THIRTY_DEGREES).expect("valid");
assert_relative(heavy / light, 50.0_f64.powf(alpha), "rate ratio");
}
#[test]
fn a_zero_rain_rate_gives_no_attenuation() {
assert_eq!(
specific_rain_attenuation(0.0, 12.0e9, HORIZONTAL, THIRTY_DEGREES),
Some(0.0)
);
}
#[test]
fn a_negative_rain_rate_is_rejected() {
assert_eq!(
specific_rain_attenuation(-1.0, 12.0e9, HORIZONTAL, THIRTY_DEGREES),
None
);
assert_eq!(
rain_attenuation(
-1.0,
12.0e9,
HORIZONTAL,
THIRTY_DEGREES,
0.0,
DEFAULT_RAIN_HEIGHT_KM,
45.0
),
None
);
}
#[test]
fn total_attenuation_is_the_specific_value_times_the_effective_path() {
let result = rain_attenuation(
20.0,
12.0e9,
HORIZONTAL,
THIRTY_DEGREES,
0.0,
DEFAULT_RAIN_HEIGHT_KM,
45.0,
)
.expect("valid");
assert_relative(
result.attenuation_db,
result.specific_db_per_km * result.path_length_km,
"total attenuation",
);
assert!(result.is_valid());
}
#[test]
fn the_reduction_factors_are_physical() {
// The horizontal reduction factor accounts for rain not filling the
// whole horizontal extent of the path, so it is at most one. The
// vertical adjustment is the ratio of two path estimates and can exceed
// one when the path crosses the rain layer at a shallow angle, which is
// the case Recommendation P.618 allows for.
let result = rain_attenuation(
20.0,
12.0e9,
HORIZONTAL,
THIRTY_DEGREES,
0.0,
DEFAULT_RAIN_HEIGHT_KM,
45.0,
)
.expect("valid");
assert!(
result.horizontal_reduction > 0.0 && result.horizontal_reduction <= 1.0,
"horizontal reduction {}",
result.horizontal_reduction
);
assert!(result.vertical_adjustment > 0.0, "vertical adjustment");
}
#[test]
fn attenuation_grows_as_the_elevation_falls() {
let high = rain_attenuation(
20.0,
12.0e9,
HORIZONTAL,
ZENITH,
0.0,
DEFAULT_RAIN_HEIGHT_KM,
45.0,
)
.expect("valid");
let low = rain_attenuation(
20.0,
12.0e9,
HORIZONTAL,
10.0_f64.to_radians(),
0.0,
DEFAULT_RAIN_HEIGHT_KM,
45.0,
)
.expect("valid");
assert!(low.attenuation_db > high.attenuation_db);
}
#[test]
fn a_heavy_rain_event_gives_a_significant_attenuation() {
// A 20 mm/h rain cell at 12 GHz and thirty degrees elevation gives a few
// decibels, which is the order of magnitude a Ku band link must budget.
let result = rain_attenuation(
20.0,
12.0e9,
HORIZONTAL,
THIRTY_DEGREES,
0.0,
DEFAULT_RAIN_HEIGHT_KM,
45.0,
)
.expect("valid");
assert!(
result.attenuation_db > 1.0 && result.attenuation_db < 6.0,
"{} dB",
result.attenuation_db
);
}
#[test]
fn a_lower_frequency_suffers_less_rain_attenuation() {
let c_band = rain_attenuation(
20.0,
6.0e9,
HORIZONTAL,
THIRTY_DEGREES,
0.0,
DEFAULT_RAIN_HEIGHT_KM,
45.0,
)
.expect("valid");
let ku_band = rain_attenuation(
20.0,
12.0e9,
HORIZONTAL,
THIRTY_DEGREES,
0.0,
DEFAULT_RAIN_HEIGHT_KM,
45.0,
)
.expect("valid");
assert!(ku_band.attenuation_db > c_band.attenuation_db);
}
#[test]
fn a_station_above_the_rain_layer_has_no_rain_path() {
let result = rain_attenuation(
20.0,
12.0e9,
HORIZONTAL,
THIRTY_DEGREES,
4.0,
DEFAULT_RAIN_HEIGHT_KM,
45.0,
)
.expect("valid");
assert_eq!(result.attenuation_db, 0.0);
assert_eq!(result.path_length_km, 0.0);
}
#[test]
fn an_elevation_at_the_horizon_is_rejected() {
assert_eq!(
rain_attenuation(
20.0,
12.0e9,
HORIZONTAL,
0.0,
0.0,
DEFAULT_RAIN_HEIGHT_KM,
45.0
),
None
);
assert_eq!(
rain_attenuation(
20.0,
12.0e9,
HORIZONTAL,
-0.1,
0.0,
DEFAULT_RAIN_HEIGHT_KM,
45.0
),
None
);
}
#[test]
fn the_latitude_enters_through_the_vertical_adjustment() {
// Below thirty six degrees the latitude term is nonzero, so the same
// geometry gives a different result at two latitudes.
let temperate = rain_attenuation(
20.0,
12.0e9,
HORIZONTAL,
THIRTY_DEGREES,
0.0,
DEFAULT_RAIN_HEIGHT_KM,
45.0,
)
.expect("valid");
let tropical = rain_attenuation(
20.0,
12.0e9,
HORIZONTAL,
THIRTY_DEGREES,
0.0,
DEFAULT_RAIN_HEIGHT_KM,
10.0,
)
.expect("valid");
assert!(
(temperate.vertical_adjustment - tropical.vertical_adjustment).abs() > 1e-6,
"{} vs {}",
temperate.vertical_adjustment,
tropical.vertical_adjustment
);
}
#[test]
fn non_finite_geometry_is_rejected() {
assert_eq!(
rain_attenuation(
20.0,
12.0e9,
HORIZONTAL,
THIRTY_DEGREES,
f64::NAN,
3.0,
45.0
),
None
);
assert_eq!(
rain_attenuation(20.0, 12.0e9, HORIZONTAL, THIRTY_DEGREES, 0.0, 3.0, f64::NAN),
None
);
}
#[test]
fn polarisation_tilt_converts_degrees() {
assert_eq!(polarisation_tilt(0.0), 0.0);
assert_relative(polarisation_tilt(90.0), FRAC_PI_2, "ninety degrees");
assert_relative(
polarisation_tilt(45.0),
FRAC_PI_2 / 2.0,
"forty five degrees",
);
}