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

@@ -252,6 +252,7 @@ def matrix_inverse(
for value, pivot_value in zip(
augmented[row_index],
augmented[pivot_column],
strict=True,
)
]
@@ -305,7 +306,7 @@ def _linear_combine(
"""Combine equal-size byte strings over GF(256), using C-level helpers."""
accumulator = 0
for coefficient, symbol in zip(coefficients, symbols):
for coefficient, symbol in zip(coefficients, symbols, strict=True):
if coefficient == 0:
continue
translated = symbol.translate(

View File

@@ -10,7 +10,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from collections import deque
import struct
from typing import Sequence
from typing import Sequence, cast
from protocol.packet_erasure_fec import (
InsufficientSymbolsError, _linear_combine, encode_parity_symbols,
@@ -212,7 +212,7 @@ class VideoErasureCollector:
block.wires[symbol.index] = wire
output = []
if not symbol.is_parity and symbol.index not in block.delivered:
item = direct_item
item = cast(Delivery, direct_item)
block.delivered.add(symbol.index)
if now_ms - item.capture_ms < self.max_age_ms:
output.append(item)

View File

@@ -6,6 +6,8 @@
from __future__ import annotations
from typing import Any, Callable, cast
import numpy as np
from protocol import convolutional_fec, punctured_fec
@@ -54,8 +56,12 @@ def _trellis(soft_values: np.ndarray, information_bit_count: int,
return decoded[:information_bit_count]
COMPILED_TRELLIS = (njit(cache=False, nogil=True, fastmath=False)(_trellis)
if njit is not None else None)
_compiler = cast(Any, njit) # У Numba нет точной типизации фабрики декоратора.
COMPILED_TRELLIS: Callable[[np.ndarray, int, np.ndarray, np.ndarray], np.ndarray] | None = (
cast(Callable[[np.ndarray, int, np.ndarray, np.ndarray], np.ndarray],
_compiler(cache=False, nogil=True, fastmath=False)(_trellis))
if njit is not None else None
)
def decode_soft(soft_values: np.ndarray, information_bit_count: int,

View File

@@ -0,0 +1,68 @@
"""Оценка несущей по преамбуле и слежение за фазой QPSK-пакета."""
from __future__ import annotations
from dataclasses import dataclass
import math
import numpy as np
from protocol.video003_modem import (
PHY_HEADER_SYMBOLS,
PREAMBLE_SYMBOLS,
_estimate_phase_frequency,
bpsk_symbols,
known_preamble_bits,
)
BODY_START = PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS
TRACK_BLOCK_SYMBOLS = 64
@dataclass(frozen=True)
class PilotEstimate:
phase_rad: float
frequency_hz: float
phase_step_rad: float
quadrant: int
def estimate_from_preamble(symbols: np.ndarray, symbol_rate: int) -> PilotEstimate:
"""Оценивает частоту и абсолютную фазу, включая поворот на 90°/180°."""
if symbols.ndim != 1 or len(symbols) < PREAMBLE_SYMBOLS:
raise ValueError("недостаточно символов известной преамбулы")
known = bpsk_symbols(known_preamble_bits())
phase, phase_step = _estimate_phase_frequency(symbols[:PREAMBLE_SYMBOLS], known)
return PilotEstimate(
phase,
phase_step * symbol_rate / (2 * math.pi),
phase_step,
round(phase / (math.pi / 2)) % 4,
)
def correct_pilot_estimate(symbols: np.ndarray, estimate: PilotEstimate) -> np.ndarray:
"""Убирает оценённые фазу и CFO из всего найденного пакета."""
indexes = np.arange(len(symbols), dtype=np.float64)
correction = np.exp(-1j * (estimate.phase_rad + estimate.phase_step_rad * indexes))
return symbols * correction.astype(np.complex64)
def track_qpsk_body(symbols: np.ndarray) -> tuple[np.ndarray, float]:
"""Причинное слежение: решение строится из текущего QPSK, без эталонных битов."""
corrected = symbols.copy()
phase_state = 0.0
errors: list[float] = []
for start in range(BODY_START, len(symbols), TRACK_BLOCK_SYMBOLS):
stop = min(start + TRACK_BLOCK_SYMBOLS, len(symbols))
block = symbols[start:stop] * np.exp(-1j * phase_state)
decisions = (np.where(block.real >= 0, 1.0, -1.0)
+ 1j * np.where(block.imag >= 0, 1.0, -1.0)).astype(np.complex64)
error = float(np.angle(np.sum(block * np.conj(decisions))))
error = float(np.clip(error, -0.2, 0.2))
phase_state += 0.35 * error
corrected[start:stop] = symbols[start:stop] * np.exp(-1j * phase_state)
errors.append(error)
root_mean_square = math.sqrt(sum(error * error for error in errors) / len(errors)) if errors else 0.0
return corrected, root_mean_square

View File

@@ -0,0 +1,60 @@
"""Формирование комплексного IQ для программного видеотракта VIDEO-005."""
from __future__ import annotations
from functools import lru_cache
from typing import Sequence
import numpy as np
from scipy.signal import upfirdn
from protocol.bpsk_radio import root_raised_cosine_taps
from protocol.video003_modem import (
RRC_ROLLOFF,
RRC_SPAN_SYMBOLS,
SAMPLE_RATE_HZ,
OfflineFrame,
samples_per_symbol,
)
SYMBOL_RATE = 500_000
NOMINAL_CARRIER_HZ = 125_000.0
GUARD_SAMPLES = 64
@lru_cache(maxsize=2)
def rrc_taps(symbol_rate: int = SYMBOL_RATE) -> np.ndarray:
"""Возвращает неизменяемые коэффициенты уже применяемого RRC-фильтра."""
taps = root_raised_cosine_taps(
RRC_ROLLOFF, samples_per_symbol(symbol_rate), RRC_SPAN_SYMBOLS
)
taps.flags.writeable = False
return taps
def shape_packet(
frame: OfflineFrame,
symbol_rate: int = SYMBOL_RATE,
carrier_hz: float = NOMINAL_CARRIER_HZ,
) -> np.ndarray:
"""Формирует один IQ-пакет с защитными паузами, без канальных нарушений."""
if not np.isfinite(carrier_hz) or abs(carrier_hz) >= SAMPLE_RATE_HZ / 2:
raise ValueError("несущая должна лежать внутри полосы дискретизации")
if frame.modulation != "QPSK" or frame.rate != "2/3":
raise ValueError("VIDEO-005 использует QPSK и внутренний FEC 2/3")
shaped = upfirdn(rrc_taps(symbol_rate), frame.symbols, up=samples_per_symbol(symbol_rate))
envelope = np.pad(shaped, (GUARD_SAMPLES, GUARD_SAMPLES))
phase = 2 * np.pi * carrier_hz * np.arange(envelope.size) / SAMPLE_RATE_HZ
return (envelope * np.exp(1j * phase)).astype(np.complex64)
def shape_stream(
frames: Sequence[OfflineFrame],
symbol_rate: int = SYMBOL_RATE,
carrier_hz: float = NOMINAL_CARRIER_HZ,
) -> np.ndarray:
"""Соединяет IQ-пакеты; приёмнику их границы не передаются."""
if not frames:
raise ValueError("пустой поток")
return np.concatenate(tuple(shape_packet(frame, symbol_rate, carrier_hz) for frame in frames))

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)

106
protocol/video005_timing.py Normal file
View File

@@ -0,0 +1,106 @@
"""Поиск преамбулы и восстановление дробного момента выборки из IQ."""
from __future__ import annotations
from dataclasses import dataclass
import math
import numpy as np
from scipy.signal import fftconvolve, find_peaks
from protocol.video003_modem import PREAMBLE_SYMBOLS, bpsk_symbols, known_preamble_bits
@dataclass(frozen=True)
class PreambleCandidate:
start_sample: float
score: float
def search_preamble_grid(
matched: np.ndarray,
samples_per_symbol: int,
minimum_score: float = 0.5,
) -> tuple[PreambleCandidate, ...]:
"""Ищет пилот во всём сигнале, не получая границ кадров от передатчика."""
if matched.ndim != 1 or samples_per_symbol < 2 or not 0 < minimum_score < 1:
raise ValueError("неверные параметры поиска преамбулы")
known = bpsk_symbols(known_preamble_bits())
all_peaks: list[PreambleCandidate] = []
# Две тактовые фазы дают грубый поиск; дробный пик уточняется отдельно.
phase_step = max(1, samples_per_symbol // 2)
for phase in range(0, samples_per_symbol, phase_step):
series = matched[phase::samples_per_symbol]
if series.size < PREAMBLE_SYMBOLS:
continue
correlation = fftconvolve(series, known[::-1].conj(), mode="valid")
power = np.abs(series) ** 2
running = np.concatenate(([0.0], np.cumsum(power, dtype=np.float64)))
window_power = running[PREAMBLE_SYMBOLS:] - running[:-PREAMBLE_SYMBOLS]
score = np.abs(correlation) / np.sqrt(
np.maximum(window_power * PREAMBLE_SYMBOLS, 1e-18)
)
peaks, _ = find_peaks(
score, height=minimum_score, distance=PREAMBLE_SYMBOLS // 2
)
for index in peaks:
all_peaks.append(PreambleCandidate(float(phase + index * samples_per_symbol),
float(score[index])))
all_peaks.sort(key=lambda item: item.start_sample)
merged: list[PreambleCandidate] = []
for candidate in all_peaks:
if merged and candidate.start_sample - merged[-1].start_sample < 2 * samples_per_symbol:
if candidate.score > merged[-1].score:
merged[-1] = candidate
else:
merged.append(candidate)
return tuple(merged)
def refine_timing(
matched: np.ndarray,
candidate: PreambleCandidate,
samples_per_symbol: int,
) -> PreambleCandidate:
"""Уточняет пик корреляции до доли отсчёта по известной преамбуле."""
known = bpsk_symbols(known_preamble_bits())
center = int(candidate.start_sample)
half_phase_step = max(2, samples_per_symbol // 4)
if (center < half_phase_step + 1
or center + (PREAMBLE_SYMBOLS - 1) * samples_per_symbol
+ half_phase_step + 1 >= matched.size):
return candidate
indexes = center + np.arange(PREAMBLE_SYMBOLS) * samples_per_symbol
shifts = range(-half_phase_step - 1, half_phase_step + 2)
scores: list[float] = [float(abs(np.vdot(known, matched[indexes + shift])))
for shift in shifts]
best = int(np.argmax(scores))
if best in (0, len(scores) - 1):
fractional = 0.0
else:
curvature = scores[best - 1] - 2 * scores[best] + scores[best + 1]
fractional = (0.0 if curvature >= -1e-12 else
0.5 * (scores[best - 1] - scores[best + 1]) / curvature)
fractional = float(np.clip(fractional, -0.5, 0.5))
return PreambleCandidate(candidate.start_sample + shifts[best] + fractional,
candidate.score)
def sample_symbols(
matched: np.ndarray,
start_sample: float,
samples_per_symbol: int,
maximum_symbols: int,
) -> np.ndarray:
"""Интерполирует найденные такты без входного oracle timing."""
if not math.isfinite(start_sample) or start_sample < 0 or maximum_symbols < 1:
raise ValueError("неверные границы выборки символов")
available = int((matched.size - 2 - start_sample) // samples_per_symbol) + 1
count = min(maximum_symbols, max(0, available))
if count == 0:
return np.empty(0, dtype=np.complex64)
positions = start_sample + np.arange(count, dtype=np.float64) * samples_per_symbol
lower = np.floor(positions).astype(np.int64)
fraction = positions - lower
return (matched[lower] * (1 - fraction)
+ matched[lower + 1] * fraction).astype(np.complex64)