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,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("Разрешение проверяется раньше любых действий")