VIDEO-005: add full offline QPSK IQ video receiver
This commit is contained in:
1136
data/processed/video005/benchmark.json
Normal file
1136
data/processed/video005/benchmark.json
Normal file
File diff suppressed because it is too large
Load Diff
187
docs/video/VIDEO_005.md
Normal file
187
docs/video/VIDEO_005.md
Normal 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 не начата.
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
68
protocol/video005_carrier.py
Normal file
68
protocol/video005_carrier.py
Normal 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
|
||||
60
protocol/video005_iq_waveform.py
Normal file
60
protocol/video005_iq_waveform.py
Normal 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))
|
||||
154
protocol/video005_receiver.py
Normal file
154
protocol/video005_receiver.py
Normal 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
106
protocol/video005_timing.py
Normal 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
28
pyrightconfig.json
Normal 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"
|
||||
}
|
||||
2
requirements-video-dev.txt
Normal file
2
requirements-video-dev.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
ruff==0.16.8
|
||||
pyright==1.1.414
|
||||
5
ruff.toml
Normal file
5
ruff.toml
Normal 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
114
tests/test_video005_iq.py
Normal 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")
|
||||
45
tests/test_video005_video.py
Normal file
45
tests/test_video005_video.py
Normal 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
179
tools/video005_benchmark.py
Normal 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
109
tools/video005_channel.py
Normal 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
354
tools/video005_video.py
Normal 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,
|
||||
}
|
||||
@@ -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