180 lines
7.8 KiB
Python
180 lines
7.8 KiB
Python
"""Детерминированный контроль 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()
|