Files
SDR-Rover/protocol/video003_modem.py

266 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Отдельный программный 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)