"""Проверки fail-closed разбора CLKIN для Lab043.""" from __future__ import annotations import hashlib import subprocess import sys from pathlib import Path import numpy as np import pytest from experiments import lab043_hackrf_bpsk as bpsk class FakeCommandRunner: """Предсказуемый HackRF host без обращения к оборудованию.""" def __init__( self, clkin_output: str | list[str] = "CLKIN status: clock signal detected\n", tx_returncode: int = 0, clkin_exception_at: int | None = None, ) -> None: self.clkin_outputs = [clkin_output] if isinstance(clkin_output, str) else list(clkin_output) self.tx_returncode = tx_returncode self.clkin_exception_at = clkin_exception_at self.clkin_queries = 0 self.commands: list[list[str]] = [] def __call__(self, command: list[str]) -> subprocess.CompletedProcess: self.commands.append(command) executable = Path(command[0]).name if executable == "hackrf_debug.exe": return subprocess.CompletedProcess(command, 0, "[ 29] -> 0x26\n", "") if executable == "hackrf_clock.exe" and "-i" in command: self.clkin_queries += 1 if self.clkin_exception_at == self.clkin_queries: raise subprocess.SubprocessError("mock clock query failure") index = min(self.clkin_queries - 1, len(self.clkin_outputs) - 1) return subprocess.CompletedProcess(command, 0, self.clkin_outputs[index], "") if executable == "hackrf_transfer.exe" and "-t" in command: return subprocess.CompletedProcess(command, self.tx_returncode, "", "") return subprocess.CompletedProcess(command, 0, "", "") class FakeClock: """Монотонные часы, которые продвигаются только через sleep.""" def __init__(self) -> None: self.seconds = 0.0 self.sleeps: list[float] = [] def monotonic(self) -> float: return self.seconds def sleep(self, seconds: float) -> None: self.sleeps.append(seconds) self.seconds += seconds class FakeRunningCommand: def __init__(self, startup_failed: bool = False, returncode: int = 0) -> None: self.startup_failed = startup_failed self.returncode = returncode self.terminated = False self.logs_closed = False def poll(self) -> int | None: if self.startup_failed: return self.returncode or 1 return self.returncode if self.terminated else None def wait(self, timeout: float) -> int: del timeout self.logs_closed = True return self.returncode def terminate(self) -> None: self.terminated = True self.logs_closed = True def close_logs(self) -> None: self.logs_closed = True class FakeProcessStarter: def __init__(self, startup_failed: bool = False, returncode: int = 0) -> None: self.startup_failed = startup_failed self.returncode = returncode self.processes: list[FakeRunningCommand] = [] def __call__(self, command: list[str], stdout_path: Path, stderr_path: Path) -> FakeRunningCommand: 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 * bpsk.CAPTURE_SAMPLE_COUNT)) process = FakeRunningCommand(self.startup_failed, self.returncode) self.processes.append(process) return process def fake_analysis() -> dict: return { "timing": {"refined_start_sample": 2}, "detection": {"preamble_detected": True}, "official_ber": 0.0, "symbol_snr_db": 10.0, "separation": 3.0, "channel": {"abs_h": 0.01}, "raw": { "clipping_count": 0, "near_int8_count": 0, "adc_headroom_code": 100.0, }, } def test_parse_clkin_status_detected() -> None: assert ( bpsk.parse_clkin_status("CLKIN status: clock signal detected\n") == bpsk.CLKIN_STATUS_DETECTED ) def test_parse_clkin_status_not_detected() -> None: assert ( bpsk.parse_clkin_status("CLKIN status: no clock signal detected\n") == bpsk.CLKIN_STATUS_NOT_DETECTED ) def test_parse_clkin_status_empty_output_is_unknown() -> None: assert bpsk.parse_clkin_status("") == bpsk.CLKIN_STATUS_UNKNOWN def test_parse_clkin_status_unexpected_output_is_unknown() -> None: assert bpsk.parse_clkin_status("CLKIN status unavailable") == bpsk.CLKIN_STATUS_UNKNOWN def test_wait_for_slave_clock_detected_on_first_query() -> None: runner = FakeCommandRunner() clock = FakeClock() result = bpsk.wait_for_slave_clock( runner, sleep_function=clock.sleep, monotonic_function=clock.monotonic ) assert result["state"] == bpsk.CLKIN_STATUS_DETECTED assert result["queries"] == 1 assert result["time_to_detect_ms"] == pytest.approx(50.0) def test_wait_for_slave_clock_delayed_detected() -> None: runner = FakeCommandRunner([ "CLKIN status: no clock signal detected\n", "CLKIN status: no clock signal detected\n", "CLKIN status: clock signal detected\n", ]) clock = FakeClock() result = bpsk.wait_for_slave_clock( runner, sleep_function=clock.sleep, monotonic_function=clock.monotonic ) assert result["state"] == bpsk.CLKIN_STATUS_DETECTED assert [poll["state"] for poll in result["polls"]] == [ bpsk.CLKIN_STATUS_NOT_DETECTED, bpsk.CLKIN_STATUS_NOT_DETECTED, bpsk.CLKIN_STATUS_DETECTED, ] def test_wait_for_slave_clock_timeout_is_bounded_and_fail_closed() -> None: runner = FakeCommandRunner("CLKIN status: no clock signal detected\n") clock = FakeClock() guard = bpsk.TransmitGuard() with pytest.raises(RuntimeError, match="TIMEOUT"): bpsk.common_clock_preflight( runner, guard, sleep_function=clock.sleep, monotonic_function=clock.monotonic, ) assert runner.clkin_queries == 10 assert clock.seconds == pytest.approx(bpsk.CLOCK_TIMEOUT_SECONDS) assert guard.transmit_calls == 0 assert guard.tx_allowed is False def test_wait_for_slave_clock_unknown_is_immediate_fail_closed() -> None: runner = FakeCommandRunner("CLKIN status unavailable\n") clock = FakeClock() guard = bpsk.TransmitGuard() with pytest.raises(RuntimeError, match="UNKNOWN"): bpsk.common_clock_preflight( runner, guard, sleep_function=clock.sleep, monotonic_function=clock.monotonic, ) assert runner.clkin_queries == 1 assert guard.transmit_calls == 0 def test_wait_for_slave_clock_subprocess_exception_is_fail_closed() -> None: runner = FakeCommandRunner(clkin_exception_at=1) clock = FakeClock() guard = bpsk.TransmitGuard() with pytest.raises(RuntimeError, match="ERROR"): bpsk.common_clock_preflight( runner, guard, sleep_function=clock.sleep, monotonic_function=clock.monotonic, ) assert guard.transmit_calls == 0 assert guard.tx_allowed is False def test_wait_for_slave_clock_detected_at_timeout_boundary() -> None: runner = FakeCommandRunner([ "CLKIN status: no clock signal detected\n", "CLKIN status: no clock signal detected\n", "CLKIN status: no clock signal detected\n", "CLKIN status: clock signal detected\n", ]) clock = FakeClock() result = bpsk.wait_for_slave_clock( runner, initial_settle_s=0.05, poll_interval_s=0.05, timeout_s=0.20, sleep_function=clock.sleep, monotonic_function=clock.monotonic, ) assert result["state"] == bpsk.CLKIN_STATUS_DETECTED assert result["queries"] == 4 assert result["time_to_detect_ms"] == pytest.approx(200.0) def test_clock_failure_cleanup_disables_both_outputs() -> None: runner = FakeCommandRunner("unexpected") clock = FakeClock() guard = bpsk.TransmitGuard() with pytest.raises(RuntimeError): bpsk.common_clock_preflight( runner, guard, sleep_function=clock.sleep, monotonic_function=clock.monotonic, ) clock_commands = [ command for command in runner.commands if Path(command[0]).name == "hackrf_clock.exe" ] assert [command[command.index("-d") + 1] for command in clock_commands[-2:]] == [ bpsk.MASTER_SERIAL, bpsk.SLAVE_SERIAL, ] assert all(command[-2:] == ["-o", "0"] for command in clock_commands[-2:]) def test_common_clock_preflight_is_fail_closed_and_skips_slave_rx() -> None: runner = FakeCommandRunner("CLKIN status: no clock signal detected\n") guard = bpsk.TransmitGuard() with pytest.raises(RuntimeError, match="TIMEOUT"): bpsk.common_clock_preflight(runner, guard) transfer_commands = [ command for command in runner.commands if Path(command[0]).name == "hackrf_transfer.exe" ] assert len(transfer_commands) == 1 assert bpsk.MASTER_SERIAL in transfer_commands[0] assert guard.tx_allowed is False assert guard.transmit_calls == 0 def test_common_clock_preflight_runs_both_short_rx_before_allowing_tx() -> None: runner = FakeCommandRunner() guard = bpsk.TransmitGuard() result = bpsk.common_clock_preflight(runner, guard) transfer_commands = [ command for command in runner.commands if Path(command[0]).name == "hackrf_transfer.exe" ] assert [command[command.index("-d") + 1] for command in transfer_commands] == [ bpsk.MASTER_SERIAL, bpsk.SLAVE_SERIAL, ] assert result["master_reg29"] == 0x26 assert result["slave_reg29"] == 0x26 assert result["slave_clkin_status"] == bpsk.CLKIN_STATUS_DETECTED assert guard.tx_allowed is True def test_failure_before_tx_produces_zero_tx_calls(tmp_path: Path) -> None: runner = FakeCommandRunner("CLKIN status: no clock signal detected\n") with pytest.raises(RuntimeError, match="TIMEOUT"): bpsk.run_one_gain_point(24, 16, tmp_path, runner) assert not any("-t" in command for command in runner.commands) def prepare_mock_point(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(bpsk, "CAPTURE_SAMPLE_COUNT", 16) monkeypatch.setattr(bpsk, "analyze_capture", lambda samples: fake_analysis()) def test_waveform_sha_failure_produces_zero_tx_calls( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: runner = FakeCommandRunner() monkeypatch.setattr(bpsk, "build_tx_cs8", lambda: (_ for _ in ()).throw(ValueError("SHA"))) with pytest.raises(ValueError, match="SHA"): bpsk.run_one_gain_point(24, 24, tmp_path, runner) assert runner.commands == [] def test_rx_startup_failure_produces_zero_tx_calls( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: prepare_mock_point(monkeypatch) runner = FakeCommandRunner() starter = FakeProcessStarter(startup_failed=True, returncode=1) with pytest.raises(RuntimeError, match="RX завершился до разрешения TX"): bpsk.run_one_gain_point(24, 24, tmp_path, runner, starter, lambda _: None) assert not any("-t" in command for command in runner.commands) assert starter.processes[0].terminated is True def test_successful_experiment_has_one_tx_and_expected_order( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: prepare_mock_point(monkeypatch) events: list[str] = [] runner = FakeCommandRunner() original_runner = runner def recording_runner(command: list[str]) -> subprocess.CompletedProcess: if "-t" in command: events.append("tx_start_finish") return original_runner(command) def preflight(command_runner: bpsk.CommandRunner, guard: bpsk.TransmitGuard) -> dict: del command_runner events.append("preflight") guard.allow() return {"passed": True} class RecordingProcess(FakeRunningCommand): def wait(self, timeout: float) -> int: events.append("rx_finish") return super().wait(timeout) def starter(command: list[str], stdout_path: Path, stderr_path: Path) -> RecordingProcess: events.append("rx_start") stdout_path.write_text("0 overruns\n", encoding="utf-8") stderr_path.write_text("", encoding="utf-8") Path(command[command.index("-r") + 1]).write_bytes(bytes(2 * bpsk.CAPTURE_SAMPLE_COUNT)) return RecordingProcess() monkeypatch.setattr(bpsk, "common_clock_preflight", preflight) monkeypatch.setattr( bpsk, "analyze_capture", lambda samples: events.append("analysis") or fake_analysis(), ) result = bpsk.run_one_gain_point( 24, 24, tmp_path, recording_runner, starter, lambda _: None ) assert events == ["preflight", "rx_start", "tx_start_finish", "rx_finish", "analysis"] assert result["tx_calls"] == 1 assert result["passed"] is True assert (tmp_path / "metadata.json").is_file() assert (tmp_path / "analysis.json").is_file() def test_tx_failure_has_no_retry_and_cleans_process_and_clocks( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: prepare_mock_point(monkeypatch) runner = FakeCommandRunner(tx_returncode=1) starter = FakeProcessStarter() with pytest.raises(RuntimeError, match="Единственный TX"): bpsk.run_one_gain_point(24, 24, tmp_path, runner, starter, lambda _: None) assert sum("-t" in command for command in runner.commands) == 1 assert starter.processes[0].terminated is True clock_commands = [ command for command in runner.commands if Path(command[0]).name == "hackrf_clock.exe" ] assert [command[command.index("-d") + 1] for command in clock_commands[-2:]] == [ bpsk.MASTER_SERIAL, bpsk.SLAVE_SERIAL, ] assert all(command[-2:] == ["-o", "0"] for command in clock_commands[-2:]) def test_analysis_failure_has_no_retry_and_cleans_process( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(bpsk, "CAPTURE_SAMPLE_COUNT", 16) monkeypatch.setattr( bpsk, "analyze_capture", lambda samples: (_ for _ in ()).throw(RuntimeError("analysis")) ) runner = FakeCommandRunner() starter = FakeProcessStarter() with pytest.raises(RuntimeError, match="analysis"): bpsk.run_one_gain_point(24, 24, tmp_path, runner, starter, lambda _: None) assert sum("-t" in command for command in runner.commands) == 1 assert starter.processes[0].terminated is True def test_transmit_guard_allows_exactly_one_tx() -> None: guard = bpsk.TransmitGuard() with pytest.raises(RuntimeError, match="общий clock-gate"): guard.register_transmit() guard.allow() guard.register_transmit() with pytest.raises(RuntimeError, match="Повторная передача"): guard.register_transmit() assert guard.transmit_calls == 1 def test_gain_ladder_first_delta_uses_scientific_24_16_baseline( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: previous_values: list[float | None] = [] def fake_point( lna_gain_db: int, vga_gain_db: int, output_directory: Path, runner: bpsk.CommandRunner | None = None, process_starter: bpsk.ProcessStarter | None = None, sleep_function: bpsk.SleepFunction = bpsk.time.sleep, previous_abs_h: float | None = None, ) -> dict: del output_directory, runner, process_starter, sleep_function previous_values.append(previous_abs_h) return { "lna_db": lna_gain_db, "vga_db": vga_gain_db, "tx_calls": 1, "passed": True, "stop_reason": "BPSK_PASS", "analysis": {"channel": {"abs_h": 0.004}}, } monkeypatch.setattr(bpsk, "run_one_gain_point", fake_point) report = bpsk.run_gain_ladder(tmp_path) assert previous_values == [bpsk.BASELINE_24_16_ABS_H] assert report["classification"] == "A" assert report["baseline"]["abs_h"] == bpsk.BASELINE_24_16_ABS_H def test_deterministic_waveform_sha256() -> None: assert hashlib.sha256(bpsk.build_tx_cs8()).hexdigest() == bpsk.EXPECTED_TX_SHA256 def test_direct_invocation_smoke() -> None: project_root = Path(__file__).resolve().parents[1] result = subprocess.run( [sys.executable, str(project_root / "experiments" / "lab043_hackrf_bpsk.py"), "--help"], cwd=project_root, capture_output=True, text=True, timeout=30, ) assert result.returncode == 0, result.stderr assert "--analyze-existing" in result.stdout def test_offline_synthetic_bpsk() -> None: tx = bpsk.signed_iq_bytes_to_complex64(bpsk.build_tx_cs8()) capture = np.concatenate(( np.zeros(80_000, dtype=np.complex64), tx, np.zeros(80_000, dtype=np.complex64), )) analysis = bpsk.analyze_capture(capture, include_oracle=False) assert analysis["detection"]["preamble_detected"] is True assert analysis["detection"]["payload_used_for_start"] is False assert analysis["official_ber"] == 0.0 assert analysis["synchronization"]["preamble_errors"] == 0