rf: add the ITU-R S.465 reference antenna pattern

This commit is contained in:
ApsisRS contributors 2026-09-26 15:27:58 +08:00
parent 0110eb7eb9
commit a2e31c32ab
6 changed files with 433 additions and 5 deletions

View File

@ -169,3 +169,7 @@ required-features = ["rf"]
[[example]]
name = "dump_rain_path"
required-features = ["rf"]
[[example]]
name = "dump_s465"
required-features = ["rf"]

View File

@ -0,0 +1,36 @@
//! Writes the ITU-R S.465 reference pattern computed by this workspace, for
//! cross-checking against the recommendation.
//!
//! Usage:
//!
//! ```text
//! cargo run -p apsis-rs --features rf --example dump_s465 \
//! > validation/data/expected/rust_s465.csv
//! ```
use apsis_antenna::{main_lobe_break_angle, reference_pattern_gain, reference_peak_gain};
/// The antenna sizes sampled, as diameter to wavelength ratios.
const RATIOS: &[f64] = &[50.0, 100.0, 200.0];
/// The off-axis angles sampled, in degrees.
const ANGLES_DEGREES: &[f64] = &[0.1, 0.5, 1.0, 5.0, 20.0, 36.0, 48.0, 90.0];
fn main() {
println!("ratio,angle_deg,peak_dbi,break_deg,gain_dbi");
for &ratio in RATIOS {
let Some(peak) = reference_peak_gain(ratio) else {
continue;
};
let Some(break_angle) = main_lobe_break_angle(ratio) else {
continue;
};
for &angle_degrees in ANGLES_DEGREES {
let angle = angle_degrees.to_radians();
let Some(gain) = reference_pattern_gain(ratio, angle) else {
continue;
};
println!("{ratio},{angle_degrees},{peak:.9},{break_angle:.9},{gain:.9}");
}
}
}

View File

@ -50,6 +50,11 @@
mod parabolic;
mod pattern;
mod reference;
pub use parabolic::{ParabolicAperture, beam_width, floor_angle};
pub use pattern::{ElementPattern, Pattern, mask_gain, off_axis_attenuation};
pub use reference::{
PEAK_GAIN_OFFSET_DB, diameter_over_wavelength_from_peak_gain, main_lobe_break_angle,
reference_beam_width, reference_pattern_decibel, reference_pattern_gain, reference_peak_gain,
};

View File

