Lab056: compare 20 and 500 ksymbol/s in controlled RF session

This commit is contained in:
LittleSam129
2026-09-10 13:58:11 +03:00
parent 5add95ecb8
commit 92477a4632
27 changed files with 6518 additions and 0 deletions

View File

@@ -0,0 +1,159 @@
"""Достоверность перечисления процессов и запрет аппаратных действий при FAIL."""
from __future__ import annotations
import json
import subprocess
from types import SimpleNamespace
import pytest
from experiments import lab054_hardware_control as lab
from tools import hackrf_process_check as check
def snapshot(names=("python.exe",), errors=()):
return check.classify_snapshot("fixture", [check.ProcessIdentity(index + 1, name)
for index, name in enumerate(names)], errors)
def completed(stdout="", stderr="", code=0):
return lambda command: subprocess.CompletedProcess(command, code, stdout, stderr)
def test_clear_full_snapshot():
result = check.check_processes([lambda: snapshot()])
assert result.state is check.ProcessState.CLEAR
check.require_clear(result)
@pytest.mark.parametrize("name", ["hackrf_transfer.exe", "hackrf_clock.exe", "HACKRF_DEBUG.EXE",
"hackrf_info.exe", "sdrangel.exe", "SDRSharp", "gqrx"])
def test_busy_known_sdr_clients(name):
result = check.check_processes([lambda: snapshot((name,), errors=("partial snapshot",)),
lambda: pytest.fail("BUSY запрещает fallback")])
assert result.state is check.ProcessState.BUSY
with pytest.raises(RuntimeError):
check.require_clear(result)
@pytest.mark.parametrize("names,errors", [((), ()), (("python",), ("Access denied",)), (("",), ())])
def test_unknown_never_becomes_clear(names, errors):
result = check.check_processes([lambda: snapshot(names, errors)])
assert result.state is check.ProcessState.UNKNOWN
with pytest.raises(RuntimeError):
check.require_clear(result)
def test_tasklist_access_denied_is_unknown():
result = check.scan_tasklist(completed(stderr="ERROR: Access denied", code=1))
assert result.state is check.ProcessState.UNKNOWN
assert "Access denied" in str(result.errors)
def test_fallback_to_powershell_after_psutil_unavailable(monkeypatch):
powershell = check.scan_powershell
monkeypatch.setattr(check, "scan_psutil", lambda: snapshot((), ("ImportError: psutil",)))
monkeypatch.setattr(check, "scan_powershell", lambda: powershell(
completed('[{"Id":12,"ProcessName":"python"}]')))
monkeypatch.setattr(check, "scan_tasklist", lambda: pytest.fail("Полный CLEAR уже получен"))
result = check.check_processes()
assert result.state is check.ProcessState.CLEAR
assert [item.state for item in result.attempts] == [check.ProcessState.UNKNOWN, check.ProcessState.CLEAR]
def test_tasklist_is_last_fallback(monkeypatch):
tasklist = check.scan_tasklist
monkeypatch.setattr(check, "scan_psutil", lambda: snapshot((), ("unavailable",)))
monkeypatch.setattr(check, "scan_powershell", lambda: snapshot((), ("denied",)))
monkeypatch.setattr(check, "scan_tasklist", lambda: tasklist(
completed('"python.exe","123","Console","1","123 K"\n')))
result = check.check_processes()
assert result.state is check.ProcessState.CLEAR
assert len(result.attempts) == 3
@pytest.mark.parametrize("output", ["", "[]", "null", "not json", '[{"Id":4,"ProcessName":null}]'])
def test_powershell_malformed_not_clear(output):
assert check.scan_powershell(completed(output)).state is check.ProcessState.UNKNOWN
@pytest.mark.parametrize("output", ["", "INFO: No tasks are running", '"python.exe","bad pid","a","b","c"'])
def test_tasklist_malformed_not_clear(output):
assert check.scan_tasklist(completed(output)).state is check.ProcessState.UNKNOWN
def test_provider_exception_and_empty_provider_list_unknown():
def failed():
raise PermissionError("denied")
assert check.check_processes([failed]).state is check.ProcessState.UNKNOWN
assert check.check_processes([]).state is check.ProcessState.UNKNOWN
@pytest.mark.parametrize("mechanism", ["powershell", "tasklist"])
@pytest.mark.parametrize("malformed_first", [False, True])
def test_busy_survives_malformed_neighbor(mechanism, malformed_first):
if mechanism == "powershell":
rows = [{"Id":42,"ProcessName":"hackrf_transfer"}, {}]
if malformed_first:
rows.reverse()
scan = check.scan_powershell(completed(json.dumps(rows)))
else:
rows = ['"hackrf_transfer.exe","42","Console","1","100 K"', "BROKEN"]
if malformed_first:
rows.reverse()
scan = check.scan_tasklist(completed("\n".join(rows)))
assert scan.errors
result = check.check_processes([lambda: scan, lambda: pytest.fail("Найденный BUSY нельзя потерять")])
assert result.state is check.ProcessState.BUSY
@pytest.mark.parametrize("disappeared", [False, True])
def test_psutil_access_denied_or_exited_process(monkeypatch, disappeared):
class NoSuchProcess(Exception):
pass
def unreadable():
raise NoSuchProcess() if disappeared else PermissionError("Access denied")
module = SimpleNamespace(NoSuchProcess=NoSuchProcess, process_iter=lambda: iter([
SimpleNamespace(pid=1, name=lambda: "python.exe"),
SimpleNamespace(pid=2, name=unreadable)]))
monkeypatch.setattr(check.importlib, "import_module", lambda name: module)
result = check.scan_psutil()
assert result.state is (check.ProcessState.CLEAR if disappeared else check.ProcessState.UNKNOWN)
@pytest.mark.parametrize("state", list(check.ProcessState))
def test_preflight_only_clear_reaches_hardware(tmp_path, monkeypatch, state):
actions = []
scan = snapshot(("hackrf_transfer.exe",)) if state is check.ProcessState.BUSY else snapshot()
if state is check.ProcessState.UNKNOWN:
scan = snapshot((), ("tasklist Access denied",))
result = check.ProcessCheck(state, (scan,))
monkeypatch.setattr(lab.phy.lab045, "_verify_device_inventory", lambda runner: actions.append("inventory") or {})
monkeypatch.setattr(lab, "clock_preflight", lambda runner, guard: actions.append("clock") or {"passed": True})
monkeypatch.setattr(lab, "disable_clock_outputs", lambda runner: actions.append("cleanup") or [])
if state is check.ProcessState.CLEAR:
assert lab.preflight_only(tmp_path, runner=lambda _: None, process_checker=lambda: result)["passed"]
assert actions == ["inventory", "clock", "cleanup"]
else:
with pytest.raises(RuntimeError):
lab.preflight_only(tmp_path, runner=lambda _: None, process_checker=lambda: result)
assert actions == []
report = json.loads((tmp_path / "common_clock_preflight.json").read_text(encoding="utf-8"))
assert report["process_check"]["state"] == state.value
assert report["rf_tx_count"] == 0
def test_unknown_after_cleanup_rejects_preflight(tmp_path, monkeypatch):
states = iter([check.ProcessCheck(check.ProcessState.CLEAR, (snapshot(),)),
check.ProcessCheck(check.ProcessState.UNKNOWN, (snapshot((), ("denied",)),))])
monkeypatch.setattr(lab.phy.lab045, "_verify_device_inventory", lambda runner: {})
monkeypatch.setattr(lab, "clock_preflight", lambda runner, guard: {"passed": True})
monkeypatch.setattr(lab, "disable_clock_outputs", lambda runner: [])
with pytest.raises(RuntimeError):
lab.preflight_only(tmp_path, runner=lambda _: None, process_checker=lambda: next(states))
report = json.loads((tmp_path / "common_clock_preflight.json").read_text(encoding="utf-8"))
assert not report["passed"]
assert report["process_check_after_cleanup"]["state"] == "UNKNOWN"

