Complete VIDEO-001 through VIDEO-004 software video studies
This commit is contained in:
200
tools/video004_benchmark.py
Normal file
200
tools/video004_benchmark.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""Воспроизводимый замер доступного программного 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()
|
||||
Reference in New Issue
Block a user