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

File diff suppressed because it is too large Load Diff

187
docs/video/VIDEO_005.md Normal file
View File

@@ -0,0 +1,187 @@
# VIDEO-005 — приём IQ и проверка видеотракта
17 сентября 2026 года. Статус: **PARTIAL**. Это программный эксперимент в
`video-development`, без доступа к SDR и без передачи в эфир. Исходная точка —
`95f6640`; история Git сохранена. Измерения и SHA записаны в
`data/processed/video005/benchmark.json` (seed `20260917`).
## Рабочая цепочка
Приёмник `receive_iq_stream()` получает одномерный complex IQ и выбранный режим
радиоканала. Он не получает границы пакета, момент выборки, истинный CFO/фазу,
payload или CRC. Цепочка: цифровое смещение несущей → согласованный RRC → поиск
известной преамбулы по всему потоку → дробное уточнение момента → оценка CFO и
фазы по преамбуле → разрешение квадранта QPSK → блочное слежение за фазой тела
по решениям QPSK → PHY/внутренний FEC 2/3/CRC → фрагменты → внешний FEC 8+1
с усилением опорного блока → сборка H.264 → декодированные кадры. Эталонные
пакеты и пиксели используются после обработки только для подсчёта ошибок.
Использованы прежние QPSK mapping, RRC, FEC, Viterbi, CRC и видеотранспорт.
Новые поиск преамбулы, восстановление такта и несущей находятся в отдельных
модулях. Порог корреляции 0,5 отсекает наблюдавшиеся боковые пики PRBS7 около
0,4. Формат преамбулы не менялся. `CURRENT PREAMBLE INSUFFICIENT = NO` **только
для проверенной сетки**; это не доказательство устойчивости в эфире.
## Проверки сигнала
На одном полном пакете и в комбинированных трёхпакетных потоках проверены
дробные сдвиги `0`, `±0,1`, `±0,25`, `±0,4` символа; CFO `0`, `±1`, `±5`,
`±20`, `±100` Гц; начальная фаза `0`, `±15`, `±45`, `90`, `180` градусов.
Синтетическое фазовое отклонение тела от 0 до 0,35 рад уменьшалось решающим
QPSK-трекером. Все заявленные строгие проверки восстановили исходный пакет.
Дрейф частоты дискретизации не вводился: `SAMPLE CLOCK OFFSET = NOT MODELED IN
VIDEO-005`.
AWGN, 16 полных пакетов на точку, фиксированная затравка: при `Eb/N0` 0, 3, 6,
9 дБ получено соответственно **0/16, 1/16, 16/16, 16/16** точных CRC-пакетов;
неверных принятых пакетов — 0. Это наблюдение для выбранного потока, не кривая
ошибок или оценка вероятности для любого канала.
`LAB066-LIKE DIAGNOSTIC CHANNEL MODEL`: медиана измеренной неравномерности
полосы 2,870 дБ и средний показатель matched/residual 5,546 дБ. Суррогат
содержит двухотводный FIR с задержкой 8 отсчётов и вторым коэффициентом
0,176194, а также белый остаток. Он дал **30/32** точных CRC-пакетов, неверных
принятых — 0. Фильтр согласован по одному сводному показателю; модель не
воспроизводит распределение помех, меняющийся канал, аппаратные искажения или
саму запись Lab066. Это не измерение RF-приёма.
## Скорость и профиль
Канонический сигнал: 32 полных пакета, 3 886 112 комплексных отсчётов,
8 Мвыб/с, длительность **0,485764 с**, SHA-256 IQ
`d34c4fbba04708180f60ed759cef8bfa9f67827a57ea5ff201d94701bf32afd2`.
Передача 500 ксимв/с, QPSK, inner FEC 2/3. Первый запуск с JIT занял
**1,203 с**. Три прогретых замера `perf_counter()` — 0,281 / 0,299 / 0,268 с;
медиана **0,281 с**, фактор **0,578**, скорость **1,729×** реального времени.
Получено 32/32 пакета с точным SHA и CRC; целевой фактор ≤0,67 выполнен,
предпочтительный ≤0,50 — нет. Эти числа зависят от нагрузки данного ПК.
В таблице времена этапов взяты из последнего прогретого запуска (0,268 с),
проценты относятся к нему. Числа вызовов взяты из отдельного `cProfile`;
его времена не складываются с таблицей. Копии/буферы перечислены по коду,
точное число выделений NumPy не измерялось.
| Этап | Время, мс | Доля | Вызовы | Основные выделения/копии |
|---|---:|---:|---:|---|
| Смещение несущей | 15,3 | 5,7% | 1 | Периодический осциллятор и complex64 baseband, по 3,89 млн отсчётов |
| Согласованный RRC | 61,5 | 22,9% | 1 | Выход свёртки; внутренние FFT-буферы SciPy |
| Поиск преамбулы | 49,2 | 18,4% | 1 | Две корреляции/массивы энергии на прореженных фазах |
| Восстановление такта | 9,2 | 3,4% | 32 | Индексы и один complex64-массив символов на кандидат |
| Оценка CFO/фазы/квадранта | 12,5 | 4,7% | 32 | Корректор и исправленные символы на пакет |
| Проверка PHY-заголовка | 0,6 | 0,2% | 32 | Малый массив заголовка |
| Слежение за фазой QPSK | 49,9 | 18,6% | 32 | Одна копия символов пакета и векторы блоков по 64 |
| Строгий PHY/FEC/CRC | 53,0 | 19,8% | 32 | Мягкие решения, depuncturing и рабочие массивы Viterbi |
В `cProfile` (полный профилированный вызов 0,282 с): слежение QPSK 74,4 мс,
RRC 54,6 мс, поиск 46,6 мс, Viterbi `decode_soft` 28,6 мс, depuncturing
1,41 мс, QPSK soft decisions 0,48 мс и `parse_packet`/CRC 0,36 мс. На чистом
двухсекундном видео внешний FEC занял 1,10 мс, сборка фрагментов 0,86 мс,
декодирование H.264 15,7 мс. Диагностическая сверка пикселей заняла ещё
11,3 мс и исключена из времени рабочего пути. Доли мелких функций из
`cProfile` относятся к 0,282 с; вложенные функции нельзя суммировать.
## Видео 640×360/10
Первые 20 кадров исходного клипа (SHA-256
`651949922042c223375d40bf8929def97dbb62c6ade651d1f5b08eea47127d5f`)
закодированы PyAV/libx264 в H.264 SHORT_GOP 400k. Создано 117 PHY-пакетов;
длительность IQ 1,991 с. Потери и повреждение вносятся в **IQ-интервалы**, до
приёмника. Возраст отсчитывается от захвата кадра до завершения радиопакета.
| Сценарий | CRC-пакеты | Кадры/FPS | Потеря кадров | P50/P95/max, мс | Просрочено | Восстановлено FEC | Опорный кадр |
|---|---:|---:|---:|---:|---:|---:|---:|
| Без потерь | 117 | 20/20; 10,0 | 0% | 101/167/167 | 0 | 0 | — |
| 1% IQ-потерь (факт 1/117) | 116 | 20/20; 10,0 | 0% | 101/167/167 | 0 | 1 | — |
| 5% IQ-потерь (факт 6/117) | 111 | 17/20; 8,5 | 15% | 119/167/167 | 1 | 3 | — |
| Два фрагмента опорного кадра | 115 | 20/20; 10,0 | 0% | 101/167/167 | 0 | 2 | 0 мс |
| Три фрагмента опорного кадра | 114 | 15/20; 7,5 | 25% | 89/167/167 | 1 | 0 | 667 мс до следующего |
| Один испорченный IQ-пакет | 116 | 20/20; 10,0 | 0% | 101/167/167 | 0 | 1 | 0 мс |
Во всех сценариях нет неверного CRC-пакета, неверно собранного или декодированного
кадра и повторного показа старого кадра. После невосстановленной потери
опорного кадра H.264 ждёт следующий опорный кадр. Чистый путь **IQ →
декодированные кадры** имеет фактор **0,576**. Полный офлайн-прогон от чтения
и кодирования источника через формирование IQ и расчёт синтетического канала
до показа имеет фактор **2,153**; его нельзя представлять как скорость одного
приёмника. В этом прогоне 0,317 с заняли чтение/кодирование, 0,597 с — внешний
FEC и PHY передатчика, 0,923 с — формирование IQ, около 1,3 с — канал и около
1,1 с — радиоприём. Сверка эталона в оба фактора не входит.
## CODE QUALITY AUDIT
**BEFORE:** рабочие модули `video003_modem`, `video004_viterbi`,
`video004_erasure` и четыре новых модуля `video005_*` — около 968 строк и
34 функции после реализации; радиофактор до короткой чистки в одном прогоне
0,513. Главные затраты: RRC, поиск преамбулы, слежение за фазой и FEC.
| Проверка или подозрительное место | Где | Почему и категория | Решение |
|---|---|---|---|
| Длина IQ, конечность, параметры режима, границы интерполяции | `video005_receiver.py`, `video005_timing.py` | A: повреждённый вход и выход за буфер | KEEP |
| PHY-заголовок, CRC, возраст, индексы внешнего FEC | `video005_receiver.py`, старые модули | A: обязательные барьеры до видео | KEEP |
| Равенство длин строк матрицы и векторов внешнего FEC | `packet_erasure_fec.py` | A: обрезка `zip()` могла скрыть невозможное состояние | REWRITE с `strict=True` |
| Предварительный PHY-заголовок и повторная проверка в строгом декодере | `video005_receiver.py` | D: длина тела нужна до FEC, строгий разбор обязателен | KEEP |
| Проверка фазовых решений внутри тела | `video005_carrier.py` | E: решения QPSK нужны для слежения | KEEP, блоками по 64 символа |
| Эталонные SHA пакетов и пикселей | `video005_benchmark.py`, `video005_video.py` | C: диагностика не должна решать судьбу кадра | MOVE_TO_DIAGNOSTICS после обработки |
| `complex128` после интерполяции/фазовой коррекции | `video005_timing.py`, `video005_carrier.py` | Лишний объём временных векторов | REWRITE в `complex64` |
| Повторный H.264 decode эталона | `video005_video.py` | C: нужен для точной пиксельной сверки, не для показа | KEEP вне измеряемого пути |
| `LAB066-LIKE`, `cProfile`, графики и подробный JSON | `tools/` | C: офлайн-диагностика | KEEP вне receiver |
| Периодический осциллятор, RRC/FFT-буферы | `video005_receiver.py` | Выделения велики, но RRC быстрее основной цели | KEEP; дальнейшая оптимизация требует отдельного профиля |
**REMOVED / SIMPLIFIED:** временные векторы символов и фазовой коррекции стали
`complex64`; эталон перестал управлять H.264-декодированием; два `zip()`
внешнего FEC теперь проверяют равенство длин. **MOVED TO TESTS /
DIAGNOSTICS:** точные SHA и сверка пикселей остаются в проверочном коде и
выполняются после рабочего прохода. Неиспользуемых импортов Ruff не обнаружил;
декодер и передатчик не содержат Lab066-ветвей или глобального изменяемого
состояния. Кеш коэффициентов RRC неизменяемый. Большие функции сборки видео
остаются в офлайн-инструменте; их разделение сейчас не даст доказанного выигрыша.
**AFTER:** радиофактор 0,578 в финальном запуске; ускорение от изменения dtype
не заявляется из-за разброса времени между прогонами. Строгие пакеты, CRC,
числа кадров и сценарные выходы остались теми же. Прошли 178 тестов,
`ruff`, `pyright` (0 ошибок/предупреждений), `git diff --check` и
`video_gate.py`. Защита от неверных пакетов, индексов и просроченного видео
не удалялась.
Независимый запуск общего `tools/quick_gate.py` дал 1103 пройденных теста,
2 пропуска и один отказ Lab065: в этом worktree отсутствует игнорируемый
исторический вход `data/raw/lab064/tx_metadata.json`. Это ограничение общего
шлюза, а не воспроизведённый отказ VIDEO-005; целевой VIDEO gate прошёл.
## Решение и границы
Предварительная политика без автоматического переключения: GOOD — NORMAL
640×360/10 с 8+1 и усилением опорных блоков; DEGRADED — снизить FPS или
увеличить избыточность; POOR — ROBUST 320×240/5; CRITICAL — отключить видео
или резко снизить поток. Пороги потерь не закреплены: один короткий сценарий
5% дал 17/20 кадров, а суррогат Lab066 не заменяет эфир.
| Итоговое поле | Результат |
|---|---|
| VIDEO-005 STATUS | **PARTIAL**: радиопуть проходит цель, офлайн-генерация медленнее реального времени, есть потери кадров при 5% |
| RAW IQ RECEIVER | **PASS**: 32/32 точных пакета; фактор 0,578 ≤0,67 |
| PREAMBLE ACQUISITION | **PASS** в проверенных потоках |
| TIMING RECOVERY | **PASS** для 0 и ±0,1/0,25/0,4 символа |
| CARRIER FREQUENCY RECOVERY | **PASS** для 0 и ±1/5/20/100 Гц |
| CARRIER PHASE TRACKING | **PASS** в синтетическом тесте изменения фазы |
| QPSK QUADRANT RESOLUTION | **PASS** для 90°/180° и проверенных фаз |
| FEC 2/3 | **PASS**, битовые/пакетные выходы точны |
| OUTER ERASURE FEC | **PASS** при одном и двух стираниях; три не восстановлены |
| 640x360 / 10 FPS END-TO-END | **PARTIAL**: чисто 20/20, при 5% — 17/20; полный офлайн-фактор 2,153 |
| INPUT VIDEO FPS / DISPLAYED FPS / FRAME LOSS RATE | 10 / 10 / 0% чисто; 10 / 8,5 / 15% при 5% IQ-потерь |
| P50 / P95 / MAX FRAME AGE | 101 / 167 / 167 мс чисто; 119 / 167 / 167 мс при 5% |
| RADIO RECEIVER REALTIME FACTOR | 0,578; IQ → декодированные кадры 0,576 в отдельном видеопрогоне |
| END-TO-END VIDEO REALTIME FACTOR | 2,153 от источника через офлайн-канал до кадра |
| RAW IQ DURATION / JIT FIRST RUN TIME / WARM PROCESSING TIME | 0,485764 / 1,203 / медиана 0,281 с |
| MAIN HOT PATH | IQ → RRC → преамбула/такт/несущая → QPSK → FEC/CRC → фрагменты → erasure FEC → H.264 |
| AWGN RESULT | 0/16, 1/16, 16/16, 16/16 точных пакетов при 0/3/6/9 дБ |
| LAB066-LIKE MODEL RESULT | 30/32 точных пакета; диагностический суррогат |
| SAMPLE CLOCK OFFSET | **NOT MODELED IN VIDEO-005** |
| RUFF / PYRIGHT / VIDEO GATE | **PASS / PASS / PASS** |
| BEST NEXT RF CANDIDATE | QPSK 500 ксимв/с, inner 2/3, outer 8+1 с усилением опорного блока, H.264 SHORT_GOP 400k |
| WHY | 20/20 чисто и при одном стирании; нуждается в отдельной проверке устойчивости к 5% и реальным искажениям |
| RF TX / SDR HARDWARE | **0 / NOT ACCESSED** |
| COMMIT / PUSH | **NOT PERFORMED / NOT PERFORMED** |
STOP перед следующим этапом. RF-испытание требует отдельной постановки и
разрешения; VIDEO-006 не начата.

