From c6064cc8e5b71aa70e97482acc88530c95b1e3b2 Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 14:44:05 +0800 Subject: [PATCH] rf: add carrier to interference and noise combination --- Cargo.lock | 4 + crates/rf/apsis-interference/Cargo.toml | 2 + crates/rf/apsis-interference/src/lib.rs | 57 ++++ crates/rf/apsis-interference/src/ratio.rs | 220 +++++++++++++++ crates/rf/apsis-interference/tests/ratio.rs | 282 ++++++++++++++++++++ 5 files changed, 565 insertions(+) create mode 100644 crates/rf/apsis-interference/src/ratio.rs create mode 100644 crates/rf/apsis-interference/tests/ratio.rs diff --git a/Cargo.lock b/Cargo.lock index b170023..164dc3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,6 +87,10 @@ version = "0.1.0" [[package]] name = "apsis-interference" version = "0.1.0" +dependencies = [ + "apsis-core", + "apsis-rf", +] [[package]] name = "apsis-isl" diff --git a/crates/rf/apsis-interference/Cargo.toml b/crates/rf/apsis-interference/Cargo.toml index a2c094c..073e517 100644 --- a/crates/rf/apsis-interference/Cargo.toml +++ b/crates/rf/apsis-interference/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-interference/src/lib.rs b/crates/rf/apsis-interference/src/lib.rs index 49db832..62eff4a 100644 --- a/crates/rf/apsis-interference/src/lib.rs +++ b/crates/rf/apsis-interference/src/lib.rs @@ -1 +1,58 @@ //! Interference and carrier-to-interference computation. +//! +//! A wanted signal rarely arrives alone. This crate combines the wanted +//! carrier with one or more interfering carriers and with noise, and reports +//! the ratios that decide whether the wanted link is usable. +//! +//! The figures computed here are: +//! +//! - The carrier to interference ratio, over one interferer or several. +//! - The carrier to noise plus interference ratio, which is what a receiver +//! actually sees, because it cannot separate the two. +//! - The equivalent noise temperature of an interferer, which lets an +//! interference contribution be folded into the link budget as if it were +//! noise. +//! +//! Interference from a second satellite sharing the same frequency is +//! described by the angle between the two satellites as seen from the ground +//! station. The receive antenna has a sidelobe response in that direction, +//! and the ratio of the interferer's power to the wanted power follows from +//! the two gains and the two path losses. This crate takes the two gains as +//! inputs, so it composes with the pattern models in `apsis-antenna`. +//! +//! Source: the definitions of carrier to interference and carrier to noise +//! plus interference, and the practice of expressing interference as an +//! equivalent noise temperature, are standard in satellite link design and +//! appear for example in the ITU-R Handbook on Satellite Communications. +//! +//! ``` +//! use apsis_interference::{Interferer, carrier_to_interference, combine}; +//! use apsis_rf::{Decibel, Power}; +//! +//! let wanted = Power::from_dbw(-130.0).expect("finite"); +//! let interferer = Interferer { +//! power: Power::from_dbw(-140.0).expect("finite"), +//! }; +//! +//! // One interferer ten decibels below the carrier leaves a C/I of 10 dB. +//! let ratio = carrier_to_interference(wanted, &[interferer]).expect("valid"); +//! assert!((ratio.db() - 10.0).abs() < 1e-9); +//! +//! // Combining with noise can only lower the result. +//! let combined = combine(ratio, Decibel::from_db(15.0)).expect("valid"); +//! assert!(combined.db() < ratio.db()); +//! ``` + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +mod ratio; + +pub use ratio::{ + InterferenceSummary, Interferer, carrier_to_interference, carrier_to_interference_single, + carrier_to_noise_plus_interference, combine, equivalent_noise_temperature, + interference_to_noise_ratio, summarise, total_interference, +}; + +pub use apsis_core::constant::BOLTZMANN; +pub use apsis_rf::{Decibel, Power}; diff --git a/crates/rf/apsis-interference/src/ratio.rs b/crates/rf/apsis-interference/src/ratio.rs new file mode 100644 index 0000000..35e0fbb --- /dev/null +++ b/crates/rf/apsis-interference/src/ratio.rs @@ -0,0 +1,220 @@ +//! Carrier to interference ratios and their combination with noise. + +use apsis_core::constant::BOLTZMANN; +use apsis_rf::{Decibel, Power}; + +/// One interfering carrier seen at the receiver input. +/// +/// The power is the interfering power that reaches the receiver, already +/// including the transmit power, the transmit gain, the path loss, the +/// receive antenna gain in the direction of the interferer, and any +/// propagation loss. This crate combines the powers; it does not compute the +/// geometry. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Interferer { + /// The interfering power at the receiver input, in watts. + pub power: Power, +} + +impl Interferer { + /// Creates an interferer from a power at the receiver input. + pub const fn new(power: Power) -> Self { + Self { power } + } +} + +/// The combined result of a wanted carrier and its interference. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct InterferenceSummary { + /// The wanted carrier power, in watts. + pub carrier: Power, + /// The total interfering power, in watts. + pub interference: Power, + /// The carrier to interference ratio. + pub carrier_to_interference: Decibel, + /// The carrier to noise plus interference ratio, when a noise power was + /// given. + pub carrier_to_noise_plus_interference: Option, +} + +impl InterferenceSummary { + /// Returns the interference to carrier ratio. + /// + /// This is the reciprocal of the carrier to interference ratio and is + /// the form some specifications use. + pub fn interference_to_carrier(self) -> Decibel { + self.carrier_to_interference.negated() + } +} + +/// Returns the total interfering power from a set of interferers. +/// +/// Interfering powers add, because they are independent signals and their +/// power is what matters, not their amplitudes. Two equal interferers +/// therefore give twice the power of one, which is three decibels and not +/// six. +/// +/// Returns `None` when any interferer has an invalid power. +pub fn total_interference(interferers: &[Interferer]) -> Option { + let mut total = 0.0; + for interferer in interferers { + if !interferer.power.is_valid() { + return None; + } + total += interferer.power.watts(); + } + Some(Power::from_watts(total)) +} + +/// Returns the carrier to interference ratio for a carrier and a set of +/// interferers. +/// +/// The ratio is the wanted carrier power divided by the sum of the +/// interfering powers. A large ratio means the interferers are weak. +/// +/// Returns `None` when the carrier is not positive, when any interferer is +/// invalid, or when the total interference is zero, which would give an +/// infinite ratio. +pub fn carrier_to_interference(carrier: Power, interferers: &[Interferer]) -> Option { + if !carrier.is_valid() || carrier.watts() <= 0.0 { + return None; + } + let interference = total_interference(interferers)?; + if interference.watts() <= 0.0 { + return None; + } + Decibel::from_ratio(carrier.watts() / interference.watts()) +} + +/// Returns the carrier to interference ratio for a single interferer. +/// +/// This is a convenience for the common case of one dominant interferer. +/// +/// Returns `None` when either power is not positive. +pub fn carrier_to_interference_single(carrier: Power, interferer: Interferer) -> Option { + carrier_to_interference(carrier, &[interferer]) +} + +/// Combines a ratio with another ratio of the same kind. +/// +/// When two independent impairments act together, their effects add as +/// reciprocal ratios, which in decibels means +/// +/// ```text +/// 1 / R = 1 / R_1 + 1 / R_2 +/// ``` +/// +/// The result is always below the smaller of the two, so adding a second +/// impairment can only lower the combined ratio. This is how a carrier to +/// noise ratio and a carrier to interference ratio are combined into a +/// carrier to noise plus interference ratio. +/// +/// Returns `None` when either value is not finite. +pub fn combine(first: Decibel, second: Decibel) -> Option { + if !first.is_finite() || !second.is_finite() { + return None; + } + // A ratio of zero in decibels means an infinite impairment, so the + // combination is also infinite. + let first_ratio = first.ratio(); + let second_ratio = second.ratio(); + if !first_ratio.is_finite() || !second_ratio.is_finite() { + return None; + } + Decibel::from_ratio(1.0 / (1.0 / first_ratio + 1.0 / second_ratio)) +} + +/// Returns the carrier to noise plus interference ratio. +/// +/// This combines a carrier to noise ratio and a carrier to interference +/// ratio through [`combine`]. It is the ratio a receiver actually +/// experiences, because it cannot separate thermal noise from a competing +/// carrier. +/// +/// Returns `None` when either ratio is not finite. +pub fn carrier_to_noise_plus_interference( + carrier_to_noise: Decibel, + carrier_to_interference: Decibel, +) -> Option { + combine(carrier_to_noise, carrier_to_interference) +} + +/// Returns the interference to noise ratio. +/// +/// This compares the interfering power with the thermal noise power in the +/// receiver. A value well below zero decibels means the interference is +/// negligible against the noise and can be ignored; a value near or above +/// zero means it must be accounted for. +/// +/// Returns `None` when either power is not positive. +pub fn interference_to_noise_ratio(interference: Power, noise: Power) -> Option { + if !interference.is_valid() || !noise.is_valid() { + return None; + } + if interference.watts() <= 0.0 || noise.watts() <= 0.0 { + return None; + } + Decibel::from_ratio(interference.watts() / noise.watts()) +} + +/// Returns the noise temperature equivalent to an interfering power. +/// +/// An interferer that a receiver treats as noise can be folded into the link +/// budget as an increase in system noise temperature. In temperature the +/// relation is +/// +/// ```text +/// T = P / (k B) +/// ``` +/// +/// with `P` the interfering power, `k` the Boltzmann constant, and `B` the +/// receiver noise bandwidth. The temperature is the value that, added to the +/// system temperature, would raise the noise by the same amount as the +/// interferer. +/// +/// Returns `None` when the power is not positive or the bandwidth is not +/// positive and finite. +pub fn equivalent_noise_temperature(interference: Power, bandwidth: f64) -> Option { + if !interference.is_valid() || interference.watts() <= 0.0 { + return None; + } + if !bandwidth.is_finite() || bandwidth <= 0.0 { + return None; + } + Some(interference.watts() / (BOLTZMANN * bandwidth)) +} + +/// Returns a summary of a carrier with its interference. +/// +/// When a noise power is given, the carrier to noise plus interference ratio +/// is computed as well. Pass `None` for the noise power when only the +/// interference is of interest. +/// +/// Returns `None` when the carrier is not positive, when any interferer is +/// invalid, or when the total interference is zero. +pub fn summarise( + carrier: Power, + interferers: &[Interferer], + noise: Option, +) -> Option { + let interference = total_interference(interferers)?; + let carrier_to_interference = carrier_to_interference(carrier, interferers)?; + + let carrier_to_noise_plus_interference = match noise { + Some(noise) => { + if !noise.is_valid() || noise.watts() <= 0.0 { + return None; + } + let carrier_to_noise = Decibel::from_ratio(carrier.watts() / noise.watts())?; + Some(combine(carrier_to_noise, carrier_to_interference)?) + } + None => None, + }; + + Some(InterferenceSummary { + carrier, + interference, + carrier_to_interference, + carrier_to_noise_plus_interference, + }) +} diff --git a/crates/rf/apsis-interference/tests/ratio.rs b/crates/rf/apsis-interference/tests/ratio.rs new file mode 100644 index 0000000..8b882b7 --- /dev/null +++ b/crates/rf/apsis-interference/tests/ratio.rs @@ -0,0 +1,282 @@ +//! Tests for carrier to interference computation. + +use apsis_interference::{ + BOLTZMANN, Decibel, Interferer, Power, carrier_to_interference, carrier_to_interference_single, + carrier_to_noise_plus_interference, combine, equivalent_noise_temperature, + interference_to_noise_ratio, summarise, total_interference, +}; + +const TOLERANCE: f64 = 1e-9; + +/// A wanted carrier at a convenient level. +fn carrier() -> Power { + Power::from_dbw(-130.0).expect("finite") +} + +/// An interferer at the given level relative to the carrier. +fn interferer_at(decibels_below: f64) -> Interferer { + Interferer::new(Power::from_dbw(-130.0 - decibels_below).expect("finite")) +} + +#[test] +fn a_single_interferer_gives_the_expected_ratio() { + // An interferer ten decibels below the carrier gives a ratio of 10 dB. + let ratio = carrier_to_interference_single(carrier(), interferer_at(10.0)).expect("valid"); + assert!((ratio.db() - 10.0).abs() < TOLERANCE, "{}", ratio.db()); +} + +#[test] +fn an_equal_interferer_gives_zero_decibels() { + let ratio = carrier_to_interference_single(carrier(), interferer_at(0.0)).expect("valid"); + assert!(ratio.db().abs() < TOLERANCE); +} + +#[test] +fn an_interferer_above_the_carrier_gives_a_negative_ratio() { + let ratio = carrier_to_interference_single(carrier(), interferer_at(-5.0)).expect("valid"); + assert!((ratio.db() + 5.0).abs() < TOLERANCE); +} + +#[test] +fn interfering_powers_add() { + // Two equal interferers give twice the power of one, which is three + // decibels above one alone. + let one = total_interference(&[interferer_at(10.0)]).expect("valid"); + let two = total_interference(&[interferer_at(10.0), interferer_at(10.0)]).expect("valid"); + assert!( + (two.dbw().expect("positive") - one.dbw().expect("positive") - 3.010_299_956_639_812).abs() + < 1e-9 + ); +} + +#[test] +fn an_empty_interferer_list_has_no_power() { + let total = total_interference(&[]).expect("empty is valid"); + assert_eq!(total.watts(), 0.0); + // With no interference there is no ratio to compute. + assert_eq!(carrier_to_interference(carrier(), &[]), None); +} + +#[test] +fn the_ratio_combines_multiple_interferers() { + // One interferer at 10 dB below gives 10 dB. Adding a second at the + // same level halves the ratio, which is three decibels less. + let single = carrier_to_interference(carrier(), &[interferer_at(10.0)]).expect("valid"); + let pair = carrier_to_interference(carrier(), &[interferer_at(10.0), interferer_at(10.0)]) + .expect("valid"); + assert!( + (single.db() - pair.db() - 3.010_299_956_639_812).abs() < 1e-9, + "{} vs {}", + single.db(), + pair.db() + ); +} + +#[test] +fn a_dominant_interferer_hides_the_others() { + // An interferer twenty decibels weaker than the carrier contributes + // almost nothing to the total, so it barely changes the ratio. + let strong = carrier_to_interference(carrier(), &[interferer_at(10.0)]).expect("valid"); + let with_weak = carrier_to_interference(carrier(), &[interferer_at(10.0), interferer_at(30.0)]) + .expect("valid"); + assert!(with_weak.db() < strong.db()); + assert!( + strong.db() - with_weak.db() < 0.05, + "change was {}", + strong.db() - with_weak.db() + ); +} + +#[test] +fn invalid_inputs_are_rejected() { + let zero_carrier = Power::from_watts(0.0); + assert_eq!( + carrier_to_interference(zero_carrier, &[interferer_at(10.0)]), + None + ); + let negative = Interferer::new(Power::from_watts(-1.0)); + assert_eq!(carrier_to_interference(carrier(), &[negative]), None); + assert_eq!(total_interference(&[negative]), None); + // A zero interferer gives an infinite ratio, which has no finite + // decibel value here. + let zero_interferer = Interferer::new(Power::from_watts(0.0)); + assert_eq!(carrier_to_interference(carrier(), &[zero_interferer]), None); +} + +#[test] +fn combining_two_equal_ratios_lowers_the_result_by_three_decibels() { + let first = Decibel::from_db(20.0); + let second = Decibel::from_db(20.0); + let combined = combine(first, second).expect("valid"); + assert!( + (combined.db() - 16.989_700_043_360_188).abs() < 1e-9, + "{}", + combined.db() + ); +} + +#[test] +fn combining_a_small_ratio_with_a_large_one_gives_the_small_one() { + // A carrier to noise ratio of 30 dB alongside a carrier to interference + // ratio of 5 dB is dominated by the interference. + let combined = combine(Decibel::from_db(30.0), Decibel::from_db(5.0)).expect("valid"); + assert!(combined.db() < 5.0); + assert!( + (combined.db() - 4.986_288_071_673_166).abs() < 1e-9, + "{}", + combined.db() + ); +} + +#[test] +fn combining_is_symmetric() { + let first = Decibel::from_db(17.0); + let second = Decibel::from_db(9.0); + let forward = combine(first, second).expect("valid"); + let backward = combine(second, first).expect("valid"); + assert!((forward.db() - backward.db()).abs() < TOLERANCE); +} + +#[test] +fn the_same_helper_is_available_for_noise_plus_interference() { + let noise = Decibel::from_db(30.0); + let interference = Decibel::from_db(5.0); + let direct = combine(noise, interference).expect("valid"); + let named = carrier_to_noise_plus_interference(noise, interference).expect("valid"); + assert!((direct.db() - named.db()).abs() < TOLERANCE); +} + +#[test] +fn invalid_ratios_are_rejected_when_combining() { + assert_eq!( + combine(Decibel::from_db(f64::NAN), Decibel::from_db(1.0)), + None + ); + assert_eq!( + combine(Decibel::from_db(1.0), Decibel::from_db(f64::INFINITY)), + None + ); +} + +#[test] +fn the_interference_to_noise_ratio_compares_two_powers() { + let interference = Power::from_dbw(-140.0).expect("finite"); + let noise = Power::from_dbw(-130.0).expect("finite"); + let ratio = interference_to_noise_ratio(interference, noise).expect("valid"); + // The interference is ten decibels below the noise. + assert!((ratio.db() + 10.0).abs() < TOLERANCE); +} + +#[test] +fn invalid_powers_are_rejected_in_the_interference_to_noise_ratio() { + let noise = Power::from_dbw(-130.0).expect("finite"); + assert_eq!( + interference_to_noise_ratio(Power::from_watts(0.0), noise), + None + ); + assert_eq!( + interference_to_noise_ratio(Power::from_watts(-1.0), noise), + None + ); + assert_eq!( + interference_to_noise_ratio( + Power::from_dbw(-140.0).expect("finite"), + Power::from_watts(0.0) + ), + None + ); +} + +#[test] +fn the_equivalent_noise_temperature_follows_the_boltzmann_relation() { + let interference = Power::from_dbw(-140.0).expect("finite"); + let bandwidth = 36.0e6; + let temperature = equivalent_noise_temperature(interference, bandwidth).expect("valid"); + let expected = interference.watts() / (BOLTZMANN * bandwidth); + assert!((temperature - expected).abs() < 1e-9); + // The interference is 1e-14 W, so the temperature is about 20 K. + assert!( + temperature > 15.0 && temperature < 25.0, + "temperature {temperature}" + ); +} + +#[test] +fn the_equivalent_temperature_grows_with_the_interfering_power() { + let low = equivalent_noise_temperature(Power::from_dbw(-150.0).expect("finite"), 1.0e6) + .expect("valid"); + let high = equivalent_noise_temperature(Power::from_dbw(-140.0).expect("finite"), 1.0e6) + .expect("valid"); + assert!(high > low); + // Ten decibels of power is ten times the temperature. + assert!((high / low - 10.0).abs() < 1e-6); +} + +#[test] +fn the_equivalent_temperature_falls_with_bandwidth() { + let narrow = equivalent_noise_temperature(Power::from_dbw(-140.0).expect("finite"), 1.0e6) + .expect("valid"); + let wide = equivalent_noise_temperature(Power::from_dbw(-140.0).expect("finite"), 10.0e6) + .expect("valid"); + assert!(narrow > wide); + assert!((narrow / wide - 10.0).abs() < 1e-6); +} + +#[test] +fn invalid_arguments_are_rejected_for_the_temperature() { + let interference = Power::from_dbw(-140.0).expect("finite"); + assert_eq!( + equivalent_noise_temperature(Power::from_watts(0.0), 1.0e6), + None + ); + assert_eq!(equivalent_noise_temperature(interference, 0.0), None); + assert_eq!(equivalent_noise_temperature(interference, -1.0), None); + assert_eq!(equivalent_noise_temperature(interference, f64::NAN), None); +} + +#[test] +fn a_summary_reports_the_carrier_and_the_interference() { + let summary = summarise( + carrier(), + &[interferer_at(10.0)], + Some(Power::from_dbw(-140.0).expect("finite")), + ) + .expect("valid"); + assert_eq!(summary.carrier, carrier()); + assert!(summary.interference.watts() > 0.0); + assert!((summary.carrier_to_interference.db() - 10.0).abs() < TOLERANCE); + // The combined ratio is below both the carrier to noise and the carrier + // to interference ratio. + let combined = summary + .carrier_to_noise_plus_interference + .expect("noise was given"); + assert!(combined.db() < summary.carrier_to_interference.db()); +} + +#[test] +fn a_summary_without_noise_omits_the_combined_ratio() { + let summary = summarise(carrier(), &[interferer_at(10.0)], None).expect("valid"); + assert_eq!(summary.carrier_to_noise_plus_interference, None); +} + +#[test] +fn the_summary_reports_the_interference_to_carrier_ratio() { + let summary = summarise(carrier(), &[interferer_at(10.0)], None).expect("valid"); + let interference_to_carrier = summary.interference_to_carrier(); + assert!((interference_to_carrier.db() + 10.0).abs() < TOLERANCE); +} + +#[test] +fn a_summary_rejects_invalid_inputs() { + let invalid = Interferer::new(Power::from_watts(-1.0)); + assert_eq!(summarise(carrier(), &[invalid], None), None); + assert_eq!(summarise(carrier(), &[], None), None); + assert_eq!( + summarise( + carrier(), + &[interferer_at(10.0)], + Some(Power::from_watts(0.0)) + ), + None + ); +}