RF link budget analysis for satellite and terrestrial communication systems.
Use linkbudget for end-to-end communication link questions: EIRP, FSPL,
receiver G/T, C/No, SNR, Eb/No, BER, fade margin, modulation/FEC, PFD, Doppler,
orbit geometry, receiver sensitivity, EVM, and quantization.
If the task starts from .sNp S-parameter files or network matrices, use
touchstone. If it is an ordered hardware-block lineup with gain, NF, P1dB, or
IP3 per stage, use gainlineup. Use rfconversions for standalone scalar dB,
dBm, wavelength, and noise-temperature conversions.
Keep units explicit: powers are generally dBm or dBW as named, antenna gains are dBi, frequencies are Hz, distances are meters, C/No is dB-Hz, and SNR, Eb/No, and margins are dB.
For derivations, assumptions, worked checks, and links from equations to tests, start with the calculation guides.
[dependencies]
linkbudget = "0.7.0"The receiver API below is available in 0.7.0. For the previous 0.6.5 API, see the 0.6.5 README.
| Module | Description |
|---|---|
| Transmitter | Output power, gain, EIRP (dBm/dBW) |
| Receiver | Gain, explicit system or source noise, SNR, G/T |
| Path Loss | Free space path loss (FSPL) from frequency and distance |
| Link Budget | End-to-end: TX → path loss → RX → SNR → Eb/No → BER → margin |
| PHY Rate | Shannon capacity from SNR and bandwidth |
| Orbits | Slant range, circular orbit speed/period |
| Doppler | Doppler shift, received frequency, max radial velocity |
| Power Flux Density | PFD (dBW/m²) and PFD per MHz for regulatory analysis |
| Quantization | ADC/DAC quantization SNR and ENOB |
| Modulation | BPSK, QPSK, M-PSK, M-QAM, MSK — symbol rate, bandwidth, spectral efficiency |
| Energy (Eb/No) | Eb/No, Es/No, Ec/No, C/No conversions between all energy-per-bit metrics |
| BER | Theoretical BER curves (erfc/Q-function), required Eb/No, link margin |
| Sensitivity | Receiver MDS from modulation, code rate, NF, target BER |
| EVM | Error Vector Magnitude ↔ SNR conversions and margin checking |
| Coding (FEC) | CodedModulation, FecCode enum, coding gain constants, DVB-S2 presets |
use linkbudget::{LinkBudget, PathLoss, Transmitter, Receiver, ReceiverNoise, Modulation};
use linkbudget::coding;
let budget = LinkBudget {
name: "Ka-band LEO downlink",
bandwidth: 36e6, // 36 MHz channel
transmitter: Transmitter {
output_power: 10.0, // dBm
gain: 35.0, // dBi
bandwidth: 36e6, // Hz
},
receiver: Receiver {
gain: 40.0, // dBi
noise: ReceiverNoise::SourceAndNoiseFigure {
source_temperature_k: 290.0,
noise_figure_db: 2.0, // receiver NF at the 290 K reference
},
bandwidth: 36e6, // Hz
},
path_loss: PathLoss {
frequency: 20.0e9, // 20 GHz Ka-band
distance: 550.0e3, // 550 km LEO
},
frequency_dependent_loss: Some(3.0), // rain fade margin
};
// RF metrics
println!("EIRP: {:.1} dBm", budget.transmitter.eirp_dbm());
println!("G/T: {:.1} dB/K", budget.receiver.g_over_t_db());
println!("Path Loss: {:.1} dB", budget.path_loss());
println!("C/No: {:.1} dB·Hz", budget.c_over_no());
// Uncoded performance
println!("SNR: {:.1} dB", budget.snr());
println!("Eb/No (QPSK): {:.1} dB", budget.eb_no_db(&Modulation::Qpsk));
println!("BER (QPSK uncoded): {:.2e}", budget.ber(&Modulation::Qpsk));
println!("Shannon capacity: {:.1} Mbps", budget.phy_rate().mbps());
let margin = budget.link_margin_db(&Modulation::Qpsk, 1e-5).unwrap();
println!("Link margin (uncoded, BER=1e-5): {:.1} dB", margin);
// With FEC coding — DVB-S2 QPSK rate 3/4 (LDPC)
let coded = coding::dvbs2_qpsk_r34();
println!("BER (coded): {:.2e}", budget.ber_coded(&coded));
println!("Throughput: {:.0} Mbps", budget.throughput_bps(&coded) / 1e6);
println!("Coded margin: {:.1} dB",
budget.link_margin_coded_db(&coded, 1e-5).unwrap());Replace the old Receiver.temperature and Receiver.noise_figure fields with
Receiver.noise. Choose the variant that matches the temperature you have:
SystemTemperatureaccepts total input-referred system temperature. This already includes receiver-added noise. Do not apply noise figure again.SourceAndNoiseFigureaccepts source temperature and receiver noise figure. WithF = 10^(NF/10), the total isT_sys = T_source + 290 * (F - 1)kelvin.
Use finite inputs with positive total temperature and positive noise bandwidth. Source temperature and receiver noise figure must be nonnegative. The model does not validate these inputs at runtime. Gain, signal power, and temperature must refer to the same receiver input plane.
For example, a 50 K source and noise factor 2 give a total of 340 K:
use linkbudget::{Receiver, ReceiverNoise};
let from_source = Receiver {
gain: 40.0, // dBi
noise: ReceiverNoise::SourceAndNoiseFigure {
source_temperature_k: 50.0,
noise_figure_db: 10.0 * 2.0_f64.log10(),
},
bandwidth: 20e6, // equivalent noise bandwidth, Hz
};
let from_system = Receiver {
gain: 40.0,
noise: ReceiverNoise::SystemTemperature { temperature_k: 340.0 },
bandwidth: 20e6,
};
assert!((from_source.system_noise_temperature_k() - 340.0).abs() < 1e-10);
assert!((from_source.calculate_noise_power() - from_system.calculate_noise_power()).abs() < 1e-10);
assert!((from_source.g_over_t_db() - from_system.g_over_t_db()).abs() < 1e-10);Noise power is k * T_sys * B_noise, and G/T uses the same T_sys.
calculate_noise_floor() now returns total noise, as an alias of
calculate_noise_power(). If you need source-only noise, compute `k * T_source
- B_noise` from your source temperature.
For a 290 K source, SourceAndNoiseFigure preserves the old noise power and
SNR. Its G/T now includes receiver-added noise. For other source temperatures,
noise power changes because the old k * T_source * B_noise * F expression
used the wrong noise-figure reference temperature.
LinkBudget::phy_rate() gives the ideal flat AWGN channel capacity:
B_channel * log2(1 + C / (N0 * B_channel)). Receiver SNR uses the receiver
noise bandwidth, which can differ from channel bandwidth. Capacity uses C/No
to put signal and noise in the same channel bandwidth. This assumes all signal
power is captured; it does not model filter clipping or distortion.
See the receiver noise decision and physics proof for the derivation, numerical counterexamples, and compatibility decision.
use linkbudget::{Modulation, ber};
let mod_qpsk = Modulation::Qpsk;
// Symbol rate and occupied bandwidth
let rs = mod_qpsk.symbol_rate(10e6, 0.75); // 10 Mbps, rate 3/4
let bw = mod_qpsk.occupied_bandwidth(rs, 0.35); // 35% roll-off
println!("Symbol rate: {:.2} Msps", rs / 1e6);
println!("Occupied BW: {:.2} MHz", bw / 1e6);
// BER at Eb/No = 10 dB
println!("BER: {:.2e}", ber::ber_from_db(10.0, &mod_qpsk));
// Required Eb/No for BER = 1e-6
println!("Required Eb/No: {:.1} dB",
ber::required_eb_no_db(1e-6, &mod_qpsk).unwrap());
// Link margin: actual vs required
println!("Margin: {:.1} dB",
ber::link_margin_db(12.0, 1e-6, &mod_qpsk).unwrap());use linkbudget::energy;
// SNR = 20 dB in 10 MHz noise bandwidth
let c_no = energy::snr_to_c_over_no(20.0, 10e6); // → 80 dB·Hz
let eb_no = energy::c_over_no_to_eb_over_no(c_no, 5e6); // 5 Mbps → 13 dB
println!("C/No: {:.1} dB·Hz", c_no);
println!("Eb/No: {:.1} dB", eb_no);
// Full chain: SNR → Eb/No via modulation and code rate
use linkbudget::Modulation;
let eb_no = energy::snr_to_eb_over_no(
20.0, // SNR (dB)
10e6, // noise bandwidth
&Modulation::Qpsk,
5e6, // symbol rate
0.75, // code rate
);use linkbudget::coding::{self, CodedModulation, FecCode};
use linkbudget::Modulation;
// DVB-S2 preset
let cm = coding::dvbs2_qpsk_r34();
println!("{}", cm); // "QPSK + LDPC (R=0.75)"
println!("η = {:.2} bits/s/Hz", cm.spectral_efficiency());
println!("Throughput in 36 MHz: {:.0} Mbps", cm.throughput_bps(36e6) / 1e6);
// Required Eb/No with coding gain
println!("Required Eb/No: {:.1} dB", cm.required_eb_no_db(1e-5).unwrap());
// Link margin at 8 dB Eb/No
println!("Margin: {:.1} dB", cm.link_margin_db(8.0, 1e-5).unwrap());
// Custom: 16-QAM + Turbo rate 1/2
let custom = CodedModulation::new(
Modulation::Mqam(16),
FecCode::Turbo { rate: 0.5 },
);| Preset | Modulation | FEC | η (bits/s/Hz) |
|---|---|---|---|
dvbs2_qpsk_r12() |
QPSK | LDPC R=1/2 | 1.00 |
dvbs2_qpsk_r34() |
QPSK | LDPC R=3/4 | 1.50 |
dvbs2_8psk_r23() |
8-PSK | LDPC R=2/3 | 2.00 |
dvbs2_16apsk_r34() |
16-APSK | LDPC R=3/4 | 3.00 |
dvbs2_32apsk_r56() |
32-APSK | LDPC R=5/6 | 4.17 |
use linkbudget::{Modulation, sensitivity};
// Matched filter (root-raised-cosine) — ideal, α-independent
let matched = sensitivity::sensitivity_matched_filter_dbm(
&Modulation::Qpsk, 10e6, 0.75, 3.0, 1e-6, 2.0,
).unwrap();
println!("Matched filter: {:.1} dBm", matched);
// Bandpass filter — practical, includes roll-off penalty
let bandpass = sensitivity::sensitivity_bandpass_dbm(
&Modulation::Qpsk, 10e6, 0.75, 3.0, 1e-6, 2.0, 0.35,
).unwrap();
println!("Bandpass (α=0.35): {:.1} dBm", bandpass);
// Roll-off penalty
println!("Penalty: {:.2} dB", sensitivity::rolloff_penalty_db(0.35));The roll-off factor α controls the excess bandwidth of raised-cosine pulse shaping:
| α | Excess BW | Sensitivity Penalty |
|---|---|---|
| 0.00 | 0% (brick-wall, impractical) | 0.00 dB |
| 0.20 | 20% (DVB-S2) | 0.79 dB |
| 0.25 | 25% | 0.97 dB |
| 0.35 | 35% (legacy DVB-S) | 1.30 dB |
| 0.50 | 50% | 1.76 dB |
| 1.00 | 100% | 3.01 dB |
With a matched filter (RRC at TX + RX), noise bandwidth = symbol rate regardless of α, so sensitivity is unaffected. The penalty applies when using a simple bandpass filter set to the occupied bandwidth Rs×(1+α).
use linkbudget::evm;
// EVM ↔ SNR
println!("EVM at 25 dB SNR: {:.1}%", evm::evm_percent_from_snr_db(25.0));
println!("SNR for 5% EVM: {:.1} dB", evm::snr_db_from_evm_percent(5.0));
// Pass/fail check with margin
let (pass, margin) = evm::evm_margin(5.0, 8.0); // measured 5%, required 8%
println!("Pass: {}, margin: {:.1} dB", pass, margin);use linkbudget::doppler;
let shift = doppler::doppler_shift_hz(14e9, 7000.0); // 14 GHz, 7 km/s
println!("Doppler shift: {:.0} Hz", shift); // ~327 kHz
let received = doppler::doppler_received_frequency(14e9, 7000.0);
println!("Received freq: {:.6} GHz", received / 1e9);use linkbudget::{power_flux_density_dbw_per_m2, pfd_per_mhz};
let pfd = power_flux_density_dbw_per_m2(45.0, 550e3);
println!("PFD: {:.1} dBW/m²", pfd);
let pfd_mhz = pfd_per_mhz(45.0, 550e3, 36.0);
println!("PFD/MHz: {:.1} dBW/m²/MHz", pfd_mhz);use linkbudget::{quantization_snr_db, enob_from_snr};
println!("12-bit SNR: {:.2} dB", quantization_snr_db(12)); // 74.00
println!("ENOB at 65 dB: {:.1} bits", enob_from_snr(65.0)); // 10.5linkbudget # runs the built-in examplelinkbudget uses tracing for structured, runtime-controllable diagnostics. Set the RUST_LOG environment variable to see what the CLI is doing:
# See HTML generation and browser launch info
RUST_LOG=linkbudget=info linkbudget config.toml
# See path resolution, file URL construction, and all diagnostics
RUST_LOG=linkbudget=debug linkbudget config.toml
# Only warnings and errors (quiet mode)
RUST_LOG=linkbudget=warn linkbudget config.tomlIf you use linkbudget as a library, install any tracing subscriber in your application to capture events. Without a subscriber, all tracing calls are zero-cost no-ops.
- Path Loss, Antenna Gain, and Frequency Dependence — Eric Jacobsen
- Understanding Eb/No, SNR, and Power Efficiency — Eric Jacobsen
- Raised-cosine filter — Wikipedia
- Proakis, J. (1995). Digital Communications (3rd ed.). McGraw-Hill.
- ETSI EN 302 307 — DVB-S2 (LDPC + BCH coding, modulation schemes)
This repo includes an optional standalone example crate for Monte Carlo analysis of
receiver SNR margin using montycarlo + linkbudget::Receiver.
Path:
examples/montecarlo-cn0-target/
What it does:
- Randomizes realistic receiver parameters each trial:
- source noise temperature (K)
- receiver noise figure at the 290 K reference (dB)
- bandwidth (Hz)
- input power (dBm)
- Instantiates a
Receiverand computes SNR per trial - Computes SNR margin vs a target and writes CSV + summary
- Produces Python histogram/CDF plots
Run:
cd examples/montecarlo-cn0-target
uv sync
cargo run
uv run plot_cn0_margin.pyOutput files:
output/receiver_snr_margin_samples.csvoutput/receiver_snr_margin_summary.txtoutput/receiver_snr_margin_plots.png