VIDEO-005: add full offline QPSK IQ video receiver

This commit is contained in:
LittleSam129
2026-09-17 13:34:58 +03:00
parent 95f664076a
commit 6df5f8aa39
18 changed files with 2651 additions and 13 deletions

179
tools/video005_benchmark.py Normal file
View 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()