Python Lab Reference
Every library, method and pattern you need for the Signals and Systems labs.
88 entries across 8 libraries
🔢NumPy
A sampled signal is a NumPy array: a time axis, the values on it, and complex numbers whenever frequency is involved. Angles are in radians.
import numpy as npSignals on a grid
np.arange / np.linspaceTime axes: n = 0 … N−1 for discrete time, t = n / fs for samples of a continuous signal.
fs = 1000
n = np.arange(1000)
t = n / fs # 1 s at 1 kHz
tau = np.linspace(-1, 1, 2001) # includes both endsnp.cos / np.sin / np.expSinusoids and exponentials: x(t) = A cos(2π f t + φ), the decaying e^(−at), the complex exponential e^(jωn).
x = 2 * np.cos(2 * np.pi * 50 * t + np.pi / 4)
y = np.exp(-3 * t) * (t >= 0)
z = np.exp(1j * 0.2 * np.pi * n)np.where / boolean masksPiecewise signals: steps, rectangles, one-sided signals.
u = np.where(t >= 0, 1.0, 0.0) # unit step
rect = (np.abs(t) <= 0.5).astype(float)np.sincThe normalised sinc, sin(πx)/(πx): the transform of a rectangle and the ideal interpolator.
h = 0.25 * np.sinc(0.25 * (n - 50)) # ideal low-pass, cutoff π/4np.zeros / np.ones / np.r_ / np.concatenateImpulses, padding and signals built from pieces.
delta = np.zeros(32); delta[0] = 1.0
h = np.r_[np.ones(4), np.zeros(4)]
xp = np.concatenate([x, np.zeros(len(x))]) # zero paddingnp.roll / x[::-1]Circular shifts and time reversal.
xs = np.roll(x, 3) # x[(n − 3) mod N]
xr = x[::-1] # x[−n] on a symmetric gridnp.cumsumA running sum: the accumulator y[n] = Σ x[k], a discrete integral.
step = np.cumsum(delta)Complex numbers
1j and complex arraysPython writes j as 1j; every arithmetic operation works on complex arrays.
z = 3 + 4j
H = 1 / (1 + 1j * w * RC)np.abs / np.angleMagnitude and phase of a phasor or a frequency response.
mag = np.abs(H)
phase = np.angle(H) # radians, in (−π, π]
phase_deg = np.degrees(np.angle(H))np.real / np.imag / np.conjParts of a complex signal; a real signal’s spectrum is conjugate-symmetric.
x = np.real(np.fft.ifft(X)) # drop the round-off imaginary partdecibels20 log10 of a magnitude, 10 log10 of a power.
mag_db = 20 * np.log10(np.abs(H))
psd_db = 10 * np.log10(Pxx)Sums, peaks and checks
np.sum / np.meanEnergy, average power and inner products of sampled signals.
E = np.sum(np.abs(x) ** 2) # discrete-time energy
P = np.mean(np.abs(x) ** 2) # average power
Ec = np.sum(np.abs(x) ** 2) / fs # approximates ∫|x(t)|² dtnp.argmax / np.argmin / np.maxWhere a spectrum peaks, where a response is smallest.
k = np.argmax(np.abs(X))
print(f[k], np.abs(X[k]))np.allcloseCheck two results agree to rounding — the labs’ favourite test.
assert np.allclose(np.convolve(x, h), lfilter(h, 1, np.r_[x, np.zeros(len(h) - 1)]))np.round / np.sortTidy printed results; order poles or frequencies.
print(np.round(np.sort(np.abs(p)), 4))Convolution and polynomials
np.convolveThe convolution sum y[n] = Σ x[k] h[n − k].
y = np.convolve(x, h) # length len(x) + len(h) − 1np.polymulMultiply polynomials given as coefficient arrays, highest power first: cascaded systems.
den = np.polymul([1, 2], [1, 2, 5]) # (s + 2)(s² + 2s + 5)np.rootsRoots of a polynomial: poles from a denominator, zeros from a numerator.
poles = np.roots([1, 3, 2]) # s² + 3s + 2 → −2, −1
stable = np.all(poles.real < 0)The FFT (numpy.fft)
np.fft.fft / np.fft.ifftThe DFT of N samples and its inverse, computed by the FFT.
X = np.fft.fft(x) # X[k], k = 0 … N−1
x2 = np.fft.ifft(X)np.fft.rfft / np.fft.rfftfreqOne-sided spectrum of a real signal, with its frequency axis in Hz.
X = np.fft.rfft(x)
f = np.fft.rfftfreq(len(x), d=1 / fs)
amp = 2 * np.abs(X) / len(x) # amplitude of each sinusoidnp.fft.fftshift / np.fft.fftfreqPut zero frequency in the middle for a two-sided plot or table.
Xs = np.fft.fftshift(np.fft.fft(x))
fs_axis = np.fft.fftshift(np.fft.fftfreq(len(x), d=1 / fs))zero paddingPass n > len(x) to sample the DTFT more finely — it adds no resolution.
X = np.fft.rfft(x, n=8 * len(x))Random signals
np.random.default_rngReproducible noise, random bits and codes.
rng = np.random.default_rng(7)
noise = rng.normal(0, 0.5, size=len(t))
bits = rng.integers(0, 2, size=100)📡scipy.signal
The working library of the course: run a system on a signal, compute its frequency response, design filters, estimate spectra, change sample rates.
from scipy import signal # or: from scipy.signal import lfilter, freqz, …Running systems
lfilterRun a difference equation a[0]y[n] + a[1]y[n−1] + … = b[0]x[n] + b[1]x[n−1] + … on x.
b, a = [0.1], [1, -0.9] # y[n] = 0.9 y[n−1] + 0.1 x[n]
y = lfilter(b, a, x)filtfiltFilter forward and backward: zero phase, squared magnitude. Offline only.
y = filtfilt(b, a, noisy)sosfiltRun a filter as cascaded second-order sections — the safe way for high orders.
sos = butter(8, 100, fs=fs, output='sos')
y = sosfilt(sos, x)fftconvolveConvolution by FFT: the same answer as np.convolve, much faster for long signals.
y = fftconvolve(x, h)lti / lsim / impulse / stepContinuous-time systems from numerator and denominator: responses to any input, an impulse or a step.
sys = lti([1], [1, 2, 5])
t, y = step(sys, T=np.linspace(0, 6, 601))
t, h = impulse(sys, T=t)
tout, yout, _ = lsim(sys, U=np.sin(3 * t), T=t)Frequency response
freqzH(e^(jω)) of a discrete-time system.
f, H = freqz(b, a, worN=2048, fs=fs) # f in Hz because fs is given
mag_db = 20 * np.log10(np.abs(H))freqsH(jω) of a continuous-time system (analog filters, RC circuits).
w = np.logspace(0, 4, 400)
w, H = freqs([1], [1e-3, 1], worN=w)group_delayDelay of each frequency in samples — constant for a linear-phase FIR filter.
f, gd = group_delay((h, 1), w=1024, fs=fs)bodeMagnitude (dB) and phase (degrees) of a continuous-time system in one call.
w, mag, phase = bode(lti([100], [1, 10]), w=np.logspace(0, 3, 300))Filter design
butter / cheby1 / ellipClassical IIR designs: maximally flat, equiripple pass band, equiripple both bands.
b, a = butter(4, 200, btype='low', fs=fs)
b, a = cheby1(4, 1, 200, fs=fs) # 1 dB ripple
b, a = ellip(4, 1, 60, 200, fs=fs) # 1 dB ripple, 60 dB stop bandbuttord / cheb1ordThe lowest order that meets a pass-band and stop-band specification.
N, Wn = buttord(100, 150, 1, 40, fs=fs)
b, a = butter(N, Wn, fs=fs)iirnotchA second-order notch: remove one frequency (mains hum) and keep the rest.
b, a = iirnotch(50, 30, fs=fs)firwin / firwin2FIR design by the window method: from a cutoff, or from any gain curve.
h = firwin(101, 200, fs=fs) # low-pass
h = firwin(101, [100, 300], pass_zero=False, fs=fs) # band-pass
h2 = firwin2(101, [0, 200, 250, fs / 2], [1, 1, 0, 0], fs=fs)kaiserordKaiser’s formula: taps and window shape for a given attenuation and transition width.
N, beta = kaiserord(60, (fst - fp) / (fs / 2))
h = firwin(N | 1, fp, window=('kaiser', beta), fs=fs)remezParks–McClellan equiripple FIR design.
h = remez(61, [0, fp, fst, fs / 2], [1, 0], weight=[1, dp / ds], fs=fs)tf2zpkZeros, poles and gain from numerator and denominator.
z, p, k = tf2zpk(b, a)
print(np.abs(p).max() < 1) # stable if every pole is inside the unit circlePartial fractions
residuePartial fractions of H(s) = Σ r_i/(s − p_i) + k(s): inverse Laplace transforms by table.
r, p, k = residue([1, 3], [1, 3, 2]) # (s + 3)/((s + 1)(s + 2))residuezThe same for H(z) in powers of z^(−1): each term r/(1 − p z^(−1)) gives r·pⁿ.
r, p, k = residuez([1], [1, -1.5, 0.56])
h = sum(rk * pk ** n for rk, pk in zip(r, p)).realSpectra
get_windowWindow samples by name: hann, hamming, blackman, (kaiser, β).
w = get_window('hann', len(x))
X = np.fft.rfft(x * w)periodogram / welchPower spectral density: one FFT, or the average of many windowed segments (less variance).
f, P = periodogram(x, fs=fs)
f, Pw = welch(x, fs=fs, nperseg=256)spectrogramThe short-time Fourier transform as a power grid: frequency against time.
f, tt, S = spectrogram(x, fs, nperseg=256, noverlap=192)
ridge = f[np.argmax(S, axis=0)] # the strongest frequency at each timefind_peaksIndices of local maxima above a height: tones in a spectrum, pulses in a signal.
pk, _ = find_peaks(np.abs(X), height=0.1 * np.abs(X).max())
print(f[pk])Rates, analytic signals and test signals
resample_poly / decimateChange the sample rate by a ratio with built-in anti-aliasing filters.
y = resample_poly(x, up=3, down=2) # 48 kHz → 72 kHz
d = decimate(x, 4) # low-pass, then keep every 4th samplehilbertThe analytic signal x + j·H{x}: its magnitude is the envelope, its phase the instantaneous phase.
z = hilbert(x)
env = np.abs(z)
f_inst = np.diff(np.unwrap(np.angle(z))) * fs / (2 * np.pi)chirp / squareTest signals: a swept sine, a square wave.
c = chirp(t, f0=10, t1=1, f1=200, method='linear')
sq = square(2 * np.pi * 5 * t) # ±1, 50 % dutymax_len_seqA maximal-length pseudo-random sequence of 2^nbits − 1 chips (0 and 1).
seq, _ = max_len_seq(10)
code = 2.0 * seq - 1 # ±1 chips, length 1023correlateCross-correlation: find a known code or pulse inside a noisy signal.
r = correlate(rx, code, mode='full')
lag = np.argmax(np.abs(r)) - (len(code) - 1)∫SciPy: integrals, ODEs, special functions
Numerical checks on the formulas the lessons derive: an integral done by machine, a differential equation stepped in time, a Bessel function for FM.
from scipy.integrate import quad, solve_ivp
from scipy.special import jv, siciIntegration
quadA definite integral to near machine precision: Fourier coefficients, energies, convolution integrals.
T = 2.0
ak = quad(lambda t: np.cos(2 * np.pi * 3 * t / T) * x(t), 0, T)[0] * 2 / Tsolve_ivpStep a differential equation in time — check a transform answer against direct simulation.
sol = solve_ivp(lambda t, y: [y[1], -4 * y[0] - 0.4 * y[1] + u(t)], (0, 10), [0, 0],
t_eval=np.linspace(0, 10, 1001), max_step=0.01)
y = sol.y[0]Special functions
jvBessel functions of the first kind: the amplitudes of FM sidebands.
beta = 2.4
side = [jv(n, beta) for n in range(6)] # carrier vanishes near β = 2.405siciThe sine integral: the Gibbs overshoot is Si(π)/π − ½ ≈ 9 % of the jump.
Si, _ = sici(np.pi)
print(Si / np.pi - 0.5)∑SymPy
Exact transforms, integrals and partial fractions: derive by machine what the lesson derives by hand, then compare.
import sympy as spSymbols and exact numbers
sp.symbolsDeclare symbols, with assumptions that help simplification.
t, f = sp.symbols('t f', real=True)
s = sp.symbols('s')
a, T = sp.symbols('a T', positive=True)sp.Rational / sp.nsimplify / sp.pi / sp.I / sp.ooExact fractions, constants and infinity.
half = sp.Rational(1, 2)
print(sp.nsimplify(0.375)) # 3/8sp.simplify / sp.expand / sp.factorTidy a result before comparing it with the lesson.
print(sp.factor(s**2 + 3*s + 2)) # (s + 1)*(s + 2)sp.lambdifyTurn an expression into a NumPy function to evaluate on arrays.
Xf = sp.lambdify(f, X, 'numpy')
vals = Xf(np.linspace(-5, 5, 101))Calculus
sp.integrate / sp.limit / sp.diffExact integrals (energies, coefficients), limits (initial and final values) and derivatives.
E = sp.integrate(sp.exp(-2*a*t), (t, 0, sp.oo)) # 1/(2a)
print(sp.limit(s * F, s, 0)) # final valuesp.summationClosed forms of series: geometric sums and z-transforms.
n = sp.symbols('n', integer=True, nonnegative=True)
z = sp.symbols('z')
X = sp.summation((sp.Rational(1, 2) / z)**n, (n, 0, sp.oo))sp.Heaviside / sp.DiracDelta / sp.PiecewiseSteps, impulses and piecewise signals.
x = sp.exp(-a*t) * sp.Heaviside(t)
rect = sp.Piecewise((1, sp.Abs(t) <= sp.Rational(1, 2)), (0, True))sp.dsolveSolve a linear differential equation with initial conditions.
y = sp.Function('y')
sol = sp.dsolve(sp.Eq(y(t).diff(t, 2) + 3*y(t).diff(t) + 2*y(t), 0), y(t),
ics={y(0): 1, y(t).diff(t).subs(t, 0): 0})Transforms and partial fractions
sp.laplace_transform / sp.inverse_laplace_transformLaplace pairs, both ways.
F = sp.laplace_transform(sp.exp(-2*t) * sp.cos(3*t), t, s, noconds=True)
f = sp.inverse_laplace_transform(1 / (s**2 + 3*s + 2), s, t)sp.apartPartial fractions of a rational function, before reading terms off a table.
print(sp.apart((s + 3) / ((s + 1) * (s + 2)), s))sp.fourier_transformThe Fourier transform with frequency in hertz: X(f) = ∫ x(t) e^(−j2πft) dt.
X = sp.simplify(sp.fourier_transform(sp.exp(-a * sp.Abs(t)), t, f))sp.fourier_seriesThe Fourier series of a function on an interval, truncated to a few terms.
saw = sp.fourier_series(t, (t, -sp.pi, sp.pi))
print(saw.truncate(4))sp.PolyWork with a polynomial’s coefficients and degree.
den = sp.Poly(s**3 + 2*s**2 + 3*s + 1, s)
print(den.all_coeffs())🎛️python-control
Transfer functions as objects: poles, gains, bandwidths, step specifications, feedback loops and stability margins — from Unit 4 on, and the core of the Laplace and feedback lessons.
import control as ctTransfer functions
ct.tfA transfer function from coefficient lists, or algebra on the symbol s.
G = ct.tf([10], [1, 2, 10])
s = ct.tf('s')
H = 100 / (s * (s + 10))ct.poles / ct.zerosPoles and zeros: stable if every pole has a negative real part.
print(ct.poles(G))
print(all(p.real < 0 for p in ct.poles(G)))ct.dcgain / ct.bandwidthThe gain at ω = 0 and the frequency where the gain falls 3 dB below it.
print(ct.dcgain(G), ct.bandwidth(G)) # bandwidth in rad/sResponses and feedback
ct.step_infoRise time, settling time, overshoot (%) and peak of the step response.
info = ct.step_info(G)
print(info['Overshoot'], info['SettlingTime'])ct.feedbackThe closed loop G/(1 + GH), negative feedback by default.
T = ct.feedback(K * G)
print(ct.poles(T))ct.marginGain margin (as a ratio), phase margin (degrees) and the frequencies where they are read.
gm, pm, wg, wp = ct.margin(L)
print(20 * np.log10(gm), pm)〰️PyWavelets
The continuous wavelet transform for the time–frequency lesson: short wavelets for high frequencies, long ones for low.
import pywtScalograms
pywt.frequency2scaleChoose scales from the frequencies you want to see.
wavelet = 'cmor1.5-1.0'
scales = pywt.frequency2scale(wavelet, np.geomspace(10, 400, 80) / fs)pywt.cwtThe continuous wavelet transform: a complex coefficient per scale and time, with each scale’s frequency in Hz.
C, freqs = pywt.cwt(x, scales, wavelet, sampling_period=1 / fs)
ridge = freqs[np.argmax(np.abs(C), axis=0)]🐼pandas
Measured signals with real timestamps: daily readings, averages and the moving average as a filter.
import pandas as pdSeries with a time index
pd.Series / pd.date_rangeValues on a calendar: one reading per day or per hour.
days = pd.date_range('2024-01-01', periods=365, freq='D')
temp = pd.Series(values, index=days).rolling(N).mean()The N-point moving average — an FIR low-pass filter on a table.
smooth = temp.rolling(7, center=True).mean().mean() / .std() / pd.DataFrameSummary statistics, and several signals side by side.
df = pd.DataFrame({'raw': temp, 'smooth': smooth})
print(df.std())📈Signals cookbook
The small pieces of code the labs build again and again, and the traps that catch most first attempts.
Recipes
frequency of the strongest toneOne-sided spectrum, then its largest bin.
X = np.fft.rfft(x * np.hanning(len(x)))
f = np.fft.rfftfreq(len(x), d=1 / fs)
print(f[np.argmax(np.abs(X))])the aliased frequencyWhere a tone at f lands after sampling at fs: fold into 0 … fs/2.
def alias(f, fs):
r = f % fs
return min(r, fs - r)a frequency response at one frequencyEvaluate H(e^(jΩ)) or H(jω) directly from the coefficients.
z = np.exp(1j * 2 * np.pi * 50 / fs)
H50 = np.polyval(b[::-1], 1 / z) / np.polyval(a[::-1], 1 / z)impulse response of a difference equationFeed an impulse through lfilter.
d = np.zeros(50); d[0] = 1
h = lfilter(b, a, d)energy in a bandParseval: sum |X[k]|² over the bins of the band.
band = (f >= 40) & (f <= 60)
E_band = np.sum(np.abs(X[band]) ** 2)Common traps
normalised frequencyscipy.signal designs take cutoffs as a fraction of Nyquist (fs/2) unless you pass fs=.
butter(4, 0.2) # 0.2 × fs/2
butter(4, 100, fs=1000) # 100 Hz — the same filterconvolution lengthnp.convolve returns len(x) + len(h) − 1 samples, not len(x).
y = np.convolve(x, h)[:len(x)] # or mode='same' for a centred filtertwo-sided against one-sidednp.fft.fft of a real cosine of amplitude A has two peaks of height A·N/2, at +f and −f; rfft keeps one.
A_est = 2 * np.abs(np.fft.rfft(x)[k]) / len(x)radians and hertzω = 2πf. freqs and python-control work in rad/s; freqz with fs= and the FFT axes in Hz.
f_hz = w / (2 * np.pi)circular against linear convolutionMultiplying two DFTs gives circular convolution. Pad both to len(x) + len(h) − 1 first.
N = len(x) + len(h) - 1
y = np.real(np.fft.ifft(np.fft.fft(x, N) * np.fft.fft(h, N)))