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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,9 @@ Common to both demos:
- `DEVOURER_CONT_TX=1` — the modulated sibling of the CW tone: a true 100%-duty
modulated OFDM carrier (Realtek's MP hardware continuous-TX mode) on all three
generations, instead of a bare tone. A full-channel active stimulus for
spectral / power / thermal characterisation and the active-link probe. See
spectral / power / thermal characterisation and the active-link probe. 100% duty
is the worst-case PA heat — a debug/characterisation knob, not for sustained
use. See
[`docs/adaptive-link-building-blocks.md`](docs/adaptive-link-building-blocks.md).

On-air TX throughput vs wfb-ng (SDR-verified parity; how to reproduce) is
Expand Down
55 changes: 36 additions & 19 deletions docs/adaptive-link-building-blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,28 +89,42 @@ link reveal itself on demand.
whole 20/40/80 MHz and loads the PA like real traffic, it is the *realistic*
stimulus — what you want for spectral-occupancy, power, and thermal-duty
characterisation. It idle-holds the carrier until stopped, then restores the
chip. (SDR spectrum-shape check: `tests/sdr_spectrum.py` distinguishes a
chip. 100% duty is the worst-case PA heat, so it is a debug / characterisation
stimulus — not for sustained use; pair it with the thermal telemetry and watch
the drift. (SDR spectrum-shape check: `tests/sdr_spectrum.py` distinguishes a
full-channel modulated block from a bare tone by occupied bandwidth.)

The two are complementary: the tone probes *one frequency* narrowly; the modulated
carrier probes *the whole channel* realistically.

## Active probing — turning a stimulus into a decision

The **active link-probe** (`tests/link_probe.sh` + `tests/link_probe.py`) composes
a stimulus and a sensor into an operating-point recommendation. One adapter emits
a modulated feed and **sweeps a lever** in steps (marking each step); the ground
station reads its per-step SNR and NHM; the analyzer aligns the two by time and
reports the **margin-vs-lever curve** plus the operating point that meets a target.

- **Power↔margin** — sweep transmit power, read the ground SNR at each level, and
pick the *minimum power that clears the margin*. This is the energy-min reflex
made measurable: rather than guess the power or discover it by degrading video,
measure the cheapest power that holds the link. (Sweep the noise-limited,
lower-power regime for a clean monotonic curve; very high power into a strong
link just saturates the receiver.)
- **MCS-headroom** — the same harness with the rate as the swept axis: does the
next modulation still clear the SNR/EVM floor? A proactive rate-adaptation input.
The **active link-probe** (`tests/link_probe.sh --axis power|mcs` +
`tests/link_probe.py`) composes a stimulus and a sensor into an operating-point
recommendation. One adapter emits a modulated feed and **sweeps a lever** in steps
(marking each step); the ground station reads its per-step SNR and NHM; the
analyzer aligns the two by time and reports the **margin-vs-lever curve** plus the
operating point that meets a target. It also polls the emitter's PA thermal meter
during the sweep and reports the drift — the thermal-budget overlay below.

The probe deliberately uses a **beacon feed** (fresh, gap-separated frames), not
the 100%-duty continuous carrier: the receiver needs decodable per-frame SNR at
each step, and the continuous carrier — a spectral/thermal stimulus — is not a
clean frame source (its looped payload isn't FCS-valid, and a gapless carrier
offers no frame boundaries to lock onto). Stimulus and probe are thus decoupled:
the continuous carrier characterises the spectrum/power/thermal; the beacon feed
carries the per-frame link quality.

- **Power↔margin** (`--axis power`, the `DEVOURER_TX_PWR_*` ramp) — sweep transmit
power, read the ground SNR at each level, and pick the *minimum power that clears
the margin*. This is the energy-min reflex made measurable: rather than guess the
power or discover it by degrading video, measure the cheapest power that holds
the link. (Sweep the noise-limited, lower-power regime for a clean monotonic
curve; very high power into a strong link just saturates the receiver.)
- **MCS-headroom** (`--axis mcs`, `DEVOURER_TX_MCS_SWEEP="MCS0,MCS2,…"`) — the same
harness with the rate as the swept axis: does the next modulation still clear the
SNR floor? The analyzer reports each rate's ground SNR and delivery and picks the
*highest rate the link holds* — a proactive rate-adaptation input.

This is the concrete building block an energy-min controller uses to place the
operating point *before* committing the video stream to it.
Expand All @@ -131,10 +145,13 @@ The design's decisions map onto the blocks above:
RX-path lever, driven by the per-chain sensors.
- **Hold the thermal / regulatory ceiling** — the thermal telemetry bounds the
power/duty the controller may request; the continuous-TX stimulus is the
worst-case duty for characterising that ceiling.
- **Re-find each other when feedback drops** — a modulated continuous (or periodic)
carrier as the ground's cheap re-find beacon, detected by the drone's low-duty
energy sensor — the asymmetric-duty rendezvous the design describes.
worst-case duty for characterising that ceiling, and the link-probe overlay
reports the PA drift under the swept load.
- **Re-find each other when feedback drops** — a modulated continuous carrier as
the ground's cheap re-find beacon, detected by the drone's low-duty energy
sensor: `tests/rendezvous.sh` parks the beacon on one channel and the scanner
discovers it from a channel sweep. The asymmetric-duty rendezvous the design
describes.

None of these is a policy in itself; each is a lever to pull or a number to read.
The controller that weighs them against the energy objective and the per-layer
Expand Down
114 changes: 83 additions & 31 deletions tests/link_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@

For each swept level it reports the ground's mean SNR / NHM-peak, then picks the
operating point: the *minimum* TX-power index whose ground SNR clears
--target-snr (the energy-min reflex: least power that holds the margin). The
same harness serves the MCS axis when the emitter steps MCS instead of power.
--target-snr (the energy-min reflex: least power that holds the margin).

The same analyzer serves the MCS-headroom axis: when the emitter steps its rate
(DEVOURER_TX_MCS_SWEEP) it prints <devourer-contx>mcs=<spec> markers instead, and
this reports per-MCS ground SNR + frame delivery and picks the *highest* rate that
still clears the floor (the highest modulation the link holds). The axis is auto-
detected from whichever marker the emitter log carries.

tests/link_probe.py --emit emit.log --ground ground.log --target-snr 20
"""
Expand All @@ -26,9 +31,11 @@
import sys

TS = re.compile(r"^(\d+\.\d+)\s+(.*)$")
STEP = re.compile(r"<devourer-txpwr>index=(\d+)")
STEP_PWR = re.compile(r"<devourer-txpwr>index=(\d+)")
STEP_MCS = re.compile(r"<devourer-contx>mcs=(\S+)")
ENERGY = re.compile(r"<devourer-energy>(.*)")
NHM = re.compile(r"<devourer-nhm>.*peak=(\d+)")
THERMAL = re.compile(r"<devourer-thermal>raw=(\d+)")
KV = re.compile(r"(\w+)=(-?\d+)")


Expand Down Expand Up @@ -56,15 +63,21 @@ def main() -> int:
emit = read_stamped(args.emit)
ground = read_stamped(args.ground)

# Build step windows: [(t_start, index), ...] from the emitter markers.
steps = []
# Auto-detect the swept axis from the emitter markers.
steps = [] # [(t_start, label), ...]
axis = None
for t, text in emit:
m = STEP.search(text)
if m:
steps.append((t, int(m.group(1))))
mp = STEP_PWR.search(text)
mm = STEP_MCS.search(text)
if mp:
axis = axis or "power"
steps.append((t, mp.group(1)))
elif mm:
axis = axis or "mcs"
steps.append((t, mm.group(1)))
if not steps:
print("no <devourer-txpwr>index= markers in emitter log — was the power "
"ramp (DEVOURER_TX_PWR_START/STOP/STEP) set?", file=sys.stderr)
print("no step markers in emitter log — set DEVOURER_TX_PWR_START/STOP/STEP "
"(power axis) or DEVOURER_TX_MCS_SWEEP (MCS axis)", file=sys.stderr)
return 1

# Ground samples: (t, snr_mean, frames, nhm_peak).
Expand All @@ -80,38 +93,66 @@ def main() -> int:
kv = dict((k, int(v)) for k, v in KV.findall(me.group(1)))
samples.append((t, kv.get("snr_mean"), kv.get("frames", 0), last_nhm))

# Assign each ground sample to the step window it falls in.
rows = []
for i, (t0, idx) in enumerate(steps):
# Assign each ground sample to the step window it falls in, accumulating by
# label (the sweep may cycle through the levels more than once).
agg = {} # label -> {snr:[], nhm:[], frames:int}
order = [] # unique labels in first-seen (sweep) order
for i, (t0, label) in enumerate(steps):
t1 = steps[i + 1][0] if i + 1 < len(steps) else float("inf")
snrs = [s for (t, s, fr, _) in samples
if t0 + args.settle_ms / 1000.0 <= t < t1 and s is not None and fr]
nhms = [n for (t, s, fr, n) in samples
if t0 + args.settle_ms / 1000.0 <= t < t1 and n is not None]
if not snrs and not nhms:
win = [(s, fr, n) for (t, s, fr, n) in samples
if t0 + args.settle_ms / 1000.0 <= t < t1]
if label not in agg:
agg[label] = {"snr": [], "nhm": [], "frames": 0}
order.append(label)
agg[label]["snr"] += [s for (s, fr, n) in win if s is not None and fr]
agg[label]["nhm"] += [n for (s, fr, n) in win if n is not None]
agg[label]["frames"] += sum(fr for (s, fr, n) in win if fr)

rows = [] # (label, snr, nhm, frames, n) in sweep order
for label in order:
a = agg[label]
if not a["snr"] and not a["nhm"]:
continue
rows.append((idx,
statistics.mean(snrs) if snrs else None,
statistics.median(nhms) if nhms else None,
len(snrs)))
rows.append((label,
statistics.mean(a["snr"]) if a["snr"] else None,
statistics.median(a["nhm"]) if a["nhm"] else None,
a["frames"], len(a["snr"])))

if not rows:
print("no ground samples aligned to any step window — check that both "
"sides ran concurrently and the RX heard the emitter", file=sys.stderr)
return 1

print(f"# link probe: {len(rows)} levels, {len(samples)} ground samples")
print(f"# {'index':>5} {'gnd_SNR':>8} {'nhm_peak':>8} {'n':>4}")
for idx, snr, nhm, n in rows:
col = "index" if axis == "power" else "MCS"
print(f"# link probe ({axis} axis): {len(rows)} levels, {len(samples)} "
f"ground samples")
print(f"# {col:>8} {'gnd_SNR':>8} {'frames':>7} {'nhm_peak':>8} {'n':>4}")
for label, snr, nhm, frames, n in rows:
s = f"{snr:6.1f}" if snr is not None else " - "
nn = f"{nhm}" if nhm is not None else "-"
print(f" {idx:5d} {s:>8} {nn:>8} {n:>4}")

if args.target_snr is not None:
ok = [(idx, snr) for idx, snr, _, _ in rows
if snr is not None and snr >= args.target_snr]
print(f" {label:>8} {s:>8} {frames:>7} {nn:>8} {n:>4}")

# Thermal-budget overlay: the emitter's PA thermal-meter delta over the sweep
# (DEVOURER_THERMAL_POLL_MS on the emitter). Continuous stepping at full duty
# is the worst-case heat; this reports how far the PA drifted, bounding the
# power/duty a controller may sustain (the drone's local safety override).
raws = [int(m.group(1)) for _, text in emit
for m in [THERMAL.search(text)] if m]
if raws:
print(f"\n# PA thermal (emitter raw meter): {raws[0]} -> {raws[-1]} "
f"units over the sweep (range {min(raws)}..{max(raws)}, "
f"+{max(raws) - min(raws)}); ~1.5-2 C/unit. Bounds the sustainable "
f"power/duty.")

if args.target_snr is None:
return 0

ok = [(label, snr) for label, snr, _, _, _ in rows
if snr is not None and snr >= args.target_snr]
if axis == "power":
# Cheapest power that clears the floor (levels are numeric indices).
if ok:
best = min(ok, key=lambda r: r[0])
best = min(ok, key=lambda r: int(r[0]))
print(f"\nRECOMMEND: TX-power index {best[0]} — the minimum that clears "
f"the {args.target_snr:.0f} dB SNR floor (ground SNR "
f"{best[1]:.1f} dB). Energy-min: least power that holds margin.")
Expand All @@ -120,6 +161,17 @@ def main() -> int:
print(f"\nRECOMMEND: no level clears {args.target_snr:.0f} dB — best is "
f"index {top[0]} at {top[1]:.1f} dB. Link too weak for this rate; "
f"drop MCS or raise power ceiling.")
else:
# Highest MCS that still holds the floor (list order = ascending rate).
held = [label for (label, snr, _, frames, _) in rows
if snr is not None and snr >= args.target_snr and frames]
if held:
print(f"\nRECOMMEND: {held[-1]} — the highest swept rate whose ground "
f"SNR clears the {args.target_snr:.0f} dB floor. Energy-min: ride "
f"the fastest modulation the link holds.")
else:
print(f"\nRECOMMEND: no swept rate clears {args.target_snr:.0f} dB — the "
f"link can't hold these MCS; sweep lower rates or raise power.")
return 0


Expand Down
64 changes: 41 additions & 23 deletions tests/link_probe.sh
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
#!/usr/bin/env bash
# Active link-probe — the adaptive-link building block. One adapter emits a
# modulated continuous-TX carrier (DEVOURER_CONT_TX) and sweeps its TX power in
# steps; a second adapter (the "ground station") reads per-frame SNR + the
# frame-free NHM power histogram at each step. tests/link_probe.py aligns the two
# by wall-clock and reports the margin-vs-power curve + the cheapest power that
# clears a target SNR (the energy-min reflex: least power that holds the link).
# modulated beacon feed and sweeps a lever (--axis power | mcs) in steps; a second
# adapter (the "ground station") reads per-frame SNR + the frame-free NHM power
# histogram at each step. tests/link_probe.py aligns the two by wall-clock and
# reports the margin-vs-lever curve + the operating point that meets a target:
# power axis — the cheapest power that clears the SNR floor (least power that
# holds the link); the emitter must be a Jaguar1 part (the TXAGC ramp
# DEVOURER_TX_PWR_START/STOP/STEP is Jaguar1-only).
# mcs axis — the highest rate whose ground SNR clears the floor (ride the
# fastest modulation the link holds); works on any emitter generation.
# The ground can be any generation. Two adapters, no SDR. The emitter's PA thermal
# meter is polled and reported as a thermal-budget overlay.
#
# The power sweep reuses the Jaguar1 TXAGC ramp (DEVOURER_TX_PWR_START/STOP/STEP),
# so the EMITTER must be a Jaguar1 part (8812AU/8814AU/8821AU). The ground can be
# any generation. Two adapters, no SDR.
#
# sudo tests/link_probe.sh --emit-pid 0x8812 --ground-pid 0xc812 \
# --channel 36 --rate MCS4 --pwr-start 4 --pwr-stop 40 --pwr-step 4 \
# --step-ms 1500 --target-snr 20
# sudo tests/link_probe.sh --emit-pid 0x8812 --ground-pid 0xc812 --channel 36 \
# --axis power --rate MCS4 --pwr-start 4 --pwr-stop 40 --step-ms 1500
# sudo tests/link_probe.sh --emit-pid 0x8812 --ground-pid 0xc812 --channel 36 \
# --axis mcs --mcs-list MCS0,MCS2,MCS4,MCS6,MCS7 --step-ms 3000 --target-snr 10
#
# NB (bench): sweep the noise-limited (lower-power) regime for a monotonic SNR
# curve — pushing very high power with the two adapters co-located inches apart
Expand All @@ -29,6 +32,7 @@ EMIT_VID=0x0bda EMIT_PID="" GROUND_VID=0x0bda GROUND_PID=""
CHANNEL=36 RATE=MCS4
PWR_START=4 PWR_STOP=40 PWR_STEP=4 STEP_MS=1500
TARGET_SNR=20 ENERGY_MS=300 OUT=/tmp/devourer-link-probe
AXIS=power MCS_LIST="MCS0,MCS2,MCS4,MCS6,MCS7"

while [ $# -gt 0 ]; do
case "$1" in
Expand All @@ -38,6 +42,8 @@ while [ $# -gt 0 ]; do
--ground-pid) GROUND_PID="$2"; shift 2 ;;
--channel) CHANNEL="$2"; shift 2 ;;
--rate) RATE="$2"; shift 2 ;;
--axis) AXIS="$2"; shift 2 ;; # power | mcs
--mcs-list) MCS_LIST="$2"; shift 2 ;;
--pwr-start) PWR_START="$2"; shift 2 ;;
--pwr-stop) PWR_STOP="$2"; shift 2 ;;
--pwr-step) PWR_STEP="$2"; shift 2 ;;
Expand All @@ -48,6 +54,7 @@ while [ $# -gt 0 ]; do
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
[ "$AXIS" = power ] || [ "$AXIS" = mcs ] || { echo "--axis power|mcs" >&2; exit 2; }
[ -n "$EMIT_PID" ] && [ -n "$GROUND_PID" ] || {
echo "need --emit-pid (Jaguar1) and --ground-pid" >&2; exit 2; }

Expand All @@ -65,7 +72,11 @@ mkdir -p "$OUT"; rm -f "$OUT"/emit.log "$OUT"/ground.log
cleanup; sleep 1

# Total sweep time: number of steps * step-ms, + bring-up + margin.
nsteps=$(( (PWR_STOP - PWR_START) / PWR_STEP + 1 ))
if [ "$AXIS" = power ]; then
nsteps=$(( (PWR_STOP - PWR_START) / PWR_STEP + 1 ))
else
nsteps=$(( $(awk -F',' '{print NF}' <<< "$MCS_LIST") ))
fi
run_secs=$(( nsteps * STEP_MS / 1000 + 12 ))

echo "== ground station: $GROUND_PID ch$CHANNEL energy sensor =="
Expand All @@ -75,18 +86,25 @@ timeout -sINT "$((run_secs + 4))" env DEVOURER_VID="$GROUND_VID" \
| python3 -c "$TS" > "$OUT/ground.log" &
sleep 6 # let the ground RX come up before the emitter starts stepping

echo "== emitter: $EMIT_PID cont-TX $RATE, power $PWR_START..$PWR_STOP step $PWR_STEP =="
# The probe uses the plain modulated beacon feed at a steady rate + the TXAGC
# power ramp (decodable per-frame SNR at each step) — NOT the HW-continuous
# carrier (DEVOURER_CONT_TX), which is idle-hold and doesn't step power.
# The probe uses the plain modulated beacon feed (decodable per-frame SNR at each
# step) — NOT the HW-continuous carrier (DEVOURER_CONT_TX), which is idle-hold and
# doesn't step. Power axis = the TXAGC ramp; MCS axis = the rate sweep.
if [ "$AXIS" = power ]; then
echo "== emitter: $EMIT_PID $RATE, power $PWR_START..$PWR_STOP step $PWR_STEP =="
emit_env=(DEVOURER_TX_RATE="$RATE" DEVOURER_TX_PWR_START="$PWR_START"
DEVOURER_TX_PWR_STOP="$PWR_STOP" DEVOURER_TX_PWR_STEP="$PWR_STEP"
DEVOURER_TX_PWR_STEP_MS="$STEP_MS")
else
echo "== emitter: $EMIT_PID MCS sweep [$MCS_LIST] =="
emit_env=(DEVOURER_TX_MCS_SWEEP="$MCS_LIST" DEVOURER_TX_MCS_STEP_MS="$STEP_MS")
fi
# DEVOURER_THERMAL_POLL_MS on the emitter feeds the thermal-budget overlay (the
# PA heats under the swept full-duty load; link_probe.py reports the drift).
timeout -sINT "$run_secs" env DEVOURER_VID="$EMIT_VID" DEVOURER_PID="$EMIT_PID" \
DEVOURER_CHANNEL="$CHANNEL" DEVOURER_TX_RATE="$RATE" \
DEVOURER_TX_PWR_START="$PWR_START" \
DEVOURER_TX_PWR_STOP="$PWR_STOP" DEVOURER_TX_PWR_STEP="$PWR_STEP" \
DEVOURER_TX_PWR_STEP_MS="$STEP_MS" "$TXDEMO" 2>&1 \
| python3 -c "$TS" > "$OUT/emit.log" || true
DEVOURER_CHANNEL="$CHANNEL" DEVOURER_THERMAL_POLL_MS=1000 "${emit_env[@]}" \
"$TXDEMO" 2>&1 | python3 -c "$TS" > "$OUT/emit.log" || true

cleanup
echo "== margin-vs-power curve =="
echo "== margin-vs-$AXIS curve =="
python3 "$HERE/link_probe.py" --emit "$OUT/emit.log" --ground "$OUT/ground.log" \
--target-snr "$TARGET_SNR"
Loading
Loading