Signal Processing
This chapter is a reference table generated by reading the interpreter's own builtin dispatch table (engine/crates/qu-interp/src/lib.rs, the call_builtin match statement) rather than hand-written prose, so it reflects exactly what runs today.
Example data
Every worked example below is self-contained and generates whatever data it needs — a sine, a chirp, a burst of noise — right there in its own code block, so nothing needs a data file. The snippet here shows the shape that data typically takes: a two-tone signal plus noise, and a matching lowpass filter, built with functions documented in Signal Generation and Filter Design. It reappears, inlined, as the starting point of the Butterworth design example further down.
fs = 1000
t = (0 to 999) / fs
signal = sin(2 * pi * 50 * t) + 0.5 * sin(2 * pi * 220 * t) + 0.1 * randn(1000, seed = 2)
lp = butter(4, "low", 100, fs) # 4th-order low-pass at 100 Hz
print("signal length: {length(signal)}, lp sections: {shape(lp.sos)[0]}")
signal length: 1000, lp sections: 2
Transforms
| Function | Signature | Description |
fft / fftc | fft(x, [n]) | Forward DFT (Bluestein/rustfft-accelerated). x is a length-N real or complex vector; n (optional number) zero-pads/truncates x to n samples before transforming, defaulting to N. Returns a length-n complex vector (CVec). fftc is an explicit-complex alias of the same function, identical signature and return. |
ifft | ifft(X, [n]) | Inverse DFT, same underlying kernel as fft run in reverse. X is a length-N complex vector (CVec); n (optional number, default N) is the target length, same zero-pad/truncate rule as fft. Returns a length-n CVec. |
rfft | rfft(x) | Real-optimized half-spectrum FFT. x is a length-N real vector. Returns a length-floor(N/2)+1 CVec (DC through Nyquist) — a real signal's spectrum is Hermitian-symmetric, so only the non-negative-frequency half is returned. |
irfft | irfft(half_spectrum, n) | Inverse of rfft. half_spectrum is a length-floor(n/2)+1 complex vector (CVec); n (required number), the real output length, can't be recovered from the half-spectrum's length alone (even and odd n can share a half-length). Returns a length-n real vector. (fftr is a deprecated second name for this — see below.) |
dft | dft(x) | x is a length-N real or complex vector. Returns a length-N CVec. The direct, textbook O(n²) transform, kept as an independent reference implementation to validate fft against and as the definitional basis goertzel specializes. |
idft | idft(X) | X is a length-N complex vector (CVec). Returns a length-N CVec. Inverse of dft, same O(n²) direct implementation. |
goertzel | goertzel(x, k) | x is a length-N real vector; k is the bin index to evaluate (a number, need not be an integer — evaluates the DFT at an arbitrary point between the FFT's grid bins) or a length-K vector of bins. Returns a single complex scalar for a scalar k, or a length-K CVec for a vector — the K-point DFT this is named for: K arbitrary points in O(n·K), each independently, instead of every bin of a full N-point fft whether wanted or not. The standard tool for checking a handful of specific frequencies without paying for a whole FFT. |
goertzel_freq | goertzel_freq(x, fs, freq) | x is a length-N real vector; fs (number, Hz) is the sample rate; freq is the frequency to evaluate (a number, Hz) or a length-K vector of frequencies. Returns a single complex scalar or a length-K CVec, same batching as goertzel above. goertzel parameterized by real frequencies rather than raw bin indices — the common case of checking one or several known tone frequencies at a fixed sample rate. |
vanicek | vanicek(t, x, freqs) | t is a length-N vector of sample times (need not be uniformly spaced); x is the matching length-N vector of samples; freqs is a length-M vector of frequencies (Hz) to evaluate. Returns a length-M real vector, the power spectrum at each requested frequency — Vanicek's Least-Squares Spectral Analysis, valid for unevenly-sampled (t, x) where fft/dft/goertzel (which all assume uniform sampling) don't apply. |
dct | dct(x) | x is a length-N real vector. Returns a length-N real vector. Orthonormal DCT-II (MATLAB dct / scipy.fft.dct(norm='ortho') convention). |
idct | idct(X) | X is a length-N real vector of DCT coefficients. Returns a length-N real vector. Orthonormal DCT-III, the exact inverse of dct (round-trips exactly since the basis is orthonormal). |
dwt | dwt(x) | x is a length-N real vector (N should be even). Returns a (2, N/2) matrix — row 0 is the approximation band, row 1 is the detail band (Qu has no multi-return). One level of the orthogonal Haar discrete wavelet transform; only the Haar family is implemented. |
idwt | idwt(m) | m is a (2, N) matrix of [approx; detail] rows (each row length N). Returns a length-2N real vector, reconstructing the original signal. Inverse of dwt. |
stft | stft(x, nfft, [hop=nfft/2]) | x is a length-N real vector; nfft (number) is the frame/FFT size; hop (optional number, default nfft/2, i.e. 50% overlap) is the sample stride between successive frame starts. Returns an nfft×num_frames complex matrix (CMat, the full per-frame spectrum, not one-sided) — one column per frame, built from a periodic-Hann-windowed FFT per frame. abs(stft(x, ...)) is a spectrogram. |
hilbert | hilbert(x) | x is a length-N real vector. Returns a length-N complex vector (CVec), the analytic signal x + j*H[x]. abs(hilbert(x)) is x's envelope; imag(hilbert(x)) is the Hilbert transform of x itself. |
Convolution, Correlation & Peaks
| Function | Signature | Description |
conv | conv(x, h, [mode="full"]) | x is a length-N vector, h a length-M vector (the kernel/impulse response). mode (optional string) follows MATLAB's conv/NumPy's convolve naming: "full" (default, length N+M-1, every nonzero-overlap sample), "same" (length N, the middle samples aligned with the input), "valid" (length max(N,M)-min(N,M)+1, only where h fully overlaps x). Returns a vector of the length implied by mode. |
xcorr | xcorr(x, [y]) | x is a length-N vector; y (optional length-M vector, defaults to x for autocorrelation). Returns a length-N+M-1 vector, the full lag range (zero lag at the middle for equal-length inputs). |
find_peaks | find_peaks(x, [height=], [distance=], [prominence=], [width=], [rel_height=0.5]) | x is a length-N vector. height (optional number): minimum peak value; distance (optional number, samples): minimum separation, enforced greedily by height so a close cluster keeps its tallest member; prominence (optional number): minimum rise above surrounding terrain (the real bounding-minima computation, not just local-maxima detection); width (optional number, samples): minimum width at rel_height (default 0.5, i.e. half-prominence) of the peak's prominence below its height, via linear interpolation of the crossing — matches scipy.signal.find_peaks's own algorithm. Kwarg names match SciPy; the original min_height/min_distance/min_prominence names are still accepted as aliases. Returns a vector of 0-indexed integer indices into x, one per surviving local maximum. |
findpeaks | findpeaks(x, [min_peak_height=], [min_peak_distance=]) | x is a length-N vector; min_peak_height/min_peak_distance (optional numbers) as above. MATLAB-style [peaks, locations] = findpeaks(x, ...). Qu has no multi-return/tuple-unpacking assignment, so this returns a Model (kind "findpeaks") with fields peaks (a vector of the peak values), locations (a vector of their 0-indexed integer indices into x — not MATLAB's 1-indexing), prominences (a vector, one per peak, the real prominence value) and widths (a vector, one per peak, the width in samples at rel_height=0.5) — the same named-field convention qr/lu/svd/eig use. Shares its peak-finding/non-max-suppression logic with find_peaks above. |
Overloads: conv
| Call | Output length | What it keeps |
conv(x, h) / conv(x, h, mode="full") | N+M-1 | Every nonzero-overlap sample — the default. |
conv(x, h, mode="same") | N | The middle samples, aligned with x. |
conv(x, h, mode="valid") | max(N,M)-min(N,M)+1 | Only the samples where h fully overlaps x. |
mode is a keyword argument, not a third positional one. The example below convolves the same 4-sample x and 3-sample h all three ways and prints each result's length alongside its values.
x = [1, 2, 3, 4]
h = [1, 1, 1]
f = conv(x, h)
s = conv(x, h, mode="same")
v = conv(x, h, mode="valid")
print("full ({length(f)}): {f}") # full (6): [1, 3, 6, 9, 7, 4]
print("same ({length(s)}): {s}") # same (4): [3, 6, 9, 7]
print("valid ({length(v)}): {v}") # valid (2): [6, 9]
full (6): [1, 3, 6, 9, 7, 4] same (4): [3, 6, 9, 7] valid (2): [6, 9]
Level & Power Conversions
| Function | Signature | Description |
mag2db | mag2db(x) | x: a number or vector (amplitude/voltage ratio). Returns the same shape: 20*log10(x). AMPLITUDE/field-quantity convention. |
db | db(x) | Alias of mag2db(x) above (20*log10(x), amplitude convention) — the bare SciPy/general-DSP-flavored spelling. |
db2mag | db2mag(db) | db: a number or vector (decibel value). Returns the same shape: 10^(db/20) — inverse of mag2db. |
pow2db | pow2db(x) | x: a number or vector (power ratio). Returns the same shape: 10*log10(x). POWER-domain convention. |
db_power | db_power(x) | Alias of pow2db(x) above (10*log10(x), power convention) — the bare SciPy/general-DSP-flavored spelling. |
db2pow | db2pow(db) | db: a number or vector (decibel value). Returns the same shape: 10^(db/10) — inverse of pow2db/db_power. |
gain | gain(x, db) | x: a number, vector, matrix or Signal; db: a number of decibels, also nameable (gain(x, db = -3)) and accepting a dB-suffixed literal (gain(x, -3 dB), since dB passes through dimensionless). Returns the same shape as x, scaled by 10^(db/20) — the AMPLITUDE convention, the same factor of 20 as db2mag. A Signal keeps its Fs: changing a level does not touch the time axis. |
Calibration, Units, Metadata & Markers
toolkit-signal.md §10. A raw ADC sample is a number; a measurement is a number in a unit, and these are how one becomes the other. The rule throughout is that the unit and the calibration travel with the samples, so the two can never disagree — convert_unit rescales the data and the tag together, and spl refuses outright rather than reporting pascals it cannot justify.
Deviation from the spec's literal syntax. toolkit-signal.md §10 writes calibrate(s, tone: 94 dB @ 1 kHz) and calibrate(s, sensitivity: 12.3 mV/Pa). Neither parses in Qu: call arguments are spelled key = value (not key: value), @ is a statement-level self-assign prefix with no infix form, dB evaluates to a bare number before any operator could see it, and mV/Pa is not a unit literal the lexer can produce. The keyword spellings below are the real API.
Canonical unit set: V, mV, uV/µV, kV; A, mA, uA/µA; Ohm/ohm/Ω, mOhm, kOhm, MOhm; Pa, hPa, kPa, mbar, bar; m/s^2/m/s², g (the acceleration g, 9.80665 m/s², not the gram — mg is deliberately absent because it reads as "milligram" to as many people as "milli-g"); N, mN, kN; strain, ustrain/µstrain/microstrain; and the affine temperatures degC/°C, degF/°F, K (bare C and F are absent — they are Coulomb and Farad elsewhere in this engine). Conversion within a family is allowed; across families it is an error naming both, because volts to pascals is a calibration, not a unit change.
| Function | Signature | Description |
calibrate | calibrate(s, sensitivity=, [sensitivity_unit="mV/Pa"]) or calibrate(s, tone_level=, [tone_freq=], [tone_ref=], [tone_unit="Pa"]) | Added in v0.2.4. Turns a raw Signal into a physically calibrated one, scaling the samples and stamping the resulting unit. Name exactly one of sensitivity= (a transducer datasheet figure — calibrate(s, sensitivity = 12.3) means 12.3 mV/Pa, and the slope is its reciprocal) or tone_level= (dB level of an acoustic calibrator recorded in s itself, so spl(calibrate(rec, tone_level = 94)) reads back 94). tone_ref defaults to 20 µPa for pressure; tone_unit names the physical unit. If tone_freq= is given, the named tone is checked against the recording via a Goertzel estimate and a record whose energy is not concentrated there is refused — the wrong-file mistake, which every later measurement would otherwise inherit silently. The level is measured AC-coupled (RMS about the mean), as a sound level meter does. Errors on a non-Signal, on an empty signal, or when both/neither form is named. |
spl | spl(s) | Added in v0.2.4. Sound pressure level of a calibrated Signal: 20*log10(rms/reference) using the calibration's own reference (20 µPa for pressure), AC-coupled. On an uncalibrated signal it refuses — and the error carries the dBFS reading the signal *can* support plus the calibrate(...) call that would fix it. That refusal is the point: an uncalibrated signal answers in dBFS and says so, and will not pretend to know pascals. |
signal_unit | signal_unit(s, [unit]) | Added in v0.2.4. With one argument, reads the sample unit (Nothing if unset). With two, stamps one without touching a sample — the caller asserting what the numbers already are, which is the only way a raw capture acquires a unit. Stamping is not converting; convert_unit is the one that rescales. An unknown unit is refused with the full known list. |
convert_unit | convert_unit(s, unit) | Added in v0.2.4. Rescales the samples into another unit of the same physical quantity and moves the unit tag with them, so the two cannot disagree; a calibration's dB reference is re-expressed at the same time, which is why spl is unchanged by a conversion. Affine for temperatures. Refuses across physical quantities (V → Pa) naming both, and refuses a signal with no unit to convert *from*. |
to_unit | to_unit(s, unit) | Added in v0.2.4. Alias of convert_unit(s, unit) above — the spec's own second spelling, kept so a reader following toolkit-signal.md §10 literally finds it. |
apply_gain | apply_gain(s, g) | Added in v0.2.4. Multiplies every sample by a linear gain factor and composes it into the calibration record, so a calibrated signal stays calibrated and its level rises accordingly. gain(x, db) is the decibel spelling of the same operation and the two agree exactly; both exist because both are idiomatic, and the names say which is which. |
apply_offset | apply_offset(s, b) | Added in v0.2.4. Adds a constant to every sample (a DC trim, a tare), composed into the calibration record like any other linear step. |
apply_calibration | apply_calibration(s, slope, [offset=0], [unit=]) | Added in v0.2.4. The general linear calibration physical = slope*raw + offset that the two calibrate forms are specific instances of. unit= stamps the resulting physical unit (and picks up its conventional dB reference, enabling spl for pressure). |
apply_calibration_curve | apply_calibration_curve(s, raw_points, physical_points, [unit=], [extrapolate="error"]) | Added in v0.2.4. A non-linear calibration: a lookup table from raw reading to physical value, interpolated piecewise-linearly — what a thermocouple or a certificated load cell actually gives you, and what no slope/offset pair represents. raw_points must be strictly increasing (a curve that doubles back does not define a single value for a reading). extrapolate defaults to "error" because a sample outside the measured domain is one the calibration says nothing about; "clamp" holds the end value and "linear" continues the end slope, both explicit opt-ins to a guess. The curve's own points are not retained on the result. |
start_time | start_time(s) | Added in v0.2.4. The time of sample 0, in seconds (0 unless set_start_time was called, so nothing about an un-dated signal changes). |
end_time | end_time(s) | Added in v0.2.4. start_time + duration, i.e. the end of the record — one sample period after the last sample's own timestamp, so that end_time - start_time is exactly the duration. Deliberately not the same number as the last entry of timestamps. |
set_start_time | set_start_time(s, t) | Added in v0.2.4. Dates the signal: sets the time of sample 0 in seconds. The whole time axis moves with it — timestamps(s), s.t, and the range that add_marker/add_region check against. |
timestamps | timestamps(s) | Added in v0.2.4. The absolute time axis as a vector, start_time + i/Fs for each sample. Agrees with the s.t field accessor by construction. Errors if the signal's rate is not positive and finite, since then it has no time axis to report. |
metadata | metadata(s) | Added in v0.2.4. Returns a Record of the signal's metadata. sampling_rate, start_time and unit are read through from the value itself rather than from stored copies, alongside duration, n, calibrated and the calibration's source/slope/offset/reference when present, marker and region counts, and whichever free-form fields were set. |
set_metadata | set_metadata(s, key, value) | Added in v0.2.4. Writes one field of the named schema: channel, comment, experiment, gain, instrument, offset, operator, sample, sampling_rate, sensor, start_time, temperature, unit. Keys match ignoring case and underscores, so the spec's samplingRate/startTime resolve too. An off-schema key is an error listing the schema — a typo that silently became its own field would be one nobody ever reads back. sampling_rate, start_time and unit are derived from the signal and are refused with a pointer at their real setter, so a stored copy can never contradict the value. Values must be a number, string or boolean. |
add_marker | add_marker(s, time, [label]) | Added in v0.2.4. Annotates an instant — an impact, a fault injection — at time seconds on the signal's own (origin-aware) axis. A time outside the signal is refused naming the span, because a marker outside the data it annotates is not an annotation. Markers are kept in time order. |
markers | markers(s) | Added in v0.2.4. The signal's markers as a list of records with time and label, in time order. |
add_region | add_region(s, start, end, [label]) | Added in v0.2.4. Annotates an interval rather than an instant. end must be at least start and both must lie within the signal, refused by name otherwise. Regions are kept in start order. |
regions | regions(s) | Added in v0.2.4. The signal's regions as a list of records with start, end and label, in start order. |
Annotations survive an operation exactly as far as they remain true of the result. A sample-aligned, unit-preserving operation (a rolling statistic, a dropout fill, denoising) keeps everything; a gain, offset or unit conversion composes into the calibration; an arbitrary elementwise function (sqrt, sin) keeps the time axis but drops the unit and the calibration, because sqrt of a signal in pascals is not in pascals; delay keeps the unit and calibration but drops markers, whose features have moved. save/load round-trips all of it; the XML writer carries samples and rate only.
mag2db/db2mag/pow2db/db2pow are the canonical, MATLAB-derived names; db/db_power are aliases for callers coming from plain-SciPy/DSP habit who reach for a bare db() first. Whether a ratio is a power or an amplitude changes the factor of 10 vs. 20 — db/mag2db are for field quantities (V, A, Pa, ...), db_power/pow2db are for quantities already a power/energy ratio (W, W/Hz, |X|^2, ...). Getting this backwards is the classic dB mixup.
Spectral Analysis
| Function | Signature | Description |
spectrogram | spectrogram(x, [nfft=256], [hop=nfft/2], [fs=1]) | x: a length-N real vector or Signal. nfft/hop (optional numbers): STFT frame size and hop, as in stft. fs (optional number, Hz): scales row ticks to Hz and column ticks to seconds. If x is a Signal, its own Fs is used and fs need not be passed; passing one that disagrees with the signal's rate is an error rather than a silent relabelling (see *Which rate is used*, below). The default of 1 applies only to a plain vector, which carries no rate. Renders a heatmap of stft(x, nfft, hop)'s magnitude, restricted to the positive-frequency half (nfft/2+1 rows), in dB (floored at -120dB); frequency increases upward (row 0 = Nyquist). Draws and returns: the surface would otherwise be unreachable, with no way to read a level off it or hand it to Qu Studio's Interactive Mode. Returns a Record with fields db (an (nfft/2+1, frames) Mat of dB magnitudes, row 0 = DC — the honest axis order, the opposite of the drawn picture's), freq (a length-nfft/2+1 Vec, Hz) and time (a length-frames Vec, seconds). |
spectral_entropy | spectral_entropy(x, [nfft=256], [hop=nfft/2]) | x: a length-N real vector. nfft/hop (optional numbers): STFT frame size/hop as in stft. Returns a vector of length num_frames (one value per STFT frame), each the Shannon entropy of that frame's normalized power spectrum, scaled to [0, 1]: low when energy concentrates in a few bins (a clean tone), high when spread across many (broadband noise). |
spectrum | spectrum(x, [fs], [window="hann"], [scaling="amplitude"]) | x: a length-N real vector or Signal; fs (number, Hz) — required, either passed or carried by a Signal (the result is a spectrum, whose point is its frequency axis; there is no default rate to mislabel it with). window (optional string: "hann" (default), "hamming", "blackman", "kaiser" (needs beta=), or "rectangular"/"boxcar"/"none" for no taper): the analysis window applied to the record before transforming. scaling (optional string: "amplitude" (default) or "rms"): the convention stamped on X.norm. Returns a spectrum of floor(N/2)+1 one-sided bins (DC to Nyquist). The window is normalised to unit coherent gain (mean(w) == 1) before it is applied, so a bin-aligned tone of amplitude A reads exactly A (A/sqrt(2) under "rms") — an uncompensated Hann taper would read A/2. This is what spectrum adds over rfft(sig, scaling=), which applies no window at all; window="rectangular" makes the two agree bin for bin. Compensation is applied uniformly, not only when scaling="amplitude", so two calls differing only in scaling= differ only by the documented 1/sqrt(2). It corrects a discrete tone's peak, not a broadband level — noise spread across bins is governed by the window's noise-equivalent bandwidth (sum(w^2), 1.5 for Hann), which is what psd/welch are scaled for; integrate power with those, not this. scaling="raw" is deliberately refused: a spectrum carries no window field, so raw bins would be exactly what band_power integrates as if the record had not been tapered — use rfft(sig) for the unwindowed raw transform. |
welch | welch(x, fs, [nperseg=min(256,len(x))], [noverlap=nperseg/2], [window="hann"]) | x: a length-N real vector; fs (number, Hz): sample rate. nperseg (optional number): samples per segment; noverlap (optional number): overlap between segments; window (optional string, e.g. "hann"/"hamming"/"blackman"): window applied to each segment. Returns a spectrum of nperseg/2 + 1 one-sided bins (DC to Nyquist), stamped norm = "density" — SciPy's "density" scaling, units²/Hz. Welch's method segments x into overlapping windowed frames and averages their squared-magnitude spectra. Changed in v0.2.4: this used to return a bare vector whose frequency axis you had to rebuild by hand, which is exactly the mislabelling toolkit-signal.md §2 exists to prevent; it now carries its own axis, so P.freq is the axis, P.df the bin spacing (Fs/nperseg — the *segment* length, not length(x)) and P.N the segment length. The change is back-compatible: a density reads as a plain real vector everywhere one was accepted before, so length(P), max(P), arithmetic and plotting are unaffected, and linspace(0, fs/2, length(P)) still gives the same axis. band_power(P, f0, f1) integrates it over a band, returning mean-square power whose square root is the band RMS. It cannot be inverted (ifft) or unnormalised — a PSD discarded its phase. |
periodogram | periodogram(x, fs, [window="hann"]) | x: a length-N real vector; fs (number, Hz); window (optional string). Returns a spectrum of N/2 + 1 one-sided bins stamped norm = "density", same scaling as welch (changed in v0.2.4 from a bare vector, back-compatibly — see welch). The simplest PSD estimate — welch's one-segment (nperseg=length(x), noverlap=0), no-averaging special case. |
psd | psd(x, [fs], [window="hann"], [nfft=min(256,len(x))], [overlap=0.5]) | x: a length-N real vector or Signal; fs (number, Hz) — required, or carried by a Signal. nfft (optional number): the segment length — the same meaning spectrogram/stft give nfft in this engine, *not* SciPy's zero-padded transform length. overlap (optional number): segment overlap as a fraction in [0, 1) — 0.5 is 50%, where welch's noverlap= is an absolute sample count. window (optional string, as for welch). Returns a spectrum of nfft/2 + 1 one-sided bins stamped norm = "density", the Welch PSD in SciPy's "density" scaling, exactly as welch does — literally the same code path, so psd(x, fs) and welch(x, fs) stay bit-identical and the defaults are chosen to keep that true. Changed in v0.2.4 from a bare vector, back-compatibly: it now carries its own frequency axis (P.freq, P.df), which is what toolkit-signal.md §2 asks for. The scaling is a typed tag, not a unit string. §2 sketches a P.unit of V^2/Hz; the engine still has no derived/compound unit algebra to propagate a per-hertz density through arithmetic, so what is recorded is the *convention* (norm = "density"), which the engine does enforce — ifft and spectrum_unnormalize refuse a density, and band_power integrates it correctly — rather than a label it could not. |
csd | csd(x, y, fs, [nperseg=min(256,len(x),len(y))], [noverlap=nperseg/2], [window="hann"]) | x/y: equal-length real vectors; fs (number, Hz). Returns a length-nperseg/2 + 1 complex vector (CVec), the one-sided cross-spectral density Pxy — Welch's method, sharing welch's own framing/windowing/averaging exactly, so it lines up bin-for-bin with welch(x, ...)/welch(y, ...). abs(csd(x, y, fs)) is cross-power magnitude, angle(csd(x, y, fs)) is phase. |
spectral_coherence | spectral_coherence(x, y, fs, [nperseg=], [noverlap=], [window="hann"]) | x/y: equal-length real vectors; fs (number, Hz). Returns a length-nperseg/2 + 1 real vector, the magnitude-squared coherence |Pxy|^2 / (Pxx*Pyy) in [0, 1] — 1 at a frequency where y is an exact linear/time-invariant function of x's content there. Unrelated to coherence(A) (see the Compressed Sensing chapter) — the largest inner product between two different normalised columns of a compressed-sensing matrix — which is a different, pre-existing function under its own name. |
transfer_function | transfer_function(input, output, [fs], [method="h1"], [nperseg=min(256,len)], [noverlap=nperseg/2], [window="hann"]) | Returns an frf model carrying the frequency response of the system taking input to output, with fields h (length-nperseg/2 + 1 CVec), freq (the matching Hz axis), coherence, method, fs and nperseg. input/output: equal-length real vectors or Signals recorded simultaneously; fs (number, Hz) may instead be carried by either one, and two recordings carrying rates that disagree is an error. method selects the noise-weighted estimator: "h1" (default) assumes the noise is on the output and is the usual choice for a controlled excitation; "h2" assumes it is on the input and is the one to use at a resonance; "hv" is the geometric-mean compromise sqrt(H1*H2). All three share one phase and differ only in magnitude, in the fixed ratio |H1| = coherence * |H2|, so they agree exactly on a noiseless record. Built on welch/csd with one shared framing, so h, coherence and the axis line up bin-for-bin. Read it with H.magnitude(), H.phase(), H.coherence(), H.freq, and plot it with H.bode(). Added in v0.3.0. |
bode | bode(Z, [freqs], [color=], [label=], [marker="line"]) | Draws a complete two-panel Bode figure — magnitude in dB above, phase in degrees below, both against a log frequency axis — and returns Nothing. Z: an frf/impedance model (from transfer_function or impedance(v, i)), which already carries its own freq axis so no second argument is needed; or a plain CVec, which carries no axis and so must be given the matching length-N freqs vector (Hz) positionally. color/label/marker (optional strings): plot styling. This is §11's self-plotting Z.bode(); bode_magnitude/bode_phase remain available for a script that wants one half in a panel of its own choosing. Added in v0.3.0. |
magnitude | magnitude(x) | An exact alias of abs — the same match arm, so the two names cannot drift apart. x: any real or complex number, vector or matrix, or an frf/impedance model (whose complex data it reads through). Returns sqrt(re^2+im^2) elementwise for complex input and the absolute value for real input. Exists because toolkit-signal.md §11 spells the accessor Z.magnitude()/H.magnitude(), which method sugar rewrites to magnitude(Z). Added in v0.3.0. |
nyquist | nyquist(Z, [color=], [label=], [marker="o"]) | Z: a length-N complex vector (CVec, the impedance spectrum). color/label/marker (optional strings): plot styling. The standard EIS plot: Re(Z) on x, -Im(Z) on y (the impedance-spectroscopy sign convention every EIS instrument uses, so capacitive/inductive behavior plots in the upper half-plane). Returns Nothing — this call is made for the figure it draws. |
bode_magnitude | bode_magnitude(Z, freqs, [color=], [label=], [marker="line"]) | Z: a length-N complex vector (CVec); freqs: the matching length-N real vector (Hz). color/label/marker (optional strings). Magnitude half of a Bode plot: 20*log10(|Z|) vs. log-scaled frequency. Returns Nothing — this call is made for the figure it draws. |
bode_phase | bode_phase(Z, freqs, [color=], [label=], [marker="line"]) | Z: a length-N complex vector (CVec); freqs: the matching length-N real vector (Hz). color/label/marker (optional strings). Phase half of a Bode plot: arg(Z) in degrees vs. log-scaled frequency. Kept as a separate call from bode_magnitude (rather than one builtin silently building a 2-panel figure) so a script stays in control of its own subplot layout. Returns Nothing — this call is made for the figure it draws. |
Adaptive Decomposition
| Function | Signature | Description |
emd | emd(x, [max_imfs=]) | x: a length-N real vector. max_imfs (optional number): caps the number of IMFs extracted. Empirical Mode Decomposition: splits x into oscillatory intrinsic mode functions (IMFs, highest frequency first) plus a residual trend, via the standard sifting algorithm. Returns a Model (kind "emd") with fields imfs (a K×N matrix, one IMF per row), residual (a length-N vector), count (a number, K). sum(e.imfs, axis=0) + e.residual reconstructs x exactly. |
vmd | vmd(x, k, [alpha=2000]) | x: a length-N real vector; k (number, integer): the exact number of modes to extract; alpha (optional number): bandwidth-penalty strength. Variational Mode Decomposition: decomposes x into exactly k band-limited modes by minimizing each mode's bandwidth around its own center frequency, solved by ADMM (not sifting, unlike emd). Returns a Model (kind "vmd") with fields modes (a k×N matrix, one mode per row) and center_freqs (a length-k vector, normalized to cycles/sample — 0.5 is Nyquist). |
Signal Features
| Function | Signature | Description |
energy | energy(x) | x: a length-N vector or Signal. Returns a single number, sum(x.^2) — total signal energy. See tkeo for a per-sample instantaneous estimate instead. |
tkeo | tkeo(x) | x: a length-N real vector. Returns a length-(N-2) vector (like diff, doesn't pad — the definition needs a neighbor on each side). Teager-Kaiser Energy Operator, a cheap near-instantaneous nonlinear energy tracker: psi[x(n)] = x(n)^2 - x(n-1)*x(n+1). |
ste | ste(x, [win=256], [hop=win/2]) | x: a length-N real vector. win (optional number): frame length in samples; hop (optional number, default win/2): samples between successive frame starts. Returns a vector of length num_frames, one energy value per frame. Short-time energy: the windowed extension of energy(x) over sliding, overlapping frames — the standard way to track how a signal's power evolves over time (e.g. locating bursts in EMG, or voiced/silent segments in speech). |
Digital Communications
Bit-level building blocks for a baseband link. A *bit vector* throughout is a Vec of literal 0/1 values, MSB-first — the order a codeword is written down in — and anything else in it is an error rather than a truthiness coercion, because a stray 2 or 0.5 would otherwise encode a codeword no decoder disagrees with.
Amplitude modulation and the two-point schemes (BPSK, QPSK) are already a couple of lines on top of hilbert and complex arithmetic, and stay that way; these are the pieces that are not. qam_modulate(bits, 4) is QPSK, so the hand-rolled version and the general one agree.
| Function | Signature | Description |
qam_modulate | qam_modulate(bits, order) | bits: a bit vector whose length is a multiple of log2(order); order (number): the constellation size, a power of two with an even exponent (4, 16, 64, 256, ...). Maps each group of log2(order) bits to one point of the standard Gray-coded square constellation — the first half of the group labels the in-phase level, the second half the quadrature one. Points sit on the unnormalized odd-integer grid (±1, ±3 for 16-QAM), the same convention MATLAB's qammod uses by default, so the mapping is exact in floating point. Cross constellations (32, 128) are a different geometry and are rejected rather than approximated by a rectangle. Returns a CVec, one symbol per group. (Added in v0.3.0.) |
qam_demodulate | qam_demodulate(symbols, order) | symbols: a CVec, a real Vec (quadrature taken as zero), or a single Complex; order as for qam_modulate. Nearest-constellation-point decision, the exact inverse of qam_modulate on clean symbols. Because a square constellation is a product of two independent PAM axes, the nearest point is found by rounding per axis rather than searching all order points; a symbol landing outside the constellation decides to the edge point. Returns a Vec of 0/1, log2(order) bits per symbol. (Added in v0.3.0.) |
hamming74_encode | hamming74_encode(d) | d: a bit vector whose length is a multiple of 4. Hamming(7,4) systematic encoder, 7 bits out per 4 in, laid out [p1, p2, d1, p4, d2, d3, d4] with the parity bits at the power-of-two positions — the layout that makes a nonzero syndrome read out directly as the 1-indexed position of the corrupted bit. Not to be confused with hamming(n), the window function. Returns a Vec of 0/1. (Added in v0.3.0.) |
hamming74_decode | hamming74_decode(codeword) | codeword: a bit vector whose length is a multiple of 7. Computes each block's syndrome, flips the bit it names, and recovers the data. Corrects any single bit flip per codeword; the code's minimum distance is 3, so a double flip is mis-corrected — that is the code's limit, not the implementation's. Returns a Record with fields data (the recovered bits, 4 per codeword), corrected (a Bool, true when any codeword needed fixing) and positions (per codeword, the 1-indexed bit flipped back, or 0 for a clean one). (Added in v0.3.0.) |
crc | crc(bits, polynomial) | bits: the message as a bit vector; polynomial: the generator, written MSB-first with its leading 1 included, either as a bit vector ([1,0,1,1]) or as the number spelling the same bits (11). Textbook unreflected, zero-initialized polynomial division: the message is shifted left by the CRC width and divided mod 2. The width comes from the polynomial, not from a hardcoded table, so any width works; this is deliberately not any one named catalogue variant (CRC-32 and friends also specify an init value, input/output reflection and a final XOR). Returns a Vec of 0/1, len(polynomial) - 1 bits long. (Added in v0.3.0.) |
crc_check | crc_check(bits, polynomial) | bits: a message with its crc remainder already appended; polynomial as for crc. Divides the whole codeword and reports whether the remainder is all zeros. Returns a Bool. (Added in v0.3.0.) |
bits = [0, 1, 1, 0, 1, 0, 0, 1]
syms = qam_modulate(bits, 16) # 2 symbols on the odd-integer grid
print(qam_demodulate(syms, 16)) # back to the same 8 bits
print(hamming74_encode([1, 0, 1, 1])) # [0, 1, 1, 0, 0, 1, 1]
r = hamming74_decode([0, 1, 1, 0, 1, 1, 1]) # bit 5 flipped in transit
print("{r.data} corrected={r.corrected} at {r.positions}")
m = [1, 1, 0, 1]
print(crc(m, 11)) # [0, 0, 1] -- x^3 + x + 1
print(crc_check([1, 1, 0, 1, 0, 0, 1], 11)) # true
[0, 1, 1, 0, 1, 0, 0, 1] [0, 1, 1, 0, 0, 1, 1] [1, 0, 1, 1] corrected=true at [5] [0, 0, 1] true
Filter Design
Every filter-design function returns a Model of kind "filter" (field sos for IIR designs — a num_sections×5 matrix, one biquad section per row as [b0,b1,b2,a1,a2] — or field b for FIR designs, a plain length-n+1 tap vector). order/n is a number (the filter order, an integer); kind (string) for the IIR designs accepts "low"/"high"/"band"/"stop", with "lowpass"/"highpass"/"bandpass"/"bandstop"/"notch" as more readable aliases; cutoff is one Hz number for low/high or a length-2 vector [low, high] for band/stop; fs is a number, the sample rate in Hz.
| Function | Signature | Description |
butter | butter(order, kind, cutoff, fs) | order: number (filter order); kind: string; cutoff: number or length-2 vector (Hz); fs: number (Hz). Returns a Model (kind "filter", field sos). Digital Butterworth filter as a cascade of second-order sections — monotonic, ripple-free response. |
ellip | ellip(order, ripple_db, atten_db, kind, cutoff, fs) | order: number; ripple_db/atten_db: numbers (dB); kind: string; cutoff: number or length-2 vector (Hz); fs: number (Hz). Returns a Model (kind "filter", field sos). Digital elliptic (Cauer) filter, equiripple in both the passband (within ripple_db dB) and stopband (at least atten_db dB down) — the steepest transition band of the classical IIR families at a given order, at the cost of ripple in both bands. cutoff is where gain first drops below -ripple_db (MATLAB/SciPy convention), not the -3dB point. |
cheby1 | cheby1(order, ripple_db, kind, cutoff, fs) | order: number; ripple_db: number (dB); kind: string; cutoff: number or length-2 vector (Hz); fs: number (Hz). Returns a Model (kind "filter", field sos). Digital Chebyshev Type I filter: equiripple passband (bounded by ripple_db dB), monotonic stopband, steeper rolloff than Butterworth at the same order. |
cheby2 | cheby2(order, atten_db, kind, cutoff, fs) | order: number; atten_db: number (dB); kind: string; cutoff: number or length-2 vector (Hz); fs: number (Hz). Returns a Model (kind "filter", field sos). Digital Chebyshev Type II (inverse Chebyshev) filter: monotonic passband, equiripple stopband (at least atten_db dB down starting at the stopband edge), flatter passband than Type I at the cost of a wider transition band. |
fir1 | fir1(n, cutoff, [kind="low"], [window="hamming"], [fs=2], [beta=]) | n: number (order, taps = n+1); cutoff: number or length-2 vector; kind (optional string); window (optional string: "hamming", "hann"/"hanning", "blackman", or "kaiser" — the last needs beta=, a required number when chosen); fs (optional number, default 2.0, MATLAB's normalized convention where 1 = Nyquist) — pass an explicit fs= for butter-style Hz cutoffs. Returns a Model (kind "filter", field b, a length-n+1 vector). Windowed-sinc FIR design (MATLAB/SciPy fir1): the ideal impulse response truncated to n+1 taps and multiplied by the requested window, normalized to unity gain at the passband center. |
firls | firls(n, freq_bands, desired, [weights=]) | n: number, must be even (taps = n+1); freq_bands: a length-2B vector of band-edge pairs (normalized 0..1, 1=Nyquist); desired: a length-2B vector, the target amplitude at each edge; weights (optional length-B vector, default all 1.0), one weight per band. Returns a Model (kind "filter", field b, a length-n+1 vector). Least-squares FIR design (MATLAB/SciPy firls): given a piecewise-linear desired response, finds the symmetric FIR minimizing weighted squared error. |
Overloads: butter
| Call | cutoff shape |
butter(order, "low"/"high", cutoff, fs) | A single Hz number — the low-pass or high-pass edge. |
butter(order, "band"/"stop", cutoff, fs) | A length-2 [low, high] Hz vector — the passband/stopband edges ("stop"/"notch" cuts that band out instead of keeping it). |
kind doesn't just relabel the same response — it changes what cutoff itself has to be. The example below designs a lowpass (scalar cutoff) and a bandpass (2-element cutoff) at the same order and sample rate, confirming each yields its own sos section count.
fs = 1000
lp = butter(4, "low", 100, fs)
bp = butter(4, "band", [80, 120], fs)
print("lowpass sections: {shape(lp.sos)[0]}") # lowpass sections: 2
print("bandpass sections: {shape(bp.sos)[0]}") # bandpass sections: 4
lowpass sections: 2 bandpass sections: 4
Overloads: ellip
| Call | cutoff shape |
ellip(order, ripple_db, atten_db, "low"/"high", cutoff, fs) | A single Hz number. |
ellip(order, ripple_db, atten_db, "band"/"stop", cutoff, fs) | A length-2 [low, high] Hz vector. |
Same kind/cutoff split as butter. The example below designs a lowpass and a bandpass elliptic filter at the same order/ripple/attenuation.
fs = 1000
lp = ellip(4, 1, 40, "low", 100, fs)
bp = ellip(4, 1, 40, "band", [80, 120], fs)
print("lowpass sections: {shape(lp.sos)[0]}") # lowpass sections: 2
print("bandpass sections: {shape(bp.sos)[0]}") # bandpass sections: 4
lowpass sections: 2 bandpass sections: 4
Overloads: cheby1
| Call | cutoff shape |
cheby1(order, ripple_db, "low"/"high", cutoff, fs) | A single Hz number. |
cheby1(order, ripple_db, "band"/"stop", cutoff, fs) | A length-2 [low, high] Hz vector. |
Same kind/cutoff split as butter/ellip.
fs = 1000
lp = cheby1(4, 1, "low", 100, fs)
bp = cheby1(4, 1, "band", [80, 120], fs)
print("lowpass sections: {shape(lp.sos)[0]}") # lowpass sections: 2
print("bandpass sections: {shape(bp.sos)[0]}") # bandpass sections: 4
lowpass sections: 2 bandpass sections: 4
Overloads: cheby2
| Call | cutoff shape |
cheby2(order, atten_db, "low"/"high", cutoff, fs) | A single Hz number. |
cheby2(order, atten_db, "band"/"stop", cutoff, fs) | A length-2 [low, high] Hz vector. |
Same kind/cutoff split as butter/ellip/cheby1.
fs = 1000
lp = cheby2(4, 40, "low", 100, fs)
bp = cheby2(4, 40, "band", [80, 120], fs)
print("lowpass sections: {shape(lp.sos)[0]}") # lowpass sections: 2
print("bandpass sections: {shape(bp.sos)[0]}") # bandpass sections: 4
lowpass sections: 2 bandpass sections: 4
Overloads: fir1
| Call | cutoff shape |
fir1(n, cutoff, kind="low"/"high", ...) | A single normalized/Hz number. |
fir1(n, cutoff, kind="band"/"stop", ...) | A length-2 [low, high] number/Hz vector. |
Same kind-driven cutoff shape as the IIR family above, even though the tap count (n+1) stays fixed regardless of which branch runs — only the ideal response being windowed changes.
fs = 1000
lp = fir1(20, 100, kind="low", fs=fs)
bp = fir1(20, [80, 120], kind="band", fs=fs)
print("lowpass taps: {length(lp.b)}") # lowpass taps: 21
print("bandpass taps: {length(bp.b)}") # bandpass taps: 21
lowpass taps: 21 bandpass taps: 21
Streaming & Batch Filter Application
| Function | Signature | Description |
sosfilt | sosfilt(filt, x) | filt: a Model (kind "filter", field sos or b); x: a length-N vector or Signal. Returns a same-length vector or Signal (keeping x's own Fs on the way out, when x is a Signal). Causal (one-pass) filtering, with the usual IIR phase lag. Branches on the filter's representation: sos uses the biquad cascade, b (FIR) is conv(x, b, "full") truncated to len(x). |
filter_ba, lfilter | filter_ba(b, a, x) | b, a: vectors (numerator/denominator coefficients); x: a length-N vector or Signal. Returns a same-length vector or Signal. Apply a transfer function given as raw numerator and denominator coefficients, direct form II transposed. For a function you derived yourself, or a script ported from MATLAB (filter(b, a, x)) or scipy (lfilter) — filter itself is the higher-order predicate filter here, so the name is taken. Warns above order 8, where a direct form loses accuracy and second-order sections do not. |
filtfilt | filtfilt(filt, x) | filt: a Model (kind "filter"); x: a length-N vector or Signal. Returns a same-length vector or Signal, same Fs-preserving convention as sosfilt. Zero-phase filtering (forward, then backward): doubles the effective order but cancels phase distortion entirely — the standard offline-analysis choice. |
filter_init | filter_init(filt) | filt: a Model (kind "filter"). Returns a Model (kind "filter_state") holding fresh all-zero streaming state for sample-at-a-time filtering: field is an (n_sections, 2) biquad-state matrix for an IIR (sos) filter, or a length-(len(b)-1) all-zero delay vector for an FIR (b) filter. |
filter_next | filter_next(state, x) | state: a Model (kind "filter_state", from filter_init or a prior filter_next); x: a single number (one new sample). Returns a new "filter_state" Model (field y, a number, holds the filtered output). Use as state = filter_next(state, next_sample) in a loop, reading state.y each time — the real-time/serial-fed-data counterpart to sosfilt/filtfilt. |
block_process | block_process(x, f, [block=256]) | x: a vector or Signal; f: a function (a value or the name of a user function) called once per block; block: block length in samples, positional or named, default 256. Cuts x into consecutive blocks, calls f on each, and concatenates the results. The last block is short when the length is not a multiple of block — passed as-is, never zero-padded. A block of a Signal is itself a Signal at the same Fs, so f can read the rate off its own argument. Carries no state between blocks — see the note below. |
blocks | blocks(x, n, [hop=n]) | x: a vector or Signal; n: block length, either a whole number of samples or a duration (100 ms) resolved against x's own Fs; hop: distance between block starts, default n (no overlap). Returns a list of blocks, so for b in s.blocks(4096) iterates it with the ordinary for loop. Each block is a Signal at x's Fs (a plain vector if x had none). The last block is short when the length does not divide evenly — passed as-is, never zero-padded. A duration is a length: 0.5 s at 8 Hz is 4 samples, one fewer than the inclusive slice s[0 s : 0.5 s] spans. Added in v0.2.4. |
processor | processor(f, [block=256], [state=], [rate=]) | f: a function (a value or the name of a user function) called once per block; block: block length in samples, positional or named, default 256; state: the initial state — passing it is what makes the processor stateful; rate: the design sample rate, a number or a frequency (48 kHz). Returns a Model (kind "processor") with fields fn, block, latency (samples), stateful, plus state when stateful and rate/latency_ms when a rate was given. Run it over a whole signal with process. Added in v0.2.4. |
process | process(p, x) | p: a Model (kind "processor"); x: a vector or Signal. Returns the processed signal, applying p's function block by block and carrying state across the block boundaries. Also spelled p.process(x). A stateless processor calls f(block); a stateful one calls f(block, state) and requires the two-element list (y, next_state) back. A processor built with a rate refuses a signal at any other rate, naming both. Added in v0.2.4. |
block_process is the offline half of "write the algorithm once, run it block by block". For a function with no history it is exactly equivalent to applying that function to the whole signal — block_process(x, (b) := 2 .* b, block = 256) on a 1000-sample x reassembles 2 .* x to the bit, short final block and all.
It does not thread state across block boundaries, and that is a real limit, not a rounding detail. A filter applied per block restarts at every boundary: block_process(x, (b) := sosfilt(lp, b), block = 256) differs from sosfilt(lp, x) by about 1.1 on a unit-amplitude signal — block-edge artifacts, not noise. Use it for per-block work that is independent of history (blockwise FFT or RMS metering, level detection, gain, any pure elementwise map). For genuinely stateful work, use processor/process below, which threads state across the boundaries; the sample-at-a-time filter_init/filter_next pair above carries state one sample at a time.
The rate of the result. If f returns as many samples as it was handed, a Signal keeps its Fs. If f reduces each block to one number — the metering case — the result is genuinely sampled at Fs / block and is tagged that way, so a metering series plots against the right time axis instead of the input's rate. Any other output length has no rate that can be named, so it comes back as a plain vector rather than a Signal carrying a guess.
s = signal(sin(2 * pi * 7 .* ((0 to 999) / 1000)), 1000)
level = block_process(s, (b) := rms(b), block = 250)
print("{length(level)} blocks at {level.Fs} Hz") # 4 blocks at 4 Hz
4 blocks at 4 Hz
Stateful streaming: processor and process
processor is the stateful sibling of block_process, and it is what closes the gap described just above. State is threaded explicitly, the same way filter_init/filter_next and kalman_predict/kalman_update already do it, lifted from a sample to a block: a stateful body is called as f(block, state) and returns the two-element list (y, next_state).
Passing state= is what opts in. Without it the body is the stateless f(block) shape and process agrees with block_process sample for sample.
lp = butter(4, "low", 80, 1000)
function lp_block(b, st)
s = st
out = ()
for v in b
s = filter_next(s, v)
out = append(out, s.y)
end for
return (out, s)
end function
p = processor("lp_block", block = 256, state = filter_init(lp), rate = 1000)
y = process(p, x) # or: p.process(x)
print("{p.latency} samples, {p.latency_ms} ms") # 256 samples, 256 ms
256 samples, 256 ms
Run that way, y equals sosfilt(lp, x) exactly — the biquad state crosses every block boundary. The same filter through block_process is off by about 1.1 on a unit-amplitude signal.
A body that forgets to return its state is an error rather than a silent reset: taken leniently, the seed state would be reused for every block, which is the block-edge bug this exists to remove, in a form that still returns a full-length signal and that no length or finiteness check would catch.
Latency. p.latency is the block length in samples — a block cannot be emitted until its last sample has arrived. p.latency_ms is the same figure in milliseconds, and exists only when the processor was given a rate; a processor that was never told a rate has no latency_ms field at all, rather than reporting milliseconds derived from a rate nobody supplied.
Iterating blocks yourself. When you want the loop rather than the machinery, blocks hands back an ordinary list:
for b in s.blocks(250)
print(rms(b))
end for
for b in s.blocks(100 ms) # sized against s.Fs
print(rms(b))
end for
0.705691 0.70852 0.705691 0.70852 0.679771 0.76825 0.62606 0.764603 0.686392 0.679771 0.76825 0.62606 0.764603 0.686392
Not built. §5 of the signal-toolkit design also sketches run(proc, input: mic, output: speakers) for live device I/O, pre-built stream.lowPass()/.fft()/.detectPeaks() stages, and signal.memoryMap(). None of these exist: there is no audio-device layer in the engine, and the file-side memory mapping (mmap_open/mmap_read/mmap_len) has no signal-domain counterpart. processor/process/blocks are the offline half only. Under-run counting belongs with the live half and is deliberately not surfaced as a field that would always read zero offline.
Overloads: sosfilt
Case: IIR filter (sos)
filt a Model with a sos field (butter/ellip/cheby1/cheby2). Runs x through the biquad cascade section by section.
fs = 100
t = (0 to 19) / fs
x = sin(2*pi*10*t)
iir = butter(2, "low", 20, fs)
y_iir = sosfilt(iir, x)
print("IIR-filtered length: {length(y_iir)}") # IIR-filtered length: 20
IIR-filtered length: 20
Case: FIR filter (b)
filt a Model with a b field (fir1/firls). Runs x through conv(x, b, "full"), truncated back to length(x) — no biquad state at all.
fs = 100
t = (0 to 19) / fs
x = sin(2*pi*10*t)
fir = fir1(6, 20, kind="low", fs=fs)
y_fir = sosfilt(fir, x)
print("FIR-filtered length: {length(y_fir)}") # FIR-filtered length: 20
FIR-filtered length: 20
Overloads: filter_init
Case: IIR filter (sos)
filt a Model with a sos field. Returns a filter_state Model whose z is an (n_sections, 2) all-zero biquad-state matrix — 2 numbers of memory per section.
fs = 1000
iir = butter(2, "low", 100, fs)
si = filter_init(iir)
print("IIR state shape: {shape(si.z)}") # IIR state shape: [1, 2]
IIR state shape: [1, 2]
Case: FIR filter (b)
filt a Model with a b field. Returns a filter_state Model whose z is a plain length-(len(b)-1) all-zero delay vector — just the last few inputs, no feedback to track.
fs = 1000
fir = fir1(6, 100, kind="low", fs=fs)
sf = filter_init(fir)
print("FIR state length: {length(sf.z)}") # FIR state length: 6
FIR state length: 6
Adaptive Filters
| Function | Signature | Description |
lms_init | lms_init(n_taps, mu) | The least-mean-squares (LMS) adaptive FIR filter's initial state. n_taps (number, at least 1): the adaptive FIR's length; mu (number, positive): the step size. Returns a Model (kind "lms") with fields w (a length-n_taps vector of weights, all zero), hist (the length-n_taps sliding input window, all zero), mu, y (the last output, 0) and e (the last error, 0). State is immutable, the same convention kalman_init uses: thread the value returned by .update(x, d) through successive calls rather than mutating in place, so the whole weight trajectory stays available. Stability: the weights converge for 0 < mu < 2 / (n_taps * E[x^2]); only mu > 0 is enforced, because E[x^2] is a property of the input signal that lms_init has not seen yet — the upper bound is the caller's to respect. Added in v0.3.0. |
update (lms) | state.update(x, d) | One LMS sample step. x (number): the new input sample; d (number): the desired/reference sample. Returns a new "lms" state (the receiver is untouched) whose w holds the updated weights, y the filter output for this sample and e the error d - y. Per sample, in this order: x is shifted into hist (newest first), y = dot(w, hist), e = d - y, w = w + mu * e * hist. Since y is formed from the window that already contains x, the 1-tap case reduces exactly to the textbook single-weight recursion e = d - w*x, w = w + mu*e*x. Added in v0.3.0. |
Adaptive filter example: identifying an unknown FIR
The classic use is system identification: drive an unknown filter and the adaptive filter with the same input, feed the unknown filter's output in as the desired signal d, and the weights converge to the unknown impulse response. Here h_true = [0.5, 0.3, -0.2] is recovered from 1500 noise samples.
h = [0.5, 0.3, -0.2]
s = lms_init(3, 0.05) # mu well inside 2/(3*1) = 0.667
x = randn(1500, seed = 7)
xm1 = 0
xm2 = 0
for i = 0 to 1499
xi = x[i]
d = h[0]*xi + h[1]*xm1 + h[2]*xm2 # the unknown system's output
s = s.update(xi, d)
xm2 = xm1
xm1 = xi
end for
print("w = {s.w}") # w = [0.5, 0.3, -0.2] to ~2 decimals
print("residual = {s.e}") # ~0 once identified
w = [0.5, 0.3, -0.2] residual = 0
The single-weight case is the same filter with n_taps = 1, and has a closed form worth checking an implementation against: with a constant input x = 1 chasing a gain of 0.5, w_k = 0.5 * (1 - (1-mu)^k), so mu = 0.1 reaches 0.4974 after 50 updates.
s = lms_init(1, 0.1)
for i = 1 to 50
s = s.update(1, 0.5)
end for
print("w = {s.w[0]}") # w = 0.4974231...
w = 0.497423
Filter Analysis
| Function | Signature | Description |
freqz | freqz(filt, [n=512]) | filt: a Model (kind "filter"); n (optional number, default 512): number of frequency points. Returns a length-n complex vector (CVec), the response H(e^{jω}) at n points from DC to Nyquist inclusive. abs(freqz(filt)) is the magnitude response, angle(freqz(filt)) the phase. Pairs with linspace(0, fs/2, n) for the matching frequency axis. |
group_delay | group_delay(filt, [n=512]) | filt: a Model (kind "filter"); n (optional number, default 512). Returns a length-n real vector, -dφ/dω in samples, same n-point/frequency-axis convention as freqz. For a linear-phase fir1 lowpass this is exactly constant at n_order/2 samples. |
fvtool | fvtool(filt, [n=512]) | filt: a Model (kind "filter"); n (optional number, default 512). MATLAB's Filter Visualization Tool: a 2x2 figure (magnitude response in dB, phase, group delay, impulse response) built from freqz/group_delay/sosfilt. Callable as filt.fvtool(). Returns Nothing — this call is made for the figure it draws. |
poles | poles(filt) | filt: a Model (kind "filter"). Returns a complex vector (CVec), one entry per pole across all sections/taps. For an IIR (sos) filter, each biquad section's z^2 + a1*z + a2 = 0 roots; for an FIR (b) filter, all poles sit at the origin (no feedback). |
zeros (filter overload) | zeros(filt) | filt: a Model (kind "filter") — the sole overload argument. Returns a complex vector (CVec) of that filter's zeros: IIR: each section's b0*z^2+b1*z+b2=0 roots; FIR: the tap polynomial's roots via the companion-matrix eigenvalue method (reusing eig). zeros is otherwise dual-purpose: with a plain size argument it's the ordinary array constructor (zeros(n)/zeros(r, c), returning a zero-filled vector/matrix). Matches MATLAB's zeros/tf2zpk naming, pairing with poles(filt). |
is_stable | is_stable(filt) | filt: a Model (kind "filter"). Returns a boolean, true iff every pole lies strictly inside the unit circle — the standard causal-LTI stability criterion. An FIR filter (poles at the origin) is always stable. |
pzplot | pzplot(filt) | filt: a Model (kind "filter"). Pole-zero plot (MATLAB's zplane concept): an x marker at each pole, o at each zero, and the unit circle as a reference polyline, titled with the filter's stability verdict. Returns Nothing — this call is made for the figure it draws. |
Overloads: poles
Case: IIR filter (sos)
filt a Model built by butter/ellip/cheby1/cheby2 (or anything else with a sos field). Returns each biquad section's z^2 + a1*z + a2 = 0 roots as a CVec — genuine, generally complex, poles.
fs = 1000
iir = butter(2, "low", 100, fs)
print("IIR poles: {poles(iir)}")
# IIR poles: [0.57149 + 0.293599i, 0.57149 - 0.293599i]
IIR poles: [0.57149 + 0.293599j, 0.57149 - 0.293599j]
Case: FIR filter (b)
filt a Model built by fir1/firls (a b field, no sos). An FIR filter has no feedback at all, so every pole sits at the origin regardless of the design — the same fact is_stable's "an FIR filter is always stable" relies on.
fs = 1000
fir = fir1(6, 100, kind="low", fs=fs)
print("FIR poles: {poles(fir)}")
# FIR poles: [0, 0, 0, 0, 0, 0]
FIR poles: [0, 0, 0, 0, 0, 0]
Overloads: zeros
| Call | Returns |
zeros(n) | A length-n real vector of 0.0s — the plain array constructor. |
zeros(r, c) | An (r, c) real matrix of 0.0s. |
zeros(filt) | A CVec of the filter Model's zeros (IIR: each section's b0*z^2+b1*z+b2=0 roots; FIR: the tap polynomial's roots) — nothing to do with array construction at all. |
The single argument's own type is what tells these apart: a plain number or pair of numbers builds an array, while a filter Model instead reads off poles(filt)'s zero-side counterpart. The example below builds a plain vector and a matrix, then asks the same name for a 2nd-order lowpass Butterworth's zeros.
v = zeros(4)
m = zeros(2, 3)
fs = 1000
lp = butter(2, "low", 100, fs)
zf = zeros(lp)
print("vector: {v}") # vector: [0, 0, 0, 0]
print("matrix shape: {shape(m)}") # matrix shape: [2, 3]
print("filter zeros: {zf}") # filter zeros: [-1, -1]
vector: [0, 0, 0, 0] matrix shape: [2, 3] filter zeros: [-1, -1]
Root-Finding & Optimization
These take a user-defined function *by name* (a string), the same convention pmap/spawn use — not a builtin function as the target.
| Function | Signature | Description |
fzero | fzero(f, a, b, [tol=1e-10], [max_iter=100]) | f: a string naming a user-defined function of one number, returning a number; a, b: numbers, the bracket endpoints where f changes sign; tol/max_iter (optional numbers). Returns a single number, the root. Brent's method: robust root finding given a bracket [a,b], no derivative needed. |
newton | newton(f, fprime, x0, [tol=1e-12], [max_iter=100]) | f, fprime: strings naming user-defined functions of one number (the function and its derivative); x0: number, the starting guess; tol/max_iter (optional numbers). Returns a single number, the root. Newton's method: fast (quadratic convergence) when it works, but needs a derivative and can diverge from a poor starting guess, unlike fzero. |
curve_fit | curve_fit(model, xdata, ydata, p0, [max_iter=200], [tol=1e-10]) | model: a string naming a user-defined function model(x, params) (x a scalar, params a length-P vector) returning a scalar; xdata, ydata: length-N vectors; p0: a length-P vector, the initial parameter guess; max_iter/tol (optional numbers). Returns a Model (kind "curve_fit") with fields params (a length-P vector), cost (a number, residual sum of squares), converged (a boolean). Nonlinear least-squares curve fitting via Levenberg-Marquardt (the Jacobian is numerical, forward differences): finds params minimizing sum((model(x_i, params) - y_i)^2). |
minimize | minimize(f, x0, [grad=], [max_iter=200], [tol=1e-8]) | f: a string naming a user-defined function f(params) (a length-P vector) returning a scalar; x0: a length-P vector, the starting point; grad (optional string naming a gradient function, else the gradient is numerical/central-differences); max_iter/tol (optional numbers). Returns a Model (kind "minimize") with fields params (length-P vector), value (a number), converged (a boolean). L-BFGS quasi-Newton multivariate minimizer. |
basin_hopping | basin_hopping(f, x0, [grad=], [n_iter=100], [step_size=1.0], [temperature=1.0], [seed=]) | f: a string naming f(params) as in minimize; x0: a length-P vector; grad (optional string); n_iter (optional number, number of hops); step_size/temperature (optional numbers); seed (optional number, RNG seed). Returns a Model (kind "minimize", same fields as minimize). Global optimization: repeats random perturbation + local minimize from there, accepting a worse local minimum with Metropolis probability exp(-(new-old)/temperature) so the search can escape the first local minimum found. |
monte_carlo | monte_carlo(f, n, [seed=]) | f: a string naming a zero-argument user-defined function (typically drawing its own randomness via randn/rand) returning a number; n: a number (integer), how many times to call it; seed (optional number). Returns a length-n vector collecting every result. |
Signal Generation
> square is a square-WAVE generator, not "x squared." The name is easy > to misread as an elementwise math function, e.g. piping data through it by > mistake: x = [1,2,3,4,5]; x |> square |> sum. That's a type error now > (see below), but if it's ever ambiguous at a call site, spell it > signals.square(...) instead — see "signals. sugar" below for the full > namespace, which exists purely for this disambiguation and is otherwise > 100% identical to the bare name. > > Every generator below validates its numeric arguments strictly: an > argument that's simply omitted still uses its documented default, but an > argument that IS supplied with the wrong type (e.g. a Vec/Signal > where a plain number was expected) is a clear error naming the function, > the argument, and what was actually given — never a silent fallback to > the default.
| Function | Signature | Description | |
sine | sine(freq, fs, n, [phase=0], [amplitude=1]) | freq, fs: numbers (Hz); n: number (integer sample count); phase (optional number, radians); amplitude (optional number). Returns a length-n Signal (carries Fs=fs). Single-tone generator built on the same synthesis kernel as multisine (a sine is a one-tone multisine). | |
multisine | multisine(freqs, amps, fs, n, [phases=]) | freqs, amps: length-K vectors (Hz, amplitude per tone); fs: number (Hz); n: number (integer sample count); phases (optional length-K vector, radians). Returns a length-n Signal. Sum of cosines at the given frequencies/amplitudes. Defaults to Schroeder's phase formula (phi_k = -k(k-1)*pi/K) when phases isn't given — keeps crest factor low without iterative optimization. | |
chirp / sweep | chirp(f0, f1, fs, n, [method="linear"]) | f0, f1, fs: numbers (Hz); n: number (integer sample count); method (optional string: "linear" or "logarithmic"/"log"/"exponential"). Returns a length-n Signal. Frequency sweep from f0 to f1 over n samples, reaching f1 exactly at the last sample (MATLAB's chirp convention), using exact analytic-phase formulas (not per-sample numerical integration). sweep is an alias with the same signature and return. | |
impulse | impulse(n, [index=0], [amplitude=1]) | n: number (integer length); index (optional number, integer sample position); amplitude (optional number). Returns a length-n plain vector (not a Signal, since no fs is given — wrap in signal(impulse(n), fs) if a sample rate is needed): a Kronecker delta, all zeros except amplitude at index. Also reachable as signals.impulse. | |
square (signals.square) | square(freq, fs, n, [duty=0.5]) | freq, fs: numbers (Hz); n: number (integer sample count); duty (optional number in (0,1), fraction of each period spent at +1). Returns a length-n Signal, a bipolar (±1) periodic pulse train. Not elementwise squaring — see the warning above. | |
pwm (signals.pwm) | pwm(modulator, carrier_freq, fs, [carrier="triangle"]) | modulator: a length-N vector or Signal (the message waveform, expected roughly in [-1,1]); carrier_freq, fs: numbers (Hz); carrier (optional string: "triangle" default, or "sawtooth"/"saw"). Returns a length-N Signal (same length as modulator). Natural-sampling pulse-width modulation: +1 where modulator exceeds the carrier wave oscillating in [-1,1] at carrier_freq, else -1. | |
sawtooth (signals.sawtooth) | sawtooth(freq, fs, n) | freq, fs: numbers (Hz); n: number (integer sample count). Returns a length-n Signal. Periodic linear ramp from -1 up to (but not including) +1 over each period, then an instant drop back to -1 (MATLAB's default sawtooth convention). | |
triangle (signals.triangle) | triangle(freq, fs, n) | freq, fs: numbers (Hz); n: number (integer sample count). Returns a length-n Signal. Periodic, continuous triangle wave: linear rise from -1 to +1 over the first half of each period, linear fall back to -1 over the second half — no discontinuity anywhere, unlike sawtooth. | |
sigma_delta | sigma_delta(x) | x: a length-N vector or Signal. Returns a same-length vector or Signal (preserving x's Fs when it's a Signal), each sample +1/-1. First-order (error-feedback) 1-bit noise-shaping quantizer: binarizes x, pushing quantization error to higher frequencies instead of spreading it evenly like plain sign(x) — the technique behind "DIBS-ΔΣ" excitation signals (multisine(...) | > sigma_delta) and 1-bit DACs. |
signals. sugar
signals.square(...)/signals.impulse(...)/signals.pwm(...)/ signals.sawtooth(...)/signals.triangle(...) are part of the same small, closed table./timer. "namespace-dot" alias set (§ ergonomic API layer, 2026-08-31; signals. added § argument-validation audit, 2026-09-01): the parser recognizes exactly those five signals . method ( token sequences and rewrites each, at parse time, straight to the bare call of the same name (signals.square(f, fs, n) becomes exactly square(f, fs, n)) — so evaluation is byte-identical either way. Unlike table.load/timer.start, these aliases don't rename anything; the point is purely disambiguation — an unambiguous way to spell the call when square in particular could otherwise be misread as "x squared." signals is not a namespace value; it still works as an ordinary variable (signals = 5; print(signals)).
The example below builds the same pulse train two ways — once through the bare square name, once through signals.square — and prints both to confirm the parser really does treat them as the exact same call.
a = square(100, 1000, 20, duty=0.25)
b = signals.square(100, 1000, 20, duty=0.25) # identical to `a`
print("square: {a}")
print("signals.square matches: {max(abs(a - b)) < 1e-12}")
square: signal(Fs=1000) [1, 1, 1, -1, -1, -1, -1, -1, ... (20 elements)] signals.square matches: true
Resampling
| Function | Signature | Description |
resample_to | resample_to(signal, new_fs) | signal: a length-N Signal (must carry a known Fs); new_fs: a number (Hz), the target sample rate. Returns a new Signal at Fs=new_fs, whose length is whatever it takes to span the *same* start/end time as the input (generally not N). Real resampling, not a relabel: evaluates the signal's own piecewise-linear waveform (through its samples at their original instants i/Fs, via the same kernel interp1's "linear" method uses) at the new instants j/new_fs — e.g. a 4-sample, 1 Hz ramp [0,10,20,30] (spanning t=0..3s) resampled to 2 Hz becomes the 7-sample [0,5,10,15,20,25,30], still spanning t=0..3s — never a same-length signal with Fs merely swapped out (which would silently misstate the recorded duration). signal.resample_to(new_fs) also works, via the generic method-chain sugar. Wrap a plain vector first with signal(data, Fs) if it isn't already a Signal. Anti-aliased on the way down, and this changed the numbers (v0.3.0). When new_fs < fs the samples now pass through a lowpass at the new Nyquist (new_fs/2, order 60, designed and applied at the original rate) before the grid change; before v0.3.0 they did not, so anything above the new Nyquist folded back into the band and came out as a plausible-looking tone that was never recorded. The filter is causal, so the result is delayed by its group delay, 30/fs seconds (order/2 = 30 input samples), and the first and last ~30 samples are filter transient. The order is reduced automatically for inputs shorter than 61 samples, and no filter is applied below 5 samples. Upsampling (new_fs >= fs) cannot alias and is untouched — same values, no filter, no delay. To opt out of anti-aliasing entirely, interpolate onto the new grid yourself with interp1 and accept the aliasing. |
upsample | upsample(x, L, [order=60], [fs=]) | x: a length-N vector or Signal; L: a whole number ≥ 1, the integer interpolation factor; order (optional, even, default 60): the FIR interpolation filter's order; fs (optional): the input sample rate, defaulting to the Signal's own Fs or 1.0 for a plain vector. Raises the sample rate by an integer factor: inserts L-1 zeros between samples, then interpolates with a lowpass at the original Nyquist (fs/2), scaled by L to preserve amplitude. That interpolation filter runs at the new rate fs*L, and the gain of L is what stops the zero-stuffing dividing the amplitude by L. Returns N*L samples — a Signal at Fs = fs*L if x was one, a plain vector otherwise. The filter is causal, so the output is delayed by order/2 samples at the OUTPUT rate (30 samples by default), which is order/(2*fs*L) seconds. L = 1 is the identity, with no filter and no delay. Added in v0.3.0. |
downsample | downsample(x, M, [order=60], [fs=]) | x: a length-N vector or Signal; M: a whole number ≥ 1, the integer decimation factor; order (optional, even, default 60): the FIR anti-alias filter's order; fs (optional): the input sample rate, defaulting to the Signal's own Fs or 1.0 for a plain vector. Lowers the sample rate by an integer factor, anti-aliasing first: applies a lowpass at the new Nyquist (fs/(2*M), at the input rate) and only then keeps every Mth sample. The order matters and is the point — decimating first and filtering afterwards filters alias that has already folded into the band, which no filter can separate from signal again. Returns ceil(N/M) samples (y[i] = filtered[i*M], sample 0 always kept) — a Signal at Fs = fs/M if x was one, a plain vector otherwise. The filter is causal, so the output is delayed by order/2 samples at the INPUT rate (30 by default, i.e. order/(2*M) output samples), which is order/(2*fs) seconds. M = 1 is the identity. Added in v0.3.0. |
resample_int | resample_int(x, L, M, [order=60], [fs=]) | x: a length-N vector or Signal; L, M: whole numbers ≥ 1, the interpolation and decimation factors; order (optional, even, default 60); fs (optional), as for upsample. Rational rate change by L/M in a single pass: zero-stuff by L, filter once, decimate by M. Returns ceil(N*L/M) samples at Fs = fs*L/M. Prefer this to x.upsample(L).downsample(M): that runs two filters in series at the same intermediate rate, doubling both the transition-band loss and the group delay for no benefit. The single shared filter's cutoff is min(fs/2, fs*L/(2*M)) — the interpolation limit when L >= M, the anti-alias limit when M > L — with gain L. Causal, so the output is delayed by order/2 samples at the intermediate rate fs*L (order/(2*fs*L) seconds), half what the two-step form costs. Added in v0.3.0. |
interp2 | interp2(x, y, Z, xq, yq, [method="bilinear"|"nearest"]) | x: a length-ncols vector, the grid's COLUMN coordinates (strictly increasing); y: a length-nrows vector, the grid's ROW coordinates (strictly increasing); Z: an (nrows, ncols) matrix with Z[i,j] the value at (y[i], x[j]) — Matrix::get(row, col)'s own indexing convention used everywhere else in this codebase, not a swapped axis order; xq/yq: paired query coordinates, either both numbers or both length-M vectors (xq[k]/yq[k] is query point k — not an implied meshgrid over every combination); method (optional string: "bilinear" default or "nearest"). 2-D grid interpolation (MATLAB's interp2): a query exactly at a grid coordinate returns that exact stored value, not an approximation; extrapolation beyond the grid is always on, matching interp1. Returns a number (if xq/yq are numbers) or a length-M vector (if they are vectors). |
interp2 is the 2-D counterpart of interp1 below — instead of a single (x, y) curve, it interpolates over a whole (x, y, Z) grid, the way a lookup table or a measured 2-D response surface would be sampled at an arbitrary point. The example below builds a tiny 2×3 grid, reads back an exact grid point (verifying no interpolation error is introduced there), then reads back the point exactly between all four corners (verifying the bilinear average).
x = [0, 1, 2] # column coordinates
y = [0, 1] # row coordinates
Z = [1, 2, 3; 4, 5, 6] # Z[i,j] at (y[i], x[j])
v_grid = interp2(x, y, Z, 1, 1) # exact grid point, no interpolation error
v_center = interp2(x, y, Z, 0.5, 0.5) # bilinear average of the 4 surrounding corners
print("interp2 at grid point (x=1,y=1): {v_grid}")
print("interp2 at center (x=0.5,y=0.5): {v_center}")
interp2 at grid point (x=1,y=1): 5 interp2 at center (x=0.5,y=0.5): 3
Interpolation
| Function | Signature | Description |
interp1 | interp1(x, y, xq, [method="linear"|"spline"|"nearest"]) | x, y: matching length-N vectors (the known points, x strictly increasing); xq: a number or length-M vector, the query point(s); method (optional string). 1-D interpolation through (x, y) (MATLAB's interp1). Extrapolation beyond x's range is always on — "linear" continues the boundary segment's own slope, "spline" extends the natural cubic spline's boundary segment, "nearest" just keeps returning the nearest endpoint's y — never a clamp or an error, matching what "interpolation *and* extrapolation" implies. Returns a number (if xq is a number) or a length-M vector (if xq is a vector). |
interpolate_at | interpolate_at(sig, t_query, [method="linear"|"spline"|"nearest"]) | sig: a length-N Signal; t_query: a number or length-M vector, query time(s) in seconds; method (optional string, interp1's own three). A Signal-aware interp1: instead of an explicit x, the signal's OWN implicit time axis (sig.t, i.e. i/Fs) is used as x and sig's samples as y. Shares interp1's exact interpolation/extrapolation kernel, so the two never silently diverge in behavior. sig.interpolate_at(t_query, [method]) also works via the generic method-chain sugar. Returns a number (if t_query is a number) or a length-M vector (if t_query is a vector) — never re-wrapped as a Signal: a Signal is a promise of uniform sampling at a known Fs, but the query times here are arbitrary caller-chosen instants with no guaranteed spacing, so there is no honest Fs to attach. |
Overloads: interp1
| Call | Extrapolation beyond x's range |
interp1(x, y, xq) / interp1(x, y, xq, "linear") | Continues the boundary segment's own slope. |
interp1(x, y, xq, "spline") | Extends the natural cubic spline's boundary segment. |
interp1(x, y, xq, "nearest") | Keeps returning the nearest endpoint's y — never a clamp or an error, matching what "interpolation *and* extrapolation" implies. |
The three methods agree everywhere xq sits inside x's own range; they only diverge on how far past the edge they're willing to follow a curve. The example below queries x=3.5, one full step past the last known point at x=3, all three ways.
x = [0, 1, 2, 3]
y = [0, 1, 4, 9]
lin = interp1(x, y, 3.5)
sp = interp1(x, y, 3.5, "spline")
near = interp1(x, y, 3.5, "nearest")
print("linear extrapolation at 3.5: {lin}") # linear extrapolation at 3.5: 11.5
print("spline extrapolation at 3.5: {sp}") # spline extrapolation at 3.5: 11.65
print("nearest extrapolation at 3.5: {near}") # nearest extrapolation at 3.5: 9
linear extrapolation at 3.5: 11.5 spline extrapolation at 3.5: 11.65 nearest extrapolation at 3.5: 9
interpolate_at saves you from manually building a signal's time axis just to call interp1 on it — hand it a Signal and a query time (or times) in seconds and it reads sig.t/sig's samples for you. The example below queries an interior point (ordinary interpolation), a point before the first sample and one after the last (extrapolation, using the boundary segment's own slope), and a vector of query times with the "nearest" method, both through the free-function and the method-chain spelling.
sig = signal([0, 10, 20, 30], 2) # samples at t = 0, 0.5, 1.0, 1.5s
mid = interpolate_at(sig, 0.75) # interior, halfway between samples 1 and 2
lo = interpolate_at(sig, -0.5) # extrapolated below t=0 via the boundary slope
hi = sig.interpolate_at(2.0) # extrapolated past the last sample
qs = sig.interpolate_at([0.0, 0.5, 1.0], "nearest")
print("mid={mid}, lo={lo}, hi={hi}, qs={qs}")
mid=15, lo=-10, hi=40, qs=[0, 10, 20]
interp1(x, y, xq)/interpolate_at's existing extrapolation already covers what a separate extrapolate_at builtin would offer — there is no such function, and none is needed.
Array Processing (beamforming)
| Function | Signature | Description |
beamform | beamform(signals, positions, angle_degrees, [c=343], [method="nearest"]) | Delay-and-sum beamformer. signals: a list of Signals, one per array element, all at the same Fs and the same length; positions: a matching-length vector of element coordinates (metres, measured along the array from its origin); angle_degrees: the steering direction in degrees off broadside (the same degrees-in convention imrotate/bode_phase use, not radians); c (optional): propagation speed in m/s, default 343 (air — use ~1500 for water), also spellable c=; method (optional): the interpolation kernel used to apply each delay, interpolate_at's own three, default "nearest". Steers each channel by its geometric delay x_m*sin(theta)/c (exactly steer_delays, shared code) and averages, returning a Signal at the inputs' own Fs — which is honest here, unlike interpolate_at's plain-vector return, because every query time is the uniform axis shifted by one constant per channel. method defaults to "nearest" rather than interpolate_at's "linear" on purpose: linear interpolation averages neighbouring samples, which low-pass-filters the noise and inflates the measured array gain (7.25 dB with "nearest" vs 8.58 dB with "linear" on the 5-element example below, against 10*log10(5) = 6.99 dB of theory) — the default must report what the geometry bought, not add denoising of its own. Added in v0.3.0. |
steer_delays | steer_delays(positions, angle_degrees, [c=343]) | The geometry half of beamform on its own: the per-element steering delays tau_m = x_m*sin(theta)/c, in seconds, for element coordinates positions and a direction angle_degrees degrees off broadside (c as in beamform: m/s, default 343, also spellable c=). Returns a plain vector, so it composes directly with interpolate_at(sig_m, sig_m.t + delays[m], method) when you want per-channel treatment beamform cannot express — element weighting/shading, a different method per channel, or a subset of the array. Added in v0.3.0. |
A five-microphone linear array, 0.08 m spacing, steered at a 1 kHz tone arriving 30° off broadside in air. Each channel carries the same tone with its own geometric delay plus independent noise; summing the steered channels raises the tone coherently while the noise adds incoherently, so the SNR improves by about 10*log10(M).
fs = 48000
d = 0.08
th = 30
c = 343
mics = [-2, -1, 0, 1, 2] .* d # positions, metres, origin-centred
taus = steer_delays(mics, th, c) # per-element delay, seconds
chans = []
for m in 0 to 4 {
t = (0 to 4799) ./ fs # 0.1 s of record
tone = sin(2 * pi * 1000 .* (t .- taus[m]))
chans = push(chans, signal(tone .+ 0.8 .* randn(1, 4800, seed=m), fs))
}
beam = beamform(chans, mics, th) # c=343, method="nearest" by default
print("beamformed {beam.len} samples at {beam.fs} Hz")
Elementwise Apply (container-preserving)
| Function | Signature | Description |
apply | apply(x, f) | x: a number, boolean, vector, matrix, or Signal; f: a one-argument function of a number returning a number — the function itself, or a Str naming one you defined. Returns the SAME container type and shape as x (a Signal(xs, fs) comes back as a new Signal at the same fs, a vector/matrix keeps its own shape, a bare number/boolean applies the function once). Elementwise map that PRESERVES x's own container type — unlike map(xs, f) (Collections, Strings & Data Frames), which always returns a plain List, discarding any Fs/shape information on purpose. A builtin is accepted as a value (apply(x, sin)) but refused as a quoted name (apply(x, "sin")), the same rule map/pmap follow. Argument order is (x, f) — the value first, like every other higher-order builtin — so the receiver lands in args[0] and x.apply(f) works for free via the generic method-chain sugar. Every element's result must be numeric; a function returning something else is a clear error naming the offending element. Any other input type (List, Table, Str, ...) is a clear error — map/pmap already own the arbitrary-result/List-returning case. |
Overloads: apply
Case: scalar
x a bare number (or boolean). Applies fnName exactly once, returning a number.
double(v) := v * 2
n = apply(5, "double")
print("scalar: {n}") # scalar: 10
scalar: 10
Case: vector or matrix
x a Vec/Mat. Applies fnName elementwise, returning the same shape.
double(v) := v * 2
v = apply([1, 2, 3], "double")
print("vector: {v}") # vector: [2, 4, 6]
vector: [2, 4, 6]
Case: Signal
x a Signal. Applies fnName elementwise and re-wraps the result at the SAME Fs — the one case map can't reproduce, since map always returns a plain List and drops the sample rate.
double(v) := v * 2
sig = signal([1, 2, 3, 4], 8)
s = sig.apply("double")
print("signal: {s}") # signal: signal(Fs=8) [2, 4, 6, 8]
signal: signal(Fs=8) [2, 4, 6, 8]
apply is the one to reach for when you have your own user-defined transform and need it applied elementwise while KEEPING whatever container you started with — a Signal's sample rate in particular, which map would silently drop. The example below defines a trivial doubling function and applies it to a Signal, printing the result to show the Fs survives the round trip.
double(v) := v * 2
sig = signal([1, 2, 3, 4], 8)
out = sig.apply("double") # same Fs, genuinely transformed
print("apply(double): {out}")
apply(double): signal(Fs=8) [2, 4, 6, 8]
Time-Based Slicing
| Function | Signature | Description |
cut | cut(sig, t_start, t_end) | sig: a length-N Signal; t_start, t_end: numbers (seconds, t_start >= 0 and t_end >= t_start). Returns a new Signal at the SAME Fs, shorter than sig. Trims sig to the time range [t_start, t_end] — INCLUSIVE of both ends, like every other range in the language, so cut(s, 0.2, 0.7) contains the sample AT 0.7. (The documented cost: abutting cuts no longer tile — cut(s, 0, 1) and cut(s, 1, 2) share the sample at t = 1. Step the second window's start on by one sample to partition.) t_start/t_end round to the nearest sample via sig's own Fs (i = round(t*Fs), the exact inverse of sig.t's i/Fs). A negative t_start or t_end < t_start is a clear error; t_end past sig's actual duration clamps to the last sample rather than erroring (matching ordinary slice hi-clamping, e.g. x[0:1000] on a 10-element vector). Composes with the @ self-mutation prefix like any other method chain: @s.cut(1.0, 2.0) needs no special-casing — it's an ordinary function under the hood. cut, signal_slice_time and the bracket form sig[t_start s : t_end s] are three spellings of ONE implementation (they share signal_time_span, so they can never silently diverge). |
signal_slice_time | signal_slice_time(sig, t0, t1) | sig: a length-N Signal; t0, t1: numbers (seconds). Returns a new Signal at the same Fs. The original name for the same operation as cut above — kept as a working alias, not deprecated. |
delay | delay(x, n) | x: a vector or Signal; n: a whole number of samples, positional or named (delay(x, samples = 4)). Returns the SAME length as x, shifted later by n with the vacated head zero-filled and whatever runs off the end dropped. A negative n advances instead (drops from the head, zero-pads the tail); abs(n) >= length(x) is all zeros rather than an error. A Signal keeps its Fs — moving samples along the time axis does not change how fast they were taken. |
wrap | wrap(x, [lo=-pi], [hi=pi]) | x: a number, vector, matrix or Signal; lo/hi (optional numbers, default -pi/pi): the half-open interval [lo, hi) to wrap into. Returns the same shape as x, each value reduced modulo hi - lo into that range — the standard fix for angle/phase data that has accumulated past a full turn (atan2 output, an integrated angular velocity). Defaults to [-pi, pi), the convention atan2/angle already return in. Errors if hi <= lo. |
delay keeps the length on purpose: that is what a fixed-size buffer does, and it is what lets x + delay(x, n) — a comb or a single echo, the obvious first use — line up without the caller trimming anything first.
v = [1, 2, 3, 4, 5]
print("{delay(v, 2)}") # [0, 0, 1, 2, 3]
print("{delay(v, -2)}") # [3, 4, 5, 0, 0]
[0, 0, 1, 2, 3] [3, 4, 5, 0, 0]
cut is how you trim a Signal down to a time window of interest — say, the settling portion after a step, or a single cycle of a periodic waveform — without doing the sample-index arithmetic (round(t*Fs)) yourself. The example below trims a 10-sample, 10 Hz signal to [0.5, 100.0] seconds (the far end clamps to the last real sample rather than erroring), then uses the self-mutating @s.cut(...) form to trim s itself in place.
s = signal(0 to 9, 10) # 10 samples at 10 Hz, t = 0.0, 0.1, ..., 0.9s
trimmed = cut(s, 0.5, 100.0) # end clamps past the signal's duration
print("trimmed: {trimmed}")
@s.cut(0.2, 0.7) # self-mutating form: s = cut(s, 0.2, 0.7)
print("s after @cut: {s}")
trimmed: signal(Fs=10) [5, 6, 7, 8, 9] s after @cut: signal(Fs=10) [2, 3, 4, 5, 6, 7]
Indexing by time and frequency
A Signal and a spectrum both carry their own axis, so both can be indexed in the axis's own units instead of in buffer positions. The unit on the index is what chooses the reading — there is no separate function to remember and no mode to set:
s = signal(0 to 999, 1000) # 1000 samples at 1 kHz, 1 s long
s[3] # sample 3 — a number
s[100:200] # samples 100..=200 — a Signal at the same Fs
s[0.1 s : 0.2 s] # 0.1 s to 0.2 s — a Signal at the same Fs
s[0.5 s] # the sample NEAREST 0.5 s — a number
s[100 ms : 200 ms] # identical to s[0.1 s : 0.2 s]; prefixes scale
X = rfft(s)
X[3] # bin 3 — a complex number
X[440 Hz] # the bin nearest 440 Hz — exactly spectrum_at(X, 440)
X[20 Hz : 200 Hz] # that band's bins — a complex vector
X[0.2 kHz] # same bin as X[200 Hz]; prefixes scale here too
(X[20 Hz : 20 kHz] is the usual audio-band spelling, but this X only reaches 500 Hz — its Nyquist — so the upper end would clamp there. A frequency past the last bin answers with the last bin rather than erroring, which is the convention spectrum_at already had.)
The rules, and why each one is what it is:
| Case | Result |
s[a:b], no units | Unchanged from before: sample positions, both ends inclusive, b past the end clamps. |
s[t0 s : t1 s] | A Signal at the same Fs — exactly cut(s, t0, t1), through the same code. |
s[t s] | The single nearest sample, round(t*Fs). Past the end errors rather than clamping, matching what s[999999] does: a scalar lookup has one right answer or none. A time *range* still clamps, matching what a positional range does. |
s[: t1 s] / s[t0 s :] | The omitted end reads as the start of the signal / its full duration, exactly as an omitted positional bound reads as 0 / the last index. |
X[f Hz] | The nearest bin, clamped at both ends — the same bin, through the same helper, that spectrum_at(X, f) returns. |
X[f0 Hz : f1 Hz] | A complex vector of that band's bins, *not* a spectrum. See below. |
s[440 Hz], X[0.5 s] | An error naming the transform that would make the request meaningful. A signal has no frequency axis; a spectrum has no time axis. |
s[0 : 0.2 s] | An error. The two readings differ by a factor of Fs, so neither is guessed. |
s[t0 s : n : t1 s] | An error: a step counts samples, which is a different axis from the one the bounds are written on. |
A frequency band returns a plain complex vector rather than a spectrum on purpose. Bin 0 of X[20 Hz : 200 Hz] is 20 Hz, not DC — so a spectrum wrapper around it would make .freq, spectrum_at and band_power all answer confidently and wrongly. This is the same reason band_zero zeroes a band in place instead of slicing one out: an axis that lies is worse than no axis, and a complex vector has no axis to be wrong about.
Before this existed, a unit-tagged index was not an error — it was a *silent* one. A unit-tagged number unwraps to its bare SI magnitude in most contexts, so s[1 s : 2 s] on a 1 kHz signal selected samples 1 and 2: two of the 1001 it plainly asks for, with nothing anywhere saying so.
Which rate is used
A Signal carries its own Fs. Every spectral producer now reads it, and the rule is the same for all of them:
| You pass | What is used |
A Signal, no fs | The signal's own Fs. welch(sig) and periodogram(sig) no longer require a rate argument at all. |
A Signal *and* a matching fs | That rate. Scripts that already pass the correct rate are unaffected. |
A Signal *and* a different fs | An error. This is the temporal analogue of a shape mismatch: one of the two numbers is wrong, and computing an answer from either would be guessing which. |
A plain vector and an fs | That rate — a vector carries none of its own. |
A plain vector, no fs | The builtin's default where it has one (spectrogram uses 1), otherwise an error asking for a rate or for signal(x, fs). |
The mismatch case cannot break correct code: a correct caller passes the rate the signal already has. What it catches is the genuine bug — a PSD or spectrogram computed at a rate the data was not sampled at, which until now produced a plausible plot with a silently mislabelled axis.
goertzel_freq is the one exception to the "rate becomes optional" half: it still requires fs positionally, because goertzel_freq(sig, 100) would otherwise be ambiguous between a sample rate and the frequency to evaluate. Its rate is still checked against the signal's.
Frequency-Based Addressing
A Signal knows its own sample rate, so the transform of one can know its own frequency axis. fft(sig)/rfft(sig) return a spectrum: the complex bins plus the sampling contract they came from. That removes the step that causes most DSP bugs — converting a physical frequency to a bin index by hand (round(f * N / fs)) and then keeping that arithmetic in step with every resample, zero-pad and window change.
fft on a *plain vector* still returns a plain complex vector, exactly as before: a vector carries no rate, so there is nothing to propagate.
| Accessor | Returns | Description |
X.Fs | number | The sample rate the spectrum was transformed from, in Hz. |
X.N | number | The transform length — the number of time-domain samples. fft(sig).N == sig.N. For a half spectrum this is *not* the bin count. |
X.bins | number | How many bins X actually holds: N for a full fft, N/2 + 1 for a half rfft. |
X.df | number | Bin spacing in Hz, Fs/N. Errors on an empty spectrum rather than reporting a meaningless 0. |
X.nyquist | number | The Nyquist frequency, Fs/2, in Hz. |
X.freq | Vec | The frequency axis in Hz, one entry per bin (k*Fs/N) — computed, never hand-built. |
X.mag | Vec | Bin magnitudes, sqrt(re^2+im^2). A plain vector, so it can be sliced freely. |
X.phase | Vec | Bin phases in radians, atan2(im, re), in (-pi, pi]. |
X.norm | string | How the producer normalised these bins: "raw_transform" (default, fft/rfft) — bin k is the unnormalised DFT sum, so a unit-amplitude tone in an N-sample record reads N/2; "amplitude" (rfft(sig, scaling="amplitude"), and spectrum's default) — a bin reads the peak amplitude of the tone at that frequency, so a unit-amplitude tone's bin reads 1.0; "rms" (rfft(sig, scaling="rms") or spectrum(sig, scaling="rms")) — that tone's RMS value instead (amplitude / sqrt(2)). "amplitude"/"rms" fold the negative-frequency half onto the one-sided bins returned by rfft, so DC and (for even N) Nyquist — which have no distinct mirror — keep the 1/N factor while every other bin gets the folded 2/N (further /sqrt(2) for "rms"). Read-only — stamped at construction, never passed in. |
X.norm exists because band_power is only correct for one convention: it reads raw bins and divides by N^2. Handed an already-normalised power density it would return a plausible number computed from the wrong premise, so it checks the tag rather than assuming — band_power refuses an "amplitude"/"rms" spectrum today rather than guess at the right formula for it (spectrum_unnormalize(X) first, or take rfft(sig) with no scaling= in the first place). band_zero is unaffected by the convention — it clears bins to zero regardless of what they're scaled to, checked directly against the "band_zero" => match arm, which has no norm guard at all — and preserves X's norm on the result. ifft/irfft refuse a non-raw_transform spectrum the same way band_power does: those scaled bins are not the unnormalised DFT sum an inverse transform needs, and silently treating them as if they were would reconstruct a signal smaller by exactly the folding factor, with nothing to notice it by. The tag is what makes a second producer (a scaled spectrum, or eventually a PSD estimator returning one) a visible difference at runtime rather than an invisible assumption baked into every script written before it landed. The analysis window and its ENBW are deliberately *not* recorded on a raw fft result: fft takes no window argument, so it cannot honestly claim one. spectrum *does* apply one and still cannot record it, which is why it refuses scaling="raw" — see its row above; the taper it applies is normalised to unit coherent gain, so the "amplitude"/"rms" bins it returns mean what they say without needing the window to travel with them.
| Function | Signature | Description |
spectrum_at | spectrum_at(X, freq) | X: a spectrum; freq: a number (Hz). Returns the single complex bin nearest that physical frequency (round(freq/df), clamped to the available bins) — addressing the spectrum in Hz instead of by index. |
band_power | band_power(X, f0, f1) | X: a raw_transform-scaled spectrum; f0, f1: numbers (Hz, f1 >= f0). Returns the power in that frequency band by Parseval, normalised by N^2 so a tone of amplitude A reads A^2/2. On a full spectrum each conjugate pair is counted once, since the pair is one physical tone. Refuses an "amplitude"/"rms"-scaled X today — call spectrum_unnormalize(X) first. |
band_zero | band_zero(X, f0, f1) | X: a spectrum; f0, f1: numbers (Hz, f1 >= f0). Returns a new spectrum at the same Fs, N and scaling with that band cleared — an ideal brick-wall notch. On a full spectrum it also clears each bin's conjugate partner (N-k), which is what keeps the inverse transform real; clearing only the positive-frequency half is the classic way to get a silently complex signal back out of ifft. |
spectrum_normalize | spectrum_normalize(X, [scaling="amplitude"]) | X: a raw_transform-scaled spectrum; scaling= (optional string, "amplitude" or "rms"). Returns a new spectrum with that convention applied — the same conversion fft/rfft(..., scaling=) do at transform time, usable afterward on a spectrum that already went through band_zero or another raw-bin operation. Errors if X is already normalised — spectrum_unnormalize(X) first if you want a different scaling. |
spectrum_unnormalize | spectrum_unnormalize(X) | X: an "amplitude"/"rms"-scaled spectrum. Returns a new raw_transform-scaled spectrum — the exact inverse of spectrum_normalize/scaling=. Required before ifft/irfft/band_power, which all refuse a non-raw_transform spectrum rather than silently treat scaled bins as the unnormalised DFT sum. |
There is deliberately no "slice a band out of a spectrum" verb. Slicing bins would leave bin 0 of the result no longer at DC, so .freq would quietly start lying about what the data is. band_zero keeps the axis intact, and .mag/ .freq hand back plain vectors that can be sliced safely.
fs = 1000
t = (0 to 999) / fs
x = sin(2*pi*100*t) + 0.5*sin(2*pi*300*t)
sig = signal(x, fs)
X = fft(sig)
print("df = {X.df} Hz, Nyquist = {X.nyquist} Hz")
print("power at 100 Hz: {band_power(X, 90, 110)}") # 0.5 = 1.0^2/2
print("power at 300 Hz: {band_power(X, 290, 310)}") # 0.125 = 0.5^2/2
clean = ifft(band_zero(X, 250, 350)) # notch out the 300 Hz tone
print("residual imaginary part: {max(abs(imag(clean)))}") # ~1e-16, still real
df = 1 Hz, Nyquist = 500 Hz power at 100 Hz: 0.5 power at 300 Hz: 0.125 residual imaginary part: 3.079059e-16
Windows (design *and* generation)
hann, hamming, blackman, and kaiser are used in two distinct places in the interpreter: as generator functions callable directly by name (below, returning the window samples as a plain Vec), and internally as the window option for fir1(..., window="hamming") and for every spectral estimator that tapers a segment — welch, periodogram, csd, spectral_coherence, spectrum, and psd, all spelled window="hann" (passed as a style-argument string, not called as a function). Both call sites share the same underlying raised_cosine_window/kaiser_window Rust implementations. The estimators additionally accept window="rectangular" (aliases "boxcar", "none") for an all-ones window — no taper — which has no generator-function counterpart because ones(n) already is one. Note the generators are symmetric (sym=True, right for FIR design) while the estimators build the periodic (sym=False) form of the same window, which is the convention spectral analysis wants; they therefore differ by one sample's worth of phase and are not interchangeable by hand.
| Function | Signature | Description |
hann | hann(n) | n: a number (integer window length). Returns a length-n real vector. Raised-cosine window, w[i] = 0.5*(1 - cos(2*pi*i/(n-1))). Symmetric convention (denominator n-1, MATLAB/SciPy sym=True); w[0] == w[n-1] == 0 exactly. |
hamming | hamming(n) | n: a number (integer window length). Returns a length-n real vector. Same raised-cosine family as hann but with coefficients 0.54 - 0.46*cos(...), chosen to minimize the nearest side-lobe (at the cost of slower roll-off). Endpoints are 0.08 exactly, not zero. |
blackman | blackman(n) | n: a number (integer window length). Returns a length-n real vector. Standard 3-term Blackman window, a0 - a1*cos(...) + a2*cos(2*...) with a0=0.42, a1=0.5, a2=0.08 (textbook "exact" coefficients). Endpoints are 0.0 exactly. |
kaiser | kaiser(n, beta) | n: a number (integer window length); beta: a number, required second positional argument (no default — it fundamentally changes the main-lobe-width/side-lobe-level tradeoff). Returns a length-n real vector, w[i] = I0(beta*sqrt(1-((2i/(n-1))-1)^2)) / I0(beta). Center sample is always 1.0 for any beta. |
Time & Frequency Axes
| Function | Signature | Description |
linspace | linspace([a=0], [b=1], [n=50]) | a, b (optional numbers): the inclusive endpoints; n (optional number, integer count). Returns a length-n real vector, n linearly spaced points from a to b inclusive — the standard companion for building a time or frequency axis to plot alongside a generated/transformed signal. |
logspace | logspace([a=0], [b=1], [n=50]) | a, b (optional numbers, decade exponents); n (optional number, integer count). Returns a length-n real vector, n logarithmically spaced points, 10^linspace(a, b, n) — used for frequency axes in Bode-style plots and rLKK's default DRT grid. |
Equivalent Circuits
A circuit is a value. You build it from elements, combine them in series and parallel, and ask for its impedance over a frequency sweep — instead of writing out the algebraically pre-solved closed form by hand.
That hand-solving is what this replaces. A Randles circuit is usually written as Rs + Rct/(1 + jwRctCdl), which is Rs in series with Rct parallel Cdl, with the parallel branch solved on paper. Fine for one circuit; it does not survive a library of them, and every new topology is a fresh chance to drop a factor.
randles = series(resistor(0.2), parallel(resistor(0.01), capacitor(100e-6)))
Z = impedance(randles, logspace(-2, 5, 200)) # frequencies in Hz
nyquist(Z)
A circuit is a model and carries no frequency axis of its own — impedance(c, freqs) is what turns it into data. That separation is deliberate: the same circuit on two different sweeps is the same circuit.
Elements
| Function | Signature | Impedance |
resistor | resistor(R) | R |
capacitor | capacitor(C) | 1/(jwC) |
inductor | inductor(L) | jwL |
cpe | cpe(Q, n) | 1/(Q (jw)^n) — constant-phase element. n=1 is a capacitor, n=0 a resistor, n=0.5 a Warburg. |
warburg | warburg(Aw) | Aw (1-j)/sqrt(w) — semi-infinite diffusion, a constant −45° phase at every frequency. |
warburg_short | warburg_short(Rw, tau) | Rw tanh(x)/x, x = sqrt(jw tau) — finite length, transmissive boundary. Spec-string name Ws. |
warburg_open | warburg_open(Rw, tau) | Rw coth(x)/x, x = sqrt(jw tau) — finite length, reflecting boundary. Spec-string name Wo. |
gerischer | gerischer(Zg, k) | Zg/sqrt(k + jw) — a coupled chemical reaction. |
porous | porous(Rp, Q, n) | sqrt(Rp Zi)/tanh(sqrt(Rp/Zi)), Zi = 1/(Q (jw)^n) — de Levie porous electrode. |
havriliak_negami | havriliak_negami(Rh, tau, a, g) | Rh/(1 + (jw tau)^a)^g |
Composition and evaluation
| Function | Signature | Description |
series | series(a, b, ...) | Two or more circuits in series; impedances add. |
parallel | parallel(a, b, ...) | Two or more circuits in parallel; admittances add. An ideal short in any branch shorts the block. |
impedance | impedance(c, freqs) | c: a circuit; freqs: frequencies in Hz, not rad/s. Returns a complex vector, one impedance per frequency — this is the forward form, which evaluates a circuit model; see *Measured impedance* below for impedance(voltage, current), which estimates the same quantity from two recordings instead. A zero or negative frequency is an error rather than an infinity: at DC a capacitor's impedance is unbounded and a Warburg's is undefined, and returning inf/NaN into a spectrum hides the mistake instead of reporting it. |
circuit | circuit(spec, params) | Builds a circuit from a spec string and a flat parameter vector. |
Spec strings
circuit("R-p(R,C)", [0.2, 0.01, 100e-6]) builds the same Randles circuit as the series/parallel form above. The grammar is A-B-C for series and p(A,B,C) for parallel, with each leaf an element letter plus an optional name — Rs, Rct and R1 are all resistors, and the name is for the reader.
| Name | Element | Params |
R C L W | resistor, capacitor, inductor, Warburg | 1 |
Q G | CPE, Gerischer | 2 |
Ws Wo | finite-length Warburg, short / open boundary | 2 |
P | porous | 3 |
H | Havriliak-Negami | 4 |
Element names are matched longest-first — two characters, then one. That is what lets Ws be an element at all: Ws was already a valid spelling before it was one (a W with instance tag s), so the boundary between element and tag cannot be "always one character". W1 still reads as a Warburg named 1, because W1 is not an element name.
T and O were the original spellings of Ws and Wo and are still read as deprecated aliases, so existing spec strings keep working. They are never written: a round trip normalises T1 to Ws, so the deprecation retires instead of persisting.
Parameters are consumed in left-to-right leaf order, and that ordering is the documented contract — it is what makes a parameter vector interchangeable with the papers/ecm-pf Qu and Python implementations, whose 40-topology cross-language check therefore also checks this code.
A parameter vector of the wrong length is an error in both directions. Too few is obvious; too many is rejected rather than truncated, because silently ignoring the tail would fit a different circuit than the one written.
c = circuit("R-p(Q,R)-Ws", [0.2, 1e-5, 0.8, 0.01, 5.0, 2.0])
print("{c}") # circuit("R-p(Q,R)-Ws") [0.2, 1e-5, 0.8, 0.01, 5, 2]
circuit("R-p(Q,R)-Ws") [0.2, 1e-5, 0.8, 0.01, 5, 2]Circuits compare with == by topology and component values. There is no order on them, so < is an error rather than a guess.
> Not yet built: fitting a circuit to measured data. The constraints that > work must respect — optimise log10 of magnitude parameters rather than > rescaling per circuit; weight chi-squared by the noise level so chi2 ~ 2N > is an absolute standard; never rank nested topologies by chi-squared, since > a superset always reaches its subset's score; use multiple starts; and > report which parameters the data actually constrains — are recorded in the > commit that added this section.
Measured impedance
impedance names two different computations, told apart by what you hand it. impedance(circuit, freqs) (above) evaluates a model. impedance(voltage, current) estimates the same quantity from two simultaneous recordings — the measurement, rather than the model of it. A circuit is never a recording and a recording is never a circuit, so the two forms cannot be confused; they share a name because they produce the same physical quantity, a complex impedance over a frequency axis.
| Function | Signature | Description |
impedance (measured) | impedance(voltage, current, [fs], [frequencies=], [method="h1"], [nperseg=], [noverlap=], [window="hann"]) | Returns an impedance model with fields z (a CVec), freq (the matching Hz axis), method, fs, and — on the broadband path only — coherence and nperseg. voltage/current: equal-length real vectors or Signals recorded simultaneously; fs (Hz) may instead be carried by either. Because Z = V/I, this is the transfer function from current (the input) to voltage (the output) — the reverse of the argument order, which follows the way the quantity is written. That ordering is what makes method="h1" the right default: H1 assumes the noise is on the output, i.e. on the measured voltage, which is the galvanostatic case. A potentiostatic rig has its noise on the current and should pass method="h2". With frequencies= (a vector of excitation frequencies in Hz) the impedance is evaluated by Goertzel at exactly those points instead, which is what a multisine or stepped sweep wants: EIS sweeps are logarithmically spaced and rarely land on FFT bin centres, and interpolating a complex spectrum across an arc is how a Nyquist plot acquires a kink that is not in the data. That path reports no coherence — a single-record ratio has no averaged segments for one to mean anything — and a frequency above Nyquist, at or below zero, or at which the current has no measurable content is an error rather than an alias or a division by zero. The measured form was added in v0.3.0; the forward impedance(circuit, freqs) form predates it. |
Read the result with Z.real(), Z.imag(), Z.magnitude(), Z.phase(), Z.freq and Z.coherence(), and plot it with Z.nyquist() or Z.bode(). The freq field follows the same convention as Spectrum's own .freq — bin k at k*fs/nperseg, in Hz — so an impedance result hands straight to rlkk_validate(Z.z, Z.freq) and the rest of the rLKK family below.
# A broadband (noise or multisine) excitation, galvanostatic.
Z = impedance(v, i, 1000)
print("coherence at bin 10: {Z.coherence()[10]}")
Z.nyquist()
# A stepped/multisine sweep at known excitation frequencies.
Z = impedance(v, i, 1000, frequencies=logspace(0, 2, 20))
Impedance Spectroscopy (rLKK)
A port of Ahmed Yousef Kallel's own published method (Kallel & Kanoun, "Regularized linear Kramers-Kronig transform for consistency check of noisy impedance spectra with logarithmic frequency distribution", IWIS 2021), shipped 2026-08-23. Z is a complex impedance spectrum (a length-N CVec), freqs its matching length-N frequency vector in Hz.
| Function | Signature | Description |
rlkk_reconstruct | rlkk_reconstruct(Z, freqs, [lambda=1e-4], [fx=]) | Z: a length-N complex vector (CVec); freqs: a length-N real vector (Hz); lambda (optional number): regularization strength, larger tolerates noisier data at the cost of fitting detail; fx (optional length-M real vector, Hz): overrides the default DRT frequency grid (logspace(-8, 8, 160), i.e. M=160). Returns a Model (kind "rlkk") with fields z (a length-N CVec, the reconstructed spectrum) and gamma (a length-M real vector, the fitted DRT coefficients). Fits the regularized Distribution of Relaxation Times (DRT) and reconstructs Z from it. |
rlkk_validate | rlkk_validate(Z, freqs, [lambda=1e-3], [threshold=2.0]) | Z: a length-N complex vector (CVec); freqs: a length-N real vector (Hz); lambda (optional number); threshold (optional number, percent). Returns a Model (kind "rlkk_validation") with fields valid (a boolean, true iff every point's percent residual is under threshold), residuals (a length-N real vector, percent), max_residual (a number). Checks Z against its own rLKK reconstruction. |
rlkk_extrapolate | rlkk_extrapolate(Z, freqs, target_freqs, [lambda=1e-4]) | Z: a length-N complex vector (CVec); freqs: a length-N real vector (Hz); target_freqs: a length-M real vector (Hz), which may extend beyond the measured band; lambda (optional number). Returns a single length-M CVec, not a model. Refits the DRT from (Z, freqs) and evaluates it at target_freqs — for reconstructing truncated/incomplete measurements. |
nyquist(Z), bode_magnitude(Z, freqs), and bode_phase(Z, freqs) (documented above under Spectral Analysis) are the standard plots for visualizing rLKK inputs and outputs.
Worked Examples
FFT and magnitude spectrum
fft/fftc is the entry point for almost every frequency-domain task in this chapter: given a real time-domain record, it returns the complex spectrum that everything else (magnitude, phase, peak-finding, filtering analysis) is read off of. The example below builds a two-tone signal (80 Hz and 300 Hz), transforms it, keeps only the positive-frequency half (the negative half is redundant for a real input), and plots magnitude against a matching Hz axis built with linspace. It then uses find_peaks to confirm both tones are recovered at their expected frequencies.
Fs = 1000
n = 0:1:1023
t = n / Fs
x = sin(2*pi*80*t) + 0.5*sin(2*pi*300*t)
X = fftc(x)
mag = abs(X)[0:512] # positive-frequency half
freqs = linspace(0, Fs/2, 512)
peaks = find_peaks(mag, min_height=max(mag)*0.2, min_distance=5)
print("tones at: {freqs[peaks]} Hz")
plot(freqs, mag)
xlabel("frequency (Hz)")
tones at: [80.234834, 300.391389] Hz · plot: figure 2 (513 points)
Butterworth design, batch and streaming filtering
butter is the go-to when you just need "a lowpass/highpass/bandpass/notch filter of about this order and cutoff" without worrying about ripple — its cascade-of-second-order-sections (sos) form stays numerically well-behaved even at higher orders, unlike a raw transfer-function [b, a] pair. The example below designs a 100 Hz lowpass and a 45-55 Hz mains-hum notch, applies the lowpass both causally (sosfilt, real-time-safe but phase-lagged) and zero-phase (filtfilt, offline-only but no phase distortion), checks both designs are stable, and reads the lowpass's frequency response with freqz.
fs = 1000
t = (0 to 999) / fs
signal = sin(2 * pi * 50 * t) + 0.5 * sin(2 * pi * 220 * t) + 0.1 * randn(1000, seed = 2)
lp = butter(4, "low", 100, fs) # 4th-order lowpass, 100 Hz cutoff
notch = butter(2, "notch", [45, 55], fs) # removes 45-55 Hz mains hum
filtered = sosfilt(lp, signal) # causal, one pass
zero_phase = filtfilt(lp, signal) # zero-phase, double effective order
print("lp is stable: {is_stable(lp)}, notch is stable: {is_stable(notch)}")
H = freqz(lp, 512)
plot(linspace(0, fs/2, 512), mag2db(abs(H)))
lp is stable: true, notch is stable: true · plot: figure 3 (512 points)
Spectrogram of a time-varying signal
Where a single fft gives one frequency snapshot of an entire record, spectrogram shows how that spectrum evolves over time — the standard tool for a signal whose content changes mid-record (a chirp, a burst, a spoken word). It slides a windowed FFT along x and renders the resulting magnitude-vs-time-vs-frequency data as a heatmap. The example below renders two stationary tones (80 Hz and 300 Hz, present for the whole record) as two horizontal bands.
Fs = 1000
n = 0:1:1023
t = n / Fs
x = sin(2*pi*80*t) + sin(2*pi*300*t)
spectrogram(x, 128, 32, Fs)
title("Two stationary tones")
xlabel("time (s)")
ylabel("frequency (Hz)")
Nonlinear curve fitting
curve_fit is for the common case where a model is nonlinear in its parameters (so ordinary least-squares regression doesn't apply) but a reasonable starting guess is available — fitting an exponential decay's rate and amplitude, a resonance's center frequency and width, and so on. The example below generates noisy samples of a known exponential decay a * exp(-b * x), fits the same functional form back from a [1, 1] starting guess, and prints how closely the recovered a/b match the values (3, 0.7) the data was generated from.
model(x, p) := p[0] * exp(-p[1] * x)
xdata = linspace(0, 5, 50)
ydata = 3 * exp(-0.7 * xdata) + 0.02 * randn(50, seed=1) # noisy samples of a=3, b=0.7
fit = curve_fit("model", xdata, ydata, [1, 1]) # initial guess [1, 1]
print("a = {fit.params[0]}, b = {fit.params[1]}, cost = {fit.cost}")
a = 2.990869, b = 0.702002, cost = 0.018922
rLKK reconstruction and validation
rlkk_reconstruct and rlkk_validate are the two halves of a self-consistency check on measured impedance data: fit a smooth Distribution of Relaxation Times to the spectrum, reconstruct the spectrum from that fit, and see how far the reconstruction departs from the original. A departure beyond the tolerance signals a measurement that violates the Kramers-Kronig relations (non-causal, non-linear, or drifting during acquisition) rather than genuine device behavior. The example below builds a clean synthetic RC impedance (so the reconstruction is expected to be near-exact), validates it, and draws the standard Nyquist view.
freqs = logspace(0, 4, 20)
R = 100
C = 1e-6
w = 2 * pi * freqs
Z = R / (1 + 1j * w * R * C) # a simple RC impedance, as measured data
fit = rlkk_reconstruct(Z, freqs, lambda=1e-4) # fit.z, fit.gamma
v = rlkk_validate(Z, freqs, lambda=1e-3, threshold=2.0)
if v.valid
print("measurement is causal/linear/stable within {v.max_residual}%")
else
print("bad points: {where(abs(v.residuals) > 2.0)}")
end
nyquist(Z) # Re(Z) vs -Im(Z), the standard EIS view
measurement is causal/linear/stable within 0.013416%
Direct DFT, inverse DFT, and Goertzel
dft/idft are the textbook O(n²) reference pair, kept around specifically to validate the fast implementations (fft/rfft) against on new code, rather than for everyday use. goertzel/goertzel_freq take the opposite approach — evaluating a single DFT bin (or a single Hz frequency) in O(n), which is the right tool when only a handful of known tones matter and paying for a whole FFT would be wasteful. irfft inverts a real-optimized half-spectrum back to a real signal. It has a second name, fftr, which is deprecated: it is the inverse transform sitting one row away from rfft, the forward one, so the pair reads backwards and gets used backwards. The example below round-trips a 100 Hz tone through fft/idft, checks goertzel/goertzel_freq agree on the same tone's bin, and round-trips rfft/irfft, printing the residual error of each round trip.
fs = 1000
n = 0:1:63
t = n / fs
x = sin(2*pi*100*t)
X = fft(x)
xr = idft(X)
print("idft roundtrip max err: {max(abs(xr - x))}")
k100 = 100 * length(x) / fs # bin index equivalent to 100 Hz
bin100 = goertzel(x, k100)
print("goertzel magnitude at bin {k100}: {abs(bin100)}")
same = goertzel_freq(x, fs, 100) # the Hz-parameterized convenience form
print("goertzel_freq agrees: {abs(same - bin100) < 1e-9}")
half = rfft(x)
back = irfft(half, 64)
print("rfft/irfft roundtrip max err: {max(abs(back - x))}")
idft roundtrip max err: 1.604386e-14 goertzel magnitude at bin 6.4: 32.158025 goertzel_freq agrees: true rfft/irfft roundtrip max err: 3.330669e-16
Level conversions and window functions
mag2db/db2mag/pow2db/db2pow are the small conversions that come up constantly when reporting amplitudes and powers on a decibel scale, and being separate named functions (rather than one ambiguous db()) avoids the classic factor-of-10-vs-20 mixup between amplitude and power ratios. hamming/blackman/kaiser (and hann, not shown here) are the window generators used both standalone and internally by fir1/welch/periodogram. logspace rounds this out as the standard way to build a log-spaced frequency axis. The example below exercises one of each.
amp = db2mag(-6) # amplitude ratio for -6 dB
pow = db2pow(-3) # power ratio for -3 dB
print("db2mag(-6) = {amp}, db2pow(-3) = {pow}")
w_hamming = hamming(8)
w_blackman = blackman(8)
w_kaiser = kaiser(9, 5) # beta=5, odd length so there's an exact center sample
print("hamming[0] = {w_hamming[0]}, blackman[0] = {w_blackman[0]}, kaiser center = {w_kaiser[4]}")
f = logspace(0, 3, 4) # 1, 10, 100, 1000 -- a log-spaced frequency axis
print("logspace(0,3,4) = {f}")
db2mag(-6) = 0.501187, db2pow(-3) = 0.501187 hamming[0] = 0.08, blackman[0] = -1.387779e-17, kaiser center = 1 logspace(0,3,4) = [1, 10, 100, 1000]
Welch and periodogram PSD estimates
welch and periodogram both estimate a signal's power spectral density — where it lands in frequency, not just a snapshot magnitude spectrum — but trade off differently: periodogram uses the whole record as one segment (higher frequency resolution, noisier estimate), while welch averages many shorter overlapping segments (coarser resolution, much less variance). The example below estimates the PSD of a 100 Hz tone buried in noise both ways and confirms each estimate's spectral peak lands near 100 Hz.
fs = 1000
n = 0:1:1023
t = n / fs
x = sin(2*pi*100*t) + 0.05 * randn(1024, seed=1)
psd_w = welch(x, fs, nperseg=256)
f_w = linspace(0, fs/2, length(psd_w))
peak_w = find_peaks(psd_w, min_height=max(psd_w)*0.5)
print("welch peak near {f_w[peak_w[0]]} Hz")
psd_p = periodogram(x, fs) # single-segment special case of welch
f_p = linspace(0, fs/2, length(psd_p))
peak_p = find_peaks(psd_p, min_height=max(psd_p)*0.5)
print("periodogram peak near {f_p[peak_p[0]]} Hz")
welch peak near 101.5625 Hz periodogram peak near 99.609375 Hz
Elliptic/Chebyshev/FIR filter design, application, and analysis
Beyond butter, the IIR family adds ellip and cheby1/cheby2 for when a steeper transition band is worth trading for passband/stopband ripple, and fir1/firls cover the FIR side (always stable, linear phase, but needing a higher order for the same rolloff). This example designs one of each IIR type at matching specs, applies an FIR lowpass two equivalent ways (filter_ba and its lfilter alias), designs a least-squares FIR from explicit band edges, and finishes with the filter-analysis tools (group_delay, poles, fvtool, pzplot) that inspect a design instead of just applying it.
fs = 1000
ep = ellip(4, 1, 40, "low", 100, fs)
c1 = cheby1(4, 1, "low", 100, fs)
c2 = cheby2(4, 40, "low", 100, fs)
print("ellip stable: {is_stable(ep)}, cheby1 stable: {is_stable(c1)}, cheby2 stable: {is_stable(c2)}")
fir = fir1(6, 100, kind="low", window="hamming", fs=fs)
b = fir.b
a = [1]
n = 0:1:255
t = n / fs
x = sin(2*pi*40*t) + sin(2*pi*300*t)
y = filter_ba(b, a, x)
y2 = lfilter(b, a, x) # lfilter is the same builtin under another name
print("filter_ba/lfilter agree: {max(abs(y - y2)) < 1e-12}")
lsq = firls(20, [0, 0.2, 0.3, 1.0], [1, 1, 0, 0]) # low-pass band edges/response
print("firls taps: {length(lsq.b)}")
gd = group_delay(fir, 256)
print("FIR group delay (should be ~order/2=3): {gd[10]}")
print("ellip filter has {length(poles(ep))} poles")
ep.fvtool() # 2x2 magnitude/phase/group-delay/impulse-response figure
pzplot(ep) # pole-zero plot with the unit circle and stability verdict
ellip stable: true, cheby1 stable: true, cheby2 stable: true filter_ba/lfilter agree: true firls taps: 21 FIR group delay (should be ~order/2=3): 3 ellip filter has 4 poles
Sigma-delta modulation and time-based slicing
sigma_delta turns a smooth signal into a 1-bit stream that still encodes the original waveform in its average behavior — the building block of 1-bit DACs and of "DIBS-ΔΣ" style excitation signals. signal_slice_time (the original name for cut, shown here for completeness) trims a Signal to a time window. Neither depends on the other; they're grouped here simply because both examples are short. The example below quantizes a 50 Hz tone and confirms the output only ever takes the values ±1, then slices a 10-sample signal down to [0.2, 0.6) seconds.
fs = 1000
tone = sine(50, fs, 20)
bits = sigma_delta(tone) # 1-bit noise-shaping quantizer
print("sigma_delta output range: [{min(bits)}, {max(bits)}]")
s = signal(0 to 9, 10)
trimmed = signal_slice_time(s, 0.2, 0.6) # same operation `cut` is the friendlier name for
print("signal_slice_time(0.2, 0.6) -> {trimmed}")
sigma_delta output range: [-1, 1] signal_slice_time(0.2, 0.6) -> signal(Fs=10) [2, 3, 4, 5, 6]
Extrapolating an impedance spectrum with rLKK
Real impedance measurements often don't cover the full frequency range a later analysis needs — an instrument might be limited at the low or high end while the physics of interest extends beyond it. rlkk_extrapolate refits the same regularized DRT model rlkk_reconstruct uses, then evaluates it at whatever frequencies are asked for, including ones outside the originally measured band. The example below extrapolates a synthetic RC impedance measured over 1 Hz-10 kHz out to a wider 0.1 Hz-100 kHz target grid.
freqs = logspace(0, 4, 20)
R = 100
C = 1e-6
w = 2 * pi * freqs
Z = R / (1 + 1j * w * R * C) # a simple RC impedance, as measured data
target = logspace(-1, 5, 10) # extends past the measured band on both sides
Z_ext = rlkk_extrapolate(Z, freqs, target, lambda=1e-4)
print("extrapolated {length(Z_ext)} points, first = {Z_ext[0]}, last = {Z_ext[9]}")
extrapolated 10 points, first = 100.003341 + 0.005947j, last = 0.110709 - 2.404278j
ADC signal-quality metrics: THD, SNR, SINAD, SFDR, ENOB
thd, snr, sinad, sfdr, and enob (see More functions below for their full parameter list, including the tones=/bins=/leak= options for multisine excitations) all characterize how cleanly a sampled record represents its input, each from a different angle: distortion power, noise power, the combination of both, the worst single spurious tone, and the resolution a converter is actually delivering. sinad(x) alone returns the whole breakdown as one record, which is usually more convenient than calling each metric separately. The example below builds a coherently-sampled tone with a known third-harmonic distortion component and background noise, reads off every metric, and compares the measured ENOB against enob_estimate's closed-form prediction for an ideal 12-bit converter.
fs = 1000
n = 0:1:1023
t = n / fs
f0 = 99 * fs / 1024 # exactly 99 cycles in the record -- coherent sampling
clean = sin(2*pi*f0*t)
x = clean + 0.01 * sin(2*pi*3*f0*t) + 0.001 * randn(1024, seed=3)
r = sinad(x) # full breakdown: r.sinad, r.enob, r.snr, r.thd, r.sfdr
print("THD = {thd(x)} dB")
print("SNR = {snr(x)} dB")
print("SINAD = {r.sinad} dB, ENOB = {r.enob} bits, SFDR = {sfdr(x)} dB")
print("ENOB (from thd/sinad breakdown) = {enob(x)} bits")
# closed-form estimates: what an ideal N-bit converter would show for a
# signal of a given crest factor, no record needed -- the number sinad(x)
# above is checked against. Signature is (bits, crest_factor).
est_sinad = sinad_estimate(12, sqrt(2)) # 12-bit ADC, sine-wave crest factor
est_enob = enob_estimate(12, sqrt(2))
print("ideal 12-bit ADC: SINAD = {est_sinad} dB, ENOB = {est_enob} bits")
THD = -39.98542 dB SNR = 56.858996 dB SINAD = 39.897111 dB, ENOB = 6.335068 bits, SFDR = 39.986015 dB ENOB (from thd/sinad breakdown) = 6.335068 bits ideal 12-bit ADC: SINAD = 74 dB, ENOB = 12 bits
Autocorrelation and the Hurst exponent
acf measures how similar a signal is to a delayed copy of itself at each lag — the standard way to find a periodic signal's own period (the lag at which correlation drops off, or where it comes back up for a periodic component) without a full spectral analysis. hurst_exponent characterizes a different property, long-range dependence: 0.5 means a random walk, above 0.5 persistent/trending behavior, below 0.5 mean-reverting behavior. The example below computes the autocorrelation of a noisy 20 Hz tone and finds where it first drops below 1/e, then contrasts the Hurst exponent of that same periodic-plus-noise signal against a genuine random walk (cumsum of white noise).
fs = 1000
n = 0:1:511
t = n / fs
x = sin(2*pi*20*t) + 0.2*randn(512, seed=4)
r = acf(x, max_lag=50)
below = find_peaks(-abs(r - 1/e))
print("acf[0] = {r[0]}, acf drops below 1/e near lag {below[0]}")
h_tone = hurst_exponent(x)
h_walk = hurst_exponent(cumsum(randn(512, seed=5)))
print("hurst (periodic+noise) = {h_tone}, hurst (random walk) = {h_walk}")
acf[0] = 1, acf drops below 1/e near lag 9 hurst (periodic+noise) = 0.71599, hurst (random walk) = 0.998318
Periodic and calendar profiles
periodic_profile folds a long record onto one arbitrary cycle length and summarizes each phase bin with a chosen statistic (stat="mean", "std", "var", "hurst", or "sad") — the tool for asking "does this disturbance repeat on a schedule?" without a full spectral analysis. minutely_profile/hourly_profile/daily_profile/monthly_profile are fixed presets of the same fold, at the cycle lengths their names suggest (1 hour in 60 minute-of-hour bins, 1 day in 24 hour-of-day bins, 1 week in 7 day-of-week bins, 1 Julian year in 12 equal-width bins), so a caller doesn't have to spell out period_seconds/n_bins for the common calendar cases. The five examples below each construct a synthetic signal with a known repeating pattern at the relevant timescale (a periodic spike, a day/night cycle, a burst of per-hour activity, a weekday/weekend split, an annual sinusoid) and confirm the profile recovers it.
## periodic_profile: a burst that recurs every 2 seconds, folded onto one cycle
fs = 200
n = 0:1:1999 # 10 seconds
t = n / fs
x = 0.05*randn(2000, seed=6)
x = x + where((t mod 2.0) < 0.1, 1.0, 0.0) # a short spike once per 2s cycle
prof = periodic_profile(x, fs, 2.0, 20, stat="mean")
print("periodic_profile bins: {length(prof)}, spike bin mean = {max(prof)}, baseline ~ {prof[10]}")
## hourly_profile: a day/night sinusoid, sampled once per minute over 2 days
fs_h = 1.0/60.0
n_h = 0:1:2879 # 2 days at 1 sample/min
t_h = n_h / fs_h
hour_of_day = (t_h/3600) mod 24
xh = sin(2*pi*hour_of_day/24) + 0.1*randn(2880, seed=7)
hp = hourly_profile(xh, fs_h, stat="mean")
print("hourly_profile: {length(hp)} bins, quietest hour idx = {find_peaks(-hp)[0]}")
## minutely_profile: activity concentrated in the first 5 minutes of each hour
fs_m = 1.0
n_m = 0:1:10799 # 3 hours at 1 Hz
t_m = n_m / fs_m
minute_of_hour = (t_m/60) mod 60
xm = where(minute_of_hour < 5, 1.0, 0.0) + 0.05*randn(10800, seed=8)
mp = minutely_profile(xm, fs_m, stat="mean")
print("minutely_profile: {length(mp)} bins, busiest minute idx = {find_peaks(mp)[0]}")
## daily_profile: weekday/weekend pattern, one sample per hour over 3 weeks
fs_d = 1.0/3600.0
n_d = 0:1:503 # 21 days at 1 sample/hour
t_d = n_d / fs_d
day_of_week = (t_d/86400) mod 7
xd = where(day_of_week < 5, 1.0, 0.0) + 0.05*randn(504, seed=9) # higher on weekdays
dp = daily_profile(xd, fs_d, stat="mean")
print("daily_profile: {length(dp)} bins (0=day0..6), values = {dp}")
## monthly_profile: an annual cycle, one sample per day over ~2 years
fs_mo = 1.0/86400.0
n_mo = 0:1:729 # 2 years at 1 sample/day
t_mo = n_mo / fs_mo
xmo = sin(2*pi*t_mo/31557600) + 0.05*randn(730, seed=10)
mop = monthly_profile(xmo, fs_mo, stat="mean")
print("monthly_profile: {length(mop)} bins, values = {mop}")
periodic_profile bins: 20, spike bin mean = 0.994081, baseline ~ -0.005544 hourly_profile: 24 bins, quietest hour idx = 17 minutely_profile: 60 bins, busiest minute idx = 3 daily_profile: 7 bins (0=day0..6), values = [0.999204, 1.007344, 1.00092, 1.008568, 1.005545, 0.008587, -0.005461] monthly_profile: 12 bins, values = [0.258528, 0.701187, 0.950701, 0.941434, 0.711217, 0.260041, -0.255472, -0.69034, ... (12 elements)]
Two things worth looking at
A filter's magnitude response, on a log frequency axis where a decade of roll-off is a straight line:
fs = 1000
b = butter(4, "low", 100, fs)
h = freqz(b, 512)
f = linspace(0, fs / 2, 512)
plot(f, mag2db(abs(h)), color = "#4169e1", width = 2)
xscale("log")
ylim(-80, 5)
xlabel("frequency (Hz)")
ylabel("magnitude (dB)")
title("4th-order Butterworth, 100 Hz cutoff")
· plot: figure 8 (512 points)
Fourth order is 80 dB per decade, and the plot is the cheapest way to check that the filter you designed is the filter you meant.
And a spectrum, which is what most of this chapter exists to produce:
fs = 2000
t = linspace(0, 1, fs)
x = sin(2 * pi * 120 * t) + 0.4 * sin(2 * pi * 350 * t) + 0.2 * randn(fs, seed = 4)
X = rfft(x)
f = linspace(0, fs / 2, len(X))
print("largest peak at {round(f[argmax(abs(X))], 1)} Hz")
plot(f, mag2db(abs(X)), color = "#4169e1")
xlabel("frequency (Hz)")
ylabel("magnitude (dB)")
title("two tones in noise")
largest peak at 120 Hz · plot: figure 9 (1001 points)
rfft returns the half-spectrum, which is all a real signal has; fftr is not its forward partner but an alias for irfft, the inverse.
More functions
| Function | Signature | Description |
fft | fft(x, [scaling=]) | x: a length-N real or complex vector. scaling= (optional string: "raw", the default, "amplitude" or "rms"), same conventions as rfft below, requires x to be a Signal. On the FULL (two-sided) spectrum this returns, a real tone occupies TWO conjugate-symmetric bins, and scaling makes each of them independently read the tone's full peak amplitude (or RMS) rather than half of it — correct per bin, but summing both (or integrating a band across the mirror) double-counts that tone's energy. Use rfft instead when a total is what you want. Returns a length-N complex vector (CVec, plain x) or spectrum (Signal x), the discrete Fourier transform. abs gives magnitude, angle phase. |
rfft, fftc | rfft(x, [scaling=]) | x: a length-N real vector (rfft) or real/complex vector (fftc). rfft returns a length-floor(N/2)+1 complex vector, the one-sided half-spectrum; fftc returns a length-N complex vector, the full spectrum. rfft is about twice as fast on real data since it only computes the non-redundant half. scaling= (optional string: "raw", the default, "amplitude" or "rms") picks the normalisation stamped on the result's X.norm — see *Frequency-Based Addressing* below — and requires x to be a Signal (the convention is metadata on Value::Spectrum; a bare CVec has nowhere to record it). Returns a CVec (plain vector x) or a spectrum (Signal x): length floor(N/2)+1 from rfft, length N from fftc. |
irfft | irfft(X, n) | X: a length-floor(n/2)+1 complex vector (a one-sided spectrum); n: a number, the required real output length. Returns a length-n real vector. Back from a one-sided spectrum to a real signal — n is needed because a one-sided spectrum cannot say whether the original length was odd or even. (fftr is a deprecated second name for this — see above.) |
fftr | fftr(X, n) | Deprecated — use irfft. The same function under a name that reads as rfft's forward partner; it is actually rfft's inverse, so the pair reads backwards. Still works, and warns once per run. Returns a length-n real Vec — the inverse transform of the half-spectrum X, exactly what irfft(X, n) returns. |
thd | thd(x) | x: a length-N real vector (the sampled record). Returns a single number, dB below the fundamental. Total harmonic distortion — every non-fundamental component counts, so a two-tone signal reads as distorted. See the full form under ADC signal-quality metrics above for the tones=/freqs=/bins=/fs=/leak= keyword options shared by this whole family. |
thd_n | thd_n(x, ...) | x: a length-N real vector, same optional keywords as thd above. Returns a single number, dB below the fundamental: harmonics *and* noise together against the signal (-sinad_db, the same noise_power sinad already totals over every non-excitation bin) rather than harmonics alone. Same sign convention as thd — more negative is better. |
snr, sinad, sfdr | snr(x, [tones=|freqs=], [fs=|rate=], [bins=], [leak=0]) | x: a length-N real vector. With no excitation named, the largest non-DC FFT bin is taken as the fundamental (the single-tone case); tones=/freqs= (a vector, Hz, needs fs=/rate= too) or bins= (a vector of integer bin indices) names a multisine's tone set explicitly — treating a multisine as one tone counts every other tone as noise and reads tens of dB low. leak= (optional number, default 0) widens each named tone by that many bins on each side, for a record that isn't exactly bin-coherent. snr/sfdr each return a single number (dB); sinad(x) (no distinguishing suffix) instead returns the whole breakdown as a record with fields sinad, enob, snr, thd, sfdr (numbers), bins (a vector of the fundamental's bin indices), signal_power, noise_power (numbers). Returns a scalar Num in dB for snr and sfdr; sinad returns that eight-field Record instead. |
enob, enob_estimate | enob(x, [tones=|freqs=], [fs=|rate=], [bins=], [leak=0]) | x: a length-N real vector, same optional keywords as snr above. Returns a single number, bits — effective number of bits, inverting 6.02·bits + 1.76 on the measured SINAD. What a converter actually delivers, as opposed to what its datasheet says. enob_estimate(bits, crest_factor) is the separate closed-form estimator, documented in the next row. Returns a scalar Num, in bits. |
sinad_estimate, enob_estimate | sinad_estimate(bits, crest_factor) | bits: a number, the converter resolution; crest_factor: a number, the signal's peak-to-RMS ratio (e.g. sqrt(2) for a sine wave) — both required positional scalars, not a data record. Returns a single number: sinad_estimate in dB, enob_estimate in bits. Closed-form estimate of the SINAD/ENOB an ideal bits-resolution converter would show for a signal of the given crest_factor, with no record needed — the theoretical curve the measured sinad(x)/enob(x) above are checked against. |
dominant_frequency, estimate_frequency | dominant_frequency(x, [fs]) | x: a length-N real vector or Signal; fs (optional number, Hz) is read off a Signal input the same way welch/rfft do, or passed explicitly — a plain vector with neither returns a normalized frequency in cycles/sample instead of Hz. Returns a single number: the frequency of the largest non-DC bin in x's one-sided spectrum, accurate to the bin's own resolution fs/N. The cheapest of the estimators a full sine-fit or phase-regression method would improve on; both names are the same function. |
acf | acf(x, [max_lag=]) | x: a length-N real vector. max_lag (optional number, default min(40, N-1), R's own acf() default): highest lag to compute. Returns a vector of length max_lag+1 (lag 0 through max_lag), the autocorrelation function. The first lag at which it falls below 1/e is the usual correlation-time estimate. |
hurst_exponent | hurst_exponent(x) | x: a length-N real vector. Returns a single number: 0.5 is a random walk, above that persistent, below it mean-reverting — long-range dependence. |
periodic_profile | periodic_profile(x, fs, period_seconds, n_bins, [stat="std"]) | x: a length-N real vector; fs: a number (Hz); period_seconds: a number, the cycle length to fold onto; n_bins: a number (integer), bins per cycle; stat (optional string: "mean", "std", "var", "hurst", or "sad"). Returns a length-n_bins real vector, one summary statistic per bin. Fold a long record onto one cycle and summarise each bin — the way to see whether a disturbance keeps time with something. |
minutely_profile | minutely_profile(x, fs, [stat="std"]) | x: a length-N real vector; fs: a number (Hz); stat (optional string, as above). Returns a length-60 real vector. periodic_profile over an hour, in 60 minute-of-hour bins. |
hourly_profile | hourly_profile(x, fs, [stat="std"]) | x: a length-N real vector; fs: a number (Hz); stat (optional string). Returns a length-24 real vector, one bin per hour-of-day. A daily rhythm shows up here as a shape, where a spectrum would only give it a peak. |
daily_profile | daily_profile(x, fs, [stat="std"]) | x: a length-N real vector; fs: a number (Hz); stat (optional string). Returns a length-7 real vector, one bin per day-of-week -- so a weekday-against-weekend difference separates out. |
monthly_profile | monthly_profile(x, fs, [stat="std"]) | x: a length-N real vector; fs: a number (Hz); stat (optional string). Returns a length-12 real vector, one bin per equal-width twelfth of a Julian year. Equal width, not calendar months: the language has no calendar type, and bins of unequal length would not be comparable anyway. |
Measurement Diagnostics
Is this recording trustworthy *before* it is analysed at all. A clipped record still has a spectrum, a mean and a THD figure — every one of them wrong, and not one of them says so.
Why clipping detection looks for flat runs, not a threshold
The obvious test — "does any sample reach the maximum?" — fires on every signal ever recorded, because *some* sample is always the largest one. Tightened to "within a few percent of full scale" it still fires on any healthy recording that uses its headroom, which is exactly the well-made recording you least want flagged. A sine sampled near its peak legitimately touches the top of its range.
What a clipped record has and an unclipped one does not is a flat run: several consecutive samples holding the *same* value at an extreme — the converter emitting one code over and over because the input went somewhere it could not follow. Consecutive samples of a real sine near its peak differ by roughly A·2π²/N² for N samples per cycle, so requiring equality separates the two cases without having to guess where full scale was.
That is what makes the threshold default — the record's own largest magnitude — safe rather than a silent guess: on its own it flags nothing, it only picks the level the run test is applied at. Pass full_scale= when the converter's range is known; a flat run part-way up the range is then correctly *not* reported, because that is a limiter, not the converter.
Two limits, stated rather than hidden:
- This catches hard clipping. Soft/analogue saturation rounds the shoulder over instead of flattening it and will not produce exactly equal samples — catch it with an explicit
threshold=and a loosertol=. - A heavily oversampled record that has already been quantised can hold the same code for several consecutive samples near its peak without being clipped, once the per-sample change near the peak falls below one LSB. Raise
min_runpast the expected dwell, or pass the converter's realthreshold=. There is no way to tell the two apart from the samples alone.
detect_saturation is the case where the converter is *known* rather than guessed, and it deliberately uses a different test: no run is required, because when the rail is a number you were handed, a single sample sitting on it is already evidence, and demanding a run would lose exactly the short excursions that matter most. Its code geometry comes from the same place adc quantises with, so the detector and the simulator cannot drift into two slightly different converters. The consequence worth knowing: codes run 0 ..= 2^bits - 1, so the top code is full_scale - lsb, not full_scale — a check that looks for samples at exactly full_scale finds none, ever.
Quantization simulation lives in adc
There is no separate quantize/simulate_adc. adc already takes a word length and a reference range, optionally dithers, and reports how much clipped — a second function over the same arithmetic would be the two-that-drift-apart this section exists to avoid. adc(x, bits, full_scale=) is the symmetric spelling, sharing the word dbfs, is_clipped and detect_saturation all use for the same quantity; vmin=/ vmax= remains available for an asymmetric or offset range. Giving both is an error, not a precedence rule.
| Function | Signature | Description |
is_clipped | is_clipped(x, [threshold=], [full_scale=], [tol=], [min_run=3]) | x: a length-N real vector, Mat or Signal. Returns a Bool: does x contain a flat run at an extreme. threshold=/full_scale= (optional numbers, interchangeable spellings, must be positive) set the magnitude a sample must reach to be a candidate; with neither, the record's own largest magnitude is used — safe because detection is run-based, see above. tol= (optional number, default 1e-9 × threshold) is how nearly equal consecutive samples must be; raise it for soft saturation. min_run= (optional whole number ≥ 1, default 3) is how many consecutive samples make a run — min_run=1 degrades this to the plain "any sample at or beyond the threshold" test. Errors on an empty record, and on an all-zero record with no explicit threshold (there is no level to test against). |
find_clipping | find_clipping(x, [threshold=], [full_scale=], [tol=], [min_run=3]) | Same detection as is_clipped, same keywords, but returns *where*. Returns a Model of kind "find_clipping" (the named-field multi-return findpeaks/qr/svd already use, since Qu has no tuple unpacking) with fields count (a Num, how many runs), starts, ends (Vecs of run bounds, inclusive at both ends like every Qu range), lengths (a Vec), values (a Vec, the level each run is held at — signed, so the positive rail is distinguishable from the negative one), samples (a Num, total samples inside runs), fraction (a Num, samples / N), and threshold, tol, min_run (Nums, echoing back the parameters actually used — when the threshold was inferred, a caller reading only starts has no way to know what level the answer is relative to). |
detect_saturation | detect_saturation(x, adc_bits, [full_scale=1.0]) | x: a length-N real vector, Mat or Signal; adc_bits: a whole number 1–32, positional or as adc_bits=/bits= — required, since there is no rail to check against without it. Has x hit the rails of an adc_bits-bit converter: the clipping question for a converter whose range is *known* rather than inferred, so no flat run is required and a single sample sitting on a rail already counts. full_scale= (optional number, default 1.0, must be positive) sets a range symmetric about zero, [-full_scale, +full_scale]: signal-processing records are bipolar by default, and a unipolar reading would report the entire negative half as railed. The default is the stated convention dbfs already set, not a value read off the data — inferring the range from the record would make the answer trivially "yes" for every record. Returns a Record with fields saturated (a Bool), high, low, total (Nums, samples on the top rail, the bottom rail, and either), fraction (a Num, total / N), lsb, vmin, vmax, top_code (Nums, the code geometry used — note top_code = vmax - lsb), and bits (a Num). Errors on an empty record. |
verify_signal | verify_signal(x) | x: a real vector, Mat or Signal. Structural sanity before any of the semantic diagnostics above is worth running — deliberately narrow: it answers "are these samples a usable record", not "is this signal any good". Returns a Record with fields n (a Num, sample count), nan, inf (Nums, counts — called out separately because a single NaN makes a mean, an FFT or a filter return NaN *throughout*, not just at that position), finite (a Bool), constant (a Bool, every sample equal — reported, but not an error: a DC measurement is a real thing and a stuck channel is the caller's judgement), ok (a Bool) and issues (a List of strings naming what is wrong, empty when ok). Given a Signal, two more fields — rate and duration (Nums) — and a rate that is zero, negative or non-finite becomes an issue, since every frequency axis derived from it would be meaningless. Named verify_signal rather than verify because a bare verify says nothing about what it verifies. |
Not implemented, deliberately: check_aliasing. From a single record at one sample rate, an aliased component is mathematically *indistinguishable* from a genuine in-band one — that is the sampling theorem, not a gap in the implementation. A component folded down from 0.6·fs and a real tone at 0.4·fs produce identical samples, and no test applied to those samples can separate them. Any single-record "aliasing check" is therefore a heuristic about spectral shape (energy piling up near fs/2), which flags legitimately broadband content — noise, square waves, transients, step responses — as readily as it flags real aliasing. Rather than ship a confident-looking guess, this is left out; the honest checks are check_leakage-style coherence analysis against a *known* excitation, or an oversampled reference capture, neither of which is a property of one record.
Pulse and Edge Measurements
Oscilloscope-style measurements on a pulse train or a step response: where the signal crosses a level, where its edges and pulses are, and the standard timing numbers read off them.
Two layers, and the difference between them is the thing to know before reading any row below:
- Locators —
find_trigger,find_zero_crossings,find_edges,find_pulses— answer *where*, and return integer sample indices intox, exactly asfind_peaksdoes. An index is an index whether or not the input carries a sample rate. - Measurements —
rise_time,fall_time,pulse_width,pulse_period,pulse_frequency,duty_cycle,overshoot,undershoot— answer *how long* or *how much*, interpolate between samples the way a scope does, and report in seconds when the input is aSignalcarrying a rate and in samples otherwise. There is nofs=argument on any of them: a rate only changes the unit the answer is quoted in, never whether it can be computed.
All of it is built on one crossing definition, find_trigger. A crossing is defined on the boolean state x[i] >= level, so "exactly at the level" counts as high — which guarantees that rising and falling crossings strictly alternate, the property find_pulses relies on to pair them.
A third layer, to_digital, is sugar over the two above: it bundles a signal and a threshold into one Digital value so the threshold doesn't have to be repeated at every call site. d = x.to_digital(level) then d.edges()/d.duty_cycle()/... reads the same as find_edges(x, level=level)/duty_cycle(x, level=level), just once instead of per call — it is the identical math underneath, not a second implementation. duty_cycle accepts either shape (duty_cycle(x, level=...) or duty_cycle(d) on a Digital value); edges/rising_edges/ falling_edges/high_time/low_time are Digital-only, since (unlike duty_cycle, which already existed) they were added specifically as Digital methods and there was no established plain-signal form to stay compatible with.
On top of that digitized layer sit the protocol decoders. decode_uart and decode_spi both take Digital values and both return a decoded-frame table — one row per byte, carrying the fields that say whether to trust it — which is the shape §8 of docs/design/toolkit-signal.md groups the whole family by. They return a Table rather than the Model of parallel vectors find_edges/find_pulses use, because a decoded frame is a row of *mixed-type* fields (a number, a hex string, a validity flag, an error name) and a Model's fields are numeric vectors — the error column could not be text at all. It also means filter, nrow, sort_by and the rest of the table verbs work on a decode result without any special casing. decode_i2c/decode_can are named in the same section of the design doc and return a Model instead (see below) — the two decoder families settled on different return shapes because I2C/CAN transactions nest (a transaction holds a list of address/data records) in a way a flat per-byte Table row doesn't represent as naturally.
On top of that sit the protocol decoders, which turn a digitized capture back into the traffic that produced it. decode_i2c and decode_can are both in the table below. They share an input shape (a Digital value) and an output shape (a Model holding a list of decoded records), and nothing else: each is a protocol-specific state machine, and the two protocols disagree about almost everything that matters.
The one difference worth understanding before reading either row is where the bit timing comes from. I2C ships a clock wire, so decode_i2c takes two Digital values and never needs to be told a rate — SCL says when each bit is valid, and the START/STOP conditions are defined by SDA moving *while SCL is high*, which is why neither wire decodes alone. CAN ships no clock, so decode_can takes one Digital value and must be told the bit length, via bitrate= on a Signal (whose sample rate supplies the rest) or samples_per_bit= on anything.
CAN also needs bit de-stuffing, and this is not a detail: the protocol inserts an extra bit of opposite polarity after any five consecutive identical bits, to keep receivers' clocks locked. Ordinary traffic trips this constantly — an identifier or payload byte with a run of five like bits is common, not exotic — and a decoder that skips de-stuffing does not fail loudly, it shifts every later field by one bit per missed stuff bit and reports a plausible wrong frame. decode_can de-stuffs from SOF through the end of the CRC sequence (the CRC delimiter, ACK slot and EOF are transmitted unstuffed) and reports how many stuff bits it removed, so the count can be checked rather than trusted.
Two unit conventions worth reading twice, because they differ on purpose: duty_cycle returns a fraction in [0, 1], so that duty_cycle(x) * pulse_period(x) is the high time; overshoot/undershoot return a percent, because those numbers are read rather than composed and 0.05 would be taken for 0.05%.
pulse_frequency is an edge-counting measurement and is deliberately not called frequency: dominant_frequency/estimate_frequency above answer a different question by a different method (the largest bin of the magnitude spectrum). On a clean square wave they agree; on a signal with a strong harmonic, a drift, or a burst that is not periodic across the whole record, they do not, and both return a plausible number.
| Function | Signature | Parameters and return |
find_trigger | find_trigger(x, level, [edge="rising"]) | x: a length-N real vector or Signal; level: a number, the trigger level — positional and required (defaulting it would let find_trigger(x) answer confidently about a level the caller never chose), though level= is accepted as a keyword too. edge (optional string): "rising" (default), "falling", or "both"; "rise"/"fall"/"up"/"down"/"positive"/"negative"/"any"/"either" are accepted spellings. Returns a vector of 0-indexed integer sample indices, one per crossing, each being the first sample of the new state — a rising crossing at i means x[i-1] < level <= x[i]. A transition involving a NaN is skipped rather than reported, so a dropout does not manufacture a pair of edges. |
find_zero_crossings | find_zero_crossings(x, [edge="both"]) | x: a length-N real vector or Signal; edge (optional string) as above. find_trigger at level zero. The default is both directions, unlike find_trigger's own default: the zero-crossing rate is a count of every sign change, so len(find_zero_crossings(x)) has to be that count to be worth anything, and a "rising" default would silently halve it. Returns a vector of 0-indexed integer sample indices. |
find_edges | find_edges(x, [level=], [threshold=], [hysteresis=0]) | x: a length-N real vector or Signal; level=/threshold= (optional number, same thing under two names) defaults to the signal's own midpoint (min+max)/2; hysteresis (optional number, default 0): with 0 this is a plain single-threshold detector, i.e. find_trigger run in both directions; with a positive value it is a Schmitt trigger requiring level + hysteresis/2 to call high and level - hysteresis/2 to call low, so noise on a slow edge yields one edge rather than a burst. The reported positions are taken at level either way, so switching hysteresis on to reject glitches does not move the measurements it protects. Returns a Model (kind "edges") with fields indices (a vector of integer sample indices), directions (a vector, +1 rising / -1 falling, parallel to indices), positions (a vector of interpolated sub-sample crossing points), rising and falling (the two index splits), and count (a number). |
find_pulses | find_pulses(x, [level=], [hysteresis=0], [polarity="positive"]) | x: a length-N real vector or Signal; level/hysteresis as for find_edges; polarity (optional string): "positive" (default) pairs each rising edge with the next falling one, "negative" the reverse. Returns a Model (kind "pulses") with fields starts, stops (vectors of integer sample indices — stops, not ends, because end is a Qu keyword), widths (a vector of times: seconds for a Signal, samples otherwise, measured between the interpolated crossings so that mean(p.widths) equals pulse_width(x) exactly), and count (a number). A partial pulse at either end of the record — already high when capture started, or still high when it stopped — has only one of its two edges and is not reported; its width is genuinely unknown. |
to_digital | to_digital(x, threshold) | x: a length-N real vector or Signal; threshold: a number, positional and required. Returns a Digital value (a Model, kind "digital") remembering x and threshold together, so edges/rising_edges/falling_edges/high_time/low_time/duty_cycle below can be called on it without repeating the threshold. |
edges | edges(d, [hysteresis=0]) | d: a Digital value from to_digital. Same hysteresis as find_edges, same return shape (a Model, kind "edges", with indices/directions/positions/rising/falling/count) — d.edges() is find_edges(x, level=threshold) with the threshold already remembered. |
rising_edges, falling_edges | rising_edges(d, [hysteresis=0]) | d: a Digital value. Returns a vector of the interpolated crossing positions of one polarity only — seconds for a Signal, samples otherwise (the same convention find_pulses's widths field uses). |
high_time, low_time | high_time(d, [hysteresis=0]) | d: a Digital value. Returns a single number: the sum of the widths of every complete pulse above (high_time) or below (low_time) the threshold, in seconds for a Signal and samples otherwise. Unlike pulse_width (a mean, which errors with zero complete pulses), this is a sum, so a record with no complete pulse of that polarity returns 0.0 rather than erroring — there is a defined answer ("no time observed"), it's just zero. Deliberately not duty_cycle(d) * record_length, which would assume a clean periodic waveform the input isn't obligated to have. |
decode_uart | decode_uart(d, [baud=], [samples_per_bit=], [bits=8], [parity="none"], [stop=1]) | d: a Digital value from to_digital. Decodes asynchronous serial frames into bytes at a stated bit rate. Bit timing is stated, not recovered: give either baud= (bits per second, converted through the signal's own sample rate) or samples_per_bit= directly — one of the two is required. baud= needs a Signal, since a bits-per-second figure has nothing to refer to on a bare vector; that case is an error naming samples_per_bit= rather than a silent Fs = 1 that would decode the whole capture into nonsense. bits (optional, 5–9, default 8) data bits; parity (optional string) "none" (default), "even" or "odd"; stop (optional, 1 or 2, default 1). The line is assumed to idle high (a frame opens on a falling edge, the stop bit is high) and data is LSB-first, which the standard fixes — there is deliberately no bit_order= here, unlike decode_spi. Each frame is sampled at its bit centres measured from its own start edge, so the decoder re-syncs on every frame instead of drifting off the bit centres across a long capture. Returns a Table, one row per frame, with columns byte (number), hex (string, e.g. "0x48"), start (the falling edge that opened the frame — seconds for a Signal, samples otherwise), valid (1/0) and error (string: empty, "framing", "parity" or "framing+parity"). A frame whose stop bit is low or whose parity disagrees is reported with its decoded bits and flagged, never silently returned as a clean byte; a frame cut off by the end of the record is not reported at all, the same rule find_pulses applies to a partial pulse. Added in v0.3.0. |
decode_spi | decode_spi(clk, [mosi], [miso], [mode=], [cpol=0], [cpha=0], [bits=8], [bit_order="msb"], [cs=], [cs_active="low"]) | clk: a Digital clock. Decodes clocked SPI traffic into bytes, sampling each data line at whichever clock edge CPOL and CPHA select. SPI carries its own clock, so unlike decode_uart there is no bit rate to state — but there are multiple input signals. At least one data line is required, given positionally (decode_spi(clk, mosi) or decode_spi(clk, mosi, miso)) or by name (miso= alone, for a capture that only tapped the peripheral's output). Every line must be the same length; a mismatch is an error rather than a truncation to the shortest, since two captures of different lengths were not taken together. mode (optional, 0–3) is the datasheet spelling of cpol/cpha, with mode = 2*CPOL + CPHA; passing mode= and cpol=/cpha= is an error rather than a precedence rule, because the two can contradict. CPHA=0 samples on each clock cycle's leading edge and CPHA=1 on its trailing edge, with CPOL fixing which physical direction that is — so modes 0 and 3 sample rising, modes 1 and 2 falling. bits (optional, 2–32, default 8); bit_order (optional string) "msb" (default) or "lsb", both genuinely in use on real peripherals. cs (optional Digital) segments the capture into frames: each assertion starts a new frame and bumps frame, clock edges while CS is idle are ignored entirely, and a partial byte left over when CS releases is discarded; cs_active (optional string) is "low" (default) or "high". Without cs= the capture decodes as one continuous byte stream with frame 0 throughout — frame boundaries are deliberately not guessed from clock gaps, which has no correct answer on a bus whose idle time is unspecified. Returns a Table with columns frame, start (the sampling edge of the byte's first bit — seconds for a Signal, samples otherwise), and mosi/mosi_hex and/or miso/miso_hex for whichever data lines were given. A trailing partial byte is not reported. Added in v0.3.0. |
decode_i2c | decode_i2c(scl, sda, [hysteresis=0]) | Added in v0.3.0. scl, sda: two Digital values from to_digital, clock first, data second — I2C framing is defined by SDA moving while SCL is held high, so neither wire decodes alone and a single-argument call is an error, as is a clock and data of different lengths (they are two channels of one capture; differing lengths mean they are not aligned in time). hysteresis as for find_edges. There is deliberately no bit-rate keyword: I2C carries its own clock, and a rate keyword would invite stating something the decoder must not believe over SCL itself. Returns a Model (kind "i2c") with transactions (a list), addresses (a parallel vector) and count. Each transaction is a Model (kind "i2c_transaction") with address, read (bool), direction ("read"/"write"), ten_bit, address_complete, address_ack, address_acks (one entry per address byte), data (a vector of bytes), acks (1/0 per data byte, where 1 means the receiver pulled SDA low to acknowledge), byte_count, repeated_start (whether *this* segment was preceded by an Sr), terminator, partial_bits, start_index/stop_index and start_time/stop_time. Two things that look like bugs and are not: a repeated START ends one transaction record and begins another — the bus is never released, but the address and direction are restated, and flattening both halves of a register read into one record would discard exactly the framing this decoder exists to preserve, so terminator is a string ("stop", "repeated_start", "truncated") rather than a boolean; and partial_bits is normally 1 after a clean STOP, because generating STOP requires a real SCL rising edge that is sampled as a data bit before SDA rises to end the transfer. A transaction the capture cut off is still reported, with terminator = "truncated" and whatever bytes did decode — a clipped capture is ordinary, and dropping it silently would look identical to there having been no traffic. 10-bit addressing is handled, including the asymmetry that trips naive decoders: a 10-bit write sends the reserved 11110xx0 prefix byte *and* a second byte of low address bits, but the read half (after a repeated START) sends only 11110xx1 — so the number of address bytes is decided by the R/W bit, not fixed, and the low byte is carried forward from the write phase, with address_complete reporting false if no such phase was captured. |
decode_can | decode_can(bus, [bitrate=], [samples_per_bit=], [polarity="normal"], [hysteresis=0]) | Added in v0.3.0. bus: one Digital value — the differential pair has already been resolved to a logic level by the transceiver or by the scope's own threshold. Bit length is required and comes from either bitrate= (125000, 250000, 500000, 1e6, …), which needs a Signal whose sample rate supplies the rest, or samples_per_bit=, which works on a plain vector too; with neither, or with fewer than 2 samples per bit, it errors rather than guessing. polarity (optional string): "normal" (default) means bus dominant = logical 0 = LOW, which is what a transceiver's RX pin gives you and leaves the bus recessive/high when idle; "inverted" for a capture taken off an inverting buffer or the other line of the pair. Getting polarity wrong does not reliably yield zero frames — the complemented stream still offers candidate start bits — but it cannot produce a CRC-valid one, so check crc_ok rather than the frame count. hysteresis as for find_edges. Handles both base (11-bit) and extended (29-bit) identifiers; which one a frame uses is not knowable up front, since both open with SOF and 11 identifier bits and it is the IDE bit — the *thirteenth* — that finally says, so the fields are read strictly sequentially. Returns a Model (kind "can") with frames (a list), ids (a parallel vector), count, error_count (candidate frames abandoned on a stuff error) and samples_per_bit (the resolved value, worth printing when a bitrate= is suspected wrong). Each frame is a Model (kind "can_frame") with id, extended, rtr, dlc, byte_count, data, crc, crc_ok, crc_delim_ok, ack, ack_delim_ok, eof_ok, stuff_bits, start_index and start_time. dlc is reported raw and byte_count separately, rather than silently reconciling the two: a remote-transmission-request frame states a DLC but carries no payload at all, and classical CAN caps the payload at 8 bytes while DLC values 9–15 are legal on the wire and still mean 8. ack is true when the ACK slot was driven dominant, i.e. some node on the bus acknowledged. The decoder resynchronizes on every recessive-to-dominant edge, the way a real CAN controller does — on an ideal capture this changes nothing, and on a drifting one it is what stops the sample point walking off the bit over a ~130-bit frame. |
rise_time, fall_time | rise_time(x, [low=0.1], [high=0.9], [base=], [top=]) | x: a length-N real vector or Signal. low/high (optional numbers, defaults 0.1/0.9) are fractions of the step, not levels, so the 20%–80% convention is low=0.2, high=0.8. base/top (optional numbers) are the levels the step runs between, defaulting to x's own min and max. Returns a single number: the transition time of the first complete rising (resp. falling) transition, in seconds for a Signal and samples otherwise, with both endpoints linearly interpolated between samples. Errors rather than guessing when the record holds no complete transition. Caveat, and it is a common case: on a step that *rings*, the automatic top is the overshoot peak rather than the settled value, which drags the 90% level up and reports a rise time that is too long — pass top= explicitly when the step overshoots. |
pulse_width | pulse_width(x, [level=], [hysteresis=0], [polarity="positive"]) | x: a length-N real vector or Signal; keywords as for find_pulses. Returns a single number: the mean width of the complete pulses, in seconds for a Signal and samples otherwise. Equal by construction to mean(find_pulses(x, ...).widths). |
duty_cycle | duty_cycle(x, [level=], [hysteresis=0]) | x: a length-N real vector, Signal, or Digital value (from to_digital, in which case its stored threshold is the default level, still overridable); keywords as for find_edges. Returns a single number: mean high time over mean period, as a fraction in [0, 1], not a percentage — multiply by 100 for the figure a scope's front panel shows. The fraction is the composable form: duty_cycle(x) * pulse_period(x) is the high time. |
pulse_period, pulse_frequency | pulse_period(x, [level=], [hysteresis=0]) | x: a length-N real vector or Signal; keywords as for find_edges. Returns a single number: the mean interval between successive rising crossings (pulse_period) or its reciprocal (pulse_frequency) — seconds and Hz for a Signal, samples and cycles/sample otherwise. Errors when the record holds fewer than two rising crossings, i.e. not a full cycle. These are edge-counting measurements; dominant_frequency/estimate_frequency above are spectral estimators answering a different question — see the note before this table. |
overshoot, undershoot | overshoot(x, [settle_level=], [initial_level=], [settle_frac=0.1]) | x: a length-N real vector or Signal. settle_level/initial_level (optional numbers) default to the mean of the last and first settle_frac of the record (10%, at least one sample each — a mean rather than the single end samples, so one noisy point cannot carry the whole measurement). Returns a single number, a percent of the step's own size abs(final - initial): overshoot is how far the response travels *past its settled value* in the direction the step was going, undershoot how far it backs up *past where it started* (the pre-shoot), which is MATLAB stepinfo's convention. Both clamp at zero. A falling step works the same way with min and max exchanged. Errors when the record contains no step to be a percentage of. |
Reflection and Matching
How much of a wave a load sends back, and the quantities engineers quote for it. smith already drew this — these are the same arithmetic as *values*, so a script can read the number the chart is plotting. Both call one implementation (qu_core::rf), so the plot and the number cannot drift apart.
All four derived quantities take a reflection coefficient, not an impedance. vswr(reflection_coefficient(Z)) is one call longer than vswr(Z, z0=50) would be, and it says which model it is in; the alternative is a function whose meaning changes silently depending on whether a keyword was passed.
Scalars and vectors both work: a bare load gives a bare answer, a swept vector gives a vector, which is the shape measured data arrives in.
| Function | Signature | Description |
reflection_coefficient | reflection_coefficient(z, [z0=50]) | z: an impedance — real or complex, scalar or vector (ohms); z0 (optional number, default 50): the real reference impedance. Returns Γ = (z - z0)/(z + z0), complex. Errors at z = -z0, where Γ is genuinely undefined rather than merely large (impossible for a passive load). |
impedance_from_reflection | impedance_from_reflection(g, [z0=50]) | g: a reflection coefficient, scalar or vector; z0 (optional number, default 50). Returns z = z0(1 + Γ)/(1 - Γ), the exact inverse. Errors at Γ = 1, an ideal open. |
vswr | vswr(g) | g: a reflection coefficient. Returns (1 + |Γ|)/(1 - |Γ|). A perfect match is exactly 1; total reflection is inf, which is the true value rather than a large stand-in. |Γ| >= 1 (an active or mismeasured load) returns inf rather than the negative number the unguarded formula produces. |
return_loss | return_loss(g) | g: a reflection coefficient. Returns -20 log10 |Γ| in dB, positive for a passive load — the engineering convention in which "20 dB return loss" means well matched. A perfect match is inf. |
mismatch_loss | mismatch_loss(g) | g: a reflection coefficient. Returns -10 log10(1 - |Γ|²) in dB: the incident power not delivered to the load. Zero for a perfect match. |
z0 is a real reference impedance. Complex-z0 (power-wave) definitions exist and are not what these compute; with a real z0 the voltage-wave and power-wave definitions coincide, which is why the simpler one is safe here.
g = reflection_coefficient(100) # 100 ohm on a 50 ohm line
print("gamma = {g}") # 0.333333
print("VSWR = {vswr(g)}") # 2
print("RL = {return_loss(g)} dB") # 9.542425
swept = reflection_coefficient([50, 100, 0, 25])
print("VSWR swept = {vswr(swept)}") # [1, 2, inf, 2] -- the short is inf
smith([50, 100, 25], z0=50) # same arithmetic, drawn
gamma = 0.333333 VSWR = 2 RL = 9.542425 dB VSWR swept = [1, 2, inf, 2]