249 lines
12 KiB
Python
249 lines
12 KiB
Python
"""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
|