VIDEO-005: add full offline QPSK IQ video receiver
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
68
protocol/video005_carrier.py
Normal file
68
protocol/video005_carrier.py
Normal 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
|
||||
60
protocol/video005_iq_waveform.py
Normal file
60
protocol/video005_iq_waveform.py
Normal 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))
|
||||
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)
|
||||
106
protocol/video005_timing.py
Normal file
106
protocol/video005_timing.py
Normal 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)
|
||||
Reference in New Issue
Block a user