rf: add noise temperature and cascade models

This commit is contained in:
ApsisRS contributors 2026-09-26 14:37:15 +08:00
parent c112f02d9c
commit 9a4b5bf9e5
7 changed files with 622 additions and 0 deletions

4
Cargo.lock generated
View File

@ -121,6 +121,10 @@ version = "0.1.0"
[[package]] [[package]]
name = "apsis-noise" name = "apsis-noise"
version = "0.1.0" version = "0.1.0"
dependencies = [
"apsis-core",
"apsis-rf",
]
[[package]] [[package]]
name = "apsis-orbit" name = "apsis-orbit"

View File

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

View File

@ -0,0 +1,110 @@
//! Noise in a cascade of stages, by the Friis formula.
use crate::temperature::{NoiseTemperature, REFERENCE_TEMPERATURE_K};
use apsis_rf::Gain;
/// One stage of a receiver chain.
///
/// A stage is described by the noise it adds, referred to its own input, and
/// the gain it applies. The noise may be given as a temperature or as a
/// noise figure; both are converted to a temperature before the cascade is
/// evaluated, because temperatures add across the chain while decibel
/// figures do not.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Stage {
/// The noise temperature added by this stage, referred to its input.
pub temperature: NoiseTemperature,
/// The available gain of this stage.
pub gain: Gain,
}
impl Stage {
/// Creates a stage from a noise temperature and a gain.
pub const fn new(temperature: NoiseTemperature, gain: Gain) -> Self {
Self { temperature, gain }
}
/// Creates a stage from a noise figure in decibels and a gain in
/// decibels.
///
/// Returns `None` when either value is not finite.
pub fn from_noise_figure(noise_figure_db: f64, gain_dbi: f64) -> Option<Self> {
Some(Self {
temperature: NoiseTemperature::from_noise_figure_db(noise_figure_db)?,
gain: Gain::from_dbi(gain_dbi)?,
})
}
/// Creates a noiseless stage with the given gain.
///
/// An ideal amplifier adds no noise, so it contributes nothing to the
/// cascade except its gain.
pub const fn ideal(gain: Gain) -> Self {
Self {
temperature: NoiseTemperature::from_kelvin(0.0),
gain,
}
}
/// Returns `true` when the stage is usable.
pub fn is_valid(self) -> bool {
self.temperature.is_valid() && self.gain.ratio().is_finite() && self.gain.ratio() > 0.0
}
}
/// Returns the noise temperature of a cascade of stages, referred to the
/// input of the first stage.
///
/// The Friis formula weights each stage's contribution by the gain that
/// precedes it:
///
/// ```text
/// T = T_1 + T_2 / G_1 + T_3 / (G_1 G_2) + ...
/// ```
///
/// The consequence is that the first stage dominates: a noisy second stage
/// matters much less when the first stage has gain. The total temperature is
/// that of the chain, so it can be compared directly with the available
/// gain to find the noise figure of the chain.
///
/// Returns `None` when any stage is not usable, which includes a gain ratio
/// that is not positive.
pub fn cascade_temperature(stages: &[Stage]) -> Option<NoiseTemperature> {
let mut total = NoiseTemperature::from_kelvin(0.0);
let mut cumulative_gain = 1.0_f64;
for stage in stages {
if !stage.is_valid() {
return None;
}
total = total.plus(stage.temperature.scaled(1.0 / cumulative_gain));
cumulative_gain *= stage.gain.ratio();
}
Some(total)
}
/// Returns the noise temperature that corresponds to a noise figure in
/// decibels, using the standard reference temperature.
///
/// This is a free function form of
/// [`NoiseTemperature::from_noise_figure_db`](crate::NoiseTemperature::from_noise_figure_db).
///
/// Returns `None` when the figure is not finite.
pub fn noise_figure_to_temperature(noise_figure_db: f64) -> Option<NoiseTemperature> {
NoiseTemperature::from_noise_figure_db(noise_figure_db)
}
/// Returns the noise figure of a cascade, in decibels.
///
/// The cascade is evaluated as a temperature and then converted to a figure
/// using the standard reference temperature. This is the form in which a
/// receiver chain is usually specified.
///
/// Returns `None` when any stage is not usable.
pub fn cascade_noise_figure_db(stages: &[Stage]) -> Option<f64> {
cascade_temperature(stages)?.noise_figure_db()
}
/// Returns the reference temperature used by the noise figure definition.
pub const fn reference_temperature() -> f64 {
REFERENCE_TEMPERATURE_K
}

