"""Lab054: подготовка короткого BPSK-контроля; RF только отдельной командой.""" from __future__ import annotations import argparse import hashlib import json from pathlib import Path import subprocess import sys import time if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import numpy as np from experiments import lab047_jpeg_object_transport as hardware from experiments import lab049_bpsk_symbol_rate as phy from experiments.lab053_packet_processing import bind from experiments.lab053_streaming_receiver import StreamingReceiver from tools.hackrf_process_check import check_processes, require_clear clock = hardware.lab044.lab043 MODE = phy.PhyMode(500_000) RF_HZ = 1_400_000_000 PACKETS = 16 RX_SECONDS = 3 RX_LEAD_SECONDS = 0.5 RX_SAMPLES = RX_SECONDS * MODE.sample_rate_hz DIRECTORY = Path("data/raw/lab054") OUTPUT = Path("data/processed/lab054") def save(path: Path, value: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8") def plans(): """Обычный бинарный объект, 16 фрагментов прежнего формата; не видеопоток.""" random_generator = np.random.default_rng(54054) data = random_generator.integers(0, 256, PACKETS * phy.lab046.FRAGMENT_DATA_SIZE, dtype=np.uint8) return phy.lab046.build_object_frames(data.tobytes(), object_id=54054) def parameters() -> dict: return {"rf_hz": RF_HZ, "sample_rate_hz": MODE.sample_rate_hz, "symbol_rate": MODE.symbol_rate, "samples_per_symbol": MODE.samples_per_symbol, "carrier_hz": phy.lab045.CARRIER_OFFSET_HZ, "master_tx": clock.MASTER_SERIAL, "slave_rx": clock.SLAVE_SERIAL, "tx_vga_db": 10, "rx_lna_db": 24, "rx_vga_db": 24, "amplifier": False, "bias_tee": False, "threshold": hardware.FIXED_HARDWARE_CANDIDATE_THRESHOLD} def transfer_command(serial: str, path: Path | str, samples: int, *, transmit=False) -> list[str]: command = [str(clock.HACKRF_TOOLS_DIR / "hackrf_transfer.exe"), "-d", serial, "-t" if transmit else "-r", str(path), "-f", str(RF_HZ), "-s", str(MODE.sample_rate_hz), "-n", str(samples)] command += ["-x", "10"] if transmit else ["-l", "24", "-g", "24"] return command + ["-a", "0", "-p", "0", "-B"] def short_receive(serial, runner): """Штатный запуск приёмника для выбора опоры, без передающего режима.""" result = runner(transfer_command(serial, "NUL", clock.SHORT_RX_SAMPLE_COUNT)) if result.returncode: raise RuntimeError(f"Короткий RX не выполнен: {result.stderr}") def disable_clock_outputs(runner) -> list[str]: """Попытаться отключить обе платы даже при timeout первой команды.""" errors = [] for serial in (clock.MASTER_SERIAL, clock.SLAVE_SERIAL): try: clock._set_clkout(serial, False, runner) except Exception as error: errors.append(f"{serial}: {error}") return errors clock_preflight = bind(clock.common_clock_preflight, _short_rx=short_receive, _disable_clock_outputs=disable_clock_outputs) def preflight_only(directory: Path = OUTPUT, runner=None, process_checker=None) -> dict: """Реальный RX-only preflight; выходы тактирования выключаются в finally.""" runner = runner or clock._default_command_runner process_checker = process_checker or check_processes record = {"rf_tx_count": 0, "passed": False} guard = clock.TransmitGuard() clock_started = False try: process_state = process_checker() record["process_check"] = process_state.to_dict() require_clear(process_state) record["inventory"] = phy.lab045._verify_device_inventory(runner) clock_started = True record["clock"] = clock_preflight(runner, guard) record["passed"] = True except Exception as error: record["error"] = str(error) raise finally: record["cleanup_errors"] = disable_clock_outputs(runner) if clock_started else [] record["cleanup_performed"] = clock_started if clock_started: try: process_state = process_checker() record["process_check_after_cleanup"] = process_state.to_dict() require_clear(process_state) except Exception as error: record["cleanup_errors"].append(str(error)) record["passed"] = record["passed"] and not record["cleanup_errors"] save(directory / "common_clock_preflight.json", record) if not record["passed"]: raise RuntimeError("Не удалось выключить CLKOUT") return record def prepare(directory: Path = DIRECTORY) -> dict: directory.mkdir(parents=True, exist_ok=True) path = directory / "tx_stream.cs8" if path.exists(): raise FileExistsError("TX-файл уже существует; подготовка его не перезаписывает") with path.open("xb") as stream: for frame in plans(): stream.write(phy.build_parameterized_tx_cs8(frame.plan, MODE)) count = path.stat().st_size // 2 result = {"stage": "PREPARATION_ONLY", "rf_tx_count": 0, "head": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), "parameters": parameters(), "packets": PACKETS, "tx_samples": count, "tx_bytes": path.stat().st_size, "tx_seconds": count / MODE.sample_rate_hz, "tx_sha256": hardware._sha256_file(path), "rx_seconds": RX_SECONDS, "rx_samples": RX_SAMPLES, "rx_lead_seconds": RX_LEAD_SECONDS, "rx_command": transfer_command(clock.SLAVE_SERIAL, directory / "rx_stream.cs8", RX_SAMPLES), "tx_command": transfer_command(clock.MASTER_SERIAL, path, count, transmit=True)} save(directory / "tx_metadata.json", result) save(OUTPUT / "preparation.json", result) return result def validate_prepared(directory: Path) -> dict: metadata = json.loads((directory / "tx_metadata.json").read_text(encoding="utf-8")) path = directory / "tx_stream.cs8" expected = hashlib.sha256() count = 0 for frame in plans(): waveform = phy.build_parameterized_tx_cs8(frame.plan, MODE) expected.update(waveform) count += len(waveform) if (metadata["parameters"] != parameters() or metadata["stage"] != "PREPARATION_ONLY" or metadata["rf_tx_count"] != 0 or metadata["packets"] != PACKETS or metadata["tx_samples"] * 2 != count or path.stat().st_size != count or metadata["tx_sha256"] != expected.hexdigest() or hardware._sha256_file(path) != expected.hexdigest()): raise RuntimeError("TX-артефакты или параметры изменены") return metadata def decode_file(path: Path) -> tuple[dict, list]: """RX получает только CS8-файл; TX truth отсутствует в интерфейсе.""" observations = [] receiver = StreamingReceiver(packet_observer=lambda row, outcome: observations.append((row.copy(), outcome))) received = [] clipping = 0 try: with path.open("rb") as stream: while block := stream.read(2 * 65536): values = np.frombuffer(block, np.int8) if len(values) % 2: raise ValueError("Неполный IQ-отсчёт") clipping += int(np.count_nonzero((values == -128) | (values == 127))) samples = (values[::2].astype(np.float32) + 1j * values[1::2]).astype(np.complex64) received.extend(receiver.push_iq_block(samples)) received.extend(receiver.finish()) finally: if not receiver.closed: receiver.finish() rows = [] for row, outcome in observations: frame = outcome.frame rows.append({"accepted": outcome.accepted, "reason": outcome.reason, "sync_pass": outcome.refinement_pass and outcome.timing_pass and outcome.carrier_pass, "phy_header_pass": outcome.phy_header_pass, "fec_pass": outcome.fec_pass, "crc": outcome.crc, "start": row["absolute_refined"], "timing": frame.timing_offset_samples if frame else None, "cfo_hz": frame.estimated_cfo_hz if frame else None, "phase_rad": frame.estimated_phase_rad if frame else None, "snr_db": frame.snr_db if frame else None, "preamble_errors": frame.preamble_errors if frame else None}) return {"independent": True, "packets": len(received), "frames_detected": len({r["start"] for r in rows if r["sync_pass"]}), "frames_detected_definition": "Unique refined starts with refinement/timing/carrier PASS; may include false candidates", "strict_candidates": len(rows), "phy_header_pass": sum(r["phy_header_pass"] for r in rows), "fec_pass": sum(r["fec_pass"] for r in rows), "crc_pass": sum(r["crc"] == phy.lab045.CRC_PASS for r in rows), "crc_fail": sum(r["crc"] == phy.lab045.CRC_FAIL for r in rows), "clipping_components": clipping, "rows": rows}, observations def roundtrip(directory: Path = DIRECTORY) -> dict: validate_prepared(directory) result, observations = decode_file(directory / "tx_stream.cs8") result.update(compare_after_verdict(observations)) result["passed"] = (result["packets"] == result["exact_packets"] == result["unique_exact_packets"] == PACKETS and result["post_fec_errors"] == 0) save(OUTPUT / "software_roundtrip.json", result) if not result["passed"]: raise RuntimeError("Программный roundtrip FAIL") return result def compare_after_verdict(observations) -> dict: """Сверка разрешена только после завершения независимого приёма.""" by_payload = {frame.plan.payload: frame.plan for frame in plans()} comparisons = [] matched_payloads = set() exact_count = 0 for _, outcome in observations: if outcome.accepted and outcome.frame: frame = outcome.frame plan = by_payload.get(frame.reception.payload_bytes) if plan is not None: if frame.reception.packet_bytes == plan.packet: matched_payloads.add(frame.reception.payload_bytes) exact_count += 1 comparisons.append(phy.lab045.diagnostic_pre_and_post_fec( frame.reception, frame.post_preamble_soft, plan)) return {"exact_packets": exact_count, "unique_exact_packets": len(matched_payloads), "payload_matched_packets": len(comparisons), "pre_fec_errors": sum(item["pre_fec_coded"]["bit_errors"] for item in comparisons) if comparisons else None, "post_fec_errors": sum(item["post_fec_packet"]["bit_errors"] for item in comparisons) if comparisons else None, "error_scope": "CRC accepted packets matched by payload AFTER independent verdict; unmatched packets UNKNOWN"} def analyze_capture(directory: Path = DIRECTORY) -> dict: """Офлайн-анализ сохранённой записи, без повторной передачи.""" evidence = json.loads((directory / "capture_result.json").read_text(encoding="utf-8")) path = directory / "rx_stream.cs8" if evidence.get("rx_sha256") != hardware._sha256_file(path): raise RuntimeError("Нет подтверждённого SHA записи") result, observations = decode_file(path) save(OUTPUT / "independent_result.json", result) result.update(compare_after_verdict(observations)) result.update(frames_tx=PACKETS, rx_overruns="UNKNOWN_UNLESS_EXPLICIT_IN_LOG", tx_underruns="UNKNOWN_UNLESS_EXPLICIT_IN_LOG") result["result"] = "A" if (result["unique_exact_packets"] >= 15 and evidence["passed"] and result["post_fec_errors"] == 0 and result["clipping_components"] == 0) else "NOT_A" result["scope"] = "One short control only; no long-term stability or video RF claim" save(OUTPUT / "hardware_result.json", result) return result def capture_once(directory: Path = DIRECTORY, *, authorization=False, runner=None, process_starter=None, process_checker=None, sleep_function=time.sleep) -> dict: """Один конечный TX; эксклюзивный маркер запрещает повтор после любой ошибки.""" if authorization is not True: raise PermissionError("Нужно отдельное разрешение пользователя на Lab054 RF") metadata = validate_prepared(directory) runner = runner or clock._default_command_runner process_starter = process_starter or clock._default_process_starter process_checker = process_checker or check_processes with (directory / "hardware_attempt.json").open("x", encoding="utf-8") as stream: json.dump({"authorized_once": True, "automatic_retry": False}, stream) guard = clock.TransmitGuard() process = None clock_started = False result = {"passed": False, "rf_tx_count": 0} try: process_state = process_checker() result["process_check"] = process_state.to_dict() require_clear(process_state) result["inventory"] = phy.lab045._verify_device_inventory(runner) clock_started = True result["clock"] = clock_preflight(runner, guard) receive_path = directory / "rx_stream.cs8" process = process_starter(transfer_command(clock.SLAVE_SERIAL, receive_path, RX_SAMPLES), directory / "rx_stdout.txt", directory / "rx_stderr.txt") sleep_function(RX_LEAD_SECONDS) if process.poll() is not None: raise RuntimeError("RX завершился до TX") guard.register_transmit() result["rf_tx_count"] = guard.transmit_calls transmitted = runner(transfer_command(clock.MASTER_SERIAL, directory / "tx_stream.cs8", metadata["tx_samples"], transmit=True)) (directory / "tx_stdout.txt").write_text(transmitted.stdout or "", encoding="utf-8") (directory / "tx_stderr.txt").write_text(transmitted.stderr or "", encoding="utf-8") if transmitted.returncode or process.wait(RX_SECONDS + 10): raise RuntimeError("Ошибка единственного TX/RX; повтор запрещён") process.close_logs() if receive_path.stat().st_size != RX_SAMPLES * 2: raise RuntimeError("Неверный размер RX") for role in ("rx", "tx"): log = "".join((directory / f"{role}_{kind}.txt").read_text(encoding="utf-8") for kind in ("stdout", "stderr")) if not clock._transfer_log_is_clean(log): raise RuntimeError(f"{role}: overrun/underrun; STOP") result.update(passed=True, rx_sha256=hardware._sha256_file(receive_path)) except Exception as error: result["error"] = str(error) raise finally: result["cleanup_errors"] = [] if process is not None: try: process.terminate() except Exception as error: result["cleanup_errors"].append(str(error)) if clock_started: result["cleanup_errors"].extend(disable_clock_outputs(runner)) try: process_state = process_checker() result["process_check_after_cleanup"] = process_state.to_dict() require_clear(process_state) result["lingering_processes"] = [] except Exception as error: result["cleanup_errors"].append(str(error)) result["lingering_processes"] = None result["passed"] = result["passed"] and not result["cleanup_errors"] and not result["lingering_processes"] save(directory / "capture_result.json", result) if not result["passed"]: raise RuntimeError("Очистка аппаратного сеанса не прошла; STOP") return result def run_functional_tests() -> None: assert MODE.samples_per_symbol == 16 assert len(plans()) == PACKETS assert "-t" not in transfer_command(clock.MASTER_SERIAL, "NUL", 65536) try: capture_once(authorization=False) except PermissionError: pass else: raise AssertionError("RF без разрешения") def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--stage", choices=("prepare", "roundtrip", "preflight", "capture", "analyze"), required=True) parser.add_argument("--authorize-one-rf", action="store_true") args = parser.parse_args() if args.stage == "prepare": result = prepare() elif args.stage == "roundtrip": result = roundtrip() elif args.stage == "preflight": result = preflight_only() elif args.stage == "analyze": result = analyze_capture() else: result = capture_once(authorization=args.authorize_one_rf) print(json.dumps(result, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()