From fd34ad92c4cf72cfad1a82e57f1376550fb0a683 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:30:53 +0300 Subject: [PATCH 1/2] Link-probe MCS-headroom axis, thermal overlay, rendezvous beacon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the adaptive-link probe and add the remaining building-block harnesses. MCS-headroom axis: DEVOURER_TX_MCS_SWEEP steps the on-air rate through a list (a beacon-feed rate sweep, decoupled from the idle-hold continuous carrier like the power ramp), marking each step with mcs=. link_probe.py now auto-detects the swept axis, aggregates per rate across sweep cycles, and for the MCS axis recommends the highest rate whose ground SNR clears the target — the "ride the fastest modulation the link holds" reflex. link_probe.sh gains --axis power|mcs. (The power axis previously claimed to serve the MCS case but did not.) Thermal-budget overlay: the probe polls the emitter's PA thermal meter during the sweep and reports the drift — bounding the sustainable power/duty, the drone's local safety input. Rendezvous beacon: tests/rendezvous.sh — the ground parks a modulated continuous beacon on a channel; the drone scans candidate channels with its frame-free energy sensor and locks onto the beacon's channel. The asymmetric-duty rendezvous composed from the continuous-TX stimulus and the RX energy sensor. Docs: note that the continuous carrier is a spectral/thermal stimulus, not a clean frame source (its looped payload isn't FCS-valid), which is why the probe uses a beacon feed for decodable per-frame SNR; and flag the continuous carrier's 100%-duty PA heat as a debug/characterisation caveat. Co-Authored-By: Claude Opus 4.8 --- README.md | 4 +- docs/adaptive-link-building-blocks.md | 12 ++- tests/link_probe.py | 114 +++++++++++++++++++------- tests/link_probe.sh | 64 +++++++++------ tests/rendezvous.sh | 101 +++++++++++++++++++++++ txdemo/main.cpp | 33 ++++++++ 6 files changed, 272 insertions(+), 56 deletions(-) create mode 100755 tests/rendezvous.sh diff --git a/README.md b/README.md index e18c8d1..c1dfa37 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/adaptive-link-building-blocks.md b/docs/adaptive-link-building-blocks.md index 8fe7210..28b0cc0 100644 --- a/docs/adaptive-link-building-blocks.md +++ b/docs/adaptive-link-building-blocks.md @@ -89,7 +89,9 @@ 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 @@ -103,6 +105,14 @@ 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. +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** — 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, diff --git a/tests/link_probe.py b/tests/link_probe.py index b094ac0..b7d6356 100755 --- a/tests/link_probe.py +++ b/tests/link_probe.py @@ -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 mcs= 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 """ @@ -26,9 +31,11 @@ import sys TS = re.compile(r"^(\d+\.\d+)\s+(.*)$") -STEP = re.compile(r"index=(\d+)") +STEP_PWR = re.compile(r"index=(\d+)") +STEP_MCS = re.compile(r"mcs=(\S+)") ENERGY = re.compile(r"(.*)") NHM = re.compile(r".*peak=(\d+)") +THERMAL = re.compile(r"raw=(\d+)") KV = re.compile(r"(\w+)=(-?\d+)") @@ -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 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). @@ -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.") @@ -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 diff --git a/tests/link_probe.sh b/tests/link_probe.sh index 40c9a52..6537c0c 100755 --- a/tests/link_probe.sh +++ b/tests/link_probe.sh @@ -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 @@ -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 @@ -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 ;; @@ -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; } @@ -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 ==" @@ -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" diff --git a/tests/rendezvous.sh b/tests/rendezvous.sh new file mode 100755 index 0000000..8c4b3da --- /dev/null +++ b/tests/rendezvous.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Rendezvous building block — re-find a peer when the link has dropped. The +# mains-powered "ground station" parks a modulated continuous beacon +# (DEVOURER_CONT_TX) on a known channel; the battery-powered "drone" scans the +# candidate channels with its frame-free energy sensor (DEVOURER_RX_SWEEP) and +# locks onto the channel carrying the beacon. This is the asymmetric-duty +# rendezvous the adaptive-link design describes — an expensive always-on beaconer, +# a cheap low-duty listener — built from the continuous-TX stimulus + the RX +# energy sensor. Two adapters, no SDR; the beacon works on all three generations. +# +# sudo tests/rendezvous.sh --beacon-pid 0x8812 --scanner-pid 0xc812 \ +# --beacon-channel 6 --channels 1,6,11 +# +# The scanner should report its peak (or, on a saturating 1T1R part, dip) at the +# beacon's channel — "peer found". The scanner does not know --beacon-channel; it +# discovers it from the sweep. +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +DEMO_TX="$ROOT/build/WiFiDriverTxDemo" +DEMO_RX="$ROOT/build/WiFiDriverDemo" + +BEACON_VID=0x0bda BEACON_PID="" BEACON_CHANNEL=6 RATE=MCS0 +SCANNER_VID=0x0bda SCANNER_PID="" CHANNELS="1,6,11" DWELL_MS=350 ROUNDS=4 +OUT=/tmp/devourer-rendezvous + +while [ $# -gt 0 ]; do + case "$1" in + --beacon-vid) BEACON_VID="$2"; shift 2 ;; + --beacon-pid) BEACON_PID="$2"; shift 2 ;; + --beacon-channel) BEACON_CHANNEL="$2"; shift 2 ;; + --rate) RATE="$2"; shift 2 ;; + --scanner-vid) SCANNER_VID="$2"; shift 2 ;; + --scanner-pid) SCANNER_PID="$2"; shift 2 ;; + --channels) CHANNELS="$2"; shift 2 ;; + --dwell-ms) DWELL_MS="$2"; shift 2 ;; + --rounds) ROUNDS="$2"; shift 2 ;; + --outdir) OUT="$2"; shift 2 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done +[ -n "$BEACON_PID" ] && [ -n "$SCANNER_PID" ] || { + echo "need --beacon-pid and --scanner-pid" >&2; exit 2; } + +cleanup() { for c in WiFiDriverTxDemo WiFiDriverDemo; do pkill -x "$c" 2>/dev/null || true; done; } +trap cleanup EXIT INT TERM + +echo "== building ==" +cmake --build "$ROOT/build" -j >/dev/null +mkdir -p "$OUT"; rm -f "$OUT"/sweep.log +cleanup; sleep 1 + +echo "== ground beacon: $BEACON_PID modulated continuous carrier on ch$BEACON_CHANNEL ==" +timeout -sINT 600 env DEVOURER_VID="$BEACON_VID" DEVOURER_PID="$BEACON_PID" \ + DEVOURER_CHANNEL="$BEACON_CHANNEL" DEVOURER_TX_RATE="$RATE" DEVOURER_CONT_TX=1 \ + "$DEMO_TX" >"$OUT/beacon.log" 2>&1 & +for _ in $(seq 40); do grep -q "continuous TX armed\|carrier up" "$OUT/beacon.log" && break; sleep 0.5; done +grep -q "continuous TX armed\|carrier up" "$OUT/beacon.log" && echo " beacon up" \ + || echo " WARN: beacon not up (see $OUT/beacon.log)" + +nbins="$(awk -F',' '{print NF}' <<< "$CHANNELS")" +secs="$(( (nbins * ROUNDS * DWELL_MS) / 1000 + 8 ))" +echo "== drone scan: sweep $CHANNELS x $ROUNDS rounds (~${secs}s), locking onto the beacon ==" +timeout -sINT "$secs" env DEVOURER_VID="$SCANNER_VID" DEVOURER_PID="$SCANNER_PID" \ + DEVOURER_RX_SWEEP="$CHANNELS" DEVOURER_RX_SWEEP_DWELL_MS="$DWELL_MS" \ + "$DEMO_RX" 2>/dev/null | grep --line-buffered "devourer-energy" \ + | tee "$OUT/sweep.log" || true +cleanup + +echo "== rendezvous result ==" +# Pick the channel with the most total in-band energy (OFDM + CCK CCA) — the +# modulated beacon can register on either detector, so sum both rather than key +# on cca_ofdm alone (the CW-tone sweep's metric). +python3 - "$OUT/sweep.log" <<'PY' +import re, sys, statistics +per = {} +for line in open(sys.argv[1]): + kv = dict(re.findall(r"(\w+)=(-?\d+)", line)) + if "ch" not in kv: + continue + ch = int(kv["ch"]) + e = int(kv.get("cca_ofdm", 0)) + int(kv.get("cca_cck", 0)) + per.setdefault(ch, []).append(e) +if not per: + print("no sweep samples — beacon or scan failed"); sys.exit(1) +energy = {ch: statistics.median(v) for ch, v in per.items()} +width = 46 +peak = max(energy.values()) or 1 +for ch in sorted(energy): + bar = "#" * round(width * energy[ch] / peak) + print(f" ch {ch:>3} | {bar:<{width}} {int(energy[ch]):>6}") +best = max(energy, key=energy.__getitem__) +floor = statistics.median(list(energy.values())) +if energy[best] > 3 * max(floor, 1): + print(f"\nRENDEZVOUS: peer found on channel {best} " + f"(energy {int(energy[best])} vs floor {int(floor)}).") +else: + print(f"\nRENDEZVOUS: no clear peer — strongest ch {best} " + f"(energy {int(energy[best])}, floor {int(floor)}); beacon too weak or " + f"off the swept list.") +PY diff --git a/txdemo/main.cpp b/txdemo/main.cpp index 4e33773..60070d8 100644 --- a/txdemo/main.cpp +++ b/txdemo/main.cpp @@ -729,6 +729,29 @@ int main(int argc, char **argv) { long pwr_step_ms = 30000; long pwr_next_step_ms = 0; bool txpwr_readback = std::getenv("DEVOURER_TX_PWR_READBACK") != nullptr; + + /* DEVOURER_TX_MCS_SWEEP="MCS0,MCS2,MCS4,..." — the MCS-headroom axis of the + * link probe: step the on-air rate through the list every DEVOURER_TX_MCS_STEP_MS + * (default 2000), emitting a mcs= marker per step. A + * beacon-feed rate sweep (decoupled from the idle-hold HW carrier, like the + * power ramp) so the ground reads decodable per-frame SNR/delivery at each MCS + * and picks the highest rate the link holds. */ + std::vector> mcs_sweep; + long mcs_step_ms = 2000, mcs_next_step_ms = 0; + size_t mcs_idx = 0; + if (const char *e = std::getenv("DEVOURER_TX_MCS_SWEEP")) { + std::string s = e, tok; + std::stringstream ss(s); + while (std::getline(ss, tok, ',')) { + if (tok.empty()) continue; + mcs_sweep.emplace_back(devourer::parse_tx_mode_str(tok), tok); + } + if (const char *ms = std::getenv("DEVOURER_TX_MCS_STEP_MS")) + mcs_step_ms = std::strtol(ms, nullptr, 0); + if (!mcs_sweep.empty()) + logger->info("DEVOURER_TX_MCS_SWEEP — {} rates, {} ms/step", + mcs_sweep.size(), mcs_step_ms); + } auto apply_txpwr = [&](int idx) { /* TXAGC override is a Jaguar1 (RtlJaguarDevice) feature; a no-op otherwise. */ #if defined(DEVOURER_HAVE_JAGUAR1) @@ -864,6 +887,16 @@ int main(int argc, char **argv) { apply_txpwr(pwr_cur); pwr_next_step_ms = ms_since_start() + pwr_step_ms; } + /* MCS-headroom sweep: apply each rate for one dwell, marking the boundary. */ + if (!mcs_sweep.empty() && + (tx_count == 0 || ms_since_start() >= mcs_next_step_ms)) { + if (tx_count != 0) mcs_idx = (mcs_idx + 1) % mcs_sweep.size(); + rtlDevice->SetTxMode(mcs_sweep[mcs_idx].first); + printf("mcs=%s t_ms=%ld\n", + mcs_sweep[mcs_idx].second.c_str(), ms_since_start()); + fflush(stdout); + mcs_next_step_ms = ms_since_start() + mcs_step_ms; + } /* Retune at each dwell boundary. The first iteration (frames_in_dwell==0, * dwell_no==-1) selects the first hop channel; SetMonitorChannel * early-returns if it equals the InitWrite channel. */ From 1d524c5c523f667cac2acd827705c18bbac259e0 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:35:08 +0300 Subject: [PATCH 2/2] docs: name the MCS axis, thermal overlay, and rendezvous harness The building-blocks doc described these conceptually; name the concrete knobs and tools so the doc points at what actually exists: link_probe.sh --axis power|mcs (DEVOURER_TX_MCS_SWEEP), the PA-thermal-drift overlay the probe reports, and tests/rendezvous.sh for the beacon + scan. Co-Authored-By: Claude Opus 4.8 --- docs/adaptive-link-building-blocks.md | 41 ++++++++++++++++----------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/docs/adaptive-link-building-blocks.md b/docs/adaptive-link-building-blocks.md index 28b0cc0..256c91c 100644 --- a/docs/adaptive-link-building-blocks.md +++ b/docs/adaptive-link-building-blocks.md @@ -99,11 +99,13 @@ 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. +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 @@ -113,14 +115,16 @@ 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** — 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. +- **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. @@ -141,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