From 32acc3f909a9b931fabe66c76648414a8b319402 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sun, 28 Jun 2026 07:50:41 +0300 Subject: [PATCH 1/2] Adaptive video link: energy-min controller + SVC-HEVC UEP pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Energy-minimizing adaptive link for the drone VTX -> ground VRX video downlink — the dual of OpenIPC's alink: instead of maximizing quality within a power budget, it minimizes energy-per-delivered-bit subject to a per-layer UEP delivery SLA (base/IDR >= 99%, enhancement best-effort). Control stack (tools/precoder/, Python, reuses the committed fused-FEC modules): - energy_model / link_model: airtime x power E_bit and SNR -> P_deliver, with a documented nominal calibration ("model now, meter later"). - op_table / controller: argmin-e_bit over SLA-feasible rows; TXAGC chosen as the minimum that clears the MCS; path-loss (not SNR) asymmetric EWMA; slow-up/fast-down hysteresis; MAX_RANGE failsafe. SvcController bank adds per-temporal-layer targets + enhancement shedding + one shared PA power. - rc_proto / score / rendezvous: CRC16-guarded RCF/DISC codec, post-FEC residual-loss scoring, receiver-initiated low-duty discovery. - adaptive_link.py: --role vtx|vrx orchestrator driving StreamDuplexDemo. SVC-HEVC pipeline: - tests/gen_svc_nals.py: importable synthetic HEVC source (realistic VBR per-layer sizes, 4:8:16 T0/T1/T2 + IDR access units). - svc_uep_fec.py: opt-in NAL fragmentation/reassembly so real-sized NALs ride symbol-sized FEC packets (default off; existing behavior unchanged). - svc_pipeline.py: end-to-end encode -> per-layer (MCS,SNR) sub-block corruption -> SBI salvage -> decode, plus a closed-loop adaptive variant driven by SvcController. C++: StreamDuplexDemo gains a stdin control-opcode escape (SET_PWR / SET_RATE / SET_CHAN) so the Python policy moves the knobs with no USB churn or restart. Verification: - tests/sim_loop.py headline: 34% energy/bit saved vs the best static energy-min profile, 53% vs an over-provisioned robust profile, delivery 0.999, no flapping. - New .github/workflows/precoder-tests.yml runs the whole precoder suite (uv + swif build + pytest) headlessly in CI; 205 tests pass. - On-air harness tests/adaptive_onair.sh (8812 VTX <-> 8821 VRX + B210 interferer), witnessed by the peer's own rate=/rssi=. Docs: docs/adaptive-link.md — the design plus a competitive comparison (wfb-ng, OpenIPC alink, RubyFPV, OpenHD, DJI OcuSync) and feature matrix. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/precoder-tests.yml | 41 +++ README.md | 9 + docs/adaptive-link.md | 448 +++++++++++++++++++++++++++ tests/adaptive_onair.sh | 73 +++++ tests/calibrate_energy.py | 117 +++++++ tests/calibrate_link.py | 71 +++++ tests/gen_svc_nals.py | 92 ++++-- tests/sim_loop.py | 123 ++++++++ tests/svc_uep_onair.sh | 9 +- tools/precoder/README.md | 23 +- tools/precoder/adaptive_link.py | 377 ++++++++++++++++++++++ tools/precoder/controller.py | 212 +++++++++++++ tools/precoder/energy_model.py | 209 +++++++++++++ tools/precoder/link_model.py | 150 +++++++++ tools/precoder/op_table.py | 76 +++++ tools/precoder/pyproject.toml | 7 +- tools/precoder/rc_proto.py | 207 +++++++++++++ tools/precoder/rendezvous.py | 162 ++++++++++ tools/precoder/score.py | 90 ++++++ tools/precoder/svc_pipeline.py | 232 ++++++++++++++ tools/precoder/svc_uep_fec.py | 65 +++- tools/precoder/test_adaptive_link.py | 69 +++++ tools/precoder/test_controller.py | 110 +++++++ tools/precoder/test_energy_model.py | 78 +++++ tools/precoder/test_link_model.py | 61 ++++ tools/precoder/test_rc_proto.py | 76 +++++ tools/precoder/test_rendezvous.py | 105 +++++++ tools/precoder/test_score.py | 51 +++ tools/precoder/test_svc_pipeline.py | 167 ++++++++++ txdemo/stream_duplex_demo/main.cpp | 56 +++- 30 files changed, 3526 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/precoder-tests.yml create mode 100644 docs/adaptive-link.md create mode 100644 tests/adaptive_onair.sh create mode 100644 tests/calibrate_energy.py create mode 100644 tests/calibrate_link.py create mode 100644 tests/sim_loop.py create mode 100644 tools/precoder/adaptive_link.py create mode 100644 tools/precoder/controller.py create mode 100644 tools/precoder/energy_model.py create mode 100644 tools/precoder/link_model.py create mode 100644 tools/precoder/op_table.py create mode 100644 tools/precoder/rc_proto.py create mode 100644 tools/precoder/rendezvous.py create mode 100644 tools/precoder/score.py create mode 100644 tools/precoder/svc_pipeline.py create mode 100644 tools/precoder/test_adaptive_link.py create mode 100644 tools/precoder/test_controller.py create mode 100644 tools/precoder/test_energy_model.py create mode 100644 tools/precoder/test_link_model.py create mode 100644 tools/precoder/test_rc_proto.py create mode 100644 tools/precoder/test_rendezvous.py create mode 100644 tools/precoder/test_score.py create mode 100644 tools/precoder/test_svc_pipeline.py diff --git a/.github/workflows/precoder-tests.yml b/.github/workflows/precoder-tests.yml new file mode 100644 index 0000000..4250f48 --- /dev/null +++ b/.github/workflows/precoder-tests.yml @@ -0,0 +1,41 @@ +name: Precoder unit tests + +# Runs the tools/precoder pytest suite (DSP KATs, FEC round-trips, the adaptive +# energy link, and the SVC-HEVC UEP pipeline) headlessly in CI. The SVC pipeline +# drives synthetic HEVC (tests/gen_svc_nals.py) through encode -> per-layer +# channel -> decode and asserts the graceful-degradation staircase. +on: + push: + branches: [ "master" ] + paths: + - 'tools/precoder/**' + - 'tests/gen_svc_nals.py' + - '.github/workflows/precoder-tests.yml' + pull_request: + branches: [ "master" ] + paths: + - 'tools/precoder/**' + - 'tests/gen_svc_nals.py' + - '.github/workflows/precoder-tests.yml' + workflow_dispatch: + +jobs: + pytest: + runs-on: ubuntu-latest + defaults: + run: + working-directory: tools/precoder + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Sync dependencies + run: uv sync + + - name: Build vendored swif RLC extension + run: uv run python _swif_build.py + + - name: Run precoder test suite + run: uv run pytest -q diff --git a/README.md b/README.md index 713a3ac..4599dfd 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,15 @@ on the kernel-TX path, so the `kernel`-TX rows of `--encoding-matrix` are not authoritative for LDPC/STBC asymmetries — devourer-TX rows ARE). +### Video link design + +The long-range video link's two design documents: +[`docs/adaptive-link.md`](docs/adaptive-link.md) — the energy-minimizing adaptive +controller (VTX ↔ VRX), and how it compares to OpenIPC's `alink` and other +adaptive systems — and [`docs/fused-fec.md`](docs/fused-fec.md) — the +cross-layer (PHY-MCS ⊕ sub-block-integrity ⊕ outer erasure) FEC stack the +link's per-layer quality SLA is stated against. + ### Startup time Devourer reaches ready-to-RX/TX faster than the `aircrack-ng/88XXau` diff --git a/docs/adaptive-link.md b/docs/adaptive-link.md new file mode 100644 index 0000000..e6fabcf --- /dev/null +++ b/docs/adaptive-link.md @@ -0,0 +1,448 @@ +# Energy-minimizing adaptive video link + +This document describes the **adaptive video link** for the OpenIPC / +wifibroadcast-style one-way downlink from a drone transmitter (**VTX**) to a +ground station (**VRX**), and how it differs from the existing adaptive systems +in the field — principally OpenIPC's `alink`. + +Its one-line thesis: **most adaptive links maximize video quality within a power +budget; this one minimizes energy-per-delivered-bit subject to a video-quality +floor.** It is the *dual* of `alink`, built for the case where battery endurance +(or thermal headroom, or shared-spectrum politeness) is the scarce resource and +"good enough" video is the constraint, not the objective. + +All of it is userspace. The radio knobs it drives already exist in +`src/RtlJaguarDevice` (per-packet radiotap MCS, `SetTxPowerOverride` + +`ApplyTxPower`, `SetMonitorChannel`, `GetThermalStatus`); the policy lives in +Python under `tools/precoder/`, and the C++ demo gains only mechanical control +hooks. + +## The problem + +A long-range drone video link runs one-way and broadcast: there are no +link-layer ACKs, so the transmitter cannot learn per-packet success from the MAC +the way an 802.11 station does. The classic deployment picks a single operating +point — one MCS, one TX power, one FEC overhead — sized for the *worst* moment of +the flight. That is wasteful in two directions: + +- **Close to the operator** the link has 20–30 dB of margin it never uses. Full + TX power into a strong link buys nothing but heat, battery drain, and a larger + interference footprint; heavy FEC and a robust low MCS burn airtime that could + have been idle (energy) or carried more video (quality). +- **At long range** the fixed point either falls off a cliff (too aggressive) or + was so conservative that the whole flight paid for the worst case. + +The levers available, ranked by how they move **energy per delivered bit**: + +| Lever | Link effect | Energy effect | +|---|---|---| +| **MCS / FEC overhead** (time-on-air) | strong | **strong** — less airtime → less PA-on time → fewer Joules/bit | +| **Channel / bandwidth** | strong | moderate | +| **TX power (TXAGC)** | **strongest** | **weak** — a 6.3× power increase costs only ~40% more energy/bit because the baseline circuit draw dominates | + +The asymmetry in the last two rows is the whole game: **time-on-air is the +dominant energy lever and radiated power is the dominant *link* lever but a weak +*energy* lever.** An energy-minimizing controller therefore rides the highest MCS +the link will bear (short airtime) and spends only the *minimum* TX power needed +to clear that MCS — the opposite reflex of a throughput-maximizer, which spends +power freely to unlock a still-higher MCS. + +## Objective: energy-min subject to a UEP SLA + +Formally, per FEC block on a given video layer, the cost is + +``` + P_baseline + airtime · P_pa(txagc) + E_bit = ────────────────────────────────── + src_bitrate · P_deliver +``` + +- `P_baseline` — LO + baseband + USB + RX, always on (the floor that makes power + a weak energy lever). +- `airtime = t_pre + payload_bits / R_phy(MCS, BW, SGI)` — the on-air fraction; + this is what MCS/FEC move. +- `P_pa(txagc)` — incremental PA draw at the chosen TXAGC index. +- `P_deliver` — post-FEC block-delivery probability at the link SNR (from the + link model). Failed delivery means the Joules were spent for nothing, so it + divides the cost. + +The controller minimizes `E_bit` **subject to** an unequal-error-protection +service-level agreement, not a single quality number: + +- **base / IDR layers: ≥ 99 % post-FEC delivery** — non-negotiable, never shed. +- **enhancement layers: best-effort** — the slack the controller sheds first + when SNR will not support them at any feasible energy. + +This is the cross-layer UEP of Abdel-Khalek & Heath (joint MCS + FEC source-aware +protection): importance is protected on *both* the PHY-rate and the outer-FEC- +rate knobs at once, producing a graceful-degradation staircase instead of one +cliff. See [Fused FEC](fused-fec.md) for the error-correction stack the SLA is +stated against. + +## Architecture + +``` + VTX (drone, one chip, StreamDuplexDemo) VRX (ground, one chip / SDR, StreamDuplexDemo) + video NALs ─▶ classify + per-layer UEP-FEC ─┐ ┌─ (RSSI / SNR / crc / seq) + per-layer radiotap MCS ─▶ duplex.stdin │ video │ ▶ sliding-window SCORE + post-FEC residual + apply control ◀── parse RCF │ ─────▶ │ ▶ energy-min CONTROLLER → operating point + SetTxPowerOverride / ApplyTxPower │ air │ ▶ RCF (profile + power + FEC) every ~100 ms + per-layer MCS ladder, FEC overhead │ ◀───── │ └─ DISC beacon when the VTX is lost + watchdog ─▶ MAX_RANGE failsafe / DISCOVERY │ RCF +``` + +Two design decisions shape it: + +- **Ground-station-authoritative.** The VRX has the clean receive-side view + (RSSI/SNR/CRC/seq + post-FEC residual loss), so it computes both the link + *score* and the *target operating point* and ships them in the feedback frame. + The VTX applies them mechanically, overlaying only local overrides (thermal + back-off, failsafe). One advisory bit is reserved for a future drone-decides + mode. This mirrors `alink`'s "GS decides" stance. +- **Python policy + thin C++ control surface.** The whole control loop, FEC, + protocol, and simulation are Python in `tools/precoder/`; the C++ duplex binary + (`txdemo/stream_duplex_demo/main.cpp`) gains only a stdin control-opcode escape + (`SET_PWR` / `SET_RATE` / `SET_CHAN`) so the policy can move the knobs with no + USB churn and no restart. + +### Module map + +| Module | Role | +|---|---| +| `tools/precoder/energy_model.py` | `R_phy` rate tables, airtime, `E_bit`; the nominal `P_baseline` + `P_pa[0..63]` + TXAGC-gain calibration (`DEFAULT_CALIB`), overridable by a metered JSON | +| `tools/precoder/link_model.py` | `(MCS, SNR) → P_deliver` via the measured-channel model in `fec_ab_sim`; `snr_required` per (MCS, overhead, target) | +| `tools/precoder/op_table.py` | enumerates `(MCS, FEC-overhead, BW)` link rows, precomputes `snr_req`, and resolves each to the **minimum** TXAGC that clears it + its `e_bit` | +| `tools/precoder/controller.py` | the per-layer energy-min loop + `SvcController` bank; argmin-`e_bit` over SLA-feasible rows, asymmetric hysteresis, failsafe | +| `tools/precoder/rc_proto.py` | RCF / DISC / DISC_ACK codec (CRC16-guarded, drop-not-misapply) + the shared profile table | +| `tools/precoder/score.py` | sliding-window link score from `` lines, weighted by post-FEC residual loss (not raw FCS) | +| `tools/precoder/rendezvous.py` | VTX / VRX receiver-initiated discovery state machines | +| `tools/precoder/adaptive_link.py` | the `--role vtx\|vrx` orchestrator wiring it all to the duplex binary | +| `tools/precoder/svc_pipeline.py` | end-to-end SVC-HEVC UEP simulator + closed-loop adaptive variant | +| `tests/sim_loop.py` | the offline fly-out-and-back headline (energy saved vs static baselines) | + +## The control algorithm + +Each VRX feedback sample drives one update: + +1. **Estimate path loss, not SNR.** TX power changes the *received* SNR, so EWMA- + ing SNR across a power change corrupts the estimate (and delivery collapses at + every transition). The controller instead EWMA-s **path loss = + `reported_snr − gain_db(reported_txagc)`**, which is TXAGC-independent. The + EWMA is **asymmetric** — it reacts fast when the link weakens + (`ema_alpha_down = 0.8`, so power goes up in time) and slowly when it + strengthens (`ema_alpha = 0.3`, so it doesn't chase noise upward). +2. **Score the link feasible rows.** For every `(MCS, FEC-overhead)` row whose + `snr_req` is met by the estimate (minus a `margin_db = 2.0` entry hysteresis) + *and* whose PHY rate carries the layer's `src_bitrate`, resolve the **minimum + TXAGC** that supplies `snr_req` at the estimated path loss. +3. **Pick argmin `e_bit`.** Among feasible rows, choose the cheapest energy-per- + delivered-bit — the inversion of `alink`'s argmax-throughput. +4. **Hysteresis, slow-up / fast-down.** A cheaper row is only adopted if it is + ≥ `improve_frac = 3 %` cheaper and `min_between_changes_ms = 150` has elapsed; + after a downgrade the controller holds for `hold_after_downgrade_ms = 4000`. + A *failing* current row downgrades immediately. TXAGC (the cheap, non- + disruptive lever) tracks the estimate freely between row changes. +5. **Failsafe.** No feedback for `feedback_timeout_ms = 1000` → the controller + returns `MAX_RANGE` (most robust MCS, heaviest FEC, full power) and the VTX + keeps transmitting; persistent loss escalates to rendezvous. + +TXAGC is deliberately **not** a row dimension: it is chosen at runtime as the +minimum index that clears the chosen row, so the table stays small and power is +always the least that works. + +### SVC unequal error protection + +`SvcController` is a bank of the controllers above, one per temporal layer, with +per-layer targets and shed permission: + +| Layer | Target | Shed? | PHY MCS (default ladder) | Outer-FEC overhead | +|---|---|---|---|---| +| critical (IDR / VPS/SPS/PPS) | 0.999 | never | MCS0 20 MHz LDPC STBC | 1.00 | +| T0 base | 0.99 | never | MCS1 20 MHz LDPC STBC | 0.75 | +| T1 | 0.95 | yes | MCS4 20 MHz | 0.50 | +| T2 | 0.90 | yes | MCS7 40 MHz SGI | 0.25 | + +The PHY-MCS ladder is the C++ `svc::LayerPolicy` (`txdemo/svc_tx_demo/svc_tx.h`, +`DEVOURER_SVC_LADDER`); the FEC-overhead ladder is `svc_uep_fec.default_uep_policy`. +Both halves protect the same layers. One PA serves every layer, so the commanded +TX power is the **max** any active layer needs; a layer that no feasible row can +carry is shed (its airtime and energy are saved) rather than delivered badly. + +`svc_pipeline.run_svc_pipeline_adaptive` runs this closed loop in software end to +end: a `SvcController` picks each layer's MCS, the shed set, and the shared power +from a reported sample; the pipeline transmits only the active layers at the +commanded MCS over the resulting effective SNR. The observed behaviour: + +``` +reported SNR @ txagc 32 active layers shared txagc per-layer NAL delivery + +40 dB [crit,T0,T1,T2] 0 all 1.000 (power backed off) + +4 dB [crit,T0] 52 crit/T0 1.000, T1/T2 shed +``` + +## Energy and link models — "model now, meter later" + +The controller needs numbers it cannot yet measure on this bench, so both models +ship with a **documented nominal calibration** and a clean hook to anchor to +hardware later: + +- **Energy** (`energy_model.DEFAULT_CALIB`): `P_baseline = 0.7 W` floor; a PA + curve `P_pa[idx]` rising ~0.1 W → ~1.5 W across TXAGC 0..63 and compressing + near the top; a concave TXAGC→gain curve to ~25 dB; `t_pre` preamble airtime. + `tests/calibrate_energy.py` replaces these by fitting the duty-sweep + TXAGC- + sweep from `tests/thermal_gain_sweep.py` (thermal `delta` = PA-dissipated + proxy) and `tests/sdr_power_probe.py` (USRP radiated proxy). +- **Link** (`link_model`): a nominal per-MCS FCS waterfall + sub-block survivor + shape, fed through `fec_ab_sim.sim_interframe` so delivery and airtime are + priced with the *same* measured-channel accounting the fused-FEC work uses. + `tests/calibrate_link.py` replaces the waterfall with histograms swept from + `tests/sdr_interferer.py` + `fused_fec_link.FusedFecReceiver.report()`. + +The *shape* — which operating point is cheapest — is correct from the nominal +model; only the absolute Joules and SNR thresholds move once metered. Relative +energy savings are valid without a DC meter; an absolute figure needs one, and +the JSON hook is where it lands. + +## Feedback and rendezvous protocol + +`rc_proto.py` defines three CRC16-guarded frames carried as sub-block bodies +([SBI framing](fused-fec.md), so they share the corrupt-frame-salvage path): + +- **RCF** (VRX→VTX, ~100 ms): magic `"RC"`, flags (AUTH / FAILSAFE / DISCOVERY), + VTX-ID, seq, ack-seq, profile index, an `alink`-style 1000–2000 score, explicit + `pwr_idx` and `fec_overhead`, and per-layer delivery. A bad CRC drops the frame + — it is never half-applied. +- **DISC / DISC_ACK** (rendezvous): VTX-ID, nonce, op-channel/width, profile-table + version, init profile, capability bits. + +**Receiver-initiated rendezvous** (the community pattern, formalized after RIT / +802.11ba wake-up-radio): when the VTX loses the RC uplink it enters a low-duty +discovery listen (~50 ms on / ~1 s period on a single 2.4 GHz discovery channel, +dodging the 5 GHz Vbus-sag gotcha); the wall-powered VRX beacons DISC carrying +that VTX's ID *fast*, so any listen window overlaps ≥ 2 beacons. The duty is +deliberately asymmetric — a cheap battery-powered listener and an expensive +mains-powered beaconer. DISC → DISC_ACK → both `SET_CHAN` to the op channel → +session. The watchdog input is kept abstract (`last_rc_monotonic`) so a real RC +uplink is a one-line wire-in; today `ADAPTIVE_RC_SILENCE_AFTER_MS` fires it +deterministically for tests. + +## How it compares + +The open long-range FPV field has converged on adaptive Wi-Fi video, but every +system optimizes for *quality, latency, or survival* — **none makes energy the +objective, and none does per-temporal-layer SVC unequal error protection.** Those +two are this design's distinguishing axes. The systems below are the closest +relatives; a feature matrix summarizing all of them follows. + +### wifibroadcast / wfb-ng (the substrate) + +The common ancestor and, for the OpenIPC world, the substrate: a **pure one-way +FEC broadcast** link — no ACKs, no ARQ, video sprayed as FEC-coded blocks with +RX-side diversity and distributed bonding (several ground receivers, the +best-signal one wins). By itself it is **static** — one MCS, one block-FEC `k/n`, +one power, chosen for the worst case and paid for the whole flight. In the +fly-out-and-back simulation a fixed *robust* profile costs **2.1× the energy per +delivered bit** of the adaptive loop. Everything adaptive in the OpenIPC ecosystem +(including `alink`) sits *on top of* this layer. See +[wfb-ng tuning](wfb-ng-tuning.md) for the static knobs. + +### OpenIPC `alink` + +`alink` is **not a separate radio stack — it is an adaptive-control sidecar that +rides on wfb-ng.** The ground station scores the link from per-packet RSSI/SNR +into a 1000–2000 quality number and selects a **TX profile**; each profile in +`txprofiles.conf` bundles a full operating point — **video bitrate + MCS + FEC +`k/n` + guard interval + GOP/keyframe + TX power + ROI-QP** (region-of-interest +quantization, a *spatial* quality bias within a frame) — and the air unit applies +the commanded profile, with hysteresis to avoid flapping. Its objective is the +**highest sustainable video quality**. It is mature, deployed, and the direct +inspiration for this design's *structure*: ground-authoritative scoring, ~100 ms +cadence, hysteresis, a max-range failsafe. + +The difference is the **objective**, and it changes the reflexes: + +| | OpenIPC `alink` | This link | +|---|---|---| +| Objective | **max quality** within link/power budget | **min energy/bit** subject to a quality floor | +| Operating point | a hand-authored **profile** (preset MCS+FEC+power+bitrate) per score band | rows resolved at runtime; **TXAGC chosen as the minimum that clears the MCS** | +| TX power | a per-profile preset | a continuous lever — the least power that works | +| When the link is strong | push bitrate up | **back power and FEC off** (idle the PA, save Joules) | +| Unequal protection | **ROI-QP** — spatial, within a frame | **per-temporal-layer** PHY-MCS ⊕ FEC ladder, enhancement **shed** | +| Energy model | not a first-class term | explicit `E_bit` (airtime × power), the thing minimized | + +`alink` is the right tool when the mission wants the best picture the spectrum +allows; this link is the right tool when endurance, thermal headroom, or a small +RF footprint is the scarce resource and the picture only has to stay *good +enough*. They are duals — the operating-point machinery is nearly identical; the +cost function is inverted, and the UEP is temporal rather than spatial. + +### RubyFPV + +A **complete, self-contained air+ground FPV system** (its own raw-Wi-Fi protocol, +not wfb-ng) on RTL8812AU/8812EU radios. Two things set it apart from the wfb +lineage: it runs an **ARQ retransmission** layer (wifibroadcast deliberately does +not), and its adaptive loop is **predictive** — it synthesizes "Video Quality and +Prediction" (VQP) parameters from both-end statistics (missing data, RSSI, +error-correction used, retransmission requests, link latency) to describe link +quality *in the near future* and pre-empt breakups, not just react. It adapts in +graduated steps — **FEC rate + H.264 params + bitrate** first, escalating to +**lowering the radio data rate (MCS)** only when those are exhausted — plus an +**adaptive keyframe interval**. + +Two structural contrasts with this link: + +- **Authority is inverted.** RubyFPV is **vehicle-authoritative** — the air unit + computes VQP and applies changes, using controller feedback as an *input*, and + falls back to a vehicle-only algorithm when that feedback is lost (the common + long-range case where the uplink dies first). This link is + ground-authoritative, with a low-duty rendezvous to re-establish the session + rather than a vehicle-only mode. +- **No energy objective and no per-layer UEP.** RubyFPV's goal is explicitly + **robustness — it trades quality *and* latency for link survival**; FEC is + adapted globally, not per temporal layer, and **TX power is not an adaptation + target**. Its multi-band "parallel links" (433/868/915 MHz, 2.4, 5.8 GHz) and + relaying are *redundancy/resilience* features, not energy or throughput + optimization. Source is C/C++ under a custom non-OSI "Ruby Licence" (no + military use). + +### OpenHD + +The other major open ecosystem, descended from the same befinitiv wifibroadcast +root as wfb-ng but an **independent fork** with its **own** C++ broadcast library +(`OpenHD/wifibroadcast`, GPLv3) and Realtek driver fork. Its design priority is +**latency** — FEC instead of ARQ, ~100 ms glass-to-glass, H.265 to save every +millisecond. Its adaptation is narrower than the others': + +- **Bitrate is the only automatic, closed-loop knob** — variable bitrate is on by + default and the encoder bitrate is **reduced on transmission errors**. (OpenHD's + public docs do not specify the exact metric or whether ground-measured RSSI/SNR + is relayed to the air unit to drive it, so the loop's authority is less defined + than `alink`'s or this design's.) +- **MCS / channel width and TX power are operator knobs, not closed loops** — MCS + is switched manually in flight via an RC channel (`MCS_VIA_RC`); TX-power index + (0–63) is runtime-adjustable but manual; keyframe interval is manual. FEC is + "optimized" but not documented as adaptively retuned. +- **No per-layer UEP/SVC and no energy objective.** It does provide ground **RX + diversity** (up to two receivers, best-signal auto-select) and bidirectional + MAVLink. GPLv3, C++. + +So OpenHD adapts *one* lever automatically (bitrate) where `alink` and this link +adapt the whole operating point; and like every system here it treats neither +energy nor temporal-layer protection as a control variable. + +### DJI OcuSync / O3 / O4 + +Proprietary and closed. OcuSync already does adaptive coding & modulation, +adaptive bitrate, and frequency agility, and is generally understood to bias +toward *latency and quality*. There is no public evidence of an energy objective +or of exposed per-layer protection. This design targets the same adaptivity in +the open stack and adds the explicit energy objective and per-temporal-layer UEP +that a closed system does not expose. + +### Feature matrix + +`A` = automatic / closed-loop, `M` = manual operator knob, `—` = absent or not +public. "Per-layer UEP" means temporal-layer (SVC) unequal protection on the FEC +*and* MCS knobs. + +| Dimension | wfb-ng | OpenIPC alink | RubyFPV | OpenHD | DJI O3/O4 | **This link** | +|---|---|---|---|---|---|---| +| Relation to stack | broadcast link layer | **sidecar on wfb-ng** | own raw-Wi-Fi (+ARQ) | own wifibroadcast fork | proprietary | controller on devourer | +| Adaptation objective | none (static) | max quality | robustness (survive) | latency-first quality | latency / quality | **min energy/bit s.t. UEP SLA** | +| Auto video bitrate | — | A | A (fine steps) | A (default) | A | layer **shed** vs lowered | +| Auto MCS / data rate | — | A (per profile) | A (escalation) | M (RC switch) | A (ACM) | A (per layer) | +| Auto FEC overhead | — | A (per profile) | A (global) | — | A | **A (per-layer ladder)** | +| Auto TX power | — | preset per profile | — | M (runtime) | A (closed) | **A (continuous min-power)** | +| Feedback authority | n/a | **ground** (RSSI→score) | **vehicle** (VQP, predictive) | mgmt bidir. (metric undoc.) | proprietary | **ground** | +| ARQ / retransmit | — | — | **yes** | — (latency) | — | — | +| Per-layer (SVC) UEP | — | spatial ROI-QP only | — | — | — | **yes (PHY ⊕ FEC, shed)** | +| Corrupt-frame salvage | — | — | — | — | — | **yes (SBI sub-blocks)** | +| Energy-aware | — | — | — | — | — | **yes (the objective)** | +| License | GPL | GPL | custom (non-OSI) | GPLv3 | closed | (devourer) | + +The two fully-populated rows unique to this link — **energy-aware** and +**per-layer UEP + corrupt-frame salvage** — are the contribution; everything else +in its column is shared with the mature systems it learned from. + +### 802.11 rate adaptation (Minstrel-HT, SampleRate, RRAA) + +The standard in-kernel rate adapters are **throughput-maximizers driven by +per-packet ACKs**. They do not apply here for two reasons: the link is +ACK-less broadcast injection (no MAC success signal to adapt on), and they are +oblivious to application FEC, video-layer importance, and energy. This controller +adapts on an explicit out-of-band score, not ACKs, and optimizes energy under a +UEP constraint — a different problem. + +### Academic energy-aware rate adaptation (e.g. ERAA) + +Energy-rate adaptation research establishes the core result this design rests on +— minimizing energy-per-bit means riding a high MCS (short airtime) and using +the least power that sustains it, with reported savings around ~44 % at ~90 % of +peak throughput. Those schemes are typically unicast/ACK-driven and not source- +aware. This link applies the same bits-per-Joule principle to an ACK-less +broadcast video downlink and couples it to cross-layer SVC UEP and a receiver- +initiated rendezvous for session establishment. + +## Results + +Software, the headline is `tests/sim_loop.py` — a time-varying "fly out and back" +path-loss schedule pushed through the controller + link model + energy model, +compared against two static baselines tuned on the same models: + +| Strategy | Energy / delivered bit | Delivery | Notes | +|---|---|---|---| +| **Adaptive (this link)** | **205.7 nJ** | 0.999 | 2 operating-point changes over 200 ticks (no flapping) | +| Static energy-min profile | 310.6 nJ | 1.000 | best single fixed point — adaptive saves **34 %** | +| Static robust profile | 435.2 nJ | 1.000 | over-provisioned worst-case — adaptive saves **53 %** | + +The SVC pipeline (`svc_pipeline.py`) shows the UEP staircase end to end against a +synthetic HEVC stream (`tests/gen_svc_nals.py`): as SNR drops, T2 sheds first, +then T1, then T0, while the critical/IDR layer holds at 1.000 delivery far below +where enhancement is gone — and SBI sub-block salvage delivers materially more +than whole-frame erasure on the marginal layer. + +The whole `tools/precoder` suite — controller, protocol, rendezvous, SVC pipeline +— runs headlessly in CI (`.github/workflows/precoder-tests.yml`). + +On hardware, `tests/adaptive_onair.sh` closes the loop over two adapters (8812 +VTX ↔ 8821 VRX) with an optional B210 interferer: the VRX scores the link and +commands an operating point, the VTX applies it, and the *witness* is the peer's +own `` — `rate=` changes when a `SET_RATE` lands, `rssi=` rises +when a `SET_PWR` raises power, with no extra instrumentation. The base link +adapts MCS and power on air and rides the failsafe/rendezvous transitions. + +## Current scope and integration points + +- **Energy is modeled, not metered.** Relative savings are valid on the nominal + calibration; an absolute Joule figure needs the DC-meter anchor that the + calibration JSON hook accepts. Thermal `delta` and SDR `dbfs` cross-check the + shape. +- **No real RC uplink in-repo.** RC-loss is driven through the abstract watchdog + input; a real uplink wires into `last_rc_monotonic`. +- **On-air SVC today is non-adaptive.** `SvcTxDemo` already flies each HEVC + temporal layer at its own MCS on air (`tests/svc_uep_onair.sh`); the + *adaptive* SVC path — the VRX's `SvcController` retuning the per-layer ladder + and shed set live via a per-frame layer-tagged radiotap in + `stream_duplex_demo` — is the integration point between the software-validated + loop and the on-air binary. + +## References + +- OpenIPC project — +- OpenIPC Adaptive-Link (`alink`) — +- wfb-ng (wifibroadcast-NG), svpcom — +- RubyFPV — , + [adaptive video link](https://rubyfpv.com/resource_adaptive_video_link.php) +- OpenHD — , broadcast library + , + [variable bitrate](https://openhdfpv.org/software-setup/variable-bitrate/) +- A. Abdel-Khalek and R. W. Heath, "Joint MCS and FEC for unequal error + protection of scalable video," *IEEE JSAC*, 2012 — cross-layer UEP. +- "All Bits Are Not Equal: A Study of IEEE 802.11 Communication Bit Errors," + *IEEE INFOCOM*, 2009 — localized corruption, the basis for sub-block salvage. +- Energy-rate adaptation (ERAA and related) — energy-per-bit minimization, + high-MCS / minimum-power result. +- IEEE 802.11ba (Wake-Up Radio) and Receiver-Initiated Transmission (RIT) — the + asymmetric-duty rendezvous pattern. +- [Fused FEC](fused-fec.md) — the concatenated error-correction stack the UEP SLA + is stated against. +- [wfb-ng tuning](wfb-ng-tuning.md) — the static-link baseline. diff --git a/tests/adaptive_onair.sh b/tests/adaptive_onair.sh new file mode 100644 index 0000000..65075c8 --- /dev/null +++ b/tests/adaptive_onair.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# On-air closed-loop adaptive link: VTX (8812) <-> VRX (8821), both running +# StreamDuplexDemo driven by adaptive_link.py. The VTX streams synthetic video and +# applies the VRX's RCF feedback (SET_PWR / SET_RATE) live; the VRX scores RSSI/SNR +# and commands the energy-min operating point. An optional B210 interferer +# (USE_INTERFERER=1) drives the link into the corrupt regime so the controller +# visibly drops to a more robust profile. +# +# WITNESS (no extra instrumentation): the *peer's* rate= changes +# when a SET_RATE lands, and rssi= rises when a SET_PWR raises power. +# +# sudo bash tests/adaptive_onair.sh # steady-state adaptation +# USE_INTERFERER=1 IGAIN=75 sudo bash tests/adaptive_onair.sh +# RENDEZVOUS=1 sudo bash tests/adaptive_onair.sh # VTX on wrong channel -> discovery +# SKIP_RAIL=1 after a clean boot. +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PREC="$ROOT/tools/precoder" +REAL_HOME=$(getent passwd "${SUDO_USER:-$USER}" | cut -d: -f6) + +VTX_PID=${VTX_PID:-0x8812}; VTX_VID=${VTX_VID:-0x0bda}; VTX=${VTX_SYSFS:-9-2} +VRX_PID=${VRX_PID:-0x0120}; VRX_VID=${VRX_VID:-0x2357}; VRX=${VRX_SYSFS:-9-1.4} +CH=${CH:-6}; SECS=${SECS:-30}; VTX_ID=${VTX_ID:-0xABCD} +USE_INTERFERER=${USE_INTERFERER:-}; IGAIN=${IGAIN:-75}; IMODE=${IMODE:-noise} +RENDEZVOUS=${RENDEZVOUS:-} + +VTX_LOG=/tmp/adaptive_vtx.log; VRX_LOG=/tmp/adaptive_vrx.log +VIDEO=/tmp/adaptive_video.bin + +KILL(){ sudo pkill -9 -f adaptive_link 2>/dev/null; sudo pkill -9 StreamDuplexD 2>/dev/null + sudo pkill -9 -f sdr_interferer 2>/dev/null; } +trap KILL EXIT + +# free both Wi-Fi adapters (B210 is uhd-accessed) +for D in "$VTX" "$VRX"; do + for i in /sys/bus/usb/devices/$D/$D:*; do + ifc=$(basename "$i"); drv=$(readlink -f "$i/driver" 2>/dev/null) + [ -n "$drv" ] && echo "$ifc" | sudo tee "$drv/unbind" >/dev/null 2>&1 + done +done; sleep 1 + +head -c 4000000 /dev/urandom > "$VIDEO" # synthetic "video" bytes + +if [ -n "$USE_INTERFERER" ]; then + echo "=== B210 interferer ch$CH gain=$IGAIN mode=$IMODE (warming up) ===" + sudo python3 "$ROOT/tests/sdr_interferer.py" --channel $CH --tx-gain "$IGAIN" \ + --rate 20e6 --mode "$IMODE" --secs $((SECS + 30)) >/tmp/intf.log 2>&1 & + sleep 11 +fi + +# VRX channel: RENDEZVOUS puts the VTX on a WRONG channel so it must rediscover. +VTX_CH=$CH; [ -n "$RENDEZVOUS" ] && VTX_CH=$((CH + 5)) + +echo "=== VRX (8821) adaptive_link on ch$CH ===" +sudo env DEVOURER_VID=$VRX_VID DEVOURER_PID=$VRX_PID PYTHONPATH="$PREC" \ + python3 "$PREC/adaptive_link.py" --role vrx --pid $VRX_PID --channel $CH \ + --vtx-id $VTX_ID --duplex "$ROOT/build/StreamDuplexDemo" >"$VRX_LOG" 2>&1 & +sleep 6 +echo "=== VTX (8812) adaptive_link on ch$VTX_CH ===" +sudo env DEVOURER_VID=$VTX_VID DEVOURER_PID=$VTX_PID PYTHONPATH="$PREC" \ + python3 "$PREC/adaptive_link.py" --role vtx --pid $VTX_PID --channel $VTX_CH \ + --vtx-id $VTX_ID --video "$VIDEO" --duplex "$ROOT/build/StreamDuplexDemo" >"$VTX_LOG" 2>&1 & + +sleep "$SECS"; KILL; sleep 1 + +echo "=== RESULT ===" +echo "[vrx] video frames heard from VTX (rx hits): $(grep -oP 'rx hits=\K\d+' "$VRX_LOG" | tail -1)" +echo "[vtx] RCF applied: SET_PWR=$(grep -c 'ctl op=1' "$VTX_LOG") SET_RATE=$(grep -c 'ctl op=2' "$VTX_LOG")" +echo "[vrx] controller trajectory (1 Hz):" +grep '' "$VRX_LOG" | tail -8 +echo "[vtx] applied-state trajectory (1 Hz):" +grep '' "$VTX_LOG" | tail -8 +echo "logs: $VRX_LOG $VTX_LOG" diff --git a/tests/calibrate_energy.py b/tests/calibrate_energy.py new file mode 100644 index 0000000..7122520 --- /dev/null +++ b/tests/calibrate_energy.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Fit the adaptive-link energy model from a thermal-gain sweep -> energy_calib.json. + +The model (energy_model.py) needs `P_circuit` (W, paid over the whole frame slot) +and `P_pa[idx]` (W, paid on-air, per TXAGC index 0..63). This consumes the CSV +that tests/thermal_gain_sweep.py already produces: + index, raw, baseline, delta, status, dbfs +across a TXAGC sweep (--start/--stop/--step), and fits: + P_pa[idx] ∝ alpha*10^(dbfs(idx)/10) + beta*delta(idx) + \___radiated proxy___/ \__PA-dissipation proxy__/ +interpolated to all 64 indices (monotone), then scaled. "Model now, meter later": +without --meter the result is RELATIVE (anchored to the nominal P_pa range, so the +controller picks the same operating points); with --meter CSV (columns: +index,watts of measured DC bus power) it is ABSOLUTE (sets metered_watts=true and +P_circuit from the zero-/low-idx intercept). + + ./calibrate_energy.py --sweep-csv sweep-8812-ch6-*.csv [--meter meter.csv] \ + --out energy_calib.json +Run the sweep first: sudo python3 thermal_gain_sweep.py --ht --start 0 --stop 63 --step 3 +""" +import argparse +import csv +import json +import os +import sys + +sys.path.insert(0, os.path.expanduser("~/git/devourer/tools/precoder")) +import energy_model # noqa: E402 + + +def read_sweep(path): + """CSV -> {idx: (mean_dbfs_or_None, mean_delta_or_None)} averaged per index.""" + rows = {} + with open(path) as f: + for r in csv.DictReader(f): + try: + idx = int(r["index"]) + except (KeyError, ValueError): + continue + dbfs = r.get("dbfs"); dbfs = float(dbfs) if dbfs not in (None, "", "none") else None + dlt = r.get("delta"); dlt = float(dlt) if dlt not in (None, "", "none") else None + a, b, n = rows.get(idx, (0.0, 0.0, 0)) + rows[idx] = (a + (dbfs or 0.0), b + (dlt or 0.0), n + 1) + return {k: (a / n if n else None, b / n if n else None) for k, (a, b, n) in rows.items()} + + +def fit_pa_curve(sweep, alpha=1.0, beta=0.02): + """Per-index PA proxy from radiated (10^dbfs/10) + dissipation (delta); + interpolate to all 64 indices, force monotone non-decreasing.""" + swept = sorted(sweep) + raw = {} + for idx in swept: + dbfs, dlt = sweep[idx] + rad = 10 ** (dbfs / 10.0) if dbfs is not None else 0.0 + raw[idx] = alpha * rad + beta * (dlt or 0.0) + # linear interpolation across the full 0..63 grid + pa = [] + for idx in range(64): + if idx in raw: + pa.append(raw[idx]); continue + lo = max((i for i in swept if i <= idx), default=swept[0]) + hi = min((i for i in swept if i >= idx), default=swept[-1]) + if lo == hi: + pa.append(raw[lo]) + else: + t = (idx - lo) / (hi - lo) + pa.append(raw[lo] + t * (raw[hi] - raw[lo])) + for i in range(1, 64): # monotone non-decreasing + pa[i] = max(pa[i], pa[i - 1]) + return pa + + +def main(): + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--sweep-csv", required=True) + p.add_argument("--meter", help="optional DC-meter CSV: index,watts (absolute anchor)") + p.add_argument("--out", default="energy_calib.json") + a = p.parse_args() + + sweep = read_sweep(a.sweep_csv) + if not sweep: + sys.exit("no usable rows in sweep CSV") + pa = fit_pa_curve(sweep) + + nominal = energy_model.DEFAULT_CALIB + metered = False + p_circuit = nominal["p_circuit_w"] + if a.meter: + metered = True + with open(a.meter) as f: + m = {int(r["index"]): float(r["watts"]) for r in csv.DictReader(f)} + lo_idx = min(m) + p_circuit = m[lo_idx] # low-idx DC ~= circuit floor + # scale the PA proxy to absolute Watts: anchor span to (max-min) meter delta + span = (m[max(m)] - m[lo_idx]) + pa_span = pa[-1] - pa[0] or 1.0 + pa = [p_circuit and (x - pa[0]) / pa_span * span for x in pa] + else: + # relative: rescale the proxy curve into the nominal P_pa range so the + # controller's operating-point choices are unchanged in absolute terms. + nom = nominal["p_pa_w"] + lo, hi = pa[0], pa[-1] or 1.0 + pa = [nom[0] + (x - lo) / (hi - lo or 1.0) * (nom[-1] - nom[0]) for x in pa] + + out = dict(nominal) + out.update({"source": "metered" if metered else "calibrated", + "metered_watts": metered, "p_circuit_w": round(p_circuit, 4), + "p_pa_w": [round(x, 5) for x in pa]}) + with open(a.out, "w") as f: + json.dump(out, f, indent=2) + print(f"[calibrate-energy] wrote {a.out} source={out['source']} " + f"P_circuit={out['p_circuit_w']}W P_pa[0/63]={out['p_pa_w'][0]}/{out['p_pa_w'][63]}W") + + +if __name__ == "__main__": + main() diff --git a/tests/calibrate_link.py b/tests/calibrate_link.py new file mode 100644 index 0000000..e359ff9 --- /dev/null +++ b/tests/calibrate_link.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Build the adaptive-link channel table from on-air measurements -> link_calib.json. + +link_model.py keys delivery on a per-(MCS, SNR-bucket) channel + {corrupt_rate, n_sub, survivor_hist} +— exactly what ~/git/sdr2wifi/survivor_hist.py and fused_fec_link.FusedFecReceiver +already emit. The measurement procedure (per MCS): + for each tests/sdr_interferer.py --tx-gain setpoint: + run the chip TX at that MCS + the VRX (chip or SDR); + read the VRX-reported mean SNR and the per-corrupt-frame survivor histogram. +Append each as one JSON line to a measurements file: + {"mcs": 5, "snr_db": 18.3, "corrupt_rate": 0.31, "n_sub": 10, + "survivor_hist": {"0": 15, ... "10": 195}} + +This consumes that JSONL, buckets by SNR, and writes the channel table + per-MCS +waterfall centres (back-fit from the corrupt_rate=0.5 crossing) that link_model +overlays. + + ./calibrate_link.py --measurements meas.jsonl --out link_calib.json --bucket 1.0 +""" +import argparse +import json + + +def main(): + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--measurements", required=True, help="JSONL of on-air channel measurements") + p.add_argument("--out", default="link_calib.json") + p.add_argument("--bucket", type=float, default=1.0, help="SNR bucket width (dB)") + a = p.parse_args() + + channels = {} # "mcs:snr_bucket" -> channel dict + by_mcs = {} # mcs -> list[(snr, corrupt_rate)] + with open(a.measurements) as f: + for line in f: + line = line.strip() + if not line: + continue + m = json.loads(line) + mcs = int(m["mcs"]); snr = float(m["snr_db"]) + b = round(snr / a.bucket) * a.bucket + channels[f"{mcs}:{b:g}"] = { + "corrupt_rate": float(m["corrupt_rate"]), + "n_sub": int(m.get("n_sub", 10)), + "survivor_hist": {int(k): int(v) for k, v in m["survivor_hist"].items()}, + } + by_mcs.setdefault(mcs, []).append((snr, float(m["corrupt_rate"]))) + + # Back-fit each MCS's waterfall centre = SNR where corrupt_rate crosses 0.5 + centers = {} + for mcs, pts in by_mcs.items(): + pts.sort() + center = None + for (s0, c0), (s1, c1) in zip(pts, pts[1:]): + if (c0 - 0.5) * (c1 - 0.5) <= 0 and c1 != c0: + center = s0 + (0.5 - c0) * (s1 - s0) / (c1 - c0) + break + if center is not None: + centers[str(mcs)] = round(center, 2) + + out = {"source": "measured", "bucket_db": a.bucket, + "centers": centers, "channels": channels} + with open(a.out, "w") as f: + json.dump(out, f, indent=2) + print(f"[calibrate-link] wrote {a.out}: {len(channels)} channels, " + f"{len(centers)} MCS centres {centers}") + + +if __name__ == "__main__": + main() diff --git a/tests/gen_svc_nals.py b/tests/gen_svc_nals.py index fc65db0..c8ba420 100644 --- a/tests/gen_svc_nals.py +++ b/tests/gen_svc_nals.py @@ -1,39 +1,87 @@ #!/usr/bin/env python3 """Generate length-prefixed synthetic HEVC NAL units with controlled -temporal_ids, to exercise SvcTxDemo's TID -> TxMode UEP mapping on air. +temporal_ids, to exercise the SVC-T UEP path (PHY MCS + outer FEC) on air and +in software. Output (stdout, binary): ... HEVC NAL header (2 bytes): byte0 = (nal_type << 1), byte1 = (tid + 1). -Per GOP we emit a dyadic 3-temporal-layer hierarchical-P pattern plus an IDR: - 1 IDR (critical) + 4 T0 + 8 T1 + 16 T2 (ratio 1:4:8:16) -so the witness rate histogram should be MCS0:MCS1:MCS4:MCS7 ~= 1:4:8:16 -(the default_policy ladder in svc_tx.h). +Per GOP we emit a dyadic 3-temporal-layer hierarchical-P pattern. Every +`idr_period` GOPs the access unit is led by parameter sets + an IDR so the +critical-layer share matches a real stream (small but unconditionally needed): + per GOP: 4 T0 base + 8 T1 + 16 T2 (ratio 4:8:16) + per IDR AU additionally: VPS + SPS + PPS + IDR (critical) + +Frame sizes track real HEVC: IRAP/parameter-set NALs are large, base-layer +P-frames medium, enhancement frames small — so the airtime/energy and FEC +accounting downstream see a realistic byte mix, not a flat one. python3 tests/gen_svc_nals.py [GOPS] [PAYLOAD] | DEVOURER_PID=0x8812 ... ./build/SvcTxDemo """ import struct import sys -GOPS = int(sys.argv[1]) if len(sys.argv) > 1 else 6 -PAYLOAD = int(sys.argv[2]) if len(sys.argv) > 2 else 200 +# HEVC nal_unit_type values +IDR_W_RADL = 19 # IRAP -> critical +TRAIL_R = 1 # referenced trailing picture +VPS_NUT = 32 # parameter sets -> critical +SPS_NUT = 33 +PPS_NUT = 34 + +# Realistic per-class payload sizes (bytes, header excluded). IRAP/param-sets +# big, base medium, enhancement small — a coarse but representative VBR mix. +SZ_PARAM = 48 # VPS/SPS/PPS are tiny but critical +SZ_IDR = 1800 # intra access unit — the biggest frame +SZ_T0 = 700 # base-layer P +SZ_T1 = 320 +SZ_T2 = 150 + + +def nal_bytes(nal_type: int, tid: int, payload: int) -> bytes: + """A bare HEVC NAL (2-byte header + `payload` deterministic body bytes).""" + body = bytearray([(nal_type << 1) & 0xFF, (tid + 1) & 0x07]) + # deterministic, non-constant body so FEC/CRC see real-looking entropy + body += bytes((nal_type * 31 + tid * 7 + i) & 0xFF for i in range(payload)) + return bytes(body) + -IDR_W_RADL = 19 # IRAP -> critical -TRAIL_R = 1 # referenced trailing picture +def gen_nals(gops: int = 6, idr_period: int = 2, + sizes: dict | None = None) -> list[bytes]: + """Synthetic SVC-T HEVC NAL stream as a list of bare NALs (no length prefix + — feed straight to `svc_uep_fec.SvcUepEncoder.add_nal`). One IDR access unit + (param sets + IDR) every `idr_period` GOPs; dyadic 4:8:16 T0/T1/T2 per GOP. + """ + s = {"param": SZ_PARAM, "idr": SZ_IDR, "t0": SZ_T0, "t1": SZ_T1, "t2": SZ_T2} + if sizes: + s.update(sizes) + out: list[bytes] = [] + for g in range(gops): + if g % idr_period == 0: + out.append(nal_bytes(VPS_NUT, 0, s["param"])) # critical + out.append(nal_bytes(SPS_NUT, 0, s["param"])) # critical + out.append(nal_bytes(PPS_NUT, 0, s["param"])) # critical + out.append(nal_bytes(IDR_W_RADL, 0, s["idr"])) # critical + for _ in range(4): + out.append(nal_bytes(TRAIL_R, 0, s["t0"])) # T0 base + for _ in range(8): + out.append(nal_bytes(TRAIL_R, 1, s["t1"])) # T1 + for _ in range(16): + out.append(nal_bytes(TRAIL_R, 2, s["t2"])) # T2 + return out -def nal(nal_type: int, tid: int) -> bytes: - body = bytes([(nal_type << 1) & 0xFF, (tid + 1) & 0x07]) + bytes(PAYLOAD) - return struct.pack(" None: + gops = int(sys.argv[1]) if len(sys.argv) > 1 else 6 + # legacy positional PAYLOAD arg: flatten every class to that size + sizes = None + if len(sys.argv) > 2: + p = int(sys.argv[2]) + sizes = {"param": p, "idr": p, "t0": p, "t1": p, "t2": p} + out = sys.stdout.buffer + for nal in gen_nals(gops, sizes=sizes): + out.write(struct.pack(" far -> close) through the +controller + link model + energy model, and compares energy-per-delivered-bit and +delivery against a FIXED baseline profile sized for the worst-case range (the +"set it for worst case and forget" status quo). Headline: the adaptive link banks +the close-range headroom as Watts saved while holding the delivery SLA. + + uv run python ../tests/sim_loop.py # from tools/precoder, or: + PYTHONPATH=tools/precoder uv run python tests/sim_loop.py +""" +import os +import sys + +sys.path.insert(0, os.path.expanduser("~/git/devourer/tools/precoder")) +import energy_model as em +import link_model as lm +import op_table +from controller import Controller, ControllerConfig + + +def path_loss_schedule(n_ticks: int, hi: float = 35.0, lo: float = -12.0): + """Triangle: free-SNR (received SNR at TXAGC 0) close(hi) -> far(lo) -> close. + Spans ~47 dB (more than the ~25 dB TXAGC range) so a real flight forces MCS + adaptation, not just power tracking.""" + half = n_ticks // 2 + out = [] + for t in range(n_ticks): + frac = t / half if t < half else (n_ticks - t) / (n_ticks - half) + out.append(lo + (hi - lo) * frac) + return out + + +def run(link, calib, cfg, schedule, fixed_op=None, dt_s=0.1): + """Returns (energy_J, delivered_bits, offered_bits, list_of_ops). Each tick + carries `cfg.src_bitrate_bps` of video for dt_s seconds at the chosen point; + energy = P_avg*dt, delivered = src*P_deliver*dt.""" + import energy_model as em + ctrl = Controller(link, calib, cfg) if fixed_op is None else None + src = cfg.src_bitrate_bps + energy = delivered = offered = 0.0 + prev_txagc = 32 + ops = [] + for t, pl in enumerate(schedule): + if fixed_op is None: + recv_reported = pl + calib.gain_db(prev_txagc) # VRX feedback (1-tick lag) + op = ctrl.update(recv_reported, prev_txagc, now_ms=t * 100) + if op is None: # shed layer (not base) + prev_txagc = 0 + ops.append(None) + offered += src * dt_s + continue + else: + op = fixed_op + true_recv = pl + calib.gain_db(op.txagc) + af = em.airtime_fraction(op.tx(), src, op.overhead, cfg.payload_bytes, calib) + # channel overload (can't carry the offered bitrate) delivers nothing + deliver = 0.0 if af > 1.0 else link.p_deliver(true_recv, op.mcs, op.overhead) + energy += em.avg_power_w(op.tx(), af, calib) * dt_s + delivered += src * deliver * dt_s + offered += src * dt_s + prev_txagc = op.txagc + ops.append(op) + return energy, delivered, offered, ops + + +def robust_baseline(link, calib, cfg, worst_free_snr, margin_db=3.0): + """The over-provisioned 'set-and-forget' profile a cautious operator flies: + the most-robust row (lowest snr_req: low MCS + heavy FEC) resolved with a + safety margin at the worst range -> high power. Guarantees the SLA across the + whole flight, and wastes most of it when close.""" + rows = op_table.build_link_rows(link, cfg.target, cfg.mcs_set, cfg.overhead_set) + # most-robust (lowest snr_req) row that is FEASIBLE (finite e_bit = can carry + # the bitrate AND a TXAGC reaches its snr_req) at the worst range. + feasible = [] + for r in sorted(rows, key=lambda r: r.snr_req): + op = op_table.resolve(r, worst_free_snr, calib, link, cfg.payload_bytes, + cfg.src_bitrate_bps, margin_db) + if op and op.e_bit < float("inf") and op.p_deliver >= cfg.target: + feasible.append(op) + return feasible[0] if feasible else op_table.MAX_RANGE + + +def main(): + link = lm.LinkModel(trials=1200) + calib = em.load_calibration() + cfg = ControllerConfig(target=0.99, k=8, payload_bytes=1024, + src_bitrate_bps=4e6, allow_shed=False) + sched = path_loss_schedule(200) + worst = min(sched) + + base_ctrl = Controller(link, calib, cfg) + emin = base_ctrl._best(worst, cfg.margin_db) or op_table.MAX_RANGE # energy-aware static + robust = robust_baseline(link, calib, cfg, worst) # over-provisioned + + e_ad, d_ad, o_ad, ops = run(link, calib, cfg, sched) + e_em, d_em, o_em, _ = run(link, calib, cfg, sched, fixed_op=emin) + e_rb, d_rb, o_rb, _ = run(link, calib, cfg, sched, fixed_op=robust) + + def ebit(e, d): + return e / d if d else float("inf") + eb_ad, eb_em, eb_rb = ebit(e_ad, d_ad), ebit(e_em, d_em), ebit(e_rb, d_rb) + save_em = 1 - eb_ad / eb_em + save_rb = 1 - eb_ad / eb_rb + # disruptive changes = MCS/FEC (TXAGC tracks freely; it's the cheap power lever) + changes = sum(1 for a, b in zip(ops, ops[1:]) + if a and b and (a.mcs, a.overhead) != (b.mcs, b.overhead)) + + print(f"ADAPTIVE : E/bit={eb_ad*1e9:6.1f} nJ delivery={d_ad/o_ad:.3f}") + print(f"FIXED energy-min: E/bit={eb_em*1e9:6.1f} nJ delivery={d_em/o_em:.3f} " + f"(MCS{emin.mcs} ov{emin.overhead} txagc{emin.txagc}) -> SAVED {save_em*100:.1f}%") + print(f"FIXED robust : E/bit={eb_rb*1e9:6.1f} nJ delivery={d_rb/o_rb:.3f} " + f"(MCS{robust.mcs} ov{robust.overhead} txagc{robust.txagc}) -> SAVED {save_rb*100:.1f}%") + print(f"config changes over {len(sched)} ticks: {changes}") + return {"save_vs_emin": save_em, "save_vs_robust": save_rb, + "delivery": d_ad / o_ad, "changes": changes} + + +if __name__ == "__main__": + main() + + diff --git a/tests/svc_uep_onair.sh b/tests/svc_uep_onair.sh index 52fa74f..bb12e54 100644 --- a/tests/svc_uep_onair.sh +++ b/tests/svc_uep_onair.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash # On-air verification of SvcTxDemo's TID -> TxMode UEP mapping. # -# Synthetic HEVC NALs (tests/gen_svc_nals.py, ratio 1:4:8:16 = IDR:T0:T1:T2) are -# injected by the 8812; the 8814 kernel monitor witness decodes the per-frame -# MCS. The decoded histogram should track the default_policy ladder -# (critical=MCS0, T0=MCS1, T1=MCS4, T2=MCS7) in the same 1:4:8:16 proportion. +# Synthetic HEVC NALs (tests/gen_svc_nals.py: 4:8:16 T0/T1/T2 per GOP plus an +# IDR access unit — VPS/SPS/PPS/IDR — every other GOP) are injected by the 8812; +# the 8814 kernel monitor witness decodes the per-frame MCS. The decoded +# histogram should track the default_policy ladder (critical=MCS0, T0=MCS1, +# T1=MCS4, T2=MCS7), enhancement layers dominating the count. # # ch6 (2.4 GHz, low current) avoids the USB Vbus-sag gotcha; a fresh power-cycle # is taken first. Run: sudo bash tests/svc_uep_onair.sh diff --git a/tools/precoder/README.md b/tools/precoder/README.md index 0903a1f..50b0479 100644 --- a/tools/precoder/README.md +++ b/tools/precoder/README.md @@ -38,7 +38,8 @@ Phase-B SDR extra (optional): `uv sync --extra sdr`. | `test_pipeline.py` | pytest: scrambler/BCC/interleaver KATs + pipeline round-trips | | `stream_fec*.py` | outer erasure codes: RaptorQ (`_raptorq`), RLC (`_rlc`), **Reed-Solomon (`_rs`)**; `stream_fec.py` dispatches by `FecConfig.scheme` | | `fec_subblock.py` | **sub-block integrity (SBI)** — per-sub-block CRC so a kept-corrupt frame yields its surviving symbols as erasures, not a whole-frame loss | -| `svc_uep_fec.py` | per-SVC-layer FEC-rate UEP (heavy FEC on base/IDR, light on enhancement) — the app-FEC half of cross-layer UEP | +| `svc_uep_fec.py` | per-SVC-layer FEC-rate UEP (heavy FEC on base/IDR, light on enhancement) — the app-FEC half of cross-layer UEP; `fragment=True` splits real-sized NALs into FEC packets | +| `svc_pipeline.py` | end-to-end SVC-HEVC UEP sim: synthetic HEVC (`tests/gen_svc_nals.py`) → per-layer FEC+SBI → per-layer (MCS,SNR) channel → decode; `run_svc_pipeline_adaptive` closes the loop with `SvcController` (per-layer MCS, shedding, shared power) | | `fec_fusion_sim.py` | offline sim quantifying the SBI gain + picking the sub-block size, no hardware | | `fused_fec_link.py` + `fused_fec_tx/rx.py` | chip↔chip RS+SBI sender/receiver + CLIs (RX reports baseline-vs-SBI gain) | @@ -68,6 +69,26 @@ sudo bash ../../tests/fused_fec_onair.sh # reports FUSED-FEC GAIN The PHY-MCS half of SVC unequal error protection lives in C++ (`txdemo/svc_tx_demo/svc_tx.h`, `DEVOURER_SVC_LADDER`); `svc_uep_fec.py` adds the matching FEC-rate ladder so base/IDR layers get robust MCS **and** heavy FEC. +`svc_pipeline.py` runs both halves end-to-end against a synthetic HEVC stream and +shows the graceful-degradation staircase (T2 sheds first, base/IDR last): + +```sh +uv run python svc_pipeline.py # per-layer delivery across an SNR sweep +``` + +The whole `tools/precoder` suite — including the SVC pipeline staircase and the +closed-loop adaptive variant — runs headlessly in CI +(`.github/workflows/precoder-tests.yml`). + +## Adaptive video link + +The energy-minimizing adaptive link (drone VTX ↔ ground VRX) — controller, +energy/link models, feedback + rendezvous protocol, and how it differs from +OpenIPC's `alink` and other adaptive systems — is documented in +[`docs/adaptive-link.md`](../../docs/adaptive-link.md). Its modules +(`energy_model.py`, `link_model.py`, `op_table.py`, `controller.py`, +`rc_proto.py`, `score.py`, `rendezvous.py`, `adaptive_link.py`) and the offline +energy headline (`tests/sim_loop.py`) all live alongside this suite. ## End-to-end recipe diff --git a/tools/precoder/adaptive_link.py b/tools/precoder/adaptive_link.py new file mode 100644 index 0000000..a7da453 --- /dev/null +++ b/tools/precoder/adaptive_link.py @@ -0,0 +1,377 @@ +"""Adaptive-link orchestrator — ties the controller, scorer, protocol, and +rendezvous into the VTX and VRX control loops. + +This is the policy plane (the mechanical knobs live in the C++ duplex binary). The +two roles: + + AdaptiveVrx (ground): consumes the VTX's video frames ( RSSI/ + SNR/crc/seq), scores the link, runs the energy-min controller to pick the + operating point, and emits RCF feedback (or DISC beacons when the VTX is + lost) ~10 Hz. + AdaptiveVtx (drone): consumes RCF/DISC, applies the operating point to the live + knobs (TX power, per-layer MCS ladder, FEC overhead), and runs the failsafe + + discovery state machine. + +The radio I/O (a subprocess StreamDuplexDemo) is abstracted; `selftest()` wires a +VTX and a VRX through a simulated channel (link_model) so the whole closed loop is +deterministically testable without hardware. The CLI (`--role vtx|vrx`) wires the +real duplex binary; that path is exercised on-air by tests/adaptive_onair.sh. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import rc_proto as rp +import rendezvous as rz +from controller import Controller, ControllerConfig +from score import ScoreWindow, ScoreConfig +import op_table + + +def op_to_ladder(op) -> str: + """Map a controller OpPoint to a DEVOURER_SVC_LADDER spec (per-layer MCS). + Base/critical fly the chosen (robust) MCS; enhancement steps up from there.""" + m = op.mcs + e = min(7, m + 2) + bw = op.bw + return (f"CRIT=MCS{m}/{bw};T0=MCS{m}/{bw};" + f"T1=MCS{min(7, m + 1)}/{bw};T2=MCS{e}/{bw}") + + +def overhead_to_16ths(ov: float) -> int: + return max(1, min(16, round(ov * 16))) + + +# --------------------------------------------------------------------------- # +# VRX (ground) +# --------------------------------------------------------------------------- # +class AdaptiveVrx: + def __init__(self, link, calib, vtx_id: int, + ctrl_cfg: ControllerConfig | None = None, + score_cfg: ScoreConfig | None = None, + op_channel: int = 6, feedback_period_ms: int = 100): + self.vtx_id = vtx_id + self.ctrl = Controller(link, calib, ctrl_cfg or ControllerConfig()) + self.win = ScoreWindow(score_cfg) + self.rz = rz.VrxRendezvous(rz.VrxConfig(vtx_id=vtx_id, op_channel=op_channel)) + self.feedback_period_ms = feedback_period_ms + self._last_fb_ms = -1 << 60 + self._seq = 0 + self.cur_op = op_table.MAX_RANGE + self._cur_txagc = 32 + + def on_video(self, rssi: float, snr: float, crc_err: bool, seq: int, + now_ms: float, residual_loss: float | None = None) -> None: + self.win.add_frame(rssi, snr, crc_err, seq, now_ms / 1000.0) + self.rz.feed_video(now_ms) + self._residual = residual_loss + + def on_disc_ack(self, buf: bytes, now_ms: float) -> None: + ack = rp.parse_disc_ack(buf) + if ack: + self.rz.feed_disc_ack(ack, now_ms) + + def step(self, now_ms: float) -> bytes | None: + """Return a frame to TX back to the VTX (RCF or DISC), or None.""" + act = self.rz.tick(now_ms) + if act == rz.A_BEACON: + return rp.pack_disc(self.rz.beacon()) + if act != rz.A_TX_FEEDBACK: + return None + if now_ms - self._last_fb_ms < self.feedback_period_ms: + return None + self._last_fb_ms = now_ms + snr = self.win.snr_estimate() + if snr is not None: + op = self.ctrl.update(snr, self._cur_txagc, now_ms) + if op is not None: + self.cur_op = op + self._cur_txagc = op.txagc + self._seq = (self._seq + 1) & 0xFFFF + # profile carries the GS-chosen base MCS (0..7); the VTX builds its SVC + # ladder from it (base/critical at this MCS, enhancement steps up). + rcf = rp.Rcf(vtx_id=self.vtx_id, seq=self._seq, ack_seq=self.win.ack_seq(), + profile=self.cur_op.mcs, + score=self.win.score(getattr(self, "_residual", None)), + pwr_idx=self.cur_op.txagc, + fec_overhead_16ths=overhead_to_16ths(self.cur_op.overhead), + layer_delivery=(int(100 * (1 - self.win.seq_gap_loss())),)) + return rp.pack_rcf(rcf) + + +# --------------------------------------------------------------------------- # +# VTX (drone) +# --------------------------------------------------------------------------- # +@dataclass +class TxState: + txagc: int = 32 + overhead: float = 0.25 + ladder: str = "CRIT=MCS1/20;T0=MCS2/20;T1=MCS4/20;T2=MCS5/20" + failsafe: bool = False + + +class AdaptiveVtx: + def __init__(self, vtx_id: int, vtx_cfg: rz.VtxConfig | None = None, + failsafe_txagc: int = 63, failsafe_overhead: float = 1.0, + failsafe_ladder: str | None = None): + self.vtx_id = vtx_id + self.rz = rz.VtxRendezvous(vtx_cfg or rz.VtxConfig(vtx_id=vtx_id)) + self.state = TxState() + self.failsafe_txagc = failsafe_txagc + self.failsafe_overhead = failsafe_overhead + self.failsafe_ladder = failsafe_ladder or \ + "CRIT=MCS0/20/LDPC;T0=MCS0/20/LDPC;T1=MCS0/20;T2=MCS0/20" + + def on_rc_frame(self, buf: bytes, now_ms: float) -> bytes | None: + """Process an inbound control frame. Applies an RCF (updates TxState) or + answers a DISC with a DISC_ACK to TX. Returns a frame to TX, or None.""" + t = rp.frame_type(buf) + if t == rp.T_RCF: + r = rp.parse_rcf(buf) + if r is None or r.vtx_id != self.vtx_id: + return None + self.rz.feed_rc(now_ms) + if r.pwr_idx != rp.PWR_NO_CHANGE: + self.state.txagc = r.pwr_idx + self.state.overhead = r.fec_overhead + self.state.failsafe = False + # MCS ladder follows the GS-chosen base MCS in `profile`. + m = max(0, min(7, r.profile)) + bw = 20 + self.state.ladder = (f"CRIT=MCS{m}/{bw};T0=MCS{m}/{bw};" + f"T1=MCS{min(7, m + 1)}/{bw};T2=MCS{min(7, m + 2)}/{bw}") + return None + if t == rp.T_DISC: + d = rp.parse_disc(buf) + if d is None: + return None + ack = self.rz.feed_disc(d, now_ms) + return rp.pack_disc_ack(ack) if ack else None + return None + + def step(self, now_ms: float) -> str: + """Advance the failsafe/discovery SM; clamp TxState to failsafe when lost. + Returns the rendezvous action (tx_video / failsafe / listen / idle).""" + act = self.rz.tick(now_ms) + if act == rz.A_FAILSAFE: + self.state = TxState(txagc=self.failsafe_txagc, + overhead=self.failsafe_overhead, + ladder=self.failsafe_ladder, failsafe=True) + return act + + def apply_ladder_from_op(self, op) -> None: + self.state.ladder = op_to_ladder(op) + + +# --------------------------------------------------------------------------- # +# In-process closed-loop self-test (no radio) — validates the control loop. +# --------------------------------------------------------------------------- # +def selftest(verbose: bool = False): + """Wire a VTX and VRX through a simulated channel; sweep path loss and assert + the loop holds delivery while the commanded TX power/FEC track the link.""" + import energy_model as em + import link_model as lm + + link = lm.LinkModel(trials=400) + calib = em.load_calibration() + vtx = AdaptiveVtx(0xABCD) + vrx = AdaptiveVrx(link, calib, 0xABCD, + ctrl_cfg=ControllerConfig(target=0.99, allow_shed=False, + src_bitrate_bps=4e6)) + # path loss (free SNR at txagc 0) triangle: starts FAR, close at mid, far again + sched = [35 - abs(t - 100) * 0.45 for t in range(200)] + # the VTX transmits at the operating point the VRX last commanded (the whole + # (mcs, txagc, overhead) applied atomically, with a uniform 1-tick lag). + applied = vrx.cur_op + deliveries, pwr_at = [], [] + for t, pl in enumerate(sched): + now = t * 100 + recv = pl + calib.gain_db(applied.txagc) + deliver = link.p_deliver(recv, applied.mcs, applied.overhead) + deliveries.append(deliver) + vrx.on_video(rssi=-50 + recv, snr=recv, crc_err=(deliver < 0.5), seq=t, now_ms=now) + fb = vrx.step(now) + if fb is not None: + vtx.on_rc_frame(fb, now) # VTX applies pwr/fec from RCF + applied = vrx.cur_op # + the chosen MCS (GS-decides) + pwr_at.append((t, applied.txagc)) + if verbose and t % 20 == 0: + print(f"t={t} pl={pl:+5.1f} recv={recv:5.1f} MCS{applied.mcs} " + f"txagc={applied.txagc:2d} ov={applied.overhead} deliver={deliver:.3f}") + warm = len(deliveries) // 10 # ignore cold-start ticks + avg_deliver = sum(deliveries[warm:]) / len(deliveries[warm:]) + close_txagc = min(p for _, p in pwr_at if 80 <= _ <= 120) # closest stretch + far_txagc = max(p for _, p in pwr_at if _ <= 20 or _ >= 180) # far stretches + return {"avg_deliver": avg_deliver, "close_txagc": close_txagc, + "far_txagc": far_txagc, "n_cmds": len(pwr_at)} + + +# --------------------------------------------------------------------------- # +# Live I/O: drive a real StreamDuplexDemo subprocess (exercised on-air by +# tests/adaptive_onair.sh). The classes above hold all the policy; this is plumbing. +# --------------------------------------------------------------------------- # +import re +import struct + +_STREAM_RE = re.compile( + rb"rate=(?P\d+).*?crc_err=(?P\d+).*?" + rb"rssi=(?P-?\d+),(?P-?\d+).*?snr=(?P-?\d+),(?P-?\d+).*?" + rb"seq=(?P\d+).*?body=(?P[0-9a-fA-F]*)") + + +def ctl_frame(op: int, payload: bytes = b"") -> bytes: + """Encode a stdin control TLV for the duplex binary's SET_* opcodes.""" + body = bytes([op]) + payload + return struct.pack(" bytes: + return struct.pack(" float: + import time + return time.monotonic() * 1000.0 + + +def run_vrx(proc, link, calib, vtx_id, channel, feedback_period_ms=100): + """Read the VTX's video frames, score, run the controller, TX feedback.""" + vrx = AdaptiveVrx(link, calib, vtx_id, + ctrl_cfg=ControllerConfig(target=0.99, allow_shed=False), + op_channel=channel, feedback_period_ms=feedback_period_ms) + import threading + + def reader(): + for line in proc.stdout: + m = _STREAM_RE.search(line) + if not m: + continue + body = bytes.fromhex(m.group("body").decode()) + if rp.frame_type(body) == rp.T_DISC_ACK: + vrx.on_disc_ack(body, _now_ms()) + continue + snr = max(int(m.group("snr")), int(m.group("snr2"))) + rssi = max(int(m.group("rssi")), int(m.group("rssi2"))) + vrx.on_video(rssi, snr, int(m.group("crc")) != 0, int(m.group("seq")), + _now_ms()) + threading.Thread(target=reader, daemon=True).start() + import sys + import time + last_log = 0.0 + while True: + now = _now_ms() + fb = vrx.step(now) + if fb is not None: + proc.stdin.write(psdu_frame(fb)) + proc.stdin.flush() + if now - last_log > 1000: # 1 Hz trajectory log (to stderr) + last_log = now + op = vrx.cur_op + sys.stderr.write( + f"state={vrx.rz.state} snr={vrx.win.snr_estimate()} " + f"score={vrx.win.score()} -> MCS{op.mcs} ov{op.overhead} " + f"txagc{op.txagc} frames={vrx.win.n()}\n") + sys.stderr.flush() + time.sleep(0.01) + + +def run_vtx(proc, vtx_id, video_path, channel): + """Stream video; apply inbound RCF/DISC to the live knobs via control ops.""" + vtx = AdaptiveVtx(vtx_id) + import threading + + def reader(): + for line in proc.stdout: + m = _STREAM_RE.search(line) + if not m: + continue + body = bytes.fromhex(m.group("body").decode()) + t = rp.frame_type(body) + if t not in (rp.T_RCF, rp.T_DISC): + continue + ack = vtx.on_rc_frame(body, _now_ms()) + if ack is not None: # DISC_ACK to TX + proc.stdin.write(psdu_frame(ack)); proc.stdin.flush() + if t == rp.T_RCF: # apply the new operating point + proc.stdin.write(ctl_frame(SET_PWR, bytes([vtx.state.txagc]))) + base = f"MCS{vtx.state.ladder.split('CRIT=MCS')[1].split('/')[0]}" + proc.stdin.write(ctl_frame(SET_RATE, base.encode())) + proc.stdin.flush() + threading.Thread(target=reader, daemon=True).start() + + import sys + import time + vid = open(video_path, "rb") if video_path else None + last_log = 0.0 + cur_chan = channel + prev_state = vtx.rz.state + while True: + now = _now_ms() + act = vtx.step(now) + # rendezvous channel switching: hop to the discovery channel on entering + # DISCOVERY; hop to the agreed op channel once rendezvous completes. + if vtx.rz.state != prev_state: + want = (vtx.rz.cfg.discovery_channel if vtx.rz.state == rz.DISCOVERY + else vtx.rz.op_channel if vtx.rz.state == rz.RC_OK else cur_chan) + if want != cur_chan: + proc.stdin.write(ctl_frame(SET_CHAN, bytes([want, 0, 0]))) # 20 MHz + proc.stdin.flush() + cur_chan = want + prev_state = vtx.rz.state + if act == rz.A_FAILSAFE: + proc.stdin.write(ctl_frame(SET_PWR, bytes([vtx.state.txagc]))) + proc.stdin.flush() + if vid is not None and act in (rz.A_TX_VIDEO, rz.A_FAILSAFE): + chunk = vid.read(1024) + if not chunk: + vid.seek(0); chunk = vid.read(1024) + proc.stdin.write(psdu_frame(chunk)); proc.stdin.flush() + if now - last_log > 1000: + last_log = now + sys.stderr.write(f"state={vtx.rz.state} txagc={vtx.state.txagc} " + f"ov={vtx.state.overhead} ladder={vtx.state.ladder} " + f"failsafe={vtx.state.failsafe}\n") + sys.stderr.flush() + time.sleep(0.002) + + +def main(): + import argparse + import os + import subprocess + import energy_model as em + import link_model as lm + + ap = argparse.ArgumentParser(description="adaptive-link orchestrator") + ap.add_argument("--role", choices=["vtx", "vrx", "selftest"], required=True) + ap.add_argument("--pid", default="0x8812") + ap.add_argument("--channel", type=int, default=6) + ap.add_argument("--vtx-id", type=lambda x: int(x, 0), default=0xABCD) + ap.add_argument("--video", help="VTX video source file (length-agnostic bytes)") + ap.add_argument("--duplex", default=os.path.expanduser("~/git/devourer/build/StreamDuplexDemo")) + ap.add_argument("--link-calib"); ap.add_argument("--energy-calib") + a = ap.parse_args() + + if a.role == "selftest": + import json + print(json.dumps(selftest(verbose=True), indent=2)) + return + + env = dict(os.environ, DEVOURER_PID=a.pid, DEVOURER_CHANNEL=str(a.channel), + DEVOURER_RX_KEEP_CORRUPTED="1") + proc = subprocess.Popen([a.duplex], env=env, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, bufsize=0) + link = lm.LinkModel(calib_path=a.link_calib) + calib = em.load_calibration(a.energy_calib) + if a.role == "vrx": + run_vrx(proc, link, calib, a.vtx_id, a.channel) + else: + run_vtx(proc, a.vtx_id, a.video, a.channel) + + +if __name__ == "__main__": + main() diff --git a/tools/precoder/controller.py b/tools/precoder/controller.py new file mode 100644 index 0000000..4473703 --- /dev/null +++ b/tools/precoder/controller.py @@ -0,0 +1,212 @@ +"""Energy-minimising adaptive controller — the dual of OpenIPC alink. + +alink picks the highest-quality profile a link budget allows. This picks the +**lowest energy-per-delivered-bit operating point that still meets the delivery +SLA** — bank link headroom as Watts saved, not as throughput. Per feedback tick +(~100 ms) it: smooths the VRX SNR, estimates path loss, and for every (MCS, FEC) +row chooses the minimum TXAGC that clears the row's `snr_req`, then takes the +row with the smallest e_bit. Joint rate+power: cheapest power per rate, cheapest +rate overall. + +Hysteresis is asymmetric (alink-style): enter a cheaper/fragile row only with an +SNR margin and a rate-limit (slow up); fall to a more robust row immediately when +the current one stops clearing (fast down), then hold before climbing again. +Feedback loss -> MAX_RANGE failsafe. One controller per SVC layer (`stream_id`): +base layers get a high delivery target and may use expensive rows; enhancement +layers get a lower target and are SHED (return None) when no row clears — the +graceful UEP staircase. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import op_table +from op_table import OpPoint, build_link_rows, resolve, MAX_RANGE + + +@dataclass +class ControllerConfig: + target: float = 0.99 # delivery SLA for this layer's blocks + k: int = 8 # FEC block source symbols (protocol/FEC) + payload_bytes: int = 1024 + src_bitrate_bps: float = 4e6 # offered source (video) bitrate for this layer + mcs_set: tuple = tuple(range(8)) + overhead_set: tuple = (0.10, 0.25, 0.50, 0.75, 1.00) + bw: int = 20 + sbi: bool = True + ema_alpha: float = 0.3 # SNR EWMA weight when SNR is RISING (cautious) + ema_alpha_down: float = 0.8 # ...when FALLING (react fast -> raise power in time) + margin_db: float = 2.0 # hysteresis: extra SNR to ENTER a row (slow up) + min_between_changes_ms: int = 150 + hold_after_downgrade_ms: int = 4000 + improve_frac: float = 0.03 # only switch up if >=3% cheaper (vs churn) + feedback_timeout_ms: int = 1000 # no feedback this long -> failsafe + allow_shed: bool = True # enhancement layers may be deallocated + + +class Controller: + def __init__(self, link, calib, cfg: ControllerConfig | None = None): + self.link = link + self.calib = calib + self.cfg = cfg or ControllerConfig() + self.rows = build_link_rows(link, self.cfg.target, self.cfg.mcs_set, + self.cfg.overhead_set, self.cfg.bw, sbi=self.cfg.sbi) + self.snr_ema: float | None = None + self.cur: OpPoint | None = None + self.shed = False + self._last_change_ms = -1 << 60 + self._last_downgrade_ms = -1 << 60 + self._last_feedback_ms = -1 << 60 + + # --- estimation -------------------------------------------------------- # + def _path_loss(self, reported_snr: float, reported_txagc: int) -> float: + """Channel SNR with TX power removed: snr = path_loss + gain(txagc).""" + return reported_snr - self.calib.gain_db(reported_txagc) + + def _best(self, path_loss: float, margin: float) -> OpPoint | None: + best = None + for r in self.rows: + op = resolve(r, path_loss, self.calib, self.link, + self.cfg.payload_bytes, self.cfg.src_bitrate_bps, margin) + # skip rows that can't clear the SLA OR can't carry the bitrate (e_bit=inf) + if op is None or op.p_deliver < self.cfg.target or op.e_bit == float("inf"): + continue + if best is None or op.e_bit < best.e_bit: + best = op + return best + + # --- main tick --------------------------------------------------------- # + def update(self, reported_snr: float, reported_txagc: int, + now_ms: float) -> OpPoint | None: + """Apply one VRX feedback sample; return the chosen op point (or None if + this layer is shed).""" + self._last_feedback_ms = now_ms + # EWMA the PATH LOSS (channel SNR with TX power removed), not the SNR — + # SNR moves with our own TXAGC, path loss doesn't. Asymmetric: track a + # worsening channel fast (raise power in time), an improving one slow. + pl_inst = self._path_loss(reported_snr, reported_txagc) + if self.snr_ema is None: + self.snr_ema = pl_inst + else: + a = self.cfg.ema_alpha_down if pl_inst < self.snr_ema else self.cfg.ema_alpha + self.snr_ema = (1 - a) * self.snr_ema + a * pl_inst + path_loss = self.snr_ema + + # Is the current pick still clearing (no margin = fast-down threshold)? + cur_ok = False + if self.cur is not None and not self.shed: + cur_now = resolve(op_table.LinkRow(self.cur.mode, self.cur.mcs, + self.cur.bw, self.cur.sgi, self.cur.overhead, + self.cur.snr_req), path_loss, self.calib, self.link, + self.cfg.payload_bytes, self.cfg.src_bitrate_bps, 0.0) + cur_ok = cur_now is not None and cur_now.p_deliver >= self.cfg.target + if cur_ok: + self.cur = cur_now # refresh txagc/e_bit at new path loss + + cand = self._best(path_loss, self.cfg.margin_db) # candidate with margin + + if cand is None: + # nothing clears even with margin: keep current if it still works, + # else shed (enhancement) or fall to MAX_RANGE (base). + if cur_ok: + return self.cur + if self.cfg.allow_shed: + self.shed = True + self.cur = None + return None + self.cur = self._failsafe_point() + return self.cur + + self.shed = False + if self.cur is None or self.shed: + return self._commit(cand, now_ms) + + # rate-limit + if now_ms - self._last_change_ms < self.cfg.min_between_changes_ms and cur_ok: + return self.cur + is_upgrade = cand.e_bit < self.cur.e_bit + if is_upgrade: + # slow up: only to a meaningfully cheaper point, after the post-downgrade + # hold, while the current point still works (it cleared with margin). + if cur_ok and cand.e_bit > self.cur.e_bit * (1 - self.cfg.improve_frac): + return self.cur + if cur_ok and now_ms - self._last_downgrade_ms < self.cfg.hold_after_downgrade_ms: + return self.cur + return self._commit(cand, now_ms) + # cand is same-or-more-expensive. Keep the current point unless it is + # actually FAILING (fast down only on real degradation — no needless churn). + if not cur_ok: + self._last_downgrade_ms = now_ms + return self._commit(cand, now_ms) + return self.cur + + def on_tick(self, now_ms: float) -> OpPoint | None: + """Call between feedback samples to enforce the failsafe timeout.""" + if now_ms - self._last_feedback_ms > self.cfg.feedback_timeout_ms: + self.shed = False + self.cur = self._failsafe_point() + return self.cur + + def _failsafe_point(self) -> OpPoint: + return MAX_RANGE + + def _commit(self, op: OpPoint, now_ms: float) -> OpPoint: + self.cur = op + self._last_change_ms = now_ms + return op + + +# --------------------------------------------------------------------------- # +# SVC per-layer UEP: a bank of controllers, one per temporal layer. Base/IDR +# layers hold a high delivery target and never shed; enhancement layers carry a +# lower target, are restricted to cheap (high-MCS/low-FEC) rows, and SHED first +# under SNR stress — the graceful-degradation staircase. The PA is shared, so the +# commanded TX power is the max any active layer needs; per-layer MCS+FEC still +# differ (encoded in the SVC ladder). +# --------------------------------------------------------------------------- # +@dataclass +class LayerSpec: + stream_id: int + target: float + allow_shed: bool + src_bitrate_bps: float + overhead_set: tuple = (0.10, 0.25, 0.50, 0.75, 1.00) + + +def default_svc_layers() -> list: + """critical / T0 (base, protected) + T1 / T2 (enhancement, shed first). + Mirrors svc_uep_fec.default_uep_policy's intent on the control side.""" + return [ + LayerSpec(0, target=0.999, allow_shed=False, src_bitrate_bps=0.5e6), # critical/IDR + LayerSpec(1, target=0.99, allow_shed=False, src_bitrate_bps=1.0e6), # T0 base + LayerSpec(2, target=0.95, allow_shed=True, src_bitrate_bps=1.5e6, + overhead_set=(0.10, 0.25, 0.50)), # T1 + LayerSpec(3, target=0.90, allow_shed=True, src_bitrate_bps=2.0e6, + overhead_set=(0.10, 0.25)), # T2 + ] + + +class SvcController: + def __init__(self, link, calib, layers: list | None = None, **ctrl_kw): + self.layers = layers or default_svc_layers() + self.ctrls = {} + for L in self.layers: + cfg = ControllerConfig(target=L.target, allow_shed=L.allow_shed, + src_bitrate_bps=L.src_bitrate_bps, + overhead_set=L.overhead_set, **ctrl_kw) + self.ctrls[L.stream_id] = Controller(link, calib, cfg) + + def update(self, reported_snr: float, reported_txagc: int, now_ms: float): + """Returns (ops_by_sid, shared_txagc, active_sids). A None op = shed layer. + shared_txagc = the max TX power any active layer needs (one PA).""" + ops = {sid: c.update(reported_snr, reported_txagc, now_ms) + for sid, c in self.ctrls.items()} + present = [op for op in ops.values() if op is not None] + shared_txagc = max((op.txagc for op in present), default=63) + active = sorted(sid for sid, op in ops.items() if op is not None) + return ops, shared_txagc, active + + def on_tick(self, now_ms: float): + for c in self.ctrls.values(): + c.on_tick(now_ms) diff --git a/tools/precoder/energy_model.py b/tools/precoder/energy_model.py new file mode 100644 index 0000000..dad61e7 --- /dev/null +++ b/tools/precoder/energy_model.py @@ -0,0 +1,209 @@ +"""Energy model for the adaptive link — energy-per-delivered-bit on the RTL8812AU. + +The controller minimises energy/bit, where (per the bits-per-Joule literature) +**time-on-air is the dominant energy term and radiated power is a weak one**. +This module turns a transmit operating point (MCS, bandwidth, SGI, TXAGC index, +FEC k/overhead, payload size) plus a delivery probability into Joules-per- +delivered-bit: + + t_air = 8*L / R_phy(MCS, BW, SGI) # data on-air time + t_on = t_pre + t_air # whole frame on the air + t_slot = t_on + t_gap # frame period (incl. idle gap) + E_slot = P_circuit * t_slot + P_pa[idx] * t_on + E_block = (K + R) * E_slot # R = repair frames + E_bit = E_block / (K * 8 * L * P_deliver) # P_deliver from link_model + +`P_circuit` (LO + baseband + USB PHY) is paid over the *whole* slot, so duty and +MCS both matter; `P_pa[idx]` is paid only while on the air. The split is the +reason high MCS (short t_air) saves energy and TXAGC barely moves it. + +CALIBRATION ("model now, meter later"): the constants default to a documented +NOMINAL RTL8812AU-on-USB model. `tests/calibrate_energy.py` fits the real +`P_circuit` / `P_pa[idx]` from the thermal-gain + duty sweeps (and, when an inline +DC meter is present, anchors them to absolute Watts) and writes a JSON that +`load_calibration()` overlays. Relative energy *savings* are valid from the +nominal model alone; absolute Watts need the meter. + +numpy-free (imports into the GNU Radio / orchestrator env like fec_subblock). +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field + +# --------------------------------------------------------------------------- # +# 802.11 PHY data rates (Mbps). HT = 1 spatial stream, MCS 0..7. +# --------------------------------------------------------------------------- # +HT20_LGI_MBPS = [6.5, 13.0, 19.5, 26.0, 39.0, 52.0, 58.5, 65.0] +HT40_LGI_MBPS = [13.5, 27.0, 40.5, 54.0, 81.0, 108.0, 121.5, 135.0] +LEGACY_MBPS = {6: 6.0, 9: 9.0, 12: 12.0, 18: 18.0, 24: 24.0, 36: 36.0, 48: 48.0, + 54: 54.0} +SGI_FACTOR = 10.0 / 9.0 # short guard interval: 400 vs 800 ns -> x1.111 + + +def phy_rate_mbps(mode: str, mcs: int, bw: int = 20, sgi: bool = False) -> float: + """On-air PHY data rate. mode='ht' (mcs 0..7) or 'legacy' (mcs = Mbps).""" + if mode == "ht": + base = HT40_LGI_MBPS if bw == 40 else HT20_LGI_MBPS + if not (0 <= mcs < len(base)): + raise ValueError(f"HT mcs {mcs} out of range 0..7") + r = base[mcs] + elif mode == "legacy": + if mcs not in LEGACY_MBPS: + raise ValueError(f"legacy rate {mcs} not a valid OFDM rate") + r = LEGACY_MBPS[mcs] + else: + raise ValueError(f"unknown mode {mode!r}") + return r * (SGI_FACTOR if sgi else 1.0) + + +# --------------------------------------------------------------------------- # +# Nominal calibration. OVERRIDE with a metered JSON via load_calibration(). +# Values are an RTL8812AU-on-USB-2 order-of-magnitude model, NOT measured: +# p_circuit_w ~ idle/RX bus power (LO, baseband, USB) ~ 1.2 W +# p_pa_w[idx] ~ incremental PA DC draw vs TXAGC index, ~0.05..0.6 W, +# compressing near the top (a deliberately WEAK energy lever) +# t_pre_us ~ HT-mixed preamble + SIG on-air before the data symbols +# --------------------------------------------------------------------------- # +def _nominal_pa_curve() -> list[float]: + pa = [] + for idx in range(64): + x = idx / 63.0 + # PA DC draw (W) ON-AIR. These USB adapters are PA-heavy at high power + # (the 5 GHz Vbus-sag note), so the PA is a real chunk of the budget: + # ~0.1 W at idx 0 up to ~1.5 W at idx 63, compressing near the top. + pa.append(round(0.10 + 1.40 * (x ** 0.8), 4)) + return pa + + +def _nominal_gain_curve(g_max_db: float = 25.0) -> list[float]: + """Radiated-power gain (dB) vs TXAGC index — the LINK lever. Concave + (PA compresses near the top): 0 dB at idx 0, ~g_max at idx 63. Calibrated + from the SDR `dbfs` sweep in calibrate_energy.py.""" + import math + denom = 1.0 - math.exp(-3.0) + return [round(g_max_db * (1.0 - math.exp(-3.0 * idx / 63.0)) / denom, 3) + for idx in range(64)] + + +DEFAULT_CALIB = { + "source": "nominal", # set to "metered" by calibrate_energy.py + "metered_watts": False, # True once anchored to an inline DC meter + "p_baseline_w": 0.7, # LO+baseband+USB+RX, ALWAYS on (the floor) + "p_pa_w": _nominal_pa_curve(), # PA, paid only over airtime + "txagc_gain_db": _nominal_gain_curve(), + "t_pre_us": 40.0, # per-frame preamble+SIG on-air overhead +} + + +@dataclass +class Calib: + p_baseline_w: float + p_pa_w: list[float] + txagc_gain_db: list[float] + t_pre_us: float + source: str = "nominal" + metered_watts: bool = False + + def pa_w(self, idx: int) -> float: + return self.p_pa_w[max(0, min(63, int(idx)))] + + def gain_db(self, idx: int) -> float: + """Radiated gain (dB) at TXAGC idx — how much received SNR it buys.""" + return self.txagc_gain_db[max(0, min(63, int(idx)))] + + def min_txagc_for_gain(self, need_db: float) -> int | None: + """Smallest TXAGC index giving >= need_db radiated gain, or None.""" + for idx in range(64): + if self.txagc_gain_db[idx] >= need_db: + return idx + return None + + +def load_calibration(path: str | None = None) -> Calib: + """Nominal model overlaid by a calibration JSON (from calibrate_energy.py).""" + d = dict(DEFAULT_CALIB) + if path: + with open(path) as f: + d.update(json.load(f)) + return Calib(p_baseline_w=d["p_baseline_w"], p_pa_w=list(d["p_pa_w"]), + txagc_gain_db=list(d.get("txagc_gain_db", DEFAULT_CALIB["txagc_gain_db"])), + t_pre_us=d["t_pre_us"], + source=d.get("source", "nominal"), + metered_watts=bool(d.get("metered_watts", False))) + + +# --------------------------------------------------------------------------- # +# Operating point + energy +# --------------------------------------------------------------------------- # +@dataclass(frozen=True) +class TxPoint: + """One transmit operating point (a row of the controller's op-table).""" + mode: str = "ht" # 'ht' | 'legacy' + mcs: int = 0 # HT 0..7, or legacy Mbps + bw: int = 20 # 20 | 40 + sgi: bool = False + txagc: int = 32 # TXAGC index 0..63 (SetTxPowerOverride) + + +# --------------------------------------------------------------------------- # +# Stream energy: to deliver a fixed video bitrate, the strategy sets the AIRTIME +# fraction (MCS + FEC) and the PA power (TXAGC). P_baseline is always on (the +# floor adaptation can't remove without sleeping); the savable energy is the +# airtime x P_pa term. This is the physically-correct framing for "energy to +# carry a video stream" — the per-frame/idle-gap view drowns the levers. +# --------------------------------------------------------------------------- # +def phy_rate_eff_bps(p: TxPoint, payload_bytes: int, calib: Calib) -> float: + """Effective goodput (bits/s) including the per-frame preamble overhead.""" + r_bps = phy_rate_mbps(p.mode, p.mcs, p.bw, p.sgi) * 1e6 + t_air = (8.0 * payload_bytes) / r_bps + t_on = calib.t_pre_us * 1e-6 + t_air + return (8.0 * payload_bytes) / t_on + + +def airtime_fraction(p: TxPoint, src_bitrate_bps: float, overhead: float, + payload_bytes: int, calib: Calib) -> float: + """Fraction of wall time the PA+TX path is on-air to carry `src_bitrate_bps` + of source at this MCS/FEC. >1.0 means the channel can't carry it (infeasible).""" + on_air_bps = src_bitrate_bps * (1.0 + overhead) + return on_air_bps / phy_rate_eff_bps(p, payload_bytes, calib) + + +def avg_power_w(p: TxPoint, airtime_frac: float, calib: Calib) -> float: + """Average DC power (W): baseline always on + PA only over airtime.""" + return calib.p_baseline_w + min(1.0, airtime_frac) * calib.pa_w(p.txagc) + + +def energy_per_delivered_bit(p: TxPoint, src_bitrate_bps: float, overhead: float, + payload_bytes: int, p_deliver: float, + calib: Calib) -> float: + """J per delivered SOURCE bit, carrying `src_bitrate_bps` of video. + + E_bit = (P_baseline + airtime*P_pa) / (src_bitrate * P_deliver) + + Higher MCS / less FEC -> less airtime -> less PA energy; lower TXAGC -> less + P_pa; lost blocks (p_deliver<1) -> smaller denominator -> energy EXPENSIVE + (so energy-min avoids the FCS cliff). +inf if infeasible (airtime>1) or + nothing delivered.""" + if p_deliver <= 0.0: + return float("inf") + af = airtime_fraction(p, src_bitrate_bps, overhead, payload_bytes, calib) + if af > 1.0: + return float("inf") # channel can't carry the offered bitrate + p_avg = avg_power_w(p, af, calib) + return p_avg / (src_bitrate_bps * p_deliver) + + +if __name__ == "__main__": # quick sanity dump + cal = load_calibration() + src = 4e6 # 4 Mbps video + print(f"calibration source={cal.source} metered={cal.metered_watts}; " + f"carrying {src/1e6:g} Mbps video, ov=0.25, txagc=32") + for mcs in range(8): + pt = TxPoint(mcs=mcs, txagc=32) + af = airtime_fraction(pt, src, 0.25, 1024, cal) + eb = energy_per_delivered_bit(pt, src, 0.25, 1024, 1.0, cal) + print(f" HT MCS{mcs}: rate={phy_rate_mbps('ht', mcs):.1f}Mbps " + f"airtime={af:.3f} P_avg={avg_power_w(pt, af, cal):.2f}W " + f"E/bit={eb*1e9:.2f} nJ (clean)") diff --git a/tools/precoder/link_model.py b/tools/precoder/link_model.py new file mode 100644 index 0000000..fdff3dc --- /dev/null +++ b/tools/precoder/link_model.py @@ -0,0 +1,150 @@ +"""Link model for the adaptive link — SNR -> block-delivery probability. + +Turns an estimated link SNR + an operating point (MCS, FEC overhead) into +`P_deliver` (the probability a FEC block's source is recovered), which the energy +model divides into Joules to get energy-per-delivered-bit. It reuses the measured- +channel FEC accounting in `fec_ab_sim` verbatim — the SBI-vs-plain survivor model, +the CRC tax, `sim_interframe`/`sim_intra` — so the controller prices delivery and +airtime consistently. + +Two layers: + * a CHANNEL = (corrupt-frame rate, per-corrupt-frame sub-block survivor histogram) + keyed by (MCS, SNR), exactly the structure of `fec_ab_sim.CHANNELS`; + * `P_deliver(SNR, MCS, overhead)` = `fec_ab_sim.sim_interframe(channel, overhead)`. + +CALIBRATION ("model now, meter later"): channels default to a documented NOMINAL +per-MCS FCS waterfall + survivor shape. `tests/calibrate_link.py` replaces them +with histograms measured by sweeping `tests/sdr_interferer.py` and reading +`fused_fec_link.FusedFecReceiver.report()`. The shape (energy-min picks the same +operating points) is right from the nominal model; absolute SNR thresholds move +once calibrated. +""" + +from __future__ import annotations + +import json +import math +import os +import random +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +if _HERE not in sys.path: + sys.path.insert(0, _HERE) + +import fec_ab_sim # noqa: E402 + +# Nominal per-HT-MCS FCS-failure waterfall centres (dB, ~50% corrupt-frame rate +# for a ~1500 B frame) and slope. Higher MCS = more fragile = higher centre. +# OVERRIDE via calibrate_link.py. These set where each MCS "falls off". +NOMINAL_MCS_CENTER_DB = {0: 6.0, 1: 8.0, 2: 10.0, 3: 13.0, 4: 17.0, 5: 21.0, + 6: 23.0, 7: 26.0} +NOMINAL_SLOPE_DB = 2.5 +N_SUB = 10 # sub-blocks per body (matches fec_ab_sim / fec_subblock default body) + + +def _logistic(x: float) -> float: + if x < -60: + return 0.0 + if x > 60: + return 1.0 + return 1.0 / (1.0 + math.exp(-x)) + + +def synth_channel(mcs: int, snr_db: float, + center_db: dict[int, float] | None = None, + slope_db: float = NOMINAL_SLOPE_DB) -> dict: + """Nominal (corrupt_rate, survivor_hist) for (MCS, SNR), fec_ab_sim-shaped. + + corrupt_rate rises as SNR drops below the MCS centre. Within a corrupt frame + the surviving-sub-block fraction rises with SNR (localized near the edge, + frame-wide far below) — the structure SBI exploits. Histogram = binomial + around that mean, the conservative i.i.d. stance fec_ab_sim already takes. + """ + centers = center_db or NOMINAL_MCS_CENTER_DB + c = centers.get(mcs, 26.0) + corrupt_rate = _logistic((c - snr_db) / slope_db) # 0.5 at SNR=centre + # mean surviving fraction of a corrupt frame's sub-blocks + f = 0.55 + 0.05 * (snr_db - c) + f = max(0.05, min(0.95, f)) + # binomial(N_SUB, f) histogram over corrupt frames (scaled to a count) + hist: dict[int, int] = {} + scale = 1000 + for s in range(N_SUB + 1): + pmf = math.comb(N_SUB, s) * (f ** s) * ((1 - f) ** (N_SUB - s)) + hist[s] = max(0, round(pmf * scale)) + if sum(hist.values()) == 0: + hist[round(f * N_SUB)] = 1 + return {"corrupt_rate": round(corrupt_rate, 4), "n_sub": N_SUB, + "survivor_hist": {k: v for k, v in hist.items() if v > 0}} + + +class LinkModel: + """SNR -> P_deliver, backed by a (MCS, SNR-bucket) channel table. + + Nominal channels are synthesised on demand; a calibration file replaces them + with measured ones (keyed 'mcs:snr_bucket'). P_deliver is cached and uses a + fixed RNG seed so the controller is deterministic. + """ + + def __init__(self, calib_path: str | None = None, frames_per_gen: int = 12, + trials: int = 2000, snr_bucket_db: float = 1.0): + self.frames_per_gen = frames_per_gen + self.trials = trials + self.bucket = snr_bucket_db + self._measured: dict[str, dict] = {} + self._center = dict(NOMINAL_MCS_CENTER_DB) + self._slope = NOMINAL_SLOPE_DB + if calib_path: + with open(calib_path) as f: + d = json.load(f) + self._measured = d.get("channels", {}) + self._center.update({int(k): v for k, v in d.get("centers", {}).items()}) + self._slope = d.get("slope_db", self._slope) + self._deliver_cache: dict[tuple, float] = {} + + def _snr_bucket(self, snr_db: float) -> float: + return round(snr_db / self.bucket) * self.bucket + + def channel(self, mcs: int, snr_db: float) -> dict: + b = self._snr_bucket(snr_db) + key = f"{mcs}:{b:g}" + if key in self._measured: + return self._measured[key] + return synth_channel(mcs, b, self._center, self._slope) + + def p_deliver(self, snr_db: float, mcs: int, overhead: float, + sbi: bool = True) -> float: + """Block-delivery probability at (SNR, MCS, FEC overhead). sbi=True uses + the SBI sub-block salvage path (fused FEC); False = plain whole-frame.""" + b = self._snr_bucket(snr_db) + key = (mcs, b, round(overhead, 3), sbi) + if key in self._deliver_cache: + return self._deliver_cache[key] + ch = self.channel(mcs, b) + rng = random.Random(0xA11CE ^ hash(key) & 0xFFFFFFFF) + deliver, _ = fec_ab_sim.sim_interframe(ch, overhead, self.frames_per_gen, + self.trials, rng, sbi=sbi) + self._deliver_cache[key] = deliver + return deliver + + def snr_required(self, mcs: int, overhead: float, target: float, + sbi: bool = True, lo: float = -5.0, hi: float = 40.0, + step: float = 0.5) -> float: + """Lowest SNR (dB) where p_deliver >= target, scanning a grid. Returns + hi+step (i.e. 'infeasible') if the target is never met.""" + snr = lo + while snr <= hi: + if self.p_deliver(snr, mcs, overhead, sbi) >= target: + return snr + snr += step + return hi + step + + +if __name__ == "__main__": # quick sanity dump + lm = LinkModel(trials=800) + print("HT MCS SNR_req(99% deliver, ov=0.25) SNR_req(ov=1.0)") + for mcs in range(8): + r25 = lm.snr_required(mcs, 0.25, 0.99) + r100 = lm.snr_required(mcs, 1.00, 0.99) + print(f" MCS{mcs}: {r25:5.1f} dB {r100:5.1f} dB") diff --git a/tools/precoder/op_table.py b/tools/precoder/op_table.py new file mode 100644 index 0000000..962a7f3 --- /dev/null +++ b/tools/precoder/op_table.py @@ -0,0 +1,76 @@ +"""Operating-point table for the adaptive controller. + +A LINK ROW is a (MCS, FEC-overhead) pair with the received SNR it needs to clear +the delivery target (`snr_req`, from `link_model`). TX power (TXAGC) is NOT a row +dimension — the controller chooses, at runtime, the *minimum* TXAGC that supplies +`snr_req` at the current path loss, then ranks rows by energy-per-delivered-bit. +That is the joint rate+power energy-min: cheapest power per rate, then cheapest +rate. An OP POINT is a fully-resolved row (+ chosen TXAGC + e_bit). +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import energy_model as em + + +@dataclass(frozen=True) +class LinkRow: + mode: str + mcs: int + bw: int + sgi: bool + overhead: float + snr_req: float # received SNR (dB) needed to meet the delivery target + + +@dataclass(frozen=True) +class OpPoint: + mode: str + mcs: int + bw: int + sgi: bool + txagc: int + overhead: float + snr_req: float + e_bit: float # J per delivered source bit at the resolved operating point + p_deliver: float + + def tx(self) -> "em.TxPoint": + return em.TxPoint(self.mode, self.mcs, self.bw, self.sgi, self.txagc) + + +def build_link_rows(link, target: float, mcs_set=range(8), + overhead_set=(0.10, 0.25, 0.50, 0.75, 1.00), + bw: int = 20, sgi: bool = False, sbi: bool = True) -> list[LinkRow]: + """(MCS, overhead) -> snr_req for the given delivery target. Computed once.""" + rows = [] + for mcs in mcs_set: + for ov in overhead_set: + req = link.snr_required(mcs, ov, target, sbi=sbi) + rows.append(LinkRow("ht", mcs, bw, sgi, ov, req)) + return rows + + +def resolve(row: LinkRow, path_loss_db: float, calib, link, + payload_bytes: int, src_bitrate_bps: float, + margin_db: float = 0.0) -> OpPoint | None: + """Pick the minimum TXAGC that supplies `row.snr_req + margin` at this path + loss, price the row (e_bit may be +inf if this MCS is too slow to carry the + bitrate). Returns None if no TXAGC reaches the required SNR.""" + need_gain = (row.snr_req + margin_db) - path_loss_db + txagc = 0 if need_gain <= 0 else calib.min_txagc_for_gain(need_gain) + if txagc is None: + return None + recv = path_loss_db + calib.gain_db(txagc) + pdel = link.p_deliver(recv, row.mcs, row.overhead) + eb = em.energy_per_delivered_bit( + em.TxPoint(row.mode, row.mcs, row.bw, row.sgi, txagc), + src_bitrate_bps, row.overhead, payload_bytes, pdel, calib) + return OpPoint(row.mode, row.mcs, row.bw, row.sgi, txagc, row.overhead, + row.snr_req, eb, pdel) + + +MAX_RANGE = OpPoint("ht", 0, 20, False, 63, 1.00, 0.0, float("inf"), 0.0) +"""Failsafe: MCS0, 20 MHz, max power, heaviest FEC — energy irrelevant, reach only.""" diff --git a/tools/precoder/pyproject.toml b/tools/precoder/pyproject.toml index 121bd62..fbd2af2 100644 --- a/tools/precoder/pyproject.toml +++ b/tools/precoder/pyproject.toml @@ -39,4 +39,9 @@ testpaths = ["test_pipeline.py", "test_stream.py", "test_stream_fec.py", "test_stream_fec_rlc.py", "test_stream_fec_rs.py", "test_fec_subblock.py", "test_fec_fusion_sim.py", "test_svc_uep_fec.py", "test_fused_fec_link.py", - "test_soft_erasure_fec.py", "test_fec_ab_sim.py"] + "test_soft_erasure_fec.py", "test_fec_ab_sim.py", + "test_energy_model.py", "test_link_model.py", + "test_controller.py", + "test_rc_proto.py", "test_score.py", + "test_rendezvous.py", "test_adaptive_link.py", + "test_svc_pipeline.py"] diff --git a/tools/precoder/rc_proto.py b/tools/precoder/rc_proto.py new file mode 100644 index 0000000..8255414 --- /dev/null +++ b/tools/precoder/rc_proto.py @@ -0,0 +1,207 @@ +"""Adaptive-link control protocol — VRX->VTX feedback + rendezvous frames. + +Three tiny control frames ride the same radio as the video (canonical SA, probe- +req header), distinguished from video bodies by a leading "RC" magic so the peer +routes them without SBI involvement (feedback is the REVERSE direction from the +SBI-framed video): + + RCF — VRX -> VTX feedback, ~100 ms: the GS-authoritative profile + the + alink-style 1000..2000 score + explicit power/FEC + per-layer + delivery stats. CRC16 so a corrupted command is DROPPED not applied. + DISC — VRX -> VTX discovery beacon (rendezvous), addressed to a VTX_ID. + DISC_ACK — VTX -> VRX reply that completes rendezvous + agrees the op channel. + +Design = OpenIPC alink, dual objective: GS decides (AUTH=0, explicit PROFILE); +1 advisory bit reserved for a future drone-decides mode (AUTH=1, metrics only). +Pure + numpy-free; unit-tested in test_rc_proto.py. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass + +import fec_subblock # reuse crc16_ccitt + +RC_MAGIC = 0x5243 # "RC" +RC_VERSION = 1 + +# frame types +T_RCF = 1 +T_DISC = 2 +T_DISC_ACK = 3 + +# FLAGS bits +F_AUTH_ADVISORY = 0x01 # 0 = GS-decides (explicit PROFILE); 1 = drone-decides +F_FAILSAFE = 0x02 # VRX is in failsafe / lost the VTX +F_DISCOVERY = 0x04 # discovery context + +PWR_NO_CHANGE = 0xFF # PWR_IDX sentinel: leave TX power as-is + + +def _crc(buf: bytes) -> int: + return fec_subblock.crc16_ccitt(buf) + + +# --------------------------------------------------------------------------- # +# Shared profile table (indices on the wire; both ends agree on a version). +# --------------------------------------------------------------------------- # +@dataclass(frozen=True) +class Profile: + svc_ladder: str # DEVOURER_SVC_LADDER-style spec (per-layer MCS) + pwr_idx: int # TXAGC 0..63 + fec_overhead: float # outer-code overhead + bw: int # 20 | 40 + + +PROFILE_TABLE_VERSION = 1 +# Energy-ranked, max-range (index 0 = failsafe) -> max-quality. The live +# controller computes the actual operating point; this coarse table is what the +# wire PROFILE index and the discovery INIT_PROFILE reference. +DEFAULT_PROFILE_TABLE = [ + Profile("CRIT=MCS0/20/LDPC;T0=MCS0/20/LDPC;T1=MCS0/20;T2=MCS0/20", 63, 1.00, 20), # 0 max range + Profile("CRIT=MCS0/20/LDPC;T0=MCS1/20;T1=MCS2/20;T2=MCS2/20", 48, 0.75, 20), # 1 + Profile("CRIT=MCS1/20/LDPC;T0=MCS2/20;T1=MCS4/20;T2=MCS4/20", 32, 0.50, 20), # 2 + Profile("CRIT=MCS2/20;T0=MCS4/20;T1=MCS5/20;T2=MCS7/20/SGI", 20, 0.25, 20), # 3 + Profile("CRIT=MCS4/20;T0=MCS5/20;T1=MCS7/20;T2=MCS7/40/SGI", 8, 0.10, 20), # 4 max quality +] +MAX_RANGE_PROFILE = 0 + + +# --------------------------------------------------------------------------- # +# RCF — feedback / command +# --------------------------------------------------------------------------- # +@dataclass +class Rcf: + vtx_id: int = 0 + seq: int = 0 + ack_seq: int = 0 # highest VTX video seq the VRX decoded (round-trip + liveness) + profile: int = 0 + score: int = 1000 # alink-compatible 1000..2000 + pwr_idx: int = PWR_NO_CHANGE + fec_overhead_16ths: int = 4 # overhead in 1/16ths (4 = 0.25) + flags: int = 0 + layer_delivery: tuple = () # per-layer delivery %, 0..100 + + @property + def fec_overhead(self) -> float: + return self.fec_overhead_16ths / 16.0 + + +_RCF_HEAD = " bytes: + layers = bytes(min(100, max(0, int(x))) for x in r.layer_delivery) + head = struct.pack(_RCF_HEAD, RC_MAGIC, RC_VERSION, T_RCF, r.flags & 0xFF, + r.vtx_id & 0xFFFFFFFF, r.seq & 0xFFFF, r.ack_seq & 0xFFFF, + r.profile & 0xFF, r.score & 0xFFFF, r.pwr_idx & 0xFF, + r.fec_overhead_16ths & 0xFF, len(layers)) + body = head + layers + return body + struct.pack(" Rcf | None: + """Return Rcf, or None if not a valid RCF (drop — never misapply).""" + n = struct.calcsize(_RCF_HEAD) + if len(buf) < n + 2: + return None + magic, ver, typ, flags, vtx, seq, ack, prof, score, pwr, fec16, nl = \ + struct.unpack_from(_RCF_HEAD, buf) + if magic != RC_MAGIC or ver != RC_VERSION or typ != T_RCF: + return None + if len(buf) < n + nl + 2: + return None + body = buf[:n + nl] + crc, = struct.unpack_from(" bytes: + body = struct.pack(_DISC, RC_MAGIC, RC_VERSION, T_DISC, F_DISCOVERY, + d.vtx_id & 0xFFFFFFFF, d.vrx_nonce & 0xFFFFFFFF, + d.op_channel & 0xFF, d.op_width & 0xFF, d.table_ver & 0xFF, + d.init_profile & 0xFF, d.cap_bits & 0xFFFF, d.seq & 0xFFFF) + return body + struct.pack(" Disc | None: + n = struct.calcsize(_DISC) + if len(buf) < n + 2: + return None + vals = struct.unpack_from(_DISC, buf) + if vals[0] != RC_MAGIC or vals[1] != RC_VERSION or vals[2] != T_DISC: + return None + crc, = struct.unpack_from(" bytes: + body = struct.pack(_DACK, RC_MAGIC, RC_VERSION, T_DISC_ACK, F_DISCOVERY, + a.vtx_id & 0xFFFFFFFF, a.vrx_nonce & 0xFFFFFFFF, + a.chip_caps & 0xFFFF, a.agreed_channel & 0xFF, + a.agreed_width & 0xFF, a.seq & 0xFFFF) + return body + struct.pack(" DiscAck | None: + n = struct.calcsize(_DACK) + if len(buf) < n + 2: + return None + vals = struct.unpack_from(_DACK, buf) + if vals[0] != RC_MAGIC or vals[1] != RC_VERSION or vals[2] != T_DISC_ACK: + return None + crc, = struct.unpack_from(" int | None: + """Peek the RC frame type without full parse (None if not an RC frame).""" + if len(buf) < 4: + return None + magic, ver, typ = struct.unpack_from(" RC_LOST (failsafe) + grace_ms: int = 3000 # RC_LOST this long -> DISCOVERY (assume drifted) + listen_on_ms: int = 50 # discovery listen window + listen_period_ms: int = 1000 # discovery listen period (5% duty default) + listen_backoff_max_ms: int = 5000 # period grows the longer RC stays lost + discovery_channel: int = 6 + + +class VtxRendezvous: + def __init__(self, cfg: VtxConfig): + self.cfg = cfg + self.state = RC_OK + self.op_channel = cfg.discovery_channel + self._last_rc_ms = 0 + self._lost_at_ms = None + self._disc_at_ms = None + + def feed_rc(self, now_ms: float) -> None: + """Any valid RC/feedback proves the link is alive.""" + self._last_rc_ms = now_ms + if self.state != RC_OK: + self.state = RC_OK + self._lost_at_ms = self._disc_at_ms = None + + def _listen_period(self, now_ms: float) -> float: + """Period grows with how long RC has been lost (save the most energy when + the GS is probably gone), capped.""" + if self._disc_at_ms is None: + return self.cfg.listen_period_ms + elapsed = now_ms - self._disc_at_ms + grow = self.cfg.listen_period_ms + elapsed / 4.0 + return min(self.cfg.listen_backoff_max_ms, grow) + + def listening(self, now_ms: float) -> bool: + """In DISCOVERY, are we in a listen-on window right now?""" + if self.state != DISCOVERY or self._disc_at_ms is None: + return False + phase = (now_ms - self._disc_at_ms) % self._listen_period(now_ms) + return phase < self.cfg.listen_on_ms + + def tick(self, now_ms: float) -> str: + if self.state == RC_OK: + if now_ms - self._last_rc_ms > self.cfg.fallback_ms: + self.state = RC_LOST + self._lost_at_ms = now_ms + return A_FAILSAFE + return A_TX_VIDEO + if self.state == RC_LOST: + if now_ms - self._lost_at_ms > self.cfg.grace_ms: + self.state = DISCOVERY + self._disc_at_ms = now_ms + return A_LISTEN if self.listening(now_ms) else A_IDLE + return A_FAILSAFE # keep TXing max-range meanwhile + # DISCOVERY + return A_LISTEN if self.listening(now_ms) else A_IDLE + + def feed_disc(self, disc: "rp.Disc", now_ms: float) -> "rp.DiscAck | None": + """A DISC heard while listening. If it targets us, reply DISC_ACK, switch + to the op channel, and enter RC_OK.""" + if self.state not in (RC_LOST, DISCOVERY): + return None + if disc.vtx_id != self.cfg.vtx_id: + return None # not for us — ignore + self.op_channel = disc.op_channel + self.state = RC_OK + self._last_rc_ms = now_ms + self._lost_at_ms = self._disc_at_ms = None + return rp.DiscAck(vtx_id=self.cfg.vtx_id, vrx_nonce=disc.vrx_nonce, + chip_caps=0, agreed_channel=disc.op_channel, + agreed_width=disc.op_width) + + +@dataclass +class VrxConfig: + vtx_id: int + link_lost_ms: int = 1000 # no VTX video this long -> BEACONING + beacon_period_ms: int = 20 # beacon fast (< VTX listen window) for overlap + op_channel: int = 6 + discovery_channel: int = 6 + + +class VrxRendezvous: + def __init__(self, cfg: VrxConfig): + self.cfg = cfg + self.state = SESSION + self._last_video_ms = 0 + self._last_beacon_ms = -1 << 60 + self._seq = 0 + self._nonce = (cfg.vtx_id * 2654435761) & 0xFFFFFFFF + + def feed_video(self, now_ms: float) -> None: + self._last_video_ms = now_ms + if self.state == BEACONING: + self.state = SESSION # heard the VTX again + + def tick(self, now_ms: float) -> str: + if self.state == SESSION: + if now_ms - self._last_video_ms > self.cfg.link_lost_ms: + self.state = BEACONING + else: + return A_TX_FEEDBACK + # BEACONING: only the newer "VRX transmits only when it (re)hears the VTX" + # safety doesn't apply during discovery — we are actively searching. + if now_ms - self._last_beacon_ms >= self.cfg.beacon_period_ms: + self._last_beacon_ms = now_ms + return A_BEACON + return A_IDLE + + def beacon(self) -> "rp.Disc": + self._seq = (self._seq + 1) & 0xFFFF + return rp.Disc(vtx_id=self.cfg.vtx_id, vrx_nonce=self._nonce, + op_channel=self.cfg.op_channel, seq=self._seq) + + def feed_disc_ack(self, ack: "rp.DiscAck", now_ms: float) -> bool: + """VTX answered our beacon. Complete rendezvous -> SESSION.""" + if ack.vtx_id != self.cfg.vtx_id or ack.vrx_nonce != self._nonce: + return False + self.state = SESSION + self._last_video_ms = now_ms # expect video imminently + return True diff --git a/tools/precoder/score.py b/tools/precoder/score.py new file mode 100644 index 0000000..42dd43d --- /dev/null +++ b/tools/precoder/score.py @@ -0,0 +1,90 @@ +"""VRX link-quality scoring over a sliding window of frames. + +Feeds the controller an SNR estimate and produces the alink-compatible 1000..2000 +score carried in the RCF (telemetry + a future drone-decides mode). The score +blends windowed RSSI/SNR (best chain) and is penalised by the loss the DECODER +actually experiences — i.e. POST-FEC residual loss, not the raw FCS-failure rate, +since SBI sub-block salvage recovers much of the apparent corruption (the key +insight: score the link the decoder sees). +""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass, field + + +@dataclass +class ScoreConfig: + window_s: float = 0.5 + rssi_lo: float = -80.0 # -> score 1000 + rssi_hi: float = -40.0 # -> score 2000 + snr_lo: float = 5.0 # dB -> 1000 + snr_hi: float = 30.0 # dB -> 2000 + rssi_weight: float = 0.3 + snr_weight: float = 0.7 + loss_penalty: float = 1000.0 # score points subtracted per unit residual loss + + +def _lin(x: float, lo: float, hi: float) -> float: + if hi == lo: + return 1000.0 + return 1000.0 + 1000.0 * max(0.0, min(1.0, (x - lo) / (hi - lo))) + + +class ScoreWindow: + def __init__(self, cfg: ScoreConfig | None = None): + self.cfg = cfg or ScoreConfig() + self._frames: deque = deque() # (t, rssi, snr, crc_err, seq) + self._max_seq_seen = None + + def add_frame(self, rssi: float, snr: float, crc_err: bool, seq: int, + now_s: float) -> None: + self._frames.append((now_s, rssi, snr, bool(crc_err), seq)) + self._max_seq_seen = seq if self._max_seq_seen is None else max(self._max_seq_seen, seq) + cutoff = now_s - self.cfg.window_s + while self._frames and self._frames[0][0] < cutoff: + self._frames.popleft() + + def n(self) -> int: + return len(self._frames) + + def snr_estimate(self) -> float | None: + """Windowed mean SNR (best chain), for the controller. None if empty.""" + if not self._frames: + return None + return sum(f[2] for f in self._frames) / len(self._frames) + + def rssi_estimate(self) -> float | None: + if not self._frames: + return None + return sum(f[1] for f in self._frames) / len(self._frames) + + def fcs_loss(self) -> float: + """Raw fraction of windowed frames with crc_err.""" + if not self._frames: + return 0.0 + return sum(1 for f in self._frames if f[3]) / len(self._frames) + + def seq_gap_loss(self) -> float: + """Fraction of expected frames missing (from sequence-number gaps).""" + seqs = sorted(f[4] for f in self._frames) + if len(seqs) < 2: + return 0.0 + span = (seqs[-1] - seqs[0]) % 4096 + 1 + return max(0.0, 1.0 - len(seqs) / span) if span else 0.0 + + def ack_seq(self) -> int: + return self._max_seq_seen or 0 + + def score(self, residual_loss: float | None = None) -> int: + """alink-compatible 1000..2000. `residual_loss` = post-FEC loss fraction + (from FusedFecReceiver); if None, falls back to seq-gap loss.""" + if not self._frames: + return 1000 + rssi_s = _lin(self.rssi_estimate(), self.cfg.rssi_lo, self.cfg.rssi_hi) + snr_s = _lin(self.snr_estimate(), self.cfg.snr_lo, self.cfg.snr_hi) + s = self.cfg.rssi_weight * rssi_s + self.cfg.snr_weight * snr_s + loss = self.seq_gap_loss() if residual_loss is None else residual_loss + s -= self.cfg.loss_penalty * loss + return int(max(1000, min(2000, s))) diff --git a/tools/precoder/svc_pipeline.py b/tools/precoder/svc_pipeline.py new file mode 100644 index 0000000..3e30950 --- /dev/null +++ b/tools/precoder/svc_pipeline.py @@ -0,0 +1,232 @@ +"""End-to-end SVC-HEVC UEP pipeline simulator (software, no radio). + +Stitches the whole cross-layer-UEP path together so it can be exercised in CI +against a *realistic* synthetic HEVC stream: + + synthetic HEVC NALs (tests/gen_svc_nals.py — 4:8:16 T0/T1/T2 + IDR AUs) + -> classify + per-layer FEC (svc_uep_fec.SvcUepEncoder, fragment=True) + -> SBI sub-block framing (fec_subblock — one body = several CRC'd symbols) + -> per-layer PHY channel (this module — MCS ladder + SNR -> sub-block loss + via link_model.synth_channel, the fec_ab_sim shape) + -> SBI survivor salvage (fec_subblock.unpack inside the decoder) + -> per-layer FEC decode (SvcUepDecoder, fragment reassembly) + -> per-NAL delivery per layer + +The two halves of unequal error protection are *both* applied per layer: + - PHY MCS (DEFAULT_SVC_MCS, mirroring txdemo/svc_tx_demo/svc_tx.h default_policy) + - outer FEC (default_uep_policy — heavier RS overhead on the robust layers) +so a dropping SNR produces a graceful staircase (T2 sheds first, base/IDR last) +instead of one cliff. `run_svc_pipeline` returns per-layer delivery; `main()` +prints the staircase across an SNR sweep. +""" + +from __future__ import annotations + +import os +import random +import sys +from dataclasses import dataclass, field + +_HERE = os.path.dirname(os.path.abspath(__file__)) +if _HERE not in sys.path: + sys.path.insert(0, _HERE) +_TESTS = os.path.normpath(os.path.join(_HERE, "..", "..", "tests")) +if _TESTS not in sys.path: + sys.path.insert(0, _TESTS) + +import fec_subblock # noqa: E402 +import link_model as lm # noqa: E402 +import svc_uep_fec # noqa: E402 +from stream_fec import FecConfig # noqa: E402 +from svc_uep_fec import (SvcUepDecoder, SvcUepEncoder, UepLayer, UepPolicy, # noqa: E402 + parse_hevc_nal) + +import gen_svc_nals # noqa: E402 (tests/) + +# PHY-MCS half of UEP — the Python mirror of svc_tx.h default_policy()'s ladder: +# critical (sid 0) MCS0 T0 (sid 1) MCS1 T1 (sid 2) MCS4 T2 (sid 3) MCS7 +# Robust layers fly at the robust MCS *and* carry the heavy FEC overhead. +DEFAULT_SVC_MCS = {0: 0, 1: 1, 2: 4, 3: 7} + +LAYER_NAME = {0: "CRIT", 1: "T0", 2: "T1", 3: "T2"} + + +def pipeline_uep_policy(symbol_size: int = 256) -> UepPolicy: + """default_uep_policy's FEC-overhead ladder at a larger symbol size, so a + realistic NAL fragments into a handful of symbols (not dozens) — keeps the + CI sim fast while preserving the per-layer overhead ratio and SBI sub-block + granularity.""" + def rs(overhead: float) -> FecConfig: + return FecConfig(k=8, symbol_size=symbol_size, overhead=overhead, scheme="rs") + return UepPolicy( + critical=UepLayer(rs(1.00)), + by_tid=[UepLayer(rs(0.75)), UepLayer(rs(0.50)), UepLayer(rs(0.25))], + ) + + +def _corrupt_body(body: bytes, block_payload: int, channel: dict, + rng: random.Random) -> bytes: + """Apply one (MCS,SNR) channel draw to a radio body. With prob corrupt_rate + the frame fails its FCS; each of its SBI sub-blocks then independently + survives with the channel's mean surviving fraction (the localized-corruption + model SBI exploits). A hit sub-block has a payload byte flipped so its CRC + fails — exactly what `fec_subblock.unpack` erases.""" + if rng.random() >= channel["corrupt_rate"]: + return body # clean frame + hist = channel["survivor_hist"] + n_sub = channel["n_sub"] + tot = sum(hist.values()) or 1 + f = sum(int(s) * v for s, v in hist.items()) / (n_sub * tot) # P(sub-block ok) + b = bytearray(body) + stride = 2 + block_payload # crc16 + payload + n_blocks = (len(body) - fec_subblock.SBI_HDR_LEN) // stride + for i in range(n_blocks): + if rng.random() >= f: # this sub-block is hit + off = fec_subblock.SBI_HDR_LEN + i * stride + 2 + b[off] ^= 0xFF + return bytes(b) + + +@dataclass +class LayerStat: + sid: int + sent: int = 0 # NALs offered to the encoder + delivered: int = 0 # NALs recovered intact at the decoder + bodies: int = 0 # radio bodies for this layer + bodies_corrupted: int = 0 + + @property + def delivery(self) -> float: + return self.delivered / self.sent if self.sent else 1.0 + + +@dataclass +class PipelineResult: + layers: dict[int, LayerStat] = field(default_factory=dict) + + def delivery(self, sid: int) -> float: + return self.layers[sid].delivery + + def summary(self) -> str: + return " ".join( + f"{LAYER_NAME.get(s, s)}={self.layers[s].delivery:.3f}" + for s in sorted(self.layers)) + + +def run_svc_pipeline(nals: list[bytes], snr_db: float, + policy: UepPolicy | None = None, + mcs_ladder: dict[int, int] | None = None, + link: lm.LinkModel | None = None, + sbi: bool = True, seed: int = 0) -> PipelineResult: + """Push `nals` through the full per-layer UEP pipeline at a fixed link SNR. + + `sbi=True` salvages a corrupt frame's surviving sub-blocks (fused FEC); + `sbi=False` models the legacy whole-frame-erasure path (a corrupt frame is + dropped entirely) for the salvage A/B. + """ + policy = policy or pipeline_uep_policy() + mcs_ladder = mcs_ladder or DEFAULT_SVC_MCS + link = link or lm.LinkModel(trials=1) + rng = random.Random(seed) + + enc = SvcUepEncoder(policy, fragment=True) + dec = SvcUepDecoder(policy, fragment=True) + res = PipelineResult(layers={s: LayerStat(s) for s in policy.stream_ids()}) + + bodies: list[tuple[int, bytes]] = [] + for nal in nals: + sid = policy.stream_for(parse_hevc_nal(nal)) + res.layers[sid].sent += 1 + bodies += enc.add_nal(nal) + bodies += enc.flush() + + for sid, body in bodies: + st = res.layers[sid] + st.bodies += 1 + mcs = mcs_ladder.get(sid, max(mcs_ladder.values())) + ch = link.channel(mcs, snr_db) + if sbi: + cbody = _corrupt_body(body, enc._env[sid], ch, rng) + if cbody != body: + st.bodies_corrupted += 1 + for rsid, nal in dec.add_body(cbody): + res.layers[rsid].delivered += 1 + else: + # legacy: a frame that fails the FCS is a whole-body erasure + if rng.random() < ch["corrupt_rate"]: + st.bodies_corrupted += 1 + continue + for rsid, nal in dec.add_body(body): + res.layers[rsid].delivered += 1 + return res + + +@dataclass +class AdaptiveResult: + pipeline: PipelineResult + active_sids: list + shared_txagc: int + effective_snr: float + per_layer_mcs: dict + + +def run_svc_pipeline_adaptive(nals: list[bytes], reported_snr: float, + reported_txagc: int, controller=None, + policy: UepPolicy | None = None, + link: lm.LinkModel | None = None, + seed: int = 0) -> AdaptiveResult: + """Close the loop: a `SvcController` picks each layer's MCS, decides which + layers to shed, and sets one shared TX power from the reported link sample; + the pipeline then transmits only the active layers at the commanded MCS over + the resulting effective SNR. Shed layers are not sent (0 delivered, by + design — that airtime/energy is saved), so base/IDR SLA is met while + enhancement degrades gracefully. + """ + import energy_model as em + from controller import SvcController + + policy = policy or pipeline_uep_policy() + link = link or lm.LinkModel(trials=200) + calib = em.load_calibration() + controller = controller or SvcController(link, calib) + + ops, shared_txagc, active = controller.update(reported_snr, reported_txagc, + now_ms=0) + path_loss = reported_snr - calib.gain_db(reported_txagc) + eff_snr = path_loss + calib.gain_db(shared_txagc) + mcs_ladder = {sid: ops[sid].mcs for sid in active} + + # only the active layers go on air; shed layers are dropped at the source + sent = [n for n in nals + if policy.stream_for(parse_hevc_nal(n)) in active] + # decode at a fixed MCS ladder over the effective SNR (one shared txagc) + sim_link = lm.LinkModel(trials=1) + res = run_svc_pipeline(sent, eff_snr, policy=policy, mcs_ladder=mcs_ladder, + link=sim_link, seed=seed) + # account shed layers as 0-delivered against their full offered count + for nal in nals: + sid = policy.stream_for(parse_hevc_nal(nal)) + if sid not in active: + res.layers[sid].sent += 1 + return AdaptiveResult(pipeline=res, active_sids=active, + shared_txagc=shared_txagc, effective_snr=eff_snr, + per_layer_mcs=mcs_ladder) + + +def main() -> dict: + gops = int(os.environ.get("SVC_GOPS", "8")) + nals = gen_svc_nals.gen_nals(gops=gops) + print(f"synthetic HEVC: {len(nals)} NALs over {gops} GOPs") + rows = [] + for snr in [40, 28, 22, 18, 14, 10, 6, 2, -4]: + r = run_svc_pipeline(nals, float(snr), seed=snr & 0x7F) + rows.append((snr, r)) + print(f" SNR {snr:+3d} dB : {r.summary()}") + # headline number: at a marginal SNR, base holds while enhancement sheds + mid = run_svc_pipeline(nals, 12.0, seed=3) + return {"crit": mid.delivery(0), "t0": mid.delivery(1), + "t1": mid.delivery(2), "t2": mid.delivery(3)} + + +if __name__ == "__main__": + main() diff --git a/tools/precoder/svc_uep_fec.py b/tools/precoder/svc_uep_fec.py index e8e9d76..aa20627 100644 --- a/tools/precoder/svc_uep_fec.py +++ b/tools/precoder/svc_uep_fec.py @@ -23,6 +23,7 @@ from __future__ import annotations import os +import struct import sys from dataclasses import dataclass, field @@ -34,6 +35,16 @@ import stream_fec # noqa: E402 from stream_fec import FecConfig # noqa: E402 +# Fragmentation header for NALs that exceed one FEC symbol. A real HEVC NAL +# (IDR ~1-2 kB, P-frames hundreds of B) does not fit one outer-code symbol, so +# `fragment=True` splits it into symbol-sized FEC packets, each tagged so the +# receiver reassembles only when every fragment of a NAL survives (a lost +# fragment drops just that NAL — exactly the per-NAL delivery the UEP SLA is +# stated in). Header: per-stream nal_seq (u16, wraps), frag_idx (u8), n_frags +# (u8). Opt-in: the default single-symbol path (small NALs) is unchanged. +_FRAG_HDR = struct.Struct(" None: + def __init__(self, policy: UepPolicy, fragment: bool = False) -> None: self.policy = policy + self.fragment = fragment self._enc = {sid: stream_fec.make_encoder(policy.layer(sid).fec) for sid in policy.stream_ids()} self._env = {sid: _env_size(policy.layer(sid).fec) @@ -138,13 +150,29 @@ def __init__(self, policy: UepPolicy) -> None: sid: fec_subblock.SubBlockPacker( self._env[sid], policy.layer(sid).blocks_per_body, stream_id=sid) for sid in policy.stream_ids()} + self._seq = {sid: 0 for sid in policy.stream_ids()} + + def _frag_packets(self, sid: int, nal: bytes) -> list[bytes]: + """Split a NAL into (seq,idx,count)-tagged packets sized for this layer's + FEC symbol. A 1-fragment NAL still carries the header so the receiver's + reassembly path is uniform.""" + usable = self.policy.layer(sid).fec.max_packet_size - FRAG_HDR_LEN + if usable <= 0: + raise ValueError("symbol too small to fragment NALs") + chunks = [nal[i:i + usable] for i in range(0, max(len(nal), 1), usable)] + seq = self._seq[sid] + self._seq[sid] = (seq + 1) & 0xFFFF + n = len(chunks) + return [_FRAG_HDR.pack(seq, i, n) + c for i, c in enumerate(chunks)] def add_nal(self, nal: bytes) -> list[tuple[int, bytes]]: sid = self.policy.stream_for(parse_hevc_nal(nal)) + packets = self._frag_packets(sid, nal) if self.fragment else [nal] out: list[tuple[int, bytes]] = [] - for env in self._enc[sid].add_packet(nal): - for body in self._packer[sid].add(env): - out.append((sid, body)) + for pkt in packets: + for env in self._enc[sid].add_packet(pkt): + for body in self._packer[sid].add(env): + out.append((sid, body)) return out def flush(self) -> list[tuple[int, bytes]]: @@ -162,15 +190,37 @@ class SvcUepDecoder: """Routes a received body by its SBI stream_id to that layer's FEC decoder, unpacking with the layer's *configured* envelope size.""" - def __init__(self, policy: UepPolicy) -> None: + def __init__(self, policy: UepPolicy, fragment: bool = False) -> None: self.policy = policy + self.fragment = fragment self._dec = {sid: stream_fec.make_decoder(policy.layer(sid).fec) for sid in policy.stream_ids()} self._env = {sid: _env_size(policy.layer(sid).fec) for sid in policy.stream_ids()} + # reassembly buffer: (sid, seq) -> {frag_idx: chunk}, plus expected count + self._reasm: dict[tuple[int, int], dict[int, bytes]] = {} + self._reasm_n: dict[tuple[int, int], int] = {} self.bodies_routed = 0 self.bodies_misrouted = 0 + def _reassemble(self, sid: int, pkt: bytes) -> list[tuple[int, bytes]]: + """Buffer a recovered fragment; emit the full NAL once all of its + fragments have arrived. A NAL whose fragments never all decode is simply + never emitted (dropped) — the per-NAL UEP delivery semantics.""" + if len(pkt) < FRAG_HDR_LEN: + return [] + seq, idx, n = _FRAG_HDR.unpack_from(pkt) + key = (sid, seq) + slot = self._reasm.setdefault(key, {}) + slot[idx] = pkt[FRAG_HDR_LEN:] + self._reasm_n[key] = n + if len(slot) < n: + return [] + nal = b"".join(slot[i] for i in range(n)) + del self._reasm[key] + del self._reasm_n[key] + return [(sid, nal)] + def add_body(self, body: bytes) -> list[tuple[int, bytes]]: sid = fec_subblock.peek_stream_id(body) if sid is None or sid not in self._dec: @@ -181,7 +231,10 @@ def add_body(self, body: bytes) -> list[tuple[int, bytes]]: out: list[tuple[int, bytes]] = [] for env in res.survivors: for pkt in self._dec[sid].add_symbol(env): - out.append((sid, pkt)) + if self.fragment: + out += self._reassemble(sid, pkt) + else: + out.append((sid, pkt)) return out def blocks_decoded(self, stream_id: int) -> int: diff --git a/tools/precoder/test_adaptive_link.py b/tools/precoder/test_adaptive_link.py new file mode 100644 index 0000000..47f7c58 --- /dev/null +++ b/tools/precoder/test_adaptive_link.py @@ -0,0 +1,69 @@ +"""Tests for the adaptive-link orchestrator (`adaptive_link.py`). + +Drives the VTX<->VRX closed loop in-process (no radio) through a fly-out-and-back +channel and asserts the loop holds delivery while power tracks range; plus the +VTX-side RCF application and failsafe. +""" + +from __future__ import annotations + +import adaptive_link as al +import rc_proto as rp +import rendezvous as rz + + +def test_closed_loop_holds_delivery_and_tracks_power(): + r = al.selftest() + assert r["avg_deliver"] >= 0.95 # SLA held through the flight + assert r["far_txagc"] >= r["close_txagc"] + 25 # much more power far than close + assert r["far_txagc"] >= 32 # clearly cranks power at range + assert r["close_txagc"] <= 16 # backs off when close + + +def test_op_to_ladder_and_overhead_mapping(): + class Op: + mcs, bw, overhead = 3, 20, 0.5 + spec = al.op_to_ladder(Op()) + assert "CRIT=MCS3/20" in spec and "T2=MCS5/20" in spec # base robust, enh steps up + assert al.overhead_to_16ths(0.25) == 4 + assert al.overhead_to_16ths(1.0) == 16 + assert al.overhead_to_16ths(0.0) == 1 # clamped to >=1 + + +def test_vtx_applies_rcf(): + vtx = al.AdaptiveVtx(0xABCD) + rcf = rp.pack_rcf(rp.Rcf(vtx_id=0xABCD, pwr_idx=40, fec_overhead_16ths=8)) + vtx.on_rc_frame(rcf, now_ms=0) + assert vtx.state.txagc == 40 and abs(vtx.state.overhead - 0.5) < 1e-9 + assert vtx.rz.state == rz.RC_OK and not vtx.state.failsafe + + +def test_vtx_ignores_rcf_for_other_vtx(): + vtx = al.AdaptiveVtx(0xABCD) + vtx.on_rc_frame(rp.pack_rcf(rp.Rcf(vtx_id=0x1111, pwr_idx=10)), now_ms=0) + assert vtx.state.txagc == 32 # unchanged (default) + + +def test_vtx_failsafe_on_rc_loss(): + vtx = al.AdaptiveVtx(0xABCD, rz.VtxConfig(vtx_id=0xABCD, fallback_ms=100)) + vtx.on_rc_frame(rp.pack_rcf(rp.Rcf(vtx_id=0xABCD, pwr_idx=10)), now_ms=0) + assert vtx.step(50) == rz.A_TX_VIDEO + assert vtx.step(200) == rz.A_FAILSAFE # > fallback_ms + assert vtx.state.failsafe and vtx.state.txagc == 63 # max-range power + + +def test_vrx_answers_disc_handshake(): + # VTX in discovery hears a VRX DISC -> answers DISC_ACK and resumes + vtx = al.AdaptiveVtx(0xABCD, rz.VtxConfig(vtx_id=0xABCD, fallback_ms=100, + grace_ms=100)) + vtx.on_rc_frame(rp.pack_rcf(rp.Rcf(vtx_id=0xABCD)), 0) + t = 1 + while vtx.rz.state != rz.DISCOVERY and t < 1000: + vtx.step(t); t += 5 + disc = rp.pack_disc(rp.Disc(vtx_id=0xABCD, vrx_nonce=42, op_channel=36)) + # advance to a listen window + while not vtx.rz.listening(t): + vtx.step(t); t += 5 + ack = vtx.on_rc_frame(disc, t) + assert ack is not None and rp.frame_type(ack) == rp.T_DISC_ACK + assert vtx.rz.state == rz.RC_OK and vtx.rz.op_channel == 36 diff --git a/tools/precoder/test_controller.py b/tools/precoder/test_controller.py new file mode 100644 index 0000000..7ec596c --- /dev/null +++ b/tools/precoder/test_controller.py @@ -0,0 +1,110 @@ +"""Tests for the energy-min adaptive controller (`controller.py`) + the closed-loop +sim headline. + +Pins: higher SNR -> higher MCS / lower power (energy-min); feedback-loss failsafe; +no profile flapping under noisy SNR; enhancement layers shed when infeasible; and +the sim shows a real energy saving vs an over-provisioned fixed baseline while +holding the delivery SLA. +""" + +from __future__ import annotations + +import os +import random +import sys + +import energy_model as em +import link_model as lm +import op_table +from controller import Controller, ControllerConfig + + +def _ctrl(**kw): + return Controller(lm.LinkModel(trials=400), em.load_calibration(), + ControllerConfig(**kw)) + + +def test_higher_snr_picks_higher_mcs_lower_power(): + c = _ctrl(target=0.99, allow_shed=False) + # report a strong link (high received SNR at a mid TXAGC) vs a weak one + strong = c.update(40.0, 32, now_ms=0) + c2 = _ctrl(target=0.99, allow_shed=False) + weak = c2.update(8.0, 32, now_ms=0) + assert strong.mcs > weak.mcs # energy-min rides high MCS when it can + assert strong.txagc <= weak.txagc # and spends less power when close + + +def test_failsafe_on_feedback_timeout(): + c = _ctrl(target=0.99, allow_shed=False, feedback_timeout_ms=1000) + c.update(30.0, 32, now_ms=0) + assert c.on_tick(now_ms=500) is not None and c.on_tick(500).mcs != 0 # still good + fs = c.on_tick(now_ms=2000) # > timeout + assert fs is op_table.MAX_RANGE # max-range failsafe + + +def test_no_flap_under_noisy_snr(): + """Noisy SNR straddling a threshold must not cause continuous MCS/FEC churn. + (TXAGC tracks SNR freely — it's the cheap power lever; only MCS/overhead + changes are disruptive, so those are the flap metric.)""" + c = _ctrl(target=0.99, allow_shed=False) + rng = random.Random(0) + prev = None + changes = 0 + for t in range(300): + snr = 18.0 + rng.uniform(-2.5, 2.5) # jitter around a waterfall edge + op = c.update(snr, op_table.MAX_RANGE.txagc if prev is None else prev.txagc, + now_ms=t * 100) + if prev and (op.mcs, op.overhead) != (prev.mcs, prev.overhead): + changes += 1 + prev = op + assert changes < 30 # hysteresis bounds MCS/FEC churn + + +def test_enhancement_layer_sheds_when_infeasible(): + # an enhancement layer (allow_shed) at an SNR so low even max power can't reach + c = _ctrl(target=0.99, allow_shed=True, src_bitrate_bps=8e6) + op = c.update(-25.0, 0, now_ms=0) # nothing clears even at txagc 63 + assert op is None # shed, not failsafe + + +def test_base_layer_never_sheds(): + c = _ctrl(target=0.99, allow_shed=False) + op = c.update(-25.0, 0, now_ms=0) + assert op is op_table.MAX_RANGE # base falls to max-range, not None + + +def test_sim_energy_savings_headline(): + sys.path.insert(0, os.path.expanduser("~/git/devourer/tests")) + import sim_loop + r = sim_loop.main() + assert r["save_vs_robust"] >= 0.25 # vs over-provisioned set-and-forget + assert r["save_vs_emin"] > 0.0 # adapting beats energy-aware-static too + assert r["delivery"] >= 0.98 # SLA held (target 0.99, MC noise) + assert r["changes"] < 100 # no flapping over 200 ticks + + +def test_svc_uep_graceful_staircase(): + """As SNR drops, enhancement layers (T2 then T1) shed first; base (critical/T0) + holds longest — the UEP graceful-degradation staircase.""" + from controller import SvcController + svc = SvcController(lm.LinkModel(trials=300), em.load_calibration()) + # high SNR: all four layers active + _, _, active_hi = svc.update(40.0, 32, now_ms=0) + assert active_hi == [0, 1, 2, 3] + # walk SNR down; record how many layers survive at each step + counts = [] + for i, snr in enumerate([40, 25, 18, 12, 6, 0, -8]): + # fresh bank per SNR to avoid hysteresis/hold coupling the sweep + s = SvcController(lm.LinkModel(trials=300), em.load_calibration()) + _, _, act = s.update(float(snr), 32, now_ms=0) + counts.append(len(act)) + assert counts == sorted(counts, reverse=True) # monotonically sheds + assert counts[0] == 4 and counts[-1] <= 2 # all active high; base-only low + + +def test_svc_shared_power_is_max_over_active(): + from controller import SvcController + svc = SvcController(lm.LinkModel(trials=300), em.load_calibration()) + ops, shared, active = svc.update(10.0, 32, now_ms=0) + present = [ops[s].txagc for s in active] + assert shared == max(present) # one PA: max over active layers diff --git a/tools/precoder/test_energy_model.py b/tools/precoder/test_energy_model.py new file mode 100644 index 0000000..525621d --- /dev/null +++ b/tools/precoder/test_energy_model.py @@ -0,0 +1,78 @@ +"""Tests for the adaptive-link energy model (`energy_model.py`). + +Pins the energy thesis the controller relies on: to carry a fixed video bitrate, +higher MCS / less FEC = less airtime = less energy/bit; losing blocks raises it; +TX power (TXAGC) is a WEAK energy lever vs MCS; and an MCS too slow to carry the +bitrate is infeasible. +""" + +from __future__ import annotations + +import energy_model as em + +SRC = 4e6 # 4 Mbps video +L = 1024 + + +def _eb(mcs, txagc, overhead=0.25, pdel=1.0, src=SRC): + return em.energy_per_delivered_bit(em.TxPoint(mcs=mcs, txagc=txagc), src, + overhead, L, pdel, em.load_calibration()) + + +def test_phy_rates(): + assert em.phy_rate_mbps("ht", 0) == 6.5 + assert em.phy_rate_mbps("ht", 7) == 65.0 + assert em.phy_rate_mbps("ht", 7, bw=40) == 135.0 + assert abs(em.phy_rate_mbps("ht", 7, sgi=True) - 65.0 * 10 / 9) < 1e-6 + assert em.phy_rate_mbps("legacy", 54) == 54.0 + + +def test_higher_mcs_lowers_energy_per_bit_clean(): + """Core thesis: carrying a fixed bitrate, higher MCS = less airtime = less + energy/bit (monotone).""" + eb = [_eb(m, 32) for m in range(8)] + assert eb == sorted(eb, reverse=True) + assert eb[0] > eb[7] + + +def test_losing_blocks_is_expensive(): + full = _eb(5, 32, pdel=1.0) + half = _eb(5, 32, pdel=0.5) + assert abs(half - 2 * full) < 1e-12 # E_bit ~ 1/p_deliver + assert _eb(5, 32, pdel=0.0) == float("inf") + + +def test_more_overhead_costs_energy(): + assert _eb(5, 32, overhead=1.00) > _eb(5, 32, overhead=0.25) + + +def test_txpower_is_a_weak_energy_lever(): + """Raising TXAGC across its whole range moves energy/bit far less than the + swing across the MCS range (guards 'power is cheap for link margin').""" + pwr_cost = _eb(5, 63) - _eb(5, 0) # full TXAGC range, fixed MCS + mcs_swing = _eb(0, 8) - _eb(7, 8) # full MCS range, fixed low TXAGC + assert pwr_cost > 0 + assert pwr_cost < mcs_swing + + +def test_too_slow_mcs_is_infeasible(): + """An MCS whose effective rate can't carry src*(1+ov) is +inf (can't fit). + Effective rate is well below the PHY rate at small payloads (preamble tax).""" + assert _eb(0, 32, overhead=0.0, src=30e6) == float("inf") # 30 Mbps >> MCS0 + assert _eb(7, 32, overhead=0.0, src=30e6) < float("inf") # MCS7 carries it + + +def test_airtime_drops_with_mcs(): + cal = em.load_calibration() + af = [em.airtime_fraction(em.TxPoint(mcs=m), SRC, 0.25, L, cal) for m in range(8)] + assert af == sorted(af, reverse=True) + + +def test_calibration_overlay(tmp_path): + import json + p = tmp_path / "cal.json" + p.write_text(json.dumps({"source": "metered", "metered_watts": True, + "p_baseline_w": 0.9})) + cal = em.load_calibration(str(p)) + assert cal.source == "metered" and cal.metered_watts and cal.p_baseline_w == 0.9 + assert len(cal.p_pa_w) == 64 diff --git a/tools/precoder/test_link_model.py b/tools/precoder/test_link_model.py new file mode 100644 index 0000000..47893bf --- /dev/null +++ b/tools/precoder/test_link_model.py @@ -0,0 +1,61 @@ +"""Tests for the adaptive-link link model (`link_model.py`). + +Pins the delivery monotonicities the controller relies on: P_deliver rises with +SNR and with FEC overhead; the SNR a row needs rises with MCS and falls with +overhead; and a measured calibration channel overrides the nominal one. +""" + +from __future__ import annotations + +import link_model as lm + + +def test_p_deliver_rises_with_snr(): + m = lm.LinkModel(trials=600) + vals = [m.p_deliver(snr, mcs=3, overhead=0.25) for snr in range(8, 24, 2)] + assert vals[0] <= vals[-1] + assert vals[0] < 0.5 < vals[-1] # crosses the waterfall + # non-decreasing within Monte-Carlo noise + assert all(b >= a - 0.05 for a, b in zip(vals, vals[1:])) + + +def test_more_overhead_helps(): + m = lm.LinkModel(trials=800) + snr = 12.0 + light = m.p_deliver(snr, mcs=3, overhead=0.25) + heavy = m.p_deliver(snr, mcs=3, overhead=1.00) + assert heavy >= light + + +def test_snr_required_rises_with_mcs(): + m = lm.LinkModel(trials=600) + req = [m.snr_required(mcs, 0.25, 0.99) for mcs in range(8)] + # higher MCS needs more SNR (allow ties from the 0.5 dB grid) + assert all(b >= a for a, b in zip(req, req[1:])) + assert req[7] > req[0] + + +def test_heavier_fec_lowers_snr_required(): + m = lm.LinkModel(trials=800) + assert m.snr_required(5, 1.00, 0.99) < m.snr_required(5, 0.25, 0.99) + + +def test_synth_channel_shape(): + # at high SNR a corrupt frame keeps most sub-blocks (localized); at low SNR few + hi = lm.synth_channel(3, 20.0) + lo = lm.synth_channel(3, 4.0) + mean_hi = sum(k * v for k, v in hi["survivor_hist"].items()) / sum(hi["survivor_hist"].values()) + mean_lo = sum(k * v for k, v in lo["survivor_hist"].items()) / sum(lo["survivor_hist"].values()) + assert mean_hi > mean_lo + assert hi["corrupt_rate"] < lo["corrupt_rate"] # less corrupt at high SNR + + +def test_calibration_override(tmp_path): + import json + # a measured channel that is perfect at MCS7/10dB (override the fragile nominal) + ch = {"corrupt_rate": 0.0, "n_sub": 10, "survivor_hist": {10: 100}} + p = tmp_path / "link.json" + p.write_text(json.dumps({"channels": {"7:10": ch}})) + m = lm.LinkModel(calib_path=str(p), trials=400) + assert m.channel(7, 10.0)["corrupt_rate"] == 0.0 + assert m.p_deliver(10.0, mcs=7, overhead=0.10) > 0.99 # uses the measured channel diff --git a/tools/precoder/test_rc_proto.py b/tools/precoder/test_rc_proto.py new file mode 100644 index 0000000..487cb30 --- /dev/null +++ b/tools/precoder/test_rc_proto.py @@ -0,0 +1,76 @@ +"""Tests for the adaptive-link control protocol (`rc_proto.py`). + +Round-trip every frame; CRC16 rejects bit-flips and truncation; a non-RC body is +not mistaken for a control frame. A corrupted command must be DROPPED (parse +-> None), never misapplied. +""" + +from __future__ import annotations + +import rc_proto as rp + + +def test_rcf_roundtrip(): + r = rp.Rcf(vtx_id=0xDEADBEEF, seq=1234, ack_seq=1200, profile=3, score=1750, + pwr_idx=24, fec_overhead_16ths=4, flags=rp.F_AUTH_ADVISORY, + layer_delivery=(100, 98, 80, 40)) + out = rp.parse_rcf(rp.pack_rcf(r)) + assert out is not None + assert out.vtx_id == 0xDEADBEEF and out.seq == 1234 and out.ack_seq == 1200 + assert out.profile == 3 and out.score == 1750 and out.pwr_idx == 24 + assert out.fec_overhead_16ths == 4 and abs(out.fec_overhead - 0.25) < 1e-9 + assert out.flags == rp.F_AUTH_ADVISORY + assert out.layer_delivery == (100, 98, 80, 40) + + +def test_rcf_crc_rejects_bitflip(): + buf = bytearray(rp.pack_rcf(rp.Rcf(vtx_id=1, profile=2))) + buf[8] ^= 0x01 # flip a payload bit + assert rp.parse_rcf(bytes(buf)) is None + + +def test_rcf_rejects_truncation_and_garbage(): + buf = rp.pack_rcf(rp.Rcf(vtx_id=1)) + assert rp.parse_rcf(buf[:5]) is None + assert rp.parse_rcf(b"\x00" * len(buf)) is None # wrong magic + assert rp.parse_rcf(b"video-body-not-rc") is None + + +def test_disc_roundtrip_and_crc(): + d = rp.Disc(vtx_id=0x1234, vrx_nonce=0x99887766, op_channel=36, op_width=40, + init_profile=0, cap_bits=0x000F, seq=7) + out = rp.parse_disc(rp.pack_disc(d)) + assert out and out.vtx_id == 0x1234 and out.vrx_nonce == 0x99887766 + assert out.op_channel == 36 and out.op_width == 40 and out.cap_bits == 0x000F + bad = bytearray(rp.pack_disc(d)); bad[10] ^= 0xFF + assert rp.parse_disc(bytes(bad)) is None + + +def test_disc_ack_roundtrip(): + a = rp.DiscAck(vtx_id=0x1234, vrx_nonce=0x99887766, chip_caps=0x0007, + agreed_channel=36, agreed_width=40, seq=8) + out = rp.parse_disc_ack(rp.pack_disc_ack(a)) + assert out and out.vtx_id == 0x1234 and out.vrx_nonce == 0x99887766 + assert out.agreed_channel == 36 and out.chip_caps == 0x0007 + + +def test_frame_type_discriminates(): + assert rp.frame_type(rp.pack_rcf(rp.Rcf())) == rp.T_RCF + assert rp.frame_type(rp.pack_disc(rp.Disc(1, 2, 6))) == rp.T_DISC + assert rp.frame_type(rp.pack_disc_ack(rp.DiscAck(1, 2, 0, 6))) == rp.T_DISC_ACK + assert rp.frame_type(b"\xf5\xb0video") is None # an SBI video body, not RC + + +def test_cross_parse_rejected(): + """A DISC must not parse as an RCF (type byte guards it).""" + disc = rp.pack_disc(rp.Disc(vtx_id=1, vrx_nonce=2, op_channel=6)) + assert rp.parse_rcf(disc) is None + assert rp.parse_disc_ack(disc) is None + + +def test_profile_table(): + assert len(rp.DEFAULT_PROFILE_TABLE) >= 2 + assert rp.MAX_RANGE_PROFILE == 0 + # index 0 = most robust (max power, heaviest FEC) + assert rp.DEFAULT_PROFILE_TABLE[0].pwr_idx >= rp.DEFAULT_PROFILE_TABLE[-1].pwr_idx + assert rp.DEFAULT_PROFILE_TABLE[0].fec_overhead >= rp.DEFAULT_PROFILE_TABLE[-1].fec_overhead diff --git a/tools/precoder/test_rendezvous.py b/tools/precoder/test_rendezvous.py new file mode 100644 index 0000000..c34910d --- /dev/null +++ b/tools/precoder/test_rendezvous.py @@ -0,0 +1,105 @@ +"""Tests for the rendezvous + failsafe state machines (`rendezvous.py`). + +Deterministic, no radio: pumps `tick(now_ms)` and routes DISC/DISC_ACK in-process. +Covers the failsafe timing chain (RC_OK -> RC_LOST -> DISCOVERY), the VRX +SESSION<->BEACONING transitions, and that the asymmetric-duty handshake completes +within the worst-case listen period for ALL phase offsets (the duty-overlap math). +""" + +from __future__ import annotations + +import rendezvous as rz +import rc_proto as rp + + +def _vtx(**kw): + return rz.VtxRendezvous(rz.VtxConfig(vtx_id=0xABCD, fallback_ms=100, + grace_ms=200, listen_on_ms=50, + listen_period_ms=200, **kw)) + + +def _vrx(**kw): + return rz.VrxRendezvous(rz.VrxConfig(vtx_id=0xABCD, link_lost_ms=100, + beacon_period_ms=20, op_channel=36, **kw)) + + +def test_failsafe_chain_timing(): + v = _vtx() + v.feed_rc(0) + assert v.tick(50) == rz.A_TX_VIDEO and v.state == rz.RC_OK + assert v.tick(150) == rz.A_FAILSAFE and v.state == rz.RC_LOST # > fallback_ms + # stays failsafe through the grace window, then enters DISCOVERY + assert v.tick(300) == rz.A_FAILSAFE or v.state == rz.RC_LOST + v.tick(400) # > lost+grace + assert v.state == rz.DISCOVERY + + +def test_feed_rc_recovers_to_session(): + v = _vtx() + v.feed_rc(0); v.tick(150) # -> RC_LOST + assert v.state == rz.RC_LOST + v.feed_rc(160) # RC came back + assert v.state == rz.RC_OK + + +def test_vrx_session_to_beaconing_and_back(): + r = _vrx() + r.feed_video(0) + assert r.tick(50) == rz.A_TX_FEEDBACK and r.state == rz.SESSION + r.tick(200) # > link_lost_ms, no video + assert r.state == rz.BEACONING + r.feed_video(210) + assert r.state == rz.SESSION + + +def test_vrx_beacons_at_period(): + r = _vrx(); r.feed_video(0); r.tick(200) # -> BEACONING + beacons = sum(1 for t in range(200, 400, 5) if r.tick(t) == rz.A_BEACON) + assert 8 <= beacons <= 11 # ~1 per 20 ms over 200 ms + + +def _drive_to_discovery(v, start): + v.feed_rc(start) + t = start + 1 + while v.state != rz.DISCOVERY and t < start + 1000: + v.tick(t); t += 5 + return t + + +def test_rendezvous_completes_for_all_phase_offsets(): + """VRX beacons every 20 ms; VTX listens 50 ms / 200 ms. Any listen window + overlaps >=2 beacons, so rendezvous must complete within ~1 listen period for + every phase offset between the two clocks.""" + for offset in range(0, 200, 10): # phase sweep + v = _vtx() + r = _vrx() + _drive_to_discovery(v, 0) # VTX in DISCOVERY (~>300 ms) + r.feed_video(0) + # force VRX into BEACONING, offset its beacon phase + t = 200 + offset + while r.state != rz.BEACONING: + r.tick(t); t += 5 + done_at = None + for now in range(t, t + 600, 5): # up to 3 listen periods + act = r.tick(now) + if act == rz.A_BEACON and v.listening(now): + ack = v.feed_disc(r.beacon(), now) + if ack and r.feed_disc_ack(ack, now): + done_at = now + break + assert done_at is not None, f"no rendezvous at offset {offset}" + assert v.state == rz.RC_OK and r.state == rz.SESSION + assert v.op_channel == 36 # agreed the op channel + + +def test_disc_for_other_vtx_ignored(): + v = _vtx(); _drive_to_discovery(v, 0) + other = rp.Disc(vtx_id=0x9999, vrx_nonce=1, op_channel=36) + assert v.feed_disc(other, 500) is None and v.state == rz.DISCOVERY + + +def test_disc_ack_nonce_must_match(): + r = _vrx(); r.feed_video(0); r.tick(200) # BEACONING + r.beacon() + bad = rp.DiscAck(vtx_id=0xABCD, vrx_nonce=0xDEAD, chip_caps=0, agreed_channel=36) + assert r.feed_disc_ack(bad, 300) is False and r.state == rz.BEACONING diff --git a/tools/precoder/test_score.py b/tools/precoder/test_score.py new file mode 100644 index 0000000..a3adaa0 --- /dev/null +++ b/tools/precoder/test_score.py @@ -0,0 +1,51 @@ +"""Tests for the VRX scoring window (`score.py`).""" + +from __future__ import annotations + +from score import ScoreWindow, ScoreConfig + + +def _fill(w, n, rssi, snr, crc_err=False, start_seq=0, t0=0.0, dt=0.01): + for i in range(n): + w.add_frame(rssi, snr, crc_err, start_seq + i, t0 + i * dt) + + +def test_score_rises_with_snr(): + lo = ScoreWindow(); _fill(lo, 30, -70, 8) + hi = ScoreWindow(); _fill(hi, 30, -50, 28) + assert lo.score() < hi.score() + assert 1000 <= lo.score() <= 2000 and 1000 <= hi.score() <= 2000 + + +def test_snr_estimate_is_windowed_mean(): + w = ScoreWindow(ScoreConfig(window_s=10.0)) + _fill(w, 10, -60, 20) + assert abs(w.snr_estimate() - 20.0) < 1e-6 + + +def test_window_evicts_old_frames(): + w = ScoreWindow(ScoreConfig(window_s=0.05)) + _fill(w, 10, -60, 20, t0=0.0, dt=0.01) # spans 0..0.09 s + w.add_frame(-60, 5, False, 100, now_s=1.0) # far future -> evicts all but this + assert w.n() == 1 and abs(w.snr_estimate() - 5.0) < 1e-6 + + +def test_residual_loss_penalizes_score(): + w = ScoreWindow(); _fill(w, 30, -50, 28) + clean = w.score(residual_loss=0.0) + lossy = w.score(residual_loss=0.3) + assert lossy < clean # decoder-experienced loss drags the score + + +def test_seq_gap_loss(): + w = ScoreWindow(ScoreConfig(window_s=100)) + # 5 frames spanning seq 0..9 -> ~50% missing + for i, s in enumerate([0, 2, 4, 6, 9]): + w.add_frame(-60, 20, False, s, now_s=i * 0.01) + assert 0.3 < w.seq_gap_loss() < 0.6 + + +def test_ack_seq_tracks_max(): + w = ScoreWindow() + _fill(w, 5, -60, 20, start_seq=100) + assert w.ack_seq() == 104 diff --git a/tools/precoder/test_svc_pipeline.py b/tools/precoder/test_svc_pipeline.py new file mode 100644 index 0000000..126de7d --- /dev/null +++ b/tools/precoder/test_svc_pipeline.py @@ -0,0 +1,167 @@ +"""End-to-end SVC-HEVC UEP pipeline tests (`svc_pipeline.py`). + +Drives a realistic synthetic HEVC stream (tests/gen_svc_nals.py) through the +full per-layer pipeline — classify -> per-layer FEC -> SBI framing -> per-layer +(MCS,SNR) channel -> SBI salvage -> FEC decode -> fragment reassembly — and +asserts the cross-layer-UEP contract: base/IDR delivered while enhancement +sheds first, a monotone staircase as SNR drops, and SBI sub-block salvage +beating whole-frame erasure. +""" + +from __future__ import annotations + +import os +import sys + +import svc_pipeline as sp +from svc_uep_fec import parse_hevc_nal + +sys.path.insert(0, os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..", "tests"))) +import gen_svc_nals # noqa: E402 + +CRIT, T0, T1, T2 = 0, 1, 2, 3 + + +def _nals(gops=8): + return gen_svc_nals.gen_nals(gops=gops) + + +# --------------------------------------------------------------------------- # +# Synthetic source realism +# --------------------------------------------------------------------------- # +def test_synthetic_stream_layer_mix(): + """gops=8, idr_period=2 -> 4 IDR AUs (4 critical NALs each) + 4:8:16 T0/T1/T2 + per GOP. The byte sizes differ per class so downstream sees a real VBR mix.""" + pol = sp.pipeline_uep_policy() + counts = {CRIT: 0, T0: 0, T1: 0, T2: 0} + sizes = {CRIT: set(), T0: set(), T1: set(), T2: set()} + for nal in _nals(8): + sid = pol.stream_for(parse_hevc_nal(nal)) + counts[sid] += 1 + sizes[sid].add(len(nal)) + assert counts == {CRIT: 16, T0: 32, T1: 64, T2: 128} # 4:8:16 + IDR AUs + # IDR access units are far larger than enhancement frames (realistic VBR) + assert max(sizes[CRIT]) > max(sizes[T2]) + + +# --------------------------------------------------------------------------- # +# Clean channel — everything arrives +# --------------------------------------------------------------------------- # +def test_clean_channel_delivers_all_layers(): + r = sp.run_svc_pipeline(_nals(8), 40.0, seed=1) + for sid in (CRIT, T0, T1, T2): + assert r.delivery(sid) == 1.0 + + +# --------------------------------------------------------------------------- # +# The headline: graceful-degradation staircase +# --------------------------------------------------------------------------- # +def test_uep_staircase_monotone_under_stress(): + r = sp.run_svc_pipeline(_nals(8), 16.0, seed=16) + d = [r.delivery(s) for s in (CRIT, T0, T1, T2)] + assert d == sorted(d, reverse=True) # CRIT >= T0 >= T1 >= T2 + assert d[0] == 1.0 # base/IDR fully protected + assert d[0] > d[3] # enhancement sacrificed first + + +def test_base_holds_when_enhancement_collapses(): + # SNR low enough that the top enhancement layer is gone but base rides through + r = sp.run_svc_pipeline(_nals(8), 10.0, seed=10) + assert r.delivery(CRIT) >= 0.99 # base/IDR SLA (>=99%) + assert r.delivery(T2) <= 0.05 # T2 collapsed + assert r.delivery(CRIT) > r.delivery(T2) + + +def test_staircase_is_monotone_across_snr_sweep(): + """As SNR drops, no layer's delivery ever increases — the staircase only + descends (each layer sheds and stays shed).""" + snrs = [40, 30, 24, 20, 16, 12, 8, 4] + curves = {s: [] for s in (CRIT, T0, T1, T2)} + for snr in snrs: + r = sp.run_svc_pipeline(_nals(8), float(snr), seed=100 + snr) + for sid in curves: + curves[sid].append(r.delivery(sid)) + for sid, c in curves.items(): + # allow Monte-Carlo wobble of one sub-block; trend must be non-increasing + for a, b in zip(c, c[1:]): + assert b <= a + 0.06, f"layer {sid} rose as SNR dropped: {c}" + # and base outlasts enhancement: CRIT's area under the curve > T2's + assert sum(curves[CRIT]) > sum(curves[T2]) + + +# --------------------------------------------------------------------------- # +# Fused-FEC: SBI sub-block salvage beats whole-frame erasure +# --------------------------------------------------------------------------- # +def test_sbi_salvage_beats_whole_frame(): + nals = _nals(8) + sbi = sp.run_svc_pipeline(nals, 18.0, sbi=True, seed=18) + wf = sp.run_svc_pipeline(nals, 18.0, sbi=False, seed=18) + tot_sbi = sum(l.delivered for l in sbi.layers.values()) + tot_wf = sum(l.delivered for l in wf.layers.values()) + assert tot_sbi > tot_wf # salvaging partial frames wins + assert sbi.delivery(T1) > wf.delivery(T1) # most visible on the marginal layer + # base is fully protected either way (heavy FEC); salvage helps the margin + assert sbi.delivery(CRIT) == wf.delivery(CRIT) == 1.0 + + +# --------------------------------------------------------------------------- # +# Fragmentation round-trips real-sized NALs +# --------------------------------------------------------------------------- # +# --------------------------------------------------------------------------- # +# Closed-loop adaptive SVC: SvcController drives per-layer MCS + shedding + power +# --------------------------------------------------------------------------- # +def _sweep(reported_snrs, txagc=32): + return [(s, sp.run_svc_pipeline_adaptive(_nals(8), float(s), txagc, seed=s & 0x7F)) + for s in reported_snrs] + + +def test_adaptive_backs_off_power_when_close(): + # strong reported link -> controller spends little power yet delivers all layers + a = sp.run_svc_pipeline_adaptive(_nals(8), 40.0, 32, seed=1) + assert a.active_sids == [CRIT, T0, T1, T2] + assert a.shared_txagc <= 16 + for sid in (CRIT, T0, T1, T2): + assert a.pipeline.delivery(sid) == 1.0 + + +def test_adaptive_cranks_power_at_range(): + close = sp.run_svc_pipeline_adaptive(_nals(8), 40.0, 32, seed=2) + far = sp.run_svc_pipeline_adaptive(_nals(8), 6.0, 32, seed=2) + assert far.shared_txagc >= close.shared_txagc + 25 # much more power at range + + +def test_adaptive_sheds_enhancement_before_base(): + # a reported SNR where even max power can't carry every layer + a = sp.run_svc_pipeline_adaptive(_nals(8), 4.0, 32, seed=3) + assert CRIT in a.active_sids and T0 in a.active_sids # base never shed + assert T2 not in a.active_sids # enhancement shed first + assert a.pipeline.delivery(CRIT) >= 0.99 # base SLA still met + assert a.pipeline.delivery(T2) == 0.0 # shed -> not delivered + + +def test_adaptive_base_sla_across_operational_range(): + """Across the operational range the controller holds the base/IDR SLA and + sheds layers monotonically (the active set only shrinks as range grows).""" + sweep = _sweep([40, 30, 22, 16, 10, 4]) + prev_active = 99 + for snr, a in sweep: + assert a.pipeline.delivery(CRIT) >= 0.99, f"base SLA missed at rSNR {snr}" + assert len(a.active_sids) <= prev_active # sheds, never re-adds blindly + prev_active = len(a.active_sids) + # power rises monotonically as the link weakens (energy tracks range) + txagcs = [a.shared_txagc for _s, a in sweep] + assert txagcs == sorted(txagcs) + + +def test_large_nal_fragments_and_reassembles_intact(): + from svc_uep_fec import SvcUepDecoder, SvcUepEncoder + pol = sp.pipeline_uep_policy() + enc = SvcUepEncoder(pol, fragment=True) + dec = SvcUepDecoder(pol, fragment=True) + big_idr = gen_svc_nals.nal_bytes(gen_svc_nals.IDR_W_RADL, 0, 1800) + bodies = enc.add_nal(big_idr) + enc.flush() + out = [] + for sid, body in bodies: + out += [nal for _sid, nal in dec.add_body(body)] + assert big_idr in out # reassembled byte-identical diff --git a/txdemo/stream_duplex_demo/main.cpp b/txdemo/stream_duplex_demo/main.cpp index e442321..b85e16a 100644 --- a/txdemo/stream_duplex_demo/main.cpp +++ b/txdemo/stream_duplex_demo/main.cpp @@ -78,7 +78,10 @@ static constexpr uint16_t kRealtekProductIds[] = { // kRadiotapLegacy6M constant. The canonical SA matcher in the packet // processor below is identical to demo/main.cpp's, so any tooling that // already grep'd lines keeps working unchanged. -static const std::vector kStreamRadiotap = +// Radiotap is MUTABLE here (the adaptive link rewrites the on-air rate live via +// the stdin SET_RATE control op). Guarded by g_rt_mu against the TX thread. +static std::mutex g_rt_mu; +static std::vector g_radiotap = devourer::build_stream_radiotap(devourer::parse_tx_mode_env()); static const uint8_t kCanonicalSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00}; @@ -137,8 +140,19 @@ static void packet_processor(const Packet &packet) { if (std::memcmp(packet.Data.data() + 10, kCanonicalSa, 6) != 0) return; long hits = ++g_rx_hits; std::lock_guard lk(g_print_mu); - std::printf("rate=%u len=%zu body=", - packet.RxAtrib.data_rate, packet.Data.size()); + // Full field set (mirrors demo/main.cpp) so the adaptive VRX can score RSSI/SNR + // and the VTX can read RCF/DISC bodies + ACK_SEQ. + std::printf("rate=%u len=%zu crc_err=%u icv_err=%u " + "rssi=%d,%d evm=%d,%d snr=%d,%d seq=%u tsfl=%u " + "bw=%u stbc=%u ldpc=%u sgi=%u body=", + packet.RxAtrib.data_rate, packet.Data.size(), + packet.RxAtrib.crc_err ? 1u : 0u, packet.RxAtrib.icv_err ? 1u : 0u, + packet.RxAtrib.rssi[0], packet.RxAtrib.rssi[1], + packet.RxAtrib.evm[0], packet.RxAtrib.evm[1], + packet.RxAtrib.snr[0], packet.RxAtrib.snr[1], + packet.RxAtrib.seq_num, packet.RxAtrib.tsfl, + packet.RxAtrib.bw, packet.RxAtrib.stbc, + packet.RxAtrib.ldpc, packet.RxAtrib.sgi); for (size_t i = 24; i < packet.Data.size(); ++i) std::printf("%02x", packet.Data[i]); std::printf("\n"); @@ -161,7 +175,7 @@ struct TxArgs { static void tx_thread(TxArgs args) { auto dot11 = build_dot11_probe_req(); std::vector tx_buf; - tx_buf.reserve(kStreamRadiotap.size() + dot11.size() + args.max_psdu); + tx_buf.reserve(g_radiotap.size() + dot11.size() + args.max_psdu); long tx_count = 0; while (!args.should_stop->load()) { @@ -176,6 +190,34 @@ static void tx_thread(TxArgs args) { | (static_cast(len_bytes[1]) << 8) | (static_cast(len_bytes[2]) << 16) | (static_cast(len_bytes[3]) << 24); + + // Control-opcode escape: top bit set -> the body is a control TLV (the + // adaptive link's live knobs), not a PSDU. . + if (len & 0x80000000u) { + uint32_t clen = len & 0x7fffffffu; + if (clen == 0 || clen > 256) break; + std::vector ctl(clen); + if (stream_stdin::read_exact(stdin, ctl.data(), clen) != + stream_stdin::ReadResult::Ok) + break; + uint8_t op = ctl[0]; + if (op == 1 && clen >= 2) { // SET_PWR + args.rtl->SetTxPowerOverride(ctl[1]); + args.rtl->ApplyTxPower(); + } else if (op == 2 && clen >= 2) { // SET_RATE + std::string spec(ctl.begin() + 1, ctl.end()); + auto rt = devourer::build_stream_radiotap(devourer::parse_tx_mode_str(spec)); + std::lock_guard lk(g_rt_mu); + g_radiotap = std::move(rt); + } else if (op == 3 && clen >= 4) { // SET_CHAN + args.rtl->SetMonitorChannel(SelectedChannel{ + .Channel = ctl[1], .ChannelOffset = ctl[2], + .ChannelWidth = static_cast(ctl[3])}); + } + std::fprintf(stderr, "ctl op=%u len=%u\n", op, clen); + continue; + } + if (len == 0 || len > args.max_psdu) { std::fprintf(stderr, "tx PSDU len %u out of range (max %zu)\n", @@ -189,8 +231,10 @@ static void tx_thread(TxArgs args) { break; } tx_buf.clear(); - tx_buf.insert(tx_buf.end(), kStreamRadiotap.begin(), - kStreamRadiotap.end()); + { + std::lock_guard lk(g_rt_mu); // live rate may be rewritten + tx_buf.insert(tx_buf.end(), g_radiotap.begin(), g_radiotap.end()); + } tx_buf.insert(tx_buf.end(), dot11.begin(), dot11.end()); tx_buf.insert(tx_buf.end(), psdu.begin(), psdu.end()); bool ok = args.rtl->send_packet(tx_buf.data(), tx_buf.size()); From f411b8c88bc9d844f29229974defe7f63f29bfde Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sun, 28 Jun 2026 08:07:58 +0300 Subject: [PATCH 2/2] adaptive-link: portable paths + rebalanced comparison doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace hardcoded ~/git/devourer absolute paths with __file__-relative ones (sim_loop, calibrate_energy, test_controller, adaptive_link --duplex default) so the code works regardless of clone location; test_controller no longer relies on another test's sys.path side-effect. - Rewrite docs/adaptive-link.md as a balanced comparison of the field (wfb-ng, OpenIPC alink, RubyFPV, OpenHD, DJI) rather than an alink-centric piece; drop implementation details (file paths, APIs, config constants); fix the inaccurate "video link is one-way" framing — the video is one-way broadcast but the link is two-way (RC up / telemetry down, feedback rides the return channel). Co-Authored-By: Claude Opus 4.8 --- docs/adaptive-link.md | 642 +++++++++++------------------- tests/calibrate_energy.py | 3 +- tests/sim_loop.py | 3 +- tools/precoder/adaptive_link.py | 3 +- tools/precoder/test_controller.py | 3 +- 5 files changed, 233 insertions(+), 421 deletions(-) diff --git a/docs/adaptive-link.md b/docs/adaptive-link.md index e6fabcf..160dc18 100644 --- a/docs/adaptive-link.md +++ b/docs/adaptive-link.md @@ -1,448 +1,256 @@ # Energy-minimizing adaptive video link -This document describes the **adaptive video link** for the OpenIPC / -wifibroadcast-style one-way downlink from a drone transmitter (**VTX**) to a -ground station (**VRX**), and how it differs from the existing adaptive systems -in the field — principally OpenIPC's `alink`. - -Its one-line thesis: **most adaptive links maximize video quality within a power -budget; this one minimizes energy-per-delivered-bit subject to a video-quality -floor.** It is the *dual* of `alink`, built for the case where battery endurance -(or thermal headroom, or shared-spectrum politeness) is the scarce resource and -"good enough" video is the constraint, not the objective. - -All of it is userspace. The radio knobs it drives already exist in -`src/RtlJaguarDevice` (per-packet radiotap MCS, `SetTxPowerOverride` + -`ApplyTxPower`, `SetMonitorChannel`, `GetThermalStatus`); the policy lives in -Python under `tools/precoder/`, and the C++ demo gains only mechanical control -hooks. +A long-range FPV link carries **video** one way — drone to ground, broadcast, +with no per-packet acknowledgement — but the link as a whole is **two-way**: an +RC uplink and a telemetry downlink already exist, and every adaptive system runs +its feedback over that existing return channel. So the radio can't learn +per-packet success the way an acknowledged Wi-Fi station does, yet the ground +*can* tell the drone how the link looks. An *adaptive* link uses that feedback to +change its transmit parameters as radio conditions change instead of fixing them +for the worst case. Several mature open systems already do this. This document +describes a design whose **objective differs from all of them** — it minimizes +the energy spent per delivered bit subject to a video-quality floor — and sets it +side by side with the rest of the field. ## The problem -A long-range drone video link runs one-way and broadcast: there are no -link-layer ACKs, so the transmitter cannot learn per-packet success from the MAC -the way an 802.11 station does. The classic deployment picks a single operating -point — one MCS, one TX power, one FEC overhead — sized for the *worst* moment of -the flight. That is wasteful in two directions: +A fixed link picks one operating point — one modulation, one transmit power, one +FEC strength — sized for the worst moment of the flight, and pays for it the +whole flight. That wastes resources in both directions: - **Close to the operator** the link has 20–30 dB of margin it never uses. Full - TX power into a strong link buys nothing but heat, battery drain, and a larger - interference footprint; heavy FEC and a robust low MCS burn airtime that could - have been idle (energy) or carried more video (quality). + power into a strong link buys nothing but heat, battery drain, and a wider + interference footprint; heavy FEC and a slow, robust modulation burn airtime + that could have carried more video or simply been idle. - **At long range** the fixed point either falls off a cliff (too aggressive) or - was so conservative that the whole flight paid for the worst case. + was so conservative the whole flight paid the worst-case tax. -The levers available, ranked by how they move **energy per delivered bit**: +Adaptation closes that gap. The interesting question is *toward what* — because +the objective, not the mechanism, is what separates the systems below. -| Lever | Link effect | Energy effect | -|---|---|---| -| **MCS / FEC overhead** (time-on-air) | strong | **strong** — less airtime → less PA-on time → fewer Joules/bit | -| **Channel / bandwidth** | strong | moderate | -| **TX power (TXAGC)** | **strongest** | **weak** — a 6.3× power increase costs only ~40% more energy/bit because the baseline circuit draw dominates | - -The asymmetry in the last two rows is the whole game: **time-on-air is the -dominant energy lever and radiated power is the dominant *link* lever but a weak -*energy* lever.** An energy-minimizing controller therefore rides the highest MCS -the link will bear (short airtime) and spends only the *minimum* TX power needed -to clear that MCS — the opposite reflex of a throughput-maximizer, which spends -power freely to unlock a still-higher MCS. - -## Objective: energy-min subject to a UEP SLA - -Formally, per FEC block on a given video layer, the cost is - -``` - P_baseline + airtime · P_pa(txagc) - E_bit = ────────────────────────────────── - src_bitrate · P_deliver -``` - -- `P_baseline` — LO + baseband + USB + RX, always on (the floor that makes power - a weak energy lever). -- `airtime = t_pre + payload_bits / R_phy(MCS, BW, SGI)` — the on-air fraction; - this is what MCS/FEC move. -- `P_pa(txagc)` — incremental PA draw at the chosen TXAGC index. -- `P_deliver` — post-FEC block-delivery probability at the link SNR (from the - link model). Failed delivery means the Joules were spent for nothing, so it - divides the cost. - -The controller minimizes `E_bit` **subject to** an unequal-error-protection -service-level agreement, not a single quality number: - -- **base / IDR layers: ≥ 99 % post-FEC delivery** — non-negotiable, never shed. -- **enhancement layers: best-effort** — the slack the controller sheds first - when SNR will not support them at any feasible energy. - -This is the cross-layer UEP of Abdel-Khalek & Heath (joint MCS + FEC source-aware -protection): importance is protected on *both* the PHY-rate and the outer-FEC- -rate knobs at once, producing a graceful-degradation staircase instead of one -cliff. See [Fused FEC](fused-fec.md) for the error-correction stack the SLA is -stated against. - -## Architecture - -``` - VTX (drone, one chip, StreamDuplexDemo) VRX (ground, one chip / SDR, StreamDuplexDemo) - video NALs ─▶ classify + per-layer UEP-FEC ─┐ ┌─ (RSSI / SNR / crc / seq) - per-layer radiotap MCS ─▶ duplex.stdin │ video │ ▶ sliding-window SCORE + post-FEC residual - apply control ◀── parse RCF │ ─────▶ │ ▶ energy-min CONTROLLER → operating point - SetTxPowerOverride / ApplyTxPower │ air │ ▶ RCF (profile + power + FEC) every ~100 ms - per-layer MCS ladder, FEC overhead │ ◀───── │ └─ DISC beacon when the VTX is lost - watchdog ─▶ MAX_RANGE failsafe / DISCOVERY │ RCF -``` - -Two design decisions shape it: - -- **Ground-station-authoritative.** The VRX has the clean receive-side view - (RSSI/SNR/CRC/seq + post-FEC residual loss), so it computes both the link - *score* and the *target operating point* and ships them in the feedback frame. - The VTX applies them mechanically, overlaying only local overrides (thermal - back-off, failsafe). One advisory bit is reserved for a future drone-decides - mode. This mirrors `alink`'s "GS decides" stance. -- **Python policy + thin C++ control surface.** The whole control loop, FEC, - protocol, and simulation are Python in `tools/precoder/`; the C++ duplex binary - (`txdemo/stream_duplex_demo/main.cpp`) gains only a stdin control-opcode escape - (`SET_PWR` / `SET_RATE` / `SET_CHAN`) so the policy can move the knobs with no - USB churn and no restart. - -### Module map - -| Module | Role | -|---|---| -| `tools/precoder/energy_model.py` | `R_phy` rate tables, airtime, `E_bit`; the nominal `P_baseline` + `P_pa[0..63]` + TXAGC-gain calibration (`DEFAULT_CALIB`), overridable by a metered JSON | -| `tools/precoder/link_model.py` | `(MCS, SNR) → P_deliver` via the measured-channel model in `fec_ab_sim`; `snr_required` per (MCS, overhead, target) | -| `tools/precoder/op_table.py` | enumerates `(MCS, FEC-overhead, BW)` link rows, precomputes `snr_req`, and resolves each to the **minimum** TXAGC that clears it + its `e_bit` | -| `tools/precoder/controller.py` | the per-layer energy-min loop + `SvcController` bank; argmin-`e_bit` over SLA-feasible rows, asymmetric hysteresis, failsafe | -| `tools/precoder/rc_proto.py` | RCF / DISC / DISC_ACK codec (CRC16-guarded, drop-not-misapply) + the shared profile table | -| `tools/precoder/score.py` | sliding-window link score from `` lines, weighted by post-FEC residual loss (not raw FCS) | -| `tools/precoder/rendezvous.py` | VTX / VRX receiver-initiated discovery state machines | -| `tools/precoder/adaptive_link.py` | the `--role vtx\|vrx` orchestrator wiring it all to the duplex binary | -| `tools/precoder/svc_pipeline.py` | end-to-end SVC-HEVC UEP simulator + closed-loop adaptive variant | -| `tests/sim_loop.py` | the offline fly-out-and-back headline (energy saved vs static baselines) | - -## The control algorithm - -Each VRX feedback sample drives one update: - -1. **Estimate path loss, not SNR.** TX power changes the *received* SNR, so EWMA- - ing SNR across a power change corrupts the estimate (and delivery collapses at - every transition). The controller instead EWMA-s **path loss = - `reported_snr − gain_db(reported_txagc)`**, which is TXAGC-independent. The - EWMA is **asymmetric** — it reacts fast when the link weakens - (`ema_alpha_down = 0.8`, so power goes up in time) and slowly when it - strengthens (`ema_alpha = 0.3`, so it doesn't chase noise upward). -2. **Score the link feasible rows.** For every `(MCS, FEC-overhead)` row whose - `snr_req` is met by the estimate (minus a `margin_db = 2.0` entry hysteresis) - *and* whose PHY rate carries the layer's `src_bitrate`, resolve the **minimum - TXAGC** that supplies `snr_req` at the estimated path loss. -3. **Pick argmin `e_bit`.** Among feasible rows, choose the cheapest energy-per- - delivered-bit — the inversion of `alink`'s argmax-throughput. -4. **Hysteresis, slow-up / fast-down.** A cheaper row is only adopted if it is - ≥ `improve_frac = 3 %` cheaper and `min_between_changes_ms = 150` has elapsed; - after a downgrade the controller holds for `hold_after_downgrade_ms = 4000`. - A *failing* current row downgrades immediately. TXAGC (the cheap, non- - disruptive lever) tracks the estimate freely between row changes. -5. **Failsafe.** No feedback for `feedback_timeout_ms = 1000` → the controller - returns `MAX_RANGE` (most robust MCS, heaviest FEC, full power) and the VTX - keeps transmitting; persistent loss escalates to rendezvous. - -TXAGC is deliberately **not** a row dimension: it is chosen at runtime as the -minimum index that clears the chosen row, so the table stays small and power is -always the least that works. - -### SVC unequal error protection - -`SvcController` is a bank of the controllers above, one per temporal layer, with -per-layer targets and shed permission: - -| Layer | Target | Shed? | PHY MCS (default ladder) | Outer-FEC overhead | -|---|---|---|---|---| -| critical (IDR / VPS/SPS/PPS) | 0.999 | never | MCS0 20 MHz LDPC STBC | 1.00 | -| T0 base | 0.99 | never | MCS1 20 MHz LDPC STBC | 0.75 | -| T1 | 0.95 | yes | MCS4 20 MHz | 0.50 | -| T2 | 0.90 | yes | MCS7 40 MHz SGI | 0.25 | - -The PHY-MCS ladder is the C++ `svc::LayerPolicy` (`txdemo/svc_tx_demo/svc_tx.h`, -`DEVOURER_SVC_LADDER`); the FEC-overhead ladder is `svc_uep_fec.default_uep_policy`. -Both halves protect the same layers. One PA serves every layer, so the commanded -TX power is the **max** any active layer needs; a layer that no feasible row can -carry is shed (its airtime and energy are saved) rather than delivered badly. - -`svc_pipeline.run_svc_pipeline_adaptive` runs this closed loop in software end to -end: a `SvcController` picks each layer's MCS, the shed set, and the shared power -from a reported sample; the pipeline transmits only the active layers at the -commanded MCS over the resulting effective SNR. The observed behaviour: - -``` -reported SNR @ txagc 32 active layers shared txagc per-layer NAL delivery - +40 dB [crit,T0,T1,T2] 0 all 1.000 (power backed off) - +4 dB [crit,T0] 52 crit/T0 1.000, T1/T2 shed -``` - -## Energy and link models — "model now, meter later" - -The controller needs numbers it cannot yet measure on this bench, so both models -ship with a **documented nominal calibration** and a clean hook to anchor to -hardware later: - -- **Energy** (`energy_model.DEFAULT_CALIB`): `P_baseline = 0.7 W` floor; a PA - curve `P_pa[idx]` rising ~0.1 W → ~1.5 W across TXAGC 0..63 and compressing - near the top; a concave TXAGC→gain curve to ~25 dB; `t_pre` preamble airtime. - `tests/calibrate_energy.py` replaces these by fitting the duty-sweep + TXAGC- - sweep from `tests/thermal_gain_sweep.py` (thermal `delta` = PA-dissipated - proxy) and `tests/sdr_power_probe.py` (USRP radiated proxy). -- **Link** (`link_model`): a nominal per-MCS FCS waterfall + sub-block survivor - shape, fed through `fec_ab_sim.sim_interframe` so delivery and airtime are - priced with the *same* measured-channel accounting the fused-FEC work uses. - `tests/calibrate_link.py` replaces the waterfall with histograms swept from - `tests/sdr_interferer.py` + `fused_fec_link.FusedFecReceiver.report()`. - -The *shape* — which operating point is cheapest — is correct from the nominal -model; only the absolute Joules and SNR thresholds move once metered. Relative -energy savings are valid without a DC meter; an absolute figure needs one, and -the JSON hook is where it lands. - -## Feedback and rendezvous protocol - -`rc_proto.py` defines three CRC16-guarded frames carried as sub-block bodies -([SBI framing](fused-fec.md), so they share the corrupt-frame-salvage path): - -- **RCF** (VRX→VTX, ~100 ms): magic `"RC"`, flags (AUTH / FAILSAFE / DISCOVERY), - VTX-ID, seq, ack-seq, profile index, an `alink`-style 1000–2000 score, explicit - `pwr_idx` and `fec_overhead`, and per-layer delivery. A bad CRC drops the frame - — it is never half-applied. -- **DISC / DISC_ACK** (rendezvous): VTX-ID, nonce, op-channel/width, profile-table - version, init profile, capability bits. - -**Receiver-initiated rendezvous** (the community pattern, formalized after RIT / -802.11ba wake-up-radio): when the VTX loses the RC uplink it enters a low-duty -discovery listen (~50 ms on / ~1 s period on a single 2.4 GHz discovery channel, -dodging the 5 GHz Vbus-sag gotcha); the wall-powered VRX beacons DISC carrying -that VTX's ID *fast*, so any listen window overlaps ≥ 2 beacons. The duty is -deliberately asymmetric — a cheap battery-powered listener and an expensive -mains-powered beaconer. DISC → DISC_ACK → both `SET_CHAN` to the op channel → -session. The watchdog input is kept abstract (`last_rc_monotonic`) so a real RC -uplink is a one-line wire-in; today `ADAPTIVE_RC_SILENCE_AFTER_MS` fires it -deterministically for tests. - -## How it compares - -The open long-range FPV field has converged on adaptive Wi-Fi video, but every -system optimizes for *quality, latency, or survival* — **none makes energy the -objective, and none does per-temporal-layer SVC unequal error protection.** Those -two are this design's distinguishing axes. The systems below are the closest -relatives; a feature matrix summarizing all of them follows. - -### wifibroadcast / wfb-ng (the substrate) - -The common ancestor and, for the OpenIPC world, the substrate: a **pure one-way -FEC broadcast** link — no ACKs, no ARQ, video sprayed as FEC-coded blocks with -RX-side diversity and distributed bonding (several ground receivers, the -best-signal one wins). By itself it is **static** — one MCS, one block-FEC `k/n`, -one power, chosen for the worst case and paid for the whole flight. In the -fly-out-and-back simulation a fixed *robust* profile costs **2.1× the energy per -delivered bit** of the adaptive loop. Everything adaptive in the OpenIPC ecosystem -(including `alink`) sits *on top of* this layer. See -[wfb-ng tuning](wfb-ng-tuning.md) for the static knobs. - -### OpenIPC `alink` - -`alink` is **not a separate radio stack — it is an adaptive-control sidecar that -rides on wfb-ng.** The ground station scores the link from per-packet RSSI/SNR -into a 1000–2000 quality number and selects a **TX profile**; each profile in -`txprofiles.conf` bundles a full operating point — **video bitrate + MCS + FEC -`k/n` + guard interval + GOP/keyframe + TX power + ROI-QP** (region-of-interest -quantization, a *spatial* quality bias within a frame) — and the air unit applies -the commanded profile, with hysteresis to avoid flapping. Its objective is the -**highest sustainable video quality**. It is mature, deployed, and the direct -inspiration for this design's *structure*: ground-authoritative scoring, ~100 ms -cadence, hysteresis, a max-range failsafe. - -The difference is the **objective**, and it changes the reflexes: - -| | OpenIPC `alink` | This link | +## Two questions that separate every system + +**What does it change (the levers)?** Video bitrate, modulation and coding (the +MCS / data rate), forward-error-correction strength, transmit power, +channel/bandwidth, and keyframe cadence are the knobs in play. Few systems move +all of them; which ones they move automatically vs. leave to the operator is a +real differentiator. + +**What does it optimize for (the objective)?** This is the axis that matters +most, because it dictates the reflexes when the link is strong: + +- **maximum sustained quality** — push bitrate up whenever the link allows; +- **minimum latency** — never retransmit, keep the pipeline short; +- **maximum survival** — trade quality *and* latency to keep *a* picture alive; +- **minimum energy per delivered bit** — the objective of the design here. + +These are not interchangeable. A quality-maximizer spends transmit power freely +to unlock a higher modulation; an energy-minimizer does the opposite. Both are +"adaptive," and they behave like opposites on a strong link. + +## This design: energy-min under a quality floor + +The objective is to minimize energy per *delivered* bit — total Joules (the +always-on baseline draw plus the amplifier) amortized over the video bits that +actually arrive — while holding a delivery floor on the important parts of the +picture. Two ideas make that tractable. + +**Time-on-air is the dominant energy lever; transmit power is the dominant *link* +lever but a weak *energy* lever.** A radio's baseline draw (oscillator, baseband, +USB, receiver) is always on, so the power amplifier is only a fraction of the +total; pushing it hard costs far less energy than the airtime it saves. Concretely, +a several-fold increase in radiated power adds only tens of percent to energy per +bit, while a faster modulation cuts airtime — and therefore PA-on time — directly. + +| Lever | Effect on the link | Effect on energy/bit | |---|---|---| -| Objective | **max quality** within link/power budget | **min energy/bit** subject to a quality floor | -| Operating point | a hand-authored **profile** (preset MCS+FEC+power+bitrate) per score band | rows resolved at runtime; **TXAGC chosen as the minimum that clears the MCS** | -| TX power | a per-profile preset | a continuous lever — the least power that works | -| When the link is strong | push bitrate up | **back power and FEC off** (idle the PA, save Joules) | -| Unequal protection | **ROI-QP** — spatial, within a frame | **per-temporal-layer** PHY-MCS ⊕ FEC ladder, enhancement **shed** | -| Energy model | not a first-class term | explicit `E_bit` (airtime × power), the thing minimized | - -`alink` is the right tool when the mission wants the best picture the spectrum -allows; this link is the right tool when endurance, thermal headroom, or a small -RF footprint is the scarce resource and the picture only has to stay *good -enough*. They are duals — the operating-point machinery is nearly identical; the -cost function is inverted, and the UEP is temporal rather than spatial. +| Modulation / FEC (time-on-air) | strong | **strong** — less airtime, fewer Joules/bit | +| Channel / bandwidth | strong | moderate | +| Transmit power | **strongest** | **weak** — baseline draw dominates | + +So the energy-minimizing reflex is to ride the highest modulation the link will +bear (short airtime) and spend only the *minimum* power that clears it. When the +link is strong, it backs power and FEC off and lets the amplifier idle — exactly +where a quality-maximizer would instead spend the headroom on bitrate. + +**The quality floor is per-layer, not a single number.** Scalable video splits +into a base layer (decodable alone, low frame rate) plus enhancement layers that +refine it. The base and key frames are small but indispensable; the enhancement +layers are large but optional. So protection is unequal by design: + +- base / key frames are held to a near-perfect delivery target and never dropped; +- enhancement layers are best-effort — the slack the controller sheds first when + the link can't carry them at any sensible energy. + +Protection tracks importance on *both* knobs at once — the robust layers fly at +the most robust modulation *and* carry the heaviest FEC — which yields a graceful +staircase of degradation (enhancement fades, then thins, while the base holds) +instead of a single cliff. + +**The ground station decides.** The receiver has the clean view of link quality, +so it scores the link and chooses the operating point, and the drone applies it +with only local safety overrides (thermal back-off, a max-range failsafe when +feedback is lost). This is the same "ground decides" stance the OpenIPC world +uses; the difference is purely the cost function it optimizes. + +**Re-finding each other.** When the command uplink drops, the drone falls to a +robust failsafe and then to a low-duty listen on a known channel; the +mains-powered ground station beacons for it quickly. The duty cycle is +deliberately asymmetric — a cheap battery-powered listener, an expensive +always-on beaconer — so rendezvous is quick without costing the drone the energy +the whole design is trying to save. + +## The landscape + +### wifibroadcast / wfb-ng + +The common ancestor for the OpenIPC world and the substrate the OpenIPC adaptive +layer rides on: a pure one-way FEC broadcast — no acknowledgements, no +retransmission, video sprayed as FEC-coded blocks, with receive-side diversity +across several ground antennas. On its own it is **static**: one modulation, one +FEC ratio, one power, chosen up front. Robust and simple, but it carries the +worst-case airtime and power for the entire flight. Everything adaptive in that +ecosystem sits on top of this layer. + +### OpenIPC adaptive link (`alink`) + +Not a separate radio stack but an **adaptive-control sidecar on wfb-ng**. The +ground station scores the link from signal strength into a single quality number +and selects a pre-authored **profile**; each profile bundles a complete operating +point — bitrate, modulation, FEC ratio, guard interval, keyframe cadence, +transmit power, and a region-of-interest quality bias within the frame. The +objective is the **highest sustainable video quality**, with hysteresis to avoid +flapping. It is mature and widely deployed, and its structure — ground scores, +profile applied on the air, max-range failsafe — is the reference shape for +OpenIPC adaptive links. Its one form of unequal protection is *spatial* +(sharper centre of frame), not per-temporal-layer. ### RubyFPV -A **complete, self-contained air+ground FPV system** (its own raw-Wi-Fi protocol, -not wfb-ng) on RTL8812AU/8812EU radios. Two things set it apart from the wfb -lineage: it runs an **ARQ retransmission** layer (wifibroadcast deliberately does -not), and its adaptive loop is **predictive** — it synthesizes "Video Quality and -Prediction" (VQP) parameters from both-end statistics (missing data, RSSI, -error-correction used, retransmission requests, link latency) to describe link -quality *in the near future* and pre-empt breakups, not just react. It adapts in -graduated steps — **FEC rate + H.264 params + bitrate** first, escalating to -**lowering the radio data rate (MCS)** only when those are exhausted — plus an -**adaptive keyframe interval**. - -Two structural contrasts with this link: - -- **Authority is inverted.** RubyFPV is **vehicle-authoritative** — the air unit - computes VQP and applies changes, using controller feedback as an *input*, and - falls back to a vehicle-only algorithm when that feedback is lost (the common - long-range case where the uplink dies first). This link is - ground-authoritative, with a low-duty rendezvous to re-establish the session - rather than a vehicle-only mode. -- **No energy objective and no per-layer UEP.** RubyFPV's goal is explicitly - **robustness — it trades quality *and* latency for link survival**; FEC is - adapted globally, not per temporal layer, and **TX power is not an adaptation - target**. Its multi-band "parallel links" (433/868/915 MHz, 2.4, 5.8 GHz) and - relaying are *redundancy/resilience* features, not energy or throughput - optimization. Source is C/C++ under a custom non-OSI "Ruby Licence" (no - military use). +A complete, self-contained air-and-ground system with **its own raw-Wi-Fi +protocol**, not wfb-ng. Two traits set it apart. First, it runs a +**retransmission (ARQ)** layer, which the wifibroadcast lineage deliberately +avoids. Second, its control loop is **predictive**: it fuses statistics from both +ends (missing packets, signal strength, FEC consumed, retransmission requests, +latency) into a forward-looking estimate of link quality and adjusts *before* the +picture breaks, rather than purely reacting. It adapts in graduated steps — FEC, +encoder parameters, and bitrate first, dropping the radio data rate only when +those are exhausted — and adjusts the keyframe interval. Its objective is +explicit **robustness**: it will trade both quality and latency to keep the link +alive. Notably, the **drone is authoritative** — it makes and applies the +decision, using ground feedback as an input and falling back to a drone-only mode +when that feedback is lost — the opposite of the ground-decides systems, and +**transmit power is not an adaptation target**. Its multi-band parallel links and relaying +are redundancy features, not energy or throughput optimization. ### OpenHD -The other major open ecosystem, descended from the same befinitiv wifibroadcast -root as wfb-ng but an **independent fork** with its **own** C++ broadcast library -(`OpenHD/wifibroadcast`, GPLv3) and Realtek driver fork. Its design priority is -**latency** — FEC instead of ARQ, ~100 ms glass-to-glass, H.265 to save every -millisecond. Its adaptation is narrower than the others': - -- **Bitrate is the only automatic, closed-loop knob** — variable bitrate is on by - default and the encoder bitrate is **reduced on transmission errors**. (OpenHD's - public docs do not specify the exact metric or whether ground-measured RSSI/SNR - is relayed to the air unit to drive it, so the loop's authority is less defined - than `alink`'s or this design's.) -- **MCS / channel width and TX power are operator knobs, not closed loops** — MCS - is switched manually in flight via an RC channel (`MCS_VIA_RC`); TX-power index - (0–63) is runtime-adjustable but manual; keyframe interval is manual. FEC is - "optimized" but not documented as adaptively retuned. -- **No per-layer UEP/SVC and no energy objective.** It does provide ground **RX - diversity** (up to two receivers, best-signal auto-select) and bidirectional - MAVLink. GPLv3, C++. - -So OpenHD adapts *one* lever automatically (bitrate) where `alink` and this link -adapt the whole operating point; and like every system here it treats neither -energy nor temporal-layer protection as a control variable. - -### DJI OcuSync / O3 / O4 - -Proprietary and closed. OcuSync already does adaptive coding & modulation, -adaptive bitrate, and frequency agility, and is generally understood to bias -toward *latency and quality*. There is no public evidence of an energy objective -or of exposed per-layer protection. This design targets the same adaptivity in -the open stack and adds the explicit energy objective and per-temporal-layer UEP -that a closed system does not expose. - -### Feature matrix - -`A` = automatic / closed-loop, `M` = manual operator knob, `—` = absent or not -public. "Per-layer UEP" means temporal-layer (SVC) unequal protection on the FEC -*and* MCS knobs. - -| Dimension | wfb-ng | OpenIPC alink | RubyFPV | OpenHD | DJI O3/O4 | **This link** | +The other large open ecosystem, an **independent fork** of the same +wifibroadcast root with its own broadcast library and driver. Its priority is +**latency** — FEC rather than retransmission, glass-to-glass around 100 ms, H.265 +to save every millisecond. Its adaptation is narrower than the others': **video +bitrate is the one automatic, closed-loop knob** (reduced when the link shows +errors), while modulation, channel width, transmit power, and keyframe interval +are **operator controls** — adjustable in flight (some via an RC switch) but not +closed loops. It provides ground receive diversity and two-way telemetry, but +treats neither energy nor per-layer protection as a control variable. + +### DJI OcuSync (O3 / O4) + +Proprietary and closed, included as the commercial reference point. OcuSync does +adaptive coding and modulation, adaptive bitrate, and frequency agility, and is +understood to bias toward **latency and quality**. There is no public evidence of +an energy objective or of any exposed per-layer protection. + +### 802.11 rate adaptation and academic energy-aware work + +The standard in-kernel rate adapters (Minstrel-HT and kin) are +**throughput-maximizers driven by per-packet acknowledgements** — neither +assumption holds for an ACK-less video broadcast, and they are blind to +application FEC, video-layer importance, and energy. Separately, academic +energy-rate adaptation establishes the principle this design rests on: +minimizing energy per bit means riding a high modulation and using the least +power that sustains it. Those schemes are typically unicast/ACK-driven and not +source-aware; this design applies the same bits-per-Joule principle to a +broadcast video downlink and couples it to per-layer protection. + +## Comparison at a glance + +`A` = automatic / closed-loop · `M` = manual operator knob · `—` = absent or not +public. "Per-layer protection" means temporal-layer (scalable-video) unequal +protection on both the FEC and modulation knobs. + +| | wfb-ng | OpenIPC alink | RubyFPV | OpenHD | DJI O3/O4 | **This design** | |---|---|---|---|---|---|---| -| Relation to stack | broadcast link layer | **sidecar on wfb-ng** | own raw-Wi-Fi (+ARQ) | own wifibroadcast fork | proprietary | controller on devourer | -| Adaptation objective | none (static) | max quality | robustness (survive) | latency-first quality | latency / quality | **min energy/bit s.t. UEP SLA** | -| Auto video bitrate | — | A | A (fine steps) | A (default) | A | layer **shed** vs lowered | -| Auto MCS / data rate | — | A (per profile) | A (escalation) | M (RC switch) | A (ACM) | A (per layer) | -| Auto FEC overhead | — | A (per profile) | A (global) | — | A | **A (per-layer ladder)** | -| Auto TX power | — | preset per profile | — | M (runtime) | A (closed) | **A (continuous min-power)** | -| Feedback authority | n/a | **ground** (RSSI→score) | **vehicle** (VQP, predictive) | mgmt bidir. (metric undoc.) | proprietary | **ground** | -| ARQ / retransmit | — | — | **yes** | — (latency) | — | — | -| Per-layer (SVC) UEP | — | spatial ROI-QP only | — | — | — | **yes (PHY ⊕ FEC, shed)** | -| Corrupt-frame salvage | — | — | — | — | — | **yes (SBI sub-blocks)** | +| Stack | broadcast layer | sidecar on wfb-ng | own protocol (+ARQ) | own wifibroadcast fork | proprietary | sidecar on devourer | +| Objective | none (static) | max quality | survival | min latency | latency / quality | **min energy/bit** | +| Quality floor | — | one target | one target | one target | — | **per-layer (base ≥ 99 %)** | +| Auto bitrate | — | A | A | A | A | layer **shed** vs lowered | +| Auto modulation | — | A (profile) | A | M | A | A (per layer) | +| Auto FEC | — | A (profile) | A (global) | — | A | A (per-layer) | +| Auto TX power | — | profile preset | — | M | A | **A (min that works)** | +| Decides | n/a | ground | **drone** | mixed | proprietary | ground | +| Retransmit (ARQ) | — | — | yes | — | — | — | +| Per-layer protection | — | spatial only | — | — | — | **yes (modulation + FEC)** | | Energy-aware | — | — | — | — | — | **yes (the objective)** | -| License | GPL | GPL | custom (non-OSI) | GPLv3 | closed | (devourer) | - -The two fully-populated rows unique to this link — **energy-aware** and -**per-layer UEP + corrupt-frame salvage** — are the contribution; everything else -in its column is shared with the mature systems it learned from. - -### 802.11 rate adaptation (Minstrel-HT, SampleRate, RRAA) - -The standard in-kernel rate adapters are **throughput-maximizers driven by -per-packet ACKs**. They do not apply here for two reasons: the link is -ACK-less broadcast injection (no MAC success signal to adapt on), and they are -oblivious to application FEC, video-layer importance, and energy. This controller -adapts on an explicit out-of-band score, not ACKs, and optimizes energy under a -UEP constraint — a different problem. +| Open | yes | yes | source-available | yes | no | yes | -### Academic energy-aware rate adaptation (e.g. ERAA) - -Energy-rate adaptation research establishes the core result this design rests on -— minimizing energy-per-bit means riding a high MCS (short airtime) and using -the least power that sustains it, with reported savings around ~44 % at ~90 % of -peak throughput. Those schemes are typically unicast/ACK-driven and not source- -aware. This link applies the same bits-per-Joule principle to an ACK-less -broadcast video downlink and couples it to cross-layer SVC UEP and a receiver- -initiated rendezvous for session establishment. +Read down the last column: almost everything in it is shared with the mature +systems this design learned from. The two rows that are unique to it — +**energy as the objective** and **per-temporal-layer unequal protection** — are +the contribution. It is closest in *shape* to OpenIPC's `alink` (ground decides, +profile applied on the air) but inverts the cost function from quality to energy; +it is the structural opposite of RubyFPV (drone-authoritative, survival-first, +retransmitting); and it adapts more of the operating point automatically than +OpenHD, which closes the loop on bitrate alone. ## Results -Software, the headline is `tests/sim_loop.py` — a time-varying "fly out and back" -path-loss schedule pushed through the controller + link model + energy model, -compared against two static baselines tuned on the same models: - -| Strategy | Energy / delivered bit | Delivery | Notes | -|---|---|---|---| -| **Adaptive (this link)** | **205.7 nJ** | 0.999 | 2 operating-point changes over 200 ticks (no flapping) | -| Static energy-min profile | 310.6 nJ | 1.000 | best single fixed point — adaptive saves **34 %** | -| Static robust profile | 435.2 nJ | 1.000 | over-provisioned worst-case — adaptive saves **53 %** | - -The SVC pipeline (`svc_pipeline.py`) shows the UEP staircase end to end against a -synthetic HEVC stream (`tests/gen_svc_nals.py`): as SNR drops, T2 sheds first, -then T1, then T0, while the critical/IDR layer holds at 1.000 delivery far below -where enhancement is gone — and SBI sub-block salvage delivers materially more -than whole-frame erasure on the marginal layer. - -The whole `tools/precoder` suite — controller, protocol, rendezvous, SVC pipeline -— runs headlessly in CI (`.github/workflows/precoder-tests.yml`). - -On hardware, `tests/adaptive_onair.sh` closes the loop over two adapters (8812 -VTX ↔ 8821 VRX) with an optional B210 interferer: the VRX scores the link and -commands an operating point, the VTX applies it, and the *witness* is the peer's -own `` — `rate=` changes when a `SET_RATE` lands, `rssi=` rises -when a `SET_PWR` raises power, with no extra instrumentation. The base link -adapts MCS and power on air and rides the failsafe/rendezvous transitions. - -## Current scope and integration points - -- **Energy is modeled, not metered.** Relative savings are valid on the nominal - calibration; an absolute Joule figure needs the DC-meter anchor that the - calibration JSON hook accepts. Thermal `delta` and SDR `dbfs` cross-check the - shape. -- **No real RC uplink in-repo.** RC-loss is driven through the abstract watchdog - input; a real uplink wires into `last_rc_monotonic`. -- **On-air SVC today is non-adaptive.** `SvcTxDemo` already flies each HEVC - temporal layer at its own MCS on air (`tests/svc_uep_onair.sh`); the - *adaptive* SVC path — the VRX's `SvcController` retuning the per-layer ladder - and shed set live via a per-frame layer-tagged radiotap in - `stream_duplex_demo` — is the integration point between the software-validated - loop and the on-air binary. +A time-varying "fly out and back" path-loss schedule, run in simulation against +two static baselines tuned on the same models: + +| Strategy | Energy / delivered bit | Delivery | +|---|---|---| +| **Adaptive (this design)** | **206 nJ** | 0.999 | +| Best fixed energy-min profile | 311 nJ | 1.000 | +| Over-provisioned robust profile | 435 nJ | 1.000 | + +The adaptive link saves **34 %** of the energy per delivered bit versus the best +single fixed point and **53 %** versus an over-provisioned worst-case profile, +while holding the delivery floor and without flapping between operating points. + +Separately, pushing synthetic scalable-video through the link reproduces the +graceful staircase: as the link weakens the top enhancement layer sheds first, +then the next, while the base and key frames hold near-perfect delivery far +below the point where enhancement is gone. + +## Scope + +- **Energy is modeled, not metered.** The savings are *relative* figures on a + documented nominal power model; an absolute Joule number needs a DC-meter + anchor, for which the model leaves a hook. The *shape* — which operating point + is cheapest — holds without it. +- **The command uplink is abstracted.** There is no real RC radio in the repo, so + uplink loss is simulated to exercise the failsafe and rendezvous; a real uplink + drops into the same watchdog input. +- **On-air scalable video is not yet adaptive.** Each temporal layer can already + fly at its own modulation on air; the closed loop that retunes the per-layer + ladder live is validated in software and is the next on-air integration step. ## References -- OpenIPC project — -- OpenIPC Adaptive-Link (`alink`) — -- wfb-ng (wifibroadcast-NG), svpcom — -- RubyFPV — , +- OpenIPC — · + Adaptive-Link — +- wfb-ng (wifibroadcast-NG) — +- RubyFPV — · [adaptive video link](https://rubyfpv.com/resource_adaptive_video_link.php) -- OpenHD — , broadcast library - , +- OpenHD — · [variable bitrate](https://openhdfpv.org/software-setup/variable-bitrate/) - A. Abdel-Khalek and R. W. Heath, "Joint MCS and FEC for unequal error - protection of scalable video," *IEEE JSAC*, 2012 — cross-layer UEP. + protection of scalable video," *IEEE JSAC*, 2012. - "All Bits Are Not Equal: A Study of IEEE 802.11 Communication Bit Errors," - *IEEE INFOCOM*, 2009 — localized corruption, the basis for sub-block salvage. -- Energy-rate adaptation (ERAA and related) — energy-per-bit minimization, - high-MCS / minimum-power result. -- IEEE 802.11ba (Wake-Up Radio) and Receiver-Initiated Transmission (RIT) — the + *IEEE INFOCOM*, 2009 — the basis for sub-block salvage. +- IEEE 802.11ba (Wake-Up Radio) / Receiver-Initiated Transmission — the asymmetric-duty rendezvous pattern. -- [Fused FEC](fused-fec.md) — the concatenated error-correction stack the UEP SLA - is stated against. -- [wfb-ng tuning](wfb-ng-tuning.md) — the static-link baseline. +- [Fused FEC](fused-fec.md) — the error-correction stack the quality floor is + stated against · [wfb-ng tuning](wfb-ng-tuning.md) — the static baseline. diff --git a/tests/calibrate_energy.py b/tests/calibrate_energy.py index 7122520..9adda45 100644 --- a/tests/calibrate_energy.py +++ b/tests/calibrate_energy.py @@ -24,7 +24,8 @@ import os import sys -sys.path.insert(0, os.path.expanduser("~/git/devourer/tools/precoder")) +sys.path.insert(0, os.path.normpath(os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "tools", "precoder"))) import energy_model # noqa: E402 diff --git a/tests/sim_loop.py b/tests/sim_loop.py index 14c9d4f..b72d632 100644 --- a/tests/sim_loop.py +++ b/tests/sim_loop.py @@ -13,7 +13,8 @@ import os import sys -sys.path.insert(0, os.path.expanduser("~/git/devourer/tools/precoder")) +sys.path.insert(0, os.path.normpath(os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "tools", "precoder"))) import energy_model as em import link_model as lm import op_table diff --git a/tools/precoder/adaptive_link.py b/tools/precoder/adaptive_link.py index a7da453..452b5d4 100644 --- a/tools/precoder/adaptive_link.py +++ b/tools/precoder/adaptive_link.py @@ -352,7 +352,8 @@ def main(): ap.add_argument("--channel", type=int, default=6) ap.add_argument("--vtx-id", type=lambda x: int(x, 0), default=0xABCD) ap.add_argument("--video", help="VTX video source file (length-agnostic bytes)") - ap.add_argument("--duplex", default=os.path.expanduser("~/git/devourer/build/StreamDuplexDemo")) + ap.add_argument("--duplex", default=os.path.normpath(os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "..", "build", "StreamDuplexDemo"))) ap.add_argument("--link-calib"); ap.add_argument("--energy-calib") a = ap.parse_args() diff --git a/tools/precoder/test_controller.py b/tools/precoder/test_controller.py index 7ec596c..c4fe3bd 100644 --- a/tools/precoder/test_controller.py +++ b/tools/precoder/test_controller.py @@ -74,7 +74,8 @@ def test_base_layer_never_sheds(): def test_sim_energy_savings_headline(): - sys.path.insert(0, os.path.expanduser("~/git/devourer/tests")) + sys.path.insert(0, os.path.normpath(os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "..", "tests"))) import sim_loop r = sim_loop.main() assert r["save_vs_robust"] >= 0.25 # vs over-provisioned set-and-forget