Files
SDR-Rover/experiments/lab056_capture_runner.py

264 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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("Разрешение проверяется раньше любых действий")