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()

109
tools/video005_channel.py Normal file
View File

@@ -0,0 +1,109 @@
"""Только программные нарушения IQ; эталоны сюда не передаются приёмнику."""
from __future__ import annotations
import json
import math
from pathlib import Path
import statistics
from typing import cast
import numpy as np
from scipy.optimize import brentq
from scipy.signal import lfilter
from protocol.video003_modem import SAMPLE_RATE_HZ, samples_per_symbol
from protocol.video005_iq_waveform import NOMINAL_CARRIER_HZ, SYMBOL_RATE
ROOT = Path(__file__).resolve().parents[1]
LAB066_RESULT = ROOT / "data/processed/lab066/rf_result.json"
def impair(
iq: np.ndarray,
*,
timing_offset_symbols: float = 0.0,
cfo_hz: float = 0.0,
phase_degrees: float = 0.0,
ebn0_db: float | None = None,
seed: int = 20260917,
symbol_rate: int = SYMBOL_RATE,
) -> np.ndarray:
"""Сдвиг времени, CFO, фаза и AWGN с фиксированной затравкой."""
if not all(math.isfinite(value) for value in (timing_offset_symbols, cfo_hz, phase_degrees)):
raise ValueError("параметры канала должны быть конечными")
if ebn0_db is not None and not math.isfinite(ebn0_db):
raise ValueError("Eb/N0 должно быть конечным")
transmitted = np.asarray(iq, dtype=np.complex64)
received = np.empty_like(transmitted)
delay = timing_offset_symbols * samples_per_symbol(symbol_rate)
# Ограничиваем рабочие массивы: полная видеозапись занимает миллионы IQ-отсчётов.
random_generator = np.random.default_rng(seed)
sigma = (math.sqrt(1 / (2 * (2 / 3) * 10 ** (ebn0_db / 10)) / 2)
if ebn0_db is not None else 0.0)
for start in range(0, transmitted.size, 1_000_000):
stop = min(start + 1_000_000, transmitted.size)
indexes = np.arange(start, stop, dtype=np.float64)
if delay:
positions = indexes - delay
lower = np.floor(positions).astype(np.int64)
valid = (lower >= 0) & (lower + 1 < transmitted.size)
part = np.zeros(stop - start, dtype=np.complex64)
fraction = positions[valid] - lower[valid]
part[valid] = (transmitted[lower[valid]] * (1 - fraction)
+ transmitted[lower[valid] + 1] * fraction)
else:
part = transmitted[start:stop].copy()
if cfo_hz or phase_degrees:
phase = math.radians(phase_degrees) + 2 * math.pi * cfo_hz * indexes / SAMPLE_RATE_HZ
part *= np.exp(1j * phase).astype(np.complex64)
if ebn0_db is not None:
part += (sigma * (random_generator.standard_normal(stop - start)
+ 1j * random_generator.standard_normal(stop - start))
).astype(np.complex64)
received[start:stop] = part
return received
def lab066_like(iq: np.ndarray, *, seed: int = 20260917) -> tuple[np.ndarray, dict]:
"""Диагностический суррогат по двум измеренным сводным характеристикам.
Частотная рябь и добавочный остаток не воспроизводят точную OTA-передачу.
"""
report = json.loads(LAB066_RESULT.read_text(encoding="utf-8"))
rows = report["physical_diagnostics"]["rows"]
ripple_db = statistics.median(row["band_gain_nonuniformity_p95_p05_db"] for row in rows)
useful_to_residual_db = statistics.mean(row["matched_residual_db"] for row in rows)
frequencies = np.linspace(NOMINAL_CARRIER_HZ - 337_500,
NOMINAL_CARRIER_HZ + 337_500, 2048)
delay_samples = 8
def modeled_ripple(alpha: float) -> float:
response = np.abs(1 + alpha * np.exp(-2j * np.pi * frequencies
* delay_samples / SAMPLE_RATE_HZ))
return 20 * math.log10(float(np.percentile(response, 95)
/ np.percentile(response, 5)))
alpha = cast(float, brentq(lambda value: modeled_ripple(value) - ripple_db,
0.0, 0.8, full_output=False))
coefficients = np.zeros(delay_samples + 1)
coefficients[0], coefficients[-1] = 1.0, alpha
filtered = cast(np.ndarray, lfilter(coefficients, [1.0],
np.asarray(iq, dtype=np.complex64)))
residual_ratio = 10 ** (useful_to_residual_db / 10)
sigma = math.sqrt(1 / (2 * residual_ratio))
random_generator = np.random.default_rng(seed)
result = filtered + sigma * (
random_generator.standard_normal(filtered.size)
+ 1j * random_generator.standard_normal(filtered.size)
)
return result.astype(np.complex64), {
"label": "LAB066-LIKE DIAGNOSTIC CHANNEL MODEL",
"measured_band_p95_p05_db": ripple_db,
"modeled_band_p95_p05_db": modeled_ripple(alpha),
"measured_matched_residual_db": useful_to_residual_db,
"filter_delay_samples": delay_samples,
"filter_second_tap": alpha,
"limitation": "FIR ripple and white residual surrogate do not reproduce OTA propagation",
}

