diff --git a/README.md b/README.md index b3db0f5..14452ce 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,11 @@ sandbox; and [`docs/fused-fec.md`](docs/fused-fec.md) — the cross-layer (PHY-MCS ⊕ sub-block-integrity ⊕ outer erasure) FEC stack the link's per-layer quality SLA is stated against. +New to the low-level RF machinery? [`docs/rf-primer.md`](docs/rf-primer.md) is a +visual primer — four short animations (the OFDM channel, the constellation, a +tone vs a modulated carrier, and AGC saturation) that make the rest of the docs +click. + ### Startup time Devourer reaches ready-to-RX/TX faster than the `aircrack-ng/88XXau` diff --git a/docs/img/agc_saturation.gif b/docs/img/agc_saturation.gif new file mode 100644 index 0000000..268fc1a Binary files /dev/null and b/docs/img/agc_saturation.gif differ diff --git a/docs/img/constellation.gif b/docs/img/constellation.gif new file mode 100644 index 0000000..9eefe7d Binary files /dev/null and b/docs/img/constellation.gif differ diff --git a/docs/img/ofdm_anatomy.gif b/docs/img/ofdm_anatomy.gif new file mode 100644 index 0000000..00c2d40 Binary files /dev/null and b/docs/img/ofdm_anatomy.gif differ diff --git a/docs/img/spectrum_compare.gif b/docs/img/spectrum_compare.gif new file mode 100644 index 0000000..50dcba1 Binary files /dev/null and b/docs/img/spectrum_compare.gif differ diff --git a/docs/rf-primer.md b/docs/rf-primer.md new file mode 100644 index 0000000..68d93ea --- /dev/null +++ b/docs/rf-primer.md @@ -0,0 +1,85 @@ +# A visual primer on the RF machinery + +devourer talks to a Wi-Fi radio at a very low level — subcarriers, constellations, +gain control, the transmit and receive chains. If you're new to that machinery, +the terms in the other docs (per-tone SNR, EVM, CCA, AGC, occupied bandwidth) can +feel like jargon. This page is a picture book: four short animations, each +built in the DEVOURER live-monitor style, that show what the machinery actually +looks like. Read it top to bottom and the rest of the docs will click. + +Everything here is grounded in what devourer measures — the constellation noise +follows the textbook AWGN model, the spectrum levels are from a real USRP B210 +capture, and the AGC behaviour is the very effect the energy sensor keeps seeing. + +## 1. The channel — what a "subcarrier" is + +![OFDM channel anatomy](img/ofdm_anatomy.gif) + +A Wi-Fi channel isn't one frequency — it's a comb of many narrow **subcarriers**. +A 20 MHz channel is 64 of them, spaced 312.5 kHz apart: a **DC null** left empty +in the middle, a handful of **pilot** tones the receiver uses to track drift, +dozens of **data** tones that carry the bits, and empty **guard** bins at the +edges so the signal doesn't spill into the neighbours. Wider channels (40/80 MHz) +just add more of the same 312.5 kHz tones; the narrowband 5/10 MHz modes re-clock +to *closer* spacing to fit a thin channel (more robust, less throughput). + +This comb is the coordinate system everything per-tone lives in — the +[per-subcarrier SNR waterfall](beamforming-self-sounding.md), the +[NHM power buckets](rx-spectrum-sensing.md), and the tone mask all index into it. + +## 2. Modulation — how bits ride the signal, and why SNR matters + +![IQ constellation vs SNR](img/constellation.gif) + +Each subcarrier carries bits by taking one of a set of points on the I/Q plane — +the **constellation**. QPSK has 4 points (2 bits each); 256-QAM packs 256 points +(8 bits each). More points = more bits per symbol = more throughput. The catch: +noise nudges each received point away from its ideal spot (that displacement is +the **EVM**), and if it drifts across the boundary into a neighbouring point's +cell, that's a **bit error**. The animation holds one channel and climbs the +modulation: QPSK's points are far apart so there's margin to spare, but 256-QAM +packs them so tight the *same* noise smears the clusters together and the link +breaks. That boundary — the highest modulation a given SNR can hold — is exactly +what the [MCS-headroom probe](adaptive-link-building-blocks.md) measures. + +## 3. On the air — a bare tone vs a modulated carrier + +![CW tone vs modulated spectrum](img/spectrum_compare.gif) + +Look at the same signals on a **spectrum analyzer** (power vs frequency). A +**CW tone** puts all its energy at one frequency — a single tall spike, nearly +zero bandwidth; it's a clean narrowband probe or interferer +(`DEVOURER_CW_TONE`). A **modulated carrier** spreads its energy across every +subcarrier — a flat block filling the whole 20 MHz (`DEVOURER_CONT_TX`); it's what +real traffic looks like, and the realistic stimulus for link probing. Same +transmitter, two completely different spectral footprints. (Levels here are a real +B210 capture on ch100: a −25 dB floor, the tone ~+18 dB above it, the block ~+28.) + +## 4. At the receiver — gain control, and why a strong signal goes deaf + +![AGC gain and saturation](img/agc_saturation.gif) + +The receiver can't handle every signal level directly, so an **AGC** (automatic +gain control) turns its gain up for weak signals and down for strong ones, aiming +to keep the ADC in its sweet spot. But the gain has a floor. When a signal is +*too* strong — a transmitter co-located inches away — the AGC runs out of +attenuation, the ADC input exceeds full scale, and the waveform **clips** flat +against the rails. A clipped waveform can't be demodulated: the receiver goes +deaf. That's why, in the [sensing docs](rx-spectrum-sensing.md), a *moderate* +interferer makes the CCA counter **spike** while a *strong* co-located one makes +frames and CCA **collapse** toward zero — the AGC saturating is the collapse. + +--- + +## Where to go next + +With the machinery in hand, the rest reads straight: + +- [`rx-spectrum-sensing.md`](rx-spectrum-sensing.md) — reading energy, noise, and + interference off that channel comb, frame-free (includes the animated NHM + monitor). +- [`beamforming-self-sounding.md`](beamforming-self-sounding.md) — measuring the + per-subcarrier channel with two adapters (the animated SNR waterfall). +- [`adaptive-link-building-blocks.md`](adaptive-link-building-blocks.md) — the + levers, sensors, and probes that turn all of the above into an adaptive link, + and [`adaptive-link.md`](adaptive-link.md) — the objective they serve. diff --git a/tests/sdr_spectrum.py b/tests/sdr_spectrum.py index e58d3e6..82f22f7 100755 --- a/tests/sdr_spectrum.py +++ b/tests/sdr_spectrum.py @@ -83,6 +83,8 @@ def main() -> int: ap.add_argument("--args", default="type=b200") ap.add_argument("--duration", type=float, default=1.5) ap.add_argument("--label", default="") + ap.add_argument("--dump", default=None, + help="write freq_MHz,psd_dB CSV (for the docs spectrum diagram)") args = ap.parse_args() nsamp = max(1 << 16, int(args.rate * args.duration)) @@ -111,6 +113,12 @@ def main() -> int: print(f" {line}") print(f" peakiness={peakiness:.1f} dB occ_bw={occ_mhz:.1f} MHz " f"(tone: high peakiness/low occ; modulated: low peakiness/wide occ)") + if args.dump: + with open(args.dump, "w") as fh: + fh.write("freq_mhz,psd_db\n") + for f, p in zip(freqs, psd): + fh.write(f"{(args.freq + f) / 1e6:.4f},{p:.3f}\n") + print(f" wrote {args.dump}") return 0 diff --git a/tools/agc_gif.py b/tools/agc_gif.py new file mode 100644 index 0000000..18a9d93 --- /dev/null +++ b/tools/agc_gif.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Animated AGC monitor — 'why a strong signal makes the receiver go deaf', in +the DEVOURER live-monitor style. + + tools/agc_gif.py -o docs/img/agc_saturation.gif + +The receiver's automatic gain control (AGC) turns its gain down as the incoming +signal gets stronger, to keep the ADC in its sweet spot. But the gain has a +floor: when a signal is *too* strong — a co-located carrier inches away — the AGC +runs out of attenuation, the ADC input exceeds full scale, and the waveform clips +flat against the rails. A clipped waveform can't be demodulated: the RX goes +deaf. That is the 'collapse' the frame-free energy sensor sees when a strong +interferer arrives (frames and CCA fall toward zero) — the flip side of the +'spike' a moderate one causes. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import math +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from monitor_style import (AMBER, CYAN, DIM, GRID, INK, OK, WARN, chrome, font, + new_frame, save_gif) + +TARGET = 0.42 # ADC level the AGC aims for (fraction of full scale) +GMIN, GMAX = 0.16, 6.0 +NS = 220 # waveform samples across the scope + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="agc_saturation.gif") + ap.add_argument("--frames", type=int, default=64) + ap.add_argument("--ms", type=int, default=85) + args = ap.parse_args() + + padL, padT, padB = 34, 92, 46 + scope_w, scope_h = 500, 300 + panelW = 300 + W = padL + scope_w + 24 + panelW + H = padT + scope_h + padB + cy = padT + scope_h // 2 + rail = scope_h // 2 - 10 # full-scale rail offset from centre + + imgs = [] + for fi in range(args.frames): + # interferer approaches and recedes: input level (full-scale units at + # unity gain) sweeps low -> very high -> low. + u = fi / args.frames + env = 0.5 - 0.5 * math.cos(2 * math.pi * u) # 0..1..0 + inp = 0.05 * math.exp(env * 5.3) # 0.05 .. ~10 (peak clips) + gain = min(GMAX, max(GMIN, TARGET / inp)) # AGC decision + adc = inp * gain # ADC input level + clipping = adc > 1.0 + floored = gain <= GMIN + 1e-3 + + img, d = new_frame(W, H) + chrome(d, W, H, "AGC / ADC MONITOR", + "the receiver turns gain down as signal rises — until the gain " + "floor, where the ADC clips", fi) + + # scope box + rails + d.rectangle([padL, padT, padL + scope_w, padT + scope_h], + outline=(0, 70, 80)) + for sgn in (1, -1): + yr = cy - sgn * rail + d.line([padL, yr, padL + scope_w, yr], fill=(90, 40, 40)) + d.text((padL + 6, cy - rail - 14), "+full scale", font=font(10), + fill=(150, 80, 80)) + d.text((padL + 6, cy + rail + 2), "−full scale", font=font(10), + fill=(150, 80, 80)) + # saturation zones (above/below rails) tinted when clipping + if clipping: + d.rectangle([padL + 1, padT + 1, padL + scope_w - 1, cy - rail], + fill=(40, 12, 12)) + d.rectangle([padL + 1, cy + rail, padL + scope_w - 1, + padT + scope_h - 1], fill=(40, 12, 12)) + # the ADC waveform (sine at `adc` amplitude, clipped at the rails) + pts = [] + for i in range(NS): + ph = i / NS * 6 * math.pi + fi * 0.35 + s = math.sin(ph) * adc + s = max(-1.0, min(1.0, s)) # clip + x = padL + int(i / (NS - 1) * scope_w) + pts.append((x, cy - int(s * rail))) + wcol = WARN if clipping else OK + d.line(pts, fill=wcol, width=2) + + # readout panel + x0 = padL + scope_w + 22 + d.text((x0, padT - 2), "LIVE READOUT", font=font(12), fill=CYAN) + y = padT + 22 + + def bar(lbl, frac, col, txt): + nonlocal y + d.text((x0, y), lbl, font=font(11), fill=DIM) + d.text((x0 + 150, y - 1), txt, font=font(13, True), fill=col) + y += 16 + bw = 260 + d.rectangle([x0, y, x0 + bw, y + 12], outline=(40, 52, 68)) + d.rectangle([x0, y, x0 + int(bw * max(0, min(1, frac))), y + 12], + fill=col) + y += 26 + + in_db = 20 * math.log10(inp / 0.05) # relative dB + g_db = 20 * math.log10(gain) + bar("input signal", inp / 10.5, AMBER if inp > 1 else CYAN, + f"{in_db:+4.0f} dB") + bar("AGC gain", (gain - GMIN) / (GMAX - GMIN), + WARN if floored else OK, f"{g_db:+4.0f} dB") + bar("ADC level", min(1.0, adc), WARN if clipping else OK, + "CLIP" if clipping else f"{int(adc*100):d} %") + + # status pill + if clipping: + st, sc, sub = "SATURATED — RX DEAF", WARN, "energy sensor sees a COLLAPSE" + elif floored: + st, sc, sub = "GAIN FLOORED", AMBER, "no headroom left" + else: + st, sc, sub = "LOCKED", OK, "ADC in its sweet spot" + y += 6 + d.rectangle([x0, y, x0 + 286, y + 32], outline=sc, width=2) + d.ellipse([x0 + 9, y + 11, x0 + 20, y + 22], fill=sc) + d.text((x0 + 30, y + 8), st, font=font(14, True), fill=sc) + y += 40 + d.text((x0, y), sub, font=font(11), fill=INK) + + d.text((padL, padT + scope_h + 20), + "a moderate interferer → CCA spike; a strong one → AGC floors, " + "RX collapses", font=font(11), fill=DIM) + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/constellation_gif.py b/tools/constellation_gif.py new file mode 100644 index 0000000..1bf5487 --- /dev/null +++ b/tools/constellation_gif.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Animated IQ-constellation monitor — 'what MCS actually is, and why SNR +matters', in the DEVOURER live-monitor style. + + tools/constellation_gif.py -o docs/img/constellation.gif + +A fixed link SNR (slowly wobbling, like a real fading channel) while the +modulation steps up QPSK -> 16-QAM -> 64-QAM -> 256-QAM. Each received symbol is +an ideal constellation point plus Gaussian noise sized by the SNR (textbook AWGN, +EVM = 1/sqrt(SNR)); a dot is green if it still lands in the right decision cell, +red if the noise pushed it across a boundary into a bit error. The lesson: on the +*same* channel, low-order modulation has margin to spare while high-order packs +the points so tight the same noise breaks it — exactly the boundary the +MCS-headroom probe measures. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import math +import os +import random +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from monitor_style import (AMBER, CYAN, DIM, GRID, INK, OK, WARN, chrome, font, + new_frame, save_gif) + +# (name, M, required-SNR dB for ~1e-3 uncoded BER) +MODS = [("QPSK", 4, 9.0), ("16-QAM", 16, 16.0), + ("64-QAM", 64, 22.0), ("256-QAM", 256, 28.0)] + + +def constellation(M): + """Unit-average-power square M-QAM points.""" + s = int(round(math.sqrt(M))) + pts = [(2 * i - (s - 1), 2 * q - (s - 1)) for i in range(s) for q in range(s)] + norm = math.sqrt(sum(x * x + y * y for x, y in pts) / len(pts)) + return [(x / norm, y / norm) for x, y in pts], s, norm + + +def nearest_idx(pt, s, norm): + """Index of the nearest constellation point (grid quantise).""" + def q(v): + i = round((v * norm + (s - 1)) / 2) + return max(0, min(s - 1, i)) + return q(pt[0]) * s + q(pt[1]) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="constellation.gif") + ap.add_argument("--per-mod", type=int, default=16) + ap.add_argument("--pts", type=int, default=520) + ap.add_argument("--ms", type=int, default=95) + ap.add_argument("--snr", type=float, default=24.0, help="mean link SNR dB") + args = ap.parse_args() + rnd = random.Random(0xC0) + + padL, padT, padB = 34, 92, 40 + grid = 388 + panelW = 250 + W = padL + grid + 30 + panelW + H = padT + grid + padB + cx, cy = padL + grid // 2, padT + grid // 2 + R = grid // 2 - 8 # IQ plane radius (|point| ~ 1 -> 0.7*R-ish) + scale = R / 1.45 + + imgs = [] + nseg = len(MODS) + total = nseg * args.per_mod + for fi in range(total): + seg = fi // args.per_mod + name, M, req = MODS[seg] + pts, s, norm = constellation(M) + # link SNR wobbles like a fading channel + snr_db = args.snr + 3.0 * math.sin(fi / 5.0) + snr_lin = 10 ** (snr_db / 10) + sigma = math.sqrt(1.0 / (2 * snr_lin)) # per-component AWGN + evm = 100.0 / math.sqrt(snr_lin) + margin = snr_db - req + + img, d = new_frame(W, H) + chrome(d, W, H, "CONSTELLATION MONITOR", + f"AWGN model · EVM = 1/√SNR · a dot crossing a cell boundary " + f"= one bit error", fi) + + # IQ plane frame + axes + d.rectangle([padL, padT, padL + grid, padT + grid], outline=(0, 70, 80)) + d.line([cx, padT, cx, padT + grid], fill=GRID) + d.line([padL, cy, padL + grid, cy], fill=GRID) + d.text((padL + grid - 12, cy + 4), "I", font=font(11), fill=DIM) + d.text((cx + 5, padT + 2), "Q", font=font(11), fill=DIM) + # decision gridlines (cell boundaries) + for k in range(1, s): + off = (2 * k - s) / norm * scale + d.line([cx + off, padT + 6, cx + off, padT + grid - 6], fill=(18, 26, 38)) + d.line([padL + 6, cy + off, padL + grid - 6, cy + off], fill=(18, 26, 38)) + # ideal points (dim crosses) + for (px, py) in pts: + ix, iy = cx + px * scale, cy - py * scale + d.line([ix - 3, iy, ix + 3, iy], fill=(70, 90, 110)) + d.line([ix, iy - 3, ix, iy + 3], fill=(70, 90, 110)) + # received symbols + errs = 0 + for _ in range(args.pts): + ideal = rnd.randrange(M) + ix0, iy0 = pts[ideal] + rx = (ix0 + rnd.gauss(0, sigma), iy0 + rnd.gauss(0, sigma)) + bad = nearest_idx(rx, s, norm) != ideal + errs += bad + x, y = cx + rx[0] * scale, cy - rx[1] * scale + col = WARN if bad else OK + d.ellipse([x - 1.5, y - 1.5, x + 1.5, y + 1.5], fill=col) + ber = errs / args.pts + + # readout + x0 = padL + grid + 26 + d.text((x0, padT - 2), "LIVE READOUT", font=font(12), fill=CYAN) + y = padT + 24 + + def line(lbl, val, col=INK): + nonlocal y + d.text((x0, y), lbl, font=font(11), fill=DIM) + d.text((x0 + 108, y - 3), val, font=font(16, True), fill=col) + y += 30 + + line("modulation", name, CYAN) + line("link SNR", f"{snr_db:4.1f} dB") + line("needs", f"{req:4.1f} dB", DIM) + line("margin", f"{margin:+4.1f} dB", OK if margin > 3 else + (AMBER if margin > 0 else WARN)) + line("EVM", f"{evm:4.1f} %") + line("sym errors", f"{errs}/{args.pts}", OK if errs == 0 else WARN) + + # status pill + if margin > 3 and ber < 0.002: + st, sc = "LOCKED", OK + elif margin > 0: + st, sc = "MARGINAL", AMBER + else: + st, sc = "LOST", WARN + y += 6 + d.rectangle([x0, y, x0 + 210, y + 30], outline=sc, width=2) + d.ellipse([x0 + 8, y + 10, x0 + 18, y + 20], fill=sc) + d.text((x0 + 28, y + 6), st, font=font(16, True), fill=sc) + y += 48 + d.text((x0, y), f"{M} points — {int(math.log2(M))} bits/symbol", + font=font(11), fill=DIM) + + d.text((padL, H - 22), + "same channel · climb the modulation until the points pack too " + "tight for the noise", font=font(11), fill=DIM) + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/monitor_style.py b/tools/monitor_style.py new file mode 100644 index 0000000..4e7c54c --- /dev/null +++ b/tools/monitor_style.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Shared 'DEVOURER live monitor' chrome for the animated documentation diagrams +(constellation / OFDM anatomy / spectrum / AGC), matching the look of the +per-subcarrier waterfall and NHM histogram GIFs. Pillow only.""" +from __future__ import annotations + +import os + +from PIL import Image, ImageDraw, ImageFont + +FONT = "/usr/share/fonts/TTF/DejaVuSansMono.ttf" +FONTB = "/usr/share/fonts/TTF/DejaVuSansMono-Bold.ttf" + +# House palette (shared with bf_waterfall_gif.py / nhm_histogram_gif.py). +BG = (8, 11, 18) +CYAN = (0, 220, 235) +INK = (225, 232, 240) +DIM = (120, 140, 165) +WARN = (240, 90, 70) +OK = (70, 220, 140) +AMBER = (235, 200, 70) +GRID = (22, 30, 42) + + +def font(sz, bold=False): + try: + return ImageFont.truetype(FONTB if bold else FONT, sz) + except OSError: + return ImageFont.load_default() + + +def new_frame(W, H): + img = Image.new("RGB", (W, H), BG) + return img, ImageDraw.Draw(img) + + +def chrome(d, W, H, title, subtitle, tick): + """Glowing panel border + DEVOURER header + blinking LIVE + subtitle rule.""" + for i, a in enumerate((40, 90, 160)): + d.rectangle([6 - i, 6 - i, W - 7 + i, H - 7 + i], + outline=(0, a, a), width=1) + d.text((34, 20), "DEVOURER", font=font(19, True), fill=CYAN) + d.text((34 + 116, 23), title, font=font(12), fill=INK) + if tick % 6 < 4: + d.ellipse([W - 96, 22, W - 86, 32], fill=WARN) + d.text((W - 80, 21), "LIVE", font=font(12), fill=(240, 90, 90)) + d.line([34, 46, W - 20, 46], fill=(0, 70, 80), width=1) + d.text((34, 54), subtitle, font=font(11), fill=DIM) + + +def save_gif(imgs, out, ms=90, colors=128): + """Save with one global palette (no dither) so unchanged pixels stay + bit-identical across frames — no 'trembling' text.""" + W, H = imgs[0].size + sample = imgs[:: max(1, len(imgs) // 8)] + montage = Image.new("RGB", (W, H * len(sample))) + for i, im in enumerate(sample): + montage.paste(im, (0, i * H)) + pal = montage.quantize(colors=colors, method=Image.MEDIANCUT) + quant = [im.quantize(palette=pal, dither=Image.Dither.NONE) for im in imgs] + quant[0].save(out, save_all=True, append_images=quant[1:], + duration=ms, loop=0, optimize=False, disposal=1) + kb = os.path.getsize(out) / 1024 + print(f"wrote {out} {W}x{H} {len(imgs)} frames {kb:.0f} KB") diff --git a/tools/ofdm_anatomy_gif.py b/tools/ofdm_anatomy_gif.py new file mode 100644 index 0000000..7fac23e --- /dev/null +++ b/tools/ofdm_anatomy_gif.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Animated OFDM channel anatomy — 'what a subcarrier / a channel actually is', +in the DEVOURER live-monitor style. + + tools/ofdm_anatomy_gif.py -o docs/img/ofdm_anatomy.gif + +A 20 MHz channel is 64 subcarriers 312.5 kHz apart: a DC null in the middle, +pilot tones the receiver tracks, data tones that carry the bits, and guard bins +at the edges. A cursor sweeps across naming each region; a side ticker cycles the +bandwidth options — 20/40/80 MHz add more 312.5 kHz tones, while the 5/10 MHz +narrowband re-clocks to *closer* spacing to fit a thin channel. This is the +coordinate system the per-subcarrier waterfall, the NHM buckets, and the tone +mask all live in. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from monitor_style import (AMBER, CYAN, DIM, GRID, INK, OK, WARN, chrome, font, + new_frame, save_gif) + +# 802.11ac 20 MHz: 64 FFT bins, indices -32..+31. Occupied -28..+28 (56 tones: +# 52 data + 4 pilot), pilots at ±7 / ±21, DC null at 0, guard beyond ±28. +PILOTS = {7, 21} + + +def region(k): + if k == 0: + return "dc" + if abs(k) > 28: + return "guard" + if abs(k) in PILOTS: + return "pilot" + return "data" + + +# bandwidth ticker: (label, FFT, data tones, spacing kHz, note) +BWS = [ + ("20 MHz", 64, 52, 312.5, "the baseline"), + ("40 MHz", 128, 108, 312.5, "2× the tones"), + ("80 MHz", 256, 234, 312.5, "4× the tones"), + ("10 MHz", 64, 52, 156.25, "re-clocked narrower"), + ("5 MHz", 64, 52, 78.125, "narrowest — most robust"), +] + +COL = {"data": CYAN, "pilot": AMBER, "guard": (70, 84, 100), "dc": WARN} + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="ofdm_anatomy.gif") + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + + padL, padT, padB = 34, 96, 128 + grid_w, grid_h = 560, 260 + panelW = 210 + W = padL + grid_w + 28 + panelW + H = padT + grid_h + padB + N = 64 + binw = grid_w / N + base_y = padT + grid_h + + imgs = [] + frames = N + 6 # sweep across all bins, then a short hold + for fi in range(frames): + cur_k = min(fi, N - 1) - 32 # cursor index -32..+31 + img, d = new_frame(W, H) + chrome(d, W, H, "OFDM CHANNEL ANATOMY", + "a 20 MHz channel = 64 subcarriers · 312.5 kHz apart · " + "the grid the per-tone sensors live in", fi) + + # subcarrier comb + heights = {"data": 0.82, "pilot": 1.0, "guard": 0.16, "dc": 0.0} + for i in range(N): + k = i - 32 + reg = region(k) + x = padL + i * binw + h = int(heights[reg] * grid_h) + col = COL[reg] + if reg == "dc": + d.line([x + binw / 2, base_y - 8, x + binw / 2, base_y], fill=col) + else: + d.rectangle([x + 1, base_y - h, x + binw - 1, base_y], fill=col) + if k == cur_k: # cursor highlight + d.rectangle([x, padT, x + binw, base_y], outline=CYAN, width=1) + d.rectangle([padL, padT, padL + grid_w, base_y], outline=(0, 70, 80)) + # centre / DC marker + d.text((padL + grid_w / 2 - 14, base_y + 6), "DC", font=font(11), fill=WARN) + # frequency axis + for k in (-28, -21, -14, -7, 7, 14, 21, 28): + x = padL + (k + 32) * binw + d.text((x - 12, base_y + 22), f"{k*312.5/1000:+.1f}", + font=font(11), fill=DIM) + d.text((padL, base_y + 44), "frequency offset from centre (MHz) · " + "each bar = one 312.5 kHz subcarrier", font=font(11), fill=DIM) + + # cursor callout + reg = region(cur_k) + names = {"data": "DATA subcarrier — carries the coded bits", + "pilot": "PILOT — a known tone; tracks phase/frequency drift", + "guard": "GUARD — empty edge bin (spectral mask / roll-off)", + "dc": "DC NULL — the centre bin is left empty (LO leakage)"} + cc = COL[reg] + d.text((padL, padT - 26), f"tone {cur_k:+d} · " + f"{cur_k*312.5/1000:+.3f} MHz", font=font(12, True), fill=cc) + d.text((padL + 220, padT - 25), names[reg], font=font(11), fill=INK) + + # legend + lx = padL + for reg2, lbl in (("data", "data ×52"), ("pilot", "pilot ×4"), + ("dc", "DC null"), ("guard", "guard")): + d.rectangle([lx, H - 74, lx + 14, H - 63], fill=COL[reg2]) + d.text((lx + 20, H - 75), lbl, font=font(11), fill=INK) + lx += 150 + + # bandwidth ticker (cycles ~ every 12 frames) + x0 = padL + grid_w + 26 + d.text((x0, padT - 2), "BANDWIDTH", font=font(12), fill=CYAN) + bi = (fi // 8) % len(BWS) + y = padT + 22 + for j, (lbl, fft, dat, sp, note) in enumerate(BWS): + on = j == bi + d.text((x0, y), ("▸ " if on else " ") + lbl, + font=font(13, True), fill=CYAN if on else DIM) + if on: + d.text((x0 + 14, y + 20), + f"{fft}-pt FFT · {dat} data", font=font(11), fill=INK) + d.text((x0 + 14, y + 36), + f"{sp:.4g} kHz spacing", font=font(11), fill=INK) + d.text((x0 + 14, y + 52), note, font=font(11), fill=OK) + y += 52 + y += 24 + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/spectrum_compare_gif.py b/tools/spectrum_compare_gif.py new file mode 100644 index 0000000..83dc0d6 --- /dev/null +++ b/tools/spectrum_compare_gif.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Animated spectrum analyzer — a bare CW tone vs a modulated carrier, in the +DEVOURER live-monitor style. + + tools/spectrum_compare_gif.py -o docs/img/spectrum_compare.gif + +A quiet channel, then a CW tone rises (all the energy at ONE frequency — a single +spike, ~zero bandwidth), then a modulated carrier rises (the energy spread across +the whole 20 MHz — a flat OFDM block). This is the difference between +DEVOURER_CW_TONE (a narrowband probe / interferer) and DEVOURER_CONT_TX (a +full-channel stimulus). The trace levels are the real ones a USRP B210 measured +on ch100: a −25 dB noise floor, the tone spiking ~+18 dB above it, the modulated +block ~+28 dB and ~20 MHz wide. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import math +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from monitor_style import (AMBER, CYAN, DIM, GRID, INK, OK, WARN, chrome, font, + new_frame, save_gif) + +FLOOR = -25.0 # measured noise floor (dB) +TONE_PK = -7.0 # measured CW-tone peak +MOD_PK = 3.0 # measured modulated-block level +NBIN = 200 # frequency bins across the display +SPAN = 30.0 # MHz shown (channel-relative, -15..+15) + + +def floor_trace(seed): + import random + r = random.Random(seed) + return [FLOOR + r.uniform(-1.2, 1.2) for _ in range(NBIN)] + + +def freq_of(i): + return (i / (NBIN - 1) - 0.5) * SPAN + + +def tone_shape(i): + """narrow Gaussian spike at centre (~0.4 MHz wide).""" + f = freq_of(i) + return math.exp(-(f / 0.5) ** 2) + + +def mod_shape(i): + """flat ~20 MHz block with steep OFDM shoulders + slight ripple.""" + f = freq_of(i) + edge = 10.0 + roll = 1.0 / (1.0 + math.exp((abs(f) - edge) * 3.0)) # brick-ish + ripple = 1.0 + 0.06 * math.sin(f * 2.1) + return roll * ripple + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="spectrum_compare.gif") + ap.add_argument("--per", type=int, default=14, help="frames per phase") + ap.add_argument("--ms", type=int, default=95) + ap.add_argument("--channel", type=int, default=100) + args = ap.parse_args() + + base = floor_trace(7) + tshape = [tone_shape(i) for i in range(NBIN)] + mshape = [mod_shape(i) for i in range(NBIN)] + + def smooth(t): + return t * t * (3 - 2 * t) + + # scenario: (label, tone_level, mod_level) keyframes, eased between + KEYS = [("QUIET", 0, 0), ("CW TONE", 1, 0), ("QUIET", 0, 0), + ("MODULATED", 0, 1), ("QUIET", 0, 0)] + + seq = [] # (trace[], label, tone_lv, mod_lv) + for k in range(len(KEYS) - 1): + (l0, t0, m0), (l1, t1, m1) = KEYS[k], KEYS[k + 1] + for fi in range(args.per): + e = smooth(fi / args.per) + tl = t0 + (t1 - t0) * e + ml = m0 + (m1 - m0) * e + lbl = l1 if e > 0.5 else l0 + tr = [] + for i in range(NBIN): + lin = 10 ** (base[i] / 10) + lin += tl * 10 ** ((TONE_PK) / 10) * tshape[i] + lin += ml * 10 ** ((MOD_PK) / 10) * mshape[i] + tr.append(10 * math.log10(lin)) + seq.append((tr, lbl, tl, ml)) + + padL, padT, padB = 34, 92, 52 + grid_w, grid_h = 620, 300 + panelW = 200 + W = padL + grid_w + 26 + panelW + H = padT + grid_h + padB + base_y = padT + grid_h + lo_db, hi_db = -30.0, 8.0 + + def yof(db): + return base_y - int((db - lo_db) / (hi_db - lo_db) * grid_h) + + imgs = [] + for fi, (tr, lbl, tl, ml) in enumerate(seq): + img, d = new_frame(W, H) + chrome(d, W, H, "SPECTRUM ANALYZER", + f"ch {args.channel} · {5000 + 5*args.channel} MHz · 30 MHz span · " + "power vs frequency", fi) + + # grid + dB axis + for gd in range(-30, 9, 10): + y = yof(gd) + d.line([padL, y, padL + grid_w, y], fill=GRID) + d.text((padL - 2, y - 6), f"{gd:+d}", font=font(10), fill=DIM) + # 20 MHz channel edges + for fq in (-10, 10): + x = padL + int((fq / SPAN + 0.5) * grid_w) + d.line([x, padT, x, base_y], fill=(40, 60, 80)) + d.text((padL + grid_w // 2 - 60, padT + 4), "20 MHz channel", + font=font(10), fill=(70, 100, 130)) + + # filled spectrum trace + col = AMBER if tl > 0.3 else (CYAN if ml > 0.3 else (90, 130, 150)) + pts = [(padL + int(i / (NBIN - 1) * grid_w), yof(tr[i])) + for i in range(NBIN)] + d.polygon([(padL, base_y)] + pts + [(padL + grid_w, base_y)], + fill=(col[0] // 6, col[1] // 6, col[2] // 6)) + d.line(pts, fill=col, width=2) + + # frequency axis + for fq in range(-12, 13, 6): + x = padL + int((fq / SPAN + 0.5) * grid_w) + d.text((x - 8, base_y + 6), f"{fq:+d}", font=font(10), fill=DIM) + d.text((padL, base_y + 26), "frequency offset from channel centre (MHz)", + font=font(11), fill=DIM) + + # readout + x0 = padL + grid_w + 24 + peak = max(tr) + # crude occupied-BW: span of bins within 10 dB of peak, above floor+6 + occ = sum(1 for v in tr if v > peak - 10 and v > FLOOR + 6) / NBIN * SPAN + d.text((x0, padT - 2), "LIVE READOUT", font=font(12), fill=CYAN) + y = padT + 24 + + def line(lb, val, c=INK): + nonlocal y + d.text((x0, y), lb, font=font(11), fill=DIM) + d.text((x0 + 96, y - 3), val, font=font(15, True), fill=c) + y += 30 + + typ = "CW TONE" if tl > 0.5 else ("MODULATED" if ml > 0.5 else "— none —") + tc = AMBER if tl > 0.5 else (CYAN if ml > 0.5 else DIM) + line("signal", lbl, tc) + line("peak", f"{peak:+4.1f} dB") + line("occupied", f"{occ:4.1f} MHz", tc if occ > 0.5 or tl > 0.5 else DIM) + y += 8 + note = ("all energy at ONE\nfrequency — a spike,\n~zero bandwidth" + if tl > 0.5 else + "energy spread over\nthe whole channel —\na flat OFDM block" + if ml > 0.5 else + "just the noise floor") + for ln in note.split("\n"): + d.text((x0, y), ln, font=font(11), fill=INK); y += 16 + y += 12 + d.text((x0, y), "CW_TONE = probe", font=font(10), fill=AMBER); y += 15 + d.text((x0, y), "CONT_TX = stimulus", font=font(10), fill=CYAN) + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms) + return 0 + + +if __name__ == "__main__": + sys.exit(main())