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