View File

@ -1 +1,46 @@
//! Noise temperature and noise figure models. //! Noise temperature and noise figure models.
//!
//! Noise enters a receiver from three places: the source, the antenna and
//! its feed, and the receiver itself. They are combined by adding noise
//! temperatures rather than powers, because a temperature is a power
//! referred to a fixed bandwidth through the Boltzmann relation, and a
//! cascade of stages at different gains does not simply add powers.
//!
//! The central relations are:
//!
//! - Thermal noise power in a bandwidth, `P = k T B`, with `k` the Boltzmann
//! constant.
//! - Noise figure as a temperature, `T = T_0 (F - 1)`, with `T_0` the
//! reference temperature of 290 K.
//! - The Friis cascade, which weights each stage's noise by the gain that
//! precedes it.
//!
//! Sources:
//!
//! - The reference temperature of 290 K and the noise figure definition are
//! from the IEEE standard definition of noise figure, IEEE Std 161.
//! - The cascade is the Friis formula for noise, as given in any receiver
//! design text.
//!
//! ```
//! use apsis_noise::{NoiseTemperature, thermal_noise_power};
//!
//! // A 36 MHz bandwidth at 290 K carries about -128 dBW of thermal noise.
//! let temperature = NoiseTemperature::from_kelvin(290.0);
//! let power = thermal_noise_power(temperature, 36.0e6).expect("valid arguments");
//! assert!((power.dbw().expect("positive") + 128.0).abs() < 1.0);
//! ```
#![forbid(unsafe_code)]
#![deny(missing_docs)]
mod cascade;
mod temperature;
pub use cascade::{
Stage, cascade_noise_figure_db, cascade_temperature, noise_figure_to_temperature,
reference_temperature,
};
pub use temperature::{NoiseTemperature, REFERENCE_TEMPERATURE_K, thermal_noise_power};
pub use apsis_core::constant::BOLTZMANN;

View File

