115 lines
5.0 KiB
Python
115 lines
5.0 KiB
Python
"""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")
|