Complete VIDEO-001 through VIDEO-004 software video studies

This commit is contained in:
LittleSam129
2026-09-16 18:27:35 +03:00
parent af7c05f2da
commit 95f664076a
403 changed files with 392009 additions and 0 deletions

265
protocol/video003_modem.py Normal file
View 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)