354
tools/video005_video.py Normal file
View File

@@ -0,0 +1,354 @@
"""VIDEO-005: H.264 через непрерывный IQ, строгий PHY и внешний FEC."""
from __future__ import annotations
from dataclasses import dataclass, replace
from fractions import Fraction
import hashlib
import math
import random
import time
import av
from av.error import FFmpegError
import numpy as np
from protocol import punctured_fec
from protocol.packet import parse_packet
from protocol.video003_modem import PHY_HEADER_SYMBOLS, PREAMBLE_SYMBOLS, build_frame
from protocol.video004_erasure import VideoErasureCollector, decode_source, parse_symbol
from protocol.video005_iq_waveform import SAMPLE_RATE_HZ, rrc_taps, shape_packet
from protocol.video005_receiver import IqStreamResult, receive_iq_stream
from protocol.video_stream_transport import FrameReassembler, FrameType, VideoFrame
from tools.video003_codec import SOURCE, encode_case
from tools.video004_transport import _wire_schedule
from tools.video_compare import source_frames
from tools.video005_channel import impair
STREAM_ID = 0x56303535
FPS = 10
SYMBOL_RATE = 500_000
@dataclass(frozen=True)
class Transmission:
index: int
start_sample: int
end_sample: int
block_id: int
source_index: int | None
source_frame_id: int | None
source_is_keyframe: bool
@dataclass(frozen=True)
class VideoFixture:
iq: np.ndarray
frames: tuple[VideoFrame, ...]
transmissions: tuple[Transmission, ...]
phy_packet_sha256: tuple[str, ...]
clean_pixel_sha256: dict[int, str]
source_sha256: str
encoder_settings: dict
preparation_time_s: dict[str, float]
def _clean_pixels(packets: list[bytes]) -> dict[int, str]:
decoder = av.CodecContext.create("h264", "r")
decoder.open()
hashes = {}
for index, wire in enumerate(packets):
packet = av.Packet(wire)
packet.pts = packet.dts = index
packet.time_base = Fraction(1, FPS)
decoded = decoder.decode(packet)
if len(decoded) != 1 or decoded[0].pts != index:
raise AssertionError("исходный H.264 не декодировался точно")
hashes[index] = hashlib.sha256(decoded[0].to_ndarray(format="rgb24").tobytes()).hexdigest()
return hashes
def build_fixture(frame_count: int = 20,
pixels_override: list[np.ndarray] | None = None) -> VideoFixture:
"""Кодирует исходный клип и передаёт видео по расписанию без oracle для RX."""
if not 1 <= frame_count <= 20:
raise ValueError("контроль VIDEO-005 ограничен первыми 20 кадрами")
started = time.perf_counter()
pixels = (source_frames(SOURCE, 640, 360, FPS, 0, frame_count / FPS)
if pixels_override is None else pixels_override)
if len(pixels) != frame_count or any(item.shape != (360, 640, 3) for item in pixels):
raise ValueError("ожидаются кадры RGB 640x360 выбранной длины")
codec, encoded = encode_case(pixels, FPS, "SHORT_GOP", 400_000)
if len(encoded) != frame_count:
raise AssertionError("кодек вернул неверное число кадров")
encode_time = time.perf_counter() - started
started = time.perf_counter()
clean_hashes = _clean_pixels(encoded)
reference_decode_time = time.perf_counter() - started
frames = tuple(VideoFrame(
STREAM_ID, index,
FrameType.KEYFRAME if index in codec["keyframe_positions"] else FrameType.DELTA_FRAME,
index * 100, wire,
) for index, wire in enumerate(encoded))
started = time.perf_counter()
schedule, _ = _wire_schedule(list(frames), 8, 1, True, 1)
phy_frames = [build_frame(wire, "QPSK", "2/3", sequence_number=index)
for index, (wire, _, _, _) in enumerate(schedule)]
outer_phy_time = time.perf_counter() - started
started = time.perf_counter()
chunks: list[np.ndarray] = []
intervals: list[Transmission] = []
position = 0
for index, ((wire, ready_ms, block_id, is_parity), phy) in enumerate(
zip(schedule, phy_frames, strict=True)
):
ready_sample = math.ceil(ready_ms * SAMPLE_RATE_HZ / 1000)
if ready_sample > position:
chunks.append(np.zeros(ready_sample - position, dtype=np.complex64))
position = ready_sample
waveform = shape_packet(phy)
start_sample = position
chunks.append(waveform)
position += waveform.size
symbol = parse_symbol(wire)
source = (decode_source(symbol.data, symbol.stream_id, symbol.index, False)
if not is_parity else None)
intervals.append(Transmission(
index, start_sample, position, block_id,
symbol.index if source is not None else None,
source.fragment.frame_id if source is not None else None,
bool(source is not None and source.fragment.frame_type == FrameType.KEYFRAME),
))
iq = np.concatenate(chunks)
tx_rrc_time = time.perf_counter() - started
source_sha256 = (hashlib.sha256(SOURCE.read_bytes()).hexdigest()
if pixels_override is None else hashlib.sha256(
b"".join(item.tobytes() for item in pixels)
).hexdigest())
return VideoFixture(
iq, frames, tuple(intervals),
tuple(hashlib.sha256(frame.packet).hexdigest() for frame in phy_frames),
clean_hashes, source_sha256,
codec["settings"],
{"source_and_h264_encode": encode_time, "reference_h264_diagnostic": reference_decode_time,
"outer_fec_and_phy": outer_phy_time, "tx_rrc_and_iq": tx_rrc_time},
)
def _loss_indices(fixture: VideoFixture, scenario: str, seed: int) -> tuple[set[int], int | None]:
random_generator = random.Random(seed)
if scenario == "clean":
return set(), None
if scenario in ("random_1", "random_5"):
probability = int(scenario.split("_")[1]) / 100
return {item.index for item in fixture.transmissions
if random_generator.random() < probability}, None
if scenario in ("keyframe_double", "keyframe_triple"):
count = 2 if scenario == "keyframe_double" else 3
by_block: dict[int, list[Transmission]] = {}
for item in fixture.transmissions:
if item.source_is_keyframe and item.source_frame_id is not None \
and item.source_frame_id >= 5:
by_block.setdefault(item.block_id, []).append(item)
selected = next((items for items in by_block.values() if len(items) >= count), None)
if selected is None:
raise ValueError("не найден блок опорного кадра для выбранных потерь")
return {item.index for item in selected[:count]}, selected[0].source_frame_id
if scenario == "corrupt_packet":
selected = next(item for item in fixture.transmissions
if item.source_frame_id is not None and item.source_frame_id >= 2)
return {selected.index}, selected.source_frame_id
raise ValueError("неизвестный сценарий стираний")
def _packet_end_ms(packet_start_sample: float, full_packet_bytes: int) -> int:
body_symbols = math.ceil(punctured_fec.coded_bit_count(full_packet_bytes * 8, "2/3") / 2)
symbols = PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS + body_symbols
# Начало в согласованном фильтре сдвинуто на длину двух RRC-фильтров минус один.
sample = packet_start_sample - (len(rrc_taps()) - 1) + symbols * 16
return math.ceil(sample * 1000 / SAMPLE_RATE_HZ)
def assemble_video(fixture: VideoFixture, radio: IqStreamResult,
lost_keyframe_id: int | None = None) -> dict:
"""Подаёт только CRC-valid payload в FEC, сборщик и настоящий H.264-декодер."""
collector = VideoErasureCollector(STREAM_ID)
reassembler = FrameReassembler(STREAM_ID, 1012, max_age_ms=300)
decoder = av.CodecContext.create("h264", "r")
decoder.open()
displayed: list[tuple[int, int, int]] = []
completed_ids: list[int] = []
completed_data: list[tuple[int, bytes]] = []
decoded_frames: list[tuple[int, av.VideoFrame]] = []
decoder_errors = 0
erasure_time = 0.0
transport_time = 0.0
h264_time = 0.0
synchronized = False
last_displayed_id: int | None = None
seen_ids: set[int] = set()
for packet in radio.packets:
now_ms = _packet_end_ms(packet.start_sample, len(packet.packet))
started = time.perf_counter()
deliveries = collector.offer(packet.payload, now_ms)
erasure_time += time.perf_counter() - started
for delivery in deliveries:
age = now_ms - delivery.capture_ms
if not 0 <= age < 300:
raise AssertionError("видеофрагмент вышел за срок")
started = time.perf_counter()
complete = reassembler.offer_fragment(replace(delivery.fragment, age_ms=age), now_ms)
transport_time += time.perf_counter() - started
if complete is None:
continue
completed_ids.append(complete.frame_id)
completed_data.append((complete.frame_id, complete.data))
if complete.frame_type == FrameType.KEYFRAME:
decoder = av.CodecContext.create("h264", "r")
decoder.open()
synchronized = True
elif last_displayed_id is None or complete.frame_id != last_displayed_id + 1:
synchronized = False
if not synchronized:
continue
started = time.perf_counter()
encoded = av.Packet(complete.data)
encoded.pts = encoded.dts = complete.frame_id
encoded.time_base = Fraction(1, FPS)
try:
decoded = decoder.decode(encoded)
except FFmpegError:
h264_time += time.perf_counter() - started
decoder_errors += 1
synchronized = False
continue
if len(decoded) != 1 or decoded[0].pts != complete.frame_id:
h264_time += time.perf_counter() - started
decoder_errors += 1
synchronized = False
continue
h264_time += time.perf_counter() - started
decoded_frames.append((complete.frame_id, decoded[0]))
if complete.frame_id in seen_ids:
raise AssertionError("старый кадр повторно показан")
seen_ids.add(complete.frame_id)
displayed.append((complete.frame_id, now_ms, now_ms - complete.capture_ms))
last_displayed_id = complete.frame_id
collector.finish()
started = time.perf_counter()
wrong_encoded_frames = sum(
not 0 <= frame_id < len(fixture.frames)
or data != fixture.frames[frame_id].data
for frame_id, data in completed_data
)
wrong_decoded_frames = sum(
hashlib.sha256(frame.to_ndarray(format="rgb24").tobytes()).hexdigest()
!= fixture.clean_pixel_sha256.get(frame_id)
for frame_id, frame in decoded_frames
)
diagnostic_time = time.perf_counter() - started
ages = [age for _, _, age in displayed]
recovery_ms = None
if lost_keyframe_id is not None:
if lost_keyframe_id in seen_ids:
recovery_ms = 0
else:
later = next((arrival - fixture.frames[lost_keyframe_id].capture_ms
for frame_id, arrival, _ in displayed
if frame_id > lost_keyframe_id
and fixture.frames[frame_id].frame_type == FrameType.KEYFRAME), None)
recovery_ms = later
return {
"input_frames": len(fixture.frames),
"unique_decoded_frames": len(displayed),
"displayed_frame_ids": [frame_id for frame_id, _, _ in displayed],
"displayed_fps": len(displayed) * FPS / len(fixture.frames),
"frame_loss_rate": 1 - len(displayed) / len(fixture.frames),
"p50_frame_age_ms": float(np.percentile(ages, 50)) if ages else None,
"p95_frame_age_ms": float(np.percentile(ages, 95)) if ages else None,
"max_frame_age_ms": max(ages) if ages else None,
"stale_drops": collector.stale_rejections + reassembler.expired_frames,
"recovered_fragments": collector.parity_recoveries,
"unrecoverable_blocks": collector.unrecoverable_blocks,
"completed_frame_ids": completed_ids,
"wrong_encoded_frames": wrong_encoded_frames,
"wrong_decoded_frames": wrong_decoded_frames,
"h264_decoder_errors": decoder_errors,
"keyframe_recovery_ms": recovery_ms,
"old_frame_replay": displayed != sorted(displayed, key=lambda item: item[0]),
"erasure_fec_time_s": erasure_time,
"video_transport_time_s": transport_time,
"h264_decode_time_s": h264_time,
"diagnostic_verification_time_s": diagnostic_time,
}
def run_case(
fixture: VideoFixture,
scenario: str = "clean",
*,
seed: int = 20260917,
ebn0_db: float = 9.0,
decoder_backend: str = "numba",
) -> dict:
"""Потери создаются в IQ; приёмник получает только повреждённый поток."""
selected, lost_keyframe_id = _loss_indices(fixture, scenario, seed)
started = time.perf_counter()
iq = impair(fixture.iq, cfo_hz=20, phase_degrees=15,
ebn0_db=ebn0_db, seed=seed)
if selected:
random_generator = np.random.default_rng(seed + 1)
for index in selected:
item = fixture.transmissions[index]
if scenario == "corrupt_packet":
sigma = 2.0
count = item.end_sample - item.start_sample
iq[item.start_sample:item.end_sample] = sigma * (
random_generator.standard_normal(count)
+ 1j * random_generator.standard_normal(count)
)
else:
iq[item.start_sample:item.end_sample] = 0
channel_time = time.perf_counter() - started
started = time.perf_counter()
radio = receive_iq_stream(iq, decoder_backend=decoder_backend)
radio_time = time.perf_counter() - started
started = time.perf_counter()
video = assemble_video(fixture, radio, lost_keyframe_id)
video_time = time.perf_counter() - started
video_pipeline_time = video_time - video["diagnostic_verification_time_s"]
sequence_numbers = [parse_packet(packet.packet).sequence_number for packet in radio.packets]
# Сравнение с эталоном только после CRC и видео-декодирования.
exact_packets = len(set(sequence_numbers)) == len(sequence_numbers) and all(
number < len(fixture.phy_packet_sha256)
for number in sequence_numbers
) and all(
hashlib.sha256(packet.packet).hexdigest() == fixture.phy_packet_sha256[number]
for packet, number in zip(radio.packets, sequence_numbers, strict=True)
)
return {
"scenario": scenario, "seed": seed, "ebn0_db": ebn0_db,
"iq_duration_s": len(iq) / SAMPLE_RATE_HZ,
"scheduled_phy_packets": len(fixture.transmissions),
"iq_erased_or_corrupted_packets": len(selected),
"crc_valid_radio_packets": len(radio.packets),
"radio_candidates": radio.candidate_count,
"exact_crc_packets": exact_packets,
"channel_time_s": channel_time, "radio_time_s": radio_time,
"video_transport_and_decode_time_s": video_pipeline_time,
"diagnostic_verification_time_s": video["diagnostic_verification_time_s"],
"radio_realtime_factor": radio_time / (len(iq) / SAMPLE_RATE_HZ),
"iq_to_decoded_video_realtime_factor": (
radio_time + video_pipeline_time
) / (len(iq) / SAMPLE_RATE_HZ),
"end_to_end_realtime_factor": (
sum(duration for stage, duration in fixture.preparation_time_s.items()
if stage != "reference_h264_diagnostic")
+ channel_time + radio_time + video_pipeline_time
) / (len(iq) / SAMPLE_RATE_HZ),
"radio_stage_time_s": radio.stage_time_s,
"video": video,
}