@ -0,0 +1,139 @@
//! Noise temperature.
use apsis_core::constant::BOLTZMANN;
use apsis_rf::Power;
use core::fmt;
/// The reference temperature used to define the noise figure, in kelvin.
///
/// The value is 290 K, which is the standard reference temperature of the
/// IEEE definition of noise figure. It is a convention rather than a
/// physical temperature, and the choice affects the noise figure quoted for
/// a given noise temperature.
pub const REFERENCE_TEMPERATURE_K: f64 = 290.0;
/// A noise temperature in kelvin.
///
/// A noise temperature is the temperature of a resistor that would deliver
/// the same noise power in the same bandwidth. It is a convenient way to
/// express any noise source, including one that is not thermal in origin,
/// because temperatures add across a cascade while powers do not.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
pub struct NoiseTemperature(f64);
impl NoiseTemperature {
/// The standard reference temperature of 290 K.
pub const REFERENCE: Self = Self(REFERENCE_TEMPERATURE_K);
/// Creates a noise temperature from a value in kelvin.
pub const fn from_kelvin(kelvin: f64) -> Self {
Self(kelvin)
}
/// Creates a noise temperature from a noise figure in decibels.
///
/// The relation is `T = T_0 (F - 1)` with `F` the linear noise factor,
/// so a noise figure of zero decibels gives a temperature of zero.
///
/// Returns `None` when the figure is not finite.
pub fn from_noise_figure_db(decibels: f64) -> Option<Self> {
if !decibels.is_finite() {
return None;
}
let factor = 10f64.powf(decibels / 10.0);
Some(Self(REFERENCE_TEMPERATURE_K * (factor - 1.0)))
}
/// Creates a noise temperature from a linear noise factor.
///
/// Returns `None` when the factor is not finite or is below one, which
/// would describe a stage that removes noise.
pub fn from_noise_factor(factor: f64) -> Option<Self> {
if factor.is_finite() && factor >= 1.0 {
Some(Self(REFERENCE_TEMPERATURE_K * (factor - 1.0)))
} else {
None
}
}
/// Returns this temperature in kelvin.
pub const fn kelvin(self) -> f64 {
self.0
}
/// Returns this temperature as a linear noise factor.
///
/// The factor is `T / T_0 + 1`, which is one for zero noise.
pub fn noise_factor(self) -> f64 {
self.0 / REFERENCE_TEMPERATURE_K + 1.0
}
/// Returns this temperature as a noise figure in decibels.
///
/// Returns `None` when the temperature is not finite or is negative,
/// which would give a factor below one.
pub fn noise_figure_db(self) -> Option<f64> {
let factor = self.noise_factor();
if factor > 0.0 && factor.is_finite() && self.0 >= 0.0 {
Some(10.0 * factor.log10())
} else {
None
}
}
/// Returns the sum of two temperatures.
///
/// Noise temperatures of independent sources add, which is the reason
/// the quantity is useful.
pub const fn plus(self, other: Self) -> Self {
Self(self.0 + other.0)
}
/// Returns this temperature scaled by a gain ratio.
///
/// A source followed by a gain contributes its temperature divided by
/// that gain when referred to the input, so the scaling for a
/// contribution referred to the input is the reciprocal.
pub const fn scaled(self, factor: f64) -> Self {
Self(self.0 * factor)
}
/// Returns `true` when the temperature is finite and not negative.
pub const fn is_valid(self) -> bool {
self.0.is_finite() && self.0 >= 0.0
}
}
impl fmt::Display for NoiseTemperature {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} K", self.0)
}
}
impl core::ops::Add for NoiseTemperature {
type Output = Self;
fn add(self, rhs: Self) -> Self {
self.plus(rhs)
}
}
/// Returns the thermal noise power in a bandwidth at a noise temperature.
///
/// The relation is `P = k T B`, with `k` the Boltzmann constant. This is the
/// power that a matched resistor at the given temperature would deliver to a
/// load over the stated bandwidth.
///
/// The bandwidth must be positive. Returns `None` for an invalid
/// temperature or bandwidth.
pub fn thermal_noise_power(temperature: NoiseTemperature, bandwidth: f64) -> Option<Power> {
if !temperature.is_valid() {
return None;
}
if !bandwidth.is_finite() || bandwidth <= 0.0 {
return None;
}
Some(Power::from_watts(
BOLTZMANN * temperature.kelvin() * bandwidth,
))
}

View File

