diff --git a/Cargo.lock b/Cargo.lock index 83cb19f..86c8e10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,10 @@ version = "0.1.0" [[package]] name = "apsis-noise" version = "0.1.0" +dependencies = [ + "apsis-core", + "apsis-rf", +] [[package]] name = "apsis-orbit" diff --git a/crates/rf/apsis-noise/Cargo.toml b/crates/rf/apsis-noise/Cargo.toml index 230b8d8..0ae8292 100644 --- a/crates/rf/apsis-noise/Cargo.toml +++ b/crates/rf/apsis-noise/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-noise/src/cascade.rs b/crates/rf/apsis-noise/src/cascade.rs new file mode 100644 index 0000000..9c689fe --- /dev/null +++ b/crates/rf/apsis-noise/src/cascade.rs @@ -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 { + 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 { + 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::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 { + 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 +} diff --git a/crates/rf/apsis-noise/src/lib.rs b/crates/rf/apsis-noise/src/lib.rs index 2bb8bce..1722704 100644 --- a/crates/rf/apsis-noise/src/lib.rs +++ b/crates/rf/apsis-noise/src/lib.rs @@ -1 +1,46 @@ //! 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; diff --git a/crates/rf/apsis-noise/src/temperature.rs b/crates/rf/apsis-noise/src/temperature.rs new file mode 100644 index 0000000..3229ced --- /dev/null +++ b/crates/rf/apsis-noise/src/temperature.rs @@ -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 { + 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 { + 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 { + 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 { + if !temperature.is_valid() { + return None; + } + if !bandwidth.is_finite() || bandwidth <= 0.0 { + return None; + } + Some(Power::from_watts( + BOLTZMANN * temperature.kelvin() * bandwidth, + )) +} diff --git a/crates/rf/apsis-noise/tests/cascade.rs b/crates/rf/apsis-noise/tests/cascade.rs new file mode 100644 index 0000000..d226ca8 --- /dev/null +++ b/crates/rf/apsis-noise/tests/cascade.rs @@ -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() + ); +} diff --git a/crates/rf/apsis-noise/tests/temperature.rs b/crates/rf/apsis-noise/tests/temperature.rs new file mode 100644 index 0000000..ec89d95 --- /dev/null +++ b/crates/rf/apsis-noise/tests/temperature.rs @@ -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"); +}