rf: add parabolic aperture and off-axis pattern models
This commit is contained in:
parent
cd1cdb7810
commit
c9baeb87d2
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -11,3 +11,5 @@ publish = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
apsis-core.workspace = true
|
||||
apsis-rf.workspace = true
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<Gain> {
|
||||
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<f64> {
|
||||
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<f64> {
|
||||
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<f64> {
|
||||
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<f64> {
|
||||
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())
|
||||
}
|
||||
|
|
@ -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<Decibel> {
|
||||
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<f64> {
|
||||
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<Gain> {
|
||||
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<f64> {
|
||||
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<Self> {
|
||||
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<Gain> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue