diff --git a/docs/adaptive-link.md b/docs/adaptive-link.md index 45b7c0a..c3daa3e 100644 --- a/docs/adaptive-link.md +++ b/docs/adaptive-link.md @@ -67,12 +67,26 @@ bit, while a faster modulation cuts airtime — and therefore PA-on time — dir | Modulation / FEC (time-on-air) | strong | **strong** — less airtime, fewer Joules/bit | | Channel / bandwidth | strong | moderate | | Transmit power | **strongest** | **weak** — baseline draw dominates | +| Active receive chains | conditional | **conditional** — see below | 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 number of active receive chains is a fade-state lever.** A multi-chain +receiver can light up one antenna or several. Each extra chain adds always-on +baseline draw, but combining several chains improves delivery — which, for a +fixed video rate, means less airtime spent covering losses and the baseline +amortised over more delivered bits. Whether that trade pays depends entirely on +how *decorrelated* the antennas are: at a static position, closely-spaced +antennas see nearly the same channel, so combining barely improves delivery and +the extra chains are pure baseline cost; under motion the antennas decorrelate, +so combining fills the fades and the added chains cheapen delivery by more than +they cost. The energy-minimizing chain count is therefore not fixed — it collapses +toward a single chain on a still, strong link and opens up as the link fades or +the platform moves. It adapts to the fade state, not merely to range. + **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 diff --git a/tools/precoder/chain_count_energy_sim.py b/tools/precoder/chain_count_energy_sim.py new file mode 100644 index 0000000..20e90bd --- /dev/null +++ b/tools/precoder/chain_count_energy_sim.py @@ -0,0 +1,165 @@ +"""Active-chain count as an energy lever (spatial-diversity #135). + +A multi-chain receiver can light up 1..N RF chains. Each active chain adds +always-on baseline draw (LNA + mixer + ADC), but more chains combine to improve +delivery — which, for a fixed video bitrate, means less airtime/FEC spent +covering losses and the baseline amortised over more delivered bits. So there is +an optimal chain count that minimises energy per *delivered* bit, and — this is +the point — it depends on the fade state: + + * static / correlated antennas (high ρ): combining barely helps delivery, so + extra chains are pure baseline cost → run FEWER chains; + * moving / decorrelated antennas (low ρ): combining fills the fades, so extra + chains cheapen delivery more than their baseline costs → run MORE chains. + +This is the energy corollary of the measured result (RX-MRC gives ~nothing at a +static position and eliminates dropouts under motion; see +docs/measuring-spatial-diversity.md): a ground station should adapt its chain +count to the fade/motion state, not just to range. + +The model reuses the calibrated power/airtime model (energy_model.py) and adds +two terms: a per-chain baseline, and a diversity-improved delivery parameterised +by the antenna correlation ρ (via the same N_eff used elsewhere). + +CLI: + uv run python chain_count_energy_sim.py # ρ sweep table + uv run python chain_count_energy_sim.py --self-test +""" +from __future__ import annotations + +import argparse + +from energy_model import (Calib, TxPoint, airtime_fraction, load_calibration) + +# Per-active-RX-chain baseline draw (W) on top of the core baseline (LO + USB + +# baseband + one chain). LNA + mixer + ADC of an extra chain — a fraction of the +# core floor. +P_CHAIN_W = 0.15 + + +def n_eff(n_ant: int, rho: float) -> float: + """Effective diversity order of n_ant equicorrelated branches (participation + ratio N/(1+(N-1)ρ²)); ρ=0 → n_ant, ρ=1 → 1. Same metric as the measurement + doc and space_freq_diversity_sim.""" + if n_ant <= 1: + return 1.0 + return n_ant / (1.0 + (n_ant - 1) * rho * rho) + + +def deliver_multichain(d1: float, n_ant: int, rho: float) -> float: + """N-chain delivery from single-chain delivery d1. Combining drops the + per-chain outage (1-d1) with the effective diversity order: outage^N_eff.""" + return 1.0 - (1.0 - d1) ** n_eff(n_ant, rho) + + +def energy_per_bit_nchain(p: TxPoint, src_bps: float, overhead: float, + payload: int, d1: float, n_ant: int, rho: float, + calib: Calib, p_chain_w: float = P_CHAIN_W) -> float: + """J per delivered source bit with `n_ant` active RX chains at correlation ρ: + + (P_core + n_ant*P_chain + airtime*P_pa) / (src_bitrate * deliver_N) + + n_ant chains add baseline but raise deliver_N; +inf if infeasible / dead.""" + dN = deliver_multichain(d1, n_ant, rho) + if dN <= 0.0: + return float("inf") + af = airtime_fraction(p, src_bps, overhead, payload, calib) + if af > 1.0: + return float("inf") + p_avg = calib.p_baseline_w + n_ant * p_chain_w + min(1.0, af) * calib.pa_w(p.txagc) + return p_avg / (src_bps * dN) + + +def optimal_chains(p: TxPoint, src_bps: float, overhead: float, payload: int, + d1: float, rho: float, calib: Calib, max_chains: int = 4, + p_chain_w: float = P_CHAIN_W) -> tuple[int, list[float]]: + """Return (best_n, [E/bit for n=1..max_chains]) at fade correlation ρ.""" + eb = [energy_per_bit_nchain(p, src_bps, overhead, payload, d1, n, rho, + calib, p_chain_w) for n in range(1, max_chains + 1)] + best = min(range(len(eb)), key=lambda i: eb[i]) + 1 + return best, eb + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--d1", type=float, default=0.6, + help="single-chain delivery at the operating point (marginal link)") + ap.add_argument("--src-mbps", type=float, default=4.0) + ap.add_argument("--overhead", type=float, default=0.25) + ap.add_argument("--payload", type=int, default=1024) + ap.add_argument("--mcs", type=int, default=3) + ap.add_argument("--txagc", type=int, default=32) + ap.add_argument("--max-chains", type=int, default=4) + ap.add_argument("--p-chain-w", type=float, default=P_CHAIN_W) + ap.add_argument("--self-test", action="store_true") + args = ap.parse_args() + if args.self_test: + return self_test() + + cal = load_calibration() + p = TxPoint(mcs=args.mcs, txagc=args.txagc) + src = args.src_mbps * 1e6 + print(f"energy/delivered-bit vs active chains, by fade correlation ρ\n" + f"(single-chain delivery d1={args.d1}, {args.src_mbps:g} Mbps video, " + f"MCS{args.mcs}, P_chain={args.p_chain_w} W, P_core={cal.p_baseline_w} W)") + hdr = " " + " ".join(f"N={n}" for n in range(1, args.max_chains + 1)) + print(f"{'ρ':>5} {'N_eff':>6} {'deliverN(best)':>15} E/bit nJ [{hdr.strip()}] best") + for rho in (0.9, 0.7, 0.5, 0.3, 0.1, 0.0): + best, eb = optimal_chains(p, src, args.overhead, args.payload, args.d1, + rho, cal, args.max_chains, args.p_chain_w) + dN = deliver_multichain(args.d1, best, rho) + cells = " ".join(f"{e*1e9:5.1f}" for e in eb) + print(f"{rho:>5.1f} {n_eff(args.max_chains, rho):>6.2f} {dN:>15.3f} " + f"[{cells}] N={best}") + print("\nHigh ρ (static): diversity ~nil, so extra chains are pure baseline " + "cost → best N=1.\nLow ρ (mobile): diversity fills the fades, so extra " + "chains cheapen delivery > their baseline → best N grows.") + return 0 + + +def self_test() -> int: + print("=== chain_count_energy_sim self-test ===") + ok = True + cal = load_calibration() + p = TxPoint(mcs=3, txagc=32) + src = 4e6 + + # N_eff limits + c = abs(n_eff(4, 0.0) - 4) < 1e-9 and abs(n_eff(4, 1.0) - 1) < 1e-9 + ok &= c + print(f"[{'ok' if c else 'FAIL'}] N_eff(4): ρ0={n_eff(4,0):.2f} ρ1={n_eff(4,1):.2f}") + + # decorrelated (low ρ) improves delivery; correlated (high ρ) barely does + d_lo = deliver_multichain(0.6, 4, 0.1) + d_hi = deliver_multichain(0.6, 4, 0.9) + c = d_lo > 0.95 and d_hi < d_lo + ok &= c + print(f"[{'ok' if c else 'FAIL'}] deliver(4ch): low-ρ={d_lo:.3f} > high-ρ={d_hi:.3f}") + + # the headline: static prefers fewer chains, mobile prefers more + best_static, _ = optimal_chains(p, src, 0.25, 1024, 0.6, 0.9, cal) + best_mobile, _ = optimal_chains(p, src, 0.25, 1024, 0.6, 0.1, cal) + c = best_static < best_mobile + ok &= c + print(f"[{'ok' if c else 'FAIL'}] optimal chains: static N={best_static} < " + f"mobile N={best_mobile}") + + # static optimum is 1 chain (extra chains pure cost when diversity is nil) + c = best_static == 1 + ok &= c + print(f"[{'ok' if c else 'FAIL'}] static optimum is a single chain (N={best_static})") + + # a more marginal link (lower d1) makes MORE chains worth their baseline + best_marg, _ = optimal_chains(p, src, 0.25, 1024, 0.35, 0.1, cal) + c = best_marg >= best_mobile + ok &= c + print(f"[{'ok' if c else 'FAIL'}] more-marginal link opens up more chains: " + f"d1=0.35 mobile N={best_marg} >= d1=0.6 mobile N={best_mobile}") + + print("=== PASS ===" if ok else "=== FAIL ===") + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/precoder/test_chain_count_energy_sim.py b/tools/precoder/test_chain_count_energy_sim.py new file mode 100644 index 0000000..70b928b --- /dev/null +++ b/tools/precoder/test_chain_count_energy_sim.py @@ -0,0 +1,51 @@ +"""Tests for the active-chain-count energy sim (chain_count_energy_sim.py).""" +from energy_model import TxPoint, load_calibration +import chain_count_energy_sim as cc + + +def _setup(): + return load_calibration(), TxPoint(mcs=3, txagc=32), 4e6 + + +def test_n_eff_limits(): + assert abs(cc.n_eff(4, 0.0) - 4.0) < 1e-9 + assert abs(cc.n_eff(4, 1.0) - 1.0) < 1e-9 + assert 1.0 < cc.n_eff(4, 0.5) < 4.0 + + +def test_delivery_improves_only_when_decorrelated(): + # correlated antennas barely lift delivery; decorrelated lift it a lot + assert abs(cc.deliver_multichain(0.6, 4, 1.0) - 0.6) < 1e-9 # rho=1: no help + assert cc.deliver_multichain(0.6, 4, 0.1) > 0.95 + assert cc.deliver_multichain(0.6, 4, 0.1) > cc.deliver_multichain(0.6, 4, 0.9) + + +def test_static_prefers_single_chain(): + cal, p, src = _setup() + best, eb = cc.optimal_chains(p, src, 0.25, 1024, 0.6, 0.9, cal) + assert best == 1 + # and energy strictly increases with each added chain when diversity is nil + assert eb[0] < eb[1] < eb[2] < eb[3] + + +def test_mobile_prefers_more_chains(): + cal, p, src = _setup() + best_static, _ = cc.optimal_chains(p, src, 0.25, 1024, 0.6, 0.9, cal) + best_mobile, _ = cc.optimal_chains(p, src, 0.25, 1024, 0.6, 0.1, cal) + assert best_mobile > best_static + + +def test_more_marginal_link_opens_more_chains(): + # at a deeper marginal link, more chains earn their baseline + cal, p, src = _setup() + best_mid, _ = cc.optimal_chains(p, src, 0.25, 1024, 0.6, 0.1, cal) + best_deep, _ = cc.optimal_chains(p, src, 0.25, 1024, 0.35, 0.1, cal) + assert best_deep >= best_mid + assert best_deep == 4 # a deep marginal + decorrelated link wants all four + + +def test_cheaper_chains_shift_optimum_up(): + cal, p, src = _setup() + n_expensive, _ = cc.optimal_chains(p, src, 0.25, 1024, 0.6, 0.1, cal, p_chain_w=0.15) + n_cheap, _ = cc.optimal_chains(p, src, 0.25, 1024, 0.6, 0.1, cal, p_chain_w=0.05) + assert n_cheap >= n_expensive