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

97
tests/test_clock001.py Normal file
View File

@@ -0,0 +1,97 @@
"""CLOCK-001: отдельная частота АЦП, строгий приём и область метрик."""
from __future__ import annotations
import math
import numpy as np
import pytest
from scipy.ndimage import map_coordinates
from protocol.video003_modem import SAMPLE_RATE_HZ
from protocol.video005_receiver import IqStreamResult, receive_iq_stream
from tools import clock001
from tools.qpsk_rf_common import SEED, payload_for_sequence
def test_sampling_rate_changes_time_base_and_keeps_cfo_independent() -> None:
tone_hz = 125_000
cfo_hz = 100
indexes = np.arange(200_000)
transmitted = np.exp(2j * np.pi * tone_hz * indexes / SAMPLE_RATE_HZ).astype(np.complex64)
for ppm in (-50, 0, 50):
received = clock001.sample_independent_clock(
transmitted, ppm, cfo_hz=cfo_hz, initial_phase_degrees=45,
)
scale = 1 + ppm * 1e-6
assert len(received) == math.floor((len(transmitted) - 1) * scale) + 1
measured_hz = (np.angle(np.vdot(received[:-1].astype(np.complex128),
received[1:].astype(np.complex128)))
* SAMPLE_RATE_HZ / (2 * np.pi))
assert measured_hz == pytest.approx((tone_hz + cfo_hz) / scale, abs=0.2)
assert np.angle(received[0]) == pytest.approx(np.pi / 4, abs=1e-6)
assert clock001.payload_for_case(256, 7) == payload_for_sequence(SEED, 7)
@pytest.mark.parametrize("ppm", (-50, 50))
def test_long_packets_reacquire_and_decode_at_primary_limit(ppm: int) -> None:
fixture = clock001.build_fixture(1012)
received_iq = clock001.sample_independent_clock(fixture.iq, ppm)
result = receive_iq_stream(received_iq, decoder_backend="legacy")
summary = clock001.summarize_case(
result, fixture, sample_clock_offset_ppm=ppm, cfo_hz=0,
initial_phase_degrees=0, ebn0_db=None, receiver_time_s=1.0,
received_samples=len(received_iq),
)
assert summary["exact_packets"] == 3
assert summary["exact_sequences"] == [0, 1, 2]
assert summary["reacquisition_per_packet"] is True
assert summary["injected_timing_drift_across_packet_symbols"] == pytest.approx(-ppm * 1e-6 * 7572)
@pytest.mark.parametrize("ppm", (-100, 100))
def test_long_packet_stress_failure_is_not_declared_exact(ppm: int) -> None:
fixture = clock001.build_fixture(1012, packet_count=1)
received_iq = clock001.sample_independent_clock(fixture.iq, ppm)
result = receive_iq_stream(received_iq, decoder_backend="legacy")
summary = clock001.summarize_case(
result, fixture, sample_clock_offset_ppm=ppm, cfo_hz=0,
initial_phase_degrees=0, ebn0_db=None, receiver_time_s=1.0,
received_samples=len(received_iq),
)
assert summary["exact_packets"] == 0
assert summary["missing_sequences"] == [0]
assert summary["initial_timing_error_symbols_median"] is None
assert summary["final_timing_error_symbols_median"] is None
assert abs(summary["injected_timing_drift_across_packet_symbols"]) > 0.75
def test_cubic_resampling_confirms_long_packet_stress_boundary() -> None:
"""Проверяет вывод вторым интерполятором, без TX-подсказок приёмнику."""
fixture = clock001.build_fixture(1012, packet_count=1)
for ppm, expected_packets in ((-100, 0), (-50, 1), (50, 1), (100, 0)):
scale = 1 + ppm * 1e-6
count = math.floor((len(fixture.iq) - 1) * scale) + 1
positions = np.arange(count, dtype=np.float64) / scale
received_iq = (
map_coordinates(fixture.iq.real, [positions], order=3, mode="nearest")
+ 1j * map_coordinates(fixture.iq.imag, [positions], order=3, mode="nearest")
).astype(np.complex64)
result = receive_iq_stream(received_iq, decoder_backend="legacy")
assert len(result.packets) == expected_packets
def test_reference_is_used_only_after_receiver(monkeypatch: pytest.MonkeyPatch) -> None:
fixture = clock001.build_fixture(256, packet_count=1)
calls = []
def fake_receiver(iq: np.ndarray) -> IqStreamResult:
calls.append(iq)
return IqStreamResult((), 0, 0, {})
monkeypatch.setattr(clock001, "receive_iq_stream", fake_receiver)
summary = clock001.evaluate_case(fixture, ppm=20)
assert len(calls) == 1
assert isinstance(calls[0], np.ndarray)
assert summary["exact_packets"] == 0
assert summary["missing_sequences"] == [0]

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