CLOCK-001: validate independent-clock QPSK receiver

This commit is contained in:
LittleSam129
2026-09-17 15:05:44 +03:00
parent 6df5f8aa39
commit 3c31bac0da
12 changed files with 4458 additions and 1 deletions

View File

@@ -0,0 +1,113 @@
"""RF-001: воспроизводимость файлового IQ без аппаратуры и передачи."""
from __future__ import annotations
import json
from itertools import pairwise
from pathlib import Path
import numpy as np
import pytest
from protocol.video003_modem import build_frame
from protocol.video005_receiver import IqStreamResult, ReceivedIqPacket
from tools import qpsk_rf_rx
from tools.qpsk_rf_common import (
GAP_SAMPLES, PACKET_COUNT, PAYLOAD_BYTES, SEED, payload_for_sequence,
read_iq_file, sha256_file,
)
from tools.qpsk_rf_tx import build_dry_run, write_dry_run
def test_payload_and_gap_are_fixed_and_reproducible() -> None:
first = payload_for_sequence(SEED, 0)
assert len(first) == PAYLOAD_BYTES
assert first == payload_for_sequence(SEED, 0)
assert first != payload_for_sequence(SEED, 1)
iq, metadata = build_dry_run()
assert iq.dtype == np.dtype("<c8")
assert metadata["packet_count"] == PACKET_COUNT
assert metadata["seed"] == SEED
assert metadata["inter_packet_gap_samples"] == GAP_SAMPLES
assert metadata["rf_tx"] == 0
starts = metadata["packet_waveform_start_samples"]
assert len(starts) == PACKET_COUNT
assert starts[0] == 0
assert all(right > left for left, right in pairwise(starts))
assert iq.size == metadata["waveform_sample_count"]
with pytest.raises(ValueError):
payload_for_sequence(-1, 0)
def test_full_100_packet_file_roundtrip(tmp_path: Path) -> None:
output_directory = tmp_path / "rf001"
iq_path, metadata_path = write_dry_run(output_directory)
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
assert iq_path.stat().st_size == metadata["waveform_sample_count"] * 8
assert sha256_file(iq_path) == metadata["iq_sha256"]
with pytest.raises(FileExistsError):
write_dry_run(output_directory)
report = qpsk_rf_rx.decode_file(iq_path, truth_metadata_path=metadata_path)
assert report["decoded_packets"] == PACKET_COUNT
assert report["crc_valid_packets"] == PACKET_COUNT
assert report["exact_packets"] == PACKET_COUNT
assert report["packet_sequences"] == list(range(PACKET_COUNT))
assert report["duplicates"] == 0
assert report["missing_packets"] == []
assert report["raw_ber"] == 0
assert report["raw_bits_compared_on_crc_valid_packets"] > 0
assert report["raw_ber_replay_failures"] == 0
assert report["post_fec_bit_errors"] == 0
assert report["same_bytes_as_tx_waveform"]
assert report["rf_tx"] == 0
assert report["sdr_hardware"] == "NOT_ACCESSED"
def test_missing_packets_do_not_get_invented_raw_ber() -> None:
frame = build_frame(payload_for_sequence(SEED, 0), "QPSK", "2/3", sequence_number=0)
packet = ReceivedIqPacket(frame.packet, payload_for_sequence(SEED, 0),
0.0, 0.0, 0.0, 0.0, 0, 1.0, 0.0)
reception = IqStreamResult((packet,), 1, 0, {})
_, metadata = build_dry_run()
report = qpsk_rf_rx.summarize_result(
reception, iq_sample_count=100, iq_sha256="different", iq_format="c64le",
clipping_codes=None, truth=metadata, raw_bits=(0, 3210, 0),
)
assert report["exact_packets"] == 1
assert len(report["missing_packets"]) == 99
assert report["raw_ber"] is None
assert report["raw_ber_on_crc_valid_packets"] == 0
assert report["raw_ber_scope"].startswith("CRC_VALID_PACKETS_ONLY")
assert report["post_fec_error_rate"] is None
assert report["post_fec_error_rate_on_compared_packets"] == 0
def test_truth_file_is_read_only_after_receiver(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
iq_path = tmp_path / "input.c64le"
np.zeros(32, dtype="<c8").tofile(iq_path)
truth_path = tmp_path / "truth.json"
truth_path.write_text("invalid json", encoding="utf-8")
called = []
def fake_receiver(iq: np.ndarray, *, decoder_backend: str) -> IqStreamResult:
assert iq.size == 32
called.append(True)
return IqStreamResult((), 0, 0, {})
monkeypatch.setattr(qpsk_rf_rx, "receive_iq_stream", fake_receiver)
with pytest.raises(json.JSONDecodeError):
qpsk_rf_rx.decode_file(iq_path, truth_metadata_path=truth_path)
assert called == [True]
def test_iq_file_format_and_clipping_validation(tmp_path: Path) -> None:
invalid = tmp_path / "bad.c64le"
invalid.write_bytes(b"123")
with pytest.raises(ValueError, match="c64le"):
read_iq_file(invalid)
raw = tmp_path / "capture.cs8"
raw.write_bytes(bytes([127, 0, 128, 10]))
iq, clipping = read_iq_file(raw, "cs8")
assert iq.dtype == np.complex64
assert iq.size == 2
assert clipping == 2