VIDEO-005: add full offline QPSK IQ video receiver

This commit is contained in:
LittleSam129
2026-09-17 13:34:58 +03:00
parent 95f664076a
commit 6df5f8aa39
18 changed files with 2651 additions and 13 deletions

114
tests/test_video005_iq.py Normal file
View File

@@ -0,0 +1,114 @@
"""VIDEO-005: приём из IQ без границ, фазовых и битовых подсказок."""
from __future__ import annotations
import inspect
import math
import numpy as np
import pytest
from protocol.video003_modem import build_frame
from protocol.video005_carrier import BODY_START, track_qpsk_body
from protocol.video005_iq_waveform import SAMPLE_RATE_HZ, shape_packet, shape_stream
from protocol.video005_receiver import receive_iq_stream
from tools.video005_channel import impair
@pytest.fixture(scope="module")
def packet_fixture() -> tuple:
frame = build_frame(bytes((index * 37) % 256 for index in range(1012)),
"QPSK", "2/3", sequence_number=7)
return frame, shape_packet(frame)
def _exact(iq: np.ndarray, expected: bytes) -> bool:
received = receive_iq_stream(iq, decoder_backend="legacy")
return len(received.packets) == 1 and received.packets[0].packet == expected
def test_clean_raw_iq_is_exact(packet_fixture: tuple) -> None:
frame, iq = packet_fixture
result = receive_iq_stream(iq, decoder_backend="legacy")
assert len(result.packets) == 1
assert result.packets[0].packet == frame.packet
assert result.packets[0].payload == bytes((index * 37) % 256 for index in range(1012))
assert iq.size / SAMPLE_RATE_HZ > 0
@pytest.mark.parametrize("offset", [0, 0.1, -0.1, 0.25, -0.25, 0.4, -0.4])
def test_fractional_timing_is_recovered(packet_fixture: tuple, offset: float) -> None:
frame, iq = packet_fixture
baseline = receive_iq_stream(iq, decoder_backend="legacy").packets[0].start_sample
result = receive_iq_stream(impair(iq, timing_offset_symbols=offset),
decoder_backend="legacy")
assert len(result.packets) == 1
assert result.packets[0].packet == frame.packet
assert abs(result.packets[0].start_sample - baseline - offset * 16) < 0.05
@pytest.mark.parametrize("offset_hz", [0, 1, -1, 5, -5, 20, -20, 100, -100])
def test_carrier_frequency_is_recovered(packet_fixture: tuple, offset_hz: int) -> None:
frame, iq = packet_fixture
result = receive_iq_stream(impair(iq, cfo_hz=offset_hz), decoder_backend="legacy")
assert len(result.packets) == 1
assert result.packets[0].packet == frame.packet
assert abs(result.packets[0].carrier_offset_hz - offset_hz) < 0.5
@pytest.mark.parametrize("phase_degrees", [0, 15, -15, 45, -45, 90, 180])
def test_preamble_resolves_phase_and_quadrant(packet_fixture: tuple,
phase_degrees: int) -> None:
frame, iq = packet_fixture
result = receive_iq_stream(impair(iq, phase_degrees=phase_degrees),
decoder_backend="legacy")
assert len(result.packets) == 1
assert result.packets[0].packet == frame.packet
error = math.remainder(result.packets[0].initial_phase_rad
- math.radians(phase_degrees), 2 * math.pi)
assert abs(error) < 0.02
if phase_degrees in (90, 180):
assert result.packets[0].quadrant == phase_degrees // 90
@pytest.mark.parametrize("ebn0_db", [3, 6, 9])
def test_awgn_never_yields_wrong_crc_packet(packet_fixture: tuple, ebn0_db: int) -> None:
frame, iq = packet_fixture
result = receive_iq_stream(impair(iq, ebn0_db=ebn0_db), decoder_backend="legacy")
assert all(packet.packet == frame.packet for packet in result.packets)
if ebn0_db == 9:
assert len(result.packets) == 1
@pytest.mark.parametrize("settings", [
{"timing_offset_symbols": 0.4, "cfo_hz": 100,
"phase_degrees": 90, "ebn0_db": 9},
{"timing_offset_symbols": -0.25, "cfo_hz": -20,
"phase_degrees": -45, "ebn0_db": 9},
])
def test_combined_offsets_in_continuous_stream(settings: dict) -> None:
frames = [build_frame(bytes(((index + packet_index) * 37) % 256
for index in range(1012)), "QPSK", "2/3",
sequence_number=packet_index)
for packet_index in range(3)]
iq = impair(shape_stream(frames), **settings)
result = receive_iq_stream(iq, decoder_backend="legacy")
assert [packet.packet for packet in result.packets] == [frame.packet for frame in frames]
def test_qpsk_tracker_reduces_phase_wander_without_truth_input(packet_fixture: tuple) -> None:
frame, _ = packet_fixture
impaired = frame.symbols.copy()
body_count = len(impaired) - BODY_START
impaired[BODY_START:] *= np.exp(1j * np.linspace(0, 0.35, body_count))
tracked, _ = track_qpsk_body(impaired)
before = np.mean(np.abs(np.angle(impaired[BODY_START:] / frame.symbols[BODY_START:])))
after = np.mean(np.abs(np.angle(tracked[BODY_START:] / frame.symbols[BODY_START:])))
assert after < before / 2
def test_receiver_api_has_no_oracle_parameters() -> None:
names = set(inspect.signature(receive_iq_stream).parameters)
assert names == {"iq", "symbol_rate", "carrier_hz", "decoder_backend"}
with pytest.raises(ValueError, match="комплексный IQ"):
receive_iq_stream(np.zeros(100_000), decoder_backend="legacy")