"""Синтетические и 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