VIDEO-005: add full offline QPSK IQ video receiver
This commit is contained in:
154
protocol/video005_receiver.py
Normal file
154
protocol/video005_receiver.py
Normal 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)
|
||||
Reference in New Issue
Block a user