VIDEO-005: add full offline QPSK IQ video receiver
This commit is contained in:
109
tools/video005_channel.py
Normal file
109
tools/video005_channel.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Только программные нарушения IQ; эталоны сюда не передаются приёмнику."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import statistics
|
||||
from typing import cast
|
||||
|
||||
import numpy as np
|
||||
from scipy.optimize import brentq
|
||||
from scipy.signal import lfilter
|
||||
|
||||
from protocol.video003_modem import SAMPLE_RATE_HZ, samples_per_symbol
|
||||
from protocol.video005_iq_waveform import NOMINAL_CARRIER_HZ, SYMBOL_RATE
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LAB066_RESULT = ROOT / "data/processed/lab066/rf_result.json"
|
||||
|
||||
|
||||
def impair(
|
||||
iq: np.ndarray,
|
||||
*,
|
||||
timing_offset_symbols: float = 0.0,
|
||||
cfo_hz: float = 0.0,
|
||||
phase_degrees: float = 0.0,
|
||||
ebn0_db: float | None = None,
|
||||
seed: int = 20260917,
|
||||
symbol_rate: int = SYMBOL_RATE,
|
||||
) -> np.ndarray:
|
||||
"""Сдвиг времени, CFO, фаза и AWGN с фиксированной затравкой."""
|
||||
if not all(math.isfinite(value) for value in (timing_offset_symbols, cfo_hz, phase_degrees)):
|
||||
raise ValueError("параметры канала должны быть конечными")
|
||||
if ebn0_db is not None and not math.isfinite(ebn0_db):
|
||||
raise ValueError("Eb/N0 должно быть конечным")
|
||||
transmitted = np.asarray(iq, dtype=np.complex64)
|
||||
received = np.empty_like(transmitted)
|
||||
delay = timing_offset_symbols * samples_per_symbol(symbol_rate)
|
||||
# Ограничиваем рабочие массивы: полная видеозапись занимает миллионы IQ-отсчётов.
|
||||
random_generator = np.random.default_rng(seed)
|
||||
sigma = (math.sqrt(1 / (2 * (2 / 3) * 10 ** (ebn0_db / 10)) / 2)
|
||||
if ebn0_db is not None else 0.0)
|
||||
for start in range(0, transmitted.size, 1_000_000):
|
||||
stop = min(start + 1_000_000, transmitted.size)
|
||||
indexes = np.arange(start, stop, dtype=np.float64)
|
||||
if delay:
|
||||
positions = indexes - delay
|
||||
lower = np.floor(positions).astype(np.int64)
|
||||
valid = (lower >= 0) & (lower + 1 < transmitted.size)
|
||||
part = np.zeros(stop - start, dtype=np.complex64)
|
||||
fraction = positions[valid] - lower[valid]
|
||||
part[valid] = (transmitted[lower[valid]] * (1 - fraction)
|
||||
+ transmitted[lower[valid] + 1] * fraction)
|
||||
else:
|
||||
part = transmitted[start:stop].copy()
|
||||
if cfo_hz or phase_degrees:
|
||||
phase = math.radians(phase_degrees) + 2 * math.pi * cfo_hz * indexes / SAMPLE_RATE_HZ
|
||||
part *= np.exp(1j * phase).astype(np.complex64)
|
||||
if ebn0_db is not None:
|
||||
part += (sigma * (random_generator.standard_normal(stop - start)
|
||||
+ 1j * random_generator.standard_normal(stop - start))
|
||||
).astype(np.complex64)
|
||||
received[start:stop] = part
|
||||
return received
|
||||
|
||||
|
||||
def lab066_like(iq: np.ndarray, *, seed: int = 20260917) -> tuple[np.ndarray, dict]:
|
||||
"""Диагностический суррогат по двум измеренным сводным характеристикам.
|
||||
|
||||
Частотная рябь и добавочный остаток не воспроизводят точную OTA-передачу.
|
||||
"""
|
||||
report = json.loads(LAB066_RESULT.read_text(encoding="utf-8"))
|
||||
rows = report["physical_diagnostics"]["rows"]
|
||||
ripple_db = statistics.median(row["band_gain_nonuniformity_p95_p05_db"] for row in rows)
|
||||
useful_to_residual_db = statistics.mean(row["matched_residual_db"] for row in rows)
|
||||
frequencies = np.linspace(NOMINAL_CARRIER_HZ - 337_500,
|
||||
NOMINAL_CARRIER_HZ + 337_500, 2048)
|
||||
delay_samples = 8
|
||||
|
||||
def modeled_ripple(alpha: float) -> float:
|
||||
response = np.abs(1 + alpha * np.exp(-2j * np.pi * frequencies
|
||||
* delay_samples / SAMPLE_RATE_HZ))
|
||||
return 20 * math.log10(float(np.percentile(response, 95)
|
||||
/ np.percentile(response, 5)))
|
||||
|
||||
alpha = cast(float, brentq(lambda value: modeled_ripple(value) - ripple_db,
|
||||
0.0, 0.8, full_output=False))
|
||||
coefficients = np.zeros(delay_samples + 1)
|
||||
coefficients[0], coefficients[-1] = 1.0, alpha
|
||||
filtered = cast(np.ndarray, lfilter(coefficients, [1.0],
|
||||
np.asarray(iq, dtype=np.complex64)))
|
||||
residual_ratio = 10 ** (useful_to_residual_db / 10)
|
||||
sigma = math.sqrt(1 / (2 * residual_ratio))
|
||||
random_generator = np.random.default_rng(seed)
|
||||
result = filtered + sigma * (
|
||||
random_generator.standard_normal(filtered.size)
|
||||
+ 1j * random_generator.standard_normal(filtered.size)
|
||||
)
|
||||
return result.astype(np.complex64), {
|
||||
"label": "LAB066-LIKE DIAGNOSTIC CHANNEL MODEL",
|
||||
"measured_band_p95_p05_db": ripple_db,
|
||||
"modeled_band_p95_p05_db": modeled_ripple(alpha),
|
||||
"measured_matched_residual_db": useful_to_residual_db,
|
||||
"filter_delay_samples": delay_samples,
|
||||
"filter_second_tap": alpha,
|
||||
"limitation": "FIR ripple and white residual surrogate do not reproduce OTA propagation",
|
||||
}
|
||||
Reference in New Issue
Block a user