From d6b55de95cc7cd226f209a28b1e09334e2e891d6 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:06:02 +0300 Subject: [PATCH] Beam-steer feasibility sim for a moving broadcast source (#137, speculative) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quantifies the go/no-go: an N-element array buys up to 10log10(N) dB directive gain, but it must point, and a broadcast FPV downlink has no per-packet CSI — only a slow, coarse return-channel direction, on a moving platform. The sim models the array-factor roll-off vs pointing error and a pointing-error budget (rate x update-latency + CSI resolution), and compares the realised beam gain against the measured feedback-free STBC gain (2.5 dB, no pointing). Verdict: for a moving drone (e.g. 90 deg/s, 0.2 s return-channel latency, 10 deg CSI resolution) the ~28 deg pointing error lands the beam in a null (-17 dB) — far worse than a single antenna. Beam-steering a moving broadcast source is a no-go; feedback-free diversity (STBC/MRC, measured) wins. Only a slow / known-geometry case (fixed relay, stabilised gimbal) clears STBC. tools/precoder/beamsteer_feasibility_sim.py + pytest; --sweep and --self-test. Co-Authored-By: Claude Opus 4.8 --- tools/precoder/beamsteer_feasibility_sim.py | 151 ++++++++++++++++++ .../test_beamsteer_feasibility_sim.py | 34 ++++ 2 files changed, 185 insertions(+) create mode 100644 tools/precoder/beamsteer_feasibility_sim.py create mode 100644 tools/precoder/test_beamsteer_feasibility_sim.py diff --git a/tools/precoder/beamsteer_feasibility_sim.py b/tools/precoder/beamsteer_feasibility_sim.py new file mode 100644 index 0000000..54ab010 --- /dev/null +++ b/tools/precoder/beamsteer_feasibility_sim.py @@ -0,0 +1,151 @@ +"""TX beam-steering feasibility for a moving broadcast source (#137, speculative). + +Coherent multi-antenna beamforming focuses transmit energy toward the receiver: +for an N-element array it buys up to 10·log10(N) dB of directive gain over a +single antenna, and its EIRP-per-PA-watt scales with N (the energy lever of +#135 at its strongest). The catch is that it must *point*: the gain falls off the +main lobe, and a **broadcast** FPV downlink has no per-packet CSI to point with — +only a slow, coarse direction inferred from the return channel (RC/telemetry), +and the platform is moving. So the question is not "does beamforming have gain" +but "does the realised gain, after the pointing error a moving source with stale +direction suffers, beat the feedback-free diversity (STBC/MRC) we already +measured — which needs no pointing at all?" + +This sim quantifies that. It models an idealised uniform linear array (λ/2 +spacing) — its directive gain vs pointing error via the array factor — and a +pointing-error budget = platform angular rate × direction-update latency + the +angular resolution of the return-channel direction estimate. It then compares the +*realised* beam gain against the measured feedback-free STBC gain. + +Idealisation caveat: real adapter antennas are not a calibrated λ/2 phased array +(the 8814's four antennas are packed, some internal), so this is an upper bound on +what beamforming could offer, not a prediction of a specific adapter. + +CLI: + uv run python beamsteer_feasibility_sim.py --n 4 + uv run python beamsteer_feasibility_sim.py --sweep + uv run python beamsteer_feasibility_sim.py --self-test +""" +from __future__ import annotations + +import argparse +import math + +# The feedback-free alternative we actually measured (#132): STBC 2x1 coding gain +# (~2-3 dB), no pointing required. Beamforming must clear THIS to be worth it. +STBC_GAIN_DB = 2.5 + + +def array_factor_db(n: int, theta_err_deg: float, spacing_wl: float = 0.5) -> float: + """Directive gain (dB over a single element) of an N-element uniform linear + array steered on-axis, evaluated at a pointing error theta_err. Peak is + 10·log10(N) on-axis; the array factor rolls it off toward the nulls.""" + if n <= 1: + return 0.0 + peak_db = 10.0 * math.log10(n) + psi = 2.0 * math.pi * spacing_wl * math.sin(math.radians(theta_err_deg)) + # normalised array factor |sin(N psi/2) / (N sin(psi/2))| + denom = n * math.sin(psi / 2.0) + if abs(denom) < 1e-12: + af = 1.0 + else: + af = abs(math.sin(n * psi / 2.0) / denom) + return peak_db + 20.0 * math.log10(max(af, 1e-6)) + + +def pointing_error_deg(rate_deg_s: float, update_latency_s: float, + csi_res_deg: float) -> float: + """Pointing error budget: how far the true direction drifts between direction + updates (rate x latency) plus the coarse return-channel angular resolution.""" + return rate_deg_s * update_latency_s + csi_res_deg + + +def realised_beam_gain_db(n: int, rate_deg_s: float, update_latency_s: float, + csi_res_deg: float) -> float: + return array_factor_db(n, pointing_error_deg(rate_deg_s, update_latency_s, + csi_res_deg)) + + +def verdict(beam_db: float) -> str: + if beam_db > STBC_GAIN_DB + 1.0: + return "BEAMFORM (clears STBC with margin)" + if beam_db > STBC_GAIN_DB: + return "marginal (~= STBC, not worth the CSI/pointing complexity)" + return "USE DIVERSITY (STBC beats the pointed beam — no pointing needed)" + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--n", type=int, default=4, help="array elements") + ap.add_argument("--rate-deg-s", type=float, default=90.0, + help="platform angular rate (deg/s) — how fast the direction changes") + ap.add_argument("--latency-s", type=float, default=0.2, + help="direction-update latency over the return channel (s)") + ap.add_argument("--csi-res-deg", type=float, default=10.0, + help="angular resolution of the return-channel direction estimate") + ap.add_argument("--sweep", action="store_true") + ap.add_argument("--self-test", action="store_true") + args = ap.parse_args() + if args.self_test: + return self_test() + + if args.sweep: + print(f"N={args.n}: peak beam gain {10*math.log10(args.n):.1f} dB " + f"(vs STBC {STBC_GAIN_DB} dB, no pointing). Realised gain vs " + f"pointing error:") + print(f"{'θ_err°':>7} {'beam dB':>8} {'verdict'}") + for te in (0, 5, 10, 15, 20, 30, 45): + g = array_factor_db(args.n, te) + print(f"{te:>7} {g:>8.1f} {verdict(g)}") + print("\nθ_err ≈ rate×latency + CSI-resolution. A fast platform with a " + "slow/coarse\nreturn-channel direction blows the pointing budget " + "long before the beam pays.") + return 0 + + te = pointing_error_deg(args.rate_deg_s, args.latency_s, args.csi_res_deg) + g = realised_beam_gain_db(args.n, args.rate_deg_s, args.latency_s, args.csi_res_deg) + print(f"N={args.n}, rate={args.rate_deg_s}°/s, latency={args.latency_s}s, " + f"CSI-res={args.csi_res_deg}° → pointing error {te:.1f}°") + print(f"peak beam gain {10*math.log10(args.n):.1f} dB → realised {g:.1f} dB " + f"(STBC {STBC_GAIN_DB} dB)") + print(f"verdict: {verdict(g)}") + return 0 + + +def self_test() -> int: + print("=== beamsteer_feasibility_sim self-test ===") + ok = True + + # on-axis peak is 10log10(N) + c = abs(array_factor_db(4, 0.0) - 10 * math.log10(4)) < 1e-6 + ok &= c + print(f"[{'ok' if c else 'FAIL'}] N=4 on-axis peak = {array_factor_db(4,0):.2f} dB (=6.02)") + + # gain falls monotonically off-axis (within the main lobe) + c = array_factor_db(4, 0) > array_factor_db(4, 10) > array_factor_db(4, 20) + ok &= c + print(f"[{'ok' if c else 'FAIL'}] gain rolls off with pointing error " + f"(0°={array_factor_db(4,0):.1f} 10°={array_factor_db(4,10):.1f} 20°={array_factor_db(4,20):.1f})") + + # pointing budget grows with rate and latency + c = (pointing_error_deg(180, 0.3, 10) > pointing_error_deg(30, 0.3, 10) + > pointing_error_deg(30, 0.05, 10)) + ok &= c + print(f"[{'ok' if c else 'FAIL'}] pointing error grows with rate & latency") + + # the headline: a fast platform + slow coarse direction -> diversity wins; + # a slow/known-geometry case -> beamforming clears STBC. + fast = realised_beam_gain_db(4, rate_deg_s=120, update_latency_s=0.3, csi_res_deg=15) + slow = realised_beam_gain_db(4, rate_deg_s=5, update_latency_s=0.05, csi_res_deg=3) + c = fast < STBC_GAIN_DB and slow > STBC_GAIN_DB + ok &= c + print(f"[{'ok' if c else 'FAIL'}] fast/coarse={fast:.1f}dB (< STBC) vs " + f"slow/precise={slow:.1f}dB (> STBC)") + + print("=== PASS ===" if ok else "=== FAIL ===") + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/precoder/test_beamsteer_feasibility_sim.py b/tools/precoder/test_beamsteer_feasibility_sim.py new file mode 100644 index 0000000..da3a195 --- /dev/null +++ b/tools/precoder/test_beamsteer_feasibility_sim.py @@ -0,0 +1,34 @@ +"""Tests for the beam-steer feasibility sim (beamsteer_feasibility_sim.py).""" +import math +import beamsteer_feasibility_sim as bs + + +def test_onaxis_peak_is_array_gain(): + for n in (2, 3, 4, 8): + assert abs(bs.array_factor_db(n, 0.0) - 10 * math.log10(n)) < 1e-6 + + +def test_gain_rolls_off_within_main_lobe(): + assert bs.array_factor_db(4, 0) > bs.array_factor_db(4, 8) > bs.array_factor_db(4, 14) + + +def test_pointing_budget_grows_with_rate_and_latency(): + assert bs.pointing_error_deg(180, 0.3, 5) > bs.pointing_error_deg(30, 0.3, 5) + assert bs.pointing_error_deg(30, 0.3, 5) > bs.pointing_error_deg(30, 0.05, 5) + assert bs.pointing_error_deg(0, 0, 7) == 7 # pure CSI resolution floor + + +def test_fast_moving_source_loses_to_diversity(): + # a fast platform + slow coarse direction blows the pointing budget + g = bs.realised_beam_gain_db(4, rate_deg_s=90, update_latency_s=0.2, + csi_res_deg=10) + assert g < bs.STBC_GAIN_DB + assert bs.verdict(g).startswith("USE DIVERSITY") + + +def test_slow_known_geometry_can_beamform(): + # a near-stationary / precisely-known geometry keeps the pointing tight + g = bs.realised_beam_gain_db(4, rate_deg_s=5, update_latency_s=0.05, + csi_res_deg=3) + assert g > bs.STBC_GAIN_DB + assert "BEAMFORM" in bs.verdict(g)