@ -7,11 +7,10 @@ use core::f64::consts::PI;
/// Returns the gain of an antenna at an angle from boresight, given a peak
/// gain and a half power beam width.
///
/// This is the squared angle model used for interference analysis, in the
/// form adopted by the Radio Regulations and by ITU-R Recommendation S.465
/// for the reference pattern of a simple Earth station antenna. The gain
/// falls as the square of the off-axis angle until it reaches the peak gain
/// minus a fixed floor, and stays there beyond. In decibels,
/// This is the squared angle model that several interference assessments
/// use, expressed in terms of the half power beam width so that any antenna
/// can be described by the two numbers a link budget has to hand. In
/// decibels,
///
/// ```text
/// G(theta) = G_peak - min(12 (theta / theta_0)^2, G_floor)
@ -23,6 +22,14 @@ use core::f64::consts::PI;
/// illuminated aperture, which is a good approximation down to the first
/// sidelobe.
///
/// The coefficient twelve is chosen so that the attenuation reaches three
/// decibels at `theta_0 / 2`, which is the definition of the half power beam
/// width. A pattern taken from a recommendation should be used in place of
/// this one where the recommendation applies: see
/// [`reference_pattern_gain`](crate::reference_pattern_gain) for the ITU-R
/// S.465 pattern, which differs from this model in the main lobe and also in
/// the shape of its sidelobe region.
///
/// Returns `None` when the peak gain has no decibel form, or when the beam
/// width or floor is not positive and finite.
pub fn off_axis_attenuation(

View File

@ -0,0 +1,178 @@
//! The ITU-R S.465 reference antenna pattern.
//!
//! Recommendation S.465 defines a reference pattern for an Earth station
//! antenna, used to assess interference between satellite networks. The
//! pattern has three regions: a main lobe that falls as the square of the
//! off-axis angle, a sidelobe region that falls as the logarithm of the
//! angle, and a floor.
//!
//! The pattern is selected only by the ratio of the antenna diameter to the
//! wavelength. The recommendation gives one pattern for antennas with
//! `D / lambda` of at least 100 and another for smaller antennas, whose main
//! lobe is broader and whose sidelobe region therefore begins later.
//!
//! The main lobe expression is what distinguishes this pattern from the
//! generic reference pattern of the Radio Regulations Appendix 8, which
//! models the sidelobe region only and takes the main lobe width as an
//! input. The two share the shape of the sidelobe region.
//!
//! Source: ITU-R Recommendation S.465, *Reference earth-station radiation
//! pattern for use in coordination and interference assessment in the
//! frequency range from 2 to about 30 GHz*.
use apsis_rf::Decibel;
use core::f64::consts::PI;
/// The peak gain offset of the reference pattern, in decibels.
///
/// The recommendation gives the peak gain as `20 log10(D / lambda) + 7.7`,
/// which corresponds to an aperture efficiency near 0.6. It is slightly
/// below what a well adjusted dish reaches, because the reference pattern
/// represents a typical rather than an optimal antenna.
pub const PEAK_GAIN_OFFSET_DB: f64 = 7.7;
/// The diameter to wavelength ratio at which the pattern changes form.
///
/// A larger antenna has a narrower main lobe, so its sidelobe region begins
/// earlier and reaches the floor earlier.
const LARGE_APERTURE_RATIO: f64 = 100.0;
/// The constant and floor angle of the sidelobe region.
///
/// The constant is the value of `G + 25 log10(phi_deg)` in the sidelobe
/// region, and the floor angle is the angle at which the region reaches its
/// floor.
fn sidelobe_branch(diameter_over_wavelength: f64) -> (f64, f64) {
if diameter_over_wavelength >= LARGE_APERTURE_RATIO {
(29.0, 36.0)
} else {
(32.0, 48.0)
}
}
/// Returns the peak gain of the reference pattern, in decibels relative to an
/// isotropic radiator.
///
/// The expression is `20 log10(D / lambda) + 7.7`.
///
/// Returns `None` when the ratio is not positive and finite.
pub fn reference_peak_gain(diameter_over_wavelength: f64) -> Option<f64> {
if !diameter_over_wavelength.is_finite() || diameter_over_wavelength <= 0.0 {
return None;
}
Some(20.0 * diameter_over_wavelength.log10() + PEAK_GAIN_OFFSET_DB)
}
/// Returns the angle at which the main lobe meets the sidelobe region, in
/// degrees.
///
/// The two expressions intersect at this angle, and there is no closed form,
/// so the value is found by bisection. The main lobe falls as the square of
/// the angle while the sidelobe region falls as its logarithm, so the
/// difference has a single zero in the interval searched, which makes
/// bisection reliable.
///
/// The result lies between zero and the floor angle of the sidelobe region.
pub fn main_lobe_break_angle(diameter_over_wavelength: f64) -> Option<f64> {
let peak = reference_peak_gain(diameter_over_wavelength)?;
let (head, floor_angle) = sidelobe_branch(diameter_over_wavelength);
let difference = |angle_degrees: f64| {
let main_lobe = peak - 2.5e-3 * (diameter_over_wavelength * angle_degrees).powi(2);
let sidelobe = head - 25.0 * angle_degrees.log10();
main_lobe - sidelobe
};
// The main lobe dominates at very small angles and the sidelobe
// expression dominates at the floor angle, so the zero lies between.
let mut low = 1.0e-9;
let mut high = floor_angle;
if difference(high) > 0.0 {
return Some(high);
}
for _ in 0..100 {
let middle = 0.5 * (low + high);
if difference(middle) > 0.0 {
low = middle;
} else {
high = middle;
}
}
Some(0.5 * (low + high))
}
/// Returns the gain of the S.465 reference pattern at an off-axis angle.
///
/// The angle is in radians. The three regions are
///
/// ```text
/// G = G_max - 2.5e-3 (D/lambda phi_deg)^2 for 0 <= phi < phi_break
/// G = head - 25 log10(phi_deg) for phi_break <= phi < phi_floor
/// G = -10 for phi_floor <= phi <= 180
/// ```
///
/// where `phi_break` is the angle returned by [`main_lobe_break_angle`], the
/// head is 29 for a large aperture and 32 for a small one, and the floor
/// angle is 36 or 48 degrees accordingly.
///
/// Returns `None` when the ratio or the angle is not usable.
pub fn reference_pattern_gain(diameter_over_wavelength: f64, off_axis_angle: f64) -> Option<f64> {
if !off_axis_angle.is_finite() || off_axis_angle < 0.0 {
return None;
}
let peak = reference_peak_gain(diameter_over_wavelength)?;
let break_angle = main_lobe_break_angle(diameter_over_wavelength)?;
let (head, floor_angle) = sidelobe_branch(diameter_over_wavelength);
let angle_degrees = off_axis_angle.to_degrees();
if angle_degrees < break_angle {
Some(peak - 2.5e-3 * (diameter_over_wavelength * angle_degrees).powi(2))
} else if angle_degrees <= floor_angle {
Some(head - 25.0 * angle_degrees.log10())
} else {
Some(-10.0)
}
}
/// Returns the gain of the S.465 reference pattern as a decibel value.
///
/// Returns `None` when the ratio or the angle is not usable.
pub fn reference_pattern_decibel(
diameter_over_wavelength: f64,
off_axis_angle: f64,
) -> Option<Decibel> {
let gain = reference_pattern_gain(diameter_over_wavelength, off_axis_angle)?;
Decibel::from_db(gain)
.is_finite()
.then_some(Decibel::from_db(gain))
}
/// Returns the diameter to wavelength ratio of an antenna with a given peak
/// gain.
///
/// This inverts the peak gain expression, which is useful when a station is
/// specified by its gain rather than its size.
///
/// Returns `None` when the gain is not finite.
pub fn diameter_over_wavelength_from_peak_gain(peak_gain_dbi: f64) -> Option<f64> {
if !peak_gain_dbi.is_finite() {
return None;
}
Some(10f64.powf((peak_gain_dbi - PEAK_GAIN_OFFSET_DB) / 20.0))
}
/// Returns the half power beam width of the reference pattern main lobe, in
/// radians.
///
/// The main lobe falls to three decibels at
/// `phi = sqrt(3 / 2.5e-3) / (D / lambda)` degrees from boresight, so the
/// full width between the two three decibel points is twice that value.
///
/// Returns `None` when the ratio is not positive and finite.
pub fn reference_beam_width(diameter_over_wavelength: f64) -> Option<f64> {
if !diameter_over_wavelength.is_finite() || diameter_over_wavelength <= 0.0 {
return None;
}
let half_angle_degrees = (3.0_f64 / 2.5e-3).sqrt() / diameter_over_wavelength;
Some(2.0 * half_angle_degrees * PI / 180.0)
}

View File

@ -0,0 +1,198 @@
//! Tests for the ITU-R S.465 reference antenna pattern.
use apsis_antenna::{
PEAK_GAIN_OFFSET_DB, diameter_over_wavelength_from_peak_gain, main_lobe_break_angle,
reference_beam_width, reference_pattern_gain, reference_peak_gain,
};
const TOLERANCE: f64 = 1e-9;
#[test]
fn the_peak_gain_follows_the_recommendation() {
// The recommendation gives 20 log10(D / lambda) + 7.7.
for ratio in [10.0_f64, 50.0, 100.0, 200.0, 1_000.0] {
let expected = 20.0 * ratio.log10() + PEAK_GAIN_OFFSET_DB;
let actual = reference_peak_gain(ratio).expect("valid ratio");
assert!((actual - expected).abs() < TOLERANCE, "ratio {ratio}");
}
// A ratio of one hundred gives 47.7 dBi.
assert!((reference_peak_gain(100.0).expect("valid") - 47.7).abs() < TOLERANCE);
}
#[test]
fn the_peak_gain_is_inverted_by_the_diameter_helper() {
for ratio in [10.0, 100.0, 500.0] {
let gain = reference_peak_gain(ratio).expect("valid");
let recovered = diameter_over_wavelength_from_peak_gain(gain).expect("finite gain");
assert!((recovered / ratio - 1.0).abs() < 1e-12, "ratio {ratio}");
}
}
#[test]
fn the_pattern_at_boresight_is_the_peak_gain() {
for ratio in [50.0, 100.0, 200.0] {
let peak = reference_peak_gain(ratio).expect("valid");
let boresight = reference_pattern_gain(ratio, 0.0).expect("valid");
assert!((boresight - peak).abs() < TOLERANCE, "ratio {ratio}");
}
}
#[test]
fn the_main_lobe_falls_as_the_square_of_the_angle() {
// Inside the main lobe the attenuation is 2.5e-3 (D/lambda phi)^2.
let ratio = 100.0;
let peak = reference_peak_gain(ratio).expect("valid");
for angle_degrees in [0.05_f64, 0.1, 0.2, 0.3] {
let gain = reference_pattern_gain(ratio, angle_degrees.to_radians()).expect("valid");
let expected = peak - 2.5e-3 * (ratio * angle_degrees).powi(2);
assert!(
(gain - expected).abs() < TOLERANCE,
"{angle_degrees} degrees: got {gain}, expected {expected}"
);
}
}
#[test]
fn the_pattern_matches_recorded_values() {
// These are the values of the recommendation for a ratio of one hundred,
// computed independently from its equations.
let cases: [(f64, f64); 8] = [
(0.1, 47.45),
(0.5, 41.45),
(1.0, 29.0),
(5.0, 11.525_749_892),
(20.0, -3.525_749_892),
(36.0, -9.907_562_519),
(48.0, -10.0),
(90.0, -10.0),
];
for (angle_degrees, expected) in cases {
let gain = reference_pattern_gain(100.0, angle_degrees.to_radians()).expect("valid");
assert!(
(gain - expected).abs() < 1e-6,
"{angle_degrees} degrees: got {gain}, expected {expected}"
);
}
}
#[test]
fn the_pattern_matches_recorded_values_for_a_small_aperture() {
// A ratio of fifty uses the small aperture branch, with a head of 32 and
// a floor angle of 48 degrees.
// At exactly the floor angle the branch switches to the floor, so the
// last value of the falling branch is approached from below.
let cases: [(f64, f64); 6] = [
(0.1, 41.616_900_087),
(1.0, 35.429_400_087),
(5.0, 14.525_749_892),
(36.0, -6.907_562_519),
(47.99, -10.028_768_748_310_533),
(48.0, -10.0),
];
for (angle_degrees, expected) in cases {
let gain = reference_pattern_gain(50.0, angle_degrees.to_radians()).expect("valid");
assert!(
(gain - expected).abs() < 1e-6,
"{angle_degrees} degrees: got {gain}, expected {expected}"
);
}
}
#[test]
fn the_two_branches_meet_at_the_break_angle() {
// The main lobe and the sidelobe region must agree at the junction, or
// the pattern would jump.
for ratio in [50.0_f64, 100.0, 200.0, 500.0] {
let peak = reference_peak_gain(ratio).expect("valid");
let break_angle = main_lobe_break_angle(ratio).expect("valid");
let (head, _) = if ratio >= 100.0 {
(29.0, 36.0)
} else {
(32.0, 48.0)
};
let main_lobe = peak - 2.5e-3 * (ratio * break_angle).powi(2);
let sidelobe = head - 25.0 * break_angle.log10();
assert!(
(main_lobe - sidelobe).abs() < 1e-6,
"ratio {ratio}: {main_lobe} against {sidelobe}"
);
}
}
#[test]
fn the_break_angle_shrinks_as_the_aperture_grows() {
// A larger antenna has a narrower main lobe, so the sidelobe region
// begins sooner.
let small = main_lobe_break_angle(50.0).expect("valid");
let medium = main_lobe_break_angle(100.0).expect("valid");
let large = main_lobe_break_angle(400.0).expect("valid");
assert!(small > medium, "{small} against {medium}");
assert!(medium > large, "{medium} against {large}");
}
#[test]
fn the_pattern_floors_at_minus_ten_decibels() {
for ratio in [50.0, 100.0, 200.0] {
let gain = reference_pattern_gain(ratio, 90.0_f64.to_radians()).expect("valid");
assert!((gain + 10.0).abs() < TOLERANCE, "ratio {ratio}");
let beyond = reference_pattern_gain(ratio, core::f64::consts::PI).expect("valid");
assert!((beyond + 10.0).abs() < TOLERANCE, "ratio {ratio}");
}
}
#[test]
fn the_pattern_never_rises_above_the_peak() {
let ratio = 100.0;
let peak = reference_peak_gain(ratio).expect("valid");
for tenth in 0..=100 {
let angle = tenth as f64 * 0.01;
let gain = reference_pattern_gain(ratio, angle).expect("valid");
assert!(gain <= peak + TOLERANCE, "angle {angle} gave {gain}");
}
}
#[test]
fn the_pattern_falls_monotonically_across_the_main_lobe() {
let ratio = 100.0;
let mut previous = f64::INFINITY;
for step in 0..=80 {
let angle_degrees = step as f64 * 0.01;
let gain = reference_pattern_gain(ratio, angle_degrees.to_radians()).expect("valid");
assert!(gain <= previous + TOLERANCE, "angle {angle_degrees}");
previous = gain;
}
}
#[test]
fn the_beam_width_follows_from_the_main_lobe_law() {
// Three decibels is reached at sqrt(3 / 2.5e-3) / (D/lambda) degrees
// from boresight, so the full width is twice that.
let ratio = 100.0;
let width = reference_beam_width(ratio).expect("valid");
let half_degrees = (3.0_f64 / 2.5e-3).sqrt() / ratio;
let expected = 2.0 * half_degrees.to_radians();
assert!((width - expected).abs() < 1e-15);
// The gain at half the beam width from boresight is three decibels down.
let gain = reference_pattern_gain(ratio, width / 2.0).expect("valid");
let peak = reference_peak_gain(ratio).expect("valid");
assert!(
(peak - gain - 3.0).abs() < 1e-9,
"peak - gain = {}",
peak - gain
);
}
#[test]
fn invalid_arguments_are_rejected() {
assert_eq!(reference_peak_gain(0.0), None);
assert_eq!(reference_peak_gain(-1.0), None);
assert_eq!(reference_peak_gain(f64::NAN), None);
assert_eq!(reference_pattern_gain(100.0, -0.1), None);
assert_eq!(reference_pattern_gain(100.0, f64::NAN), None);
assert_eq!(reference_pattern_gain(0.0, 0.1), None);
assert_eq!(reference_beam_width(0.0), None);
assert_eq!(diameter_over_wavelength_from_peak_gain(f64::NAN), None);
assert_eq!(main_lobe_break_angle(0.0), None);
}