From 1d1d5fd952882f771238bd22bbd4b035588946ec Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:59:30 +0300 Subject: [PATCH] =?UTF-8?q?Spatial=20diversity=20keeps=20corruption=20loca?= =?UTF-8?q?lized=20=E2=80=94=20the=20SBI=20precondition=20(#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SBI sub-block-salvage layer only pays when corruption is localized within a frame; a deep fade smears it frame-wide and SBI recovers nothing. Spatial diversity cuts the SNR *variance* (deep fades are variance), so it moves frames out of the frame-wide (lost) bin into the localized (SBI-salvageable) one — improving SBI's precondition, not just delivery. - tools/precoder/spatial_sbi_sim.py (+ pytest): Monte-Carlo correlated-Rayleigh branches, combined at matched mean SNR to isolate the diversity/variance effect from the array/mean gain, classified clean / localized / lost. --sweep-rho and --self-test. - docs/fused-fec.md: "Spatial diversity feeds the same precondition" in the localization section + module-map entry. Result (matched mean, isolating variance — consistent with the measured static-≈-nil / mobile-pays finding): static/correlated (ρ=0.9) barely moves the frame-wide-loss bin (0.31 vs single-chain 0.33); mobile/decorrelated (ρ=0.2) collapses it (0.12) and lifts the SBI-salvageable fraction of corrupt frames 0.59 → 0.86. Corruption localization is a mobility effect. Co-Authored-By: Claude Opus 4.8 --- docs/fused-fec.md | 14 ++ tools/precoder/spatial_sbi_sim.py | 175 +++++++++++++++++++++++++ tools/precoder/test_spatial_sbi_sim.py | 47 +++++++ 3 files changed, 236 insertions(+) create mode 100644 tools/precoder/spatial_sbi_sim.py create mode 100644 tools/precoder/test_spatial_sbi_sim.py diff --git a/docs/fused-fec.md b/docs/fused-fec.md index 619c809..2554763 100644 --- a/docs/fused-fec.md +++ b/docs/fused-fec.md @@ -71,6 +71,19 @@ hard-decision Viterbi lands in the second; a soft-decision path decoder — not at the outer code; see [Soft information](#soft-information-inner-decoder-vs-outer-code). +**Spatial diversity feeds the same precondition.** What pushes a frame from the +localized regime into the frame-wide regime is a *deep fade* — the SNR dropping +far enough that the whole frame smears. A multi-chain receiver +(`docs/measuring-spatial-diversity.md`) attacks exactly that: combining +decorrelated antennas cuts the SNR *variance*, which is what deep fades are, so it +moves frames out of the frame-wide (lost) bin and into the localized +(SBI-salvageable) one. It improves SBI's precondition rather than just delivering +more. And because the variance only shrinks when the antennas decorrelate — the +measured static-vs-motion result — this is a mobility effect, largest exactly +where a long-range link spends its time. `spatial_sbi_sim.py` quantifies it: at a +static, correlated position the frame-wide-loss bin barely moves, while under +motion it collapses and the salvageable fraction of corrupt frames rises sharply. + ## Outer codes `tools/precoder/stream_fec.py` is a thin dispatcher selecting one of three outer @@ -196,6 +209,7 @@ for both receivers. Only the receiver — and thus the inner decode — differs: | `stream_fec.py` | dispatcher (adds the `rs` scheme + `FEC_MAGIC_RS`) | | `svc_uep_fec.py` | per-SVC-layer FEC-rate UEP (HEVC NAL → layer → RS config) | | `svc_spatial_uep_sim.py` | per-SVC-layer spatial UEP — the 3-axis staircase (MCS + FEC + STBC), quantifying the spatial knob's survival-SNR contribution | +| `spatial_sbi_sim.py` | spatial diversity keeps corruption localized — how combining (by fade correlation ρ) moves frames out of the frame-wide-loss bin into the SBI-salvageable one | | `fused_fec_link.py` | chip-path `FusedFecSender` / `FusedFecReceiver` (baseline-vs-SBI) | | `fused_fec_tx.py` / `fused_fec_rx.py` | chip-path CLIs (bytes ↔ `StreamTxDemo` / ``) | | `fec_fusion_sim.py` | offline simulation: quantify SBI gain, size sub-blocks, no hardware | diff --git a/tools/precoder/spatial_sbi_sim.py b/tools/precoder/spatial_sbi_sim.py new file mode 100644 index 0000000..50f7e15 --- /dev/null +++ b/tools/precoder/spatial_sbi_sim.py @@ -0,0 +1,175 @@ +"""Spatial diversity keeps corruption localized — the SBI precondition (#133). + +The fused-FEC sub-block-integrity (SBI) layer salvages an FCS-failed frame by +keeping its surviving CRC-guarded sub-blocks. It only pays when corruption is +*localized* within the frame; a **deep fade** drops SNR far enough that the whole +frame's data smears (frame-wide corruption), and SBI recovers nothing from it +(measured survivor fraction ~0.36 in that regime — see docs/fused-fec.md). + +Spatial diversity attacks exactly that. Combining several RX chains raises the +effective SNR and — more importantly — *reduces its variance*, which is what deep +fades are. So diversity moves frames out of the deep-fade (frame-wide, lost) +regime and into the marginal (localized, SBI-salvageable) and clean regimes. It +does not just deliver more frames; it improves SBI's **precondition** — the +fraction of the corrupt frames that are localized rather than frame-wide. + +And because combining only reduces variance when the antennas decorrelate (the +measured static-vs-motion result), this benefit — like the rest of the spatial +story — is a mobility effect. + +Model: Monte-Carlo correlated-Rayleigh branches (equicorrelation ρ), MRC-combine +N of them, classify each frame by its combined SNR into clean / localized / lost, +and compare single-chain vs N-chain. Pure-Python (no numpy), GNU-Radio-env safe. + +CLI: + uv run python spatial_sbi_sim.py --chains 4 --rho 0.2 + uv run python spatial_sbi_sim.py --sweep-rho + uv run python spatial_sbi_sim.py --self-test +""" +from __future__ import annotations + +import argparse +import math +import random + +# Regime thresholds (dB, on the combined SNR). Above CLEAN_DB the FCS passes; +# between DEEP_DB and CLEAN_DB the frame is marginal — corruption stays localized +# and SBI salvages surviving sub-blocks; below DEEP_DB the fade is deep enough +# that corruption is frame-wide and SBI recovers nothing. +CLEAN_DB = 12.0 +DEEP_DB = 6.0 + + +def _complex_gauss(rng: random.Random) -> complex: + """One unit-variance complex Gaussian (Box-Muller).""" + u1 = max(rng.random(), 1e-12) + u2 = rng.random() + r = math.sqrt(-math.log(u1)) # var 1/2 per component -> |h|^2 mean 1 + return complex(r * math.cos(2 * math.pi * u2), r * math.sin(2 * math.pi * u2)) + + +def _branches(rng: random.Random, n: int, rho_env: float) -> list[float]: + """N equicorrelated Rayleigh branch powers |h_i|^2 (mean 1). Envelope + correlation rho_env is achieved with field correlation sqrt(rho_env): each + branch shares a common component and adds an independent one.""" + a = math.sqrt(math.sqrt(rho_env)) # field weight; rho_env = |rho_field|^2 + b = math.sqrt(max(0.0, 1.0 - a * a)) + shared = _complex_gauss(rng) + return [abs(a * shared + b * _complex_gauss(rng)) ** 2 for _ in range(n)] + + +def trial_snr_db(rng: random.Random, n: int, rho_env: float, + mean_snr_db: float) -> float: + """Combined SNR (dB) of N combined branches, normalised to the SAME MEAN as a + single chain. Corruption localisation is a *variance* effect — deep fades are + variance — so this isolates the diversity (fade-filling) benefit from MRC's + array/mean gain (which is a separate delivery/energy effect, and which even + correlated combining provides). Averaging the branch powers keeps the mean at + the per-branch mean while cutting the variance in proportion to how + decorrelated the branches are: correlated branches (static) move together so + the average still fades deep; decorrelated branches (motion) smooth out.""" + gamma = 10 ** (mean_snr_db / 10.0) # per-branch mean SNR (linear) + powers = _branches(rng, n, rho_env) + comb = gamma * (sum(powers) / n) # matched-mean combine (variance only) + return 10 * math.log10(max(comb, 1e-9)) + + +def classify(snr_db: float) -> str: + if snr_db >= CLEAN_DB: + return "clean" + if snr_db >= DEEP_DB: + return "localized" + return "lost" + + +def montecarlo(n: int, rho_env: float, mean_snr_db: float, trials: int, + seed: int) -> dict: + rng = random.Random(seed) + c = {"clean": 0, "localized": 0, "lost": 0} + for _ in range(trials): + c[classify(trial_snr_db(rng, n, rho_env, mean_snr_db))] += 1 + t = float(trials) + clean, loc, lost = c["clean"] / t, c["localized"] / t, c["lost"] / t + corrupt = loc + lost + return { + "clean": clean, "localized": loc, "lost": lost, + # SBI's precondition: of the corrupt frames, the fraction that is + # localized (salvageable) rather than frame-wide (lost). + "sbi_salvageable_of_corrupt": (loc / corrupt) if corrupt > 0 else 1.0, + # delivered = clean + SBI-salvaged localized frames. + "delivered": clean + loc, + } + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--chains", type=int, default=4) + ap.add_argument("--rho", type=float, default=0.2) + ap.add_argument("--mean-snr-db", type=float, default=10.0) + ap.add_argument("--trials", type=int, default=20000) + ap.add_argument("--seed", type=int, default=1) + ap.add_argument("--sweep-rho", action="store_true") + ap.add_argument("--self-test", action="store_true") + args = ap.parse_args() + if args.self_test: + return self_test() + + def show(label, r): + print(f" {label:>16}: clean {r['clean']:.2f} localized {r['localized']:.2f} " + f"lost {r['lost']:.2f} | SBI-salvageable-of-corrupt " + f"{r['sbi_salvageable_of_corrupt']:.2f} delivered {r['delivered']:.2f}") + + if args.sweep_rho: + print(f"single-chain vs {args.chains}-chain MRC, mean SNR " + f"{args.mean_snr_db} dB, by correlation ρ") + r1 = montecarlo(1, 0.0, args.mean_snr_db, args.trials, args.seed) + show("1 chain", r1) + for rho in (0.9, 0.5, 0.2, 0.0): + rn = montecarlo(args.chains, rho, args.mean_snr_db, args.trials, args.seed) + show(f"{args.chains}ch ρ={rho}", rn) + print("\nDecorrelated combining (low ρ) collapses the 'lost' (frame-wide) " + "bin and\nlifts the SBI-salvageable-of-corrupt fraction — it improves " + "SBI's precondition,\nnot just raw delivery. High ρ (static) barely " + "moves it.") + return 0 + + r1 = montecarlo(1, 0.0, args.mean_snr_db, args.trials, args.seed) + rn = montecarlo(args.chains, args.rho, args.mean_snr_db, args.trials, args.seed) + print(f"mean SNR {args.mean_snr_db} dB, ρ={args.rho}") + show("1 chain", r1) + show(f"{args.chains} chain", rn) + return 0 + + +def self_test() -> int: + print("=== spatial_sbi_sim self-test ===") + ok = True + T, S = 20000, 3 + + r1 = montecarlo(1, 0.0, 10.0, T, S) + r4_lo = montecarlo(4, 0.1, 10.0, T, S) # decorrelated (mobile) + r4_hi = montecarlo(4, 0.9, 10.0, T, S) # correlated (static) + + checks = [ + ("diversity cuts the frame-wide 'lost' bin", r4_lo["lost"] < r1["lost"]), + ("diversity lifts SBI-salvageable-of-corrupt", + r4_lo["sbi_salvageable_of_corrupt"] > r1["sbi_salvageable_of_corrupt"]), + ("diversity raises delivered", r4_lo["delivered"] > r1["delivered"]), + ("decorrelated beats correlated on the precondition", + r4_lo["sbi_salvageable_of_corrupt"] > r4_hi["sbi_salvageable_of_corrupt"]), + ("correlated (static) barely helps the lost bin", + r4_hi["lost"] > r4_lo["lost"]), + ] + for name, c in checks: + ok &= c + print(f"[{'ok' if c else 'FAIL'}] {name}") + print(f" 1ch: lost={r1['lost']:.2f} salv-of-corrupt={r1['sbi_salvageable_of_corrupt']:.2f}") + print(f" 4ch ρ=.1: lost={r4_lo['lost']:.2f} salv-of-corrupt={r4_lo['sbi_salvageable_of_corrupt']:.2f}") + print(f" 4ch ρ=.9: lost={r4_hi['lost']:.2f} salv-of-corrupt={r4_hi['sbi_salvageable_of_corrupt']:.2f}") + print("=== PASS ===" if ok else "=== FAIL ===") + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/precoder/test_spatial_sbi_sim.py b/tools/precoder/test_spatial_sbi_sim.py new file mode 100644 index 0000000..2f1a060 --- /dev/null +++ b/tools/precoder/test_spatial_sbi_sim.py @@ -0,0 +1,47 @@ +"""Tests for the spatial-diversity SBI-precondition sim (spatial_sbi_sim.py).""" +import spatial_sbi_sim as sb + + +def _mc(n, rho, snr=10.0, trials=20000, seed=3): + return sb.montecarlo(n, rho, snr, trials, seed) + + +def test_classify_regimes(): + assert sb.classify(sb.CLEAN_DB + 1) == "clean" + assert sb.classify((sb.CLEAN_DB + sb.DEEP_DB) / 2) == "localized" + assert sb.classify(sb.DEEP_DB - 1) == "lost" + + +def test_fractions_sum_to_one(): + r = _mc(4, 0.2) + assert abs(r["clean"] + r["localized"] + r["lost"] - 1.0) < 1e-9 + + +def test_decorrelated_diversity_collapses_frame_wide_loss(): + r1 = _mc(1, 0.0) + r4 = _mc(4, 0.1) + assert r4["lost"] < r1["lost"] * 0.6 # frame-wide bin much smaller + assert r4["sbi_salvageable_of_corrupt"] > r1["sbi_salvageable_of_corrupt"] + + +def test_static_correlated_barely_helps(): + # matches the measured "static MRC ~= nil": correlated combining leaves the + # frame-wide loss almost where a single chain had it. + r1 = _mc(1, 0.0) + r4_hi = _mc(4, 0.9) + assert abs(r4_hi["lost"] - r1["lost"]) < 0.05 + + +def test_precondition_improves_with_decorrelation(): + # SBI-salvageable-of-corrupt rises as antennas decorrelate (mobility) + lo = _mc(4, 0.1)["sbi_salvageable_of_corrupt"] + mid = _mc(4, 0.5)["sbi_salvageable_of_corrupt"] + hi = _mc(4, 0.9)["sbi_salvageable_of_corrupt"] + assert lo > mid > hi + + +def test_matched_mean_keeps_clean_fraction_roughly_constant(): + # the benefit is moving frames lost->localized, not raising the clean bin + r1 = _mc(1, 0.0) + r4 = _mc(4, 0.1) + assert abs(r4["clean"] - r1["clean"]) < 0.12