VIDEO-005: add full offline QPSK IQ video receiver

This commit is contained in:
LittleSam129
2026-09-17 13:34:58 +03:00
parent 95f664076a
commit 6df5f8aa39
18 changed files with 2651 additions and 13 deletions

View File

@@ -0,0 +1,154 @@
"""Строгий программный приём QPSK из непрерывного комплексного IQ."""
from __future__ import annotations
from dataclasses import dataclass
import math
import time
import numpy as np
from scipy.signal import oaconvolve
from protocol import punctured_fec
from protocol.video003_modem import (
MAX_FULL_PACKET_BYTES,
PHY_HEADER_SYMBOLS,
PREAMBLE_SYMBOLS,
SAMPLE_RATE_HZ,
_decode_phy_header,
receive_frame,
samples_per_symbol,
)
from protocol.video005_carrier import (
correct_pilot_estimate,
estimate_from_preamble,
track_qpsk_body,
)
from protocol.video005_iq_waveform import NOMINAL_CARRIER_HZ, SYMBOL_RATE, rrc_taps
from protocol.video005_timing import refine_timing, sample_symbols, search_preamble_grid
@dataclass(frozen=True)
class ReceivedIqPacket:
packet: bytes
payload: bytes
start_sample: float
timing_phase_symbols: float
carrier_offset_hz: float
initial_phase_rad: float
quadrant: int
preamble_score: float
tracking_error_rms_rad: float
@dataclass(frozen=True)
class IqStreamResult:
packets: tuple[ReceivedIqPacket, ...]
candidate_count: int
rejected_candidates: int
stage_time_s: dict[str, float]
def receive_iq_stream(
iq: np.ndarray,
*,
symbol_rate: int = SYMBOL_RATE,
carrier_hz: float = NOMINAL_CARRIER_HZ,
decoder_backend: str = "numba",
) -> IqStreamResult:
"""Получает только IQ и параметры радиорежима, без подсказок передатчика.
Внешние метаданные сигнала и ожидаемое число пакетов не являются аргументами.
Возвращаются только пакеты, прошедшие PHY/FEC/CRC.
"""
samples_per_symbol_count = samples_per_symbol(symbol_rate)
if not math.isfinite(carrier_hz) or abs(carrier_hz) >= SAMPLE_RATE_HZ / 2:
raise ValueError("неверная номинальная несущая")
if decoder_backend == "numba":
from protocol.video004_viterbi import decode_soft as fec_decoder
elif decoder_backend == "legacy":
fec_decoder = punctured_fec.decode_soft
else:
raise ValueError("неизвестный выбранный декодер")
samples = np.asarray(iq)
if samples.ndim != 1 or not np.iscomplexobj(samples):
raise ValueError("приёмнику нужен одномерный комплексный IQ")
if not np.all(np.isfinite(samples)):
raise ValueError("IQ содержит неконечные значения")
stage: dict[str, float] = {}
if samples.size < PREAMBLE_SYMBOLS * samples_per_symbol_count:
return IqStreamResult((), 0, 0, stage)
started = time.perf_counter()
period = round(SAMPLE_RATE_HZ / abs(carrier_hz)) if carrier_hz else 0
if carrier_hz and abs(abs(carrier_hz) * period - SAMPLE_RATE_HZ) < 1e-8:
cycle = np.exp(-1j * np.sign(carrier_hz) * 2 * np.pi
* np.arange(period) / period).astype(np.complex64)
oscillator = np.resize(cycle, samples.size)
baseband = samples.astype(np.complex64, copy=False) * oscillator
else:
indexes = np.arange(samples.size, dtype=np.float64)
oscillator = np.exp(-1j * (2 * np.pi * carrier_hz / SAMPLE_RATE_HZ) * indexes)
baseband = samples * oscillator
stage["digital_downconversion"] = time.perf_counter() - started
started = time.perf_counter()
matched = oaconvolve(baseband, rrc_taps(symbol_rate).astype(np.float32), mode="full")
stage["rrc"] = time.perf_counter() - started
started = time.perf_counter()
candidates = search_preamble_grid(matched, samples_per_symbol_count)
stage["preamble_search"] = time.perf_counter() - started
maximum_symbols = (
PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS
+ math.ceil(punctured_fec.coded_bit_count(MAX_FULL_PACKET_BYTES * 8, "2/3") / 2)
)
stage.update(timing_recovery=0.0, carrier_estimation=0.0, phy_header=0.0,
carrier_tracking=0.0, strict_phy_fec_crc=0.0)
accepted: list[ReceivedIqPacket] = []
rejected = 0
for candidate in candidates:
started = time.perf_counter()
refined = refine_timing(matched, candidate, samples_per_symbol_count)
symbols = sample_symbols(matched, refined.start_sample,
samples_per_symbol_count, maximum_symbols)
stage["timing_recovery"] += time.perf_counter() - started
if symbols.size < PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS:
rejected += 1
continue
started = time.perf_counter()
estimate = estimate_from_preamble(symbols, symbol_rate)
corrected = correct_pilot_estimate(symbols, estimate)
stage["carrier_estimation"] += time.perf_counter() - started
started = time.perf_counter()
packet_bytes = _decode_phy_header(
corrected[PREAMBLE_SYMBOLS:PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS].real
)
stage["phy_header"] += time.perf_counter() - started
if packet_bytes is None:
rejected += 1
continue
frame_symbols = (
PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS
+ math.ceil(punctured_fec.coded_bit_count(packet_bytes * 8, "2/3") / 2)
)
corrected = corrected[:frame_symbols]
started = time.perf_counter()
tracked, error_rms = track_qpsk_body(corrected)
stage["carrier_tracking"] += time.perf_counter() - started
started = time.perf_counter()
reception = receive_frame(
tracked, "QPSK", "2/3", symbol_rate,
maximum_start=0, fec_decoder=fec_decoder,
)
stage["strict_phy_fec_crc"] += time.perf_counter() - started
if not reception.crc_pass or reception.packet is None or reception.payload is None:
rejected += 1
continue
accepted.append(ReceivedIqPacket(
reception.packet, reception.payload, refined.start_sample,
(refined.start_sample % samples_per_symbol_count) / samples_per_symbol_count,
estimate.frequency_hz, estimate.phase_rad, estimate.quadrant,
refined.score, error_rms,
))
return IqStreamResult(tuple(accepted), len(candidates), rejected, stage)