Lab044: validate arbitrary BPSK packet reception

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
LittleSam129
2026-08-31 14:39:12 +03:00
parent 4cee6e9b96
commit 72f933a32b
4 changed files with 1611 additions and 0 deletions

View 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