Python QPSK Generator
Build a simple digital radio signal in Python and see it on a spectrum display and a scatter plot of its samples.
Blocks used
Overview
Digital radios carry data by switching a carrier between a few agreed positions, and building one of those signals yourself is the quickest way to see what it looks like. Quadrature phase shift keying, or QPSK, uses four phases 90 degrees apart, so every symbol carries two bits.[1]
This flowgraph writes a QPSK signal in under twenty lines of NumPy inside a Python block. A Signal Generator supplies faint complex noise, the Python block adds random symbols on top of it, and a Spectrum Analyzer and a Constellation show the result. Plasma is a gentler first look at how a Python block feeds a native display.
Everything is generated in software, so no radio is needed, only a Python installation with NumPy for the Python block.
How it works
The Signal Generator makes 4,096 complex noise samples per update at 1 MS/s. For every 8 of those samples, the Python block draws two random bits. Each bit sets the sign of one axis, so the pair picks one of four points, , equally far from the origin and differing only in phase. The block scales the point by 0.7, holds it for 8 samples, and adds the noise.
That makes 512 symbols per update, each lasting 8 µs, for 125,000 symbols per second or 250 kb/s.
Holding each symbol makes a rectangular pulse. A rectangular pulse of length has a sinc-shaped spectrum, a central hump with smaller ripples on each side, whose first nulls fall at .[2] For 8 µs symbols that is kHz.
What to look for
The Constellation plots every sample as a dot, in-phase (I) across and quadrature (Q) up. Four clouds sit at the corners of a square, about halfway from the center to each edge, each smeared by the noise. The pulses jump from corner to corner, so no dots appear in between. Setting the generator's Noise Variance to 0 shrinks each cloud to a single point, and raising it from 0.005 to 0.05 swells the clouds until they start to overlap.
The Spectrum Analyzer shows a broad hump at 0 MHz instead of a single spike. The hump falls into its first dips at MHz, and smaller ripples repeat every 125 kHz out to the edges of the 1 MHz span. Those ripples come from the sharp jumps between phases.[2] The noise fills in the dips, so with Noise Variance at 0 they drop off the bottom of the plot, and raising it from 0.005 to 0.05 lifts them by 10 dB. Every update brings fresh random symbols, so the trace jitters until Lineplot Averaging is raised. Changing samples_per_symbol in the code from 8 to 4 and pressing Ctrl+Enter doubles the symbol rate and the width of the hump.
For a gentle introduction to spectra and digital modulation, see the sources below.
Going further
More points in the constellation carry more bits per symbol. To turn the QPSK into 16-QAM, replace the four lines of the Python block from the # Two random bits comment through symbols = with the lines below, then press Ctrl+Enter to run them.
levels = rng.integers(0, 4, (2, count))
i, q = 2 * levels - 3
symbols = amplitude * (i + 1j * q) / np.sqrt(18)
Each axis now takes one of four levels, so the Constellation shows 16 clouds in a grid, and each symbol carries four bits, or 500 kb/s. The hump keeps its width and its dips at MHz, because bandwidth follows the symbol rate, not the number of points.[1] The points sit a third as far apart as in QPSK, so their clouds nearly touch, and denser constellations need a cleaner signal before a receiver can tell the points apart.[1]
The generator's Noise Variance and Sample Rate can change freely, and the symbol rate follows the sample rate. The value of samples_per_symbol must divide 4,096 evenly, and the generator's Buffer Size must stay at 4,096 to match the Python block's declared output, or the block stops with a shape error in its console. The Python block reference explains output shapes.
Real transmitters smooth the jumps between symbols with a pulse-shaping filter, which greatly narrows the spectrum.[3] The Signal Generator entry mixes tones, chirps, and noise with native blocks, and the Spectrum Analyzer entry points the same display at a live radio.
References
References
-
M. Lichtman, "Digital modulation," PySDR: A Guide to SDR and DSP using Python. pysdr.org/content/digital_modulation.html ↩ ↩2 ↩3
-
M. Lichtman, "Frequency domain," PySDR: A Guide to SDR and DSP using Python. pysdr.org/content/frequency_domain.html ↩ ↩2
-
M. Lichtman, "Pulse shaping," PySDR: A Guide to SDR and DSP using Python. pysdr.org/content/pulse_shaping.html ↩