CommPy API Reference¶
Complete API documentation for the CommPy communication engineering library.
Table of Contents¶
- Modulation (legacy per-scheme classes)
- Generic Modulation Engine
- Pulse Shaping
- Equalization
- Synchronization
- Channels
- Finite Fields
- Channel Coding: Block Codes
- Channel Coding: Convolutional Codes
- Channel Coding: Interleaving
- OFDM
- Information Theory
- Source Coding
- Queuing Theory
- Waveforms
- SDR Interoperability
- Link-Level Simulation
- Utilities
Modulation¶
These classes predate the generic modulation engine below and are kept only for backward compatibility with their original call signatures. New code should prefer
MPSKModulator/MQAMModulator/MPAMModulator.
All modulation classes provide static modulate() and demodulate() methods.
BPSK_Modulator¶
Binary Phase Shift Keying
Maps binary data to phase-shifted symbols: - 0 → -1 (magnitude 1, phase π) - 1 → +1 (magnitude 1, phase 0)
from commpy import BPSK_Modulator
# Modulation
bitstream = [0, 1, 0, 1, 1, 0]
symbols = BPSK_Modulator.modulate(bitstream)
# Output: [-1+0j, 1+0j, -1+0j, 1+0j, 1+0j, -1+0j]
# Demodulation
recovered = BPSK_Modulator.demodulate(symbols)
# Output: [0, 1, 0, 1, 1, 0]
Methods:
modulate(bitstream: array-like) -> ndarray- Modulates binary data using BPSK
- Parameters:
bitstream- Array of 0s and 1s -
Returns: Complex128 array of BPSK symbols
-
demodulate(signal: array-like, threshold: float = 0) -> ndarray - Demodulates BPSK signal
- Parameters:
signal- Complex array of received symbolsthreshold- Decision threshold for real part
- Returns: Array of recovered bits (0 or 1)
QPSK_Modulator¶
Quadrature Phase Shift Keying
Encodes 2 bits per symbol using 4-point constellation.
from commpy import QPSK_Modulator
# Input: 2-bit values (0-3)
bits = [0, 1, 2, 3]
symbols = QPSK_Modulator.modulate(bits)
recovered = QPSK_Modulator.demodulate(symbols)
Methods:
modulate(bits: array-like) -> ndarray- Parameters:
bits- 2-bit values (0, 1, 2, or 3) -
Returns: Complex128 array of QPSK symbols
-
demodulate(signal: array-like) -> ndarray - Parameters:
signal- Complex array of received symbols - Returns: Array with values 0-3
OOK_Modulator¶
On-Off Keying (Amplitude Shift Keying - 2 levels)
Simple binary modulation: 0 → 0, 1 → amplitude
from commpy import OOK_Modulator
data = [0, 1, 0, 1]
symbols = OOK_Modulator.modulate(data, amplitude=1.0)
recovered = OOK_Modulator.demodulate(symbols, threshold=0.5)
Methods:
modulate(data: array-like, amplitude: float = 1) -> ndarray- Parameters:
data- Binary data (0s and 1s)amplitude- Signal amplitude for bit 1
-
Returns: Complex128 array
-
demodulate(signal: array-like, threshold: float = 0.5) -> list - Parameters:
signal- Received signalthreshold- Decision threshold
- Returns: List of demodulated bits
ASK_2_Modulator¶
Amplitude Shift Keying - 2 Levels
Similar to BPSK but uses amplitude variation instead of phase.
from commpy import ASK_2_Modulator
bitstream = [0, 1, 0, 1]
symbols = ASK_2_Modulator.modulate(bitstream, amplitudes=[-1, 1])
recovered = ASK_2_Modulator.demodulate(symbols, threshold=0)
Methods:
modulate(bitstream: array-like, amplitudes: list = [-1, 1]) -> ndarray- Parameters:
bitstream- Binary dataamplitudes- [amplitude_for_0, amplitude_for_1]
-
Returns: Complex128 array
-
demodulate(signal: array-like, threshold: float = 0) -> ndarray - Parameters:
signal- Received signalthreshold- Decision threshold
- Returns: Binary array
ASK_4_Modulator¶
Amplitude Shift Keying - 4 Levels
Encodes 2 bits per symbol using amplitude levels.
from commpy import ASK_4_Modulator
values = [0, 1, 2, 3] # 2-bit values
symbols = ASK_4_Modulator.modulate(values)
recovered = ASK_4_Modulator.demodulate(symbols)
Methods:
modulate(values: array-like, levels: list = None) -> ndarray- Parameters:
values- Input symbols (0-3)levels- Amplitude levels for each symbol
-
Returns: Complex128 array
-
demodulate(signal: array-like) -> ndarray - Returns: Array with values 0-3
PSK_8_Modulator¶
Phase Shift Keying - 8 Symbols
Encodes 3 bits per symbol using 8-point constellation.
from commpy import PSK_8_Modulator
values = [0, 1, 2, 3, 4, 5, 6, 7] # 3-bit values
symbols = PSK_8_Modulator.modulate(values)
recovered = PSK_8_Modulator.demodulate(symbols)
Methods:
modulate(values: array-like) -> ndarray- Parameters:
values- Input symbols (0-7) -
Returns: Complex128 array of 8-PSK symbols
-
demodulate(signal: array-like) -> ndarray - Returns: Array with values 0-7
Generic Modulation Engine¶
A Modulator abstract base class with MPSKModulator, MQAMModulator, and MPAMModulator
subclasses. All three build a unit-average-energy, Gray-coded constellation once at construction;
modulate/demodulate/soft_demodulate are then generic and fully vectorized — new schemes get
correct nearest-neighbor demodulation and LLR computation for free.
from commpy import MQAMModulator, Channels
mod = MQAMModulator(16) # 16-QAM, Gray-coded, unit average energy
print(mod.bits_per_symbol) # 4
print(mod.constellation) # 16 complex points, mean(|s|^2) == 1
bits = [0, 1, 1, 0] * 100
symbols = mod.modulate(bits)
received = Channels.awgn(symbols, snr_db=15)
hard_bits = mod.demodulate(received)
llrs = mod.soft_demodulate(received, noise_var=1.0) # positive LLR favors bit 0
Constructors:
- MPSKModulator(M) — M-ary PSK, any M a power of two >= 2.
- MQAMModulator(M) — square M-QAM, M a power of four (4, 16, 64, 256, ...). Non-square/cross
constellations (e.g. 32-QAM) are out of scope.
- MPAMModulator(M) — M-ary PAM, any M a power of two >= 2.
Attributes (set at construction): M, bits_per_symbol, constellation (shape (M,)),
bit_labels (shape (M, bits_per_symbol)).
Methods:
- modulate(bits) -> ndarray[complex128] — maps a bitstream (list, tuple, or ndarray; length a
multiple of bits_per_symbol) onto constellation symbols.
- demodulate(symbols) -> ndarray[int64] — hard-decision nearest-neighbor demodulation.
- soft_demodulate(symbols, noise_var) -> ndarray[float64] — max-log-approximate LLRs, one per
bit, in the same ordering as demodulate's output. Feed directly into viterbi_decode(mode='soft').
Pulse Shaping¶
raised_cosine_filter / root_raised_cosine_filter build callables compatible with
IQWaveform's pulse_shape parameter (causal, peaking at span*T/2).
from commpy import raised_cosine_filter, IQWaveform
import numpy as np
pulse = raised_cosine_filter(symbol_period=1e-3, rolloff=0.35, span=8)
wf = IQWaveform(I=np.array([1., -1., 1.]), Q=np.zeros(3), T=1e-3, fs=20_000, pulse_shape=pulse, span=8)
Signatures:
raised_cosine_filter(symbol_period: float, rolloff: float, span: int) -> Callable
root_raised_cosine_filter(symbol_period: float, rolloff: float, span: int) -> Callable
rolloff— excess bandwidth factorbetain[0, 1].span— truncation window in multiples ofsymbol_period(pass the same value toIQWaveform).- The raised-cosine pulse satisfies the Nyquist zero-ISI criterion (
g(k*T) == 0for nonzero integerk). A matched RRC/RRC pair's combined response equals the RC pulse.
Equalization¶
Linear FIR channel equalizers that invert a known channel impulse response.
from commpy import zf_equalizer, mmse_equalizer
channel = [0.2, 1.0, 0.3, -0.1] # a short multipath channel
w_zf = zf_equalizer(channel, n_taps=15)
w_mmse = mmse_equalizer(channel, n_taps=15, noise_var=0.1)
Signatures:
zf_equalizer(channel_taps, n_taps: int, delay: int | None = None) -> ndarray[float64]
mmse_equalizer(channel_taps, n_taps: int, noise_var: float, delay: int | None = None) -> ndarray[float64]
zf_equalizerminimizes residual ISI alone (least squares);mmse_equalizeradds a noise-variance regularization term (noise_var=0reduces exactly tozf_equalizer), trading a little residual ISI for much better noise suppression under real (noisy) conditions.mmse_equalizersolves the Wiener-Hopf normal equations viascipy.linalg.solve_toeplitz(the channel's autocorrelation Gram matrix is Toeplitz), an O(n²) solve instead of O(n³).
Synchronization¶
Narrow-scope, single-algorithm synchronization primitives (not a configurable PLL framework).
from commpy import gardner_timing_error, estimate_cfo_mth_power, costas_loop_bpsk
# Symbol timing error (2 samples/symbol input): zero at the correct sampling instant.
error = gardner_timing_error(samples_at_2sps)
# Carrier frequency offset via the M-th power method (removes M-PSK modulation).
cfo_hz = estimate_cfo_mth_power(signal, fs=1000.0, m_order=4) # QPSK -> m_order=4
# Minimal first-order Costas loop for BPSK carrier phase recovery.
corrected, phase_history = costas_loop_bpsk(signal, loop_gain=0.05)
gardner_timing_error(samples)— the classic Gardner (1986) non-data-aided timing-error detector; a discriminator, not a closed loop (feed its output into your own loop filter).estimate_cfo_mth_power(signal, fs, m_order)— raises the signal to them_order-th power to strip M-PSK modulation, leaving a pure tone atm_order * cfo, located via FFT peak search.costas_loop_bpsk(signal, loop_gain=0.05)— fixed proportional-gain feedback loop; recovers carrier phase up to the usual BPSK 180-degree ambiguity.
Channels¶
Channels (Static Class)¶
Channel models for digital communication systems.
bsc() - Binary Symmetric Channel¶
Flips each bit independently with probability p.
from commpy import Channels
import numpy as np
bits = np.array([1, 0, 1, 1, 0])
corrupted = Channels.bsc(bits, p=0.1) # 10% bit error rate
Signature:
Channels.bsc(
bits: array-like,
p: float,
rng: np.random.Generator | None = None
) -> ndarray
Parameters:
- bits - Array-like of 0/1 or boolean values
- p - Bit flip probability in [0, 1]
- rng - Optional np.random.Generator for reproducibility. If None, uses default RNG.
Returns: - ndarray of same shape/dtype as input with (possibly) flipped bits
Example:
rng = np.random.default_rng(seed=42)
bits = [1, 1, 0, 0, 1]
output = Channels.bsc(bits, p=0.2, rng=rng)
bec() - Binary Erasure Channel¶
Erases (replaces) each symbol independently with probability p.
from commpy import Channels
bits = np.array([1, 0, 1, 1, 0])
erased = Channels.bec(bits, p=0.1, erasure_value=-1)
Signature:
Channels.bec(
bits: array-like,
p: float,
erasure_value: any = -1,
rng: np.random.Generator | None = None
) -> ndarray
Parameters:
- bits - Array-like of symbols (commonly 0/1)
- p - Erasure probability in [0, 1]
- erasure_value - Symbol used to mark erased positions (default: -1)
- rng - Optional np.random.Generator
Returns:
- Float array with erased entries set to erasure_value
Example:
# Simulate 20% erasures with None marker
output = Channels.bec([1, 0, 1], p=0.2, erasure_value=None)
awgn() - Additive White Gaussian Noise¶
Adds Gaussian noise to achieve target SNR in dB.
from commpy import Channels
import numpy as np
signal = np.array([1+1j, -1-1j, 1-1j])
noisy = Channels.awgn(signal, snr_db=10)
Signature:
Channels.awgn(
x: array-like,
snr_db: float,
rng: np.random.Generator | None = None
) -> ndarray
Parameters:
- x - Input signal (array-like, real or complex)
- snr_db - Target SNR in dB: SNR_linear = 10^(snr_db/10)
- rng - Optional np.random.Generator
Noise Generation:
- Signal power: P_s = mean(|x|²)
- Noise power: P_n = P_s / SNR_linear
- For real signals: Real Gaussian noise with variance P_n
- For complex signals: Complex Gaussian with noise power P_n split equally between I/Q
Returns: - ndarray with same shape/dtype as input, containing noisy signal
Example:
# Modulate, transmit through AWGN at varying SNR
bits = np.random.randint(0, 2, 1000)
symbols = BPSK_Modulator.modulate(bits)
for snr in [0, 5, 10, 15]:
noisy = Channels.awgn(symbols, snr_db=snr)
recovered = BPSK_Modulator.demodulate(noisy)
ber = np.mean(recovered != bits)
print(f"SNR={snr} dB: BER={ber}")
Other channel models¶
from commpy import Channels
import numpy as np
x = np.array([1+0j, -1+0j, 1+0j])
Channels.rayleigh(x, snr_db=15) # Rayleigh fading + AWGN
Channels.rician(x, snr_db=15, k_factor=10.0) # Rician fading + AWGN (LOS + scatter)
Channels.z_channel([1, 0, 1, 1], p=0.1) # asymmetric: only 1 -> 0 flips
Channels.gilbert_elliott( # 2-state bursty (Markov) channel
np.zeros(1000, dtype=int), p_gb=0.05, p_bg=0.2, p_good=0.0, p_bad=0.3,
)
Channels.quantize(np.linspace(-1, 1, 100), bits=8) # uniform scalar quantization
rayleigh(x, snr_db, rng=None)/rician(x, snr_db, k_factor=10.0, rng=None)— multiplicative fading (complex Gaussian / LOS-plus-scatter) followed byawgn.z_channel(bits, p, rng=None)— asymmetric binary channel:1flips to0with probabilityp;0never flips.gilbert_elliott(bits, p_gb, p_bg, p_good=0.0, p_bad=0.2, rng=None, init_state='good')— a 2-state Markov chain alternating between low- and high-error-rate states, modeling bursty errors (unlike BSC's memoryless errors).quantize(x, bits=8, vmin=None, vmax=None)— uniform scalar quantization of an analog signal.
Finite Fields¶
PrimeField (GF(p)) and GF2m (GF(2^m)) share a common FiniteField interface
(add/subtract/multiply/divide/power/negate). GF2m is the algebraic foundation for
BCH and Reed-Solomon codes below.
from commpy import PrimeField, GF2m
gf7 = PrimeField(7)
gf7.multiply(3, 5) # 1 ((3*5) mod 7)
gf7.divide(6, 3) # 2
gf256 = GF2m(m=8) # GF(2^8), used by RS(255, k) codes
gf256.multiply(0x53, 0xCA) # O(1) via log/antilog tables, vectorizes over arrays
gf256.power(2, 100) # alpha^100 for the field's primitive element (== 2)
PrimeField(p) — raises ValueError if p isn't prime. primitive_roots() returns all
generators of the multiplicative group.
GF2m(m, modulus_poly=None) — 2**m-element field; if modulus_poly is omitted, the
numerically smallest primitive polynomial of degree m is found automatically (any primitive
polynomial defines an isomorphic copy of the field, so this doesn't need to match another
library's conventional choice). exp(k) returns alpha**k for an array of (possibly negative)
exponents k.
Channel Coding: Block Codes¶
CRC¶
Parametrized ("Rocksoft model") CRC, verified against zlib.crc32/binascii.crc_hqx.
from commpy import CRC
CRC.crc32().compute(b'123456789') # 0xCBF43926
CRC.crc16_xmodem().compute(b'hello')
CRC.crc8().compute(b'hello')
Custom polynomials via CRCConfig(width, poly, init, xorout, refin, refout) passed to CRC(config).
HammingCode¶
from commpy import HammingCode
import numpy as np
code = HammingCode(m=3) # Hamming(7, 4)
codeword = code.encode(np.array([1, 0, 1, 1]))
corrupted = codeword.copy(); corrupted[2] ^= 1
message, corrected, error_pos = code.decode(corrupted) # corrects any single-bit error
CyclicCode¶
Generic cyclic code from a generator polynomial (over any FiniteField); the base BCH and
Reed-Solomon build on.
from commpy import CyclicCode, PrimeField
code = CyclicCode(n=7, generator=[1, 1, 0, 1], field=PrimeField(2)) # (7,4) Hamming as a cyclic code
codeword = code.encode([1, 0, 1, 1])
code.is_codeword(codeword) # True
code.syndrome(codeword) # zero polynomial
BCHCode¶
Binary BCH code correcting up to t errors, via Berlekamp-Massey + Chien search.
from commpy import BCHCode
code = BCHCode(m=4, t=2) # BCH(15, 7, t=2) over GF(16)
codeword = code.encode(message)
message, corrected, n_errors = code.decode(received)
ReedSolomonCode¶
RS code over GF(2^m); error decoding (unknown positions, up to t = (n-k)//2) and a separate
erasures-only decoder (known positions, up to n-k — twice the error-only capability).
from commpy import ReedSolomonCode
code = ReedSolomonCode(m=8, k=223) # RS(255, 223), the classic CCSDS/DVB code
codeword = code.encode(message)
message, corrected, n_errors = code.decode(received)
message, corrected = code.decode_erasures(received, erasure_positions)
Channel Coding: Convolutional Codes¶
Trellis + ConvolutionalEncoder + viterbi_decode (hard- and soft-decision).
from commpy import Trellis, ConvolutionalEncoder, viterbi_decode
trellis = Trellis(constraint_length=7, generators=(0o171, 0o133)) # the Voyager/NASA code
encoder = ConvolutionalEncoder(trellis)
codeword, final_state = encoder.encode(message, terminate=True) # zero-tail terminated
decoded = viterbi_decode(trellis, codeword, mode='hard', terminated=True)
# Soft-decision: feed LLRs from Modulator.soft_demodulate directly.
decoded = viterbi_decode(trellis, llrs, mode='soft', terminated=True)
Trellis(constraint_length, generators)—generatorsis one tap-pattern integer per output bit (MSB = current input bit), giving a rate-1/len(generators)code.- Viterbi's add-compare-select trellis traversal is JIT-accelerated via the optional
numbaextra (pip install commpy[fast]); without it, a pure-Python fallback gives identical results.
Channel Coding: Interleaving¶
from commpy import BlockInterleaver, ConvolutionalInterleaver
# Block: write rows, read columns. Frame-based (fixed block size).
il = BlockInterleaver(rows=8, cols=8)
interleaved = il.interleave(data) # length rows*cols
original = il.deinterleave(interleaved)
# Convolutional (Ramsey type-II): streaming, round-robin shift-register lanes.
tx_il = ConvolutionalInterleaver(n_lanes=4, delay_increment=3)
rx_il = ConvolutionalInterleaver(n_lanes=4, delay_increment=3, is_deinterleaver=True)
interleaved = tx_il.process(data)
recovered = rx_il.process(interleaved) # matches data after tx_il.total_delay samples
The convolutional interleaver is stateful and streaming: it introduces a fixed end-to-end
latency of n_lanes * (n_lanes - 1) * delay_increment samples (see total_delay), after which
recovered[i] == data[i - total_delay].
OFDM¶
from commpy import OFDMModulator, OFDMDemodulator, MQAMModulator, papr_db
mod = OFDMModulator(n_fft=64, cp_len=16, active_subcarriers=range(4, 60)) # null DC/edges
demod = OFDMDemodulator(n_fft=64, cp_len=16, active_subcarriers=range(4, 60))
qam = MQAMModulator(16)
symbols = qam.modulate(bits)
tx = mod.modulate(symbols) # (n_fft + cp_len) samples per OFDM symbol
print(papr_db(tx[:64])) # PAPR of the first OFDM symbol, in dB
rx_symbols = demod.demodulate(tx) # exact inverse (noiseless round trip)
Signatures:
OFDMModulator(n_fft: int, cp_len: int, active_subcarriers: ArrayLike | None = None)
OFDMDemodulator(n_fft: int, cp_len: int, active_subcarriers: ArrayLike | None = None)
papr(signal) -> float # linear peak-to-average power ratio
papr_db(signal) -> float
papr_ccdf(ofdm_symbols, thresholds_db) -> ndarray # P(PAPR > threshold) per threshold
active_subcarriers defaults to all n_fft subcarriers; pass a subset to null DC/guard bands.
The theoretical worst case (all subcarriers equal) produces a single time-domain impulse with
papr == n_fft.
Information Theory¶
shannon_entropy()¶
Calculate Shannon entropy of a probability distribution.
Signature:
shannon_entropy(probabilities: list[float]) -> float
Parameters:
- probabilities - List of probability values. Values ≤ 0 are skipped.
Returns: - Shannon entropy in bits: $H = -\sum_{i} p_i \log_2(p_i)$
Example:
from commpy import shannon_entropy
# Uniform distribution
H_uniform = shannon_entropy([0.25, 0.25, 0.25, 0.25])
print(f"Uniform: {H_uniform} bits") # Output: 2.0
# Skewed distribution
H_skewed = shannon_entropy([0.5, 0.25, 0.125, 0.125])
print(f"Skewed: {H_skewed:.3f} bits") # Output: ~1.75
# Zero probabilities are ignored
H_two = shannon_entropy([1.0, 0.0, 0.0])
print(f"Deterministic: {H_two} bits") # Output: 0.0
Mutual information & channel capacity¶
from commpy import (
binary_entropy, mutual_information, channel_capacity_bsc,
channel_capacity_awgn, channel_capacity_dmc,
)
import numpy as np
binary_entropy(0.1) # H_b(p), the entropy of a Bernoulli(p) source
mutual_information(joint_prob_matrix) # I(X;Y) from a joint distribution P[x,y]
channel_capacity_bsc(p=0.1) # closed form: 1 - H_b(p)
channel_capacity_awgn(snr_linear=10) # closed form (Shannon-Hartley): log2(1 + snr)
# General discrete memoryless channel, via the Blahut-Arimoto algorithm.
Q = np.array([[1.0, 0.0], [0.3, 0.7]]) # Z-channel transition matrix Q[x,y] = p(y|x)
capacity, optimal_input_dist = channel_capacity_dmc(Q)
channel_capacity_dmc iteratively finds the capacity-achieving input distribution; cross-checked
in the test suite against channel_capacity_bsc (symmetric channels) and a brute-force grid
search (asymmetric channels).
Source Coding¶
Huffman coding and arithmetic coding (_informationTheory/source_coding.py).
from commpy import huffman_codes, huffman_encode, huffman_decode
codes = huffman_codes({'a': 0.5, 'b': 0.25, 'c': 0.25}) # symbol -> bitstring
encoded = huffman_encode(['a', 'a', 'b', 'c'], codes)
decoded = huffman_decode(encoded, codes) # ['a', 'a', 'b', 'c']
from commpy import arithmetic_encode, arithmetic_decode
probs = {'a': 0.5, 'b': 0.3, 'c': 0.2}
symbols = ['a', 'b', 'c', 'a', 'a']
code, n_bits = arithmetic_encode(symbols, probs)
decoded = arithmetic_decode(code, n_bits, len(symbols), probs) # same probs, same length
The arithmetic coder uses exact fractions.Fraction interval arithmetic rather than the classic
fixed-precision-register formulation with carry propagation — simpler to verify correct, at the
cost of speed on very long sequences (appropriate for a supporting feature, not a bulk compressor).
rate_distortion_binary()¶
from commpy import rate_distortion_binary
rate_distortion_binary(p=0.5, distortion=0.1) # R(D) = H_b(p) - H_b(D) for D <= min(p, 1-p)
Closed-form rate-distortion function for a Bernoulli(p) source under Hamming distortion.
Queuing Theory¶
Closed-form M/M/1-family performance models (_networking/queuing.py), no simulation.
from commpy import MM1Queue, MM1KQueue, MMcQueue
q = MM1Queue(arrival_rate=3.0, service_rate=5.0)
q.utilization, q.mean_number_in_system, q.mean_wait_in_queue # rho, L, Wq
qk = MM1KQueue(arrival_rate=3.0, service_rate=2.0, capacity=10) # finite capacity: always stable
qk.blocking_probability, qk.effective_arrival_rate
qc = MMcQueue(arrival_rate=8.0, service_rate=3.0, n_servers=4) # Erlang-C
qc.erlang_c # probability an arriving customer must wait
All three expose mean_number_in_system/mean_number_in_queue/mean_wait_in_system/
mean_wait_in_queue, consistent with Little's Law (L = lambda * W); MM1KQueue uses the
effective (admitted) arrival rate since blocked arrivals don't enter the system.
Waveforms¶
IQWaveform¶
Class for generating and manipulating IQ modulated waveforms.
Signature:
IQWaveform(
I: Sequence[float],
Q: Sequence[float],
T: float,
fs: float,
f0: float = 0.0,
pulse_shape: Callable[[ndarray], ndarray] | None = None,
span: int = 4,
) -> None
Parameters:
- I, Q - Arrays of I/Q symbol values (baseband symbols)
- T - Symbol period in seconds
- fs - Sample rate in Hz (samples per second)
- f0 - Carrier frequency in Hz; use 0 for baseband-only output
- pulse_shape - Callable g_s(tau) defining the pulse shape. Default: rectangular pulse
- span - Pulse window duration in multiples of T (affects computation length)
Attributes:
- .t - Time vector (ndarray)
- .s - Transmitted signal:
- If f0 > 0: Real-valued bandpass signal
- If f0 = 0: Complex-valued baseband signal
- .s_I - I component (baseband)
- .s_Q - Q component (baseband)
- .I, .Q - Input symbol arrays
- .T, .fs, .f0 - Parameters
Methods:
plot_waveform()- Visualizes the transmitted signal
s(t)using Matplotlib - Shows time-domain waveform with proper labels
Example: Baseband
from commpy import IQWaveform
import numpy as np
# Simple constellation: alternating I and Q
I = np.array([1, 0, -1, 0])
Q = np.array([0, 1, 0, -1])
wf = IQWaveform(I=I, Q=Q, T=1e-3, fs=1e6)
# Baseband signal
print(f"Baseband signal shape: {wf.s.shape}")
print(f"Time vector: {wf.t[:10]}")
Example: Bandpass with Custom Pulse
from commpy import IQWaveform
import numpy as np
def raised_cosine(tau, T, alpha=0.5):
"""Raised cosine pulse shape."""
# Simplified: you may want a complete implementation
return np.sinc(tau/T)
I = np.array([1, 1, -1, -1])
Q = np.array([0, 0, 0, 0])
wf = IQWaveform(
I=I, Q=Q,
T=100e-6, # 100 µs symbol period
fs=10e6, # 10 MHz sampling
f0=1e6, # 1 MHz carrier
pulse_shape=raised_cosine,
span=4
)
print(f"Bandpass signal (real): {wf.s[:100]}")
wf.plot_waveform()
SDR Interoperability¶
Read/write complex baseband IQ recordings in two on-disk formats: a raw,
headerless binary stream (compatible with GNU Radio's
blocks.file_sink/blocks.file_source) and SigMF
recordings (a .sigmf-data sample file plus a .sigmf-meta JSON sidecar).
write_iq() / read_iq()¶
Signature:
write_iq(path: str | Path, samples: ArrayLike, dtype: DTypeLike = np.complex64) -> None
read_iq(path: str | Path, dtype: DTypeLike = np.complex64) -> ndarray
Parameters:
- path - Output/input file path
- samples - Complex baseband samples (write only)
- dtype - On-disk sample dtype; np.complex64 (GNU Radio's default gr_complex) or np.complex128
Raises: ValueError if dtype is not complex64/complex128 (write_iq only).
from commpy import MQAMModulator, read_iq, write_iq
mod = MQAMModulator(16)
symbols = mod.modulate([0, 1] * 500)
write_iq('recording.cf32', symbols)
recovered = read_iq('recording.cf32')
write_sigmf() / read_sigmf()¶
Signature:
write_sigmf(
path: str | Path,
samples: ArrayLike,
sample_rate: float,
center_freq: float = 0.0,
*,
dtype: DTypeLike = np.complex64,
description: str = '',
author: str = '',
) -> None
read_sigmf(path: str | Path) -> tuple[ndarray, dict]
Parameters:
- path - Base path (extension-less); writes/reads <path>.sigmf-data and <path>.sigmf-meta.
read_sigmf also accepts a path ending in .sigmf-data or .sigmf-meta.
- sample_rate, center_freq - Recorded in the .sigmf-meta sidecar's global/captures sections
- description, author - Free-text metadata fields
Raises: ValueError if dtype (write) or the recording's core:datatype (read) is not a supported complex format.
from commpy import write_sigmf, read_sigmf
write_sigmf('capture', symbols, sample_rate=1e6, center_freq=915e6, description='16-QAM test capture')
recovered, meta = read_sigmf('capture')
print(meta['global']['core:sample_rate'])
Link-Level Simulation¶
Monte-Carlo error-rate simulation with early stopping and confidence intervals, plus waterfall-curve plotting.
simulate_error_rate()¶
Generic core: drives an arbitrary "run N trials at this SNR, count errors" function, so it works for bit-, frame-, or symbol-error rates alike.
Signature:
simulate_error_rate(
trial_fn: Callable[[float, Generator, int], tuple[int, int]],
snr_db_range: ArrayLike,
*,
target_errors: int = 100,
max_trials: int = 10_000_000,
trials_per_batch: int = 10_000,
confidence: float = 0.95,
rng: Generator | None = None,
) -> SimulationResult
For each SNR point, trial_fn runs in batches until either target_errors
errors have been observed or max_trials trials have run — early stopping
avoids spending a large, fixed trial budget at SNR points that converge
almost immediately.
simulate_ber()¶
Convenience wrapper around simulate_error_rate for the common
modulate → channel → demodulate → count-bit-errors case.
Signature:
simulate_ber(
modulator: Modulator,
channel_fn: Callable[[ndarray, float, Generator], ndarray],
snr_db_range: ArrayLike,
*,
bits_per_batch: int = 10_000,
target_errors: int = 100,
max_trials: int = 10_000_000,
confidence: float = 0.95,
rng: Generator | None = None,
) -> SimulationResult
channel_fn is called positionally as channel_fn(symbols, snr_db, rng),
so Channels.awgn/Channels.rayleigh work directly; channels with extra
parameters (e.g. Channels.rician) need a small wrapper.
SimulationResult¶
Frozen dataclass with one entry per SNR point: snr_db, error_rate,
ci_lower, ci_upper (Wilson score interval), n_trials, n_errors.
plot_waterfall()¶
Signature:
plot_waterfall(
result: SimulationResult,
theoretical: Callable[[ndarray], ndarray] | None = None,
ax: Axes | None = None,
) -> Axes
Plots the BER/FER-vs-SNR curve (log scale) with confidence-interval error
bars, optionally overlaid with a closed-form theoretical reference curve.
from commpy import Channels, MQAMModulator, plot_waterfall, simulate_ber
mod = MQAMModulator(16)
result = simulate_ber(mod, Channels.awgn, snr_db_range=[6, 8, 10, 12, 14], target_errors=200)
ax = plot_waterfall(result)
ax.figure.show()
Utilities¶
is_prime()¶
Check if a number is prime.
Signature:
is_prime(n: int) -> bool
Parameters:
- n - Integer to test
Returns:
- True if n is prime, False otherwise
Example:
from commpy import is_prime
print(is_prime(7)) # True
print(is_prime(10)) # False
modinv()¶
Compute modular multiplicative inverse.
Find $a^{-1} \pmod{m}$ such that $a \cdot a^{-1} \equiv 1 \pmod{m}$.
Signature:
modinv(a: int, m: int) -> int
Parameters:
- a - Integer to invert
- m - Modulus (should be coprime with a)
Returns:
- Modular inverse of a modulo m
Raises:
- ValueError if inverse doesn't exist (gcd(a, m) ≠ 1)
Example:
from commpy import modinv
inv = modinv(3, 7)
print(inv) # 5, since 3*5 = 15 ≡ 1 (mod 7)
print((3 * inv) % 7) # 1
PrimeField¶
See Finite Fields above — PrimeField (GF(p)) now shares a common
FiniteField interface with GF2m (GF(2^m)). Note the divide-by method is divide(a, b)
(gf7.divide(1, 3) for the inverse of 3), not inverse(a).
Type Hints¶
All functions use Python type hints for better IDE support and type checking.
Common types used:
- array-like - Lists, tuples, or numpy arrays
- ndarray - NumPy array (from numpy)
- Callable - Function type
- Sequence - Ordered collection
- float | int - Union type (Python 3.10+)
- ... | None - Optional type
Error Handling¶
Most functions handle invalid inputs gracefully:
- Array conversion: Auto-converts array-like inputs to numpy arrays
- Type preservation: Output dtype/shape match input when possible
- RNG seeding: Use np.random.default_rng(seed=N) for reproducible results
References & Further Reading¶
- Digital Communications theory
- Information Theory fundamentals
- IQ modulation and waveform generation