"""Аппаратно-независимые проверки Lab046C preparation и fail-closed runner.""" from __future__ import annotations import inspect import json import subprocess import sys from pathlib import Path import numpy as np import pytest from experiments import lab046c_hackrf_bpsk_object as lab046c def small_waveform_setup(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(lab046c, "OBJECT_SIZE_BYTES", 300) monkeypatch.setattr(lab046c, "_validate_fixed_phy", lambda: None) monkeypatch.setattr( lab046c.lab045, "build_tx_cs8", lambda plan: bytes([plan.payload[12] & 0x7F, 0]) * plan.waveform_sample_count, ) def test_frame_and_stream_accounting_matches_actual_code() -> None: frames = lab046c.lab046.build_object_frames(bytes(lab046c.OBJECT_SIZE_BYTES)) assert len(frames) == 68 assert frames[-1].encoded_payload_length - 12 == 36 assert {frame.plan.waveform_sample_count for frame in frames} == {2_306_400} assert sum(frame.plan.waveform_sample_count for frame in frames) == 156_835_200 assert 2 * sum(frame.plan.waveform_sample_count for frame in frames) == 313_670_400 assert sum(frame.plan.airtime_seconds for frame in frames) == pytest.approx(19.6044) def test_preparation_writes_one_stream_sequentially_and_hashes( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: import hashlib small_waveform_setup(monkeypatch) output = tmp_path / "prepared" prepared = lab046c.prepare_transmission( output, object_factory=lambda count: bytes((index * 17 + 3) % 256 for index in range(count)), ) metadata = json.loads(prepared.metadata_path.read_text(encoding="utf-8")) assert prepared.object_path.stat().st_size == 300 assert prepared.frame_count == 2 assert prepared.fragment_count == 2 assert prepared.stream_path.stat().st_size == prepared.stream_file_bytes assert hashlib.sha256(prepared.object_path.read_bytes()).hexdigest() == prepared.object_sha256 assert hashlib.sha256(prepared.stream_path.read_bytes()).hexdigest() == prepared.stream_sha256 assert metadata["rf_tx_count"] == 0 assert metadata["object"]["deterministic_seed"] is None assert metadata["tx_stream"]["inter_frame_samples_added"] == 0 assert metadata["recovered_object"] is None def test_receiver_api_has_no_tx_oracle() -> None: parameters = set(inspect.signature(lab046c.receive_object_from_cs8).parameters) assert parameters == { "capture_path", "preamble_threshold", "max_frames", "max_search_iterations", "chunk_samples", } forbidden = { "tx_starts", "frame_starts", "frame_lengths", "expected_fragment_count", "tx_object", "tx_packets", "tx_coded_bits", } assert parameters.isdisjoint(forbidden) def test_cs8_window_is_complex64_and_exact(tmp_path: Path) -> None: path = tmp_path / "capture.cs8" path.write_bytes(np.asarray([64, -64, -128, 127, 0, 1], dtype=np.int8).tobytes()) raw = np.memmap(path, dtype=np.int8, mode="r") samples = lab046c._cs8_window(raw, 0, 3) assert samples.dtype == np.complex64 assert samples[0] == pytest.approx(0.5 - 0.5j) assert samples[1].real == pytest.approx(-1.0) assert samples[1].imag == pytest.approx(127 / 128) def fake_frame(local_start: int, local_end: int) -> lab046c.lab046b.IqFrameReception: reception = lab046c.lab045.IndependentReception( {"fec_decode_pass": True, "crc": lab046c.lab045.CRC_PASS}, b"packet", bytes(lab046c.lab045.PAYLOAD_SIZE_BYTES), ) return lab046c.lab046b.IqFrameReception( detection_number=1, coarse_start_sample=local_start, refined_start_sample=local_start, frame_end_sample=local_end, preamble_score=0.9, over_median_db=20.0, over_second_db=0.0, timing_offset_samples=0, symbol_sample_phase=0, nominal_sample=0, estimated_cfo_hz=0.0, estimated_phase_rad=0.0, bpsk_polarity="resolved from preamble", preamble_errors=0, snr_db=20.0, post_preamble_soft=np.empty(0), reception=reception, ) def test_windowed_scanner_owns_each_candidate_once_and_cursor_moves_forward( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: path = tmp_path / "capture.cs8" path.write_bytes(bytes(2 * 80)) monkeypatch.setattr(lab046c, "maximum_phy_waveform_samples", lambda: 16) monkeypatch.setattr(lab046c, "REFINEMENT_MARGIN_SAMPLES", 0) monkeypatch.setattr(lab046c.lab044.lab043, "DETECTION_DECIMATION", 8) def detect(samples, threshold): del threshold starts = [8, 24, 40] if len(samples) == 48 else [8] return tuple( lab046c.lab046b.IqPreambleCandidate(start, 0.9, 20.0, 0.0) for start in starts ), 0 def decode(samples, candidate, detection_number): del samples start = candidate.coarse_start_sample frame = fake_frame(start, start + 4) return lab046c.lab046b.CandidateDecode( start, start + 4, True, True, lab046c.lab045.CRC_PASS, True, "CRC_PASS", frame, ) monkeypatch.setattr(lab046c.lab046b, "_detect_iq_preamble_candidates", detect) monkeypatch.setattr(lab046c.lab046b, "_decode_iq_candidate", decode) result = lab046c.scan_cs8_capture(path, chunk_samples=48) starts = [frame.refined_start_sample for frame in result.accepted_frames] assert starts == [8, 24, 40, 56, 72] assert len(starts) == len(set(starts)) assert all(after > before for before, after in zip(result.cursor_history, result.cursor_history[1:])) assert result.processing_dtype == "complex64" assert result.maximum_window_samples <= 48 def test_windowed_scanner_safety_limits_fail_closed( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: path = tmp_path / "capture.cs8" path.write_bytes(bytes(2 * 80)) monkeypatch.setattr(lab046c, "maximum_phy_waveform_samples", lambda: 16) monkeypatch.setattr(lab046c, "REFINEMENT_MARGIN_SAMPLES", 0) monkeypatch.setattr(lab046c.lab044.lab043, "DETECTION_DECIMATION", 8) monkeypatch.setattr( lab046c.lab046b, "_detect_iq_preamble_candidates", lambda samples, threshold: ( tuple(lab046c.lab046b.IqPreambleCandidate(start, 0.9, 20.0, 0.0) for start in (8, 16, 24)), 0, ), ) monkeypatch.setattr( lab046c.lab046b, "_decode_iq_candidate", lambda samples, candidate, number: lab046c.lab046b.CandidateDecode( candidate.coarse_start_sample, candidate.coarse_start_sample + 1, False, False, lab046c.lab045.CRC_NOT_REACHED, False, "BAD", None, ), ) result = lab046c.scan_cs8_capture(path, chunk_samples=48, max_search_iterations=1) assert result.search_iterations == 1 assert result.termination_reason == "MAX_SEARCH_ITERATIONS_REACHED" class FakeRunner: def __init__(self) -> None: self.commands: list[list[str]] = [] def __call__(self, command: list[str]): self.commands.append(command) return subprocess.CompletedProcess(command, 0, "", "") def test_preflight_only_never_registers_tx(monkeypatch: pytest.MonkeyPatch) -> None: runner = FakeRunner() monkeypatch.setattr(lab046c.lab045, "_verify_device_inventory", lambda runner: {"passed": True}) def preflight(runner, guard): guard.allow() return {"passed": True, "master_reg29": 0x26, "slave_reg29": 0x26} monkeypatch.setattr(lab046c.lab044.lab043, "common_clock_preflight", preflight) monkeypatch.setattr(lab046c.lab044.lab043, "_disable_clock_outputs", lambda runner: []) result = lab046c.run_common_clock_preflight_only(runner, process_checker=lambda: []) assert result["rf_tx_count"] == 0 assert not any("-t" in command for command in runner.commands) @pytest.mark.parametrize( ("clock_errors", "processes", "message"), [ (["CLKOUT OFF failed"], [], "CLKOUT cleanup"), ([], ["pid 123"], "hackrf_transfer"), ], ) def test_preflight_cleanup_failure_is_fail_closed( monkeypatch: pytest.MonkeyPatch, clock_errors: list[str], processes: list[str], message: str, ) -> None: monkeypatch.setattr(lab046c.lab045, "_verify_device_inventory", lambda runner: {"passed": True}) def preflight(runner, guard): guard.allow() return {"passed": True} calls = 0 def process_checker(): nonlocal calls calls += 1 return [] if calls == 1 else processes monkeypatch.setattr(lab046c.lab044.lab043, "common_clock_preflight", preflight) monkeypatch.setattr( lab046c.lab044.lab043, "_disable_clock_outputs", lambda runner: clock_errors, ) with pytest.raises(RuntimeError, match=message): lab046c.run_common_clock_preflight_only(FakeRunner(), process_checker=process_checker) def test_prepared_metadata_cannot_self_declare_short_stream( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: import hashlib output = tmp_path / "forged" output.mkdir() object_path = output / "tx_object.bin" stream_path = output / "tx_stream.cs8" object_path.write_bytes(bytes(lab046c.OBJECT_SIZE_BYTES)) stream_path.write_bytes(b"\x00\x00") metadata = { "stage": "LAB046C_PREPARATION_ONLY", "rf_tx_count": 0, "object": { "path": str(object_path), "sha256": hashlib.sha256(object_path.read_bytes()).hexdigest(), "object_id": lab046c.OBJECT_ID, }, "fragmentation": { "fragment_data_bytes": lab046c.lab046.FRAGMENT_DATA_SIZE, "fragment_count": 1, "last_fragment_data_bytes": 1, }, "tx_stream": { "path": str(stream_path), "sha256": hashlib.sha256(stream_path.read_bytes()).hexdigest(), "frame_count": 1, "samples_per_frame": 1, "sample_count": 1, "file_bytes": 2, "duration_seconds": 1 / lab046c.SAMPLE_RATE_HZ, "inter_frame_samples_added": 0, }, "hardware": lab046c.hardware_parameters(), "receiver_oracles": {"tx_start_offset": False}, } (output / "tx_metadata.json").write_text(json.dumps(metadata), encoding="utf-8") monkeypatch.setattr(lab046c, "_sha256_file", lambda path: hashlib.sha256(path.read_bytes()).hexdigest()) with pytest.raises(RuntimeError, match="accounting"): lab046c._load_and_validate_prepared(output) def test_prepared_metadata_requires_exact_oracle_declaration() -> None: exact = {key: False for key in lab046c.RECEIVER_ORACLE_KEYS} assert lab046c._receiver_oracle_declaration_is_safe(exact) is True assert lab046c._receiver_oracle_declaration_is_safe( {"tx_start_offset": False} ) is False with_enabled_oracle = exact | {"tx_start_offset": True} assert lab046c._receiver_oracle_declaration_is_safe(with_enabled_oracle) is False def test_hardware_entry_point_requires_two_explicit_keys() -> None: project_root = Path(__file__).resolve().parents[1] script = project_root / "experiments" / "lab046c_hackrf_bpsk_object.py" result = subprocess.run( [sys.executable, str(script), "--hardware"], cwd=project_root, capture_output=True, text=True, timeout=60, ) assert result.returncode == 2 assert "RF TX = 0" in result.stdout def test_hardware_sequence_has_rx_before_one_tx_and_cleanup_in_finally() -> None: source = inspect.getsource(lab046c.run_hardware_experiment) assert source.index("process_starter(rx_command") < source.index("guard.register_transmit()") assert source.count("guard.register_transmit()") == 1 assert "finally:" in source assert "_disable_clock_outputs" in source assert '"-d", MASTER_SERIAL' in source assert '"-d", SLAVE_SERIAL' in source def test_direct_invocation_is_software_only() -> None: project_root = Path(__file__).resolve().parents[1] result = subprocess.run( [sys.executable, str(project_root / "experiments" / "lab046c_hackrf_bpsk_object.py")], cwd=project_root, capture_output=True, text=True, timeout=60, ) assert result.returncode == 0, result.stderr assert "LAB046C SOFTWARE PREFLIGHT = PASS" in result.stdout assert "RF TX = 0" in result.stdout