"""Отдельный программный 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)