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

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__":