Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/fused-fec.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ Verified graceful-degradation staircase at 30 % loss: blocks decoded
critical 17 / T0 16 / T1 12 / T2 7 (of 20). Together with `svc_tx.h`'s MCS
ladder, base/IDR layers get the most robust MCS **and** the heaviest FEC.

### The third axis: per-layer spatial diversity

Protection runs on a *third* knob as well. `svc_tx.h`'s ladder already flies the
critical and base layers with **STBC** (its default is `MCS0/LDPC/STBC` and
`MCS1/LDPC/STBC`) and leaves the enhancement layers single-stream — so the same
layers that get the robust MCS and the heavy FEC also get transmit diversity.
Measuring what STBC buys (a ~2–3 dB coding gain plus diversity that widens under
motion) makes this a real axis, not a flag: a critical layer flown at a robust
MCS, with heavy FEC, *and* STBC survives several dB deeper into a fade than any
one axis alone, while the enhancement layers — cheap on all three — shed first.
`space_freq_diversity_sim.py`'s spatial factor and `svc_spatial_uep_sim.py`
quantify the staircase: the spatial knob extends the critical/base layers'
survival SNR by the STBC coding gain, stacked on the MCS and FEC knobs.

## Frequency diversity (a complementary erasure source)

The outer erasure code recovers symbols that don't arrive, whatever the cause —
Expand Down Expand Up @@ -181,6 +195,7 @@ for both receivers. Only the receiver — and thus the inner decode — differs:
| `stream_fec_rs.py` | Reed-Solomon outer scheme (GF(2⁸), systematic Vandermonde) |
| `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 |
| `fused_fec_link.py` | chip-path `FusedFecSender` / `FusedFecReceiver` (baseline-vs-SBI) |
| `fused_fec_tx.py` / `fused_fec_rx.py` | chip-path CLIs (bytes ↔ `StreamTxDemo` / `<devourer-stream>`) |
| `fec_fusion_sim.py` | offline simulation: quantify SBI gain, size sub-blocks, no hardware |
Expand Down
173 changes: 173 additions & 0 deletions tools/precoder/svc_spatial_uep_sim.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""Per-SVC-layer spatial UEP — the third protection axis (#136).

Unequal error protection already runs on two axes in devourer: the SVC PHY ladder
(svc_tx.h) flies important layers at a robust MCS, and per-layer FEC
(svc_uep_fec.py) gives them heavier Reed-Solomon redundancy. This module models
the *third* axis — **spatial diversity order** — which the ladder already carries
(its default puts LDPC+**STBC** on the critical/base layers and leaves the
enhancement layers single-stream), now that #132 measured what STBC actually buys
(~2-3 dB coding gain plus diversity, widening under motion).

The point is that the three axes compound. A critical layer flown at a robust
MCS, with heavy FEC, *and* STBC survives far deeper into a fade than any one axis
alone — while the enhancement layers, cheap on all three, shed first. That is the
graceful staircase, now on three knobs instead of two.

The model is analytical (no codec dependency): each layer's per-frame PHY
delivery is a soft function of the link SNR around its MCS threshold; STBC shifts
that threshold down by the measured coding gain; then the layer's Reed-Solomon
block survives if the per-symbol losses stay within its repair budget (binomial).
Sweeping the link SNR down produces the per-layer delivery staircase.

CLI:
uv run python svc_spatial_uep_sim.py # staircase sweep
uv run python svc_spatial_uep_sim.py --no-stbc # spatial axis off (A/B)
uv run python svc_spatial_uep_sim.py --self-test
"""
from __future__ import annotations

import argparse
import math
from dataclasses import dataclass

# Approx HT20 per-MCS decode threshold (dB SNR for ~clean delivery). Rough but
# monotone — the staircase only needs the ordering and spacing.
MCS_THRESH_DB = {0: 5.0, 1: 7.0, 2: 9.0, 3: 11.0, 4: 15.0, 5: 18.0, 6: 22.0, 7: 25.0}
# STBC 2x1 coding gain (dB) — from the #132 measurement (static 1.94x at the MCS5
# cliff ≈ a couple dB; the diversity part adds more under motion).
STBC_GAIN_DB = 2.5
SOFT_WIDTH_DB = 2.0 # FER-vs-SNR transition sharpness


@dataclass(frozen=True)
class Layer:
name: str
mcs: int
stbc: bool
fec_overhead: float # RS repair/source ratio
k: int = 8 # RS source symbols/block


# Default ladder: mirrors svc_tx.h (MCS + STBC) and svc_uep_fec.py (FEC overhead).
DEFAULT_LADDER = [
Layer("critical", mcs=0, stbc=True, fec_overhead=1.00),
Layer("T0-base", mcs=1, stbc=True, fec_overhead=0.75),
Layer("T1", mcs=4, stbc=False, fec_overhead=0.50),
Layer("T2", mcs=7, stbc=False, fec_overhead=0.25),
]


def phy_delivery(snr_db: float, mcs: int, stbc: bool) -> float:
"""Per-frame delivery probability: soft transition around the MCS threshold,
shifted down by the STBC coding gain when enabled."""
thresh = MCS_THRESH_DB[mcs] - (STBC_GAIN_DB if stbc else 0.0)
return 0.5 * (1.0 + math.tanh((snr_db - thresh) / SOFT_WIDTH_DB))


def _binom_cdf(k: int, n: int, p: float) -> float:
"""P(X <= k) for X ~ Binomial(n, p)."""
if p <= 0.0:
return 1.0
if p >= 1.0:
return 1.0 if k >= n else 0.0
return sum(math.comb(n, i) * p**i * (1 - p)**(n - i) for i in range(0, k + 1))


def layer_delivery(snr_db: float, layer: Layer, stbc_enabled: bool = True) -> float:
"""Probability the layer's RS block is recovered: PHY per-symbol loss folded
through the layer's repair budget."""
d = phy_delivery(snr_db, layer.mcs, layer.stbc and stbc_enabled)
loss = 1.0 - d
n = layer.k + max(0, round(layer.k * layer.fec_overhead))
repair = n - layer.k
return _binom_cdf(repair, n, loss)


def sweep(ladder=DEFAULT_LADDER, snr_hi=30.0, snr_lo=0.0, step=2.0,
stbc_enabled=True):
rows = []
snr = snr_hi
while snr >= snr_lo - 1e-9:
rows.append((snr, [layer_delivery(snr, L, stbc_enabled) for L in ladder]))
snr -= step
return rows


def survival_snr(layer: Layer, thresh=0.9, stbc_enabled=True) -> float:
"""Lowest SNR (dB) at which this layer still delivers >= `thresh`."""
snr = 30.0
while snr >= -10.0:
if layer_delivery(snr, layer, stbc_enabled) < thresh:
return snr + 0.1
snr -= 0.1
return -10.0


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--no-stbc", action="store_true",
help="disable the spatial axis (A/B: ladder without STBC)")
ap.add_argument("--self-test", action="store_true")
args = ap.parse_args()
if args.self_test:
return self_test()

stbc = not args.no_stbc
ladder = DEFAULT_LADDER
print(f"per-SVC-layer delivery vs link SNR (spatial axis {'ON' if stbc else 'OFF'})")
print(f"ladder: " + " ".join(
f"{L.name}[MCS{L.mcs}{'/STBC' if L.stbc else ''} ov{L.fec_overhead:.2f}]"
for L in ladder))
print(f"{'SNR':>5} | " + " ".join(f"{L.name:>9}" for L in ladder))
for snr, ds in sweep(ladder, stbc_enabled=stbc):
print(f"{snr:>5.0f} | " + " ".join(f"{d:>9.2f}" for d in ds))
print("\nsurvival SNR (>=90% delivery):")
for L in ladder:
s_on = survival_snr(L, stbc_enabled=True)
s_off = survival_snr(L, stbc_enabled=False)
tag = f" (STBC extends survival by {s_off - s_on:.1f} dB)" if L.stbc else ""
print(f" {L.name:>9}: {survival_snr(L, stbc_enabled=stbc):>5.1f} dB{tag}")
print("\nThe staircase: enhancement (T2, T1) sheds first; base/critical hold "
"deepest.\nThe spatial axis (STBC) extends the critical/base layers' "
"survival by the coding gain — a third UEP knob stacked on MCS and FEC.")
return 0


def self_test() -> int:
print("=== svc_spatial_uep_sim self-test ===")
ok = True

# STBC lowers the effective threshold -> higher delivery at a given SNR
c = phy_delivery(10, 5, True) > phy_delivery(10, 5, False)
ok &= c
print(f"[{'ok' if c else 'FAIL'}] STBC lifts PHY delivery at fixed SNR")

# the staircase: at a mid SNR, importance order holds (critical >= T0 >= T1 >= T2)
snr = 14.0
ds = [layer_delivery(snr, L) for L in DEFAULT_LADDER]
c = ds[0] >= ds[1] >= ds[2] >= ds[3]
ok &= c
print(f"[{'ok' if c else 'FAIL'}] staircase at {snr}dB: "
f"{[round(d,2) for d in ds]} (critical>=T0>=T1>=T2)")

# critical survives deeper than enhancement
c = survival_snr(DEFAULT_LADDER[0]) < survival_snr(DEFAULT_LADDER[3])
ok &= c
print(f"[{'ok' if c else 'FAIL'}] critical survival {survival_snr(DEFAULT_LADDER[0]):.1f}dB "
f"< T2 {survival_snr(DEFAULT_LADDER[3]):.1f}dB")

# the spatial axis genuinely extends critical survival (3rd knob adds margin)
s_on = survival_snr(DEFAULT_LADDER[0], stbc_enabled=True)
s_off = survival_snr(DEFAULT_LADDER[0], stbc_enabled=False)
c = s_on < s_off - 1.0 # STBC pushes survival >=1 dB lower
ok &= c
print(f"[{'ok' if c else 'FAIL'}] STBC extends critical survival: "
f"{s_off:.1f}dB -> {s_on:.1f}dB (Δ{s_off-s_on:.1f}dB)")

print("=== PASS ===" if ok else "=== FAIL ===")
return 0 if ok else 1


if __name__ == "__main__":
raise SystemExit(main())
42 changes: 42 additions & 0 deletions tools/precoder/test_svc_spatial_uep_sim.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Tests for the per-SVC-layer spatial UEP sim (svc_spatial_uep_sim.py)."""
import svc_spatial_uep_sim as uep


def test_stbc_lifts_phy_delivery():
# STBC shifts the MCS threshold down -> more delivery at a fixed SNR
for snr in (8.0, 10.0, 12.0):
assert uep.phy_delivery(snr, 5, True) >= uep.phy_delivery(snr, 5, False)


def test_binom_cdf_bounds():
assert uep._binom_cdf(0, 10, 0.0) == 1.0 # no loss -> always within budget
assert uep._binom_cdf(0, 10, 1.0) == 0.0 # all lost, 0 repair -> fail
assert 0.0 < uep._binom_cdf(2, 12, 0.3) < 1.0


def test_staircase_importance_ordering():
# at a mid SNR the delivery order follows layer importance
for snr in (10.0, 12.0, 14.0, 16.0):
ds = [uep.layer_delivery(snr, L) for L in uep.DEFAULT_LADDER]
assert ds[0] >= ds[1] >= ds[2] >= ds[3]


def test_critical_survives_deeper_than_enhancement():
s_crit = uep.survival_snr(uep.DEFAULT_LADDER[0])
s_t2 = uep.survival_snr(uep.DEFAULT_LADDER[3])
assert s_crit < s_t2 - 10.0 # critical holds many dB deeper


def test_spatial_axis_extends_survival():
# the third UEP knob: STBC pushes the critical layer's survival lower
crit = uep.DEFAULT_LADDER[0]
s_on = uep.survival_snr(crit, stbc_enabled=True)
s_off = uep.survival_snr(crit, stbc_enabled=False)
assert s_on < s_off
assert abs((s_off - s_on) - uep.STBC_GAIN_DB) < 0.3 # ~= the coding gain


def test_enhancement_has_no_spatial_axis():
# T1/T2 carry no STBC, so toggling the spatial axis doesn't change them
for L in uep.DEFAULT_LADDER[2:]:
assert uep.survival_snr(L, stbc_enabled=True) == uep.survival_snr(L, stbc_enabled=False)
Loading