rf: add carrier to interference and noise combination

This commit is contained in:
ApsisRS contributors 2026-09-26 14:44:05 +08:00
parent 1581a1951c
commit 8144df75a3
5 changed files with 565 additions and 0 deletions

4
Cargo.lock generated
View File

@ -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"

View File

@ -11,3 +11,5 @@ publish = false
workspace = true
[dependencies]
apsis-core.workspace = true
apsis-rf.workspace = true

View File

@ -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};

View File

@ -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<Decibel>,
}
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<Power> {
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<Decibel> {
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<Decibel> {
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<Decibel> {
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<Decibel> {
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<Decibel> {
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<f64> {
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<Power>,
) -> Option<InterferenceSummary> {
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,
})
}

View File

@ -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
);
}