View File

@@ -1,4 +1,4 @@
"""VIDEO-001/002/003: проверки без данных аппаратных лабораторий и без RF."""
"""VIDEO-001–005: проверка программного видеотракта без аппаратных лабораторий и RF."""
from __future__ import annotations
@@ -34,6 +34,9 @@ MODULES = (
"tools.video004_viterbi_benchmark", "tools.video004_longrun",
"tools.video004_pipeline_benchmark",
"tools.video004_keyframe", "tools.video004_seed_sweep",
"protocol.video005_iq_waveform", "protocol.video005_timing",
"protocol.video005_carrier", "protocol.video005_receiver",
"tools.video005_channel", "tools.video005_video", "tools.video005_benchmark",
)
TESTS = (
"tests/test_packet.py", "tests/test_bpsk_radio.py",
@@ -45,6 +48,18 @@ TESTS = (
"tests/test_video004_transport.py",
"tests/test_video004_pipeline.py",
"tests/test_video004_keyframe.py",
"tests/test_video005_iq.py", "tests/test_video005_video.py",
)
STATIC_PATHS = (
"protocol/packet.py", "protocol/video_packet.py", "protocol/bpsk_radio.py",
"protocol/convolutional_fec.py", "protocol/packet_erasure_fec.py",
"protocol/video_stream_transport.py", "protocol/punctured_fec.py", "protocol/video003_modem.py",
"protocol/video003_compact_header.py", "protocol/video004_viterbi.py",
"protocol/video004_erasure.py", "protocol/video005_iq_waveform.py",
"protocol/video005_timing.py", "protocol/video005_carrier.py",
"protocol/video005_receiver.py", "tools/video005_channel.py",
"tools/video005_video.py", "tools/video005_benchmark.py", "tools/video_gate.py",
"tests/test_video005_iq.py", "tests/test_video005_video.py",
)
ARTIFACTS = {
"video001_160": "data/processed/video001/comparison_traffic_160k.json",
@@ -66,6 +81,7 @@ ARTIFACTS = {
"pipeline004": "data/processed/video004/pipeline_benchmark.json",
"keyframe004": "data/processed/video004/keyframe_recovery.json",
"seed_sweep004": "data/processed/video004/normal_seed_sweep.json",
"benchmark005": "data/processed/video005/benchmark.json",
}
FORBIDDEN_IMPORT_ROOTS = {"iio", "adi", "SoapySDR", "hackrf", "pylibiio", "pyhackrf"}
@@ -73,6 +89,8 @@ FORBIDDEN_IMPORT_ROOTS = {"iio", "adi", "SoapySDR", "hackrf", "pylibiio", "pyhac
def _check_imports_and_hardware_boundary() -> None:
for name in MODULES:
module = importlib.import_module(name)
if module.__file__ is None:
raise AssertionError(f"у модуля нет исходного файла: {name}")
source_path = Path(module.__file__).resolve()
if not source_path.is_relative_to(ROOT.resolve()):
raise AssertionError(f"локальный модуль вне worktree: {name}")
@@ -156,9 +174,9 @@ def _check_results(documents: dict[str, dict]) -> None:
for case in transport["cases"]:
if not case["no_old_frame_replay"]:
raise AssertionError("повторно показан старый кадр")
if case["p95_end_to_end_age_ms"] is not None:
if case["p95_end_to_end_age_ms"] > case["max_end_to_end_age_ms"]:
raise AssertionError("P95 возраста больше максимума")
if (case["p95_end_to_end_age_ms"] is not None
and case["p95_end_to_end_age_ms"] > case["max_end_to_end_age_ms"]):
raise AssertionError("P95 возраста больше максимума")
selected_clean = [case for case in transport["cases"]
if case["codec_profile"] == "SHORT_GOP_400K"
and case["link"] == "QPSK_500_R23_1012_0MS"
@@ -171,7 +189,7 @@ def _check_results(documents: dict[str, dict]) -> None:
"benchmark_manifest", "benchmark_before", "benchmark_after",
"transport004", "interleaving004", "viterbi004", "long004",
"pipeline_manifest004", "pipeline004", "keyframe004",
"seed_sweep004")):
"seed_sweep004", "benchmark005")):
raise AssertionError("в результатах найден ненулевой RF TX")
from tools.video004_benchmark import load_fixture, run as benchmark_run
@@ -239,35 +257,101 @@ def _check_results(documents: dict[str, dict]) -> None:
or sum(x["displayed_frames"] for x in boosted_double) != 120):
raise AssertionError("защита опорного кадра изменила результат")
benchmark005 = documents["benchmark005"]
saved_radio = benchmark005["radio"]
if (saved_radio["packets"] != 32
or saved_radio["strict_outcome"]["exact_packets"] != 32
or saved_radio["strict_outcome"]["unexpected_or_wrong_packets"] != 0
or saved_radio["realtime_factor"] > 0.67):
raise AssertionError("сохранённый IQ-контроль VIDEO-005 не прошёл")
cases = {case["scenario"]: case for case in benchmark005["video"]["cases"]}
if (len(cases) != 6 or cases["clean"]["video"]["unique_decoded_frames"] != 20
or cases["random_1"]["video"]["unique_decoded_frames"] != 20
or cases["keyframe_double"]["video"]["unique_decoded_frames"] != 20
or cases["corrupt_packet"]["video"]["unique_decoded_frames"] != 20
or any(not case["exact_crc_packets"] or case["video"]["wrong_decoded_frames"]
or case["video"]["old_frame_replay"] for case in cases.values())):
raise AssertionError("видеорезультаты VIDEO-005 нарушают строгий контроль")
from tools.video005_benchmark import _strict_outcomes, build_radio_fixture
from protocol.video005_receiver import receive_iq_stream
reference005, _, receiver_iq = build_radio_fixture()
if hashlib.sha256(receiver_iq.tobytes()).hexdigest() != saved_radio["signal_sha256"]:
raise AssertionError("IQ-сигнал VIDEO-005 изменился")
started = time.perf_counter()
current005 = receive_iq_stream(receiver_iq)
elapsed005 = time.perf_counter() - started
current_outcome = _strict_outcomes(current005, reference005)
current_outcome["packet_sha256_by_sequence"] = {
str(index): digest
for index, digest in current_outcome["packet_sha256_by_sequence"].items()
}
if (current_outcome != saved_radio["strict_outcome"]
or elapsed005 / saved_radio["iq_duration_s"] > 0.67):
raise AssertionError("строгий IQ-приём VIDEO-005 или его скорость изменились")
from tools.video005_video import build_fixture, run_case
fixture005 = build_fixture(20)
if (fixture005.source_sha256 != benchmark005["video"]["source_sha256"]
or len(fixture005.transmissions) != benchmark005["video"]["scheduled_phy_packets"]):
raise AssertionError("видеовход VIDEO-005 изменился")
for scenario in ("clean", "random_5"):
actual = run_case(fixture005, scenario)
saved = cases[scenario]
if (actual["crc_valid_radio_packets"] != saved["crc_valid_radio_packets"]
or actual["exact_crc_packets"] != saved["exact_crc_packets"]
or any(actual["video"][key] != saved["video"][key] for key in (
"displayed_frame_ids", "recovered_fragments", "stale_drops",
"wrong_encoded_frames", "wrong_decoded_frames", "old_frame_replay",
))):
raise AssertionError(f"повторная сборка видео изменила результат: {scenario}")
def _run_static_gates(environment: dict[str, str]) -> None:
commands = (
[sys.executable, "-B", "-m", "ruff", "check", *STATIC_PATHS],
[sys.executable, "-B", "-m", "pyright", "--project", "pyrightconfig.json"],
["git", "diff", "--check"],
)
for command in commands:
completed = subprocess.run(command, cwd=ROOT, env=environment, check=False)
if completed.returncode != 0:
raise SystemExit(f"статический шлюз завершился ошибкой: {command[0:4]}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--numba-packages", type=Path)
parser.add_argument("--dev-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()))
if args.dev_packages is not None and not args.dev_packages.is_dir():
parser.error("каталог dev-зависимостей отсутствует")
_check_imports_and_hardware_boundary()
documents = _load_artifacts()
_check_results(documents)
output_root = (ROOT / "data/processed/video003").resolve()
output_root = (ROOT / "data/processed/video005").resolve()
if not output_root.is_relative_to(ROOT.resolve()):
raise AssertionError("временный каталог вне worktree")
environment = os.environ.copy()
environment["PYTHONDONTWRITEBYTECODE"] = "1"
if args.numba_packages is not None:
environment["PYTHONPATH"] = str(args.numba_packages.resolve()) + os.pathsep + environment.get("PYTHONPATH", "")
if args.dev_packages is not None:
environment["PYTHONPATH"] = (str(args.dev_packages.resolve()) + os.pathsep
+ environment.get("PYTHONPATH", ""))
_run_static_gates(environment)
with tempfile.TemporaryDirectory(prefix="video_gate_pytest_", dir=output_root) as name:
temporary = Path(name).resolve()
if not temporary.is_relative_to(output_root):
raise AssertionError("временный каталог вне результата VIDEO-003")
raise AssertionError("временный каталог вне результата VIDEO-005")
command = [sys.executable, "-B", "-m", "pytest", *TESTS,
"-q", "-p", "no:cacheprovider", "--basetemp", str(temporary)]
completed = subprocess.run(command, cwd=ROOT, env=environment, check=False)
if completed.returncode != 0:
raise SystemExit(completed.returncode)
print("VIDEO-SPECIFIC GATE: PASS; результаты, импорты, аппаратная граница и тесты")
print("VIDEO-SPECIFIC GATE: PASS; IQ, видео, ruff, pyright, git diff, тесты и RF-граница")
if __name__ == "__main__":