Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Binary file added docs/img/bf_sequence.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/hop_pattern.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/mrc_diversity.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/img/ofdm_anatomy.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/tx_pipeline.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 55 additions & 4 deletions docs/rf-primer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand All @@ -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
Expand Down
135 changes: 135 additions & 0 deletions tools/bf_sequence_gif.py
Original file line number Diff line number Diff line change
@@ -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())
145 changes: 145 additions & 0 deletions tools/hop_pattern_gif.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading