VIDEO-005: add full offline QPSK IQ video receiver
This commit is contained in:
179
tools/video005_benchmark.py
Normal file
179
tools/video005_benchmark.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Детерминированный контроль IQ-приёмника и полного программного видео."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import cProfile
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pstats
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from protocol.packet import parse_packet
|
||||
from protocol.video003_modem import build_frame
|
||||
from protocol.video005_iq_waveform import SAMPLE_RATE_HZ, shape_stream
|
||||
from protocol.video005_receiver import IqStreamResult, receive_iq_stream
|
||||
from tools.video005_channel import impair, lab066_like
|
||||
from tools.video005_video import build_fixture, run_case
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUTPUT = ROOT / "data/processed/video005/benchmark.json"
|
||||
SEED = 20260917
|
||||
|
||||
|
||||
def _sha(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _strict_outcomes(result: IqStreamResult, expected: list[bytes]) -> dict:
|
||||
observed = {parse_packet(item.packet).sequence_number: _sha(item.packet)
|
||||
for item in result.packets}
|
||||
reference = {index: _sha(packet) for index, packet in enumerate(expected)}
|
||||
return {
|
||||
"crc_valid_packets": len(result.packets),
|
||||
"exact_packets": sum(observed.get(index) == digest
|
||||
for index, digest in reference.items()),
|
||||
"unexpected_or_wrong_packets": sum(index not in reference or digest != reference[index]
|
||||
for index, digest in observed.items()),
|
||||
"packet_sha256_by_sequence": observed,
|
||||
}
|
||||
|
||||
|
||||
def _profile(receiver_iq) -> dict:
|
||||
profiler = cProfile.Profile()
|
||||
profiler.enable()
|
||||
receive_iq_stream(receiver_iq)
|
||||
profiler.disable()
|
||||
stats = pstats.Stats(profiler).strip_dirs().sort_stats("cumtime")
|
||||
raw_stats = vars(stats)["stats"] # Атрибут pstats создаётся динамически.
|
||||
listing = [{"function": key[2], "file": key[0], "calls": values[1],
|
||||
"self_s": values[2], "cumulative_s": values[3]}
|
||||
for key, values in raw_stats.items()]
|
||||
listing.sort(key=lambda item: item["cumulative_s"], reverse=True)
|
||||
selected = ("oaconvolve", "search_preamble_grid", "refine_timing",
|
||||
"estimate_from_preamble", "track_qpsk_body", "qpsk_soft",
|
||||
"depuncture_soft", "decode_soft", "_trellis", "parse_packet")
|
||||
stage_functions = [item for item in listing if item["function"] in selected]
|
||||
return {"profile_total_s": vars(stats)["total_tt"],
|
||||
"top_functions": listing[:40], "selected_stages": stage_functions}
|
||||
|
||||
|
||||
def build_radio_fixture() -> tuple[list[bytes], np.ndarray, np.ndarray]:
|
||||
"""Канонический IQ-вход для 32 полных пакетов; эталон остаётся снаружи RX."""
|
||||
frames = [build_frame(bytes(((bit + index) * 37) % 256 for bit in range(1012)),
|
||||
"QPSK", "2/3", sequence_number=index)
|
||||
for index in range(32)]
|
||||
clean_iq = shape_stream(frames)
|
||||
channel_iq = impair(clean_iq, cfo_hz=20, phase_degrees=15,
|
||||
timing_offset_symbols=0.1, ebn0_db=9, seed=SEED)
|
||||
return [frame.packet for frame in frames], clean_iq, channel_iq
|
||||
|
||||
|
||||
def run() -> dict:
|
||||
reference, clean_iq, channel_iq = build_radio_fixture()
|
||||
duration = len(channel_iq) / SAMPLE_RATE_HZ
|
||||
signal_sha256 = _sha(channel_iq.tobytes())
|
||||
|
||||
started = time.perf_counter()
|
||||
first = receive_iq_stream(channel_iq)
|
||||
first_time = time.perf_counter() - started
|
||||
first_outcome = _strict_outcomes(first, reference)
|
||||
warm_times = []
|
||||
warm_results = []
|
||||
for _ in range(3):
|
||||
started = time.perf_counter()
|
||||
received = receive_iq_stream(channel_iq)
|
||||
warm_times.append(time.perf_counter() - started)
|
||||
warm_results.append(received)
|
||||
if _strict_outcomes(received, reference) != first_outcome:
|
||||
raise AssertionError("вход IQ дал разные строгие пакеты на повторах")
|
||||
median_time = statistics.median(warm_times)
|
||||
profile = _profile(channel_iq)
|
||||
|
||||
awgn_frames = [build_frame(bytes(((bit + index) * 37) % 256 for bit in range(1012)),
|
||||
"QPSK", "2/3", sequence_number=index)
|
||||
for index in range(16)]
|
||||
awgn_iq = shape_stream(awgn_frames)
|
||||
awgn = []
|
||||
for ebn0_db in (0, 3, 6, 9):
|
||||
received = receive_iq_stream(impair(awgn_iq, ebn0_db=ebn0_db, seed=SEED))
|
||||
awgn.append({"ebn0_db": ebn0_db,
|
||||
**_strict_outcomes(received, [frame.packet for frame in awgn_frames])})
|
||||
|
||||
diagnostic_iq, diagnostic_parameters = lab066_like(clean_iq, seed=SEED)
|
||||
diagnostic = receive_iq_stream(diagnostic_iq)
|
||||
diagnostic_outcome = _strict_outcomes(diagnostic, reference)
|
||||
|
||||
fixture = build_fixture(20)
|
||||
video_cases = [run_case(fixture, scenario) for scenario in (
|
||||
"clean", "random_1", "random_5", "keyframe_double",
|
||||
"keyframe_triple", "corrupt_packet",
|
||||
)]
|
||||
if not all(case["exact_crc_packets"] for case in video_cases):
|
||||
raise AssertionError("после CRC обнаружен неправильный пакет")
|
||||
if not all(not case["video"]["wrong_encoded_frames"]
|
||||
and not case["video"]["wrong_decoded_frames"]
|
||||
and not case["video"]["old_frame_replay"] for case in video_cases):
|
||||
raise AssertionError("в видеотракте обнаружены испорченные или повторные кадры")
|
||||
clean_video = video_cases[0]
|
||||
return {
|
||||
"status": (
|
||||
"PARTIAL" if (clean_video["end_to_end_realtime_factor"] > 0.67
|
||||
or video_cases[2]["video"]["frame_loss_rate"] > 0)
|
||||
else "PASS"
|
||||
),
|
||||
"seed": SEED, "rf_tx": 0, "sdr_hardware": "NOT ACCESSED",
|
||||
"radio": {
|
||||
"packets": len(reference), "iq_samples": len(channel_iq),
|
||||
"iq_duration_s": duration, "signal_sha256": signal_sha256,
|
||||
"jit_first_run_s": first_time, "jit_first_outcome": first_outcome,
|
||||
"warm_times_s": warm_times, "warm_median_s": median_time,
|
||||
"realtime_factor": median_time / duration,
|
||||
"realtime_speed": duration / median_time,
|
||||
"strict_outcome": first_outcome,
|
||||
"stage_wall_s": warm_results[-1].stage_time_s,
|
||||
"cprofile": profile,
|
||||
},
|
||||
"awgn": awgn,
|
||||
"lab066_like": {"parameters": diagnostic_parameters,
|
||||
"outcome": diagnostic_outcome},
|
||||
"video": {
|
||||
"source_sha256": fixture.source_sha256,
|
||||
"original_frames": len(fixture.frames),
|
||||
"scheduled_phy_packets": len(fixture.transmissions),
|
||||
"iq_duration_s": len(fixture.iq) / SAMPLE_RATE_HZ,
|
||||
"preparation_time_s": fixture.preparation_time_s,
|
||||
"cases": video_cases,
|
||||
},
|
||||
"sample_clock_offset": "NOT MODELED IN VIDEO-005",
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
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()))
|
||||
result = run()
|
||||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUTPUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"RADIO IQ factor={result['radio']['realtime_factor']:.3f}; "
|
||||
f"exact={result['radio']['strict_outcome']['exact_packets']}/32")
|
||||
for case in result["video"]["cases"]:
|
||||
print(f"{case['scenario']}: CRC={case['crc_valid_radio_packets']}, "
|
||||
f"displayed={case['video']['unique_decoded_frames']}/20, "
|
||||
f"radio={case['radio_realtime_factor']:.3f}, "
|
||||
f"end-to-end={case['end_to_end_realtime_factor']:.3f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user