VIDEO-005: add full offline QPSK IQ video receiver
This commit is contained in:
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user