Lab044: validate arbitrary BPSK packet reception
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
506
tests/test_lab044_hackrf_bpsk_packet.py
Normal file
506
tests/test_lab044_hackrf_bpsk_packet.py
Normal file
@@ -0,0 +1,506 @@
|
||||
"""Синтетические и fail-closed проверки Lab044 без оборудования."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from experiments import lab043_hackrf_bpsk as lab043
|
||||
from experiments import lab044_hackrf_bpsk_packet as lab044
|
||||
from protocol.packet import CRCError, parse_packet
|
||||
|
||||
|
||||
def deterministic_payload() -> bytes:
|
||||
return bytes(range(256))
|
||||
|
||||
|
||||
def test_bytes_bits_round_trip_is_explicit_msb_first() -> None:
|
||||
random_bytes = np.random.default_rng(44).integers(0, 256, 1024, dtype=np.uint8).tobytes()
|
||||
bits = lab044.bytes_to_bits(random_bytes)
|
||||
assert bits[:8].tolist() == [int(bit) for bit in f"{random_bytes[0]:08b}"]
|
||||
assert lab044.bits_to_bytes(bits) == random_bytes
|
||||
|
||||
|
||||
def test_phy_header_round_trip_and_valid_length() -> None:
|
||||
decoded = lab044.decode_phy_header(
|
||||
lab044.bytes_to_bits(lab044.encode_phy_header(268))
|
||||
)
|
||||
assert decoded == {"passed": True, "reason": None, "sync": 0xD391, "packet_bytes": 268}
|
||||
|
||||
|
||||
def test_phy_header_rejects_zero_length() -> None:
|
||||
header = lab044.bytes_to_bits(struct_pack_header(0))
|
||||
assert lab044.decode_phy_header(header)["reason"] == "LENGTH_OUT_OF_RANGE"
|
||||
|
||||
|
||||
def test_phy_header_rejects_excessive_length() -> None:
|
||||
header = lab044.bytes_to_bits(struct_pack_header(lab044.MAX_PACKET_BYTES + 1))
|
||||
assert lab044.decode_phy_header(header)["reason"] == "LENGTH_OUT_OF_RANGE"
|
||||
|
||||
|
||||
def struct_pack_header(length: int) -> bytes:
|
||||
import struct
|
||||
|
||||
return struct.pack(lab044.PHY_HEADER_FORMAT, lab044.PHY_SYNC, length)
|
||||
|
||||
|
||||
def test_truncated_phy_header_is_safe() -> None:
|
||||
result = lab044.decode_phy_header(np.zeros(15, dtype=np.uint8))
|
||||
assert result["passed"] is False
|
||||
assert result["reason"] == "TRUNCATED"
|
||||
|
||||
|
||||
def test_arbitrary_payload_packet_and_frame_round_trip() -> None:
|
||||
plan = lab044.build_transmission_plan(deterministic_payload())
|
||||
assert len(plan.packet) == 268
|
||||
assert plan.frame_symbol_count == 3456
|
||||
assert plan.waveform_sample_count == 1_399_200
|
||||
assert parse_packet(plan.packet).payload == deterministic_payload()
|
||||
|
||||
|
||||
def test_crc_pass_and_one_payload_bit_crc_fail() -> None:
|
||||
plan = lab044.build_transmission_plan(deterministic_payload())
|
||||
assert parse_packet(plan.packet).payload == deterministic_payload()
|
||||
corrupted = bytearray(plan.packet)
|
||||
corrupted[lab044.HEADER_SIZE + 7] ^= 0x01
|
||||
with pytest.raises(CRCError):
|
||||
parse_packet(bytes(corrupted))
|
||||
|
||||
|
||||
def synthetic_capture(phase_rad: float = 0.0, cfo_hz: float = 0.0) -> tuple[np.ndarray, lab044.TransmissionPlan]:
|
||||
plan = lab044.build_transmission_plan(deterministic_payload())
|
||||
tx = lab044.signed_iq_bytes_to_complex64(plan.tx_cs8)
|
||||
indexes = np.arange(len(tx), dtype=np.float64)
|
||||
impaired = tx * np.exp(1j * (phase_rad + 2 * np.pi * cfo_hz * indexes / lab044.SAMPLE_RATE_HZ))
|
||||
capture = np.concatenate((
|
||||
np.zeros(60_000, dtype=np.complex64),
|
||||
impaired.astype(np.complex64),
|
||||
np.zeros(60_000, dtype=np.complex64),
|
||||
))
|
||||
return capture, plan
|
||||
|
||||
|
||||
def test_ideal_bpsk_receiver_recovers_unknown_payload_and_crc() -> None:
|
||||
capture, plan = synthetic_capture()
|
||||
reception = lab044.analyze_capture_independently(capture)
|
||||
assert reception.analysis["phy_sync_pass"] is True
|
||||
assert reception.analysis["frame_header_pass"] is True
|
||||
assert reception.analysis["packet_received"] is True
|
||||
assert reception.analysis["protocol_header_pass"] is True
|
||||
assert reception.analysis["crc"] == lab044.CRC_PASS
|
||||
assert reception.payload_bytes == plan.payload
|
||||
|
||||
|
||||
def test_polarity_inversion_is_resolved_only_by_preamble() -> None:
|
||||
capture, plan = synthetic_capture(phase_rad=np.pi)
|
||||
reception = lab044.analyze_capture_independently(capture)
|
||||
assert reception.analysis["crc"] == lab044.CRC_PASS
|
||||
assert reception.payload_bytes == plan.payload
|
||||
|
||||
|
||||
def test_synthetic_cfo_and_timing_case() -> None:
|
||||
capture, plan = synthetic_capture(phase_rad=0.7, cfo_hz=0.2)
|
||||
reception = lab044.analyze_capture_independently(capture)
|
||||
assert reception.analysis["crc"] == lab044.CRC_PASS
|
||||
assert reception.payload_bytes == plan.payload
|
||||
assert reception.analysis["detection"]["payload_used"] is False
|
||||
|
||||
|
||||
def test_truncated_protocol_packet_is_flagged_without_exception() -> None:
|
||||
"""Gap A: valid preamble + valid PHY header + physically cut-off packet body.
|
||||
|
||||
The capture array itself ends partway through the protocol packet (no
|
||||
trailing zero padding), so the demodulated bit stream is genuinely
|
||||
shorter than PHY LENGTH implies. This must be reported as a structured
|
||||
FAIL (TRUNCATED_PACKET) with CRC NOT_REACHED -- never as an exception
|
||||
and never as a fabricated packet/payload.
|
||||
"""
|
||||
|
||||
plan = lab044.build_transmission_plan(deterministic_payload())
|
||||
full = lab044.signed_iq_bytes_to_complex64(plan.tx_cs8)
|
||||
header_symbols = lab044.PREAMBLE_BIT_COUNT + lab044.PHY_HEADER_BIT_COUNT
|
||||
# Preamble and PHY header fully present; only ~100 of 2144 packet bits follow.
|
||||
partial_symbol_count = header_symbols + 100
|
||||
cut_sample = partial_symbol_count * lab044.SAMPLES_PER_SYMBOL
|
||||
truncated = full[:cut_sample]
|
||||
capture = np.concatenate((
|
||||
np.zeros(60_000, dtype=np.complex64),
|
||||
truncated.astype(np.complex64),
|
||||
))
|
||||
|
||||
reception = lab044.analyze_capture_independently(capture)
|
||||
analysis = reception.analysis
|
||||
|
||||
assert analysis["phy_sync_pass"] is True
|
||||
assert analysis["frame_header_pass"] is False
|
||||
assert analysis["packet_received"] is False
|
||||
assert analysis["frame_header"]["reason"] == "TRUNCATED_PACKET"
|
||||
assert analysis["crc"] == lab044.CRC_NOT_REACHED
|
||||
assert analysis["classification"] == "C"
|
||||
assert reception.packet_bytes is None
|
||||
assert reception.payload_bytes is None
|
||||
|
||||
|
||||
def test_capture_intervals_present_for_independently_decoded_frame() -> None:
|
||||
"""Gap B: BEFORE/DURING/AFTER diagnostics, bounded by the independent start."""
|
||||
|
||||
capture, plan = synthetic_capture()
|
||||
reception = lab044.analyze_capture_independently(capture)
|
||||
intervals = reception.analysis["capture_intervals"]
|
||||
|
||||
assert intervals["frame_start_sample"] == 60_000
|
||||
assert intervals["frame_end_sample"] > intervals["frame_start_sample"]
|
||||
for region in ("before", "during", "after"):
|
||||
stats = intervals[region]
|
||||
assert stats is not None
|
||||
assert "sample_range" in stats
|
||||
assert "duration_seconds" in stats
|
||||
assert "mean_i_code" in stats
|
||||
assert "mean_q_code" in stats
|
||||
assert "rms_after_dc_removal_code" in stats
|
||||
assert "amplitude_p95_code" in stats
|
||||
assert "amplitude_p99_code" in stats
|
||||
assert "amplitude_p999_code" in stats
|
||||
assert "peak_complex_code" in stats
|
||||
assert "clipping_count" in stats
|
||||
assert "clipping_fraction" in stats
|
||||
assert "декодированной PHY LENGTH" in intervals["note"]
|
||||
|
||||
|
||||
def test_capture_intervals_undefined_when_no_independent_start(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Gap B: with no independent preamble, DURING must stay undefined, not guessed."""
|
||||
|
||||
def never_detects(samples: np.ndarray) -> dict:
|
||||
return {
|
||||
"preamble_detected": False,
|
||||
"method": "stub",
|
||||
"payload_used": False,
|
||||
"peak": 0.0,
|
||||
"median": 0.0,
|
||||
"over_median_db": 0.0,
|
||||
"over_second_db": 0.0,
|
||||
"coarse_start_sample": 0,
|
||||
"refined_start_sample": 0,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(lab044, "_detect_preamble", never_detects)
|
||||
capture = np.zeros(50_000, dtype=np.complex64)
|
||||
reception = lab044.analyze_capture_independently(capture)
|
||||
intervals = reception.analysis["capture_intervals"]
|
||||
|
||||
assert intervals["before"] is None
|
||||
assert intervals["during"] is None
|
||||
assert intervals["after"] is None
|
||||
assert intervals["whole_capture"] is not None
|
||||
assert "не найден" in intervals["note"]
|
||||
|
||||
|
||||
def test_receiver_does_not_read_payload_before_diagnostic(tmp_path: Path) -> None:
|
||||
events: list[str] = []
|
||||
payload_path = tmp_path / "tx_payload.bin"
|
||||
packet_path = tmp_path / "tx_packet.bin"
|
||||
payload_path.write_bytes(deterministic_payload())
|
||||
packet_path.write_bytes(b"packet")
|
||||
reception = lab044.IndependentReception({"crc": lab044.CRC_FAIL}, b"rx", b"payload")
|
||||
|
||||
def analyzer(samples: np.ndarray) -> lab044.IndependentReception:
|
||||
del samples
|
||||
events.append("independent_decode")
|
||||
return reception
|
||||
|
||||
def comparer(
|
||||
result: lab044.IndependentReception,
|
||||
tx_payload_path: Path,
|
||||
tx_packet_path: Path,
|
||||
) -> dict:
|
||||
assert result.analysis["crc"] == lab044.CRC_FAIL
|
||||
events.append("diagnostic_read")
|
||||
assert tx_payload_path.read_bytes() == deterministic_payload()
|
||||
assert tx_packet_path.read_bytes() == b"packet"
|
||||
return {"stage": "AFTER_INDEPENDENT_CRC_DECISION"}
|
||||
|
||||
lab044.analyze_then_compare(
|
||||
np.zeros(1, dtype=np.complex64),
|
||||
payload_path,
|
||||
packet_path,
|
||||
analyzer=analyzer,
|
||||
comparer=comparer,
|
||||
)
|
||||
assert events == ["independent_decode", "diagnostic_read"]
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
def __init__(self) -> None:
|
||||
self.commands: list[list[str]] = []
|
||||
|
||||
def __call__(self, command: list[str]) -> subprocess.CompletedProcess:
|
||||
self.commands.append(command)
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(self, startup_failed: bool = False) -> None:
|
||||
self.startup_failed = startup_failed
|
||||
self.terminated = False
|
||||
|
||||
def poll(self) -> int | None:
|
||||
return 1 if self.startup_failed else (0 if self.terminated else None)
|
||||
|
||||
def wait(self, timeout: float) -> int:
|
||||
del timeout
|
||||
return 0
|
||||
|
||||
def terminate(self) -> None:
|
||||
self.terminated = True
|
||||
|
||||
def close_logs(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class FakeStarter:
|
||||
def __init__(self, startup_failed: bool = False) -> None:
|
||||
self.startup_failed = startup_failed
|
||||
self.processes: list[FakeProcess] = []
|
||||
|
||||
def __call__(self, command: list[str], stdout_path: Path, stderr_path: Path) -> FakeProcess:
|
||||
stdout_path.write_text("0 overruns\n", encoding="utf-8")
|
||||
stderr_path.write_text("", encoding="utf-8")
|
||||
raw_path = Path(command[command.index("-r") + 1])
|
||||
raw_path.write_bytes(bytes(2 * lab044.CAPTURE_SAMPLE_COUNT))
|
||||
process = FakeProcess(self.startup_failed)
|
||||
self.processes.append(process)
|
||||
return process
|
||||
|
||||
|
||||
def fake_reception() -> lab044.IndependentReception:
|
||||
return lab044.IndependentReception(
|
||||
{
|
||||
"classification": "A",
|
||||
"crc": lab044.CRC_PASS,
|
||||
"phy_sync_pass": True,
|
||||
"frame_header_pass": True,
|
||||
"packet_received": True,
|
||||
"protocol_header_pass": True,
|
||||
},
|
||||
b"packet",
|
||||
b"payload",
|
||||
)
|
||||
|
||||
|
||||
def allow_preflight(
|
||||
runner: lab043.CommandRunner,
|
||||
guard: lab043.TransmitGuard,
|
||||
) -> dict:
|
||||
del runner
|
||||
guard.allow()
|
||||
return {"passed": True}
|
||||
|
||||
|
||||
def test_common_clock_failure_means_zero_tx(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
runner = FakeRunner()
|
||||
monkeypatch.setattr(
|
||||
lab043,
|
||||
"common_clock_preflight",
|
||||
lambda runner, guard: (_ for _ in ()).throw(RuntimeError("clock")),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="clock"):
|
||||
lab044.run_hardware_experiment(
|
||||
tmp_path,
|
||||
runner=runner,
|
||||
payload_factory=lambda count: bytes(count),
|
||||
)
|
||||
assert not any("-t" in command for command in runner.commands)
|
||||
|
||||
|
||||
def test_frame_validation_failure_means_zero_tx(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
runner = FakeRunner()
|
||||
monkeypatch.setattr(
|
||||
lab044,
|
||||
"validate_transmission_plan",
|
||||
lambda plan: (_ for _ in ()).throw(ValueError("frame")),
|
||||
)
|
||||
with pytest.raises(ValueError, match="frame"):
|
||||
lab044.run_hardware_experiment(
|
||||
tmp_path,
|
||||
runner=runner,
|
||||
payload_factory=lambda count: bytes(count),
|
||||
)
|
||||
assert runner.commands == []
|
||||
|
||||
|
||||
def test_rx_startup_failure_means_zero_tx(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(lab044, "CAPTURE_SAMPLE_COUNT", 16)
|
||||
monkeypatch.setattr(lab043, "common_clock_preflight", allow_preflight)
|
||||
runner = FakeRunner()
|
||||
starter = FakeStarter(startup_failed=True)
|
||||
with pytest.raises(RuntimeError, match="RX завершился"):
|
||||
lab044.run_hardware_experiment(
|
||||
tmp_path,
|
||||
runner=runner,
|
||||
process_starter=starter,
|
||||
sleep_function=lambda seconds: None,
|
||||
payload_factory=lambda count: bytes(count),
|
||||
)
|
||||
assert not any("-t" in command for command in runner.commands)
|
||||
assert starter.processes[0].terminated is True
|
||||
|
||||
|
||||
def test_one_tx_and_exception_cleanup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(lab044, "CAPTURE_SAMPLE_COUNT", 16)
|
||||
monkeypatch.setattr(lab043, "common_clock_preflight", allow_preflight)
|
||||
cleanup_calls: list[bool] = []
|
||||
monkeypatch.setattr(
|
||||
lab043,
|
||||
"_disable_clock_outputs",
|
||||
lambda runner: cleanup_calls.append(True) or [],
|
||||
)
|
||||
runner = FakeRunner()
|
||||
starter = FakeStarter()
|
||||
|
||||
def analyzer(samples: np.ndarray) -> lab044.IndependentReception:
|
||||
del samples
|
||||
raise RuntimeError("analysis")
|
||||
|
||||
with pytest.raises(RuntimeError, match="analysis"):
|
||||
lab044.run_hardware_experiment(
|
||||
tmp_path,
|
||||
runner=runner,
|
||||
process_starter=starter,
|
||||
sleep_function=lambda seconds: None,
|
||||
payload_factory=lambda count: bytes(count),
|
||||
analyzer=analyzer,
|
||||
)
|
||||
assert sum("-t" in command for command in runner.commands) == 1
|
||||
assert starter.processes[0].terminated is True
|
||||
assert cleanup_calls == [True]
|
||||
|
||||
|
||||
def test_transmit_guard_rejects_second_tx() -> None:
|
||||
guard = lab043.TransmitGuard()
|
||||
guard.allow()
|
||||
guard.register_transmit()
|
||||
with pytest.raises(RuntimeError, match="Повторная передача"):
|
||||
guard.register_transmit()
|
||||
|
||||
|
||||
def test_artifact_sha256_recorded_for_every_real_rx_and_tx_file(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Gap C: every artifact that actually exists on disk gets a real SHA-256."""
|
||||
|
||||
monkeypatch.setattr(lab044, "CAPTURE_SAMPLE_COUNT", 16)
|
||||
monkeypatch.setattr(lab043, "common_clock_preflight", allow_preflight)
|
||||
runner = FakeRunner()
|
||||
starter = FakeStarter()
|
||||
reception = fake_reception()
|
||||
|
||||
def analyzer(samples: np.ndarray) -> lab044.IndependentReception:
|
||||
del samples
|
||||
return reception
|
||||
|
||||
def comparer(
|
||||
result: lab044.IndependentReception,
|
||||
tx_payload_path: Path,
|
||||
tx_packet_path: Path,
|
||||
) -> dict:
|
||||
del result, tx_payload_path, tx_packet_path
|
||||
return {"stage": "AFTER_INDEPENDENT_CRC_DECISION"}
|
||||
|
||||
lab044.run_hardware_experiment(
|
||||
tmp_path,
|
||||
runner=runner,
|
||||
process_starter=starter,
|
||||
sleep_function=lambda seconds: None,
|
||||
payload_factory=lambda count: bytes(count),
|
||||
analyzer=analyzer,
|
||||
comparer=comparer,
|
||||
)
|
||||
|
||||
metadata = json.loads((tmp_path / "metadata.json").read_text(encoding="utf-8"))
|
||||
hashes = metadata["artifact_sha256"]
|
||||
for name in (
|
||||
"tx_payload.bin",
|
||||
"tx_packet.bin",
|
||||
"tx_bpsk_cs8.iq",
|
||||
"rx_cs8.iq",
|
||||
"rx_packet.bin",
|
||||
"rx_payload.bin",
|
||||
):
|
||||
path = tmp_path / name
|
||||
assert path.is_file(), name
|
||||
assert hashes[name] == hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def test_no_fake_rx_artifacts_or_hashes_when_packet_not_decoded(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Gap C: never fabricate rx_packet.bin/rx_payload.bin, never fabricate their hash."""
|
||||
|
||||
monkeypatch.setattr(lab044, "CAPTURE_SAMPLE_COUNT", 16)
|
||||
monkeypatch.setattr(lab043, "common_clock_preflight", allow_preflight)
|
||||
runner = FakeRunner()
|
||||
starter = FakeStarter()
|
||||
reception = lab044.IndependentReception(
|
||||
{
|
||||
"classification": "D",
|
||||
"crc": lab044.CRC_NOT_REACHED,
|
||||
"phy_sync_pass": False,
|
||||
"frame_header_pass": False,
|
||||
"packet_received": False,
|
||||
"protocol_header_pass": False,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
def analyzer(samples: np.ndarray) -> lab044.IndependentReception:
|
||||
del samples
|
||||
return reception
|
||||
|
||||
def comparer(
|
||||
result: lab044.IndependentReception,
|
||||
tx_payload_path: Path,
|
||||
tx_packet_path: Path,
|
||||
) -> dict:
|
||||
del result, tx_payload_path, tx_packet_path
|
||||
return {"stage": "AFTER_INDEPENDENT_CRC_DECISION"}
|
||||
|
||||
lab044.run_hardware_experiment(
|
||||
tmp_path,
|
||||
runner=runner,
|
||||
process_starter=starter,
|
||||
sleep_function=lambda seconds: None,
|
||||
payload_factory=lambda count: bytes(count),
|
||||
analyzer=analyzer,
|
||||
comparer=comparer,
|
||||
)
|
||||
|
||||
assert not (tmp_path / "rx_packet.bin").exists()
|
||||
assert not (tmp_path / "rx_payload.bin").exists()
|
||||
metadata = json.loads((tmp_path / "metadata.json").read_text(encoding="utf-8"))
|
||||
hashes = metadata["artifact_sha256"]
|
||||
assert hashes["rx_packet.bin"] is None
|
||||
assert hashes["rx_payload.bin"] is None
|
||||
assert hashes["rx_cs8.iq"] is not None
|
||||
assert hashes["tx_payload.bin"] is not None
|
||||
|
||||
|
||||
def test_direct_invocation_smoke() -> None:
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(project_root / "experiments" / "lab044_hackrf_bpsk_packet.py"), "--help"],
|
||||
cwd=project_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "--hardware" in result.stdout
|
||||
Reference in New Issue
Block a user