View File

@@ -0,0 +1,140 @@
"""Lab054: фиксированные RF-команды и отказы runner без оборудования."""
from __future__ import annotations
import json
from pathlib import Path
import subprocess
import pytest
from experiments import lab054_hardware_control as lab
from tools.hackrf_process_check import ProcessCheck, ProcessState
@pytest.mark.parametrize("failure", [None, "master", "slave", "clkin", "receive"])
def test_clock_gates_never_transmit(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 == lab.clock.MASTER_SERIAL)
or (failure == "slave" and serial == lab.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")
return subprocess.CompletedProcess(command, code, output, "")
guard = lab.clock.TransmitGuard()
if failure:
with pytest.raises(RuntimeError):
lab.clock_preflight(runner, guard, sleep_function=lambda _: None)
assert not guard.tx_allowed
else:
assert lab.clock_preflight(runner, guard, sleep_function=lambda _: None)["passed"]
assert guard.tx_allowed
assert not any("-t" in command for command in commands)
for command in commands:
if Path(command[0]).name == "hackrf_transfer.exe":
assert command[command.index("-f") + 1] == "1400000000"
assert command[command.index("-l") + 1] == command[command.index("-g") + 1] == "24"
def test_authorization_precedes_all_side_effects(tmp_path):
with pytest.raises(PermissionError):
lab.capture_once(tmp_path, runner=lambda _: pytest.fail("hardware"))
assert list(tmp_path.iterdir()) == []
@pytest.mark.parametrize("failure", [None, "clock", "early_rx", "tx", "cleanup"])
def test_one_shot_order_and_no_retry(tmp_path, monkeypatch, failure):
events = []
monkeypatch.setattr(lab, "validate_prepared", lambda directory: {"tx_samples": 123})
monkeypatch.setattr(lab.phy.lab045, "_verify_device_inventory", lambda runner: {})
def preflight(runner, guard):
if failure == "clock":
raise RuntimeError("clock")
guard.allow()
return {"passed": True}
monkeypatch.setattr(lab, "clock_preflight", preflight)
monkeypatch.setattr(lab, "disable_clock_outputs", lambda runner: events.append("clocks_off") or [])
class Process:
def poll(self):
return 1 if failure == "early_rx" else None
def wait(self, timeout):
with (tmp_path / "rx_stream.cs8").open("wb") as stream:
stream.truncate(lab.RX_SAMPLES * 2)
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")
stdout.write_text("")
stderr.write_text("")
return Process()
def runner(command):
assert "-t" in command and "-R" not in command
events.append("tx")
return subprocess.CompletedProcess(command, int(failure == "tx"), "", "")
kwargs = dict(authorization=True, runner=runner, process_starter=starter,
process_checker=lambda: ProcessCheck(ProcessState.CLEAR, ()),
sleep_function=lambda _: events.append("lead"))
if failure:
with pytest.raises(RuntimeError):
lab.capture_once(tmp_path, **kwargs)
else:
assert lab.capture_once(tmp_path, **kwargs)["passed"]
assert events.count("tx") == (0 if failure in ("clock", "early_rx") else 1)
if "tx" in events:
assert events.index("rx") < events.index("lead") < events.index("tx")
assert "clocks_off" in events
assert json.loads((tmp_path / "capture_result.json").read_text())["passed"] == (failure is None)
before = events.copy()
with pytest.raises(FileExistsError):
lab.capture_once(tmp_path, **kwargs)
assert events == before
def test_clock_cleanup_continues_after_timeout():
commands = []
def runner(command):
commands.append(command)
raise subprocess.TimeoutExpired(command, 1)
errors = lab.disable_clock_outputs(runner)
assert len(commands) == len(errors) == 2
assert [command[2] for command in commands] == [lab.clock.MASTER_SERIAL, lab.clock.SLAVE_SERIAL]
def test_payload_match_does_not_hide_changed_packet():
from types import SimpleNamespace
plan = lab.plans()[0].plan
packet = bytes([plan.packet[0] ^ 1]) + plan.packet[1:]
reception = SimpleNamespace(payload_bytes=plan.payload, packet_bytes=packet)
frame = SimpleNamespace(reception=reception,
post_preamble_soft=lab.phy.lab045.modulate_bits_to_ideal_soft(
__import__("numpy").concatenate((plan.phy_header_bits, plan.interleaved_bits))))
result = lab.compare_after_verdict([(None, SimpleNamespace(accepted=True, frame=frame))])
assert result["payload_matched_packets"] == 1
assert result["exact_packets"] == result["unique_exact_packets"] == 0
assert result["post_fec_errors"] == 1

View File

@@ -0,0 +1,248 @@
"""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