Lab056: compare 20 and 500 ksymbol/s in controlled RF session
This commit is contained in:
263
experiments/lab056_capture_runner.py
Normal file
263
experiments/lab056_capture_runner.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""Lab056: конечный RX-first runner; этот модуль не имеет аппаратного CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
|
||||
from experiments import lab054_hardware_control as previous
|
||||
from experiments import lab056_abab_energy as waveform
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperatingPoint:
|
||||
"""Частота пользователя и описание неподвижного эфирного тракта."""
|
||||
|
||||
frequency_hz: int | None = None
|
||||
physical_path: str = "MASTER TX -> TX antenna -> free space -> RX antenna -> SLAVE RX"
|
||||
path_confirmed: bool = False
|
||||
tx_antenna: str = "UNKNOWN"
|
||||
rx_antenna: str = "UNKNOWN"
|
||||
distance_m: float | None = None
|
||||
orientation: str = "UNKNOWN"
|
||||
visibility: str = "UNKNOWN"
|
||||
|
||||
def validate(self) -> None:
|
||||
# Допустимую лабораторную частоту определяет пользователь, не этот модуль.
|
||||
if type(self.frequency_hz) is not int or self.frequency_hz <= 0:
|
||||
raise PermissionError("Нужно конкретное целое RF_FREQ в Гц от пользователя")
|
||||
|
||||
|
||||
def command(point: OperatingPoint, path: Path | str, samples: int, *, transmit: bool = False) -> list[str]:
|
||||
point.validate()
|
||||
if not isinstance(samples, int) or samples <= 0:
|
||||
raise ValueError("Нужен положительный конечный счётчик отсчётов")
|
||||
clock = previous.clock
|
||||
result = [str(clock.HACKRF_TOOLS_DIR / "hackrf_transfer.exe"), "-d",
|
||||
clock.MASTER_SERIAL if transmit else clock.SLAVE_SERIAL, "-t" if transmit else "-r",
|
||||
str(path), "-f", str(point.frequency_hz), "-s", str(waveform.SAMPLE_RATE), "-n", str(samples)]
|
||||
return result + (["-x", "10"] if transmit else ["-l", "24", "-g", "24"]) + ["-a", "0", "-p", "0", "-B"]
|
||||
|
||||
|
||||
def common_clock(point: OperatingPoint, runner, guard, *, sleep_function=time.sleep) -> dict:
|
||||
"""Прежняя логика регистров/CLKIN; короткий RX использует новую согласованную RF."""
|
||||
point.validate()
|
||||
def short_receive(serial, command_runner):
|
||||
parameters = command(point, "NUL", previous.clock.SHORT_RX_SAMPLE_COUNT)
|
||||
parameters[parameters.index("-d") + 1] = serial
|
||||
result = command_runner(parameters)
|
||||
if result.returncode:
|
||||
raise RuntimeError("Короткий RX-start не выполнен")
|
||||
preflight = previous.bind(previous.clock.common_clock_preflight, _short_rx=short_receive,
|
||||
_disable_clock_outputs=previous.disable_clock_outputs)
|
||||
return preflight(runner, guard, sleep_function=sleep_function)
|
||||
|
||||
|
||||
def validated_metadata(directory: Path) -> dict:
|
||||
metadata = waveform.energy.read_json(directory / "tx_metadata.json")
|
||||
path = directory / "tx_abab.cs8"
|
||||
if (metadata["stage"] != "PREPARATION_ONLY" or metadata["rf_tx"] != 0 or
|
||||
metadata["sample_rate_hz"] != waveform.SAMPLE_RATE or metadata["carrier_hz"] != waveform.CARRIER or
|
||||
metadata["tx_samples"] * 2 != path.stat().st_size or
|
||||
waveform.energy.sha256(path) != metadata["tx_sha256"] or
|
||||
metadata["rx_samples"] != waveform.RX_SAMPLES or
|
||||
[(s["label"], s["symbol_rate"]) for s in metadata["segments"]] !=
|
||||
[("A1", 20000), ("B1", 500000), ("A2", 20000), ("B2", 500000)] or
|
||||
abs(metadata["whole_power_difference_db"]) > .01):
|
||||
raise ValueError("Подготовленный TX/параметры изменились")
|
||||
return metadata
|
||||
|
||||
|
||||
def clean_counter_log(log: str, expected: str) -> bool:
|
||||
"""Все упоминания потерь должны быть разобраны и иметь явный нулевой счётчик."""
|
||||
pattern = re.compile(r"(?<![\w.+-])(\d+)\s+(?:(?:rx|tx)\s+)?(overruns?|underruns?)\b", re.I)
|
||||
matches = list(pattern.finditer(log))
|
||||
if not matches or any(int(match[1]) != 0 for match in matches):
|
||||
return False
|
||||
if not any(match[2].lower().rstrip("s") == expected for match in matches):
|
||||
return False
|
||||
return re.search(r"overrun|underrun", pattern.sub("", log), re.I) is None
|
||||
|
||||
|
||||
def require_clock_pass(result: dict) -> None:
|
||||
if (not result.get("passed") or result.get("master_reg29") != 0x26 or
|
||||
result.get("slave_reg29") != 0x26 or result.get("slave_clkin_status") != "DETECTED"):
|
||||
raise RuntimeError("Common clock FAIL")
|
||||
|
||||
|
||||
def receive_level(path: Path, *, tail_samples: int | None = None) -> dict:
|
||||
"""Уровень CS8 без изменения gain; rail-коды являются признаком клиппинга."""
|
||||
source = waveform.energy.Cs8File(path)
|
||||
if source.size < 65536:
|
||||
raise RuntimeError("Недостаточно сохранённых RX-отсчётов для проверки уровня")
|
||||
start = max(0, source.size - tail_samples) if tail_samples else 0
|
||||
moments = waveform.energy.Moments()
|
||||
for begin in range(start, source.size, 262144):
|
||||
moments.add(source.read(begin, min(262144, source.size - begin)))
|
||||
result = moments.result(20000)
|
||||
result["saturation_detected"] = result["clipped_samples_fraction"] > 0
|
||||
result["analog_compression"] = "UNKNOWN_WITHOUT_CALIBRATION"
|
||||
return result
|
||||
|
||||
|
||||
def preflight_only(directory: Path, point: OperatingPoint, *, runner=None,
|
||||
process_checker=None, clock_checker=None, sleep_function=time.sleep) -> dict:
|
||||
"""Только RX: опора и 0,1 с фона; сохранить результат и STOP до разрешения TX."""
|
||||
point.validate()
|
||||
metadata = validated_metadata(directory)
|
||||
waveform.save_new(directory / "preflight_attempt.json", {"point": asdict(point), "rf_tx_count": 0})
|
||||
actual_runner = runner or previous.clock._default_command_runner
|
||||
process_checker = process_checker or previous.check_processes
|
||||
clock_checker = clock_checker or common_clock
|
||||
def receive_only_runner(parameters):
|
||||
if "-t" in parameters:
|
||||
raise PermissionError("TX запрещён внутри preflight_only")
|
||||
return actual_runner(parameters)
|
||||
record = {"passed": False, "rf_tx_count": 0, "point": asdict(point),
|
||||
"tx_sha256": metadata["tx_sha256"], "stop_before_tx": True}
|
||||
clock_started = False
|
||||
try:
|
||||
before = process_checker()
|
||||
record["process_before"] = before.to_dict()
|
||||
previous.require_clear(before)
|
||||
record["inventory"] = previous.phy.lab045._verify_device_inventory(receive_only_runner)
|
||||
clock_started = True
|
||||
record["clock"] = clock_checker(point, receive_only_runner, previous.clock.TransmitGuard(),
|
||||
sleep_function=sleep_function)
|
||||
require_clock_pass(record["clock"])
|
||||
path = directory / "preflight_rx.cs8"
|
||||
if path.exists():
|
||||
raise FileExistsError("RX preflight уже существует")
|
||||
captured = receive_only_runner(command(point, path, 800000))
|
||||
record["rx_process"] = {"returncode": captured.returncode,
|
||||
"stdout": captured.stdout, "stderr": captured.stderr}
|
||||
if captured.returncode or not clean_counter_log(captured.stdout + captured.stderr, "overrun"):
|
||||
raise RuntimeError("RX preflight: ошибка процесса или счётчиков")
|
||||
if path.stat().st_size != 1600000:
|
||||
raise RuntimeError("Неверный размер RX preflight")
|
||||
record["rx_level"] = receive_level(path)
|
||||
record["rx_sha256"] = waveform.energy.sha256(path)
|
||||
if record["rx_level"]["saturation_detected"]:
|
||||
raise RuntimeError("RX saturation до TX; STOP без изменения gains")
|
||||
record["passed"] = True
|
||||
except Exception as error:
|
||||
record["error"] = str(error)
|
||||
raise
|
||||
finally:
|
||||
errors = previous.disable_clock_outputs(receive_only_runner) if clock_started else []
|
||||
try:
|
||||
after = process_checker()
|
||||
record["process_after"] = after.to_dict()
|
||||
previous.require_clear(after)
|
||||
except Exception as error:
|
||||
errors.append(str(error))
|
||||
record["cleanup_errors"] = errors
|
||||
record["passed"] = record["passed"] and not errors
|
||||
waveform.save_new(directory / "preflight_result.json", record)
|
||||
if not record["passed"]:
|
||||
raise RuntimeError("Preflight cleanup FAIL")
|
||||
return record
|
||||
|
||||
|
||||
def require_preflight(directory: Path, point: OperatingPoint, metadata: dict) -> None:
|
||||
record = waveform.energy.read_json(directory / "preflight_result.json")
|
||||
if (record.get("passed") is not True or record.get("rf_tx_count") != 0 or
|
||||
record.get("point") != asdict(point) or record.get("tx_sha256") != metadata["tx_sha256"]):
|
||||
raise PermissionError("Нужен успешный RX-only preflight именно этой конфигурации")
|
||||
require_clock_pass(record.get("clock", {}))
|
||||
if record.get("rx_level", {}).get("saturation_detected") is not False:
|
||||
raise PermissionError("Нет подтверждённой проверки уровня RX до TX")
|
||||
|
||||
|
||||
def capture_once(directory: Path, point: OperatingPoint, *, authorization: bool = False,
|
||||
runner=None, process_starter=None, process_checker=None,
|
||||
clock_checker=None, sleep_function=time.sleep) -> dict:
|
||||
"""Один RX и один TX; ошибка расходует попытку, автоматического повтора нет."""
|
||||
if authorization is not True:
|
||||
raise PermissionError("Нужно отдельное разрешение на конкретный RF-сеанс Lab056")
|
||||
point.validate()
|
||||
metadata = validated_metadata(directory)
|
||||
require_preflight(directory, point, metadata)
|
||||
receive_path = directory / "rx_stream.cs8"
|
||||
if receive_path.exists() or (directory / "capture_result.json").exists():
|
||||
raise FileExistsError("Аппаратные результаты уже существуют")
|
||||
runner = runner or previous.clock._default_command_runner
|
||||
process_starter = process_starter or previous.clock._default_process_starter
|
||||
process_checker = process_checker or previous.check_processes
|
||||
clock_checker = clock_checker or common_clock
|
||||
waveform.save_new(directory / "hardware_attempt.json", {"authorized_once": True,
|
||||
"point": asdict(point), "tx_sha256": metadata["tx_sha256"], "automatic_retry": False})
|
||||
result = {"passed": False, "rf_tx_count": 0, "point": asdict(point)}
|
||||
guard = previous.clock.TransmitGuard()
|
||||
process, clock_started = None, False
|
||||
try:
|
||||
before = process_checker()
|
||||
result["process_before"] = before.to_dict()
|
||||
previous.require_clear(before)
|
||||
result["inventory"] = previous.phy.lab045._verify_device_inventory(runner)
|
||||
clock_started = True
|
||||
result["clock"] = clock_checker(point, runner, guard, sleep_function=sleep_function)
|
||||
require_clock_pass(result["clock"])
|
||||
process = process_starter(command(point, receive_path, waveform.RX_SAMPLES),
|
||||
directory / "rx_stdout.txt", directory / "rx_stderr.txt")
|
||||
sleep_function(waveform.RX_LEAD_SECONDS)
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError("RX завершился до TX")
|
||||
result["rx_level_before_tx"] = receive_level(receive_path, tail_samples=65536)
|
||||
if result["rx_level_before_tx"]["saturation_detected"]:
|
||||
raise RuntimeError("RX saturation до TX; STOP без изменения gains")
|
||||
guard.register_transmit()
|
||||
result["rf_tx_count"] = guard.transmit_calls
|
||||
transmitted = runner(command(point, directory / "tx_abab.cs8", metadata["tx_samples"], transmit=True))
|
||||
waveform.save_new(directory / "tx_process_result.json", {"returncode": transmitted.returncode,
|
||||
"stdout": transmitted.stdout, "stderr": transmitted.stderr})
|
||||
if transmitted.returncode or process.wait(13):
|
||||
raise RuntimeError("Единственный TX/RX завершился ошибкой")
|
||||
process.close_logs()
|
||||
if receive_path.stat().st_size != waveform.RX_SAMPLES * 2:
|
||||
raise RuntimeError("Неверный размер RX")
|
||||
rx_log = "".join((directory / f"rx_{kind}.txt").read_text(encoding="utf-8") for kind in ("stdout", "stderr"))
|
||||
for role, log, counter in (("RX", rx_log, "overrun"),
|
||||
("TX", transmitted.stdout + transmitted.stderr, "underrun")):
|
||||
if not clean_counter_log(log, counter):
|
||||
raise RuntimeError(f"{role}: нет явного нулевого счётчика потерь")
|
||||
result.update(rx_sha256=waveform.energy.sha256(receive_path), rx_level=receive_level(receive_path))
|
||||
if result["rx_level"]["saturation_detected"]:
|
||||
raise RuntimeError("RX saturation в сохранённом сеансе; TX уже был, повтор запрещён")
|
||||
result["passed"] = True
|
||||
except Exception as error:
|
||||
result["error"] = str(error)
|
||||
raise
|
||||
finally:
|
||||
errors = []
|
||||
if process is not None:
|
||||
try:
|
||||
process.terminate()
|
||||
except Exception as error:
|
||||
errors.append(str(error))
|
||||
if clock_started:
|
||||
errors.extend(previous.disable_clock_outputs(runner))
|
||||
try:
|
||||
after = process_checker()
|
||||
result["process_after"] = after.to_dict()
|
||||
previous.require_clear(after)
|
||||
except Exception as error:
|
||||
errors.append(str(error))
|
||||
result["cleanup_errors"] = errors
|
||||
result["passed"] = result["passed"] and not errors
|
||||
waveform.save_new(directory / "capture_result.json", result)
|
||||
if not result["passed"]:
|
||||
raise RuntimeError("Cleanup FAIL; повтор запрещён")
|
||||
return result
|
||||
|
||||
|
||||
def run_functional_tests() -> None:
|
||||
try:
|
||||
capture_once(Path("UNUSED"), OperatingPoint())
|
||||
except PermissionError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("Разрешение проверяется раньше любых действий")
|
||||
Reference in New Issue
Block a user