37 releases (20 breaking)
| 0.22.2 | Mar 2, 2026 |
|---|---|
| 0.21.0 | Feb 27, 2026 |
| 0.15.0 | Dec 28, 2025 |
| 0.3.1 | Sep 9, 2025 |
| 0.0.2 | Feb 22, 2025 |
#176 in Compression
270 downloads per month
5.5MB
6K
SLoC
gainlineup
RF signal chain (gain lineup) analysis for receiver and transmitter design.
What It Does
gainlineup models an RF signal chain as a sequence of blocks (amplifiers, filters, attenuators, mixers) and cascades their effects on signal power, noise, and linearity. Think of it as a spreadsheet-style RF lineup — but in Rust, with proper Friis equation cascading.
Quick Start
1. Define Your Input Signal
Every chain starts with an input signal: power level, frequency, bandwidth, and optionally a noise temperature (e.g., antenna sky temperature).
use gainlineup::{Input};
let input = Input {
power_dbm: -80.0, // received signal level
frequency_hz: 6.0e9, // 6 GHz C-band
bandwidth_hz: 1.0e6, // 1 MHz channel
noise_temperature_k: Some(50.0), // cool sky
};
2. Define Your Blocks
Each block in the chain has a name, gain, noise figure, and optionally compression (P1dB) and linearity (IP3) specs.
use gainlineup::{Block};
let lna = Block {
name: "Low Noise Amplifier".to_string(),
gain_db: 20.0,
noise_figure_db: 1.5,
output_p1db_dbm: Some(5.0),
output_ip3_dbm: Some(20.0),
};
let mixer = Block {
name: "Mixer".to_string(),
gain_db: -8.0,
noise_figure_db: 8.0,
output_p1db_dbm: Some(10.0),
output_ip3_dbm: Some(15.0),
};
let if_amp = Block {
name: "IF Amplifier".to_string(),
gain_db: 25.0,
noise_figure_db: 4.0,
output_p1db_dbm: Some(15.0),
output_ip3_dbm: Some(25.0),
};
3. Run the Cascade
Pass the input and blocks through the cascade to get signal nodes at each stage.
use gainlineup::{Block, Input, cascade_vector_return_vector};
let input = Input {
power_dbm: -80.0,
frequency_hz: 6.0e9,
bandwidth_hz: 1.0e6,
noise_temperature_k: Some(50.0),
};
let lna = Block {
name: "Low Noise Amplifier".to_string(),
gain_db: 20.0,
noise_figure_db: 1.5,
output_p1db_dbm: Some(5.0),
output_ip3_dbm: Some(20.0),
};
let mixer = Block {
name: "Mixer".to_string(),
gain_db: -8.0,
noise_figure_db: 8.0,
output_p1db_dbm: Some(10.0),
output_ip3_dbm: Some(15.0),
};
let if_amp = Block {
name: "IF Amplifier".to_string(),
gain_db: 25.0,
noise_figure_db: 4.0,
output_p1db_dbm: Some(15.0),
output_ip3_dbm: Some(25.0),
};
let blocks = vec![lna.clone(), mixer.clone(), if_amp.clone()];
let nodes = cascade_vector_return_vector(input, blocks);
for node in &nodes {
println!("{}: Pout={:.1} dBm, NF={:.2} dB, Gain={:.1} dB",
node.name, node.signal_power_dbm,
node.cumulative_noise_figure_db, node.cumulative_gain_db);
}
// Final cascade result
let output = nodes.last().unwrap();
println!("\nCascade: Gain={:.1} dB, NF={:.2} dB, SNR={:.1} dB",
output.cumulative_gain_db,
output.cumulative_noise_figure_db,
output.signal_to_noise_ratio_db());
4. What Gets Cascaded
At each node in the chain, the cascade computes:
| Parameter | Description |
|---|---|
| Signal Power (dBm) | Cumulative signal level, with compression |
| Noise Power (dBm) | Cumulative noise from all stages |
| Gain (dB) | Cumulative gain (accounts for compression) |
| Noise Figure (dB) | Cascaded NF via Friis equation |
| Noise Temperature (K) | Cascaded system temperature |
| OIP3 (dBm) | Cascaded output IP3 (when blocks have IP3 set) |
| SFDR (dB) | Spur-free dynamic range: 2/3 × (OIP3 − noise floor) |
Compression (P1dB)
When a block has output_p1db_dbm set, the output power clamps at P1dB + 1 dB. Signal and noise are compressed independently — noise only compresses if it actually exceeds P1dB (rare, but handled correctly).
use gainlineup::{Block};
let pa = Block {
name: "Power Amplifier".to_string(),
gain_db: 30.0,
noise_figure_db: 5.0,
output_p1db_dbm: Some(20.0), // compresses above +20 dBm out
output_ip3_dbm: None,
};
// Linear region
assert_eq!(pa.output_power(-20.0), 10.0); // -20 + 30 = 10 (below P1dB)
assert_eq!(pa.power_gain(-20.0), 30.0); // full gain
// Compressed
assert_eq!(pa.output_power(0.0), 21.0); // 0 + 30 = 30, clamps to 21
assert_eq!(pa.power_gain(0.0), 21.0); // reduced gain
Dynamic Range
Dynamic range tells you the usable power range of a block or chain: from the noise floor up to the compression point.
use gainlineup::{Block};
let lna = Block {
name: "LNA".to_string(),
gain_db: 20.0,
noise_figure_db: 3.0,
output_p1db_dbm: Some(10.0),
output_ip3_dbm: None,
};
// Output-referred: P1dB_out - noise_floor_out
let dr = lna.dynamic_range_db(1e6).unwrap();
println!("Output dynamic range: {:.1} dB", dr);
// Input-referred: input_P1dB - input_noise_floor
let dr_in = lna.input_dynamic_range_db(1e6).unwrap();
println!("Input dynamic range: {:.1} dB", dr_in);
Returns None when P1dB is not set (linear block, infinite dynamic range).
AM-AM Curves (Power Sweep)
Sweep input power to see how a block or chain behaves from linear through compression. This is the classic "Pin vs Pout" curve from amplifier datasheets.
Single Block
use gainlineup::{Block};
let lna = Block {
name: "LNA".to_string(),
gain_db: 20.0,
noise_figure_db: 3.0,
output_p1db_dbm: Some(10.0),
output_ip3_dbm: None,
};
// Pin vs Pout
let curve = lna.am_am_sweep(-50.0, 0.0, 1.0);
for (pin, pout) in &curve {
println!("Pin={:.0} dBm → Pout={:.1} dBm", pin, pout);
}
// Pin vs Gain (shows compression directly)
let gc = lna.gain_compression_sweep(-50.0, 0.0, 1.0);
for (pin, gain) in &gc {
println!("Pin={:.0} dBm → Gain={:.1} dB", pin, gain);
}
Full Cascade
use gainlineup::{Block, cascade_am_am_sweep, cascade_gain_compression_sweep};
let lna = Block {
name: "Low Noise Amplifier".to_string(),
gain_db: 20.0,
noise_figure_db: 1.5,
output_p1db_dbm: Some(5.0),
output_ip3_dbm: Some(20.0),
};
let mixer = Block {
name: "Mixer".to_string(),
gain_db: -8.0,
noise_figure_db: 8.0,
output_p1db_dbm: Some(10.0),
output_ip3_dbm: Some(15.0),
};
let if_amp = Block {
name: "IF Amplifier".to_string(),
gain_db: 25.0,
noise_figure_db: 4.0,
output_p1db_dbm: Some(15.0),
output_ip3_dbm: Some(25.0),
};
let blocks = vec![lna.clone(), mixer.clone(), if_amp.clone()];
// Cascade Pin vs Pout
let am_am = cascade_am_am_sweep(&blocks, -80.0, -20.0, 1.0);
for (pin, pout) in &am_am {
println!("Pin={:.0} → Pout={:.1}", pin, pout);
}
// Cascade Pin vs Gain
let gc = cascade_gain_compression_sweep(&blocks, -80.0, -20.0, 1.0);
for (pin, gain) in &gc {
println!("Pin={:.0} → Gain={:.1} dB", pin, gain);
}
IMD3 (Intermodulation from IP3)
When a block has output_ip3_dbm set, you can compute third-order intermodulation products — the spurious signals that appear in a two-tone test.
use gainlineup::{Block};
let amp = Block {
name: "Driver Amp".to_string(),
gain_db: 20.0,
noise_figure_db: 5.0,
output_p1db_dbm: None,
output_ip3_dbm: Some(30.0), // OIP3 = +30 dBm
};
// Single point
let im3 = amp.imd3_output_power_dbm(-30.0).unwrap();
println!("IM3 at Pin=-30: {:.1} dBm", im3); // -90 dBm
let rejection = amp.imd3_rejection_db(-30.0).unwrap();
println!("IM3 rejection: {:.0} dB", rejection); // 80 dB below carrier
// Full two-tone sweep
let sweep = amp.imd3_sweep(-50.0, -10.0, 5.0);
for pt in &sweep {
println!("Pin={:.0} Pout={:.1} IM3={:.1} Rejection={:.0} dB",
pt.input_per_tone_dbm, pt.output_per_tone_dbm,
pt.im3_output_dbm, pt.rejection_db);
}
Key relationships:
IM3_out = 3 × Pout - 2 × OIP3(all dBm)Rejection = 2 × (OIP3 - Pout)(dB)- IM3 follows the 3:1 slope rule: 3 dB increase per 1 dB input increase
Node-Level Dynamic Range Summary
After running a cascade, each SignalNode can produce a dynamic range summary that combines P1dB, noise floor, SFDR, and input limits into one struct.
use gainlineup::{Input, Block, cascade_vector_return_output};
let input = Input::new(6.0e9, 1.0e6, -80.0, Some(50.0));
let blocks = vec![
Block {
name: "LNA".to_string(),
gain_db: 20.0,
noise_figure_db: 1.5,
output_p1db_dbm: Some(5.0),
output_ip3_dbm: Some(20.0),
},
];
let node = cascade_vector_return_output(input, blocks);
// Simple linear dynamic range
if let Some(dr) = node.dynamic_range_db() {
println!("Linear DR: {:.1} dB", dr);
}
// Full summary
if let Some(summary) = node.dynamic_range_summary() {
println!("Linear DR: {:.1} dB", summary.linear_dr_db);
println!("SFDR: {:?}", summary.sfdr_db);
println!("MDS: {:.1} dBm", summary.mds_dbm);
println!("Max input: {:.1} dBm", summary.max_input_dbm);
}
Returns None when the node has no P1dB (e.g., a passive stage without a compression spec).
AmplifierModel + AM-PM
AmplifierModel wraps a Block and adds AM-PM (phase distortion) characterization. It's a separate struct — the core Block stays simple for cascade analysis, while AmplifierModel provides richer single-amplifier modeling.
use gainlineup::{Block, AmplifierModel};
let pa = Block {
name: "Power Amp".to_string(),
gain_db: 20.0,
noise_figure_db: 5.0,
output_p1db_dbm: Some(10.0),
output_ip3_dbm: Some(25.0),
};
// Simple: no AM-PM
let model = AmplifierModel::new(&pa);
// With AM-PM coefficient (10 °/dB near P1dB)
let model = AmplifierModel::with_am_pm(&pa, 10.0);
// Builder pattern for full configuration
let model = AmplifierModel::builder(&pa)
.am_pm_coefficient(10.0)
.saturation_power(25.0)
.build();
// Phase shift at a given input power
if let Some(phase) = model.phase_shift_at(-5.0) {
println!("Phase shift: {:.1}°", phase);
}
// Combined AM-AM + AM-PM sweep
let sweep = model.am_am_am_pm_sweep(-40.0, 0.0, 1.0);
for pt in &sweep {
println!("Pin={:.0} Pout={:.1} Gain={:.1} Δφ={:?}",
pt.input_dbm, pt.output_dbm, pt.gain_db, pt.phase_shift_deg);
}
// Required backoff for a phase budget
if let Some(backoff) = model.backoff_for_target_phase(5.0) {
println!("Backoff for ≤5° phase: {:.1} dB below P1dB", backoff);
}
// EVM from AM-PM distortion
if let Some(evm) = model.evm_from_am_pm(-5.0) {
println!("EVM from AM-PM: {:.4} ({:.2}%)", evm, evm * 100.0);
}
CLI (TOML File Input)
The command-line tool reads a TOML file defining the input and blocks, runs the cascade, and generates an HTML table.
gainlineup files/wideband.toml
TOML Format
input_power_dbm = -80.0
frequency_hz = 6.0e9
bandwidth_hz = 1.0e6
[[blocks]]
type = "explicit"
name = "Low Noise Amplifier"
gain_db = 20.0
noise_figure_db = 3.0
[[blocks]]
type = "explicit"
name = "Mixer"
gain_db = 10.0
noise_figure_db = 6.0
[[blocks]]
type = "explicit"
name = "IF Amplifier"
gain_db = 15.0
noise_figure_db = 5.0
Field Aliases
For brevity, you can use short field names. The unit-suffixed names are recommended for clarity.
| Full Name | Aliases |
|---|---|
gain_db |
gain |
noise_figure_db |
noise_figure, nf |
output_p1db_dbm |
output_p1db, op1db |
output_ip3_dbm |
output_ip3, oip3 |
input_power_dbm |
input_power, pin |
frequency_hz |
frequency, f |
bandwidth_hz |
bandwidth, bw |
noise_temperature_k |
noise_temperature |
Caution: Aliases hide unit suffixes.
pinis always dBm,fis always Hz. If you assume different units, you'll get wrong results silently.
HTML Output
The CLI generates an HTML visualization of the cascade:
API Summary
Core Types
| Type | Description |
|---|---|
Input |
Signal entering the chain (power, freq, BW, temp) |
Block |
A component: gain, NF, P1dB, IP3 |
SignalNode |
Result at each stage: power, noise, NF, gain, OIP3, SFDR |
Imd3Point |
Two-tone test result: carrier + IM3 levels |
DynamicRange |
Summary: linear DR, SFDR, MDS, max input |
AmplifierModel |
Block wrapper with AM-PM characterization |
AmplifierPoint |
Combined AM-AM + AM-PM sweep point |
Cascade Functions
| Function | Returns |
|---|---|
cascade_vector_return_output() |
Final SignalNode only |
cascade_vector_return_vector() |
Vec<SignalNode> at every stage |
cascade_am_am_sweep() |
Vec<(Pin, Pout)> through full chain |
cascade_gain_compression_sweep() |
Vec<(Pin, Gain)> through full chain |
Block Methods
| Method | Returns |
|---|---|
output_power(pin) |
Pout with compression |
power_gain(pin) |
Gain at a given input level |
dynamic_range_db(bw) |
Output-referred DR (P1dB - noise) |
input_dynamic_range_db(bw) |
Input-referred DR |
am_am_curve(powers) |
Vec<(Pin, Pout)> |
am_am_sweep(start, stop, step) |
Vec<(Pin, Pout)> evenly spaced |
gain_compression_curve(powers) |
Vec<(Pin, Gain)> |
gain_compression_sweep(..) |
Vec<(Pin, Gain)> evenly spaced |
imd3_output_power_dbm(pin) |
IM3 product power (dBm) |
imd3_rejection_db(pin) |
Carrier minus IM3 (dB) |
imd3_sweep(start, stop, step) |
Vec<Imd3Point> |
SignalNode Methods
| Method | Returns |
|---|---|
signal_to_noise_ratio_db() |
SNR at this node (dB) |
noise_spectral_density() |
Noise PSD (dBm/Hz) |
dynamic_range_db() |
Linear DR at node: P1dB − noise (dB) |
dynamic_range_summary() |
Full DynamicRange summary |
Diagnostics (Tracing)
gainlineup uses tracing for structured, runtime-controllable diagnostics. Set the RUST_LOG environment variable to see intermediate cascade calculations:
# See cascade math: noise power, signal power, compression at each stage
RUST_LOG=gainlineup=debug gainlineup config.toml
# See everything including config file content
RUST_LOG=gainlineup=trace gainlineup config.toml
# Combine with dependency crates (e.g. touchstone file parsing)
RUST_LOG=gainlineup=debug,touchstone=debug gainlineup config.toml
# Only warnings and errors (quiet mode)
RUST_LOG=gainlineup=warn gainlineup config.toml
Using tracing in your own application
If you use gainlineup as a library, install any tracing subscriber in your application to capture events:
use tracing_subscriber::EnvFilter;
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
// Now all gainlineup tracing events will be captured
let node = input.cascade_block(&lna);
Without a subscriber installed, all tracing calls are zero-cost no-ops.
References
- Pozar, D. Microwave Engineering (4th ed.) — Friis equation, noise figure, IP3
- Razavi, B. RF Microelectronics (2nd ed.) — dynamic range, SFDR, receiver design
- Noise Figure — Wikipedia
Dependencies
~14MB
~93K SLoC