"""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")