@ -0,0 +1,156 @@
//! Tests for the noise cascade.
use apsis_noise::{
NoiseTemperature, Stage, cascade_noise_figure_db, cascade_temperature,
noise_figure_to_temperature, reference_temperature,
};
use apsis_rf::Gain;
const TOLERANCE: f64 = 1e-9;
/// A stage that adds the given temperature with the given gain in decibels.
fn stage(temperature: f64, gain_dbi: f64) -> Stage {
Stage::new(
NoiseTemperature::from_kelvin(temperature),
Gain::from_dbi(gain_dbi).expect("finite gain"),
)
}
#[test]
fn the_reference_temperature_helper_matches_the_constant() {
assert_eq!(reference_temperature(), 290.0);
}
#[test]
fn a_single_stage_contributes_its_own_temperature() {
let only = stage(100.0, 20.0);
let total = cascade_temperature(&[only]).expect("valid stage");
assert!((total.kelvin() - 100.0).abs() < TOLERANCE);
}
#[test]
fn an_empty_cascade_has_no_noise() {
let total = cascade_temperature(&[]).expect("empty is valid");
assert_eq!(total.kelvin(), 0.0);
}
#[test]
fn the_first_stage_dominates() {
// A 100 K first stage with 20 dB of gain followed by a 1000 K second
// stage contributes the second stage divided by 100.
let cascade = [stage(100.0, 20.0), stage(1_000.0, 20.0)];
let total = cascade_temperature(&cascade).expect("valid stages");
assert!((total.kelvin() - 110.0).abs() < 1e-6, "{}", total.kelvin());
}
#[test]
fn a_noiseless_stage_contributes_nothing() {
let cascade = [
Stage::ideal(Gain::from_dbi(20.0).expect("valid")),
stage(50.0, 10.0),
];
let total = cascade_temperature(&cascade).expect("valid stages");
assert!((total.kelvin() - 0.5).abs() < 1e-9, "{}", total.kelvin());
}
#[test]
fn the_friis_formula_matches_by_hand() {
// Three stages: 50 K at 20 dB, 100 K at 10 dB, 200 K at 0 dB.
let cascade = [stage(50.0, 20.0), stage(100.0, 10.0), stage(200.0, 0.0)];
let total = cascade_temperature(&cascade).expect("valid stages");
let expected = 50.0 + 100.0 / 100.0 + 200.0 / (100.0 * 10.0);
assert!((total.kelvin() - expected).abs() < 1e-9);
}
#[test]
fn a_lossy_component_ahead_of_the_receiver_raises_the_noise() {
// A 3 dB loss is a gain of one half, and it contributes its own
// temperature divided by the gain of the stages before it, which for a
// loss is a division by a fraction and so an increase.
let loss = Stage::new(
NoiseTemperature::from_kelvin(0.0),
Gain::from_dbi(-3.0).expect("finite"),
);
let receiver = stage(100.0, 30.0);
let total = cascade_temperature(&[loss, receiver]).expect("valid stages");
// The receiver contributes 100 / 0.5012 = 199.5 K.
assert!(
total.kelvin() > 199.0 && total.kelvin() < 200.0,
"{}",
total.kelvin()
);
}
#[test]
fn the_cascade_figure_follows_from_the_cascade_temperature() {
let cascade = [stage(50.0, 20.0), stage(100.0, 10.0)];
let temperature = cascade_temperature(&cascade).expect("valid stages");
let figure = cascade_noise_figure_db(&cascade).expect("valid stages");
let expected = temperature.noise_figure_db().expect("valid");
assert!((figure - expected).abs() < TOLERANCE);
}
#[test]
fn a_noiseless_cascade_has_zero_figure() {
let cascade = [
Stage::ideal(Gain::from_dbi(10.0).expect("valid")),
Stage::ideal(Gain::from_dbi(10.0).expect("valid")),
];
let figure = cascade_noise_figure_db(&cascade).expect("valid stages");
assert!(figure.abs() < TOLERANCE, "figure {figure}");
}
#[test]
fn a_known_receiver_chain_gives_a_plausible_figure() {
// A low noise amplifier at 1 dB and 30 dB gain followed by a mixer at
// 10 dB and 0 dB gain. The cascade figure is close to the first stage
// plus the second divided by the first stage's gain.
let low_noise = Stage::from_noise_figure(1.0, 30.0).expect("valid");
let mixer = Stage::from_noise_figure(10.0, 0.0).expect("valid");
let figure = cascade_noise_figure_db(&[low_noise, mixer]).expect("valid stages");
assert!(figure > 1.0 && figure < 1.1, "figure {figure}");
}
#[test]
fn a_single_stage_cascade_figure_is_its_own_figure() {
let amplifier = Stage::from_noise_figure(2.5, 25.0).expect("valid");
let figure = cascade_noise_figure_db(&[amplifier]).expect("valid");
assert!((figure - 2.5).abs() < 1e-9, "figure {figure}");
}
#[test]
fn an_invalid_stage_is_rejected() {
let bad_gain = Stage::new(NoiseTemperature::from_kelvin(100.0), Gain::from_ratio(-1.0));
assert!(!bad_gain.is_valid());
assert_eq!(cascade_temperature(&[bad_gain]), None);
let bad_temperature = Stage::new(
NoiseTemperature::from_kelvin(-1.0),
Gain::from_dbi(10.0).expect("valid"),
);
assert!(!bad_temperature.is_valid());
assert_eq!(cascade_temperature(&[bad_temperature]), None);
}
#[test]
fn the_conversion_helper_matches_the_method() {
let via_function = noise_figure_to_temperature(3.0).expect("finite");
let via_method = NoiseTemperature::from_noise_figure_db(3.0).expect("finite");
assert_eq!(via_function, via_method);
}
#[test]
fn gain_ordering_matters() {
// Placing the noisy stage first gives a worse cascade than placing it
// second, which is why a low noise amplifier goes at the front.
let noisy = stage(500.0, 10.0);
let quiet = stage(20.0, 20.0);
let quiet_first = cascade_temperature(&[quiet, noisy]).expect("valid");
let noisy_first = cascade_temperature(&[noisy, quiet]).expect("valid");
assert!(
noisy_first.kelvin() > quiet_first.kelvin(),
"{} vs {}",
noisy_first.kelvin(),
quiet_first.kelvin()
);
}

