507 lines
18 KiB
Python
507 lines
18 KiB
Python
"""Синтетические и 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
|