diff --git a/README.md b/README.md index 14452ce..56a1118 100644 --- a/README.md +++ b/README.md @@ -305,9 +305,9 @@ sandbox; and [`docs/fused-fec.md`](docs/fused-fec.md) — the cross-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. +visual primer — eight short animations (the OFDM channel, the constellation, the +TX pipeline, a tone vs a modulated carrier, AGC saturation, channel sounding, +antenna diversity, and frequency hopping) that make the rest of the docs click. ### Startup time diff --git a/docs/img/bf_sequence.gif b/docs/img/bf_sequence.gif new file mode 100644 index 0000000..9d7173e Binary files /dev/null and b/docs/img/bf_sequence.gif differ diff --git a/docs/img/hop_pattern.gif b/docs/img/hop_pattern.gif new file mode 100644 index 0000000..24a14a4 Binary files /dev/null and b/docs/img/hop_pattern.gif differ diff --git a/docs/img/mrc_diversity.gif b/docs/img/mrc_diversity.gif new file mode 100644 index 0000000..2d5c811 Binary files /dev/null and b/docs/img/mrc_diversity.gif differ diff --git a/docs/img/ofdm_anatomy.gif b/docs/img/ofdm_anatomy.gif index 00c2d40..bb018a9 100644 Binary files a/docs/img/ofdm_anatomy.gif and b/docs/img/ofdm_anatomy.gif differ diff --git a/docs/img/tx_pipeline.gif b/docs/img/tx_pipeline.gif new file mode 100644 index 0000000..9706978 Binary files /dev/null and b/docs/img/tx_pipeline.gif differ diff --git a/docs/rf-primer.md b/docs/rf-primer.md index 68d93ea..bc733f8 100644 --- a/docs/rf-primer.md +++ b/docs/rf-primer.md @@ -3,9 +3,10 @@ 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 +feel like jargon. This page is a picture book: eight 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. +looks like — from a single subcarrier all the way to a hopping, diversity-combined +link. 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 @@ -42,7 +43,21 @@ 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 +## 3. Building the waveform — the transmit pipeline + +![TX pipeline](img/tx_pipeline.gif) + +So how does a block of bits actually *become* those constellation points on those +subcarriers? A short assembly line: the bits are **scrambled** (whitened so there +are no long runs), **forward-error-coded** (redundancy added so the receiver can +repair errors), **interleaved** (spread out so a fade damages many codewords a +little rather than one a lot), **mapped** onto the subcarrier constellations, run +through an **inverse FFT** that turns all those subcarriers into one time-domain +OFDM symbol, given a **cyclic-prefix** guard (the tail copied to the front, so +echoes off walls don't smear the next symbol), and finally up-converted and +radiated. That last waveform is exactly what the spectrum analyzer below shows. + +## 4. On the air — a bare tone vs a modulated carrier ![CW tone vs modulated spectrum](img/spectrum_compare.gif) @@ -55,7 +70,7 @@ 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 +## 5. At the receiver — gain control, and why a strong signal goes deaf ![AGC gain and saturation](img/agc_saturation.gif) @@ -69,6 +84,42 @@ 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. +## 6. Measuring the channel — beamforming self-sounding + +![Beamforming self-sounding sequence](img/bf_sequence.gif) + +To know *how good* each subcarrier is, you have to measure the channel between two +radios. The sequence: the **sounder** announces (NDPA), sends a **known waveform** +on every subcarrier (NDP), the **beamformee** compares what arrived to what it +knows was sent — that's the per-subcarrier channel `H(k)` — and sends back a +compressed **CSI report**. With two adapters you own, you play both roles yourself +(*self-sounding*). That report is the source of the per-subcarrier SNR waterfall, +the per-tone interference localizer, and the motion sensor. + +## 7. Combining two antennas — diversity under motion + +![MRC antenna diversity](img/mrc_diversity.gif) + +Multipath makes a signal **fade** — deep dips that come and go. Two antennas help, +but only when they see *different* fades. Held **still**, closely-spaced antennas +see almost the same channel: they dip together, so combining them (maximal-ratio +combining) barely fills the holes and the second chain is mostly wasted power. +Under **motion** the antennas decorrelate — when one is in a fade the other +usually isn't — so the combined signal fills the deep fades and outages drop +sharply. That's why the number of active receive chains is a *fade-state* lever, +not a range lever, and why a motion signal tells the controller when to open them. + +## 8. Spreading across the band — frequency hopping + +![Frequency-hopping pattern](img/hop_pattern.gif) + +Instead of parking on one channel, the link can **hop** channel to channel every +dwell, spreading its energy across the band. A narrowband interferer sitting on +one channel then only clips the occasional hop that lands on it — every other hop +escapes. Done per-packet (`DEVOURER_HOP_*`), hopping doubles as a +frequency-diversity interleaver for the outer FEC: losses are spread thin across +frequencies instead of wiping out a run of packets on one. + --- ## Where to go next diff --git a/tools/bf_sequence_gif.py b/tools/bf_sequence_gif.py new file mode 100644 index 0000000..1a60cb1 --- /dev/null +++ b/tools/bf_sequence_gif.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Animated beamforming self-sounding sequence — how two adapters measure the +channel between them, in the DEVOURER live-monitor style. + + tools/bf_sequence_gif.py -o docs/img/bf_sequence.gif + +The sounder announces (NDPA), sends a known waveform on every subcarrier (NDP), +the beamformee measures the per-subcarrier channel it arrived through, and sends +back a compressed CSI report. That report is where the per-subcarrier SNR +waterfall, the per-tone interference localizer, and the motion sensor all come +from — and with two adapters you own, you play both roles yourself +(self-sounding). 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) + +# (key, caption, direction) direction: +1 down (sounder->bfee), -1 up, 0 measure +STEPS = [ + ("NDPA", "NDPA — 'I'm about to sound you' (announcement)", +1, AMBER), + ("NDP", "NDP — a known waveform on every subcarrier", +1, CYAN), + ("MEASURE", "beamformee measures the per-subcarrier channel H(k)", 0, OK), + ("CSI REPORT", "compressed CSI report — per-tone angles + SNR", -1, (180, 130, 235)), +] + + +def device(d, x, y, w, h, label, col, active): + oc = col if active else (60, 74, 92) + d.rectangle([x, y, x + w, y + h], outline=oc, width=2) + d.text((x + 12, y + 10), label, font=font(14, True), fill=col if active else DIM) + # antennas + for ax in (x + w - 40, x + w - 22): + d.line([ax, y, ax, y - 14], fill=oc, width=2) + d.ellipse([ax - 3, y - 20, ax + 3, y - 14], outline=oc) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="bf_sequence.gif") + ap.add_argument("--per", type=int, default=13) + ap.add_argument("--ms", type=int, default=95) + args = ap.parse_args() + + padL, padT = 34, 96 + panelW = 236 + devW, devH = 360, 66 + airTop, airBot = padT + 40, padT + 40 + 210 + W = padL + devW + 40 + panelW + H = airBot + devH + 96 + devX = padL + 20 + + imgs = [] + for si, (key, cap, direction, col) in enumerate(STEPS): + for fi in range(args.per): + t = fi / args.per + gi = si * args.per + fi + img, d = new_frame(W, H) + chrome(d, W, H, "BEAMFORMING SELF-SOUNDING", + "two adapters measure the channel between them · you play " + "both roles", gi) + + snd_active = direction >= 0 or key == "NDPA" + bfe_active = direction <= 0 or key == "MEASURE" + device(d, devX, padT + 12, devW, devH, "SOUNDER (beamformer)", + CYAN, si <= 1) + device(d, devX, airBot, devW, devH, "BEAMFORMEE (client)", + OK, si >= 2) + # the air gap + gy0, gy1 = padT + 12 + devH, airBot + cxa = devX + devW // 2 + d.line([cxa, gy0, cxa, gy1], fill=(26, 36, 50)) + # label on the far left of the gap — clear of the flying packet and + # the readout panel on the right + d.text((devX + 4, (gy0 + gy1) // 2 - 6), "air gap", font=font(10), + fill=(60, 78, 96)) + + if direction != 0: + # packet flying along the air gap + if direction > 0: + py = gy0 + t * (gy1 - gy0 - 26) + else: + py = gy1 - 26 - t * (gy1 - gy0 - 26) + d.rectangle([cxa - 54, py, cxa + 54, py + 24], fill=col, + outline=INK) + d.text((cxa - 46, py + 4), key, font=font(12, True), fill=(10, 12, 18)) + # arrowhead + ay = py + (24 if direction > 0 else 0) + d.polygon([(cxa - 6, ay + direction * 8), (cxa + 6, ay + direction * 8), + (cxa, ay + direction * 16)], fill=col) + else: + # MEASURE: per-subcarrier bars pulsing at the beamformee + for k in range(24): + bx = devX + 60 + k * 11 + hh = int((6 + 20 * abs(math.sin(k * 0.5 + gi * 0.4))) * + (0.4 + 0.6 * t)) + d.rectangle([bx, airBot - 8 - hh, bx + 8, airBot - 8], + fill=OK) + d.text((devX + 60, airBot - 8 - 44), "H(k) per subcarrier", + font=font(10), fill=OK) + + # caption + d.text((padL, H - 40), cap, font=font(13, True), fill=col) + + # step tracker + x0 = padL + devW + 36 + d.text((x0, padT - 2), "SEQUENCE", font=font(12), fill=CYAN) + y = padT + 22 + for j, (k2, _, _, c2) in enumerate(STEPS): + on = j == si + d.ellipse([x0, y + 2, x0 + 12, y + 14], + fill=c2 if on else (40, 52, 68)) + d.text((x0 + 22, y), f"{j+1}. {k2}", + font=font(13, True) if on else font(12), + fill=INK if on else DIM) + y += 30 + y += 10 + d.text((x0, y), "the report feeds:", font=font(11), fill=DIM); y += 18 + for f in ("· SNR waterfall", "· interference localizer", + "· motion sensor"): + d.text((x0, y), f, font=font(11), fill=INK); y += 16 + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=64) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/hop_pattern_gif.py b/tools/hop_pattern_gif.py new file mode 100644 index 0000000..18928a2 --- /dev/null +++ b/tools/hop_pattern_gif.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Animated frequency-hopping pattern — 'spreading a link across the band for +diversity', in the DEVOURER live-monitor style. + + tools/hop_pattern_gif.py -o docs/img/hop_pattern.gif + +A time–frequency view: time scrolls left, frequency (channels) up the side. The +transmitter hops channel to channel each dwell, so its energy is spread across +the band instead of parked on one frequency. A narrowband interferer sitting on +one channel (the red row) only clips the occasional hop that lands on it — every +other hop escapes it. Per-packet hopping (DEVOURER_HOP_*) doubles as a +frequency-diversity interleaver for the outer FEC. Needs Pillow. +""" +from __future__ import annotations + +import argparse +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) + +CHANS = [36, 40, 44, 48, 52, 56, 60, 64] # a 5 GHz hop set +JAM = 52 # interferer parks here + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="hop_pattern.gif") + ap.add_argument("--frames", type=int, default=56) + ap.add_argument("--win", type=int, default=34, help="time cells shown") + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + rnd = random.Random(0x40) + + # a long pseudo-random hop schedule over the channel set + NT = args.frames + args.win + 4 + sched = [] + last = -1 + for _ in range(NT): + c = rnd.randrange(len(CHANS)) + while c == last: + c = rnd.randrange(len(CHANS)) + sched.append(c) + last = c + + nC = len(CHANS) + padL, padT, padB = 60, 96, 52 + gw, gh = 560, 300 + panelW = 214 + W = padL + gw + 24 + panelW + H = padT + gh + padB + cellw = gw / args.win + cellh = gh / nC + + def cell_xy(ti, ci): + x = padL + ti * cellw + y = padT + (nC - 1 - ci) * cellh + return x, y + + imgs = [] + collisions = 0 + for fi in range(args.frames): + img, d = new_frame(W, H) + chrome(d, W, H, "FREQUENCY HOPPING", + "time → · channel ↑ · the TX spreads across the band; the red " + "row is a parked interferer", fi) + + d.rectangle([padL, padT, padL + gw, padT + gh], outline=(0, 70, 80)) + # channel axis + interferer row + for ci, ch in enumerate(CHANS): + _, y = cell_xy(0, ci) + d.text((padL - 34, y + cellh / 2 - 7), f"{ch}", font=font(11), + fill=WARN if ch == JAM else DIM) + if ch == JAM: + d.rectangle([padL, y, padL + gw, y + cellh], fill=(40, 12, 12)) + d.line([padL, y, padL + gw, y], fill=GRID) + d.text((6, padT + gh // 2 - 20), "c\nh\na\nn".replace("\n", ""), + font=font(10), fill=DIM) + + # draw the hop trail across the visible window + cur_ci = None + for ti in range(args.win): + t = fi + ti + ci = sched[t] + x, y = cell_xy(ti, ci) + newest = ti == args.win - 1 + fade = 0.35 + 0.65 * (ti / args.win) + on_jam = CHANS[ci] == JAM + base = WARN if on_jam else CYAN + col = tuple(int(c * fade) for c in base) + d.rectangle([x + 2, y + 3, x + cellw - 2, y + cellh - 3], fill=col) + if newest: + cur_ci = ci + d.rectangle([x + 1, y + 2, x + cellw - 1, y + cellh - 2], + outline=INK, width=2) + if on_jam: + collisions += 1 + # link successive hops with a faint line + if ti > 0: + pt = sched[t - 1] + x0, y0 = cell_xy(ti - 1, pt) + d.line([x0 + cellw / 2, y0 + cellh / 2, x + cellw / 2, + y + cellh / 2], fill=(30, 44, 60)) + + d.text((padL, padT + gh + 8), "time → (each cell = one dwell)", + font=font(11), fill=DIM) + + # readout + x0 = padL + gw + 22 + d.text((x0, padT - 2), "LIVE READOUT", font=font(12), fill=CYAN) + y = padT + 24 + + def line(lbl, val, c=INK): + nonlocal y + d.text((x0, y), lbl, font=font(11), fill=DIM) + d.text((x0 + 96, y - 3), val, font=font(16, True), fill=c) + y += 30 + + cur = CHANS[cur_ci] + nxt = CHANS[sched[fi + args.win]] + on_jam = cur == JAM + line("channel", f"{cur}", WARN if on_jam else CYAN) + line("next", f"{nxt}", DIM) + line("interferer", f"ch {JAM}", WARN) + y += 6 + st = "HOP CLIPPED" if on_jam else "CLEAR HOP" + sc = WARN if on_jam else OK + d.rectangle([x0, y, x0 + 190, 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(14, True), fill=sc) + y += 46 + for ln in ("only the hop that lands", "on ch 52 is lost — the", + "rest escape. hopping =", "frequency diversity for", "the FEC."): + d.text((x0, y), ln, font=font(11), fill=INK); y += 15 + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=48) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/mrc_diversity_gif.py b/tools/mrc_diversity_gif.py new file mode 100644 index 0000000..f6ab7b9 --- /dev/null +++ b/tools/mrc_diversity_gif.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Animated antenna-diversity (MRC) monitor — 'why the number of receive chains +is a fade-state lever, not a range lever', in the DEVOURER live-monitor style. + + tools/mrc_diversity_gif.py -o docs/img/mrc_diversity.gif + +Two receive antennas each see a fading signal (deep dips as multipath comes and +goes). When the platform is STILL the two antennas see almost the *same* channel +— they fade together, so combining them (maximal-ratio combining) barely fills +the dips and the extra chain is mostly wasted baseline power. Under MOTION the +antennas decorrelate — when one is in a fade the other usually isn't — so the +combined signal fills the deep fades and delivery jumps. That is why the +energy-min controller opens chains up under motion and collapses to one when +still: it adapts to the *fade state*, which the motion sensor reports. 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) + +NT = 900 # precomputed timeline length +FLOOR_DB = -12.0 # delivery threshold (below = outage) + + +def smooth_noise(rnd, n, k=18): + """Filtered Gaussian -> a slow fading envelope factor in [~0,1].""" + x = [rnd.gauss(0, 1) for _ in range(n + k)] + out = [] + for i in range(n): + out.append(sum(x[i:i + k]) / k) + return out + + +def env_db(a): + """Rayleigh-ish envelope in dB from two quadrature fading components.""" + return 10 * math.log10(max(1e-3, a)) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="mrc_diversity.gif") + ap.add_argument("--frames", type=int, default=56) + ap.add_argument("--win", type=int, default=150, help="samples shown") + ap.add_argument("--ms", type=int, default=95) + args = ap.parse_args() + rnd = random.Random(0x11) + + # Build fading power (linear) for a shared component + two independent ones. + shared = [abs(v) for v in smooth_noise(rnd, NT, 14)] + indA = [abs(v) for v in smooth_noise(rnd, NT, 12)] + indB = [abs(v) for v in smooth_noise(rnd, NT, 12)] + + def branch(t, ind, rho): + # correlation rho: high -> tracks the shared fade; low -> independent + p = (rho * shared[t] + (1 - rho) * ind[t]) + return 3.0 * p * p # power (linear), scaled + + imgs = [] + padL, padT, padB = 34, 92, 46 + gw, gh = 560, 300 + panelW = 250 + W = padL + gw + 24 + panelW + H = padT + gh + padB + base_y = padT + gh + lo, hi = -28.0, 8.0 + + def yof(db): + # clamp inside the plot box so deep fades (< lo) don't draw below it + y = base_y - int((db - lo) / (hi - lo) * gh) + return max(padT, min(base_y, y)) + + # accumulate outage counts over the whole run (per phase) + for fi in range(args.frames): + # first ~40% STATIC (rho high), then MOVING (rho low), eased + u = fi / args.frames + moving = u > 0.45 + rho = 0.9 if not moving else 0.12 + t0 = int(u * (NT - args.win)) + + img, d = new_frame(W, H) + chrome(d, W, H, "ANTENNA DIVERSITY · MRC", + "two antennas fading over time · combined = maximal-ratio " + "combining · dips below the line = outages", fi) + + d.rectangle([padL, padT, padL + gw, base_y], outline=(0, 70, 80)) + for gd in range(-24, 9, 8): + y = yof(gd) + d.line([padL, y, padL + gw, y], fill=GRID) + d.text((padL - 2, y - 6), f"{gd:+d}", font=font(10), fill=DIM) + # delivery threshold + ty = yof(FLOOR_DB) + d.line([padL, ty, padL + gw, ty], fill=(150, 90, 60)) + d.text((padL + gw - 78, ty - 14), "delivery floor", font=font(10), + fill=(180, 120, 80)) + + ptsA, ptsB, ptsC = [], [], [] + out1 = out2 = 0 + for i in range(args.win): + t = t0 + i + pa, pb = branch(t, indA, rho), branch(t, indB, rho) + da, db = env_db(pa), env_db(pb) + dc = env_db(pa + pb) # MRC: branch powers add + x = padL + int(i / (args.win - 1) * gw) + ptsA.append((x, yof(da))) + ptsB.append((x, yof(db))) + ptsC.append((x, yof(dc))) + if da < FLOOR_DB: + out1 += 1 + if dc < FLOOR_DB: + out2 += 1 + d.line(ptsA, fill=(70, 95, 120), width=1) + d.line(ptsB, fill=(95, 80, 120), width=1) + d.line(ptsC, fill=CYAN, width=2) + # mark combined outages in red + for (x, y) in ptsC: + if y > ty: + d.ellipse([x - 2, ty - 2, x + 2, ty + 2], fill=WARN) + + d.text((padL, base_y + 8), "time → (signal level, dB)", + font=font(11), fill=DIM) + + # readout + x0 = padL + gw + 22 + d.text((x0, padT - 2), "LIVE READOUT", font=font(12), fill=CYAN) + y = padT + 22 + st = "MOVING" if moving else "STILL" + sc = OK if moving else AMBER + d.rectangle([x0, y, x0 + 200, y + 30], outline=sc, width=2) + d.ellipse([x0 + 8, y + 10, x0 + 18, y + 20], fill=sc) + d.text((x0 + 28, y + 6), f"PLATFORM {st}", font=font(14, True), fill=sc) + y += 44 + + def line(lbl, val, c=INK): + nonlocal y + d.text((x0, y), lbl, font=font(11), fill=DIM) + d.text((x0 + 128, y - 3), val, font=font(15, True), fill=c) + y += 28 + + corr = int(rho * 100) + line("antenna corr", f"{corr:3d} %", AMBER if corr > 50 else OK) + p1 = int(100 * out1 / args.win) + p2 = int(100 * out2 / args.win) + line("1 chain out", f"{p1:3d} %", WARN if p1 > 5 else OK) + line("2 chains out", f"{p2:3d} %", WARN if p2 > 5 else OK) + y += 8 + # verdict + if moving: + v, vc = "chains PAY — combining fills the fades", OK + else: + v, vc = "chains barely help — collapse to one", AMBER + for ln in _wrap(v, 26): + d.text((x0, y), ln, font=font(11), fill=vc); y += 15 + y += 8 + # legend + for col, lbl in ((( 70, 95, 120), "antenna A"), + ((95, 80, 120), "antenna B"), (CYAN, "combined (MRC)")): + d.rectangle([x0, y, x0 + 14, y + 4], fill=col) + d.text((x0 + 22, y - 5), lbl, font=font(11), fill=INK); y += 18 + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=32) + return 0 + + +def _wrap(s, n): + words, line, out = s.split(), "", [] + for w in words: + if len(line) + len(w) + 1 > n: + out.append(line); line = w + else: + line = (line + " " + w).strip() + if line: + out.append(line) + return out + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/ofdm_anatomy_gif.py b/tools/ofdm_anatomy_gif.py index 7fac23e..bd7b647 100644 --- a/tools/ofdm_anatomy_gif.py +++ b/tools/ofdm_anatomy_gif.py @@ -95,7 +95,7 @@ def main() -> int: 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) · " + d.text((padL, base_y + 40), "frequency offset from centre (MHz) · " "each bar = one 312.5 kHz subcarrier", font=font(11), fill=DIM) # cursor callout @@ -109,12 +109,12 @@ def main() -> int: 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 + # legend (a clear row below the frequency caption) + lx, ly = padL, base_y + 64 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) + d.rectangle([lx, ly, lx + 14, ly + 11], fill=COL[reg2]) + d.text((lx + 20, ly - 1), lbl, font=font(11), fill=INK) lx += 150 # bandwidth ticker (cycles ~ every 12 frames) diff --git a/tools/tx_pipeline_gif.py b/tools/tx_pipeline_gif.py new file mode 100644 index 0000000..dc3f3f5 --- /dev/null +++ b/tools/tx_pipeline_gif.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Animated TX pipeline — 'how a packet becomes a radio waveform', in the +DEVOURER live-monitor style. + + tools/tx_pipeline_gif.py -o docs/img/tx_pipeline.gif + +A block of bits is scrambled, forward-error-coded, interleaved, mapped onto the +subcarrier constellations, turned into a time-domain OFDM symbol by an inverse +FFT, given a cyclic-prefix guard, and pushed out the DAC to the antenna. The +animation walks a data block through each stage, showing what it looks like along +the way — raw bits, constellation points, then the waveform. This is the chain +behind DEVOURER_TX_RATE and the modulated carrier. 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) + +# (short, kind, description) +STAGES = [ + ("BITS", "bits", "raw data bits from the MAC"), + ("SCRAMBLE", "bits", "whiten — break up long runs of 0s/1s"), + ("FEC", "coded", "forward error correction — add redundancy"), + ("INTERLEAVE", "bits", "spread bits so a fade hits many codewords lightly"), + ("QAM MAP", "qam", "bits → constellation points, one per subcarrier"), + ("IFFT", "wave", "subcarriers → one time-domain OFDM symbol"), + ("+CP", "cp", "copy the tail to the front — a guard vs echoes"), + ("RF / DAC", "rf", "up-convert and radiate"), +] + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="tx_pipeline.gif") + ap.add_argument("--per", type=int, default=9) + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + rnd = random.Random(0x7A) + bits = [rnd.randint(0, 1) for _ in range(48)] + + padL, padT = 34, 92 + W, H = 900, 452 + n = len(STAGES) + bw = (W - 2 * padL) // n + row_y = padT + 6 + box_h = 54 + detail_y = row_y + box_h + 40 + detail_h = 190 + + imgs = [] + for si in range(n): + for fi in range(args.per): + gi = si * args.per + fi + img, d = new_frame(W, H) + chrome(d, W, H, "TX PIPELINE", + "a block of bits → scramble → FEC → interleave → QAM → IFFT → " + "cyclic prefix → RF", gi) + + # stage row + for j, (name, kind, _) in enumerate(STAGES): + x = padL + j * bw + on = j == si + done = j < si + oc = CYAN if on else (OK if done else (55, 70, 88)) + d.rectangle([x + 4, row_y, x + bw - 4, row_y + box_h], + outline=oc, width=2 if on else 1) + d.text((x + 12, row_y + 8), name, font=font(11, True), + fill=INK if on else (DIM if not done else OK)) + if j < n - 1: + ax = x + bw - 4 + ac = OK if done else (55, 70, 88) + d.line([ax, row_y + box_h // 2, ax + 8, row_y + box_h // 2], + fill=ac, width=2) + # flowing token (pulses every frame so nothing gets merged away) + tx = padL + (si + 0.5) * bw + pr = 5 + 2 * math.sin(gi * 0.9) + d.ellipse([tx - pr, row_y + box_h + 8, tx + pr, row_y + box_h + 8 + 2 * pr], + fill=AMBER) + # data particles flowing along the pipeline baseline + for p in range(6): + px = padL + ((gi * 14 + p * 150) % (W - 2 * padL)) + d.ellipse([px - 2, row_y - 12, px + 2, row_y - 8], + fill=(0, 120, 130)) + + # detail panel + name, kind, desc = STAGES[si] + d.rectangle([padL, detail_y, W - padL, detail_y + detail_h], + outline=(0, 70, 80)) + cx0, cyc = padL + 40, detail_y + detail_h // 2 + + if kind in ("bits", "coded"): + nb = 48 if kind == "bits" else 64 + seq = bits if kind != "coded" else bits + bits[:16] + for k in range(min(nb, 56)): + bx = cx0 + k * 14 + v = seq[k % len(seq)] + d.text((bx, cyc - 8), str(v), font=font(15, True), + fill=CYAN if v else (70, 95, 120)) + if kind == "coded": + d.text((cx0, cyc + 26), "48 bits → 64 (rate-3/4 example)", + font=font(11), fill=OK) + elif kind == "qam": + # a 16-QAM constellation, centred; caption below it (no overlap) + g = detail_h - 70 + ox, oy = cx0 + 180, detail_y + 22 + d.text((cx0, detail_y + 14), "16-QAM", font=font(11), fill=DIM) + for iq in range(4): + for qq in range(4): + px = ox + iq * (g // 4) + py = oy + qq * (g // 4) + d.ellipse([px - 3, py - 3, px + 3, py + 3], fill=CYAN) + d.text((cx0, detail_y + detail_h - 26), + "each subcarrier picks one constellation point", + font=font(12), fill=INK) + else: + # waveform (sum of a few sines = an OFDM symbol) + pts = [] + span = W - 2 * padL - 260 + for k in range(span): + ph = k / span + v = (math.sin(ph * 22) + 0.6 * math.sin(ph * 41 + 1) + + 0.4 * math.sin(ph * 7 + gi * 0.3)) + pts.append((cx0 + 200 + k, cyc - int(v * 34))) + col = OK if kind != "rf" else AMBER + if kind == "cp": + # highlight the copied prefix chunk + d.rectangle([cx0 + 200, cyc - 44, cx0 + 200 + span // 6, + cyc + 44], fill=(40, 40, 14)) + d.text((cx0 + 200, cyc - 62), "cyclic prefix", + font=font(10), fill=AMBER) + d.line(pts, fill=col, width=2) + if kind == "rf": + for r in (10, 20, 30): + d.arc([cx0 + 60 - r, cyc - r, cx0 + 60 + r, cyc + r], + 300, 60, fill=AMBER) + d.line([cx0 + 60, cyc - 34, cx0 + 60, cyc + 20], fill=AMBER, + width=2) + d.text((cx0, detail_y + 12), "OFDM symbol (time domain)", + font=font(11), fill=DIM) + + # caption + d.text((padL, detail_y + detail_h + 18), + f"{si+1}. {name} — {desc}", font=font(13, True), fill=CYAN) + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=48) + return 0 + + +if __name__ == "__main__": + sys.exit(main())