179 lines
8.4 KiB
Python
179 lines
8.4 KiB
Python
"""Замер от программных символов до видеокадра с внешним FEC, без SDR."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import cProfile
|
||
from dataclasses import replace
|
||
import hashlib
|
||
import json
|
||
import math
|
||
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 FrameReassembler, FrameType, VideoFragment
|
||
from protocol.video003_modem import PHY_HEADER_SYMBOLS, PREAMBLE_SYMBOLS, build_frame, receive_frame
|
||
from protocol.video004_erasure import MAX_VIDEO_BYTES, VideoErasureCollector, encode_block
|
||
from tools.video004_benchmark import RESULT_ROOT
|
||
|
||
|
||
SIGNAL = RESULT_ROOT / "pipeline_symbols.npy"
|
||
MANIFEST = RESULT_ROOT / "pipeline_manifest.json"
|
||
OUTPUT = RESULT_ROOT / "pipeline_benchmark.json"
|
||
PACKETS = 40
|
||
SYMBOL_RATE = 500_000
|
||
RATE = "2/3"
|
||
TRANSIENT = 42
|
||
|
||
|
||
def _sha(data: bytes) -> str:
|
||
return hashlib.sha256(data).hexdigest()
|
||
|
||
|
||
def create_fixture() -> None:
|
||
generator = np.random.default_rng(4004)
|
||
frames = []
|
||
all_wires = []
|
||
for block_id in range(4):
|
||
payload = generator.integers(0, 256, MAX_VIDEO_BYTES * 8, dtype=np.uint8).tobytes()
|
||
frames.append(payload)
|
||
sources = tuple((VideoFragment(0x56303434, block_id, index, 8,
|
||
FrameType.KEYFRAME if block_id == 0 else FrameType.DELTA_FRAME,
|
||
0, payload[index * MAX_VIDEO_BYTES:(index + 1) * MAX_VIDEO_BYTES]),
|
||
math.floor(block_id * 152.3)) for index in range(8))
|
||
all_wires.extend(encode_block(sources, block_id, 2))
|
||
phy_frames = [build_frame(wire, "QPSK", RATE, sequence_number=index)
|
||
for index, wire in enumerate(all_wires)]
|
||
symbols = np.concatenate([part for frame in phy_frames
|
||
for part in (frame.symbols,
|
||
np.zeros(TRANSIENT, dtype=np.complex128))])
|
||
n0 = (math.ceil(punctured_fec.coded_bit_count(1024 * 8, RATE) / 2)
|
||
/ (1024 * 8) / 10 ** (9 / 10))
|
||
sigma = math.sqrt(n0 / 2)
|
||
indexes = np.arange(len(symbols), dtype=np.float64)
|
||
received = symbols * np.exp(1j * (0.4 + 2 * math.pi * 73 * indexes / SYMBOL_RATE))
|
||
received += sigma * (generator.standard_normal(len(symbols))
|
||
+ 1j * generator.standard_normal(len(symbols)))
|
||
received = np.concatenate((sigma * (generator.standard_normal(11)
|
||
+ 1j * generator.standard_normal(11)), received))
|
||
np.save(SIGNAL, received, allow_pickle=False)
|
||
MANIFEST.write_text(json.dumps({
|
||
"packet_count": PACKETS, "signal_duration_s": len(symbols) / SYMBOL_RATE,
|
||
"signal_sha256": _sha(received.tobytes()),
|
||
"expected_packet_sha256": [_sha(frame.packet) for frame in phy_frames],
|
||
"expected_frame_sha256": [_sha(frame) for frame in frames],
|
||
"erased_source_symbol_indices": [0, 10, 20, 30], "rf_tx": 0,
|
||
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
|
||
def decode(received: np.ndarray, backend: str) -> dict:
|
||
fec_decoder = None
|
||
if backend == "numba":
|
||
from protocol.video004_viterbi import decode_soft as fec_decoder
|
||
elif backend != "legacy":
|
||
raise ValueError("неизвестный декодер")
|
||
collector = VideoErasureCollector(0x56303434)
|
||
reassembler = FrameReassembler(0x56303434, 1012, max_age_ms=300)
|
||
cursor = 0
|
||
packets = []
|
||
video = {}
|
||
for packet_index in range(PACKETS):
|
||
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:
|
||
raise AssertionError(f"PHY/CRC отказ пакета {packet_index}")
|
||
packets.append(_sha(item.packet))
|
||
body_count = math.ceil(punctured_fec.coded_bit_count(len(item.packet) * 8, RATE) / 2)
|
||
end_symbol = cursor + item.start_symbol + PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS + body_count
|
||
now_ms = math.ceil((end_symbol - 11) * 1000 / SYMBOL_RATE)
|
||
cursor = end_symbol + TRANSIENT
|
||
if packet_index in (0, 10, 20, 30):
|
||
continue # Контролируемое стирание только после успешного CRC.
|
||
for delivery in collector.offer(item.payload, now_ms):
|
||
age = now_ms - delivery.capture_ms
|
||
if age >= 300:
|
||
raise AssertionError("просроченный фрагмент прошёл FEC")
|
||
complete = reassembler.offer_fragment(replace(delivery.fragment, age_ms=age), now_ms)
|
||
if complete is not None:
|
||
video[complete.frame_id] = _sha(complete.data)
|
||
collector.finish()
|
||
return {"packet_sha256": packets,
|
||
"frame_sha256": [video[index] for index in sorted(video)],
|
||
"parity_recoveries": collector.parity_recoveries,
|
||
"unrecoverable_blocks": collector.unrecoverable_blocks}
|
||
|
||
|
||
def run(repetitions: int = 3) -> dict:
|
||
if not SIGNAL.exists():
|
||
create_fixture()
|
||
received = np.load(SIGNAL, allow_pickle=False)
|
||
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||
if _sha(received.tobytes()) != manifest["signal_sha256"]:
|
||
raise AssertionError("сигнал полного доступного пути изменился")
|
||
measured = {}
|
||
expected = None
|
||
for backend in ("legacy", "numba"):
|
||
samples = []
|
||
for _ in range(repetitions):
|
||
started = time.perf_counter()
|
||
output = decode(received, backend)
|
||
samples.append(time.perf_counter() - started)
|
||
if expected is not None and output != expected:
|
||
raise AssertionError("выход полного доступного пути изменился")
|
||
expected = output
|
||
samples.sort()
|
||
measured[backend] = {"wall_times_s": samples, "median_s": samples[len(samples) // 2],
|
||
"realtime_factor": samples[len(samples) // 2] / manifest["signal_duration_s"]}
|
||
if (expected["packet_sha256"] != manifest["expected_packet_sha256"]
|
||
or expected["frame_sha256"] != manifest["expected_frame_sha256"]
|
||
or expected["parity_recoveries"] != 4 or expected["unrecoverable_blocks"]):
|
||
raise AssertionError("полный доступный путь не восстановил кадры точно")
|
||
profile = cProfile.Profile()
|
||
profile.enable()
|
||
decode(received, "numba")
|
||
profile.disable()
|
||
stats = pstats.Stats(profile).strip_dirs().sort_stats("cumtime")
|
||
listing = [{"function": key[2], "file": key[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(received, "numba")
|
||
_, peak = tracemalloc.get_traced_memory()
|
||
tracemalloc.stop()
|
||
return {"packet_count": PACKETS, "signal_sha256": manifest["signal_sha256"],
|
||
"signal_duration_s": manifest["signal_duration_s"],
|
||
"legacy": measured["legacy"], "numba": measured["numba"],
|
||
"speedup": measured["legacy"]["median_s"] / measured["numba"]["median_s"],
|
||
"exact_packets_and_frames": True, "parity_recoveries": 4,
|
||
"profile_total_s": stats.total_tt, "profile_top": listing[:70],
|
||
"tracemalloc_python_peak_bytes": peak,
|
||
"timing_recovery": "NOT_IMPLEMENTED_IN_THIS_BENCHMARK",
|
||
"carrier_tracking_from_raw_iq": "NOT_IMPLEMENTED_IN_THIS_BENCHMARK",
|
||
"h264_decode": "NOT_INCLUDED; separately tested in transport model",
|
||
"rf_tx": 0}
|
||
|
||
|
||
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.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
print(f"40 packets + erasure FEC: legacy={result['legacy']['realtime_factor']:.3f} "
|
||
f"numba={result['numba']['realtime_factor']:.3f} recovered={result['parity_recoveries']}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|