"""Воспроизводимый замер доступного программного QPSK-приёмника без SDR.""" from __future__ import annotations import argparse import cProfile import hashlib import json import math import os from pathlib import Path import pstats import sys import time import tracemalloc import numpy as np from protocol import punctured_fec from protocol.video_stream_transport import FrameType, VideoFragment, decode_fragment, encode_fragment from protocol.video003_modem import ( PHY_HEADER_SYMBOLS, PREAMBLE_SYMBOLS, build_frame, receive_frame, ) from tools.video003_design import ROOT RESULT_ROOT = ROOT / "data/processed/video004" SIGNAL_PATH = RESULT_ROOT / "canonical_symbols.npy" MANIFEST_PATH = RESULT_ROOT / "canonical_manifest.json" SEED = 20260916 PACKETS = 32 RATE = "2/3" SYMBOL_RATE = 500_000 TRANSIENT_SYMBOLS = 42 def _sha(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def create_fixture(packet_count: int = PACKETS, signal_path: Path = SIGNAL_PATH, manifest_path: Path = MANIFEST_PATH) -> dict: """Создать вход; эталонные пакеты сохраняются только для сверки после приёма.""" random_generator = np.random.default_rng(SEED) frames = [] if not 1 <= packet_count <= 256: raise ValueError("число пакетов вне диапазона") for index in range(packet_count): data = random_generator.integers(0, 256, 995, dtype=np.uint8).tobytes() fragment = VideoFragment(0x56303033, index, 0, 1, FrameType.KEYFRAME if index % 10 == 0 else FrameType.DELTA_FRAME, 0, data) frames.append(build_frame(encode_fragment(fragment, 1012), "QPSK", RATE, sequence_number=index)) symbols = np.concatenate([part for frame in frames for part in (frame.symbols, np.zeros(TRANSIENT_SYMBOLS, dtype=np.complex128))]) body_symbols = math.ceil(punctured_fec.coded_bit_count(1024 * 8, RATE) / 2) n0 = body_symbols / (1024 * 8) / 10 ** (9 / 10) sigma = math.sqrt(n0 / 2) phase = random_generator.uniform(-math.pi, math.pi) + math.pi / 2 indexes = np.arange(len(symbols), dtype=np.float64) received = symbols * np.exp(1j * (phase + 2 * math.pi * 73 * indexes / SYMBOL_RATE)) received += sigma * (random_generator.standard_normal(len(symbols)) + 1j * random_generator.standard_normal(len(symbols))) leading = sigma * (random_generator.standard_normal(11) + 1j * random_generator.standard_normal(11)) received = np.concatenate((leading, received)) RESULT_ROOT.mkdir(parents=True, exist_ok=True) np.save(signal_path, received, allow_pickle=False) manifest = { "seed": SEED, "packet_count": packet_count, "modulation": "QPSK", "symbol_rate": SYMBOL_RATE, "fec_rate": RATE, "ebn0_db": 9, "carrier_offset_hz": 73, "transient_symbols_per_packet": TRANSIENT_SYMBOLS, "leading_noise_symbols": 11, "signal_duration_s": len(symbols) / SYMBOL_RATE, "received_symbols": len(received), "dtype": str(received.dtype), "signal_sha256": _sha(received.tobytes()), "expected_packet_sha256": [_sha(frame.packet) for frame in frames], "rf_tx": 0, } manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") return manifest def load_fixture(signal_path: Path = SIGNAL_PATH, manifest_path: Path = MANIFEST_PATH) -> tuple[np.ndarray, dict]: received = np.load(signal_path, allow_pickle=False) manifest = json.loads(manifest_path.read_text(encoding="utf-8")) if (str(received.dtype) != manifest["dtype"] or len(received) != manifest["received_symbols"] or _sha(received.tobytes()) != manifest["signal_sha256"]): raise ValueError("контрольный сигнал изменился") return received, manifest def decode_stream(received: np.ndarray, backend: str, packet_count: int = PACKETS) -> list[dict]: """В приёмник не передаются границы, длины или эталонные пакеты.""" if backend == "legacy": fec_decoder = None elif backend == "numba": from protocol.video004_viterbi import decode_soft as fec_decoder else: raise ValueError("неизвестный декодер") cursor = 0 outcomes = [] for _ in range(packet_count): kwargs = {} if fec_decoder is None else {"fec_decoder": fec_decoder} item = receive_frame(received[cursor:], "QPSK", RATE, SYMBOL_RATE, **kwargs) if not item.crc_pass or item.packet is None or item.payload is None or item.start_symbol is None: outcomes.append({"crc_pass": False, "start_symbol": item.start_symbol}) break fragment = decode_fragment(item.payload, 1012) hard_bits = (item.coded_soft >= 0).astype(np.uint8) outcomes.append({ "crc_pass": True, "packet_sha256": _sha(item.packet), "decoded_bits_sha256": _sha(np.unpackbits(np.frombuffer(item.packet, dtype=np.uint8)).tobytes()), "qpsk_decisions_sha256": _sha(np.packbits(hard_bits).tobytes()), "video_frame_id": fragment.frame_id, "video_data_sha256": _sha(fragment.data), "start_symbol": item.start_symbol, }) body_count = math.ceil(punctured_fec.coded_bit_count(len(item.packet) * 8, RATE) / 2) cursor += item.start_symbol + PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS + body_count + TRANSIENT_SYMBOLS return outcomes def run(phase: str, repetitions: int = 3) -> dict: if phase not in ("before", "after") or repetitions < 1: raise ValueError("неверный этап или число повторов") if phase == "before" and not SIGNAL_PATH.exists(): create_fixture() received, manifest = load_fixture() backend = "legacy" if phase == "before" else "numba" started = time.perf_counter() first_outcomes = decode_stream(received, backend) first_time = time.perf_counter() - started times = [] for _ in range(repetitions): started = time.perf_counter() outcomes = decode_stream(received, backend) times.append(time.perf_counter() - started) if outcomes != first_outcomes: raise AssertionError("повтор приёма изменил строгие выходы") if len(outcomes) != PACKETS or not all(x["crc_pass"] for x in outcomes): raise AssertionError("контрольный поток не принят полностью") if [x["packet_sha256"] for x in outcomes] != manifest["expected_packet_sha256"]: raise AssertionError("CRC-пакеты отличаются от эталона") profiler = cProfile.Profile() profiler.enable() profiled_outcomes = decode_stream(received, backend) profiler.disable() if profiled_outcomes != outcomes: raise AssertionError("профилировщик изменил строгие выходы") stats = pstats.Stats(profiler).strip_dirs().sort_stats("cumtime") listing = [{"file": key[0], "line": key[1], "function": key[2], "primitive_calls": value[0], "calls": value[1], "self_s": value[2], "cumulative_s": value[3]} for key, value in stats.stats.items()] listing.sort(key=lambda item: item["cumulative_s"], reverse=True) tracemalloc.start() decode_stream(received, backend) _, python_peak = tracemalloc.get_traced_memory() tracemalloc.stop() ordered = sorted(times) median = ordered[len(ordered) // 2] return { "phase": phase, "backend": backend, "signal_sha256": manifest["signal_sha256"], "signal_duration_s": manifest["signal_duration_s"], "jit_first_run_s" if phase == "after" else "first_run_s": first_time, "warm_run_times_s": times, "warm_median_s": median, "available_path_realtime_factor": median / manifest["signal_duration_s"], "profile_top": listing[:50], "profile_total_s": stats.total_tt, "tracemalloc_python_peak_bytes": python_peak, "tracemalloc_limit": "does not include all native NumPy/Numba allocations", "outcomes": outcomes, "timing_recovery": "NOT_IMPLEMENTED_IN_THIS_BENCHMARK", "carrier_tracking_from_raw_iq": "NOT_IMPLEMENTED_IN_THIS_BENCHMARK", "rf_tx": 0, } def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--phase", choices=("before", "after"), required=True) parser.add_argument("--repetitions", type=int, default=3) parser.add_argument("--numba-packages", type=Path) args = parser.parse_args() if args.numba_packages is not None: if not args.numba_packages.is_dir(): parser.error("каталог Numba отсутствует") sys.path.insert(0, str(args.numba_packages.resolve())) os.environ["NUMBA_CACHE_DIR"] = str(RESULT_ROOT / "numba_cache") (RESULT_ROOT / "numba_cache").mkdir(parents=True, exist_ok=True) result = run(args.phase, args.repetitions) output = RESULT_ROOT / f"benchmark_{args.phase}.json" output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") print(f"{args.phase}: first={result.get('jit_first_run_s', result.get('first_run_s')):.3f}s " f"warm={result['warm_median_s']:.3f}s factor={result['available_path_realtime_factor']:.3f}") if __name__ == "__main__": main()