Complete VIDEO-001 through VIDEO-004 software video studies
This commit is contained in:
265
protocol/video003_modem.py
Normal file
265
protocol/video003_modem.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""Отдельный программный BPSK/QPSK-тракт VIDEO-003 без обращения к SDR.
|
||||
|
||||
Приёмнику известны только PRBS7-преамбула и формат PHY-заголовка. Длина,
|
||||
кадровая граница, фаза и частотный сдвиг извлекаются из принятого сигнала;
|
||||
исходный payload приёмнику не передаётся.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
import math
|
||||
import struct
|
||||
from typing import Callable
|
||||
import zlib
|
||||
|
||||
import numpy as np
|
||||
|
||||
from protocol import punctured_fec
|
||||
from protocol.bpsk_radio import root_raised_cosine_taps
|
||||
from protocol.packet import PacketError, build_packet, parse_packet
|
||||
|
||||
|
||||
PREAMBLE_SYMBOLS = 1280
|
||||
PHY_HEADER_SYMBOLS = 144
|
||||
MAX_FULL_PACKET_BYTES = 1024
|
||||
SAMPLE_RATE_HZ = 8_000_000
|
||||
RRC_ROLLOFF = 0.35
|
||||
RRC_SPAN_SYMBOLS = 10
|
||||
PHY_MAGIC = 0xD391
|
||||
PHY_VERSION = 1
|
||||
PHY_HEADER_FORMAT = ">HBHB"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def known_preamble_bits() -> np.ndarray:
|
||||
"""Тот же PRBS7 x^7+x^6+1, состояние 0x7f, что и в Lab044."""
|
||||
state = 0x7F
|
||||
result = np.empty(PREAMBLE_SYMBOLS, dtype=np.uint8)
|
||||
for index in range(PREAMBLE_SYMBOLS):
|
||||
result[index] = (state >> 6) & 1
|
||||
feedback = ((state >> 6) ^ (state >> 5)) & 1
|
||||
state = ((state << 1) & 0x7F) | feedback
|
||||
result.flags.writeable = False
|
||||
return result
|
||||
|
||||
|
||||
def bpsk_symbols(bits: np.ndarray) -> np.ndarray:
|
||||
values = np.asarray(bits, dtype=np.uint8)
|
||||
if values.ndim != 1 or not np.all((values == 0) | (values == 1)):
|
||||
raise ValueError("ожидается одномерный массив битов")
|
||||
return (2 * values.astype(np.float64) - 1).astype(np.complex128)
|
||||
|
||||
|
||||
def qpsk_symbols(bits: np.ndarray) -> np.ndarray:
|
||||
"""Gray: 00=(-1-j)/sqrt(2), 01=(-1+j), 11=(1+j), 10=(1-j)."""
|
||||
values = np.asarray(bits, dtype=np.uint8)
|
||||
if values.ndim != 1 or len(values) % 2 or not np.all((values == 0) | (values == 1)):
|
||||
raise ValueError("QPSK требует чётного числа битов {0,1}")
|
||||
pairs = values.reshape(-1, 2)
|
||||
return ((2 * pairs[:, 0].astype(np.float64) - 1)
|
||||
+ 1j * (2 * pairs[:, 1].astype(np.float64) - 1)) / math.sqrt(2)
|
||||
|
||||
|
||||
def qpsk_soft(symbols: np.ndarray) -> np.ndarray:
|
||||
"""Решения по I/Q, приведённые к идеальным значениям ±1."""
|
||||
values = np.asarray(symbols, dtype=np.complex128)
|
||||
soft = np.empty(values.size * 2, dtype=np.float64)
|
||||
soft[0::2] = values.real * math.sqrt(2)
|
||||
soft[1::2] = values.imag * math.sqrt(2)
|
||||
return soft
|
||||
|
||||
|
||||
def samples_per_symbol(symbol_rate: int) -> int:
|
||||
if symbol_rate not in (250_000, 500_000):
|
||||
raise ValueError("VIDEO-003 проверяет 250 или 500 ксимв/с")
|
||||
return SAMPLE_RATE_HZ // symbol_rate
|
||||
|
||||
|
||||
def rrc_roundtrip(symbols: np.ndarray, symbol_rate: int) -> np.ndarray:
|
||||
"""Формирование RRC, согласованный фильтр и выборка в известных тактах.
|
||||
|
||||
Это проверка формы при идеальной тактовой синхронизации, а не новый
|
||||
производственный приёмник.
|
||||
"""
|
||||
sps = samples_per_symbol(symbol_rate)
|
||||
taps = root_raised_cosine_taps(RRC_ROLLOFF, sps, RRC_SPAN_SYMBOLS)
|
||||
upsampled = np.zeros(len(symbols) * sps, dtype=np.complex128)
|
||||
upsampled[::sps] = symbols
|
||||
shaped = np.convolve(upsampled, taps, mode="full")
|
||||
matched = np.convolve(shaped, taps, mode="full")
|
||||
group_delay = len(taps) - 1
|
||||
gain = float(np.sum(taps * taps))
|
||||
return matched[group_delay:group_delay + len(symbols) * sps:sps] / gain
|
||||
|
||||
|
||||
def _phy_header(packet_bytes: int) -> np.ndarray:
|
||||
if not 12 <= packet_bytes <= MAX_FULL_PACKET_BYTES:
|
||||
raise ValueError("полный пакет вне предела PHY")
|
||||
fields = struct.pack(">HBH", PHY_MAGIC, PHY_VERSION, packet_bytes)
|
||||
checksum = zlib.crc32(fields) & 0xFF
|
||||
bits = np.unpackbits(np.frombuffer(fields + bytes([checksum]), dtype=np.uint8))
|
||||
return np.repeat(bits, 3)
|
||||
|
||||
|
||||
def _decode_phy_header(soft: np.ndarray) -> int | None:
|
||||
if len(soft) < PHY_HEADER_SYMBOLS or not np.all(np.isfinite(soft[:PHY_HEADER_SYMBOLS])):
|
||||
return None
|
||||
bits = (soft[:PHY_HEADER_SYMBOLS].reshape(48, 3).sum(axis=1) >= 0).astype(np.uint8)
|
||||
try:
|
||||
magic, version, length, checksum = struct.unpack(PHY_HEADER_FORMAT, np.packbits(bits).tobytes())
|
||||
except struct.error:
|
||||
return None
|
||||
fields = struct.pack(">HBH", magic, version, length)
|
||||
if (magic != PHY_MAGIC or version != PHY_VERSION
|
||||
or checksum != (zlib.crc32(fields) & 0xFF)
|
||||
or not 12 <= length <= MAX_FULL_PACKET_BYTES):
|
||||
return None
|
||||
return length
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OfflineFrame:
|
||||
packet: bytes
|
||||
coded_bits: np.ndarray
|
||||
symbols: np.ndarray
|
||||
modulation: str
|
||||
rate: str
|
||||
|
||||
|
||||
def build_frame(payload: bytes, modulation: str, rate: str,
|
||||
sequence_number: int = 0) -> OfflineFrame:
|
||||
"""Сформировать отдельный смешанный кадр с BPSK-пилотом и заголовком."""
|
||||
if modulation not in ("BPSK", "QPSK"):
|
||||
raise ValueError("неизвестная модуляция")
|
||||
packet = build_packet(payload, message_type=3, sequence_number=sequence_number)
|
||||
if len(packet) > MAX_FULL_PACKET_BYTES:
|
||||
raise ValueError("полный пакет превышает 1024 байта")
|
||||
information = np.unpackbits(np.frombuffer(packet, dtype=np.uint8))
|
||||
coded = punctured_fec.encode(information, rate)
|
||||
if modulation == "QPSK":
|
||||
padded = np.pad(coded, (0, len(coded) % 2))
|
||||
body = qpsk_symbols(padded)
|
||||
else:
|
||||
body = bpsk_symbols(coded)
|
||||
prefix = bpsk_symbols(np.concatenate((known_preamble_bits(), _phy_header(len(packet)))))
|
||||
return OfflineFrame(packet, coded, np.concatenate((prefix, body)), modulation, rate)
|
||||
|
||||
|
||||
def _find_preamble(received: np.ndarray, maximum_start: int) -> int | None:
|
||||
"""Найти начало по дифференциальной корреляции известного пилота."""
|
||||
reference = bpsk_symbols(known_preamble_bits())
|
||||
differential_reference = reference[1:] * np.conj(reference[:-1])
|
||||
last = min(maximum_start, len(received) - PREAMBLE_SYMBOLS)
|
||||
if last < 0:
|
||||
return None
|
||||
best_start, best_score = None, 0.0
|
||||
for start in range(last + 1):
|
||||
section = received[start:start + PREAMBLE_SYMBOLS]
|
||||
products = section[1:] * np.conj(section[:-1])
|
||||
energy = float(np.sum(np.abs(products)))
|
||||
score = abs(np.vdot(differential_reference, products)) / max(energy, 1e-12)
|
||||
if score > best_score:
|
||||
best_start, best_score = start, score
|
||||
return best_start if best_score >= 0.25 else None
|
||||
|
||||
|
||||
def _estimate_phase_frequency(received: np.ndarray, known: np.ndarray) -> tuple[float, float]:
|
||||
"""Оценить фазу и приращение фазы только по известным символам."""
|
||||
despread = received[:len(known)] * np.conj(known)
|
||||
lag = min(256, len(known) // 4)
|
||||
delayed = despread[lag:] * np.conj(despread[:-lag])
|
||||
increment = float(np.angle(np.sum(delayed)) / lag)
|
||||
indexes = np.arange(len(known), dtype=np.float64)
|
||||
residual = despread * np.exp(-1j * increment * indexes)
|
||||
half = len(known) // 2
|
||||
left = np.sum(residual[:half])
|
||||
right = np.sum(residual[half:])
|
||||
increment += float(np.angle(right * np.conj(left)) / half)
|
||||
phase = float(np.angle(np.sum(despread * np.exp(-1j * increment * indexes))))
|
||||
return phase, increment
|
||||
|
||||
|
||||
def _correct(received: np.ndarray, phase: float, increment: float) -> np.ndarray:
|
||||
indexes = np.arange(len(received), dtype=np.float64)
|
||||
return received * np.exp(-1j * (phase + increment * indexes))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OfflineReception:
|
||||
preamble_found: bool
|
||||
phy_header_pass: bool
|
||||
crc_pass: bool
|
||||
packet: bytes | None
|
||||
payload: bytes | None
|
||||
start_symbol: int | None
|
||||
frequency_offset_hz: float | None
|
||||
initial_phase_rad: float | None
|
||||
coded_soft: np.ndarray | None
|
||||
|
||||
|
||||
def receive_frame(
|
||||
received_symbols: np.ndarray,
|
||||
modulation: str,
|
||||
rate: str,
|
||||
symbol_rate: int,
|
||||
maximum_start: int = 64,
|
||||
fec_decoder: Callable[[np.ndarray, int, str], np.ndarray] | None = None,
|
||||
) -> OfflineReception:
|
||||
"""Строго декодировать без подсказки о длине, payload и фазе передатчика."""
|
||||
samples_per_symbol(symbol_rate)
|
||||
if modulation not in ("BPSK", "QPSK") or rate not in punctured_fec.PUNCTURE_PATTERNS:
|
||||
raise ValueError("неизвестный режим")
|
||||
received = np.asarray(received_symbols, dtype=np.complex128)
|
||||
if received.ndim != 1:
|
||||
raise ValueError("сигнал должен быть одномерным массивом")
|
||||
search_end = min(len(received), maximum_start + PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS)
|
||||
if not np.all(np.isfinite(received[:search_end])):
|
||||
raise ValueError("кандидат сигнала содержит неконечные значения")
|
||||
empty = OfflineReception(False, False, False, None, None, None, None, None, None)
|
||||
start = _find_preamble(received, maximum_start)
|
||||
if start is None:
|
||||
return empty
|
||||
frame = received[start:]
|
||||
pilot = bpsk_symbols(known_preamble_bits())
|
||||
phase, increment = _estimate_phase_frequency(frame[:PREAMBLE_SYMBOLS], pilot)
|
||||
header_end = PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS
|
||||
corrected = _correct(frame[:header_end], phase, increment)
|
||||
header_start = PREAMBLE_SYMBOLS
|
||||
packet_bytes = _decode_phy_header(corrected[header_start:header_start + PHY_HEADER_SYMBOLS].real)
|
||||
if packet_bytes is None:
|
||||
return OfflineReception(True, False, False, None, None, start,
|
||||
increment * symbol_rate / (2 * math.pi), phase, None)
|
||||
known = np.concatenate((pilot, bpsk_symbols(_phy_header(packet_bytes))))
|
||||
phase, increment = _estimate_phase_frequency(frame[:len(known)], known)
|
||||
corrected_header = _correct(frame[:header_end], phase, increment)
|
||||
if _decode_phy_header(corrected_header[header_start:header_end].real) != packet_bytes:
|
||||
return OfflineReception(True, False, False, None, None, start,
|
||||
increment * symbol_rate / (2 * math.pi), phase, None)
|
||||
information_bit_count = packet_bytes * 8
|
||||
coded_count = punctured_fec.coded_bit_count(information_bit_count, rate)
|
||||
body_start = PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS
|
||||
bits_per_symbol = 1 if modulation == "BPSK" else 2
|
||||
body_count = math.ceil(coded_count / bits_per_symbol)
|
||||
frame_end = body_start + body_count
|
||||
if len(frame) < frame_end:
|
||||
return OfflineReception(True, True, False, None, None, start,
|
||||
increment * symbol_rate / (2 * math.pi), phase, None)
|
||||
if not np.all(np.isfinite(frame[body_start:frame_end])):
|
||||
raise ValueError("тело сигнала содержит неконечные значения")
|
||||
corrected = _correct(frame[body_start:frame_end],
|
||||
phase + increment * body_start, increment)
|
||||
body = corrected
|
||||
soft = body.real if modulation == "BPSK" else qpsk_soft(body)
|
||||
soft = soft[:coded_count].copy()
|
||||
decoded = (punctured_fec.decode_soft if fec_decoder is None else fec_decoder)(
|
||||
soft, information_bit_count, rate)
|
||||
packet = np.packbits(decoded).tobytes()
|
||||
try:
|
||||
parsed = parse_packet(packet)
|
||||
except PacketError:
|
||||
return OfflineReception(True, True, False, packet, None, start,
|
||||
increment * symbol_rate / (2 * math.pi), phase, soft)
|
||||
return OfflineReception(True, True, True, packet, parsed.payload, start,
|
||||
increment * symbol_rate / (2 * math.pi), phase, soft)
|
||||
Reference in New Issue
Block a user