From 37d8d977d4ca280cd57375efd704a0db449d08ac Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 15:27:58 +0800 Subject: [PATCH] rf: add the ITU-R S.465 reference antenna pattern --- crates/apsis-rs/Cargo.toml | 4 + crates/apsis-rs/examples/dump_s465.rs | 36 ++++ crates/rf/apsis-antenna/src/lib.rs | 5 + crates/rf/apsis-antenna/src/pattern.rs | 17 +- crates/rf/apsis-antenna/src/reference.rs | 178 ++++++++++++++++++ crates/rf/apsis-antenna/tests/reference.rs | 198 +++++++++++++++++++++ 6 files changed, 433 insertions(+), 5 deletions(-) create mode 100644 crates/apsis-rs/examples/dump_s465.rs create mode 100644 crates/rf/apsis-antenna/src/reference.rs create mode 100644 crates/rf/apsis-antenna/tests/reference.rs diff --git a/crates/apsis-rs/Cargo.toml b/crates/apsis-rs/Cargo.toml index e28715c..f0d6f9e 100644 --- a/crates/apsis-rs/Cargo.toml +++ b/crates/apsis-rs/Cargo.toml @@ -169,3 +169,7 @@ required-features = ["rf"] [[example]] name = "dump_rain_path" required-features = ["rf"] + +[[example]] +name = "dump_s465" +required-features = ["rf"] diff --git a/crates/apsis-rs/examples/dump_s465.rs b/crates/apsis-rs/examples/dump_s465.rs new file mode 100644 index 0000000..073685c --- /dev/null +++ b/crates/apsis-rs/examples/dump_s465.rs @@ -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}"); + } + } +} diff --git a/crates/rf/apsis-antenna/src/lib.rs b/crates/rf/apsis-antenna/src/lib.rs index 3f649e1..ccdfb5c 100644 --- a/crates/rf/apsis-antenna/src/lib.rs +++ b/crates/rf/apsis-antenna/src/lib.rs @@ -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, +}; diff --git a/crates/rf/apsis-antenna/src/pattern.rs b/crates/rf/apsis-antenna/src/pattern.rs index 82c0957..dc463d4 100644 --- a/crates/rf/apsis-antenna/src/pattern.rs +++ b/crates/rf/apsis-antenna/src/pattern.rs @@ -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( diff --git a/crates/rf/apsis-antenna/src/reference.rs b/crates/rf/apsis-antenna/src/reference.rs new file mode 100644 index 0000000..0919221 --- /dev/null +++ b/crates/rf/apsis-antenna/src/reference.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) +} diff --git a/crates/rf/apsis-antenna/tests/reference.rs b/crates/rf/apsis-antenna/tests/reference.rs new file mode 100644 index 0000000..9e9206b --- /dev/null +++ b/crates/rf/apsis-antenna/tests/reference.rs @@ -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); +}