View File

@ -0,0 +1,166 @@
//! Tests for noise temperature and thermal noise power.
use apsis_core::constant::BOLTZMANN;
use apsis_noise::{NoiseTemperature, REFERENCE_TEMPERATURE_K, thermal_noise_power};
const TOLERANCE: f64 = 1e-12;
#[test]
fn the_reference_temperature_is_290_kelvin() {
assert_eq!(REFERENCE_TEMPERATURE_K, 290.0);
assert_eq!(NoiseTemperature::REFERENCE.kelvin(), 290.0);
}
#[test]
fn a_zero_temperature_has_unit_noise_factor() {
let quiet = NoiseTemperature::from_kelvin(0.0);
assert_eq!(quiet.noise_factor(), 1.0);
assert_eq!(quiet.noise_figure_db(), Some(0.0));
}
#[test]
fn the_reference_temperature_is_a_noise_figure_of_three_decibels() {
// A noise figure is defined as the ratio of the total noise to the
// noise of a source at the reference temperature, so a stage whose
// excess noise equals the reference temperature has a factor of two,
// which is 3.01 dB.
let value = NoiseTemperature::from_kelvin(REFERENCE_TEMPERATURE_K);
assert!((value.noise_factor() - 2.0).abs() < TOLERANCE);
assert!((value.noise_figure_db().expect("valid") - 3.010_299_956_639_812).abs() < 1e-12);
}
#[test]
fn noise_figure_and_temperature_round_trip() {
for figure in [0.0, 0.5, 1.0, 3.0, 10.0, 20.0] {
let temperature = NoiseTemperature::from_noise_figure_db(figure).expect("finite figure");
let recovered = temperature.noise_figure_db().expect("valid temperature");
assert!((recovered - figure).abs() < 1e-9, "figure {figure}");
}
}
#[test]
fn noise_factor_and_temperature_round_trip() {
for factor in [1.0, 1.5, 2.0, 10.0, 100.0] {
let temperature = NoiseTemperature::from_noise_factor(factor).expect("valid factor");
assert!(
(temperature.noise_factor() - factor).abs() < 1e-9,
"factor {factor}"
);
}
}
#[test]
fn a_known_noise_figure_gives_a_known_temperature() {
// A noise figure of 1 dB is a factor of 1.2589, so the temperature is
// 290 * 0.2589, about 75.1 K.
let temperature = NoiseTemperature::from_noise_figure_db(1.0).expect("finite");
assert!(
(temperature.kelvin() - 75.09).abs() < 0.01,
"{}",
temperature.kelvin()
);
}
#[test]
fn invalid_inputs_are_rejected() {
assert_eq!(NoiseTemperature::from_noise_figure_db(f64::NAN), None);
assert_eq!(NoiseTemperature::from_noise_figure_db(f64::INFINITY), None);
assert_eq!(NoiseTemperature::from_noise_factor(0.5), None);
assert_eq!(NoiseTemperature::from_noise_factor(f64::NAN), None);
}
#[test]
fn temperatures_add() {
let first = NoiseTemperature::from_kelvin(50.0);
let second = NoiseTemperature::from_kelvin(25.0);
assert_eq!((first + second).kelvin(), 75.0);
assert_eq!(first.plus(second).kelvin(), 75.0);
}
#[test]
fn validity_is_reported() {
assert!(NoiseTemperature::from_kelvin(0.0).is_valid());
assert!(NoiseTemperature::from_kelvin(290.0).is_valid());
assert!(!NoiseTemperature::from_kelvin(-1.0).is_valid());
assert!(!NoiseTemperature::from_kelvin(f64::NAN).is_valid());
}
#[test]
fn thermal_noise_power_follows_the_boltzmann_relation() {
let temperature = NoiseTemperature::from_kelvin(290.0);
let bandwidth = 1.0e6;
let power = thermal_noise_power(temperature, bandwidth).expect("valid");
let expected = BOLTZMANN * 290.0 * bandwidth;
assert!((power.watts() - expected).abs() < 1e-30);
}
#[test]
fn a_one_hertz_bandwidth_at_290_kelvin_is_minus_204_dbw() {
// k T B with T = 290 K and B = 1 Hz is 4.0e-21 W, which is -203.98 dBW.
let power = thermal_noise_power(NoiseTemperature::from_kelvin(290.0), 1.0).expect("valid");
assert!(
(power.dbw().expect("positive") + 203.98).abs() < 0.01,
"{} dBW",
power.dbw().expect("positive")
);
}
#[test]
fn a_thirty_six_megahertz_transponder_is_about_minus_128_dbw() {
// k T B with T = 290 K and B = 36 MHz is 1.44e-13 W, which is
// -128.41 dBW. A one hertz bandwidth at the same temperature is
// -203.98 dBW, and 36 MHz is 75.56 dB above one hertz.
let power = thermal_noise_power(NoiseTemperature::from_kelvin(290.0), 36.0e6).expect("valid");
let dbw = power.dbw().expect("positive");
assert!((dbw + 128.41).abs() < 0.01, "{dbw} dBW");
}
#[test]
fn noise_power_scales_with_temperature_and_bandwidth() {
let one_hertz = thermal_noise_power(NoiseTemperature::from_kelvin(100.0), 1.0).expect("valid");
let hundred_hertz =
thermal_noise_power(NoiseTemperature::from_kelvin(100.0), 100.0).expect("valid");
let double_temperature =
thermal_noise_power(NoiseTemperature::from_kelvin(200.0), 1.0).expect("valid");
// Each doubling of either factor adds three decibels.
assert!(
(hundred_hertz.dbw().expect("positive") - one_hertz.dbw().expect("positive") - 20.0).abs()
< 1e-9
);
assert!(
(double_temperature.dbw().expect("positive")
- one_hertz.dbw().expect("positive")
- 3.010_299_956_639_812)
.abs()
< 1e-9
);
}
#[test]
fn a_zero_bandwidth_produces_no_noise() {
assert_eq!(
thermal_noise_power(NoiseTemperature::from_kelvin(290.0), 0.0),
None
);
}
#[test]
fn invalid_arguments_are_rejected() {
assert_eq!(
thermal_noise_power(NoiseTemperature::from_kelvin(-1.0), 1.0e6),
None
);
assert_eq!(
thermal_noise_power(NoiseTemperature::from_kelvin(290.0), -1.0),
None
);
assert_eq!(
thermal_noise_power(NoiseTemperature::from_kelvin(290.0), f64::NAN),
None
);
}
#[test]
fn display_includes_the_unit() {
assert_eq!(NoiseTemperature::from_kelvin(290.0).to_string(), "290 K");
}