View File

@@ -252,6 +252,7 @@ def matrix_inverse(
for value, pivot_value in zip(
augmented[row_index],
augmented[pivot_column],
strict=True,
)
]
@@ -305,7 +306,7 @@ def _linear_combine(
"""Combine equal-size byte strings over GF(256), using C-level helpers."""
accumulator = 0
for coefficient, symbol in zip(coefficients, symbols):
for coefficient, symbol in zip(coefficients, symbols, strict=True):
if coefficient == 0:
continue
translated = symbol.translate(

View File

@@ -10,7 +10,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from collections import deque
import struct
from typing import Sequence
from typing import Sequence, cast
from protocol.packet_erasure_fec import (
InsufficientSymbolsError, _linear_combine, encode_parity_symbols,
@@ -212,7 +212,7 @@ class VideoErasureCollector:
block.wires[symbol.index] = wire
output = []
if not symbol.is_parity and symbol.index not in block.delivered:
item = direct_item
item = cast(Delivery, direct_item)
block.delivered.add(symbol.index)
if now_ms - item.capture_ms < self.max_age_ms:
output.append(item)

View File

@@ -6,6 +6,8 @@
from __future__ import annotations
from typing import Any, Callable, cast
import numpy as np
from protocol import convolutional_fec, punctured_fec
@@ -54,8 +56,12 @@ def _trellis(soft_values: np.ndarray, information_bit_count: int,
return decoded[:information_bit_count]
COMPILED_TRELLIS = (njit(cache=False, nogil=True, fastmath=False)(_trellis)
if njit is not None else None)
_compiler = cast(Any, njit) # У Numba нет точной типизации фабрики декоратора.
COMPILED_TRELLIS: Callable[[np.ndarray, int, np.ndarray, np.ndarray], np.ndarray] | None = (
cast(Callable[[np.ndarray, int, np.ndarray, np.ndarray], np.ndarray],
_compiler(cache=False, nogil=True, fastmath=False)(_trellis))
if njit is not None else None
)
def decode_soft(soft_values: np.ndarray, information_bit_count: int,

View File

@@ -0,0 +1,68 @@
"""Оценка несущей по преамбуле и слежение за фазой QPSK-пакета."""
from __future__ import annotations
from dataclasses import dataclass
import math
import numpy as np
from protocol.video003_modem import (
PHY_HEADER_SYMBOLS,
PREAMBLE_SYMBOLS,
_estimate_phase_frequency,
bpsk_symbols,
known_preamble_bits,
)
BODY_START = PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS
TRACK_BLOCK_SYMBOLS = 64
@dataclass(frozen=True)
class PilotEstimate:
phase_rad: float
frequency_hz: float
phase_step_rad: float
quadrant: int
def estimate_from_preamble(symbols: np.ndarray, symbol_rate: int) -> PilotEstimate:
"""Оценивает частоту и абсолютную фазу, включая поворот на 90°/180°."""
if symbols.ndim != 1 or len(symbols) < PREAMBLE_SYMBOLS:
raise ValueError("недостаточно символов известной преамбулы")
known = bpsk_symbols(known_preamble_bits())
phase, phase_step = _estimate_phase_frequency(symbols[:PREAMBLE_SYMBOLS], known)
return PilotEstimate(
phase,
phase_step * symbol_rate / (2 * math.pi),
phase_step,
round(phase / (math.pi / 2)) % 4,
)
def correct_pilot_estimate(symbols: np.ndarray, estimate: PilotEstimate) -> np.ndarray:
"""Убирает оценённые фазу и CFO из всего найденного пакета."""
indexes = np.arange(len(symbols), dtype=np.float64)
correction = np.exp(-1j * (estimate.phase_rad + estimate.phase_step_rad * indexes))
return symbols * correction.astype(np.complex64)
def track_qpsk_body(symbols: np.ndarray) -> tuple[np.ndarray, float]:
"""Причинное слежение: решение строится из текущего QPSK, без эталонных битов."""
corrected = symbols.copy()
phase_state = 0.0
errors: list[float] = []
for start in range(BODY_START, len(symbols), TRACK_BLOCK_SYMBOLS):
stop = min(start + TRACK_BLOCK_SYMBOLS, len(symbols))
block = symbols[start:stop] * np.exp(-1j * phase_state)
decisions = (np.where(block.real >= 0, 1.0, -1.0)
+ 1j * np.where(block.imag >= 0, 1.0, -1.0)).astype(np.complex64)
error = float(np.angle(np.sum(block * np.conj(decisions))))
error = float(np.clip(error, -0.2, 0.2))
phase_state += 0.35 * error
corrected[start:stop] = symbols[start:stop] * np.exp(-1j * phase_state)
errors.append(error)
root_mean_square = math.sqrt(sum(error * error for error in errors) / len(errors)) if errors else 0.0
return corrected, root_mean_square

View File

@@ -0,0 +1,60 @@
"""Формирование комплексного IQ для программного видеотракта VIDEO-005."""
from __future__ import annotations
from functools import lru_cache
from typing import Sequence
import numpy as np
from scipy.signal import upfirdn
from protocol.bpsk_radio import root_raised_cosine_taps
from protocol.video003_modem import (
RRC_ROLLOFF,
RRC_SPAN_SYMBOLS,
SAMPLE_RATE_HZ,
OfflineFrame,
samples_per_symbol,
)
SYMBOL_RATE = 500_000
NOMINAL_CARRIER_HZ = 125_000.0
GUARD_SAMPLES = 64
@lru_cache(maxsize=2)
def rrc_taps(symbol_rate: int = SYMBOL_RATE) -> np.ndarray:
"""Возвращает неизменяемые коэффициенты уже применяемого RRC-фильтра."""
taps = root_raised_cosine_taps(
RRC_ROLLOFF, samples_per_symbol(symbol_rate), RRC_SPAN_SYMBOLS
)
taps.flags.writeable = False
return taps
def shape_packet(
frame: OfflineFrame,
symbol_rate: int = SYMBOL_RATE,
carrier_hz: float = NOMINAL_CARRIER_HZ,
) -> np.ndarray:
"""Формирует один IQ-пакет с защитными паузами, без канальных нарушений."""
if not np.isfinite(carrier_hz) or abs(carrier_hz) >= SAMPLE_RATE_HZ / 2:
raise ValueError("несущая должна лежать внутри полосы дискретизации")
if frame.modulation != "QPSK" or frame.rate != "2/3":
raise ValueError("VIDEO-005 использует QPSK и внутренний FEC 2/3")
shaped = upfirdn(rrc_taps(symbol_rate), frame.symbols, up=samples_per_symbol(symbol_rate))
envelope = np.pad(shaped, (GUARD_SAMPLES, GUARD_SAMPLES))
phase = 2 * np.pi * carrier_hz * np.arange(envelope.size) / SAMPLE_RATE_HZ
return (envelope * np.exp(1j * phase)).astype(np.complex64)
def shape_stream(
frames: Sequence[OfflineFrame],
symbol_rate: int = SYMBOL_RATE,
carrier_hz: float = NOMINAL_CARRIER_HZ,
) -> np.ndarray:
"""Соединяет IQ-пакеты; приёмнику их границы не передаются."""
if not frames:
raise ValueError("пустой поток")
return np.concatenate(tuple(shape_packet(frame, symbol_rate, carrier_hz) for frame in frames))

View File

@@ -0,0 +1,154 @@
"""Строгий программный приём QPSK из непрерывного комплексного IQ."""
from __future__ import annotations
from dataclasses import dataclass
import math
import time
import numpy as np
from scipy.signal import oaconvolve
from protocol import punctured_fec
from protocol.video003_modem import (
MAX_FULL_PACKET_BYTES,
PHY_HEADER_SYMBOLS,
PREAMBLE_SYMBOLS,
SAMPLE_RATE_HZ,
_decode_phy_header,
receive_frame,
samples_per_symbol,
)
from protocol.video005_carrier import (
correct_pilot_estimate,
estimate_from_preamble,
track_qpsk_body,
)
from protocol.video005_iq_waveform import NOMINAL_CARRIER_HZ, SYMBOL_RATE, rrc_taps
from protocol.video005_timing import refine_timing, sample_symbols, search_preamble_grid
@dataclass(frozen=True)
class ReceivedIqPacket:
packet: bytes
payload: bytes
start_sample: float
timing_phase_symbols: float
carrier_offset_hz: float
initial_phase_rad: float
quadrant: int
preamble_score: float
tracking_error_rms_rad: float
@dataclass(frozen=True)
class IqStreamResult:
packets: tuple[ReceivedIqPacket, ...]
candidate_count: int
rejected_candidates: int
stage_time_s: dict[str, float]
def receive_iq_stream(
iq: np.ndarray,
*,
symbol_rate: int = SYMBOL_RATE,
carrier_hz: float = NOMINAL_CARRIER_HZ,
decoder_backend: str = "numba",
) -> IqStreamResult:
"""Получает только IQ и параметры радиорежима, без подсказок передатчика.
Внешние метаданные сигнала и ожидаемое число пакетов не являются аргументами.
Возвращаются только пакеты, прошедшие PHY/FEC/CRC.
"""
samples_per_symbol_count = samples_per_symbol(symbol_rate)
if not math.isfinite(carrier_hz) or abs(carrier_hz) >= SAMPLE_RATE_HZ / 2:
raise ValueError("неверная номинальная несущая")
if decoder_backend == "numba":
from protocol.video004_viterbi import decode_soft as fec_decoder
elif decoder_backend == "legacy":
fec_decoder = punctured_fec.decode_soft
else:
raise ValueError("неизвестный выбранный декодер")
samples = np.asarray(iq)
if samples.ndim != 1 or not np.iscomplexobj(samples):
raise ValueError("приёмнику нужен одномерный комплексный IQ")
if not np.all(np.isfinite(samples)):
raise ValueError("IQ содержит неконечные значения")
stage: dict[str, float] = {}
if samples.size < PREAMBLE_SYMBOLS * samples_per_symbol_count:
return IqStreamResult((), 0, 0, stage)
started = time.perf_counter()
period = round(SAMPLE_RATE_HZ / abs(carrier_hz)) if carrier_hz else 0
if carrier_hz and abs(abs(carrier_hz) * period - SAMPLE_RATE_HZ) < 1e-8:
cycle = np.exp(-1j * np.sign(carrier_hz) * 2 * np.pi
* np.arange(period) / period).astype(np.complex64)
oscillator = np.resize(cycle, samples.size)
baseband = samples.astype(np.complex64, copy=False) * oscillator
else:
indexes = np.arange(samples.size, dtype=np.float64)
oscillator = np.exp(-1j * (2 * np.pi * carrier_hz / SAMPLE_RATE_HZ) * indexes)
baseband = samples * oscillator
stage["digital_downconversion"] = time.perf_counter() - started
started = time.perf_counter()
matched = oaconvolve(baseband, rrc_taps(symbol_rate).astype(np.float32), mode="full")
stage["rrc"] = time.perf_counter() - started
started = time.perf_counter()
candidates = search_preamble_grid(matched, samples_per_symbol_count)
stage["preamble_search"] = time.perf_counter() - started
maximum_symbols = (
PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS
+ math.ceil(punctured_fec.coded_bit_count(MAX_FULL_PACKET_BYTES * 8, "2/3") / 2)
)
stage.update(timing_recovery=0.0, carrier_estimation=0.0, phy_header=0.0,
carrier_tracking=0.0, strict_phy_fec_crc=0.0)
accepted: list[ReceivedIqPacket] = []
rejected = 0
for candidate in candidates:
started = time.perf_counter()
refined = refine_timing(matched, candidate, samples_per_symbol_count)
symbols = sample_symbols(matched, refined.start_sample,
samples_per_symbol_count, maximum_symbols)
stage["timing_recovery"] += time.perf_counter() - started
if symbols.size < PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS:
rejected += 1
continue
started = time.perf_counter()
estimate = estimate_from_preamble(symbols, symbol_rate)
corrected = correct_pilot_estimate(symbols, estimate)
stage["carrier_estimation"] += time.perf_counter() - started
started = time.perf_counter()
packet_bytes = _decode_phy_header(
corrected[PREAMBLE_SYMBOLS:PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS].real
)
stage["phy_header"] += time.perf_counter() - started
if packet_bytes is None:
rejected += 1
continue
frame_symbols = (
PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS
+ math.ceil(punctured_fec.coded_bit_count(packet_bytes * 8, "2/3") / 2)
)
corrected = corrected[:frame_symbols]
started = time.perf_counter()
tracked, error_rms = track_qpsk_body(corrected)
stage["carrier_tracking"] += time.perf_counter() - started
started = time.perf_counter()
reception = receive_frame(
tracked, "QPSK", "2/3", symbol_rate,
maximum_start=0, fec_decoder=fec_decoder,
)
stage["strict_phy_fec_crc"] += time.perf_counter() - started
if not reception.crc_pass or reception.packet is None or reception.payload is None:
rejected += 1
continue
accepted.append(ReceivedIqPacket(
reception.packet, reception.payload, refined.start_sample,
(refined.start_sample % samples_per_symbol_count) / samples_per_symbol_count,
estimate.frequency_hz, estimate.phase_rad, estimate.quadrant,
refined.score, error_rms,
))
return IqStreamResult(tuple(accepted), len(candidates), rejected, stage)

106
protocol/video005_timing.py Normal file
View File

@@ -0,0 +1,106 @@
"""Поиск преамбулы и восстановление дробного момента выборки из IQ."""
from __future__ import annotations
from dataclasses import dataclass
import math
import numpy as np
from scipy.signal import fftconvolve, find_peaks
from protocol.video003_modem import PREAMBLE_SYMBOLS, bpsk_symbols, known_preamble_bits
@dataclass(frozen=True)
class PreambleCandidate:
start_sample: float
score: float
def search_preamble_grid(
matched: np.ndarray,
samples_per_symbol: int,
minimum_score: float = 0.5,
) -> tuple[PreambleCandidate, ...]:
"""Ищет пилот во всём сигнале, не получая границ кадров от передатчика."""
if matched.ndim != 1 or samples_per_symbol < 2 or not 0 < minimum_score < 1:
raise ValueError("неверные параметры поиска преамбулы")
known = bpsk_symbols(known_preamble_bits())
all_peaks: list[PreambleCandidate] = []
# Две тактовые фазы дают грубый поиск; дробный пик уточняется отдельно.
phase_step = max(1, samples_per_symbol // 2)
for phase in range(0, samples_per_symbol, phase_step):
series = matched[phase::samples_per_symbol]
if series.size < PREAMBLE_SYMBOLS:
continue
correlation = fftconvolve(series, known[::-1].conj(), mode="valid")
power = np.abs(series) ** 2
running = np.concatenate(([0.0], np.cumsum(power, dtype=np.float64)))
window_power = running[PREAMBLE_SYMBOLS:] - running[:-PREAMBLE_SYMBOLS]
score = np.abs(correlation) / np.sqrt(
np.maximum(window_power * PREAMBLE_SYMBOLS, 1e-18)
)
peaks, _ = find_peaks(
score, height=minimum_score, distance=PREAMBLE_SYMBOLS // 2
)
for index in peaks:
all_peaks.append(PreambleCandidate(float(phase + index * samples_per_symbol),
float(score[index])))
all_peaks.sort(key=lambda item: item.start_sample)
merged: list[PreambleCandidate] = []
for candidate in all_peaks:
if merged and candidate.start_sample - merged[-1].start_sample < 2 * samples_per_symbol:
if candidate.score > merged[-1].score:
merged[-1] = candidate
else:
merged.append(candidate)
return tuple(merged)
def refine_timing(
matched: np.ndarray,
candidate: PreambleCandidate,
samples_per_symbol: int,
) -> PreambleCandidate:
"""Уточняет пик корреляции до доли отсчёта по известной преамбуле."""
known = bpsk_symbols(known_preamble_bits())
center = int(candidate.start_sample)
half_phase_step = max(2, samples_per_symbol // 4)
if (center < half_phase_step + 1
or center + (PREAMBLE_SYMBOLS - 1) * samples_per_symbol
+ half_phase_step + 1 >= matched.size):
return candidate
indexes = center + np.arange(PREAMBLE_SYMBOLS) * samples_per_symbol
shifts = range(-half_phase_step - 1, half_phase_step + 2)
scores: list[float] = [float(abs(np.vdot(known, matched[indexes + shift])))
for shift in shifts]
best = int(np.argmax(scores))
if best in (0, len(scores) - 1):
fractional = 0.0
else:
curvature = scores[best - 1] - 2 * scores[best] + scores[best + 1]
fractional = (0.0 if curvature >= -1e-12 else
0.5 * (scores[best - 1] - scores[best + 1]) / curvature)
fractional = float(np.clip(fractional, -0.5, 0.5))
return PreambleCandidate(candidate.start_sample + shifts[best] + fractional,
candidate.score)
def sample_symbols(
matched: np.ndarray,
start_sample: float,
samples_per_symbol: int,
maximum_symbols: int,
) -> np.ndarray:
"""Интерполирует найденные такты без входного oracle timing."""
if not math.isfinite(start_sample) or start_sample < 0 or maximum_symbols < 1:
raise ValueError("неверные границы выборки символов")
available = int((matched.size - 2 - start_sample) // samples_per_symbol) + 1
count = min(maximum_symbols, max(0, available))
if count == 0:
return np.empty(0, dtype=np.complex64)
positions = start_sample + np.arange(count, dtype=np.float64) * samples_per_symbol
lower = np.floor(positions).astype(np.int64)
fraction = positions - lower
return (matched[lower] * (1 - fraction)
+ matched[lower + 1] * fraction).astype(np.complex64)

28
pyrightconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"include": [
"protocol/packet.py",
"protocol/video_packet.py",
"protocol/bpsk_radio.py",
"protocol/convolutional_fec.py",
"protocol/packet_erasure_fec.py",
"protocol/punctured_fec.py",
"protocol/video003_compact_header.py",
"protocol/video003_modem.py",
"protocol/video004_erasure.py",
"protocol/video004_viterbi.py",
"protocol/video_stream_transport.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"
],
"typeCheckingMode": "basic",
"pythonVersion": "3.13",
"reportMissingImports": "error"
}

View File

@@ -0,0 +1,2 @@
ruff==0.16.8
pyright==1.1.414

5
ruff.toml Normal file
View File

@@ -0,0 +1,5 @@
target-version = "py313"
line-length = 100
[lint]
select = ["E4", "E7", "E9", "F", "B", "C4", "SIM"]

114
tests/test_video005_iq.py Normal file
View File

@@ -0,0 +1,114 @@
"""VIDEO-005: приём из IQ без границ, фазовых и битовых подсказок."""
from __future__ import annotations
import inspect
import math
import numpy as np
import pytest
from protocol.video003_modem import build_frame
from protocol.video005_carrier import BODY_START, track_qpsk_body
from protocol.video005_iq_waveform import SAMPLE_RATE_HZ, shape_packet, shape_stream
from protocol.video005_receiver import receive_iq_stream
from tools.video005_channel import impair
@pytest.fixture(scope="module")
def packet_fixture() -> tuple:
frame = build_frame(bytes((index * 37) % 256 for index in range(1012)),
"QPSK", "2/3", sequence_number=7)
return frame, shape_packet(frame)
def _exact(iq: np.ndarray, expected: bytes) -> bool:
received = receive_iq_stream(iq, decoder_backend="legacy")
return len(received.packets) == 1 and received.packets[0].packet == expected
def test_clean_raw_iq_is_exact(packet_fixture: tuple) -> None:
frame, iq = packet_fixture
result = receive_iq_stream(iq, decoder_backend="legacy")
assert len(result.packets) == 1
assert result.packets[0].packet == frame.packet
assert result.packets[0].payload == bytes((index * 37) % 256 for index in range(1012))
assert iq.size / SAMPLE_RATE_HZ > 0
@pytest.mark.parametrize("offset", [0, 0.1, -0.1, 0.25, -0.25, 0.4, -0.4])
def test_fractional_timing_is_recovered(packet_fixture: tuple, offset: float) -> None:
frame, iq = packet_fixture
baseline = receive_iq_stream(iq, decoder_backend="legacy").packets[0].start_sample
result = receive_iq_stream(impair(iq, timing_offset_symbols=offset),
decoder_backend="legacy")
assert len(result.packets) == 1
assert result.packets[0].packet == frame.packet
assert abs(result.packets[0].start_sample - baseline - offset * 16) < 0.05
@pytest.mark.parametrize("offset_hz", [0, 1, -1, 5, -5, 20, -20, 100, -100])
def test_carrier_frequency_is_recovered(packet_fixture: tuple, offset_hz: int) -> None:
frame, iq = packet_fixture
result = receive_iq_stream(impair(iq, cfo_hz=offset_hz), decoder_backend="legacy")
assert len(result.packets) == 1
assert result.packets[0].packet == frame.packet
assert abs(result.packets[0].carrier_offset_hz - offset_hz) < 0.5
@pytest.mark.parametrize("phase_degrees", [0, 15, -15, 45, -45, 90, 180])
def test_preamble_resolves_phase_and_quadrant(packet_fixture: tuple,
phase_degrees: int) -> None:
frame, iq = packet_fixture
result = receive_iq_stream(impair(iq, phase_degrees=phase_degrees),
decoder_backend="legacy")
assert len(result.packets) == 1
assert result.packets[0].packet == frame.packet
error = math.remainder(result.packets[0].initial_phase_rad
- math.radians(phase_degrees), 2 * math.pi)
assert abs(error) < 0.02
if phase_degrees in (90, 180):
assert result.packets[0].quadrant == phase_degrees // 90
@pytest.mark.parametrize("ebn0_db", [3, 6, 9])
def test_awgn_never_yields_wrong_crc_packet(packet_fixture: tuple, ebn0_db: int) -> None:
frame, iq = packet_fixture
result = receive_iq_stream(impair(iq, ebn0_db=ebn0_db), decoder_backend="legacy")
assert all(packet.packet == frame.packet for packet in result.packets)
if ebn0_db == 9:
assert len(result.packets) == 1
@pytest.mark.parametrize("settings", [
{"timing_offset_symbols": 0.4, "cfo_hz": 100,
"phase_degrees": 90, "ebn0_db": 9},
{"timing_offset_symbols": -0.25, "cfo_hz": -20,
"phase_degrees": -45, "ebn0_db": 9},
])
def test_combined_offsets_in_continuous_stream(settings: dict) -> None:
frames = [build_frame(bytes(((index + packet_index) * 37) % 256
for index in range(1012)), "QPSK", "2/3",
sequence_number=packet_index)
for packet_index in range(3)]
iq = impair(shape_stream(frames), **settings)
result = receive_iq_stream(iq, decoder_backend="legacy")
assert [packet.packet for packet in result.packets] == [frame.packet for frame in frames]
def test_qpsk_tracker_reduces_phase_wander_without_truth_input(packet_fixture: tuple) -> None:
frame, _ = packet_fixture
impaired = frame.symbols.copy()
body_count = len(impaired) - BODY_START
impaired[BODY_START:] *= np.exp(1j * np.linspace(0, 0.35, body_count))
tracked, _ = track_qpsk_body(impaired)
before = np.mean(np.abs(np.angle(impaired[BODY_START:] / frame.symbols[BODY_START:])))
after = np.mean(np.abs(np.angle(tracked[BODY_START:] / frame.symbols[BODY_START:])))
assert after < before / 2
def test_receiver_api_has_no_oracle_parameters() -> None:
names = set(inspect.signature(receive_iq_stream).parameters)
assert names == {"iq", "symbol_rate", "carrier_hz", "decoder_backend"}
with pytest.raises(ValueError, match="комплексный IQ"):
receive_iq_stream(np.zeros(100_000), decoder_backend="legacy")

View File

@@ -0,0 +1,45 @@
"""Небольшой H.264-контроль IQ → CRC → внешний FEC → показ кадров."""
from __future__ import annotations
import numpy as np
import pytest
from tools.video005_video import VideoFixture, build_fixture, run_case
@pytest.fixture(scope="module")
def synthetic_video() -> VideoFixture:
rows, columns = np.mgrid[0:360, 0:640]
frames = [np.stack(((columns + index * 17) % 256,
(rows + index * 11) % 256,
(columns // 2 + rows // 3 + index * 29) % 256), axis=-1
).astype(np.uint8)
for index in range(3)]
return build_fixture(3, pixels_override=frames)
def test_clean_h264_from_raw_iq_displays_three_unique_frames(
synthetic_video: VideoFixture,
) -> None:
result = run_case(synthetic_video, "clean", decoder_backend="legacy")
video = result["video"]
assert result["exact_crc_packets"]
assert result["crc_valid_radio_packets"] == len(synthetic_video.transmissions)
assert video["displayed_frame_ids"] == [0, 1, 2]
assert video["displayed_fps"] == 10
assert video["max_frame_age_ms"] < 300
assert video["wrong_encoded_frames"] == video["wrong_decoded_frames"] == 0
assert not video["old_frame_replay"]
def test_corrupted_iq_packet_is_erased_before_h264(
synthetic_video: VideoFixture,
) -> None:
result = run_case(synthetic_video, "corrupt_packet", decoder_backend="legacy")
video = result["video"]
assert result["exact_crc_packets"]
assert result["crc_valid_radio_packets"] == len(synthetic_video.transmissions) - 1
assert video["recovered_fragments"] == 1
assert video["displayed_frame_ids"] == [0, 1, 2]
assert video["wrong_encoded_frames"] == video["wrong_decoded_frames"] == 0

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,8 +174,8 @@ 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"]:
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"
@@ -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__":