diff --git a/Cargo.lock b/Cargo.lock index a48f856..83cb19f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,6 +9,10 @@ version = "0.1.0" [[package]] name = "apsis-antenna" version = "0.1.0" +dependencies = [ + "apsis-core", + "apsis-rf", +] [[package]] name = "apsis-atmosphere" diff --git a/crates/rf/apsis-antenna/Cargo.toml b/crates/rf/apsis-antenna/Cargo.toml index 4a6c61d..8962588 100644 --- a/crates/rf/apsis-antenna/Cargo.toml +++ b/crates/rf/apsis-antenna/Cargo.toml @@ -11,3 +11,5 @@ publish = false workspace = true [dependencies] +apsis-core.workspace = true +apsis-rf.workspace = true diff --git a/crates/rf/apsis-antenna/src/lib.rs b/crates/rf/apsis-antenna/src/lib.rs index 5fa7153..b6f70af 100644 --- a/crates/rf/apsis-antenna/src/lib.rs +++ b/crates/rf/apsis-antenna/src/lib.rs @@ -1 +1,54 @@ //! Antenna radiation patterns and gain models. +//! +//! An antenna does not radiate equally in every direction. This crate models +//! how the gain varies with angle for the two shapes that appear in almost +//! every satellite link: a parabolic reflector, whose beam is a narrow +//! pencil, and an idealised element with a broad pattern. +//! +//! Three beam width conventions are used in practice and they are not +//! interchangeable: +//! +//! - The half power beam width is the angle between the two directions where +//! the gain has fallen to half its peak, which is three decibels down. +//! - The null to null beam width is the angle between the first two zeros on +//! either side of the main lobe. It is roughly twice the half power width +//! for a uniformly illuminated aperture. +//! - The first sidelobe level is the peak gain outside the main lobe. For a +//! uniformly illuminated circular aperture it is about minus 17.6 dB. +//! +//! Every function here takes or returns angles in radians and gains as +//! [`Gain`], so a result can be fed straight into a link budget. +//! +//! Sources: +//! +//! - Parabolic aperture gain: `G = eta (pi D / lambda)^2`, where `eta` is +//! the aperture efficiency, typically between 0.55 and 0.70 for a +//! well adjusted dish. +//! - Half power beam width of a circular aperture: `theta = k lambda / D` +//! with `k` about 1.02 for uniform illumination and close to 1.2 for +//! typical illumination tapers. +//! - The idealised element pattern: ITU-R Recommendation M.1853, which gives +//! `G = -min(12 (theta / theta_0)^2, G_0)` for the co-polar pattern of a +//! simple Earth station antenna. +//! +//! ``` +//! use apsis_antenna::ParabolicAperture; +//! +//! // A three meter dish at twelve gigahertz has a peak gain near 50 dBi. +//! let dish = ParabolicAperture { +//! diameter: 3.0, +//! efficiency: 0.65, +//! }; +//! let peak = dish.peak_gain(0.025).expect("valid wavelength"); +//! assert!(peak.dbi().expect("positive") > 45.0); +//! assert!(peak.dbi().expect("positive") < 50.0); +//! ``` + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +mod parabolic; +mod pattern; + +pub use parabolic::{ParabolicAperture, beam_width, floor_angle}; +pub use pattern::{ElementPattern, Pattern, mask_gain, off_axis_attenuation}; diff --git a/crates/rf/apsis-antenna/src/parabolic.rs b/crates/rf/apsis-antenna/src/parabolic.rs new file mode 100644 index 0000000..e720ee1 --- /dev/null +++ b/crates/rf/apsis-antenna/src/parabolic.rs @@ -0,0 +1,141 @@ +//! The parabolic aperture model. + +use apsis_rf::Gain; +use core::f64::consts::PI; + +/// A parabolic reflector described by its diameter and aperture efficiency. +/// +/// The aperture efficiency collects every loss between the power delivered +/// to the feed and the power that leaves the aperture as a uniform plane +/// wave: illumination taper, spillover, blockage by the feed and its +/// supports, and surface error. A well adjusted dish reaches 0.65 to 0.70, +/// and a small or simply built one is nearer 0.55. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ParabolicAperture { + /// The reflector diameter, in meters. + pub diameter: f64, + /// The aperture efficiency, dimensionless, in `(0, 1]`. + pub efficiency: f64, +} + +impl ParabolicAperture { + /// Returns the physical area of the aperture, in square meters. + pub fn area(self) -> f64 { + PI * (self.diameter / 2.0).powi(2) + } + + /// Returns the effective area, in square meters. + /// + /// This is the physical area scaled by the aperture efficiency. It is + /// the area that, if it collected uniformly, would deliver the observed + /// power. + pub fn effective_area(self) -> f64 { + self.area() * self.efficiency + } + + /// Returns `true` when the aperture describes a usable dish. + pub fn is_valid(self) -> bool { + self.diameter.is_finite() + && self.diameter > 0.0 + && self.efficiency.is_finite() + && self.efficiency > 0.0 + && self.efficiency <= 1.0 + } + + /// Returns the peak gain on boresight at the given wavelength. + /// + /// The relation is + /// + /// ```text + /// G = eta (pi D / lambda)^2 + /// ``` + /// + /// which follows from the effective area through + /// [`Gain::from_effective_aperture`]. Returns `None` for an invalid + /// aperture or wavelength. + pub fn peak_gain(self, wavelength: f64) -> Option { + if !self.is_valid() { + return None; + } + Gain::from_effective_aperture(self.effective_area(), wavelength) + } + + /// Returns the half power beam width at the given wavelength, in + /// radians. + /// + /// This uses the uniform illumination relation, which is the narrowest + /// a dish of this diameter can be. A real dish with an illumination + /// taper is wider; use [`ParabolicAperture::half_power_beam_width`] to + /// include the taper. + /// + /// Returns `None` for an invalid aperture or wavelength. + pub fn uniform_beam_width(self, wavelength: f64) -> Option { + if !self.is_valid() { + return None; + } + beam_width(self.diameter, wavelength, 1.02) + } + + /// Returns the half power beam width at the given wavelength, in + /// radians, allowing for an illumination taper. + /// + /// A tapered illumination reduces the sidelobes at the cost of a wider + /// main lobe. The factor `k` in `theta = k lambda / D` rises from about + /// 1.02 for uniform illumination to about 1.2 for the taper that + /// minimises sidelobes. A value of 1.15 is representative of a + /// well designed dish. + /// + /// Returns `None` for an invalid aperture or wavelength. + pub fn half_power_beam_width(self, wavelength: f64) -> Option { + if !self.is_valid() { + return None; + } + beam_width(self.diameter, wavelength, 1.15) + } +} + +/// Returns the half power beam width of a circular aperture, in radians. +/// +/// The relation is `theta = k lambda / D`, where `k` depends on how the +/// aperture is illuminated. Uniform illumination gives the narrowest beam, +/// with `k` about 1.02, and the taper that suppresses sidelobes gives the +/// widest, with `k` about 1.2. +/// +/// The result is the full width between the two three decibel points, not +/// the half width. It is a small angle approximation, so the caller should +/// confirm that the resulting width is small compared to a radian. +/// +/// Returns `None` when the diameter or wavelength is not positive, or when +/// `k` is not positive. +pub fn beam_width(diameter: f64, wavelength: f64, k: f64) -> Option { + if !diameter.is_finite() + || diameter <= 0.0 + || !wavelength.is_finite() + || wavelength <= 0.0 + || !k.is_finite() + || k <= 0.0 + { + return None; + } + Some(k * wavelength / diameter) +} + +/// Returns the angle at which the squared angle law of +/// [`off_axis_attenuation`](crate::off_axis_attenuation) reaches the +/// sidelobe floor. +/// +/// Beyond this angle the model holds the gain at the floor rather than +/// letting it fall without bound. The angle follows from setting the +/// attenuation `12 (theta / theta_0)^2` equal to the floor, which gives +/// `theta = theta_0 sqrt(floor / 12)`. +/// +/// Returns `None` when the beam width or floor is not positive and finite. +pub fn floor_angle(beam_width: f64, floor_db: f64) -> Option { + if !beam_width.is_finite() || beam_width <= 0.0 { + return None; + } + if !floor_db.is_finite() || floor_db <= 0.0 { + return None; + } + Some(beam_width * (floor_db / 12.0).sqrt()) +} diff --git a/crates/rf/apsis-antenna/src/pattern.rs b/crates/rf/apsis-antenna/src/pattern.rs new file mode 100644 index 0000000..5a07155 --- /dev/null +++ b/crates/rf/apsis-antenna/src/pattern.rs @@ -0,0 +1,244 @@ +//! Off-axis gain patterns. + +use crate::parabolic::ParabolicAperture; +use apsis_rf::{Decibel, Gain}; +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. +/// +/// The model is the one used for interference analysis in ITU-R +/// Recommendation M.1853 and adopted in the Radio Regulations. 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, +/// +/// ```text +/// G(theta) = G_peak - min(12 (theta / theta_0)^2, G_floor) +/// ``` +/// +/// where `theta_0` is the half power beam width and `G_floor` is the +/// sidelobe level to fall to, in decibels below the peak. The squared angle +/// law comes from the first term of the Airy pattern of a uniformly +/// illuminated aperture, which is a good approximation down to the first +/// sidelobe. +/// +/// 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( + peak: Gain, + beam_width: f64, + off_axis_angle: f64, + floor_db: f64, +) -> Option { + let peak_db = peak.as_decibel()?; + if !beam_width.is_finite() || beam_width <= 0.0 { + return None; + } + if !floor_db.is_finite() || floor_db <= 0.0 { + return None; + } + if !off_axis_angle.is_finite() { + return None; + } + let ratio = 12.0 * (off_axis_angle / beam_width).powi(2); + let attenuation = ratio.min(floor_db); + Some(peak_db - Decibel::from_db(attenuation)) +} + +/// Returns the gain that an antenna is required not to exceed at an angle +/// from boresight, in decibels relative to an isotropic radiator. +/// +/// This is the reference pattern of the Radio Regulations Appendix 8, used +/// to check whether an Earth station meets the off-axis emission limits that +/// protect neighbouring satellites. The pattern is piecewise: +/// +/// ```text +/// for D/lambda >= 100: +/// G = 29 - 25 log10(theta) for 1 <= theta < 36 degrees +/// G = -10 for 36 <= theta <= 180 degrees +/// for D/lambda < 100: +/// G = 32 - 25 log10(theta) for 1 <= theta < 48 degrees +/// G = -10 for 48 <= theta <= 180 degrees +/// G = 32 - 25 log10(48) for theta < 1 degree +/// ``` +/// +/// The angle is in radians and the diameter to wavelength ratio decides +/// which branch applies, because a small antenna has a broader main lobe +/// and therefore a wider range over which the pattern must be controlled. +/// +/// This function returns the limit, that is the largest gain permitted. An +/// antenna is compliant when its actual off-axis gain is at or below it. +/// +/// Returns `None` when the angle is negative, when the ratio is not +/// positive, or when the angle is not finite. +/// +/// Source: ITU, *Radio Regulations*, Appendix 8, which reproduces the +/// reference pattern of ITU-R Recommendation S.465. +pub fn mask_gain(diameter_over_wavelength: f64, off_axis_angle: f64) -> Option { + if !diameter_over_wavelength.is_finite() || diameter_over_wavelength <= 0.0 { + return None; + } + if !off_axis_angle.is_finite() || off_axis_angle < 0.0 { + return None; + } + + let large_aperture = diameter_over_wavelength >= 100.0; + // The break point is where the pattern reaches its floor, and the cap + // angle is where the falling branch begins. + let (cap_angle, floor_angle) = if large_aperture { + (1.0_f64.to_radians(), 36.0_f64.to_radians()) + } else { + (1.0_f64.to_radians(), 48.0_f64.to_radians()) + }; + let head = if large_aperture { 29.0 } else { 32.0 }; + + if off_axis_angle < cap_angle { + // Inside the first degree the pattern is taken at its value at one + // degree, which is the most restrictive point on the falling branch. + Some(head - 25.0 * cap_angle.to_degrees().log10()) + } else if off_axis_angle <= floor_angle { + Some(head - 25.0 * off_axis_angle.to_degrees().log10()) + } else { + Some(-10.0) + } +} + +/// The shape of an antenna pattern. +/// +/// A pattern is either described by its geometry, in which case the peak +/// gain and beam width are derived from the aperture, or given directly as +/// measured or published values. The enum lets a link budget accept either. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Pattern { + /// A parabolic aperture, described by its diameter and efficiency. + Parabolic { + /// The reflector diameter, in meters. + diameter: f64, + /// The aperture efficiency, dimensionless. + efficiency: f64, + }, + /// An antenna described directly by its peak gain and half power beam + /// width. + Direct { + /// The peak gain on boresight. + peak: Gain, + /// The half power beam width, in radians. + beam_width: f64, + }, +} + +impl Pattern { + /// Returns the peak gain of this pattern at the given wavelength. + /// + /// The wavelength is ignored for a directly specified pattern, which + /// already carries its peak gain. + pub fn peak_gain(self, wavelength: f64) -> Option { + match self { + Self::Parabolic { + diameter, + efficiency, + } => ParabolicAperture { + diameter, + efficiency, + } + .peak_gain(wavelength), + Self::Direct { peak, .. } => Some(peak), + } + } + + /// Returns the half power beam width of this pattern at the given + /// wavelength, in radians. + pub fn beam_width(self, wavelength: f64) -> Option { + match self { + Self::Parabolic { + diameter, + efficiency, + } => ParabolicAperture { + diameter, + efficiency, + } + .half_power_beam_width(wavelength), + Self::Direct { beam_width, .. } => { + (beam_width.is_finite() && beam_width > 0.0).then_some(beam_width) + } + } + } +} + +/// A gain pattern constructed from a peak gain and a half power beam width. +/// +/// This is the form a link budget needs: the two numbers that describe the +/// beam, plus the model that gives the gain at any angle from boresight. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ElementPattern { + /// The peak gain on boresight. + pub peak: Gain, + /// The half power beam width, in radians. + pub beam_width: f64, + /// The sidelobe floor, in decibels below the peak. + pub floor_db: f64, +} + +impl Default for ElementPattern { + /// Returns a pattern for an isotropic antenna, with a floor that makes + /// the attenuation zero in every direction. + fn default() -> Self { + Self { + peak: Gain::ISOTROPIC, + beam_width: PI, + floor_db: f64::MIN_POSITIVE, + } + } +} + +impl ElementPattern { + /// Creates a pattern from a peak gain and a half power beam width. + /// + /// The sidelobe floor is set to the value of the Radio Regulations + /// reference pattern, 32 decibels below the peak, which is a common + /// worst case for a small Earth station. + pub const fn new(peak: Gain, beam_width: f64) -> Self { + Self { + peak, + beam_width, + floor_db: 32.0, + } + } + + /// Creates a pattern from a parabolic aperture at a wavelength. + /// + /// Returns `None` when the aperture or wavelength is not usable. + pub fn from_parabolic(aperture: ParabolicAperture, wavelength: f64) -> Option { + Some(Self { + peak: aperture.peak_gain(wavelength)?, + beam_width: aperture.half_power_beam_width(wavelength)?, + floor_db: 32.0, + }) + } + + /// Returns this pattern with a different sidelobe floor. + pub const fn with_floor(mut self, floor_db: f64) -> Self { + self.floor_db = floor_db; + self + } + + /// Returns the gain at an angle from boresight. + /// + /// Returns `None` when the pattern is not usable or the angle is not + /// finite. + pub fn gain_at(self, off_axis_angle: f64) -> Option { + let attenuation = + off_axis_attenuation(self.peak, self.beam_width, off_axis_angle, self.floor_db)?; + Gain::from_dbi(attenuation.db()) + } + + /// Returns `true` when the beam width and floor describe a usable + /// pattern. + pub fn is_valid(self) -> bool { + self.peak.dbi().is_some() + && self.beam_width.is_finite() + && self.beam_width > 0.0 + && self.floor_db.is_finite() + && self.floor_db > 0.0 + } +} diff --git a/crates/rf/apsis-antenna/tests/parabolic.rs b/crates/rf/apsis-antenna/tests/parabolic.rs new file mode 100644 index 0000000..cd11c45 --- /dev/null +++ b/crates/rf/apsis-antenna/tests/parabolic.rs @@ -0,0 +1,141 @@ +//! Tests for the parabolic aperture model. + +use apsis_antenna::{ParabolicAperture, beam_width}; +use core::f64::consts::PI; + +/// A three meter dish with a typical aperture efficiency. +const DISH: ParabolicAperture = ParabolicAperture { + diameter: 3.0, + efficiency: 0.65, +}; + +#[test] +fn aperture_area_follows_from_the_diameter() { + let expected = PI * 1.5 * 1.5; + assert!((DISH.area() - expected).abs() < 1e-12); + assert!((DISH.effective_area() - expected * 0.65).abs() < 1e-12); +} + +#[test] +fn peak_gain_matches_the_closed_form() { + // At 12 GHz the wavelength is 0.02498 m, and the gain is + // eta (pi D / lambda)^2. + let wavelength = 0.024_982_710_7; + let gain = DISH.peak_gain(wavelength).expect("valid"); + let expected = 0.65 * (PI * 3.0 / wavelength).powi(2); + assert!((gain.ratio() - expected).abs() < 1e-6); + // The result is about 49.66 dBi. + assert!( + (gain.dbi().expect("positive") - 49.661_764_937_398_814).abs() < 1e-9, + "dBi {}", + gain.dbi().expect("positive") + ); +} + +#[test] +fn a_larger_dish_has_more_gain() { + let small = ParabolicAperture { + diameter: 1.0, + efficiency: 0.65, + }; + let large = ParabolicAperture { + diameter: 2.0, + efficiency: 0.65, + }; + let wavelength = 0.025; + let small_gain = small.peak_gain(wavelength).expect("valid"); + let large_gain = large.peak_gain(wavelength).expect("valid"); + // Doubling the diameter quadruples the gain, which is six decibels. + assert!( + (large_gain.dbi().expect("positive") - small_gain.dbi().expect("positive") - 6.020_6).abs() + < 1e-6 + ); +} + +#[test] +fn a_higher_aperture_efficiency_adds_gain() { + let low = ParabolicAperture { + diameter: 3.0, + efficiency: 0.5, + }; + let high = ParabolicAperture { + diameter: 3.0, + efficiency: 0.7, + }; + let wavelength = 0.025; + assert!( + high.peak_gain(wavelength).expect("valid").ratio() + > low.peak_gain(wavelength).expect("valid").ratio() + ); +} + +#[test] +fn the_beam_width_follows_the_wavelength_over_diameter() { + let wavelength = 0.025; + let uniform = DISH.uniform_beam_width(wavelength).expect("valid"); + let tapered = DISH.half_power_beam_width(wavelength).expect("valid"); + assert!((uniform - 1.02 * wavelength / 3.0).abs() < 1e-12); + // A tapered illumination widens the beam. + assert!(tapered > uniform); + assert!((tapered - 1.15 * wavelength / 3.0).abs() < 1e-12); +} + +#[test] +fn the_uniform_beam_width_matches_a_known_value() { + // A 3 m dish at 12 GHz has a half power beam width near 0.0085 radians, + // which is about 0.49 degrees. + let width = DISH.uniform_beam_width(0.024_982_710_7).expect("valid"); + assert!((width - 0.008_494).abs() < 1e-5, "width {width}"); + assert!((width.to_degrees() - 0.486_7).abs() < 0.001); +} + +#[test] +fn beam_width_helper_validates_its_arguments() { + assert!(beam_width(3.0, 0.025, 1.02).is_some()); + assert_eq!(beam_width(0.0, 0.025, 1.02), None); + assert_eq!(beam_width(-1.0, 0.025, 1.02), None); + assert_eq!(beam_width(3.0, 0.0, 1.02), None); + assert_eq!(beam_width(3.0, -0.025, 1.02), None); + assert_eq!(beam_width(3.0, 0.025, 0.0), None); + assert_eq!(beam_width(f64::NAN, 0.025, 1.02), None); + assert_eq!(beam_width(3.0, f64::INFINITY, 1.02), None); +} + +#[test] +fn an_invalid_aperture_produces_no_result() { + let zero_diameter = ParabolicAperture { + diameter: 0.0, + efficiency: 0.65, + }; + assert!(!zero_diameter.is_valid()); + assert_eq!(zero_diameter.peak_gain(0.025), None); + assert_eq!(zero_diameter.half_power_beam_width(0.025), None); + + let bad_efficiency = ParabolicAperture { + diameter: 3.0, + efficiency: 1.5, + }; + assert!(!bad_efficiency.is_valid()); + assert_eq!(bad_efficiency.peak_gain(0.025), None); + + let zero_efficiency = ParabolicAperture { + diameter: 3.0, + efficiency: 0.0, + }; + assert!(!zero_efficiency.is_valid()); + + let non_finite = ParabolicAperture { + diameter: f64::NAN, + efficiency: 0.65, + }; + assert!(!non_finite.is_valid()); +} + +#[test] +fn the_aperture_and_gain_conversions_are_consistent() { + // The peak gain and the effective area must describe the same antenna. + let wavelength = 0.025; + let gain = DISH.peak_gain(wavelength).expect("valid"); + let aperture = gain.effective_aperture(wavelength).expect("valid"); + assert!((aperture - DISH.effective_area()).abs() < 1e-9); +} diff --git a/crates/rf/apsis-antenna/tests/pattern.rs b/crates/rf/apsis-antenna/tests/pattern.rs new file mode 100644 index 0000000..92d92f8 --- /dev/null +++ b/crates/rf/apsis-antenna/tests/pattern.rs @@ -0,0 +1,249 @@ +//! Tests for off-axis gain patterns. + +use apsis_antenna::{ + ElementPattern, ParabolicAperture, Pattern, floor_angle, mask_gain, off_axis_attenuation, +}; +use apsis_rf::Gain; +use core::f64::consts::PI; + +const TOLERANCE: f64 = 1e-9; + +#[test] +fn boresight_gain_is_the_peak() { + let peak = Gain::from_dbi(30.0).expect("valid"); + let beam = 0.01; + let attenuation = off_axis_attenuation(peak, beam, 0.0, 32.0).expect("valid arguments"); + assert!((attenuation.db() - 30.0).abs() < TOLERANCE); +} + +#[test] +fn the_half_power_angle_gives_three_decibels() { + // At half the beam width the off-axis angle is half of the half power + // beam width, and the squared angle law gives 12 * (0.5)^2 = 3 dB. + let peak = Gain::from_dbi(30.0).expect("valid"); + let beam = 0.01; + let attenuation = off_axis_attenuation(peak, beam, beam / 2.0, 32.0).expect("valid arguments"); + assert!( + (attenuation.db() - 27.0).abs() < 1e-9, + "{}", + attenuation.db() + ); +} + +#[test] +fn the_attenuation_grows_with_the_square_of_the_angle() { + let peak = Gain::from_dbi(40.0).expect("valid"); + let beam = 0.02; + // Both angles are chosen to stay on the squared angle law, well inside + // the angle at which the pattern reaches its floor. + let first = off_axis_attenuation(peak, beam, 0.02, 60.0).expect("valid"); + let second = off_axis_attenuation(peak, beam, 0.04, 60.0).expect("valid"); + // At the beam width the attenuation is 12 dB; at twice the beam width + // it is 48 dB. The rise of 36 dB is what a quadrupling of the + // attenuation ratio means in decibels. + assert!((first.db() - (40.0 - 12.0)).abs() < 1e-9); + assert!((second.db() - (40.0 - 48.0)).abs() < 1e-9); + let rise = first.db() - second.db(); + assert!((rise - 36.0).abs() < 1e-9, "rise {rise}"); +} + +#[test] +fn the_pattern_floors_at_the_sidelobe_level() { + let peak = Gain::from_dbi(40.0).expect("valid"); + let beam = 0.02; + let floor = 32.0; + // Far off axis the gain is the peak minus the floor. + let far = off_axis_attenuation(peak, beam, PI, floor).expect("valid"); + assert!((far.db() - (40.0 - floor)).abs() < TOLERANCE); + + // The floor is reached at the angle the helper reports. + let angle = floor_angle(beam, floor).expect("valid"); + let at_floor = off_axis_attenuation(peak, beam, angle, floor).expect("valid"); + assert!((at_floor.db() - (40.0 - floor)).abs() < 1e-9); + // Just inside that angle the gain is above the floor. + let inside = off_axis_attenuation(peak, beam, angle * 0.99, floor).expect("valid"); + assert!(inside.db() > at_floor.db()); +} + +#[test] +fn invalid_arguments_are_rejected() { + let peak = Gain::from_dbi(30.0).expect("valid"); + assert_eq!(off_axis_attenuation(peak, 0.0, 0.01, 32.0), None); + assert_eq!(off_axis_attenuation(peak, -0.01, 0.01, 32.0), None); + assert_eq!(off_axis_attenuation(peak, 0.01, 0.01, 0.0), None); + assert_eq!(off_axis_attenuation(peak, 0.01, f64::NAN, 32.0), None); + assert_eq!(floor_angle(0.0, 32.0), None); + assert_eq!(floor_angle(0.01, 0.0), None); +} + +#[test] +fn the_itu_mask_uses_the_large_aperture_branch() { + // For D/lambda >= 100 the pattern is 29 - 25 log10(theta) between one + // and 36 degrees and -10 beyond. + let at_ten_degrees = mask_gain(1_000.0, 10.0_f64.to_radians()).expect("valid"); + assert!((at_ten_degrees - (29.0 - 25.0 * 10.0_f64.log10())).abs() < 1e-9); + assert!((at_ten_degrees - 4.0).abs() < 1e-9); + + // The falling branch ends at 36 degrees, where the expression is close + // to but not exactly -10 dB. The standard accepts that small step and + // then holds the value at -10 dB. + let at_break = mask_gain(1_000.0, 36.0_f64.to_radians()).expect("valid"); + assert!( + (at_break - (29.0 - 25.0 * 36.0_f64.log10())).abs() < 1e-9, + "at break {at_break}" + ); + assert!((at_break + 10.0).abs() < 0.1, "at break {at_break}"); + + let beyond = mask_gain(1_000.0, 90.0_f64.to_radians()).expect("valid"); + assert!((beyond + 10.0).abs() < TOLERANCE); +} + +#[test] +fn the_itu_mask_uses_the_small_aperture_branch() { + // For D/lambda < 100 the pattern starts at 32 and breaks at 48 degrees. + let at_ten_degrees = mask_gain(50.0, 10.0_f64.to_radians()).expect("valid"); + assert!((at_ten_degrees - (32.0 - 25.0 * 10.0_f64.log10())).abs() < 1e-9); + + // The falling branch ends at 48 degrees, again close to -10 dB. + let at_break = mask_gain(50.0, 48.0_f64.to_radians()).expect("valid"); + assert!( + (at_break - (32.0 - 25.0 * 48.0_f64.log10())).abs() < 1e-9, + "at break {at_break}" + ); + assert!((at_break + 10.0).abs() < 0.1, "at break {at_break}"); + + let beyond = mask_gain(50.0, 120.0_f64.to_radians()).expect("valid"); + assert!((beyond + 10.0).abs() < TOLERANCE); +} + +#[test] +fn the_itu_mask_is_capped_inside_one_degree() { + // Below one degree the pattern does not continue to rise without bound. + let peak_limit = 32.0 - 25.0 * 1.0_f64.log10(); + for angle_degrees in [0.0_f64, 0.01, 0.5, 0.99] { + let value = mask_gain(50.0, angle_degrees.to_radians()).expect("valid"); + assert!( + (value - peak_limit).abs() < 1e-9, + "angle {angle_degrees} gave {value}" + ); + } +} + +#[test] +fn the_itu_mask_rejects_invalid_arguments() { + assert_eq!(mask_gain(0.0, 0.1), None); + assert_eq!(mask_gain(-1.0, 0.1), None); + assert_eq!(mask_gain(1_000.0, -0.1), None); + assert_eq!(mask_gain(f64::NAN, 0.1), None); + assert_eq!(mask_gain(1_000.0, f64::NAN), None); +} + +#[test] +fn an_element_pattern_reproduces_the_model() { + let pattern = ElementPattern::new(Gain::from_dbi(35.0).expect("valid"), 0.02); + assert!(pattern.is_valid()); + let gain = pattern.gain_at(0.01).expect("valid"); + // 0.01 is half the beam width, so the attenuation is 3 dB. + assert!((gain.dbi().expect("positive") - 32.0).abs() < 1e-9); +} + +#[test] +fn an_element_pattern_floors_far_off_axis() { + let pattern = ElementPattern::new(Gain::from_dbi(35.0).expect("valid"), 0.02).with_floor(30.0); + let gain = pattern.gain_at(PI).expect("valid"); + assert!((gain.dbi().expect("positive") - 5.0).abs() < 1e-9); +} + +#[test] +fn a_pattern_can_be_built_from_a_dish() { + let dish = ParabolicAperture { + diameter: 3.0, + efficiency: 0.65, + }; + let wavelength = 0.025; + let pattern = ElementPattern::from_parabolic(dish, wavelength).expect("valid"); + assert!(pattern.is_valid()); + assert!( + (pattern.peak.dbi().expect("positive") + - dish + .peak_gain(wavelength) + .expect("valid") + .dbi() + .expect("positive")) + .abs() + < 1e-9 + ); + assert!( + (pattern.beam_width - dish.half_power_beam_width(wavelength).expect("valid")).abs() < 1e-12 + ); +} + +#[test] +fn an_invalid_dish_produces_no_pattern() { + let dish = ParabolicAperture { + diameter: 0.0, + efficiency: 0.65, + }; + assert_eq!(ElementPattern::from_parabolic(dish, 0.025), None); +} + +#[test] +fn the_pattern_enum_derives_the_same_values() { + let dish = ParabolicAperture { + diameter: 3.0, + efficiency: 0.65, + }; + let wavelength = 0.025; + let from_geometry = Pattern::Parabolic { + diameter: 3.0, + efficiency: 0.65, + }; + assert!( + (from_geometry.peak_gain(wavelength).expect("valid").ratio() + - dish.peak_gain(wavelength).expect("valid").ratio()) + .abs() + < 1e-9 + ); + assert!( + (from_geometry.beam_width(wavelength).expect("valid") + - dish.half_power_beam_width(wavelength).expect("valid")) + .abs() + < 1e-12 + ); + + let direct = Pattern::Direct { + peak: Gain::from_dbi(30.0).expect("valid"), + beam_width: 0.05, + }; + // The wavelength is ignored for a directly specified pattern. + assert!( + (direct + .peak_gain(0.025) + .expect("valid") + .dbi() + .expect("positive") + - 30.0) + .abs() + < 1e-9 + ); + assert!((direct.beam_width(0.025).expect("valid") - 0.05).abs() < 1e-12); +} + +#[test] +fn an_invalid_direct_pattern_produces_no_beam_width() { + let direct = Pattern::Direct { + peak: Gain::from_dbi(30.0).expect("valid"), + beam_width: -1.0, + }; + assert_eq!(direct.beam_width(0.025), None); +} + +#[test] +fn the_default_pattern_is_effectively_isotropic() { + let pattern = ElementPattern::default(); + assert!(pattern.is_valid()); + // With a floor at the smallest positive number the attenuation is + // negligible in every direction. + let gain = pattern.gain_at(1.0).expect("valid"); + assert!(gain.dbi().expect("positive").abs() < 1e-6); +}