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,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