"""Lab056: цифровые условия и отказы runner без оборудования.""" from __future__ import annotations import json from dataclasses import asdict from pathlib import Path import subprocess import numpy as np import pytest from experiments import lab056_abab_energy as lab from experiments import lab056_capture_runner as capture from tools.hackrf_process_check import ProcessCheck, ProcessState def point(): # Только синтетическая конфигурация теста; это не разрешение на эфир. return capture.OperatingPoint(915000000, physical_path="SIMULATED", path_confirmed=True) def test_no_authorization_has_no_side_effects(tmp_path): with pytest.raises(PermissionError): capture.capture_once(tmp_path, point(), runner=lambda _: pytest.fail("hardware")) assert list(tmp_path.iterdir()) == [] @pytest.mark.parametrize("configuration", [capture.OperatingPoint(), capture.OperatingPoint(0), capture.OperatingPoint(True), capture.OperatingPoint(915e6)]) def test_missing_or_invalid_frequency_precedes_io(tmp_path, configuration): with pytest.raises(PermissionError): capture.capture_once(tmp_path, configuration, authorization=True) with pytest.raises(PermissionError): capture.preflight_only(tmp_path, configuration) assert list(tmp_path.iterdir()) == [] def test_fixed_gain_and_finite_nonrepeating_commands(): rx = capture.command(point(), "rx.cs8", 24000000) tx = capture.command(point(), "tx.cs8", 1000000, transmit=True) assert rx[rx.index("-l") + 1] == rx[rx.index("-g") + 1] == "24" assert tx[tx.index("-x") + 1] == "10" for command in (rx, tx): assert "-R" not in command assert command[command.index("-a") + 1] == command[command.index("-p") + 1] == "0" assert command[command.index("-f") + 1] == "915000000" assert command[command.index("-s") + 1] == "8000000" @pytest.mark.parametrize("failure", [None, "master", "slave", "clkin", "receive"]) def test_common_clock_logic_uses_agreed_frequency(failure): commands = [] def runner(command): commands.append(command) output, code = "", 0 if "--si5351c" in command: serial = command[command.index("-d") + 1] bad = ((failure == "master" and serial == capture.previous.clock.MASTER_SERIAL) or (failure == "slave" and serial == capture.previous.clock.SLAVE_SERIAL)) output = "0x0e" if bad else "0x26" elif "-i" in command: output = "UNKNOWN" if failure == "clkin" else "CLKIN status: clock signal detected" elif "-r" in command: code = int(failure == "receive") assert command[command.index("-f") + 1] == "915000000" return subprocess.CompletedProcess(command, code, output, "") guard = capture.previous.clock.TransmitGuard() if failure: with pytest.raises(RuntimeError): capture.common_clock(point(), runner, guard, sleep_function=lambda _: None) assert not guard.tx_allowed else: assert capture.common_clock(point(), runner, guard, sleep_function=lambda _: None)["passed"] assert not any("-t" in command for command in commands) @pytest.mark.parametrize("failure", [None, "busy", "clock", "early_rx", "tx", "counters", "cleanup", "rx_role_loss", "tx_role_loss", "clipping_before", "clipping_during"]) def test_one_shot_rx_first_and_failures(tmp_path, monkeypatch, failure): events = [] monkeypatch.setattr(capture, "validated_metadata", lambda _: {"tx_sha256": "TEST", "tx_samples": 200}) monkeypatch.setattr(capture.previous.phy.lab045, "_verify_device_inventory", lambda _: {"passed": True}) monkeypatch.setattr(lab, "RX_SAMPLES", 65536) approved = {"passed": True, "rf_tx_count": 0, "point": asdict(point()), "tx_sha256": "TEST", "clock": {"passed": True, "master_reg29": 38, "slave_reg29": 38, "slave_clkin_status": "DETECTED"}, "rx_level": {"saturation_detected": False}} lab.save_new(tmp_path / "preflight_result.json", approved) def clock_checker(point, runner, guard, **kwargs): events.append("clock") if failure == "clock": raise RuntimeError("clock") guard.allow() return {"passed": True, "master_reg29": 38, "slave_reg29": 38, "slave_clkin_status": "DETECTED"} class Process: def poll(self): return 1 if failure == "early_rx" else None def wait(self, timeout): (tmp_path / "rx_stream.cs8").write_bytes(bytes([127 if failure == "clipping_during" else 1]) * 131072) return 0 def close_logs(self): pass def terminate(self): events.append("terminate") if failure == "cleanup": raise RuntimeError("cleanup") def starter(command, stdout, stderr): events.append("rx") (tmp_path / "rx_stream.cs8").write_bytes(bytes([127 if failure == "clipping_before" else 1]) * 131072) stdout.write_text("") stderr.write_text("1 RX overruns\n0 overruns" if failure == "rx_role_loss" else "0 overruns") return Process() def runner(command): if "-t" in command: events.append("tx") log = "" if failure == "counters" else ("1 TX underruns\n0 underruns" if failure == "tx_role_loss" else "0 underruns") return subprocess.CompletedProcess(command, int(failure == "tx"), "", log) events.append("cleanup") return subprocess.CompletedProcess(command, 0, "", "") kwargs = dict(authorization=True, runner=runner, process_starter=starter, process_checker=lambda: ProcessCheck(ProcessState.BUSY if failure == "busy" else ProcessState.CLEAR, ()), clock_checker=clock_checker, sleep_function=lambda _: events.append("lead")) if failure: with pytest.raises(RuntimeError): capture.capture_once(tmp_path, point(), **kwargs) else: assert capture.capture_once(tmp_path, point(), **kwargs)["passed"] assert events.count("tx") == (0 if failure in ("busy", "clock", "early_rx", "clipping_before") else 1) if "tx" in events: assert events.index("rx") < events.index("lead") < events.index("tx") before = events.copy() with pytest.raises(FileExistsError): capture.capture_once(tmp_path, point(), **kwargs) assert events == before result = json.loads((tmp_path / "capture_result.json").read_text()) assert result["passed"] == (failure is None) assert result["rf_tx_count"] == events.count("tx") @pytest.mark.parametrize("failure", [None, "clock", "clipping", "tx_attempt", "cleanup"]) def test_rx_only_preflight_stops_before_tx(tmp_path, monkeypatch, failure): calls = [] monkeypatch.setattr(capture, "validated_metadata", lambda _: {"tx_sha256": "TEST"}) monkeypatch.setattr(capture.previous.phy.lab045, "_verify_device_inventory", lambda _: {"passed": True}) def runner(command): calls.append(command) assert "-t" not in command if "-r" in command: Path(command[command.index("-r") + 1]).write_bytes(bytes([127 if failure == "clipping" else 1]) * 1600000) if failure == "cleanup": raise RuntimeError("cleanup") return subprocess.CompletedProcess(command, 0, "0 RX overruns", "") def clock_checker(point, runner, guard, **kwargs): if failure == "tx_attempt": runner(["hackrf_transfer", "-t", "forbidden"]) return {"passed": True, "master_reg29": 14 if failure == "clock" else 38, "slave_reg29": 38, "slave_clkin_status": "DETECTED"} kwargs = dict(runner=runner, clock_checker=clock_checker, process_checker=lambda: ProcessCheck(ProcessState.CLEAR, ())) if failure: with pytest.raises((RuntimeError, PermissionError)): capture.preflight_only(tmp_path, point(), **kwargs) else: assert capture.preflight_only(tmp_path, point(), **kwargs)["passed"] result = json.loads((tmp_path / "preflight_result.json").read_text()) assert result["rf_tx_count"] == 0 assert result["passed"] == (failure is None) assert not (tmp_path / "hardware_attempt.json").exists() with pytest.raises(FileExistsError): capture.preflight_only(tmp_path, point(), **kwargs) def test_preflight_cannot_authorize_another_frequency(tmp_path): approved = {"passed": True, "rf_tx_count": 0, "point": asdict(point()), "tx_sha256": "TEST", "clock": {"passed": True, "master_reg29": 38, "slave_reg29": 38, "slave_clkin_status": "DETECTED"}, "rx_level": {"saturation_detected": False}} lab.save_new(tmp_path / "preflight_result.json", approved) capture.require_preflight(tmp_path, point(), {"tx_sha256": "TEST"}) with pytest.raises(PermissionError): capture.require_preflight(tmp_path, capture.OperatingPoint(916000000), {"tx_sha256": "TEST"}) def test_external_frequency_needs_no_country_or_band_fields(): configuration = capture.OperatingPoint(916000000) command = capture.command(configuration, "NUL", 65536) assert command[command.index("-f") + 1] == "916000000" def test_complex_gain_and_repeatability_are_not_rx_normalization(): random_generator = np.random.default_rng(56) reference = random_generator.normal(size=8000).astype(complex) expected = .3 + .4j received = 1 + 2j + expected * reference gain = lab.complex_gain(reference, received, 0) assert complex(gain["real"], gain["imag"]) == pytest.approx(expected) rows = [] for label, power in (("A1", 4), ("B1", 1), ("A2", 8), ("B2", 2)): rows.append({"label": label, "background_power": 1, "raw_rx": {"power_code2": power + 1}, "background_subtracted_signal_power": power, "channel_gain_power": power, "measurement": {"heldout_signal_power": power}}) result = lab.comparison_summary(rows) assert result["received_power_difference_db"] == pytest.approx(-6.020599913) assert result["energy_per_symbol_difference_db"] == pytest.approx(-20) assert [r["channel_gain_difference_db"] for r in result["repeatability"]] == pytest.approx([3.010299957, 3.010299957]) rows[-1]["background_subtracted_signal_power"] = None assert lab.comparison_summary(rows)["received_power_difference_db"] is None def test_nonpositive_excess_is_not_a_power_ratio(): assert lab.difference_db(-1, 1) is None assert lab.difference_db(None, 1) is None assert lab.difference_db(.25, 1) == pytest.approx(-6.020599913) def test_invalid_result_does_not_leave_partial_json(tmp_path): path = tmp_path / "result.json" with pytest.raises(ValueError): lab.save_new(path, {"invalid": float("nan")}) assert not path.exists() lab.save_new(path, {"passed": True}) with pytest.raises(FileExistsError): lab.save_new(path, {"passed": False}) assert json.loads(path.read_text())["passed"] is True @pytest.mark.parametrize("log,expected,passed", [ ("0 overruns", "overrun", True), ("0 RX overrun", "overrun", True), ("0 TX underruns", "underrun", True), ("1 RX overruns\n0 overruns", "overrun", False), ("1 TX underrun\n0 underruns", "underrun", False), ("unknown overruns\n0 overruns", "overrun", False), ("0 overruns\nunderrun detected", "overrun", False), ("0.0 overruns", "overrun", False), ("-1 overruns", "overrun", False), ("0 underruns", "overrun", False), ("", "overrun", False)]) def test_all_loss_mentions_must_be_explicit_zero(log, expected, passed): assert capture.clean_counter_log(log, expected) is passed def test_deterministic_ab_rms_and_no_clipping(): powers = [] for rate in (20000, 500000): raw = lab.build_burst(rate) values = raw[::2].astype(float) + 1j * raw[1::2].astype(float) assert not np.any((raw == -128) | (raw == 127)) powers.append(float(np.mean(abs(values)**2))) np.testing.assert_array_equal(raw, lab.build_burst(rate)) assert abs(lab.difference_db(powers[1], powers[0])) < .01