From cdf5396e807b83f82a264632dc46f71428fcd616 Mon Sep 17 00:00:00 2001 From: LittleSam129 <1qwe432@gmail.com> Date: Tue, 8 Sep 2026 13:26:06 +0300 Subject: [PATCH] Lab049: define ELRS control and SDR video architecture --- PROJECT_LOG.md | 274 ++++++ README.md | 8 + docs/LABS_INDEX.md | 40 + docs/lab049_audit.md | 86 ++ docs/lab049_crsf_elrs_architecture.md | 450 +++++++++ docs/lab049_current_state.md | 219 +++++ experiments/lab049_bpsk_symbol_rate.py | 1172 ++++++++++++++++++++++++ experiments/lab049_crsf_elrs_budget.py | 304 ++++++ experiments/lab049_independent_iq.py | 190 ++++ tests/test_lab049_bpsk_symbol_rate.py | 211 +++++ tests/test_lab049_crsf_elrs_budget.py | 119 +++ tests/test_lab049_independent_iq.py | 66 ++ 12 files changed, 3139 insertions(+) create mode 100644 docs/lab049_audit.md create mode 100644 docs/lab049_crsf_elrs_architecture.md create mode 100644 docs/lab049_current_state.md create mode 100644 experiments/lab049_bpsk_symbol_rate.py create mode 100644 experiments/lab049_crsf_elrs_budget.py create mode 100644 experiments/lab049_independent_iq.py create mode 100644 tests/test_lab049_bpsk_symbol_rate.py create mode 100644 tests/test_lab049_crsf_elrs_budget.py create mode 100644 tests/test_lab049_independent_iq.py diff --git a/PROJECT_LOG.md b/PROJECT_LOG.md index d8095ba..e6c9b2f 100644 --- a/PROJECT_LOG.md +++ b/PROJECT_LOG.md @@ -1367,3 +1367,277 @@ passed и 0 failed; `git diff --check` прошёл. STOP. Не переходить к Lab049, видео, QPSK, повышению символьной скорости или новому RF-сеансу без отдельной постановки. `RF TX = 0`; HackRF не запускать. + +# Запись 036 + +## Дата + +3 сентября 2026 года + +## Тема + +Lab049: программное повышение символьной скорости BPSK с сохранением +приоритета управления. + +## Выполнено + +До изменения кода проверены Lab047--Lab048, зависимости PHY и решения +Lab033--Lab041. Подтверждено, что новый планировщик не нужен: +`protocol/priority_scheduler.py` уже задаёт непрерываемое обслуживание с +порядком `EMERGENCY > CONTROL > TELEMETRY > VIDEO`, а `LATEST_STATE` заменяет +устаревшие ожидающие состояния CONTROL и TELEMETRY. Политики Lab034--Lab036 +сохраняют целостность видеокадров и допускают служебный трафик между пакетами. + +Реализован отдельный программный параметрический адаптер Lab049, не меняющий +доказанный аппаратный тракт Lab047. При `Fs = 8 Мвыб/с` проверены 20, 50, 100 +и дополнительный режим 200 ксимв/с, то есть ровно 400, 160, 80 и 40 +выборок/символ. Режим 20 ксимв/с побайтно воспроизводит прежнюю CS8-форму +Lab045. BPSK, PRBS7, PHY-заголовок, свёрточный FEC `R=1/2, K=7, 171/133 +octal`, перемежитель, CRC, packet format и fragment format не изменялись. + +Во всех режимах использован сохранённый JPEG Lab047: 8724 байта, RGB +320 x 240, SHA-256 +`e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a`, +36 физических кадров. Каждый режим прошёл полный frame-aligned IQ-тракт от +фрагментации и BPSK/RRC до независимого восстановления PHY-длины, FEC, CRC и +сборки объекта. Приёмник кадра не получает TX bits, payload, packet или +ожидаемое число кадров; диагностическое сравнение выполняется после CRC. + +До полного прогона для всех скоростей одинаково зафиксирован шум +`coded-bit Eb/N0 = 7,0 dB`, семена `49000 + frame_index`. Sigma вычисляется +из фактической энергии кодированного BPSK-бита, поэтому корректно меняется с +samples/symbol и не подбирается по результату режима. + +Идеальный и шумный каналы восстановили 36/36 кадров и точный JPEG во всех +четырёх режимах. В шумном канале получено 129/114/107/110 ошибок кодированных +бит до FEC для 20/50/100/200 ксимв/с; после FEC во всех режимах 0 ошибок, +CRC PASS/FAIL `36/0`, ошибочных байтов и битов JPEG `0/0`. + +Длительность одного физического кадра равна 288,30/115,32/57,66/28,83 мс, +полного JPEG — 10,37880/4,15152/2,07576/1,03788 с. Полезная скорость объекта +равна 6,724/16,811/33,622/67,245 кбит/с: фактический выигрыш 1,0/2,5/5,0/10,0 +раза, рассчитанный по неизменному кадру. + +Шумный приём занял 7,14440/3,15468/1,92894/1,30565 с; отношение обработки к +длительности сигнала 0,68836/0,75989/0,92927/1,25799. Режимы 20, 50 и 100 +ксимв/с успевают в реальном времени, 200 ксимв/с — нет. Поэтому 200 ксимв/с +является устойчиво декодируемым, но только частичным результатом. + +Проверка существующего `LATEST_STATE` на постоянно заполненной VIDEO-очереди, +CONTROL каждые 50 мс, TELEMETRY каждые 100 мс и одной EMERGENCY подтвердила +порядок `VIDEO(active) -> EMERGENCY -> CONTROL -> TELEMETRY -> VIDEO`. +Начатый кадр не прерывается. Верхняя граница блокировки новой CONTROL или +EMERGENCY равна длительности кадра: 288,30/115,32/57,66/28,83 мс. + +Для нагрузки планировщика каждое сообщение занимает целый неизменный +физический кадр Lab045. Прикладные CONTROL и EMERGENCY не дополняются внутри +`LinkPacket`: сохранены точные 36- и 34-байтные сообщения, проверено их +обратное декодирование. При CONTROL 20 сообщений/с и TELEMETRY 10 сообщений/с +требуется 30 физических кадров/с. Ёмкость режимов равна +3,469/8,672/17,343/34,686 кадра/с, поэтому остаток полезной скорости VIDEO +равен 0/0/0/9,085 кбит/с, или 0/0/0/0,1302 JPEG в секунду. Это программная +модель планирования, а не реализованная упаковка `LinkPacket` в физический +кадр. + +Штатный `LATEST_STATE` заменяет ожидающее старое состояние, но сам по себе не +удаляет пакет по истечении `deadline_ms`. В нагрузочном сценарии при 50 +ксимв/с один CONTROL начал передаваться через 100,760 мс при сроке 100 мс; +при 20 ксимв/с EMERGENCY ожидала 87,900 мс при сроке 50 мс. Конкретное +событие при 100 ксимв/с ожидало 30,240 мс, но худшая фаза 57,660 мс всё равно +нарушает 50-миллисекундный срок EMERGENCY. + +Статусы всех режимов — `PARTIAL`. 20 и 50 ксимв/с нарушают требования по +блокировке и не оставляют VIDEO; 100 ксимв/с укладывает CONTROL в 100 мс, но +нарушает срок EMERGENCY и также не оставляет VIDEO; 200 ксимв/с исправляет +эти ограничения, но приёмник не успевает в реальном времени с отношением +1,25799. Для следующего отдельного аппаратного опыта только с PHY выбран +диагностический кандидат 100 ксимв/с и 80 выборок/символ. Для объединённой +работы он не рекомендован: `recommended_for_integrated_operation = false`, +полностью пригодного режима не найдено. + +Созданы `experiments/lab049_bpsk_symbol_rate.py`, +`tests/test_lab049_bpsk_symbol_rate.py`, `docs/lab049_current_state.md` и +`data/processed/lab049/result.json`. Новый RF не выполнялся, `RF TX = 0`, +HackRF не запускался. + +Целевые проверки Lab049 дали 17 passed. `python tools/quick_gate.py` прошёл: +564 теста ядра и 12 функциональных проверок. `python -m pytest tests/ -q` дал +564 passed и 0 failed; единственное предупреждение относится к запрету записи +в служебный `.pytest_cache`. `git diff --check` прошёл. + +## Следующий этап + +STOP. До объединённого режима отдельно решить просроченные команды, упаковку +служебных сообщений в физический кадр и перегрузку бюджета. Не выполнять +аппаратный Lab049, QPSK, реальное видео, новый RF или переход на 180 МГц без +отдельной постановки и явной авторизации. + +# Запись 037 + +## Дата + +8 сентября 2026 года. + +## Тема + +Lab049: независимый IQ-приём и исправление расчёта полной доставки команд. + +## Выполнено + +Проверен HEAD ac6cc792b896e34f7dd6d291955a0387fdafcf41 и обнаружен уже +существующий незакоммиченный вариант Lab049. Запись 036 сохранена. +Независимые verifier/adversary воспроизвели отказ исходного приёмника при +неизвестном начале +240 выборок и CFO +10 Гц на 100 ксимв/с, а также ошибочный +расчёт аварийного срока по началу вместо конца передачи. + +Основной IQ-проход использует блочный поиск и строгий декодер Lab048, +lab046c_offline_recovery.stream_process_capture. Адаптер читает синтетический +complex64 без отсечения шума, временно задаёт Rs/SPS и возвращает константы +и функции через finally. TX offsets, объект и число кадров в RX не передаются. +Порог Lab047 0.030964473262429237, PRBS7, BPSK, PHY, FEC, CRC и форматы сохранены. +Дробные скорости и нецелое samples/symbol отвергаются без округления. + +При Fs=8 Мвыб/с проверен тот же JPEG 8724 байта, RGB 320×240, +SHA e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a, +в режимах 20/50/100/200 ксимв/с. Идеальный канал восстановил 36/36 и точный JPEG +во всех режимах. Шум един: coded-bit Eb/N0=7 dB, фиксированные seeds, +остаточный CFO +10 Гц, фаза 0,73 рад и чередование полярности. + +В шуме 20 ксимв/с восстановил только 6/36 кадров, JPEG не собран — FAIL. +50/100/200 восстановили 36/36, CRC PASS/FAIL 36/0, SHA match, ошибки JPEG 0/0. +До FEC на принятых кадрах 67/147/119/125 ошибок, после FEC — 0; первая пара +относится только к шести кадрам. Все восемь проходов дошли до конца IQ. + +Полная обработка шумного IQ заняла 173,65057 / 5,72980 / 7,76244 / 8,17573 с, +при длительности IQ 10,458018 / 4,230738 / 2,154978 / 1,117098 с. +Отношения 16,60454 / 1,35433 / 3,60210 / 7,31872: ни один режим не успевает +в реальном времени. Меньшие времена записи 036 описывали заранее выровненные +кадры и не переносятся на полный независимый тракт. 50/100/200 — PARTIAL. + +Переиспользован LATEST_STATE и сохранены форматы CONTROL/EMERGENCY. +Отдельно считаются ожидание начала, полная доставка, активное окно 2 с +и последующее опустошение очереди. На 100 ксимв/с максимум доставки CONTROL +105,64 мс и EMERGENCY 87,90 мс; превышений сроков соответственно 6 и 1. +На 20/50/100 внутри активного окна не доставлен ни один TELEMETRY. +На 200 худшая доставка EMERGENCY равна почти 57,66 мс, а не 28,83 мс: +к блокировке добавляется передача самой команды. Срок 50 мс нарушается. + +В модели одного полного PHY-кадра на пакет CONTROL 20 Гц и TELEMETRY 10 Гц +перегружают 20/50/100 ксимв/с. На 200 остаются 9,085 кбит/с VIDEO, около +0,1302 JPEG/с; с одной EMERGENCY за 2 с — 8,116 кбит/с. Аппаратная упаковка +служебных LinkPacket в PHY не реализована и этой моделью не доказывается. + +Для отдельного будущего диагностического PHY-опыта выбран 50 ксимв/с: +наименьшая вычислительная перегрузка среди точных шумных проходов. Это не +пригодный объединённый канал и не разрешение RF. Старый JSON сохранён как +data/processed/lab049/frame_aligned_result.json; актуальный result.json +и docs/lab049_current_state.md содержат отрицательные результаты. +Добавлены docs/lab049_audit.md, experiments/lab049_independent_iq.py и +семь регрессий tests/test_lab049_independent_iq.py. Старые тесты не изменялись. +Обновлён docs/LABS_INDEX.md. Модули protocol не изменялись. + +Verifier: 24 целевых теста PASS, независимый пересчёт JSON совпал. +Adversary подтвердил корректный отрицательный аварийный вердикт. +quick_gate PASS: 571 тест ядра, 12 функциональных проверок. +pytest: 571 passed, 0 failed; одно предупреждение служебного .pytest_cache. +git diff --check PASS. RF TX=0. Git add/commit/push/pull/fetch не выполнялись. + +Следующий этап: STOP; отдельная постановка для вычислительной перегрузки, +бюджета и задержек перед аппаратной проверкой. Не запускать RF, QPSK, +реальный видеопоток, новый FEC или 180 МГц в рамках этой работы. + +# Запись 038 + +## Дата + +8 сентября 2026 года. + +## Тема + +Lab049: архитектура CRSF/ExpressLRS и бюджет отдельного SDR-видеоканала. + +## Выполнено + +Повторно проверены main и HEAD ac6cc792b896e34f7dd6d291955a0387fdafcf41. +Существовавшие незакоммиченные Lab049, тесты, данные и записи 036–037 сохранены. +Аудит README, журнала, индекса, Lab033–041, Lab047–048 и protocol подтвердил: +общий EMERGENCY > CONTROL > TELEMETRY > VIDEO создавался для собственного +единого ресурса. Его CONTROL 20 Гц и TELEMETRY 10 Гц в полных видеокадрах +остаются историческим стресс-тестом, не целевой моделью CRSF/ELRS. + +По официальному ExpressLRS 4.1.0, коммиту +a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6 и спецификации TBS проверены +CRSF framing, UART, OTA, интервалы, Full/повторные режимы и телеметрия. +CRSF RC: 26 байт по проводу; ELRS OTA4/OTA8: 8/13 байт, DATA: 5/10 байт. +Ссылки на источники и точные допущения размещены в +docs/lab049_crsf_elrs_architecture.md и crsf_elrs_budget.json. + +Рекомендуемая архитектура: обычный RadioMaster, штатные ELRS TX/RX, +CRSF к контроллеру ровера для управления и критической телеметрии; +отдельный SDR для изображений и объёмных данных. Будущие примерно 180 МГц +относятся к SDR, не к стандартному ELRS. Лабораторная RF-база — 1400 МГц. +Модели аппаратуры и ELRS-диапазон не уточнены; 100 Full/8ch/1:8 — условный +кандидат сравнительной модели, не назначенная аппаратная конфигурация. + +При 100 Full/1:8 расчёт даёт до 87,5 RC-возможностей/с, 12,5 TLM-слотов/с, +среднее ожидание RC 6,25 мс и верхнюю границу 20 мс только для идеального +расписания RC/TLM. Полная задержка стики→ровер не измерена. DATA после +LINK имеет потолок 116,667 байт/с до прочих расходов. Даже наиболее щедрый +предел 10000 байт/с для двух DATA-трактов ELRS ниже JPEG 3 кадра/с: +26172 байт/с. Обычная ELRS-телеметрия для видео недостаточна. + +Свойства безопасности Lab038–041 сохранены. При потере ELRS живое видео +не разрешает движение. Требуются локальный таймер, аварийная защёлка, +безопасная загрузка и отдельное повторное разрешение движения. +Сопряжение не реализовано: RC CRSF не содержит session/boot/epoch/sequence +нашего протокола. Локальная нумерация UART не доказывает свежесть источника. +Порог ELRS TX для старого ввода пульта 1 с явно отмечен как незакрытое +ограничение относительно модельных таймеров 100/200 и 150/250 мс. + +JPEG Lab047 сохранён: 8724 байта, RGB 320×240, SHA +e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a. +Фактические планы Lab046/Lab045 дают 36 кадров, 206064 BPSK-бита и +207576 символьных интервалов с защитными интервалами/фильтром. +Для 3/5/10 кадров/с JPEG payload требует 209,376/348,960/697,920 кбит/с, +нынешняя PHY — минимум 622,728/1037,880/2075,760 ксимв/с без запаса. +Управление ELRS из этого SDR-бюджета не вычитается. + +20/50/100/200 ксимв/с дают в идеале 0,09635/0,24088/0,48175/0,96350 JPEG/с. +Существующие независимые шумные замеры переиспользованы по SHA, не повторены: +на 50/100/200 точный JPEG, но обработка занимает 5,72980/7,76244/8,17573 с, +отношение к IQ 1,35433/3,60210/7,31872. На 20 только 6/36 кадров. +Lab048 15 с → 13,966058 с остаётся фактом одной записи, не доказательством +реального времени при повышении скорости PHY. + +Конечная цель — больше 3 кадров/с; 3 промежуточно, далее 5 и 10. +При 25 км/ч между изображениями соответственно 2,315/1,389/0,694 м. +Рассчитана полная таблица для 10/15/25 км/ч и 1/3/5/10 кадров/с. +Предел задержки изображения в один период (333,33/200/100 мс) обозначен +как инженерное предложение, не измеренный или утверждённый норматив. +Камера, живое кодирование, очередь и экран пока не измерены; RX включает +JPEG decode, поэтому оно не прибавляется повторно. Кодек не менялся. + +Добавлены experiments/lab049_crsf_elrs_budget.py, 35 тестов в +tests/test_lab049_crsf_elrs_budget.py и отдельный машинный результат +data/processed/lab049/crsf_elrs_budget.json. Обновлены документы состояния, +индекс и краткий указатель актуальной архитектуры в README. +Старые protocol tests и модули protocol не изменены. + +Verifier независимо пересчитал расходы PHY, RC-ожидание перечислением +интервалов, LINK/DATA и SHA исходных замеров; 35 целевых тестов PASS. +Adversary: 7 проверок сеансов PASS; при живом видео без команд watchdog +переходит в ступени 100/200 мс. Искусственная нумерация старого состояния +подтверждает описанное ограничение свежести, не готовность bridge. +Воспроизведённых расхождений в новой работе не обнаружено. + +quick_gate PASS: 606 тестов и 13 функциональных проверок. +pytest: 606 passed за 64,32 с; одно предупреждение о доступе к .pytest_cache. +git diff --check PASS. Дополнительный ruff не выполнен: модуль не установлен. +RF TX = 0; оборудование не запускалось и не инвентаризировалось. +Git add/commit/push/pull/fetch не выполнялись. + +Следующий этап: STOP. Рекомендуемый Lab050 — отдельная программная задача +профилирования/потоковой обработки SDR-видео с ограничением возраста очереди +и проверкой выполнимости 5 кадров/с. Сопряжение CRSF и его сценарии отказов +требуют отдельной постановки и уточнения аппаратуры. Нового RF, QPSK, +CRSF/ELRS-протокола, перекодирования JPEG или переноса на 180 МГц здесь нет. diff --git a/README.md b/README.md index 40899f5..dbd4267 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # SDR Rover Link +Актуальное уточнение архитектуры, Lab049 от 8 сентября 2026 года: +управление и критическая телеметрия — стандартные RadioMaster/ExpressLRS и CRSF; +изображения и объёмные данные — отдельный SDR, в перспективе около 180 МГц. +Цель изображения — больше 3 кадров/с, следующий ориентир 5, дальнейший 10. +См. [аудит и бюджет Lab049](docs/lab049_crsf_elrs_architecture.md). +Основной текст ниже описывает более раннюю контрольную точку; текущие результаты +Lab047–049 перечислены в [индексе лабораторий](docs/LABS_INDEX.md). + ## 1. Назначение проекта **SDR Rover Link** — учебно-исследовательский проект по разработке цифрового радиоканала для наземного робототехнического комплекса с использованием программно-определяемого радио — SDR, Software Defined Radio. diff --git a/docs/LABS_INDEX.md b/docs/LABS_INDEX.md index 354d598..926b93d 100644 --- a/docs/LABS_INDEX.md +++ b/docs/LABS_INDEX.md @@ -200,6 +200,46 @@ - **RF:** новый радиосеанс не выполнялся, `RF TX = 0`. - **Журнал:** запись 035 ([`PROJECT_LOG.md`](../PROJECT_LOG.md)). +## Lab049 + +- **Актуальная архитектура:** RadioMaster + штатные ELRS TX/RX, CRSF к контроллеру + ровера; независимый SDR для видео и объёмных данных. Будущий SDR около 180 МГц, + лабораторная база 1400 МГц. Это не перенос штатного ELRS на 180 МГц. +- **Аудит CRSF/ELRS:** официальный релиз 4.1.0, размеры OTA 8/13 байт, + реальные интервалы и доли телеметрии; ELRS недостаточно для JPEG >3 кадра/с. +- **Бюджет SDR:** RC/критическая телеметрия в отдельном ELRS не вычитаются. + 20/50/100/200 ксимв/с дают в идеале 0,09635 / 0,24088 / 0,48175 / 0,96350 JPEG/с. + Для 3/5/10 требуются минимум 622,728 / 1037,880 / 2075,760 ксимв/с + по фактическому текущему PHY. 3 — промежуточный уровень; цель >3, ориентиры 5 и 10. +- **Задача предыдущего опыта:** 20/50/100/200 ксимв/с при Fs=8 Мвыб/с, + полный независимый IQ-приём и исторический стресс-тест общего канала. +- **Актуальный результат:** идеал — 36/36 во всех режимах. Шум Eb/N0=7 dB, CFO +10 Гц: + 20 — 6/36, FAIL; 50/100/200 — точный JPEG 36/36, CRC 36/0, PARTIAL. +- **Полный приёмник Lab048:** processing/IQ 16,60454 / 1,35433 / 3,60210 / 7,31872; + ни один режим не успевает в реальном времени в шумном сценарии. +- **Исторические задержки общего канала:** блокировка 288,30 / 115,32 / 57,66 / 28,83 мс. Доставка EMERGENCY + в худшей фазе включает ещё один кадр; даже на 200 она требует почти 57,66 мс. +- **Исторический стресс-тест:** CONTROL 20 Гц и TELEMETRY 10 Гц в 30 полных + кадрах/с оставляли VIDEO только на 200. Это не целевая CRSF/ELRS-архитектура. +- **Диагностический PHY-кандидат:** 50 ксимв/с, 160 выборок/символ — + наименьшая вычислительная перегрузка среди точных шумных проходов. RF не разрешён. +- **Код:** [расчётчик ELRS/SDR](../experiments/lab049_crsf_elrs_budget.py), + [Lab049 IQ](../experiments/lab049_bpsk_symbol_rate.py), + [независимый IQ-адаптер](../experiments/lab049_independent_iq.py). +- **Тесты:** [35 расчётных](../tests/test_lab049_crsf_elrs_budget.py), + [исходные](../tests/test_lab049_bpsk_symbol_rate.py), + [регрессии IQ](../tests/test_lab049_independent_iq.py). +- **Отчёт:** [архитектура CRSF/ELRS](lab049_crsf_elrs_architecture.md), + [новый бюджет JSON](../data/processed/lab049/crsf_elrs_budget.json), + [состояние](lab049_current_state.md), [аудит](lab049_audit.md), + [JSON](../data/processed/lab049/result.json). +- **История:** [старый frame-aligned результат](../data/processed/lab049/frame_aligned_result.json) + не измерял полный независимый поиск. Журнал: записи 036–038. +- **Безопасность:** свойства Lab038–041 сохраняются; сопряжение CRSF ещё не проверено. + Видео не разрешает движение при потере ELRS. Модули protocol и прежние тесты сохранены. +- **Далее:** программный Lab050 — профиль RX, поток видео и ограничение возраста очереди. +- **RF:** RF TX = 0, STOP. + ## Правило хранения данных Большие записи IQ, потоки `cs8`, бинарные объекты и другие аппаратные исходные diff --git a/docs/lab049_audit.md b/docs/lab049_audit.md new file mode 100644 index 0000000..baa2879 --- /dev/null +++ b/docs/lab049_audit.md @@ -0,0 +1,86 @@ +# Lab049: аудит исходного незакоммиченного варианта + +8 сентября 2026 года. Корень `C:/Users/user/Desktop/projects/SDR_Rover`, +ветка `main`, HEAD `ac6cc792b896e34f7dd6d291955a0387fdafcf41`. +До работы уже существовали незакоммиченные реализация Lab049, тесты, +`data/processed/lab049/result.json`, документ состояния и запись 036 журнала. +Прежний JSON сохранён; он относится к выровненным кадрам и не является +результатом полного независимого приёмника. + +## Карта архитектуры + +1. Базовый Rs задан в `experiments/lab043_hackrf_bpsk.py`: 20 000 символов/с. + Lab044 и Lab045 наследуют его, Lab047 явно проверяет аппаратную точку. +2. В Lab043 `SAMPLES_PER_SYMBOL = SAMPLE_RATE_HZ // SYMBOL_RATE`; Lab047 + отдельно фиксирует 400. Lab049 теперь отвергает дробные частоты и + нецелое отношение, не округляя вход. +3. Жёсткие зависимости Lab043: `_refine_frame_start`, `_matched_filter_taps`, + `_matched_waveform`, `_preamble_timing_offset`, `_estimate_cfo_gain`, + `_evaluate_symbols`. Lab044: `_scale_and_carrier`, + `_preamble_reference_samples`. Lab045: длительности TransmissionPlan; + Lab046B: `_extract_soft_window` и `_decode_iq_candidate`. Временные шкалы + используют Rs, фильтры и индексы — samples/symbol. В локальном поиске + также есть фиксированные пределы ±800 и ±2000 выборок; они сохранены. +4. `shape_frame_like_lab042`, RRC-фильтр ядра, преобразования бит/BPSK, + фрагментация, packet/PHY header, FEC, перемежитель, CRC и сборка объекта + уже параметрические либо вообще не зависят от Rs. +5. `protocol/priority_scheduler.py`: строгий приоритет четырёх классов, + непрерываемая передача пакета и `LATEST_STATE` для ожидающих состояний. + Lab034–Lab036 добавили удаление устаревших изображений и изоляцию + видеокадров в собственных блоках FEC; это внешнее стирающее кодирование + видео, не новый свёрточный FEC Lab045. +6. Минимальная доработка: адаптер скорости и чтения синтетического IQ к + `lab046c_offline_recovery.stream_process_capture`, который используется + Lab048; разделение ожидания/доставки и активного окна/опустошения очереди. + Новый планировщик и изменения `protocol/` не потребовались. + +## Проверка Lab033–Lab041 + +Сверены сохранённые отчёты этих лабораторий и связанные модули. +Lab033 подтверждает строгий приоритет и отдельно считает ожидание начала +и завершение передачи. Lab034: frame-aligned FEC изолирует удаляемое +изображение, начатый пакет не прерывается. Lab035–Lab036 ограничивают возраст +изображения и число ожидающих кадров. Lab037 подтверждает вставку управления +между пакетами VIDEO. Lab038–Lab040 добавляют failsafe, аварийную защёлку, +повторы и ACK; Lab041 — сеансы/перезапуски. Эти механизмы безопасности +в Lab049 не заменены и не заявлены как аппаратно проверенные. + +CONTROL взят из последнего Lab041: `ControlStateMessage`, 36 байт, 20 Гц; +с 32-байтным LinkPacket — 68 байт и 10 880 бит/с до физического кодирования. +TELEMETRY наследуется из Lab037 через Lab038–Lab041: синтетические 64 байта, +10 Гц, с LinkPacket — 96 байт и 7680 бит/с. Отдельного формата физических +измерений телеметрии эти лаборатории не задают. + +## Воспроизведённые несоответствия + +Проверки выполнены независимыми `verifier` и `adversary`, без правок с их +стороны и без оборудования. + +- Исходный `receive_iq_frame`: идеальный кадр 100 ксимв/с проходит CRC, + добавление 240 нулевых выборок перед ним даёт `PHY=False, CRC=NOT_REACHED`. + Поиск ограничивался двумя символами от известной TX-границы. +- Добавление +10 Гц к тому же кадру даёт `PHY=True, CRC=NOT_REACHED`; + +100 Гц — `PHY=False`. Остаточная частота не оценивалась. +- При 100 ксимв/с старый отчёт CONTROL max 47,980 мс описывал начало, + а получение пакета занимает 105,640 мс; пропущены 6 сроков CONTROL. + EMERGENCY: 30,240 мс до начала, 87,900 мс до получения — пропущен срок 50 мс. +- При 200 ксимв/с EMERGENCY через 1 мкс после VIDEO доставляется через + 57,659 мс, а не за 50 мс. Штатный failsafe до получения пакета остаётся + в `normal`. Блокировка 28,83 мс и доставка почти 57,66 мс — разные метрики. +- На 20/50/100 ксимв/с в двухсекундном окне не доставлено ни одного + TELEMETRY. Единственное сообщение старой таблицы доставляется при + последующем опустошении очереди. + +Регрессии воспроизводятся командой: + +```powershell +python -m pytest tests/test_lab049_independent_iq.py -q +``` + +Исторический `receive_iq_frame` сохранён для локальных регрессий формы; +основной проход Lab049 его больше не использует. Старые тесты не изменены. +Новый RX получает полный поток, самостоятельно находит начало, оценивает CFO, +фазу и полярность. PRBS7, физические и пакетные форматы, FEC, CRC и порог +Lab047 `0.030964473262429237` сохранены. + +RF TX = 0. Git add/commit/push/pull/fetch не выполнялись. diff --git a/docs/lab049_crsf_elrs_architecture.md b/docs/lab049_crsf_elrs_architecture.md new file mode 100644 index 0000000..62d6d39 --- /dev/null +++ b/docs/lab049_crsf_elrs_architecture.md @@ -0,0 +1,450 @@ +# Lab049: CRSF / ExpressLRS и отдельный SDR-видеоканал + +Состояние на 8 сентября 2026 года. SOFTWARE + ARCHITECTURE ONLY, RF TX = 0. +Решение по результатам аудита: **сохранить стандартный ELRS для управления, +а SDR оптимизировать под изображения и объёмные данные**. Смешанная модель +CONTROL 20 Гц + TELEMETRY 10 Гц, каждый в полном видеокадре, остаётся +историческим стресс-тестом; она не определяет бюджет этой архитектуры. + +## Источники и границы доказанного + +Проверены официальный [последний релиз ExpressLRS 4.1.0](https://github.com/ExpressLRS/ExpressLRS/releases/tag/4.1.0) +от 17 июля 2026 года и его +[коммит a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6](https://github.com/ExpressLRS/ExpressLRS/commit/a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6). +Версия исходников не означает, что такая прошивка уже стоит у пользователя. +Ссылки ниже закреплены на этом коммите; страницы документации проверены в дату отчёта. + +Локальная база: main, HEAD `ac6cc792b896e34f7dd6d291955a0387fdafcf41`. +До работы уже были изменены журнал и индекс, присутствовали незакоммиченные +Lab049, её тесты и результаты, а также сторонние локальные файлы. Они сохранены. +README описывает старую точку Lab042 и диапазон 200–250 МГц; текущая постановка +пользователя уточняет будущий SDR-видеоканал как примерно 180 МГц. + +Физические модели RadioMaster, ELRS TX/RX и их диапазон пока не указаны. +Поэтому приведено сравнение, а не назначение конкретных настроек оборудования. +Инвентаризация и аппаратная проверка не выполнялись. Совместимость системы +ниже — архитектурная, сопряжение с контроллером ровера ещё не реализовано. + +## Что уже есть в проекте + +Аудит README, PROJECT_LOG, LABS_INDEX, Lab033–Lab041, Lab047–048 и связанных +модулей дополняет [предыдущий аудит Lab049](lab049_audit.md). + +| Код / лаборатории | Для чего создавался | Что сохраняется при отдельном ELRS | +|---|---|---| +| `protocol/link_packet.py`, `priority_scheduler.py`; Lab033 | Один абстрактный непрерываемый ресурс для двух направлений, EMERGENCY > CONTROL > TELEMETRY > VIDEO, последнее ожидающее состояние | Историческая модель и регрессии; не размещаем её CONTROL/TELEMETRY в бюджете SDR | +| `video_frame_scheduler.py`, Lab034–036 | Удаление устаревшего видео, допуск целого изображения, ограничение очереди, FEC-блоки в пределах изображения | Нужны принципы возраста и ограничения очереди; сопряжение с нынешним JPEG/PHY ещё предстоит | +| Lab037, `control_repetition.py` | Повторы управления, потери и видео в одном канале, удаление дубликатов | Тестовые сценарии потерь; собственные повторы не накладываются автоматически поверх ELRS | +| Lab038, `control_failsafe.py`, `emergency_ack.py` | Таймер свежей команды, остановка, подтверждение аварии | Локальный контроль потери управления и идемпотентная аварийная остановка остаются обязательными | +| Lab039, `two_stage_failsafe.py` | Две ступени замедления/торможения, кинематика | Автомат и сценарии; тормозные параметры реального ровера пока не измерены | +| Lab040, `persistent_emergency.py` | Намерение остановить сохраняется после ACK; отдельный подтверждённый сброс | Смысл аварийной защёлки и запрет самовозобновления; перенос наземного автомата на обычный пульт не выполнен | +| Lab041, `control_messages.py`, `session_state.py` | Сеансы, загрузки, эпохи, номера команд, защита от старых ACK/reset | Сохранить свойства; CRSF не предоставляет эти поля автоматически | +| Lab047–048, Lab049 IQ | JPEG как двоичный объект, независимый PHY/FEC/CRC, производительность | Доказательная база отдельного SDR-видеоканала | + +Синтетические 64 байта TELEMETRY из Lab037 и собственные сообщения CONTROL +Lab041 не являются форматами CRSF. Модули `protocol/` и старые тесты не менялись. + +## Одна схема системы + +```mermaid +flowchart TB + subgraph RC[Управление и критическая телеметрия] + RM[RadioMaster: стики и переключатели] <-->|CRSF| ETX[Штатный ExpressLRS TX] + ETX <-->|ELRS OTA: поддерживаемый аппаратурой диапазон| ERX[Штатный ExpressLRS RX] + ERX <-->|CRSF UART| R[Контроллер ровера] + R --> S[Локальный таймер, аварийная защёлка, торможение] + S --> M[Приводы] + end + subgraph VIDEO[Независимый SDR video/data] + C[Камера] --> J[JPEG; будущий видеокодек] + J --> STX[SDR передатчик] + B[Объёмные данные ровера] --> STX + STX -->|Будущий RF около 180 МГц; лабораторная база 1400 МГц| SRX[SDR приёмник] + SRX --> D[Декодер изображения] --> O[Экран оператора] + end +``` + +При пропаже ELRS продолжающееся видео **не продлевает разрешение движения**. +Таймер, тормозной автомат и аварийная защёлка должны работать независимо от +очередей видео, загруженности декодера и доступности экрана. +Частота 180 МГц относится к будущему SDR-тракту, не к стандартному ELRS. +Это целевая архитектура; готовый двухканальный ровер пока не испытан. + +## CRSF на проводных границах + +По [спецификации TBS](https://github.com/tbs-fpv/tbs-crsf-spec/blob/main/crsf.md): +кадр содержит sync/address, длину, тип, данные и CRC8 (полином 0xD5). +Длина считает тип, данные и CRC, исключая первые два байта; полный кадр ≤64 байт. +Расширенный заголовок добавляет destination и origin. + +| Тип | Содержание | Данные / полный кадр, байт | +|---|---|---:| +| 0x16 | 16 RC-каналов по 11 бит | 22 / 26 | +| 0x14 | Статистика связи | 10 / 14 | +| 0x08 | Батарея | 8 / 12 | +| 0x02 | GPS | 15 / 19 | +| 0x1E | Ориентация | 6 / 10 | +| 0x21 | Режим, строка с завершающим нулём | переменный | +| 0x28–0x2D, 0x32 | Обнаружение, параметры, команды/ACK | переменный, расширенный | + +Пульт↔модуль: обычно однопроводный полудуплекс, 400 кбод, 8N1, 3,3 В; +приёмник↔контроллер: двухпроводный полный дуплекс, без инверсии, +в спецификации 416666 бод, 8N1, 3,0–3,3 В. +У ELRS штатная настройка UART приёмника — **420000 бод**: +[официальная Web UI](https://www.expresslrs.org/quick-start/webui/#receiver-options). +Настройки обеих сторон должны совпадать. Расчёт сериализации 26 байт при +420000 бод: 26×10/420000 = 0,619 мс; это только провод, не радиозадержка. + +[SerialCRSF::sendRCFrame](https://github.com/ExpressLRS/ExpressLRS/blob/a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6/src/src/rx-serial/SerialCRSF.cpp) +упаковывает текущие каналы в 0x16 только при `frameAvailable`. +Это не сквозная пересылка исходных CRSF-байтов через эфир: ELRS сначала +кодирует OTA, затем RX заново формирует CRSF. Статистика и сообщения +контроллера идут отдельными кадрами. Не все 16 выходных полей обновляются +по радио одновременно; часть может содержать состояние, полученное ранее. + +Для обычных стиков специальная прошивка RadioMaster не требуется: нужен +вариант с ELRS либо совместимый внешний ELRS-модуль и штатная настройка CRSF. +Вариант RadioMaster с другим внутренним RF-модулем сам по себе ELRS не даёт. +Нужен RX с доступным CRSF UART, общий поддерживаемый диапазон и совместимая +версия ELRS; конкретную пару ещё надо выбрать. См. +[настройку передатчика](https://www.expresslrs.org/quick-start/transmitters/lua-howto/). +Программное сопряжение CRSF с ровером остаётся будущей задачей. + +## ELRS в эфире + +[OTA.h](https://github.com/ExpressLRS/ExpressLRS/blob/a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6/src/lib/OTA/OTA.h) +задаёт OTA4 = **8 байт**, OTA8 = **13 байт**, включая служебные поля и CRC. +Это длина содержимого радиопакета ELRS, не весь физический расход LoRa/FLRC/GFSK. +RC-пакеты, DATA, SYNC и LINK имеют разные назначения; 26 байт CRSF не следует +подставлять вместо этих размеров. Поле DATA в +[telemetry_protocol.h](https://github.com/ExpressLRS/ExpressLRS/blob/a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6/src/include/telemetry_protocol.h) +содержит **5 либо 10 байт**, а не 8/13 байт пользовательской нагрузки. + +Точные периоды и Std взяты из +[ExpressLRS_AirRateConfig](https://github.com/ExpressLRS/ExpressLRS/blob/a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6/src/src/common.cpp). +Таблица ниже рассчитана для установившегося соединения, без потерь, +без повторных режимов D/DK; RC означает возможности отправки, не измеренные обновления стиков. + +| Режим | Доступность | OTA, байт | Период, мс | Std | TLM-слотов/с | RC-возможностей/с ≤ | Среднее / верхнее ожидание RC, мс | +|---|---|---:|---:|---:|---:|---:|---:| +| 25 | 900 | 8 | 40 | 1:8 | 3,125 | 21,875 | 25 / 80 | +| 50 | 900 и 2,4 | 8 | 20 | 1:16 | 3,125 | 46,875 | 11,250 / 40 | +| 100 | 900 | 8 | 10 | 1:32 | 3,125 | 96,875 | 5,313 / 20 | +| 100 Full | 900 и 2,4 | 13 | 10 | 1:32 | 3,125 | 96,875 | 5,313 / 20 | +| 150 | 2,4 | 8 | 6,666 | 1:32 | 4,688 | 145,327 | 3,541 / 13,332 | +| 200 | 900 | 8 | 5 | 1:64 | 3,125 | 196,875 | 2,578 / 10 | +| 250 | 2,4; 900 на LR1121 | 8 | 4 | 1:64 | 3,906 | 246,094 | 2,063 / 8 | +| 333 Full | 2,4 | 13 | 3,003 | 1:128 | 2,602 | 330,399 | 1,525 / 6,006 | +| 500 LoRa | 2,4 | 8 | 2 | 1:128 | 3,906 | 496,094 | 1,016 / 4 | +| F1000 / K1000 | 2,4, SX128X / LR1121 | 8 | 1 | 1:128 | 7,813 | 992,188 | 0,508 / 2 | +| K1000 Full | 900, LR1121 | 13 | 1 | 1:128 | 7,813 | 992,188 | 0,508 / 2 | + +Это представительная, не исчерпывающая таблица. Также существуют F500, +900 200 Full на LR1121, D50, D250/D500, DK250/DK500 и двухдиапазонные X-режимы; +аппаратные ограничения описаны в +[официальном руководстве Packet Rate](https://www.expresslrs.org/quick-start/transmitters/lua-howto/#packet-rate-and-telemetry-ratio). +Для внешнего модуля 333 Гц и выше требуется скорость связи с пультом выше 400 кбод. +Не все режимы доступны одному и тому же RX. + +Full повышает число полноразмерных каналов в OTA. В режиме 8ch каналы 1–8 +передаются вместе; 16ch Rate/2 делит частоту пополам; 12ch Mixed оставляет +первые четыре быстрыми, остальные передаёт с половинной частотой. +Hybrid/Wide передают дополнительные переключатели по очереди. +Поэтому аварийный орган нельзя без проверки помещать в медленно обновляемую группу. +Источник: [Switch Configs, варианты 4.x](https://www.expresslrs.org/software/switch-config/). +D50 повторяет команду четыре раза в слотах по 5 мс; D250/DK250 — четыре раза +по 1 мс, D500/DK500 — два. Называть их 200/1000 новыми командами в секунду нельзя. +Расчётчик отвергает повторные режимы вместо ложного пересчёта. + +## Задержка управления и телеметрия + +Обозначения: T — период RF-слота, D — знаменатель 1:D, f=1/T. +При фиксированном D телеметрии выделяется 1/D слотов, RC остаётся не более +f×(D−1)/D возможностей. До произвольного ближайшего RF-слота среднее ожидание +T/2, верхняя граница T. Если исключить каждый D-й слот под TLM, промежутки +между RC равны T или 2T; при равномерной временной фазе среднее ожидание +T×(1/2+1/D), верхняя граница 2T. Это наши аналитические расчёты, проверенные +независимым перечислением интервалов в тестах. + +[TX-планировщик](https://github.com/ExpressLRS/ExpressLRS/blob/a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6/src/src/tx_main.cpp) +заменяет часть передач RC на SYNC и активный DATA uplink; телеметрийные +слоты освобождаются для обратного направления. Стандартный Std зависит от режима, +Race выключает телеметрию при вооружении, служебный обмен может временно +изменить отношение до 1:2. Следовательно, 2T — граница только упрощённого +расписания RC/TLM, **не гарантия реального ELRS**. Потери или разрыв связи +вообще не имеют такой конечной верхней границы доставки. + +Пример 100 Full (T=10 мс), все величины ниже вычислены тем же расчётчиком: + +| TLM | Доля слотов | TLM/с | RC/с ≤ | Ожидание RC среднее / верхнее, мс | DATA с учётом LINK ≤ байт/с | +|---|---:|---:|---:|---:|---:| +| Off | 0% | 0 | 100 | 5 / 10 | 0 | +| 1:128 | 0,78125% | 0,781 | 99,219 | 5,078 / 20 | 6,250 | +| 1:64 | 1,5625% | 1,563 | 98,438 | 5,156 / 20 | 12,500 | +| 1:32 Std | 3,125% | 3,125 | 96,875 | 5,313 / 20 | 25,000 | +| 1:16 | 6,25% | 6,250 | 93,750 | 5,625 / 20 | 54,167 | +| 1:8 | 12,5% | 12,500 | 87,500 | 6,250 / 20 | 116,667 | +| 1:4 | 25% | 25 | 75 | 7,500 / 20 | 241,667 | +| 1:2 | 50% | 50 | 50 | 10 / 20 | 492,000 | + +Предварительный кандидат для программной модели ровера — **100 Full, 8ch, +TLM 1:8**, если выбранная пара его поддерживает: достаточно быстрые каналы +стиков/остановки и больше места служебным сообщениям, чем при Std. +Это не назначенная конфигурация и не доказательство сроков аварии/ACK. +Для 900 МГц при приоритете чувствительности следует сравнить 50 и обычный 100; +для 2,4 ГГц — 50/100 Full/150. Окончательный выбор требует моделей TX/RX, +диапазона, требований к дальности и отдельного измерения задержек/потерь. + +Чувствительность действительно меняется: официальные ориентиры для 2,4 ГГц +50/100 Full/250/500 — −115/−112/−108/−105 dBm, для 900 МГц 50/100/100 Full — +−120/−117/−112 dBm. Это параметры режимов из +[официальной таблицы](https://www.expresslrs.org/software/mavlink/#throughput-per-rf-mode), +а не обещание дальности. ELRS-дальность и будущая дальность SDR на 180 МГц +проверяются отдельно; дальнее видео не компенсирует короткий радиус управления. + +Полная цепь задержки управления: считывание стиков → цикл RadioMaster → +CRSF к TX → ожидание слота и OTA → RX → CRSF к роверу → цикл управления → +привод. Она **не измерена**. К рассчитанному ожиданию нельзя приписывать +название stick-to-rover latency. + +## Сколько данных в штатной телеметрии и почему это не видео + +Верхний предел одного тракта DATA: P×f/D байт/с, P=5 либо 10. +[RX-планировщик 4.1.0](https://github.com/ExpressLRS/ExpressLRS/blob/a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6/src/src/rx_main.cpp) +чередует LINK и серию DATA. LINK занимает четыре байта статистикой, +оставляя 1/6 байт DATA. При постоянной насыщенной очереди: +B=max(1, floor(512×floor(f)/D/1000)−1), средний предел +C=(f/D)×(P−4/(B+1)). Это учёт слотов, а не гарантированный полезный поток: +CRC/служебные поля OTA уже вычтены размером P; добивка, подтверждения, +повторы DATA, потери и заголовки переносимых CRSF/MAVLink снижают результат. +При низкой частоте TLM цель LINK раз в 512 мс сама по себе не гарантируется. + +Даже предельно щедрая оценка K1000 Full, 1:2 даёт 5000 байт/с на один тракт +до LINK; условное удвоение для двух DATA-трактов даёт 10000 байт/с. Уже +**JPEG 3 кадра/с требует 26172 байт/с**, то есть больше даже такого потолка. + +[Официальные оценки MAVLink](https://www.expresslrs.org/software/mavlink/#throughput-per-rf-mode) +при 1:2: 100 Full около 435 байт/с, 333 Full около 1470, +F1000 около 2375; K1000 Full около 4420, Gemini около 8845 байт/с. +Последнее — 70,76 кбит/с, против 209,376 кбит/с одного JPEG payload при 3 кадрах/с. +Это документированные оценки специального режима MAVLink, не наши замеры +и не обещание доступного JPEG-сокета у обычного CRSF RX. +**STANDARD ELRS VIDEO CAPABILITY = INSUFFICIENT.** + +Телеметрию разделяем так: + +| Класс | Путь | Реализация и бюджет | +|---|---|---| +| Качество RC-связи: LQ, RSSI, SNR | Штатный ELRS LINK → RadioMaster; CRSF к роверу | Поддерживается ELRS, но не подтверждает выполнение команды | +| Напряжение/батарея | Контроллер → CRSF → ELRS DATA → RadioMaster | Формат 0x08 предусмотрен; датчик и выдачу значений должен реализовать контроллер | +| Аварийная защёлка, режим ровера, подтверждение критического действия | Тот же ELRS/CRSF обратный путь | Семантика, события, идентификаторы и отображение на пульте ещё не согласованы/реализованы; 0x21/0x32 не дают их автоматически | +| Большие диагностические структуры и сенсорные массивы | SDR data/video | Отдельный будущий ограниченный бюджет и политика возраста, нагрузка пока не задана | + +Для масштаба: батарея 12 байт CRSF дважды в секунду требует 24 байт/с. +При 100 Full Std расчётный потолок после LINK всего 25 байт/с до прочих потерь: +запаса почти нет. При 1:8 потолок 116,667 байт/с. Предлагаемый начальный лимит +периодической служебной нагрузки — 50 байт/с **включая CRSF-заголовки**; +остаток не объявляется гарантированным: нужны измерения и резерв событий/повторов. +Критические события должны опережать периодические датчики в будущей очереди +контроллера. TLM 1:8 даёт слот раз в 80 мс: ACK за 50 мс здесь не обещается. +Остановка происходит локально по управляющему тракту и не ждёт обратного ACK. +Off/Race не подходят для требуемой постоянной критической телеметрии. + +## Связь с безопасностью Lab038–Lab041 + +Здесь сформулирован контракт будущего сопряжения, код CRSF/bridge не написан. + +1. Ровер стартует с запрещённым движением; после загрузки, пропажи RC или + аварии одно появление старого положения стиков не разрешает движение. + Нужны безопасное положение органов и явное новое разрешение оператора. +2. Команда остановки поступает через быстрый RC-орган ELRS/CRSF. Локальная + защёлка сохраняет остановку независимо от последующих стиков; ACK только + сообщает о ней. Снятие защёлки и разрешение движения — отдельные действия. + Обычный тумблер пульта не заменяет весь `PersistentEmergencyController`. +3. Таймер свежести управления не обновляется от JPEG, LINK statistics, + напряжения, DATA или любого произвольного CRSF-кадра. На потерю допустимого + управления реагируют существующие ступени торможения. Пороги Lab039 + 100/200 и 150/250 мс являются исследованными модельными вариантами, + не измеренными тормозными нормативами ровера; здесь они не изменены. +4. `ground_session_id`, `rover_boot_id`, `control_epoch`, `sequence_number`, + идентификаторы аварии/сброса Lab041 отсутствуют в RC 0x16. Локально + присвоенный номер приёма доказывает порядок UART-событий, но не свежесть + источника, защиту от повтора или подлинность команды. CRC этого не заменяет. + Binding/model match также нельзя объявлять эквивалентом автомата Lab041. +5. В [SendRCdataToRF](https://github.com/ExpressLRS/ExpressLRS/blob/a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6/src/src/tx_main.cpp) + порог давности последнего RC от пульта — 1000000 мкс. При остановке + обновлений RadioMaster до этого порога TX может посылать прежнее состояние. + Приём регулярных UART RC-кадров не обеспечивает исходную свежесть за + 100/200 мс. Сценарий требует отдельного решения и проверки всей цепи; + нормальное неизменное положение стика нельзя считать отказом только + потому, что значения равны предыдущим. +6. На возобновление соединения, reboot RX/контроллера, старый reset/ACK, + удерживаемый разрешающий тумблер и отсутствие обратной телеметрии нужны + воспроизводимые проверки. До них `SAFETY INTEGRATION = NOT VALIDATED`. + Автомат Lab041 нельзя просто обойти синтезом «нового сеанса» на каждый пакет. + +Таким образом, отдельный ELRS устраняет конкуренцию с видеокадрами, но сам +по себе не доказывает безопасность готового ровера. Сохранённые модули и +их тесты остаются эталоном свойств для последующей реализации. + +## Видео: фактическая упаковка контрольного JPEG + +Объект [Lab047](../data/processed/lab047/tx_image.jpg): 8724 байта, RGB 320×240, +SHA-256 `e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a`. +JPEG не перекодирован. [Расчётчик](../experiments/lab049_crsf_elrs_budget.py) +вызывает настоящие `build_object_frames` и `build_transmission_plan` из +[Lab046](../experiments/lab046_bpsk_object_transport.py) / +[Lab045](../experiments/lab045_hackrf_bpsk_fec.py), не генерируя IQ. + +| Ступень на одно изображение | Объём | +|---|---:| +| JPEG | 8724 байта | +| 36 фрагментов: заголовки по 12 байт + 60 байт добивки последнего | 9216 байт нагрузки пакетов | +| 36 пакетов по 268 байт: ещё 12 байт заголовка/CRC на пакет | 9648 байт | +| Свёрточный FEC R=1/2, шесть хвостовых бит на пакет | 154800 кодированных бит | +| PHY-заголовки 144×36 | 5184 бит | +| Преамбулы 1280×36 | 46080 бит | +| Передаваемые биты BPSK, сумма | 206064 бит | +| Защитные интервалы + хвост фильтра (32+10)×36 | 1512 интервалов без новых бит | +| Полное занятие радио | **207576 символьных интервалов** | + +Перемежитель длину не меняет. Внешний стирающий FEC старого видеопланировщика +Lab034 сюда дополнительно не добавлен: в доказанном потоке Lab047 его нет. +Радиосимвол BPSK несёт один передаваемый бит, но защитный интервал не является +битом. Поэтому ниже отдельно показаны поток передаваемых бит и минимальная +символьная скорость с учётом всего занятого времени. Это расход текущего +формата; он не переносится на ELRS и не включает новую модуляцию. + +| Цель | JPEG, байт | Только JPEG, байт/с | Только JPEG, кбит/с | Передаваемые BPSK-биты, кбит/с | Требуемая PHY с защитными интервалами, ксимв/с | RX: изображений/с; RF-кадров/с | Максимум обслуживания RX на изображение | Предложенный предел полной задержки* | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 3 кадра/с, промежуточно | 8724 | 26172 | 209,376 | 618,192 | **622,728** | 3; 108 | 333,33 мс | 333,33 мс | +| 5 кадров/с, следующий ориентир | 8724 | 43620 | 348,960 | 1030,320 | **1037,880** | 5; 180 | 200 мс | 200 мс | +| 10 кадров/с, дальнейший ориентир | 8724 | 87240 | 697,920 | 2060,640 | **2075,760** | 10; 360 | 100 мс | 100 мс | + +*Предел полной задержки предложен как один период изображения. Это открытое +инженерное допущение, **не утверждённый пользователем предел безопасности и +не измеренная характеристика**. RX service budget — необходимая средняя +производительность последовательного обработчика, не разрешение занять этим +весь сквозной бюджет. Измерение хвостов распределения задержки также необходимо. + +Числа PHY — нижние границы при отсутствии потерь, повторов и объёмной нагрузки, +весь ресурс отдан видео. На границе ёмкости нет запаса от роста очереди. +Произвольные 20 CONTROL + 10 TELEMETRY из SDR **не вычитаются**. +Когда появятся объёмные данные, расчётчик принимает их явно как расход +символьных интервалов в секунду. Если занято 20% ресурса, требуемая PHY +для того же видео возрастает в 1/0,8=1,25 раза — это пример, не выбранный резерв. +Точные требуемые скорости выше проверенных 200 ксимв/с; их работоспособность +и допустимое число выборок/символ при Fs=8 Мвыб/с не доказаны этой арифметикой. + +## Текущая ёмкость и фактическая обработка + +Замеры переиспользованы из +[independent/result.json](../data/processed/lab049/independent/result.json), +SHA-256 `8a2c799a9754cf439159ebd68f3d77212200a391ecfdbd3520c312a71a4e49b3`. +Полный тяжёлый IQ-опыт на этом архитектурном этапе повторно не запускался. +Расчётчик проверяет SHA JPEG, число кадров и согласие длительности с текущим PHY. + +| Rs, ксимв/с | Полезно в идеале, кбит/с | JPEG в эфире, с | Идеальные кадры/с без RC-резерва | Измеренная обработка шумного IQ, с | Длительность IQ, с | Обработка/IQ | JPEG в этом шумном сценарии | +|---|---:|---:|---:|---:|---:|---:|---| +| 20 | 6,724 | 10,37880 | 0,09635 | 173,65057 | 10,45802 | 16,60454 | Нет, 6/36 | +| 50 | 16,811 | 4,15152 | 0,24088 | 5,72980 | 4,23074 | 1,35433 | Точный, 36/36 | +| 100 | 33,622 | 2,07576 | 0,48175 | 7,76244 | 2,15498 | 3,60210 | Точный, 36/36 | +| 200 | 67,245 | 1,03788 | 0,96350 | 8,17573 | 1,11710 | 7,31872 | Точный, 36/36 | + +Шумной модели заданы одинаковые coded-bit Eb/N0=7 dB, CFO +10 Гц, фаза 0,73 рад, +чередование полярности и фиксированные seeds. Порог независимого приёмника +Lab047 сохранён: 0.030964473262429237. Это один программный сценарий, +не оценка дальности и не аппаратное подтверждение ускоренных режимов. + +Исторический [Lab048](lab048_current_state.md): 15 с записи → 13,966058 с +обработки при 20 ксимв/с, отношение 0,931071. Это реальное время только на +той сохранённой записи. В нынешней шумной модели ни один режим не успевает; +для 5 кадров/с нужен RX как минимум 180 RF-кадров/с, а не нынешние 36 за +5,73–8,18 с. Производительность нельзя линейно экстраполировать по Rs. +При неизменном Fs вход остаётся 8 Мвыб/с, но число кандидатов и полезных +кадров возрастает. 50 ксимв/с — полезная точка для профилирования, а не цель видео. + +## Движение и задержка изображения + +Расчёт расстояния: d=(v/3,6)/fps. Все числа — метры между моментами съёмки +при постоянной скорости, не тормозной путь и не полный возраст изображения. + +| Скорость | 1 кадр/с | 3 кадра/с | 5 кадров/с | 10 кадров/с | +|---|---:|---:|---:|---:| +| 10 км/ч | 2,778 | 0,926 | 0,556 | 0,278 | +| 15 км/ч | 4,167 | 1,389 | 0,833 | 0,417 | +| 25 км/ч | 6,944 | 2,315 | 1,389 | 0,694 | + +При 25 км/ч ещё 200 мс возраста показанной сцены соответствуют 1,389 м +пройденного пути. Потому 3 кадра/с — только промежуточный минимум; +конечная цель **больше 3**, следующий практический ориентир 5, дальнейший 10. + +| Участок сквозной задержки | Что известно сейчас | +|---|---| +| Съёмка/экспозиция/считывание камеры | Не измерено; сейчас готовый JPEG | +| Кодирование JPEG | Не измерено в живом тракте; перекодирование здесь не выполнялось | +| Радиоочередь | В расчёте ёмкости нулевая; живая очередь не реализована/не измерена | +| Передача RF | Рассчитанное время из фактической формы 10,37880 / 4,15152 / 2,07576 / 1,03788 с | +| Поиск и обработка RX | Измерены вместе со сборкой и JPEG decode в таблице выше | +| Декодирование JPEG | Входит в общее время RX; отдельно не выделено, второй раз не прибавляется | +| Очередь экрана/отрисовка | Не измерено | + +Полная задержка L = capture + encode + queue + RF + RX + JPEG decode + display. +Сложение RF и имеющегося общего RX описывает последовательную обработку +после накопления записи, не измеренный потоковый конвейер. Для такого +варианта на 50 ксимв/с известная часть уже 4,15152+5,72980=9,88132 с, +плюс неизвестные стадии; реального измерения камера→экран пока нет. + +Предложение для следующего проектирования: при 5 кадрах/с L≤200 мс, +например не более 100 мс на радио и оставшиеся 100 мс суммарно на прочие +стадии. Тогда по нынешнему формату требуется уже ≥2075,760 ксимв/с, +а не 1037,880: минимальный потоковый битрейт сам по себе задержку не обеспечивает. +Это иллюстративное распределение бюджета, которое предстоит проверить. +Удаление устаревших изображений ограничит возраст очереди, но не ускорит PHY. + +## Если отказаться от отдельного ELRS + +Перенос CRSF-семантики на SDR означал бы собственные радиомодем, +синхронизацию, дуплекс/планировщик, повторения, контроль потерь и весь путь +безопасности. Стандартный ELRS RX такой SDR-поток не декодирует; для +RadioMaster понадобился бы совместимый проводной адаптер/модуль. +Теряются готовая OTA-совместимость и доказанная работа штатной пары ELRS. +Это уже не стандартный ExpressLRS. Пользователь такой переход не запросил; +в данной Lab049 он не реализуется. + +## Следующий этап и воспроизведение + +Рекомендуемый Lab050 — **программный потоковый SDR-видеотракт и профиль RX**: +тот же JPEG, независимый непрерывный поиск, ограничение возраста/очереди, +измерение стадий обработки и проверка выполнимости 5 кадров/с по вычислениям +и PHY. Начать с профиля существующих 50/100/200 ксимв/с; отделить стоимость +поиска ложных кандидатов от FEC и сборки. Не объявлять ускорение успешным +до полного CRC/SHA и устойчивой очереди. Для CRSF отдельно подготовить +контракт безопасности и сценарии отказов; реализация сопряжения требует +следующей согласованной постановки и уточнения аппаратуры. + +Позднее сравнить разрешение, grayscale, JPEG quality, ROI, более лёгкий +кодек и межкадровые/дельта-данные. Контрольная картинка не окончательна, +но в этой работе ни один из этих параметров не менялся. + +```powershell +python experiments/lab049_crsf_elrs_budget.py +python -m pytest tests/test_lab049_crsf_elrs_budget.py -q -p no:cacheprovider +python tools/quick_gate.py +python -m pytest tests/ -q +git diff --check +``` + +Машинный результат: [crsf_elrs_budget.json](../data/processed/lab049/crsf_elrs_budget.json). +Он хранит источники, версию, допущения, SHA исходных замеров, расходы PHY, +таблицы ELRS, видео и движения. Старые JSON не перезаписаны. + +Итог: 35 расчётных тестов PASS; quick_gate PASS — 606 тестов и 13 функциональных +проверок; полный pytest — 606 passed за 64,32 с, одно предупреждение о доступе +к `.pytest_cache`; git diff --check PASS. Дополнительный ruff недоступен: +модуль не установлен. Verifier независимо подтвердил числа и источники; +adversary подтвердил семь проверок сеансов и локальный watchdog при живом видео. +Сквозная безопасность CRSF/ELRS остаётся непроверенной, поскольку сопряжения нет. + +RF TX = 0. HackRF и другое оборудование не запускались. +Git add/commit/push/pull/fetch не выполнялись. STOP. diff --git a/docs/lab049_current_state.md b/docs/lab049_current_state.md new file mode 100644 index 0000000..ba1d302 --- /dev/null +++ b/docs/lab049_current_state.md @@ -0,0 +1,219 @@ +# Lab049: отдельный ELRS для управления и SDR для видео + +Актуальная архитектурная постановка от 8 сентября 2026 года: +**RadioMaster → штатный ELRS → CRSF → контроллер ровера** для управления и +критической телеметрии; отдельный SDR — для изображений и объёмных данных. +Будущие примерно 180 МГц относятся только к SDR; аппаратная лабораторная +база остаётся 1400 МГц. Стандартный ELRS не объявляется работающим на 180 МГц. + +Подробный [аудит CRSF/ELRS, схема и контракт безопасности](lab049_crsf_elrs_architecture.md) +сверен с официальным ExpressLRS 4.1.0. Модели аппаратуры пока не указаны, +сопряжение контроллера с CRSF и сквозная безопасность не реализованы/не проверены. +Сохраняются локальное торможение при потере управления, аварийная защёлка, +защита от самовозобновления. Видео не обновляет таймер управления. + +Прежний общий CONTROL > TELEMETRY > VIDEO — исторический стресс-тест ниже. +Его 20 CONTROL и 10 TELEMETRY в секунду **не резервируют ёмкость SDR**. +Старые программные результаты и отрицательные вердикты сохранены. + +| SDR, ксимв/с | JPEG 8724 байта в идеале, кадры/с | Полезно, кбит/с | +|---|---:|---:| +| 20 | 0,09635 | 6,724 | +| 50 | 0,24088 | 16,811 | +| 100 | 0,48175 | 33,622 | +| 200 | 0,96350 | 67,245 | + +Требуется **больше 3 кадров/с**: 3 — промежуточный минимум, 5 — следующий +ориентир, 10 — дальнейший. Тот же JPEG в нынешнем PHY требует соответственно +622,728 / 1037,880 / 2075,760 ксимв/с с защитными интервалами, без запаса на +потери и объёмные данные. Только JPEG: 209,376 / 348,960 / 697,920 кбит/с. +Штатная ELRS-телеметрия для такого видео недостаточна; официальные источники, +расходы по ступеням, движение и бюджет полной задержки приведены в новом отчёте. + +Новый [расчётчик](../experiments/lab049_crsf_elrs_budget.py) создаёт отдельный +[crsf_elrs_budget.json](../data/processed/lab049/crsf_elrs_budget.json), +переиспользует существующие независимые замеры IQ по SHA и не запускает RF. +Он считает ELRS-возможности слотов, не измеренную задержку стики→ровер. +35 новых тестов бюджета проходят; quick_gate: 606 тестов и 13 функциональных +проверок, полный pytest: 606 passed, git diff --check: PASS. Одно предупреждение +pytest относится к доступу к `.pytest_cache`; ruff не установлен. +Независимые verifier/adversary не обнаружили воспроизведённых расхождений. +Подробности общих проверок — в записи 038. +Следующий рекомендуемый Lab050: программное профилирование и потоковая +обработка SDR-видео с ограничением возраста очереди; затем отдельное сопряжение CRSF. + +## Сохранённый независимый IQ-опыт и общий стресс-тест + +Актуальный программный результат от 8 сентября 2026 года: **полностью пригодного режима нет**. +Максимальная проверенная скорость с точным JPEG в идеальном и фиксированном шумном сценариях — +200 ксимв/с. Полный приёмник в шумном сценарии не успевает в реальном времени ни в одном режиме. +Это результат одного воспроизводимого сценария, не универсальная оценка предела устойчивости BPSK. + +Для отдельного будущего диагностического PHY-опыта выбран **50 ксимв/с, 160 выборок/символ**: +точный JPEG, полезная скорость в 2,5 раза выше базовой и наименьшая вычислительная перегрузка +среди точных шумных проходов (1,3543). Он не готов к непрерывному объединённому каналу: +блокировка 115,32 мс, перегрузка служебным трафиком и нарушенный аварийный срок. +Это кандидат для диагностики, не разрешение RF. 100 ксимв/с лучше по блокировке, но требует +3,60 длительности IQ для обработки; 200 ксимв/с — 7,32. + +## Аудит и исправления + +При входе в задачу Lab049 уже существовала без коммита. Старый результат сохранён как +`data/processed/lab049/frame_aligned_result.json`; запись 036 журнала не переписана. +Они измеряли заранее выровненные кадры без остаточного CFO. Новые данные — +`data/processed/lab049/result.json` и идентичный `independent/result.json`. +[Аудит](lab049_audit.md) содержит карту функций, проверку Lab033–Lab041 и воспроизведённые отказы. + +Основной RX получает целый IQ-поток без TX-границ, payload, объекта или ожидаемого числа кадров. +Используются блочный поиск и строгий декодер Lab048: `lab046c_offline_recovery.stream_process_capture`. +Сохранён порог Lab047 `0.030964473262429237`. Адаптер последовательно задаёт Rs/SPS и предоставляет +чтение синтетического complex64; исходные константы и функции возвращаются через finally. +Старый локальный `receive_iq_frame` остаётся для регрессий формы, основной проход его не использует. + +Fs = 8 000 000 выборок/с. Отношения Fs/Rs точные, дробные входы и нецелое samples/symbol +отвергаются. BPSK, PRBS7, PHY header, packet/fragment, CRC, свёрточный FEC R=1/2 K=7 +(171/133 octal), tail bits и перемежитель сохранены. Новый планировщик не создан, protocol не изменён. + +## Канал и объект + +Один JPEG Lab047: 8724 байта, RGB 320×240, SHA-256 +`e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a`; без перекодирования. +Каждый проход: JPEG → фрагменты → protocol packets → FEC → interleaver → BPSK/RRC → +CS8-квантование → IQ → независимый поиск → CFO/фаза/полярность → PHY/FEC/CRC → сборка → JPEG decode. + +Идеальный канал не добавляет шум/CFO. Шумный: единый coded-bit Eb/N0=7 dB, +seed=49000+номер кадра, остаточный CFO +10 Гц, фаза 0,73 рад и чередование полярности. +Eb = sum(abs(IQ)**2)/число символов; sigma² каждой компоненты шума = Eb/(2×10**(7/10)). +Нормировка одинакова во всех режимах, параметры не подбирались до PASS. +Ведущий, межкадровые и конечный интервалы не кратны символу; фон имеет отдельные фиксированные seeds. +После добавления шума IQ не переквантован в CS8, чтобы не отсекать шум. + +Кадр занимает 5766 символьных интервалов с фильтром и защитными интервалами; JPEG — 36 кадров. +Длительность IQ включает ещё 0,079217625 с фона. Полезная скорость считается по чистому времени +JPEG, отношение обработки — по всей длительности IQ. Измеряются поиск, декодирование, сборка и +JPEG decode; генерация канала и TX-сравнение после вердикта исключены. Все восемь проходов +завершились END_OF_STREAM. Первый пробный прогон с лимитами Lab046B 32/256 был неполным; +итоговые результаты получены после перехода на полный блочный путь с независимыми лимитами 4096/100000. + +## Результаты IQ + +| Метрика | 20 ксимв/с | 50 ксимв/с | 100 ксимв/с | 200 ксимв/с | +|---|---:|---:|---:|---:| +| Выборок/символ | 400 | 160 | 80 | 40 | +| Кадр, мс | 288.300 | 115.320 | 57.660 | 28.830 | +| Кадров JPEG | 36 | 36 | 36 | 36 | +| JPEG airtime, с | 10.37880 | 4.15152 | 2.07576 | 1.03788 | +| Полезно, байт/с, идеал | 840.560 | 2101.399 | 4202.798 | 8405.596 | +| Полезно, кбит/с, идеал | 6.724 | 16.811 | 33.622 | 67.245 | +| Идеал: кадры / SHA | 36/36 / да | 36/36 / да | 36/36 / да | 36/36 / да | +| Шум: кадры | 6/36 | 36/36 | 36/36 | 36/36 | +| До FEC, принятые кадры | 67 | 147 | 119 | 125 | +| После FEC, принятые кадры | 0 | 0 | 0 | 0 | +| CRC PASS / FAIL | 6 / 0 | 36 / 0 | 36 / 0 | 36 / 0 | +| Шум: JPEG SHA / decode | нет / нет | да / да | да / да | да / да | +| Ошибки JPEG байт / бит | не восстановлен | 0 / 0 | 0 / 0 | 0 / 0 | +| Обработка идеала, с | 8.73746 | 5.69848 | 7.36877 | 6.45915 | +| Обработка шума, с | 173.65057 | 5.72980 | 7.76244 | 8.17573 | +| Длительность IQ, с | 10.458018 | 4.230738 | 2.154978 | 1.117098 | +| Обработка / IQ, шум | 16.604540 | 1.354327 | 3.602096 | 7.318724 | +| Реальное время, шум | нет | нет | нет | нет | +| Статус | FAIL | PARTIAL | PARTIAL | PARTIAL | + +На 20 ксимв/с 67 ошибок до FEC и 0 после относятся **только к шести принятым кадрам**. +JPEG не восстановлен; полезная скорость целых JPEG в шумном проходе равна нулю. +Остальные 30 кадров нельзя объявлять безошибочными. Проверено 2378 кандидатов, 2372 +отклонены на PHY header. На 50/100/200 проверено 59/237/474 кандидата. +Кандидат, не достигший CRC, не записан как CRC FAIL. Причины сохранены в JSON. +Порог и шум после отказов не менялись. Все идеальные проходы имеют ноль ошибок до/после FEC. + +Базовые 6,724 кбит/с согласуются с примерно 6,7 кбит/с Lab047. Выигрыш 2,5/5/10 +рассчитан по фактической форме и числу кадров, а не обещан заранее. +Исторические аппаратные результаты Lab047/Lab048 не опровергнуты: здесь другой канал. + +## Планирование и задержки + +Переиспользован LATEST_STATE: EMERGENCY > CONTROL > TELEMETRY > VIDEO. +Проверка даёт VIDEO(active) → EMERGENCY → CONTROL → TELEMETRY → VIDEO. +Физический кадр не прерывается, но весь JPEG не удерживает канал. +CONTROL — штатные 36 байт, 20 Гц (Lab041); TELEMETRY — синтетические 64 байта, +10 Гц (Lab037–Lab041); EMERGENCY — штатный EmergencyStop, 34 байта, t=0,777 с. +Служебная нагрузка генерируется 2 с. Запас VIDEO 256 пакетов не исчерпывается в этом окне. + +Каждый пакет модели занимает один полный PHY-кадр. Это модель занятости; +упаковка LinkPacket в аппаратный 256-байтный PHY payload не реализована. +Прикладные CONTROL/EMERGENCY сохраняют точные сериализованные байты. +Задержки не включают программное время RX, потери, повторы и привод; это не гарантия +времени физического торможения. Блокировка — остаток начатого VIDEO; +доставка EMERGENCY в худшей фазе — почти два кадра, включая саму команду. +Старое поле deadline в priority_probe сохранено как совместимое поле блокировки +с явным scope; классификация использует новый delivery flag. + +| Метрика | 20 ксимв/с | 50 ксимв/с | 100 ксимв/с | 200 ксимв/с | +|---|---:|---:|---:|---:| +| CONTROL max VIDEO blocking, мс | 288.30 | 115.32 | 57.66 | 28.83 | +| EMERGENCY max VIDEO blocking, мс | 288.30 | 115.32 | 57.66 | 28.83 | +| EMERGENCY worst delivery, мс | 576.60 | 230.64 | 115.32 | 57.66 | +| CONTROL blocking <100 / <50 мс | нет / нет | нет / нет | да / нет | да / да | +| EMERGENCY delivery ≤50 мс | нет | нет | нет | нет | + +Доставка **в активном окне 0–2 с**, min / median / max, мс. Заменённые и ещё не доставленные состояния сюда не входят: + +| Метрика | 20 ксимв/с | 50 ксимв/с | 100 ксимв/с | 200 ксимв/с | +|---|---:|---:|---:|---:| +| CONTROL | 289.90 / 303.20 / 316.50 | 116.92 / 136.28 / 162.88 | 58.84 / 82.03 / 105.64 | 30.01 / 43.94 / 61.07 | +| EMERGENCY | 376.20 / 376.20 / 376.20 | 145.56 / 145.56 / 145.56 | 87.90 / 87.90 / 87.90 | 30.24 / 30.24 / 30.24 | +| TELEMETRY | нет доставленных | нет доставленных | нет доставленных | 33.84 / 45.54 / 89.69 | +| VIDEO | 288.30 / 288.30 / 288.30 | 115.32 / 115.32 / 115.32 | 57.66 / 57.66 / 57.66 | 28.83 / 850.49 / 1845.12 | + +Доставка **до полного опустошения очереди**, отдельно от активной нагрузки: + +| Метрика | 20 ксимв/с | 50 ксимв/с | 100 ксимв/с | 200 ксимв/с | +|---|---:|---:|---:|---:| +| CONTROL, мс | 289.90 / 303.20 / 331.40 | 116.92 / 139.48 / 216.08 | 58.84 / 83.84 / 105.64 | 30.01 / 43.73 / 61.07 | +| EMERGENCY, мс | 376.20 / 376.20 / 376.20 | 145.56 / 145.56 / 145.56 | 87.90 / 87.90 / 87.90 | 30.24 / 30.24 / 30.24 | +| TELEMETRY, мс | 644.70 / 644.70 / 644.70 | 356.40 / 356.40 / 356.40 | 183.42 / 183.42 / 183.42 | 33.84 / 45.54 / 89.69 | +| VIDEO, мс | 288.30 / 39352.95 / 76111.20 | 115.32 / 17009.70 / 31713.00 | 57.66 / 9485.07 / 16836.72 | 28.83 / 5434.46 / 9110.28 | +| Конец опустошения, с | 76.11120 | 31.71300 | 16.83672 | 9.11028 | +| Превышения CONTROL / EMERGENCY | 6 / 1 | 17 / 1 | 6 / 1 | 0 / 0 | + +Очередь заменяет старое ожидающее состояние, но не удаляет пакет по deadline. +На 20/50/100 за активные 2 с доставлено 0 TELEMETRY. У 200 конкретная EMERGENCY +доставлена за 30,24 мс, но худшая фаза 57,66 мс всё равно нарушает срок 50 мс. + +## Исторический бюджет единого канала — не бюджет целевой архитектуры + +Прикладные CONTROL/TELEMETRY: 5760/5120 бит/с; вместе с LinkPacket до PHY — +10880/7680 бит/с. Физическая стоимость: 20/10 полных кадров/с, или +**115320/57660 символьных интервалов в секунду** BPSK, включая всю форму. +Эту стоимость нельзя прямо вычитать из JPEG-бит/с: остаток считается через время эфира. +JSON-поля reserved_link_bps 46080/23040 относятся к внутренним 288-байтным +представителям планировщика, а не к физическому кодированному потоку. + +| Метрика | 20 ксимв/с | 50 ксимв/с | 100 ксимв/с | 200 ксимв/с | +|---|---:|---:|---:|---:| +| CONTROL, запрошено % эфира | 576.60 | 230.64 | 115.32 | 57.66 | +| TELEMETRY, запрошено % эфира | 288.30 | 115.32 | 57.66 | 28.83 | +| EMERGENCY, событие/2с, % эфира | 14.415 | 5.766 | 2.883 | 1.442 | +| VIDEO после C+T, кбит/с | 0.000 | 0.000 | 0.000 | 9.085 | +| Теоретические JPEG/с после C+T | 0.00000 | 0.00000 | 0.00000 | 0.13017 | +| VIDEO после C+T+E, кбит/с | 0.000 | 0.000 | 0.000 | 8.115 | + +Процент больше 100 означает перегрузку, а не фактически переданные данные. +Только 200 оставляет VIDEO: 9,085 кбит/с и около 0,1302 JPEG/с; +с одной EMERGENCY за 2 с — 8,116 кбит/с. Это потенциал модели, не видеопоток: +вычислительная перегрузка и аварийный срок остаются. + +## Проверки предыдущего IQ-этапа + +Независимые verifier/adversary воспроизвели прежние отказы и подтвердили исправления. +Verifier: 24 целевых теста PASS, независимый пересчёт JSON совпал. +Adversary: правильный отрицательный аварийный вердикт и отдельное активное окно. + +- python tools/quick_gate.py: PASS, 571 тест ядра, 12 функциональных проверок. +- python -m pytest tests/ -q: 571 passed, 0 failed; одно предупреждение о запрете записи .pytest_cache. +- git diff --check: PASS. + +RF TX = 0. HackRF не запускался. Git add/commit/push/pull/fetch не выполнялись. +STOP. Следующий этап требует отдельной постановки: вычислительная перегрузка, +бюджет и задержки перед аппаратным опытом. QPSK, новый FEC, реальное видео и +переход на 180 МГц в этой работе не выполнялись. diff --git a/experiments/lab049_bpsk_symbol_rate.py b/experiments/lab049_bpsk_symbol_rate.py new file mode 100644 index 0000000..90fb4ff --- /dev/null +++ b/experiments/lab049_bpsk_symbol_rate.py @@ -0,0 +1,1172 @@ +"""Lab049: программное сравнение символьных скоростей BPSK. + +Форматы PRBS7, PHY, свёрточного FEC, CRC и фрагментов остаются теми же, +что в Lab045--Lab047. Меняются только символьная скорость и точное целое +число выборок на символ при неизменной частоте дискретизации 8 Мвыб/с. + +Основной проход использует полный IQ-поток без переданных границ кадров, +битов, нагрузки или ожидаемого числа кадров. Сравнение с переданными данными +выполняется только после независимого решения PHY/FEC/CRC и сборки JPEG. +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass, replace +from functools import lru_cache +import hashlib +import json +import math +from pathlib import Path +import struct +import sys +import time + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import numpy as np +from scipy.signal import oaconvolve + +from experiments import lab043_pluto_to_rtlsdr as shaping +from experiments import lab045_hackrf_bpsk_fec as lab045 +from experiments import lab046_bpsk_object_transport as lab046 +from experiments import lab047_jpeg_object_transport as lab047 +from protocol import bpsk_radio as radio +from protocol.control_messages import ( + ControlStateMessage, + EmergencyStop, + MessageContext, + encode_message, +) +from protocol.link_packet import ( + HEADER_SIZE, + Direction, + LinkPacket, + TrafficClass, + encode_link_packet, +) +from protocol.priority_scheduler import ( + QueuedPacket, + SchedulerMode, + schedule_packets, +) + + +SAMPLE_RATE_HZ = 8_000_000 +MANDATORY_SYMBOL_RATES = (20_000, 50_000, 100_000) +OPTIONAL_SYMBOL_RATE = 200_000 +TESTED_SYMBOL_RATES = (*MANDATORY_SYMBOL_RATES, OPTIONAL_SYMBOL_RATE) + +RRC_ROLLOFF = 0.35 +NOISY_CODED_BIT_EBN0_DB = 7.0 +NOISE_BASE_SEED = 49_000 + +JPEG_PATH = Path("data/processed/lab047/tx_image.jpg") +EXPECTED_JPEG_BYTES = 8_724 +EXPECTED_JPEG_SHA256 = ( + "e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a" +) +OUTPUT_DIRECTORY = Path("data/processed/lab049") +RESULT_NAME = "result.json" + +CONTROL_PERIOD_SECONDS = 0.050 +TELEMETRY_PERIOD_SECONDS = 0.100 +SCHEDULER_SOURCE_SECONDS = 2.0 +EMERGENCY_TIME_SECONDS = 0.777 +VIDEO_BACKLOG_PACKETS = 256 +TELEMETRY_APPLICATION_BYTES = 64 + + +@dataclass(frozen=True) +class PhyMode: + """Одна точная программная конфигурация частоты символов.""" + + symbol_rate: int + sample_rate_hz: int = SAMPLE_RATE_HZ + + def __post_init__(self) -> None: + if any(isinstance(value, bool) or not isinstance(value, (int, np.integer)) + for value in (self.symbol_rate, self.sample_rate_hz)): + raise ValueError("Частоты должны быть целыми, без скрытого округления") + if self.symbol_rate <= 0: + raise ValueError("Символьная скорость должна быть положительной") + if self.sample_rate_hz <= 0: + raise ValueError("Частота дискретизации должна быть положительной") + if self.sample_rate_hz % self.symbol_rate != 0: + raise ValueError("Число выборок на символ должно быть целым") + + @property + def samples_per_symbol(self) -> int: + return self.sample_rate_hz // self.symbol_rate + + +@dataclass(frozen=True) +class IqFrameReception: + """Независимый результат одного выровненного физического кадра.""" + + phy_header_pass: bool + fec_pass: bool + crc: str + payload_bytes: bytes | None + post_preamble_soft: np.ndarray + timing_offset_samples: int + reception: lab045.IndependentReception + + +@dataclass +class _RoundtripAccumulator: + assembler: lab046.ObjectAssembler + processing_seconds: float = 0.0 + phy_header_pass: int = 0 + phy_header_fail: int = 0 + fec_pass: int = 0 + fec_fail: int = 0 + crc_pass: int = 0 + crc_fail: int = 0 + crc_not_reached: int = 0 + pre_fec_errors: int = 0 + post_fec_errors: int = 0 + noise_sigmas: list[float] | None = None + + +def phy_mode(symbol_rate: int) -> PhyMode: + """Вернуть режим только при точном целом отношении Fs/Rs.""" + + return PhyMode(symbol_rate) + + +def frame_waveform_sample_count( + plan: lab045.TransmissionPlan, + mode: PhyMode, +) -> int: + """Точная длина прежней формы при заданном числе выборок на символ.""" + + symbol_intervals = ( + plan.frame_symbol_count + + 2 * lab045.GUARD_SYMBOL_COUNT + + lab045.RRC_SPAN_SYMBOLS + ) + return symbol_intervals * mode.samples_per_symbol + + +def build_parameterized_tx_complex( + plan: lab045.TransmissionPlan, + mode: PhyMode, +) -> np.ndarray: + """Сформировать прежнюю BPSK/RRC-форму с параметрическим Rs.""" + + symbols = radio.bpsk_modulate(plan.frame_bits) + shaped = shaping.shape_frame_like_lab042(symbols, mode.samples_per_symbol) + indexes = np.arange(shaped.size, dtype=np.float64) + carriered = shaped * np.exp( + 2j + * np.pi + * lab045.CARRIER_OFFSET_HZ + * indexes + / mode.sample_rate_hz + ) + rms = float(np.sqrt(np.mean(np.abs(carriered) ** 2))) + if not np.isfinite(rms) or rms <= 0.0: + raise ValueError("Нулевая или нечисловая мощность формы") + result = carriered * (lab045.lab044.TARGET_RMS_CODE / rms) + expected = frame_waveform_sample_count(plan, mode) + if result.size != expected: + raise RuntimeError("Параметрическая форма имеет неверную длину") + return result + + +def build_parameterized_tx_cs8( + plan: lab045.TransmissionPlan, + mode: PhyMode, +) -> bytes: + """Квантовать параметрическую форму тем же преобразователем CS8.""" + + return lab045.lab044.lab043.to_cs8_bytes( + build_parameterized_tx_complex(plan, mode) + ) + + +@lru_cache(maxsize=len(TESTED_SYMBOL_RATES)) +def _matched_filter_taps(samples_per_symbol: int) -> np.ndarray: + taps = radio.root_raised_cosine_taps( + RRC_ROLLOFF, + samples_per_symbol, + lab045.RRC_SPAN_SYMBOLS, + ).astype(np.float32) + taps.flags.writeable = False + return taps + + +def _preamble_timing_offset( + matched: np.ndarray, + nominal: int, + samples_per_symbol: int, +) -> int: + """Найти фазу дискретизации только по известной PRBS7-преамбуле.""" + + expected = radio.bpsk_modulate(lab045.lab044.preamble_bits()) + offsets = np.arange(-2 * samples_per_symbol, 2 * samples_per_symbol + 1) + scores = np.zeros(offsets.size, dtype=np.float64) + first_sample = nominal + int(offsets[0]) + for phase in range(samples_per_symbol): + score_indexes = np.arange(phase, offsets.size, samples_per_symbol) + sequence_length = expected.size + score_indexes.size - 1 + start = first_sample + phase + stop = start + sequence_length * samples_per_symbol + sequence = matched[start:stop:samples_per_symbol] + if start < 0 or sequence.size != sequence_length: + raise RuntimeError("Недостаточно выборок для поиска синхронизации") + windows = np.lib.stride_tricks.sliding_window_view( + sequence, + expected.size, + ) + correlation = windows @ np.conj(expected) + energy = np.sum(np.abs(windows) ** 2, axis=1) + scores[score_indexes] = np.abs(correlation) / np.sqrt( + expected.size * energy + 1e-30 + ) + return int(offsets[int(np.argmax(scores))]) + + +def receive_iq_frame( + iq_samples: np.ndarray, + mode: PhyMode, +) -> IqFrameReception: + """Принять один IQ-кадр без переданных битов, пакета или payload.""" + + samples = np.asarray(iq_samples, dtype=np.complex64) + if samples.ndim != 1 or samples.size == 0 or not np.all(np.isfinite(samples)): + raise ValueError("Нужен конечный одномерный IQ-кадр") + indexes = np.arange(samples.size, dtype=np.float64) + centered = samples - np.complex64(np.mean(samples, dtype=np.complex128)) + baseband = ( + centered + * np.exp( + -2j + * np.pi + * lab045.CARRIER_OFFSET_HZ + * indexes + / mode.sample_rate_hz + ).astype(np.complex64) + ) + taps = _matched_filter_taps(mode.samples_per_symbol) + matched = oaconvolve(baseband, taps, mode="full") + filter_delay = (len(taps) - 1) // 2 + nominal = lab045.GUARD_SYMBOL_COUNT * mode.samples_per_symbol + 2 * filter_delay + timing = _preamble_timing_offset( + matched, + nominal, + mode.samples_per_symbol, + ) + + probe_count = lab045.PREAMBLE_BIT_COUNT + lab045.PHY_HEADER_BIT_COUNT + probe = matched[ + nominal + timing : + nominal + timing + probe_count * mode.samples_per_symbol : + mode.samples_per_symbol + ].astype(np.complex128) + if probe.size != probe_count: + raise RuntimeError("Физический кадр оборван до PHY-заголовка") + expected_preamble = radio.bpsk_modulate(lab045.lab044.preamble_bits()) + gain = complex(np.mean( + probe[: lab045.PREAMBLE_BIT_COUNT] * np.conj(expected_preamble) + )) + if abs(gain) <= 1e-30: + raise RuntimeError("Не удалось оценить комплексный коэффициент канала") + normalized_probe = (probe / gain).real + header = lab045.decode_phy_header_soft( + normalized_probe[ + lab045.PREAMBLE_BIT_COUNT : + lab045.PREAMBLE_BIT_COUNT + lab045.PHY_HEADER_BIT_COUNT + ] + ) + if not header["passed"]: + reception = lab045.analyze_post_preamble_soft( + normalized_probe[lab045.PREAMBLE_BIT_COUNT:] + ) + return IqFrameReception( + False, + False, + str(reception.analysis["crc"]), + None, + normalized_probe[lab045.PREAMBLE_BIT_COUNT:], + timing, + reception, + ) + + packet_bytes = int(header["packet_bytes"]) + coded_bits = lab045.conv.encoded_bit_count(packet_bytes * 8) + total_symbols = ( + lab045.PREAMBLE_BIT_COUNT + + lab045.PHY_HEADER_BIT_COUNT + + coded_bits + ) + symbols = matched[ + nominal + timing : + nominal + timing + total_symbols * mode.samples_per_symbol : + mode.samples_per_symbol + ].astype(np.complex128) + if symbols.size != total_symbols: + raise RuntimeError("Физический кадр оборван до конца кодированной части") + normalized = (symbols / gain).real + post_preamble = normalized[lab045.PREAMBLE_BIT_COUNT:] + reception = lab045.analyze_post_preamble_soft(post_preamble) + return IqFrameReception( + True, + bool(reception.analysis["fec_decode_pass"]), + str(reception.analysis["crc"]), + reception.payload_bytes, + post_preamble, + timing, + reception, + ) + + +def add_coded_bit_ebn0_noise( + iq_samples: np.ndarray, + transmitted_symbol_count: int, + ebn0_db: float, + seed: int, +) -> tuple[np.ndarray, float]: + """Добавить комплексный AWGN при одинаковом coded-bit Eb/N0. + + Для BPSK один переданный символ несёт один кодированный бит. Энергия бита + измеряется непосредственно по всей квантованной форме, поэтому при смене + samples/symbol шум физически масштабируется как sqrt(Eb), а не подбирается + отдельно для режима. + """ + + samples = np.asarray(iq_samples, dtype=np.complex64) + if transmitted_symbol_count <= 0: + raise ValueError("Число переданных символов должно быть положительным") + if not np.isfinite(ebn0_db): + raise ValueError("Eb/N0 должен быть конечным") + energy_per_bit = float( + np.sum(np.abs(samples) ** 2, dtype=np.float64) + / transmitted_symbol_count + ) + linear = 10.0 ** (ebn0_db / 10.0) + component_sigma = math.sqrt(energy_per_bit / (2.0 * linear)) + generator = np.random.default_rng(seed) + noisy = samples.copy() + noisy.real += np.float32(component_sigma) * generator.standard_normal( + samples.size, + dtype=np.float32, + ) + noisy.imag += np.float32(component_sigma) * generator.standard_normal( + samples.size, + dtype=np.float32, + ) + return noisy, component_sigma + + +def _offer_reception( + accumulator: _RoundtripAccumulator, + frame: lab046.FramePlan, + received: IqFrameReception, +) -> None: + accumulator.phy_header_pass += int(received.phy_header_pass) + accumulator.phy_header_fail += int(not received.phy_header_pass) + accumulator.fec_pass += int(received.fec_pass) + accumulator.fec_fail += int(received.phy_header_pass and not received.fec_pass) + accumulator.crc_pass += int(received.crc == lab045.CRC_PASS) + accumulator.crc_fail += int(received.crc == lab045.CRC_FAIL) + accumulator.crc_not_reached += int(received.crc == lab045.CRC_NOT_REACHED) + + diagnostic = lab045.diagnostic_pre_and_post_fec( + received.reception, + received.post_preamble_soft, + frame.plan, + ) + pre_fec = diagnostic["pre_fec_coded"]["bit_errors"] + post_fec = diagnostic["post_fec_packet"]["bit_errors"] + if pre_fec is not None: + accumulator.pre_fec_errors += int(pre_fec) + if post_fec is not None: + accumulator.post_fec_errors += int(post_fec) + + if received.crc == lab045.CRC_PASS and received.payload_bytes is not None: + accumulator.assembler.offer( + lab046.recover_fragment_from_payload(received.payload_bytes) + ) + + +def _roundtrip_summary( + accumulator: _RoundtripAccumulator, + jpeg: bytes, + frame_count: int, + capture_seconds: float, +) -> dict: + recovered = accumulator.assembler.recovered_object() + recovered_sha = ( + hashlib.sha256(recovered).hexdigest() if recovered is not None else None + ) + byte_errors: int | None = None + bit_errors: int | None = None + if recovered is not None and len(recovered) == len(jpeg): + difference = np.bitwise_xor( + np.frombuffer(recovered, dtype=np.uint8), + np.frombuffer(jpeg, dtype=np.uint8), + ) + byte_errors = int(np.count_nonzero(difference)) + bit_errors = int(np.unpackbits(difference).sum()) + sigmas = accumulator.noise_sigmas or [] + return { + "jpeg_frame_count": frame_count, + "phy_header_pass": accumulator.phy_header_pass, + "phy_header_fail": accumulator.phy_header_fail, + "fec_pass": accumulator.fec_pass, + "fec_fail": accumulator.fec_fail, + "crc_pass": accumulator.crc_pass, + "crc_fail": accumulator.crc_fail, + "crc_not_reached": accumulator.crc_not_reached, + "valid_frames": accumulator.assembler.counters.accepted, + "missing_fragments": accumulator.assembler.missing_indexes(), + "object_complete": accumulator.assembler.object_complete(), + "pre_fec_coded_bit_errors": accumulator.pre_fec_errors, + "post_fec_protocol_bit_errors": accumulator.post_fec_errors, + "recovered_sha256": recovered_sha, + "sha_match": recovered_sha == hashlib.sha256(jpeg).hexdigest(), + "byte_errors": byte_errors, + "bit_errors": bit_errors, + "receiver_processing_seconds": accumulator.processing_seconds, + "capture_equivalent_seconds": capture_seconds, + "processing_to_capture_ratio": ( + accumulator.processing_seconds / capture_seconds + ), + "real_time_capable": accumulator.processing_seconds < capture_seconds, + "noise_component_sigma_min": min(sigmas) if sigmas else 0.0, + "noise_component_sigma_median": ( + float(np.median(sigmas)) if sigmas else 0.0 + ), + "noise_component_sigma_max": max(sigmas) if sigmas else 0.0, + } + + +def run_mode_roundtrips(jpeg: bytes, mode: PhyMode) -> tuple[dict, dict]: + """Выполнить идеальный и единый шумный IQ-тракт для всего JPEG.""" + + from experiments.lab049_independent_iq import run_roundtrips + + return run_roundtrips(jpeg, mode) + + +def run_frame_aligned_roundtrips(jpeg: bytes, mode: PhyMode) -> tuple[dict, dict]: + """Историческая локальная проверка; не измеряет независимый поиск потока.""" + + frames = tuple(lab046.build_object_frames(jpeg, lab047.OBJECT_ID)) + ideal = _RoundtripAccumulator(lab046.ObjectAssembler()) + noisy = _RoundtripAccumulator(lab046.ObjectAssembler(), noise_sigmas=[]) + capture_samples = 0 + + for frame_number, frame in enumerate(frames): + tx_cs8 = build_parameterized_tx_cs8(frame.plan, mode) + iq = lab045.lab044.signed_iq_bytes_to_complex64(tx_cs8) + capture_samples += iq.size + + started = time.perf_counter() + ideal_received = receive_iq_frame(iq, mode) + ideal.processing_seconds += time.perf_counter() - started + _offer_reception(ideal, frame, ideal_received) + + noisy_iq, sigma = add_coded_bit_ebn0_noise( + iq, + frame.plan.frame_symbol_count, + NOISY_CODED_BIT_EBN0_DB, + NOISE_BASE_SEED + frame_number, + ) + assert noisy.noise_sigmas is not None + noisy.noise_sigmas.append(sigma) + started = time.perf_counter() + noisy_received = receive_iq_frame(noisy_iq, mode) + noisy.processing_seconds += time.perf_counter() - started + _offer_reception(noisy, frame, noisy_received) + + capture_seconds = capture_samples / mode.sample_rate_hz + return ( + _roundtrip_summary(ideal, jpeg, len(frames), capture_seconds), + _roundtrip_summary(noisy, jpeg, len(frames), capture_seconds), + ) + + +def single_frame_roundtrip(symbol_rate: int) -> dict: + """Быстрая сквозная проверка одного кадра для тестов.""" + + payload = bytes((index * 49 + 7) & 0xFF for index in range(256)) + plan = lab045.build_transmission_plan(payload) + mode = phy_mode(symbol_rate) + iq = lab045.lab044.signed_iq_bytes_to_complex64( + build_parameterized_tx_cs8(plan, mode) + ) + received = receive_iq_frame(iq, mode) + return { + "phy_header_pass": received.phy_header_pass, + "fec_pass": received.fec_pass, + "crc": received.crc, + "payload_match": received.payload_bytes == payload, + } + + +def _padded_bytes(prefix: bytes, size: int, sequence: int) -> bytes: + head = prefix + struct.pack("!I", sequence) + tail = bytes((sequence * 37 + index * 17) & 0xFF for index in range(size)) + return (head + tail)[:size] + + +def _control_payload(sequence: int) -> bytes: + context = MessageContext(49, 1, 1, sequence) + return encode_message(ControlStateMessage(context, 1.0, 0.0, False, True)) + + +def _emergency_payload(sequence: int) -> bytes: + context = MessageContext(49, 1, 1, sequence) + return encode_message(EmergencyStop(context, 49)) + + +def _link_packet( + traffic_class: TrafficClass, + sequence: int, + generation_seconds: float, + payload: bytes, +) -> LinkPacket: + directions = { + TrafficClass.EMERGENCY: Direction.GROUND_TO_ROVER, + TrafficClass.CONTROL: Direction.GROUND_TO_ROVER, + TrafficClass.TELEMETRY: Direction.ROVER_TO_GROUND, + TrafficClass.VIDEO: Direction.ROVER_TO_GROUND, + } + deadlines = { + TrafficClass.EMERGENCY: 50, + TrafficClass.CONTROL: 100, + TrafficClass.TELEMETRY: 500, + TrafficClass.VIDEO: 0, + } + return LinkPacket( + traffic_class=traffic_class, + direction=directions[traffic_class], + stream_id=int(traffic_class), + sequence_number=sequence, + generation_time_us=int(round(generation_seconds * 1_000_000)), + deadline_ms=deadlines[traffic_class], + payload=payload, + ) + + +def _schedule_fixed_physical_frames( + queued_packets: tuple[QueuedPacket, ...], + frame_duration_seconds: float, +): + """Применить штатный планировщик к одинаковым физическим кадрам. + + Исходные ``LinkPacket`` содержат неизменённые прикладные сообщения. Для + расчёта только занятости линии создаются внутренние служебные представители + той же очереди с нагрузкой 256 байт: поэтому штатный планировщик отводит + каждому элементу ровно длительность кадра Lab045. Перед возвратом результата + внутренние представители заменяются исходными пакетами и их точными байтами. + """ + + if frame_duration_seconds <= 0.0: + raise ValueError("Длительность физического кадра должна быть положительной") + actual_by_order = {item.arrival_order: item for item in queued_packets} + if len(actual_by_order) != len(queued_packets): + raise ValueError("arrival_order должен быть уникальным") + scheduling_packets = tuple( + QueuedPacket( + packet=LinkPacket( + traffic_class=item.packet.traffic_class, + direction=item.packet.direction, + stream_id=item.packet.stream_id, + sequence_number=item.packet.sequence_number, + generation_time_us=item.packet.generation_time_us, + deadline_ms=item.packet.deadline_ms, + payload=bytes(lab046.PACKET_PAYLOAD_SIZE), + ), + arrival_order=item.arrival_order, + ) + for item in queued_packets + ) + occupancy_schedule = schedule_packets( + scheduling_packets, + SchedulerMode.LATEST_STATE, + _scheduler_channel_bitrate(frame_duration_seconds), + ) + transmitted = tuple( + replace( + item, + queued=actual_by_order[item.queued.arrival_order], + blocked_by=( + None + if item.blocked_by is None + else actual_by_order[item.blocked_by.arrival_order] + ), + wire_packet=encode_link_packet( + actual_by_order[item.queued.arrival_order].packet + ), + ) + for item in occupancy_schedule.transmitted + ) + replacements = tuple( + replace( + item, + removed=actual_by_order[item.removed.arrival_order], + replacement=actual_by_order[item.replacement.arrival_order], + ) + for item in occupancy_schedule.replacements + ) + return replace( + occupancy_schedule, + transmitted=transmitted, + replacements=replacements, + ) + + +def _latency_statistics(schedule, traffic_class: TrafficClass, *, completion=False) -> dict: + values = np.asarray( + [ + (item.end_seconds if completion else item.start_seconds) + - item.queued.generation_time_seconds + for item in schedule.transmitted + if item.queued.packet.traffic_class is traffic_class + ], + dtype=np.float64, + ) + if values.size == 0: + return {"count": 0, "min_ms": None, "median_ms": None, "max_ms": None} + return { + "count": int(values.size), + "min_ms": float(np.min(values) * 1000.0), + "median_ms": float(np.median(values) * 1000.0), + "max_ms": float(np.max(values) * 1000.0), + } + + +def _deadline_misses(schedule, traffic_class: TrafficClass) -> int: + """Посчитать переданные после собственного ненулевого deadline пакеты.""" + + return sum( + item.queued.packet.traffic_class is traffic_class + and item.queued.packet.deadline_ms > 0 + and ( + item.end_seconds - item.queued.generation_time_seconds + ) * 1000.0 > item.queued.packet.deadline_ms + 1e-9 + for item in schedule.transmitted + ) + + +def _scheduler_channel_bitrate(frame_duration_seconds: float) -> float: + scheduler_video_wire_bytes = HEADER_SIZE + lab046.PACKET_PAYLOAD_SIZE + return scheduler_video_wire_bytes * 8.0 / frame_duration_seconds + + +def channel_budget( + frame_duration_seconds: float, + jpeg_bytes: int, + jpeg_frame_count: int, +) -> dict: + """Бюджет общего LinkPacket-канала, привязанный к длительности VIDEO.""" + + if jpeg_frame_count <= 0: + raise ValueError("Число кадров JPEG должно быть положительным") + physical_wire_bytes = HEADER_SIZE + lab046.PACKET_PAYLOAD_SIZE + channel_bitrate = _scheduler_channel_bitrate(frame_duration_seconds) + frame_capacity_per_second = 1.0 / frame_duration_seconds + control_frames_per_second = 1.0 / CONTROL_PERIOD_SECONDS + telemetry_frames_per_second = 1.0 / TELEMETRY_PERIOD_SECONDS + control_airtime_fraction = control_frames_per_second * frame_duration_seconds + telemetry_airtime_fraction = ( + telemetry_frames_per_second * frame_duration_seconds + ) + control_bps = physical_wire_bytes * 8.0 / CONTROL_PERIOD_SECONDS + telemetry_bps = physical_wire_bytes * 8.0 / TELEMETRY_PERIOD_SECONDS + remaining_link_bps = max(0.0, channel_bitrate - control_bps - telemetry_bps) + available_video_frames_per_second = max( + 0.0, + frame_capacity_per_second + - control_frames_per_second + - telemetry_frames_per_second, + ) + video_efficiency = jpeg_bytes / (jpeg_frame_count * physical_wire_bytes) + jpeg_bits_per_physical_frame = jpeg_bytes * 8.0 / jpeg_frame_count + available_video_bps = ( + available_video_frames_per_second * jpeg_bits_per_physical_frame + ) + return { + "scheduler_link_bitrate_bps": channel_bitrate, + "scheduler_video_wire_bytes": physical_wire_bytes, + "physical_payload_bytes_per_packet": lab046.PACKET_PAYLOAD_SIZE, + "fixed_physical_frame_per_packet": True, + "physical_frame_capacity_per_second": frame_capacity_per_second, + "control_application_bytes": len(_control_payload(0)), + "control_application_bps": ( + len(_control_payload(0)) * 8.0 / CONTROL_PERIOD_SECONDS + ), + "control_reserved_frames_per_second": control_frames_per_second, + "control_reserved_airtime_fraction": control_airtime_fraction, + "control_reserved_link_bps": control_bps, + "telemetry_application_bytes": TELEMETRY_APPLICATION_BYTES, + "telemetry_application_bps": ( + TELEMETRY_APPLICATION_BYTES * 8.0 / TELEMETRY_PERIOD_SECONDS + ), + "telemetry_load_frames_per_second": telemetry_frames_per_second, + "telemetry_airtime_fraction": telemetry_airtime_fraction, + "telemetry_load_link_bps": telemetry_bps, + "service_requested_airtime_fraction": ( + control_airtime_fraction + telemetry_airtime_fraction + ), + "remaining_link_bps": remaining_link_bps, + "video_available_frames_per_second": available_video_frames_per_second, + "video_payload_efficiency": video_efficiency, + "video_available_bitrate_bps": available_video_bps, + "theoretical_jpeg_fps": available_video_bps / (jpeg_bytes * 8.0), + "emergency_events_per_second_in_scenario": 1.0 / SCHEDULER_SOURCE_SECONDS, + "emergency_airtime_fraction_in_scenario": ( + frame_duration_seconds / SCHEDULER_SOURCE_SECONDS + ), + "video_available_bitrate_with_emergency_bps": max( + 0.0, available_video_frames_per_second - 1.0 / SCHEDULER_SOURCE_SECONDS + ) * jpeg_bits_per_physical_frame, + } + + +def priority_probe(frame_duration_seconds: float) -> dict: + """Проверить непрерываемость кадра и выбор класса на его границе.""" + + arrival = frame_duration_seconds / 4.0 + packets = ( + _link_packet( + TrafficClass.VIDEO, + 0, + 0.0, + bytes(lab046.PACKET_PAYLOAD_SIZE), + ), + _link_packet( + TrafficClass.VIDEO, + 1, + 0.0, + bytes(lab046.PACKET_PAYLOAD_SIZE), + ), + _link_packet( + TrafficClass.TELEMETRY, + 0, + arrival, + _padded_bytes(b"TELEMETRY", TELEMETRY_APPLICATION_BYTES, 0), + ), + _link_packet( + TrafficClass.CONTROL, + 0, + arrival, + _control_payload(0), + ), + _link_packet( + TrafficClass.EMERGENCY, + 0, + arrival, + _emergency_payload(0), + ), + ) + queued = tuple( + QueuedPacket(packet=packet, arrival_order=index) + for index, packet in enumerate(packets) + ) + schedule = _schedule_fixed_physical_frames(queued, frame_duration_seconds) + order = [item.queued.packet.traffic_class.name for item in schedule.transmitted] + expected = ["VIDEO", "EMERGENCY", "CONTROL", "TELEMETRY", "VIDEO"] + first = schedule.transmitted[0] + return { + "transmission_order": order, + "expected_order": expected, + "priority_pass": order == expected, + "first_video_not_preempted": ( + first.queued.packet.traffic_class is TrafficClass.VIDEO + and math.isclose( + first.end_seconds - first.start_seconds, + frame_duration_seconds, + rel_tol=0.0, + abs_tol=1e-12, + ) + ), + "control_max_video_blocking_ms": frame_duration_seconds * 1000.0, + "emergency_max_video_blocking_ms": frame_duration_seconds * 1000.0, + "emergency_deadline_ms": 50.0, + "emergency_deadline_met_in_worst_phase": frame_duration_seconds <= 0.050, + "legacy_deadline_field_scope": "blocking_only_not_delivery", + "emergency_worst_delivery_ms": 2.0 * frame_duration_seconds * 1000.0, + "emergency_delivery_deadline_met_in_worst_phase": ( + 2.0 * frame_duration_seconds <= 0.050 + ), + } + + +def loaded_scheduler_scenario(frame_duration_seconds: float) -> dict: + """Постоянно заполненная VIDEO-очередь и периодический служебный трафик.""" + + packets: list[LinkPacket] = [] + for sequence in range(VIDEO_BACKLOG_PACKETS): + packets.append( + _link_packet( + TrafficClass.VIDEO, + sequence, + 0.0, + _padded_bytes(b"VIDEO", lab046.PACKET_PAYLOAD_SIZE, sequence), + ) + ) + control_times = np.arange( + CONTROL_PERIOD_SECONDS / 2.0, + SCHEDULER_SOURCE_SECONDS, + CONTROL_PERIOD_SECONDS, + ) + for sequence, generated in enumerate(control_times): + packets.append( + _link_packet( + TrafficClass.CONTROL, + sequence, + float(generated), + _control_payload(sequence), + ) + ) + telemetry_times = np.arange( + TELEMETRY_PERIOD_SECONDS / 2.0, + SCHEDULER_SOURCE_SECONDS, + TELEMETRY_PERIOD_SECONDS, + ) + for sequence, generated in enumerate(telemetry_times): + packets.append( + _link_packet( + TrafficClass.TELEMETRY, + sequence, + float(generated), + _padded_bytes( + b"TELEMETRY", + TELEMETRY_APPLICATION_BYTES, + sequence, + ), + ) + ) + packets.append( + _link_packet( + TrafficClass.EMERGENCY, + 0, + EMERGENCY_TIME_SECONDS, + _emergency_payload(0), + ) + ) + packets.sort( + key=lambda packet: ( + packet.generation_time_us, + int(packet.traffic_class), + packet.sequence_number, + ) + ) + queued = tuple( + QueuedPacket(packet=packet, arrival_order=index) + for index, packet in enumerate(packets) + ) + schedule = _schedule_fixed_physical_frames(queued, frame_duration_seconds) + active_schedule = replace(schedule, transmitted=tuple( + item for item in schedule.transmitted + if item.end_seconds <= SCHEDULER_SOURCE_SECONDS + )) + created_by_class = { + traffic.name: sum(packet.traffic_class is traffic for packet in packets) + for traffic in TrafficClass + } + transmitted_by_class = { + traffic.name: sum( + item.queued.packet.traffic_class is traffic + for item in schedule.transmitted + ) + for traffic in TrafficClass + } + replacements_by_class = { + traffic.name: sum( + item.removed.packet.traffic_class is traffic + for item in schedule.replacements + ) + for traffic in TrafficClass + } + return { + "scheduler": SchedulerMode.LATEST_STATE.value, + "source_duration_seconds": SCHEDULER_SOURCE_SECONDS, + "control_period_seconds": CONTROL_PERIOD_SECONDS, + "telemetry_period_seconds": TELEMETRY_PERIOD_SECONDS, + "emergency_time_seconds": EMERGENCY_TIME_SECONDS, + "video_backlog_packets": VIDEO_BACKLOG_PACKETS, + "created_packets": created_by_class, + "transmitted_packets": transmitted_by_class, + "replaced_waiting_packets": replacements_by_class, + "latency": { + traffic.name: _latency_statistics(schedule, traffic) + for traffic in TrafficClass + }, + "latency_scope": "queue_wait_until_drain_end", + "delivery_latency_until_drain_end": { + traffic.name: _latency_statistics(schedule, traffic, completion=True) + for traffic in TrafficClass + }, + "active_window_delivery_latency": { + traffic.name: _latency_statistics(active_schedule, traffic, completion=True) + for traffic in TrafficClass + }, + "active_window_delivered_packets": { + traffic.name: sum(item.queued.packet.traffic_class is traffic + for item in active_schedule.transmitted) + for traffic in TrafficClass + }, + "drain_end_seconds": max(item.end_seconds for item in schedule.transmitted), + "deadline_metric": "end_minus_generation", + "deadline_misses": { + traffic.name: _deadline_misses(schedule, traffic) + for traffic in TrafficClass + }, + "scheduler_drops_expired_packets": False, + "application_payloads_padded": False, + "fixed_physical_frame_duration_model": True, + "video_queue_saturated": True, + } + + +def processing_to_capture_ratio( + processing_seconds: float, + capture_seconds: float, +) -> float: + if processing_seconds < 0.0: + raise ValueError("Время обработки не может быть отрицательным") + if capture_seconds <= 0.0: + raise ValueError("Длительность записи должна быть положительной") + return processing_seconds / capture_seconds + + +def _roundtrip_pass(result: dict, expected_frames: int) -> bool: + return bool( + result["valid_frames"] == expected_frames + and result["crc_pass"] == expected_frames + and result["crc_fail"] == 0 + and result.get("termination_reason", "END_OF_STREAM") == "END_OF_STREAM" + and result.get("jpeg_decode", True) + and result["object_complete"] + and result["sha_match"] + and result["byte_errors"] == 0 + and result["bit_errors"] == 0 + ) + + +def classify_mode( + exact_roundtrips: bool, + priority_pass: bool, + real_time_capable: bool, + control_target_met: bool, + emergency_deadline_met: bool, + video_capacity_positive: bool, +) -> str: + """Классифицировать режим по всем критериям Lab049.""" + + if ( + exact_roundtrips + and priority_pass + and real_time_capable + and control_target_met + and emergency_deadline_met + and video_capacity_positive + ): + return "PASS" + if exact_roundtrips and priority_pass: + return "PARTIAL" + return "FAIL" + + +def run_mode(jpeg: bytes, symbol_rate: int) -> dict: + print(f"Lab049: independent IQ {symbol_rate} symbol/s", file=sys.stderr, flush=True) + mode = phy_mode(symbol_rate) + frames = tuple(lab046.build_object_frames(jpeg, lab047.OBJECT_ID)) + frame_samples = frame_waveform_sample_count(frames[0].plan, mode) + frame_duration = frame_samples / mode.sample_rate_hz + jpeg_airtime = frame_duration * len(frames) + ideal, noisy = run_mode_roundtrips(jpeg, mode) + probe = priority_probe(frame_duration) + scheduler = loaded_scheduler_scenario(frame_duration) + budget = channel_budget(frame_duration, len(jpeg), len(frames)) + goodput_bps = len(jpeg) * 8.0 / jpeg_airtime + baseline_airtime = ( + frame_samples + // mode.samples_per_symbol + * len(frames) + / MANDATORY_SYMBOL_RATES[0] + ) + baseline_goodput_bps = len(jpeg) * 8.0 / baseline_airtime + exact_roundtrips = _roundtrip_pass(ideal, len(frames)) and _roundtrip_pass( + noisy, + len(frames), + ) + real_time = bool(noisy["real_time_capable"]) + control_target = frame_duration < 0.100 + video_capacity = budget["video_available_bitrate_bps"] > 0.0 + emergency_deadline = bool(probe["emergency_delivery_deadline_met_in_worst_phase"]) + status = classify_mode( + exact_roundtrips, + bool(probe["priority_pass"]), + real_time, + control_target, + emergency_deadline, + video_capacity, + ) + return { + "symbol_rate": mode.symbol_rate, + "samples_per_symbol": mode.samples_per_symbol, + "sample_rate_hz": mode.sample_rate_hz, + "frame_symbol_intervals": frame_samples // mode.samples_per_symbol, + "frame_samples": frame_samples, + "frame_duration_seconds": frame_duration, + "jpeg_rf_frames": len(frames), + "jpeg_airtime_seconds": jpeg_airtime, + "useful_goodput_bytes_per_second": len(jpeg) / jpeg_airtime, + "useful_goodput_kbit_per_second": goodput_bps / 1000.0, + "goodput_gain_over_20ksps": goodput_bps / baseline_goodput_bps, + "ideal": ideal, + "noisy": { + "coded_bit_ebn0_db": NOISY_CODED_BIT_EBN0_DB, + **noisy, + }, + "priority_probe": probe, + "loaded_scheduler": scheduler, + "channel_budget": budget, + "control_under_100ms": control_target, + "control_under_50ms": frame_duration < 0.050, + "emergency_50ms_deadline_met": emergency_deadline, + "video_capacity_positive": video_capacity, + "mode_result": status, + } + + +def choose_recommended_mode(mode_results: list[dict]) -> tuple[int | None, str]: + """Выбрать один диагностический PHY-режим с точным независимым приёмом.""" + by_rate = {item["symbol_rate"]: item for item in mode_results} + preferred = by_rate.get(100_000) + if ( + preferred is not None + and _roundtrip_pass(preferred["ideal"], preferred["jpeg_rf_frames"]) + and _roundtrip_pass(preferred["noisy"], preferred["jpeg_rf_frames"]) + and preferred["noisy"]["real_time_capable"] + and preferred["control_under_100ms"] + ): + return ( + 100_000, + "Диагностический кандидат отдельного аппаратного PHY-опыта: точный " + "шумный приём, менее 100 мс блокировки CONTROL и real-time. Для " + "объединённого канала не готов: нарушает 50 мс EMERGENCY и при " + "полных кадрах не оставляет полосы VIDEO. 200 ксимв/с исправляет " + "блокировку, но доставка EMERGENCY всё ещё превышает 50 мс.", + ) + passing = [item for item in mode_results if item["mode_result"] == "PASS"] + if not passing: + diagnostic = [item for item in mode_results + if _roundtrip_pass(item["ideal"], item["jpeg_rf_frames"]) + and _roundtrip_pass(item["noisy"], item["jpeg_rf_frames"])] + if diagnostic: + selected = min(diagnostic, key=lambda item: item["noisy"]["processing_to_capture_ratio"]) + return int(selected["symbol_rate"]), ( + "Только диагностический PHY-кандидат: среди точных независимых " + "шумных проходов имеет наименьшее отношение обработки к IQ. " + "Ни один режим не выполнил все требования; кандидат также не " + "пригоден для непрерывного объединённого канала. RF не разрешён." + ) + return None, "Ни один режим не выполнил все программные критерии" + selected = min(passing, key=lambda item: item["symbol_rate"]) + return int(selected["symbol_rate"]), "Выбран наименьший режим со статусом PASS" + + +def run_lab049(jpeg_path: Path = JPEG_PATH) -> dict: + """Выполнить все программные режимы Lab049 без аппаратных операций.""" + + path = Path(jpeg_path) + jpeg = path.read_bytes() + digest = hashlib.sha256(jpeg).hexdigest() + if len(jpeg) != EXPECTED_JPEG_BYTES or digest != EXPECTED_JPEG_SHA256: + raise RuntimeError("JPEG Lab047 не совпал с зафиксированным объектом") + modes = [run_mode(jpeg, rate) for rate in TESTED_SYMBOL_RATES] + recommended, reason = choose_recommended_mode(modes) + return { + "stage": "LAB049_SOFTWARE_SYMBOL_RATE", + "jpeg": { + "path": str(path), + "bytes": len(jpeg), + "sha256": digest, + "width": 320, + "height": 240, + "mode": "RGB", + }, + "fixed": { + "sample_rate_hz": SAMPLE_RATE_HZ, + "modulation": "BPSK", + "convolutional_fec": "R=1/2 K=7 generators=171/133 octal", + "crc_changed": False, + "packet_format_changed": False, + "fragment_format_changed": False, + "prbs7_changed": False, + "phy_header_changed": False, + "priority_order": "EMERGENCY > CONTROL > TELEMETRY > VIDEO", + "noisy_coded_bit_ebn0_db": NOISY_CODED_BIT_EBN0_DB, + "noise_tuned_per_mode": False, + }, + "receiver_input": { + "frame_aligned_iq": False, + "tx_frame_offsets": False, + "tx_bits": False, + "tx_payload": False, + "expected_frame_count": False, + }, + "modes": modes, + "maximum_stable_software_symbol_rate": max(( + item["symbol_rate"] + for item in modes + if _roundtrip_pass(item["ideal"], item["jpeg_rf_frames"]) + and _roundtrip_pass(item["noisy"], item["jpeg_rf_frames"]) + ), default=None), + "recommended_rf_symbol_rate": recommended, + "recommendation_reason": reason, + "recommended_for_integrated_operation": False, + "all_mode_requirements_met": any( + item["mode_result"] == "PASS" for item in modes + ), + "rf_tx": 0, + "hackrf_started": False, + } + + +def run_functional_tests() -> None: + assert tuple(phy_mode(rate).samples_per_symbol for rate in TESTED_SYMBOL_RATES) == ( + 400, + 160, + 80, + 40, + ) + assert SAMPLE_RATE_HZ == lab047.SAMPLE_RATE_HZ + assert NOISY_CODED_BIT_EBN0_DB == 7.0 + assert [traffic.name for traffic in TrafficClass] == [ + "EMERGENCY", + "CONTROL", + "TELEMETRY", + "VIDEO", + ] + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Lab049: программное сравнение скоростей BPSK" + ) + parser.add_argument("--jpeg", type=Path, default=JPEG_PATH) + parser.add_argument("--output-directory", type=Path, default=OUTPUT_DIRECTORY) + arguments = parser.parse_args() + result = run_lab049(arguments.jpeg) + arguments.output_directory.mkdir(parents=True, exist_ok=True) + output_path = arguments.output_directory / RESULT_NAME + output_path.write_text( + json.dumps(result, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/lab049_crsf_elrs_budget.py b/experiments/lab049_crsf_elrs_budget.py new file mode 100644 index 0000000..48ea66c --- /dev/null +++ b/experiments/lab049_crsf_elrs_budget.py @@ -0,0 +1,304 @@ +"""Lab049: раздельные бюджеты штатного ELRS и SDR-видеоканала, без RF. + +Формулы описывают возможности слотов без потерь, а не полную задержку +управления. Исходные интервалы зафиксированы по ExpressLRS 4.1.0. +Никакие интерфейсы оборудования не открываются. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +import hashlib +import json +import math +from pathlib import Path +import sys + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + + +ROOT = Path(__file__).resolve().parents[1] +ELRS_VERSION = "4.1.0" +ELRS_COMMIT = "a9d4a9cb5b5687c4c9d7e9e7fbdf44ad93651da6" +SOURCE_ROOT = f"https://github.com/ExpressLRS/ExpressLRS/blob/{ELRS_COMMIT}/src/" +SOURCES = { + "rates_and_link_burst": SOURCE_ROOT + "src/common.cpp", + "ota_sizes": SOURCE_ROOT + "lib/OTA/OTA.h", + "data_sizes": SOURCE_ROOT + "include/telemetry_protocol.h", + "tx_scheduler": SOURCE_ROOT + "src/tx_main.cpp", + "rx_scheduler": SOURCE_ROOT + "src/rx_main.cpp", + "crsf": "https://github.com/tbs-fpv/tbs-crsf-spec/blob/main/crsf.md", + "mavlink_estimates": "https://www.expresslrs.org/software/mavlink/#throughput-per-rf-mode", +} +TELEMETRY_DENOMINATORS = (128, 64, 32, 16, 8, 4, 2) +JPEG_SHA256 = "e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a" + + +def _positive(value: float, name: str) -> None: + if isinstance(value, bool) or not math.isfinite(value) or value <= 0: + raise ValueError(f"{name}: требуется конечное положительное число") + + +@dataclass(frozen=True) +class ElrsMode: + name: str + interval_us: int + ota_packet_bytes: int + standard_telemetry_denominator: int + repetitions: int = 1 + + def __post_init__(self) -> None: + if type(self.interval_us) is not int or self.interval_us <= 0: + raise ValueError("Интервал должен быть положительным целым числом микросекунд") + if type(self.ota_packet_bytes) is not int or self.ota_packet_bytes not in (8, 13): + raise ValueError("Поддерживаются OTA4=8 и OTA8=13 байт") + if (type(self.standard_telemetry_denominator) is not int + or self.standard_telemetry_denominator not in TELEMETRY_DENOMINATORS): + raise ValueError("Неизвестное штатное отношение телеметрии") + if type(self.repetitions) is not int or self.repetitions < 1: + raise ValueError("Число повторов должно быть положительным целым") + + +# Отдельные представительные режимы; наличие зависит от микросхемы и диапазона. +ELRS_MODES = ( + ElrsMode("900: 25", 40_000, 8, 8), + ElrsMode("900/2.4: 50", 20_000, 8, 16), + ElrsMode("900: 100", 10_000, 8, 32), + ElrsMode("900/2.4: 100 Full", 10_000, 13, 32), + ElrsMode("2.4: 150", 6_666, 8, 32), + ElrsMode("900: 200", 5_000, 8, 64), + ElrsMode("2.4 / LR1121 900: 250", 4_000, 8, 64), + ElrsMode("2.4: 333 Full", 3_003, 13, 128), + ElrsMode("2.4: 500", 2_000, 8, 128), + ElrsMode("2.4: F1000 / K1000", 1_000, 8, 128), + ElrsMode("LR1121 900: K1000 Full", 1_000, 13, 128), +) + + +@dataclass(frozen=True) +class ElrsBudget: + packet_interval_ms: float + rf_slot_rate_hz: float + telemetry_slot_fraction: float + telemetry_slots_per_second: float + rc_opportunities_per_second_upper_bound: float + next_rf_slot_wait_mean_ms: float + next_rf_slot_wait_supremum_ms: float + next_rc_opportunity_wait_mean_ms: float + next_rc_opportunity_wait_supremum_ms: float + data_bytes_per_full_telemetry_packet: int + data_ceiling_bytes_per_second: float + data_ceiling_with_link_stats_bytes_per_second: float + data_burst_packets: int + + +def elrs_budget(mode: ElrsMode, telemetry_denominator: int | None) -> ElrsBudget: + """Один TLM-слот из D; None означает Off, а не отношение 1:1. + + Начальная фаза равномерна по времени. RC-слоты имеют интервалы T + (D-2 раза) и 2T (один раз) в цикле DT. Не учитываются SYNC, DATA + uplink, потери, LBT, мультиплексирование каналов и очередь UART. + Повторные D/DK-режимы требуют иной модели и здесь отвергаются. + """ + if mode.repetitions != 1: + raise ValueError("D/DK: частота RF-слотов не равна частоте новых команд") + if telemetry_denominator is not None and ( + type(telemetry_denominator) is not int + or telemetry_denominator not in TELEMETRY_DENOMINATORS + ): + raise ValueError("Отношение должно быть Off или 1:128 ... 1:2") + interval_ms = mode.interval_us / 1000 + slot_rate = 1_000_000 / mode.interval_us + fraction = 0.0 if telemetry_denominator is None else 1 / telemetry_denominator + telemetry_rate = slot_rate * fraction + data_bytes = 5 if mode.ota_packet_bytes == 8 else 10 + burst = 0 + with_stats = 0.0 + if telemetry_denominator is not None: + # Точное целочисленное вычисление updateTelemetryBurst/common.cpp. + integer_rate = 1_000_000 // mode.interval_us + burst = max(1, 512 * integer_rate // telemetry_denominator // 1000 - 1) + # LINK содержит 4 байта статистики, оставляя DATA 1 либо 6 байт. + with_stats = telemetry_rate * (data_bytes - 4 / (burst + 1)) + return ElrsBudget( + interval_ms, slot_rate, fraction, telemetry_rate, + slot_rate * (1 - fraction), interval_ms / 2, interval_ms, + interval_ms * (0.5 + fraction), + interval_ms * (1 if telemetry_denominator is None else 2), + data_bytes, telemetry_rate * data_bytes, with_stats, burst, + ) + + +@dataclass(frozen=True) +class ImageAccounting: + jpeg_bytes: int + rf_frame_count: int + fragment_header_bytes_total: int + padding_bytes_total: int + packet_bytes_total: int + packet_overhead_bytes_total: int + fec_coded_bits_total: int + phy_header_bits_total: int + preamble_bits_total: int + transmitted_bpsk_bits_total: int + guard_and_filter_intervals_total: int + symbol_intervals_total: int + + +def image_accounting(data: bytes) -> ImageAccounting: + """Получить расходы из настоящих планов Lab046/Lab045, без формы IQ.""" + if not data: + raise ValueError("Объект не должен быть пустым") + from experiments import lab046_bpsk_object_transport as lab046 + + frames = lab046.build_object_frames(data) + plans = [frame.plan for frame in frames] + payload_bytes = sum(len(plan.payload) for plan in plans) + packet_bytes = sum(len(plan.packet) for plan in plans) + transmitted_bits = sum(plan.frame_symbol_count for plan in plans) + waveform_samples = sum(plan.waveform_sample_count for plan in plans) + symbol_intervals, remainder = divmod(waveform_samples, lab046.lab045.SAMPLES_PER_SYMBOL) + if remainder: + raise ValueError("Форма не занимает целое число символьных интервалов") + return ImageAccounting( + len(data), len(frames), len(frames) * lab046.fragments.FRAGMENT_HEADER_SIZE, + payload_bytes - sum(frame.encoded_payload_length for frame in frames), + packet_bytes, packet_bytes - payload_bytes, + sum(len(plan.coded_bits) for plan in plans), + sum(len(plan.phy_header_bits) for plan in plans), + sum(plan.frame_symbol_count - len(plan.post_preamble_bits) for plan in plans), + transmitted_bits, symbol_intervals - transmitted_bits, symbol_intervals, + ) + + +def jpeg_payload_bitrate(jpeg_bytes: int, frames_per_second: float) -> float: + if type(jpeg_bytes) is not int or jpeg_bytes <= 0: + raise ValueError("Размер JPEG должен быть положительным целым") + _positive(frames_per_second, "Частота изображений") + return jpeg_bytes * 8 * frames_per_second + + +def distance_per_frame(speed_kmh: float, frames_per_second: float) -> float: + _positive(frames_per_second, "Частота изображений") + if isinstance(speed_kmh, bool) or not math.isfinite(speed_kmh) or speed_kmh < 0: + raise ValueError("Скорость должна быть конечной и неотрицательной") + return speed_kmh / 3.6 / frames_per_second + + +def video_requirement(accounting: ImageAccounting, frames_per_second: float) -> dict: + """Нижние границы без потерь и объёмных данных; задержка — предложение.""" + payload_bits = jpeg_payload_bitrate(accounting.jpeg_bytes, frames_per_second) + return { + "target_fps": frames_per_second, + "jpeg_bytes": accounting.jpeg_bytes, + "payload_bytes_per_second": payload_bits / 8, + "payload_kbit_per_second": payload_bits / 1000, + "transmitted_bpsk_kbit_per_second": accounting.transmitted_bpsk_bits_total * frames_per_second / 1000, + "minimum_phy_symbol_rate_including_guards": accounting.symbol_intervals_total * frames_per_second, + "rx_images_per_second_minimum": frames_per_second, + "rx_rf_frames_per_second_minimum": accounting.rf_frame_count * frames_per_second, + "rx_service_budget_ms_per_image": 1000 / frames_per_second, + "proposed_e2e_image_latency_limit_ms": 1000 / frames_per_second, + "e2e_limit_is_approved_or_measured": False, + "distance_per_image_m": {str(speed): distance_per_frame(speed, frames_per_second) for speed in (10, 15, 25)}, + } + + +def sdr_capacity(accounting: ImageAccounting, symbol_rate: float, + *, bulk_symbol_intervals_per_second: float = 0.0) -> dict: + """Идеальная ёмкость видео; резерв только под явно заданные SDR-данные.""" + _positive(symbol_rate, "Символьная скорость") + if (isinstance(bulk_symbol_intervals_per_second, bool) + or not math.isfinite(bulk_symbol_intervals_per_second) + or not 0 <= bulk_symbol_intervals_per_second <= symbol_rate): + raise ValueError("Резерв SDR-данных должен лежать между нулём и ёмкостью") + available = symbol_rate - bulk_symbol_intervals_per_second + fps = available / accounting.symbol_intervals_total + return { + "symbol_rate": symbol_rate, + "jpeg_airtime_seconds": accounting.symbol_intervals_total / symbol_rate, + "ideal_video_fps": fps, + "ideal_payload_kbit_per_second": accounting.jpeg_bytes * 8 * fps / 1000, + "sdr_rc_reserved_symbol_intervals_per_second": 0, + "bulk_reserved_symbol_intervals_per_second": bulk_symbol_intervals_per_second, + "is_measured_streaming_fps": False, + } + + +def run_functional_tests() -> None: + assert elrs_budget(ELRS_MODES[1], 16).telemetry_slots_per_second == 3.125 + assert jpeg_payload_bitrate(8724, 5) == 348_960 + assert math.isclose(distance_per_frame(25, 5), 25 / 18) + + +def main() -> None: + run_functional_tests() + jpeg_path = ROOT / "data/processed/lab047/tx_image.jpg" + measurement_path = ROOT / "data/processed/lab049/independent/result.json" + jpeg = jpeg_path.read_bytes() + if len(jpeg) != 8724 or hashlib.sha256(jpeg).hexdigest() != JPEG_SHA256: + raise ValueError("Контрольный JPEG отличается от Lab047") + accounting = image_accounting(jpeg) + measurement_bytes = measurement_path.read_bytes() + measurements = json.loads(measurement_bytes) + if measurements["jpeg"]["sha256"] != JPEG_SHA256: + raise ValueError("Замеры относятся к другому JPEG") + modes = [] + for measured in measurements["modes"]: + if measured["jpeg_rf_frames"] != accounting.rf_frame_count: + raise ValueError("Число физических кадров изменилось") + if not math.isclose(measured["jpeg_airtime_seconds"] * measured["symbol_rate"], + accounting.symbol_intervals_total, rel_tol=1e-12): + raise ValueError("Физический формат отличается от сохранённых замеров") + row = sdr_capacity(accounting, measured["symbol_rate"]) + row["reused_noisy_measurement"] = { + key: measured["noisy"][key] for key in ( + "receiver_processing_seconds", "capture_equivalent_seconds", + "processing_to_capture_ratio", "valid_frames", "sha_match", "object_complete", + ) + } + modes.append(row) + result = { + "stage": "Lab049 CRSF/ELRS architecture and separate SDR video budget", + "source_checked_date": "2026-09-08", + "elrs_version": ELRS_VERSION, "elrs_commit": ELRS_COMMIT, "sources": SOURCES, + "jpeg_sha256": JPEG_SHA256, "accounting": asdict(accounting), + "measurement_provenance": { + "path": measurement_path.relative_to(ROOT).as_posix(), + "sha256": hashlib.sha256(measurement_bytes).hexdigest(), + "new_iq_measurement_performed": False, + }, + "assumptions": { + "elrs_hardware_model_and_band_confirmed": False, + "elrs_budgets_are_lossless_slot_opportunities_not_e2e_latency": True, + "elrs_data_capacities_are_upper_bounds_not_application_goodput": True, + "elrs_repetitions_in_calculator": 1, + "sdr_control_telemetry_reservation": 0, + "video_loss_retries_and_bulk_load": 0, + "proposed_image_latency": "one image period; engineering proposal, not a measured or approved safety limit", + }, + "elrs_standard_ratio_modes": [ + {"mode": asdict(mode), "budget": asdict(elrs_budget(mode, mode.standard_telemetry_denominator))} + for mode in ELRS_MODES + ], + "elrs_100full_ratio_comparison": [ + {"denominator": denominator, "budget": asdict(elrs_budget(ELRS_MODES[3], denominator))} + for denominator in (*TELEMETRY_DENOMINATORS, None) + ], + "elrs_maximum_single_data_ceiling": asdict(elrs_budget(ELRS_MODES[-1], 2)), + "video_requirements": [video_requirement(accounting, fps) for fps in (3, 5, 10)], + "distance_per_image_m": {str(speed): {str(fps): distance_per_frame(speed, fps) + for fps in (1, 3, 5, 10)} for speed in (10, 15, 25)}, + "sdr_modes": modes, + "rf_tx": 0, "hardware_access_performed": False, + } + output = ROOT / "data/processed/lab049/crsf_elrs_budget.json" + output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(output.relative_to(ROOT).as_posix()) + print(json.dumps({"accounting": asdict(accounting), "targets": result["video_requirements"], + "sdr_modes": modes}, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/experiments/lab049_independent_iq.py b/experiments/lab049_independent_iq.py new file mode 100644 index 0000000..bd952c8 --- /dev/null +++ b/experiments/lab049_independent_iq.py @@ -0,0 +1,190 @@ +"""Полный независимый IQ-проход Lab049 штатным приёмником Lab046B. + +Адаптация старых лабораторий выполняется только в последовательном программном +процессе. Контекст возвращает константы и очищает зависящие от них кэши даже +при исключении. Аппаратные функции здесь не вызываются. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from io import BytesIO +from pathlib import Path +import tempfile +import time +from types import SimpleNamespace + +import numpy as np +from PIL import Image + +from experiments import lab046b_continuous_iq as receiver +from experiments import lab046c_offline_recovery as offline +from experiments import lab049_bpsk_symbol_rate as lab + + +@contextmanager +def receiver_mode(mode: lab.PhyMode): + """Временно задать только Rs и Fs/Rs для последовательного офлайн RX.""" + modules = (lab.lab045, lab.lab045.lab044, lab.lab045.lab044.lab043) + originals = [(module, module.SYMBOL_RATE, module.SAMPLES_PER_SYMBOL) + for module in modules] + caches = (lab.lab045.lab044._preamble_reference_samples, + lab.lab045.lab044.lab043._matched_filter_taps, + lab.lab045.lab044.lab043._carrier_downconversion_vector) + try: + for module in modules: + module.SYMBOL_RATE = mode.symbol_rate + module.SAMPLES_PER_SYMBOL = mode.samples_per_symbol + for cache in caches: + cache.cache_clear() + yield + finally: + for module, rate, samples_per_symbol in originals: + module.SYMBOL_RATE = rate + module.SAMPLES_PER_SYMBOL = samples_per_symbol + for cache in caches: + cache.cache_clear() + + +def receive_stream(iq_samples: np.ndarray, mode: lab.PhyMode): + """Поиск, CFO, PHY, FEC и CRC без границ TX или числа кадров.""" + with receiver_mode(mode): + # У синтетического канала после добавления AWGN формат complex64. + # Подменяется только чтение IQ; блочный поиск и строгий декодер Lab048 + # выполняются без изменений. Повторное CS8-квантование отсекло бы шум. + original_validate = offline._validate_capture + original_window = offline.lab046c._cs8_window + samples = np.asarray(iq_samples, dtype=np.complex64) + if samples.ndim != 1 or not samples.size or not np.all(np.isfinite(samples)): + raise ValueError("Нужен конечный одномерный IQ-поток") + try: + offline._validate_capture = lambda unused: (samples.view(np.float32), samples.size) + offline.lab046c._cs8_window = ( + lambda raw, start, end: raw[2 * start:2 * end].view(np.complex64) + ) + coarse, strict = offline.stream_process_capture( + Path("synthetic_iq_in_memory"), + offline.CandidateRule("FIXED_LAB047", 0.999, + lab.lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD), + max_candidates=100_000, max_valid_frames=4096, + ) + finally: + offline._validate_capture = original_validate + offline.lab046c._cs8_window = original_window + counters = strict.counters + return SimpleNamespace( + termination_reason=("END_OF_STREAM" if strict.termination_reason == "END_OF_CAPTURE" + else strict.termination_reason), + accepted_frames=strict.accepted_frames, + crc_pass_count=counters.crc_pass, crc_fail_count=counters.crc_fail, + phy_header_pass_count=counters.phy_header_pass, + fec_pass_count=counters.fec_pass, + preamble_candidates=coarse.candidate_count, + processed_candidates=counters.candidates_processed, + rejected_candidates=counters.candidates_processed - counters.valid_frames, + rejected_reasons=tuple(outcome.reason for outcome in strict.outcomes if not outcome.accepted), + ) + + +def run_roundtrips(jpeg: bytes, mode: lab.PhyMode) -> tuple[dict, dict]: + """Сформировать целый поток, завершить RX, затем открыть TX-диагностику.""" + frames = tuple(lab.lab046.build_object_frames(jpeg, lab.lab047.OBJECT_ID)) + frame_sizes = [lab.frame_waveform_sample_count(frame.plan, mode) for frame in frames] + # Не кратные символу интервалы известны только генератору канала. + gaps = [12007 + (index * 7919) % 10009 for index in range(len(frames) + 1)] + sample_count = sum(frame_sizes) + sum(gaps) + results = [] + with tempfile.TemporaryDirectory(prefix="lab049_iq_") as temporary: + for noisy in (False, True): + path = Path(temporary) / "stream.c64" + iq_stream = np.memmap(path, dtype=np.complex64, mode="w+", shape=(sample_count,)) + sigmas = [] + cursor = gaps[0] + for index, frame in enumerate(frames): + iq = lab.lab045.lab044.signed_iq_bytes_to_complex64( + lab.build_parameterized_tx_cs8(frame.plan, mode) + ) + if noisy: + # Одинаковые остаточный CFO, фаза и чередование полярности. + indexes = np.arange(iq.size, dtype=np.float64) + iq *= np.exp(1j * (0.73 + (index % 2) * np.pi + + 2 * np.pi * 10.0 * indexes / mode.sample_rate_hz)).astype(np.complex64) + iq, sigma = lab.add_coded_bit_ebn0_noise( + iq, frame.plan.frame_symbol_count, + lab.NOISY_CODED_BIT_EBN0_DB, lab.NOISE_BASE_SEED + index, + ) + sigmas.append(sigma) + random_generator = np.random.default_rng(149000 + index) + gap_start = cursor - gaps[index] + gap = sigma * (random_generator.standard_normal(gaps[index]) + + 1j * random_generator.standard_normal(gaps[index])) + iq_stream[gap_start:cursor] = gap.astype(np.complex64) + iq_stream[cursor:cursor + iq.size] = iq + cursor += iq.size + gaps[index + 1] + if noisy: + random_generator = np.random.default_rng(249000) + iq_stream[-gaps[-1]:] = (sigmas[-1] * ( + random_generator.standard_normal(gaps[-1]) + + 1j * random_generator.standard_normal(gaps[-1]) + )).astype(np.complex64) + iq_stream.flush() + started = time.perf_counter() + received = receive_stream(iq_stream, mode) + assembler = lab.lab046.ObjectAssembler() + for item in received.accepted_frames: + payload = item.reception.payload_bytes + if payload is not None: + assembler.offer(lab.lab046.recover_fragment_from_payload(payload)) + recovered = assembler.recovered_object() + jpeg_decode = False + if recovered is not None: + with Image.open(BytesIO(recovered)) as image: + image.load() + jpeg_decode = (image.format, image.size, image.mode) == ( + "JPEG", (320, 240), "RGB") + elapsed = time.perf_counter() - started + # Всё ниже находится после независимого решения и сборки. + accumulator = lab._RoundtripAccumulator(assembler, processing_seconds=elapsed, + phy_header_pass=received.phy_header_pass_count, + phy_header_fail=received.processed_candidates - received.phy_header_pass_count, + fec_pass=received.fec_pass_count, crc_pass=received.crc_pass_count, + fec_fail=received.phy_header_pass_count - received.fec_pass_count, + crc_not_reached=(received.processed_candidates + - received.crc_pass_count - received.crc_fail_count), + crc_fail=received.crc_fail_count, noise_sigmas=sigmas) + diagnosed = 0 + by_index = {frame.fragment_index: frame for frame in frames} + for item in received.accepted_frames: + fragment = lab.lab046.recover_fragment_from_payload(item.reception.payload_bytes) + if fragment is None or fragment.fragment_index not in by_index: + continue + diagnostic = lab.lab045.diagnostic_pre_and_post_fec( + item.reception, item.post_preamble_soft, by_index[fragment.fragment_index].plan) + accumulator.pre_fec_errors += int(diagnostic["pre_fec_coded"]["bit_errors"]) + accumulator.post_fec_errors += int(diagnostic["post_fec_packet"]["bit_errors"]) + diagnosed += 1 + summary = lab._roundtrip_summary( + accumulator, jpeg, len(frames), sample_count / mode.sample_rate_hz) + summary.update({ + "receiver": "lab046c_offline_recovery.stream_process_capture (Lab048)", + "candidate_threshold": lab.lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD, + "frame_aligned_iq": False, "tx_offsets_provided": False, + "residual_cfo_hz": 10.0 if noisy else 0.0, + "phase_rad": 0.73 if noisy else 0.0, + "polarity_alternates": noisy, + "jpeg_decode": jpeg_decode, + "preamble_candidates": received.preamble_candidates, + "processed_candidates": received.processed_candidates, + "rejected_candidates": received.rejected_candidates, + "rejected_reasons": list(received.rejected_reasons), + "termination_reason": received.termination_reason, + "diagnosed_frames": diagnosed, + "error_counts_cover_complete_object": diagnosed == len(frames), + "estimated_cfo_hz": [item.estimated_cfo_hz for item in received.accepted_frames], + "payload_airtime_seconds": sum(frame_sizes) / mode.sample_rate_hz, + "rf_tx": 0, + }) + results.append(summary) + del iq_stream + path.unlink() + return results[0], results[1] diff --git a/tests/test_lab049_bpsk_symbol_rate.py b/tests/test_lab049_bpsk_symbol_rate.py new file mode 100644 index 0000000..0644b3b --- /dev/null +++ b/tests/test_lab049_bpsk_symbol_rate.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +from functools import lru_cache +import inspect + +import numpy as np +import pytest + +from experiments import lab045_hackrf_bpsk_fec as lab045 +from experiments import lab049_bpsk_symbol_rate as lab049 +from protocol.control_messages import ControlStateMessage, EmergencyStop, decode_message +from protocol.link_packet import TrafficClass, decode_link_packet +from protocol.priority_scheduler import QueuedPacket + + +@lru_cache(maxsize=1) +def _plan() -> lab045.TransmissionPlan: + payload = bytes((index * 49 + 7) & 0xFF for index in range(256)) + return lab045.build_transmission_plan(payload) + + +def _frame_duration(symbol_rate: int) -> float: + mode = lab049.phy_mode(symbol_rate) + return lab049.frame_waveform_sample_count(_plan(), mode) / mode.sample_rate_hz + + +def test_parameterized_symbol_rates_have_exact_samples_per_symbol() -> None: + assert { + rate: lab049.phy_mode(rate).samples_per_symbol + for rate in lab049.TESTED_SYMBOL_RATES + } == {20_000: 400, 50_000: 160, 100_000: 80, 200_000: 40} + + +def test_non_integer_samples_per_symbol_is_rejected() -> None: + with pytest.raises(ValueError, match="целым"): + lab049.phy_mode(30_000) + + +def test_20ksymbol_waveform_is_byte_exact_lab045_result() -> None: + assert lab049.build_parameterized_tx_cs8( + _plan(), + lab049.phy_mode(20_000), + ) == lab045.build_tx_cs8(_plan()) + + +def test_20ksymbol_frame_duration_preserves_lab047_baseline() -> None: + mode = lab049.phy_mode(20_000) + assert lab049.frame_waveform_sample_count(_plan(), mode) == _plan().waveform_sample_count + assert _frame_duration(20_000) == _plan().airtime_seconds + + +def test_50ksymbol_iq_roundtrip() -> None: + result = lab049.single_frame_roundtrip(50_000) + assert result == { + "phy_header_pass": True, + "fec_pass": True, + "crc": lab045.CRC_PASS, + "payload_match": True, + } + + +def test_100ksymbol_iq_roundtrip() -> None: + result = lab049.single_frame_roundtrip(100_000) + assert result == { + "phy_header_pass": True, + "fec_pass": True, + "crc": lab045.CRC_PASS, + "payload_match": True, + } + + +def test_scheduler_priority_order_is_reused() -> None: + result = lab049.priority_probe(_frame_duration(100_000)) + assert result["priority_pass"] + assert result["transmission_order"] == [ + "VIDEO", + "EMERGENCY", + "CONTROL", + "TELEMETRY", + "VIDEO", + ] + + +def test_video_is_not_interrupted_but_control_enters_at_next_boundary() -> None: + duration = _frame_duration(100_000) + result = lab049.priority_probe(duration) + assert result["first_video_not_preempted"] + assert result["control_max_video_blocking_ms"] == duration * 1000.0 + + +def test_emergency_has_highest_priority_after_active_frame() -> None: + result = lab049.priority_probe(_frame_duration(100_000)) + assert result["transmission_order"][1] == "EMERGENCY" + assert not result["emergency_deadline_met_in_worst_phase"] + assert lab049.priority_probe(_frame_duration(200_000))[ + "emergency_deadline_met_in_worst_phase" + ] + + +def test_loaded_scheduler_reports_all_latency_classes() -> None: + result = lab049.loaded_scheduler_scenario(_frame_duration(100_000)) + assert result["video_queue_saturated"] + assert set(result["latency"]) == { + "EMERGENCY", + "CONTROL", + "TELEMETRY", + "VIDEO", + } + for statistics in result["latency"].values(): + assert statistics["count"] > 0 + assert 0.0 <= statistics["min_ms"] <= statistics["median_ms"] <= statistics["max_ms"] + + +def test_scheduler_preserves_valid_control_and_emergency_payloads() -> None: + control_payload = lab049._control_payload(0) + emergency_payload = lab049._emergency_payload(0) + control = decode_message(control_payload) + emergency = decode_message(emergency_payload) + assert isinstance(control, ControlStateMessage) + assert isinstance(emergency, EmergencyStop) + assert len(control_payload) < lab049.lab046.PACKET_PAYLOAD_SIZE + assert len(emergency_payload) < lab049.lab046.PACKET_PAYLOAD_SIZE + queued = ( + QueuedPacket( + lab049._link_packet(TrafficClass.CONTROL, 0, 0.0, control_payload), + 0, + ), + QueuedPacket( + lab049._link_packet(TrafficClass.EMERGENCY, 0, 0.0, emergency_payload), + 1, + ), + ) + schedule = lab049._schedule_fixed_physical_frames( + queued, + _frame_duration(100_000), + ) + restored_payloads = { + item.queued.packet.traffic_class: decode_link_packet(item.wire_packet).payload + for item in schedule.transmitted + } + assert restored_payloads[TrafficClass.CONTROL] == control_payload + assert restored_payloads[TrafficClass.EMERGENCY] == emergency_payload + result = lab049.loaded_scheduler_scenario(_frame_duration(100_000)) + assert not result["application_payloads_padded"] + assert result["fixed_physical_frame_duration_model"] + + +def test_scheduler_reports_expired_control_without_claiming_it_was_dropped() -> None: + result = lab049.loaded_scheduler_scenario(_frame_duration(50_000)) + assert result["deadline_misses"]["CONTROL"] >= 1 + assert not result["scheduler_drops_expired_packets"] + + +def test_channel_budget_uses_control_telemetry_before_video() -> None: + slow = lab049.channel_budget( + _frame_duration(20_000), + lab049.EXPECTED_JPEG_BYTES, + 36, + ) + fast = lab049.channel_budget( + _frame_duration(200_000), + lab049.EXPECTED_JPEG_BYTES, + 36, + ) + middle = lab049.channel_budget( + _frame_duration(100_000), + lab049.EXPECTED_JPEG_BYTES, + 36, + ) + assert slow["video_available_bitrate_bps"] == 0.0 + assert middle["video_available_bitrate_bps"] == 0.0 + assert fast["video_available_bitrate_bps"] > 0.0 + assert middle["control_reserved_link_bps"] == pytest.approx(46_080.0) + assert middle["telemetry_load_link_bps"] == pytest.approx(23_040.0) + assert middle["fixed_physical_frame_per_packet"] + assert middle["service_requested_airtime_fraction"] > 1.0 + assert fast["service_requested_airtime_fraction"] < 1.0 + assert fast["video_available_frames_per_second"] == pytest.approx( + fast["physical_frame_capacity_per_second"] - 30.0 + ) + assert fast["remaining_link_bps"] == pytest.approx( + fast["scheduler_link_bitrate_bps"] + - fast["control_reserved_link_bps"] + - fast["telemetry_load_link_bps"] + ) + + +def test_receiver_real_time_ratio_calculation() -> None: + assert lab049.processing_to_capture_ratio(0.5, 1.0) == 0.5 + with pytest.raises(ValueError): + lab049.processing_to_capture_ratio(1.0, 0.0) + + +def test_noise_is_normalized_by_transmitted_bit_energy() -> None: + long = np.ones(400, dtype=np.complex64) + short = np.ones(160, dtype=np.complex64) + _, long_sigma = lab049.add_coded_bit_ebn0_noise(long, 1, 7.0, 49) + _, short_sigma = lab049.add_coded_bit_ebn0_noise(short, 1, 7.0, 49) + assert long_sigma / short_sigma == pytest.approx(np.sqrt(400 / 160)) + + +def test_receiver_api_has_no_tx_plan_or_payload_oracle() -> None: + parameters = inspect.signature(lab049.receive_iq_frame).parameters + assert tuple(parameters) == ("iq_samples", "mode") + + +def test_mode_classification_requires_all_latency_capacity_and_speed_gates() -> None: + assert lab049.classify_mode(True, True, True, True, False, False) == "PARTIAL" + assert lab049.classify_mode(True, True, False, True, True, True) == "PARTIAL" + assert lab049.classify_mode(True, True, True, True, True, True) == "PASS" + assert lab049.classify_mode(False, True, True, True, True, True) == "FAIL" diff --git a/tests/test_lab049_crsf_elrs_budget.py b/tests/test_lab049_crsf_elrs_budget.py new file mode 100644 index 0000000..fc550c8 --- /dev/null +++ b/tests/test_lab049_crsf_elrs_budget.py @@ -0,0 +1,119 @@ +"""Проверки расчётов Lab049 без файлов эксперимента и оборудования.""" + +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from experiments import lab049_crsf_elrs_budget as budget + + +@pytest.fixture(scope="module") +def accounting() -> budget.ImageAccounting: + return budget.image_accounting(bytes(8724)) + + +@pytest.mark.parametrize("interval_us, expected_ms", [(20_000, 20), (10_000, 10), (6666, 6.666), (3003, 3.003)]) +def test_source_interval_preserves_integer_microseconds(interval_us, expected_ms): + mode = budget.ElrsMode("test", interval_us, 8, 16) + result = budget.elrs_budget(mode, None) + assert result.packet_interval_ms == expected_ms + assert result.rf_slot_rate_hz == pytest.approx(1_000_000 / interval_us) + assert result.next_rc_opportunity_wait_mean_ms == pytest.approx(expected_ms / 2) + assert result.next_rc_opportunity_wait_supremum_ms == expected_ms + + +@pytest.mark.parametrize("denominator", budget.TELEMETRY_DENOMINATORS) +def test_telemetry_and_waits_against_enumerated_cycle(denominator): + result = budget.elrs_budget(budget.ELRS_MODES[3], denominator) + # Вторая независимая форма: перечислить моменты RC и интегрировать + # треугольники ожидания между ними за полный период. + rc_times = [slot * 10 for slot in range(denominator) if slot != denominator - 1] + gaps = [right - left for left, right in zip(rc_times, rc_times[1:] + [denominator * 10])] + assert result.next_rc_opportunity_wait_mean_ms == pytest.approx(sum(gap**2 / 2 for gap in gaps) / (denominator * 10)) + assert result.next_rc_opportunity_wait_supremum_ms == max(gaps) + assert result.telemetry_slot_fraction == 1 / denominator + assert result.telemetry_slots_per_second == 100 / denominator + assert result.rc_opportunities_per_second_upper_bound == len(rc_times) / (denominator / 100) + + +def test_off_is_zero_telemetry_and_not_ratio_one(): + result = budget.elrs_budget(budget.ELRS_MODES[3], None) + assert result.telemetry_slots_per_second == result.data_ceiling_bytes_per_second == 0 + assert result.data_ceiling_with_link_stats_bytes_per_second == 0 + assert result.rc_opportunities_per_second_upper_bound == 100 + with pytest.raises(ValueError): + budget.elrs_budget(budget.ELRS_MODES[3], 1) + + +def test_data_capacity_uses_data_not_whole_ota_packet(): + result = budget.elrs_budget(budget.ELRS_MODES[3], 2) + assert result.data_ceiling_bytes_per_second == 500 + assert result.data_burst_packets == 24 + assert result.data_ceiling_with_link_stats_bytes_per_second == 492 + maximum = budget.elrs_budget(budget.ELRS_MODES[-1], 2) + assert maximum.data_ceiling_bytes_per_second == 5000 + assert 2 * maximum.data_ceiling_bytes_per_second < 8724 * 3 + + +def test_repeated_packets_are_not_counted_as_new_commands(): + with pytest.raises(ValueError, match="D/DK"): + budget.elrs_budget(replace(budget.ELRS_MODES[-1], repetitions=4), 128) + + +def test_actual_transport_overhead(accounting): + assert accounting.rf_frame_count == 36 + assert accounting.fragment_header_bytes_total == 432 + assert accounting.padding_bytes_total == 60 + assert accounting.packet_bytes_total == 9648 + assert accounting.packet_overhead_bytes_total == 432 + assert accounting.fec_coded_bits_total == 154800 + assert accounting.phy_header_bits_total == 5184 + assert accounting.preamble_bits_total == 46080 + assert accounting.transmitted_bpsk_bits_total == 206064 + assert accounting.guard_and_filter_intervals_total == 1512 + assert accounting.symbol_intervals_total == 207576 + + +@pytest.mark.parametrize("fps,payload_bits,symbols", [(3, 209376, 622728), (5, 348960, 1037880), (10, 697920, 2075760)]) +def test_targets(accounting, fps, payload_bits, symbols): + result = budget.video_requirement(accounting, fps) + assert budget.jpeg_payload_bitrate(8724, fps) == payload_bits + assert result["payload_kbit_per_second"] == payload_bits / 1000 + assert result["minimum_phy_symbol_rate_including_guards"] == symbols + assert result["rx_rf_frames_per_second_minimum"] == 36 * fps + assert result["rx_service_budget_ms_per_image"] == 1000 / fps + assert not result["e2e_limit_is_approved_or_measured"] + + +@pytest.mark.parametrize("speed,fps,distance", [(10, 3, 25/27), (15, 5, 5/6), (25, 10, 25/36), (0, 1, 0)]) +def test_distance(speed, fps, distance): + assert budget.distance_per_frame(speed, fps) == pytest.approx(distance) + + +@pytest.mark.parametrize("symbol_rate,airtime", [(20000, 10.3788), (50000, 4.15152), (100000, 2.07576), (200000, 1.03788)]) +def test_sdr_capacity_reserves_no_elrs_traffic(accounting, symbol_rate, airtime): + result = budget.sdr_capacity(accounting, symbol_rate) + assert result["jpeg_airtime_seconds"] == pytest.approx(airtime) + assert result["ideal_video_fps"] == pytest.approx(1 / airtime) + assert result["ideal_payload_kbit_per_second"] == pytest.approx(8724 * 8 / airtime / 1000) + assert result["sdr_rc_reserved_symbol_intervals_per_second"] == 0 + half = budget.sdr_capacity(accounting, symbol_rate, bulk_symbol_intervals_per_second=symbol_rate/2) + assert half["ideal_video_fps"] == pytest.approx(result["ideal_video_fps"] / 2) + + +@pytest.mark.parametrize("bad", [0, -1, float("inf"), float("nan"), True]) +def test_invalid_rates_rejected(accounting, bad): + with pytest.raises(ValueError): + budget.jpeg_payload_bitrate(8724, bad) + with pytest.raises(ValueError): + budget.distance_per_frame(25, bad) + with pytest.raises(ValueError): + budget.sdr_capacity(accounting, bad) + + +@pytest.mark.parametrize("bad", [True, 16.0, 3, 0]) +def test_invalid_telemetry_ratios_rejected(bad): + with pytest.raises(ValueError): + budget.elrs_budget(budget.ELRS_MODES[1], bad) diff --git a/tests/test_lab049_independent_iq.py b/tests/test_lab049_independent_iq.py new file mode 100644 index 0000000..da38678 --- /dev/null +++ b/tests/test_lab049_independent_iq.py @@ -0,0 +1,66 @@ +"""Регрессии неизвестной границы, CFO и полной задержки доставки Lab049.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from experiments import lab049_bpsk_symbol_rate as lab +from experiments import lab049_independent_iq as independent + + +def test_fractional_symbol_rate_is_not_silently_rounded() -> None: + with pytest.raises(ValueError): + lab.phy_mode(20000.5) + + +@pytest.mark.parametrize("rate", (50_000, 100_000)) +def test_unknown_frame_start_and_residual_cfo(rate: int) -> None: + mode = lab.phy_mode(rate) + payload = bytes(range(256)) + plan = lab.lab045.build_transmission_plan(payload) + iq = lab.lab045.lab044.signed_iq_bytes_to_complex64( + lab.build_parameterized_tx_cs8(plan, mode)) + iq *= np.exp(1j * (2.7 + 2 * np.pi * 10 * np.arange(iq.size) + / mode.sample_rate_hz)).astype(np.complex64) + stream = np.pad(iq, (12007, 12003)) + result = independent.receive_stream(stream, mode) + assert result.termination_reason == "END_OF_STREAM" + assert result.crc_pass_count == 1 + assert result.accepted_frames[0].reception.payload_bytes == payload + assert result.accepted_frames[0].estimated_cfo_hz == pytest.approx(10, abs=1) + assert lab.lab045.SYMBOL_RATE == 20_000 + assert lab.lab045.lab044.lab043.SAMPLES_PER_SYMBOL == 400 + + +def test_receiver_mode_restores_constants_on_exception() -> None: + with pytest.raises(RuntimeError): + with independent.receiver_mode(lab.phy_mode(100_000)): + raise RuntimeError("контроль выхода") + assert lab.lab045.SAMPLES_PER_SYMBOL == 400 + assert lab.lab045.lab044.SAMPLES_PER_SYMBOL == 400 + + +def test_noise_only_does_not_produce_a_packet() -> None: + generator = np.random.default_rng(49) + noise = (generator.standard_normal(180000) + + 1j * generator.standard_normal(180000)).astype(np.complex64) + result = independent.receive_stream(noise, lab.phy_mode(100_000)) + assert result.crc_pass_count == 0 + + +def test_emergency_delivery_includes_its_own_frame() -> None: + probe = lab.priority_probe(0.02883) + assert probe["emergency_max_video_blocking_ms"] == pytest.approx(28.83) + assert probe["emergency_worst_delivery_ms"] == pytest.approx(57.66) + assert not probe["emergency_delivery_deadline_met_in_worst_phase"] + + +def test_deadlines_use_complete_delivery_and_drain_is_separate() -> None: + result = lab.loaded_scheduler_scenario(0.05766) + assert result["deadline_misses"]["CONTROL"] == 6 + assert result["deadline_misses"]["EMERGENCY"] == 1 + assert result["active_window_delivered_packets"]["TELEMETRY"] == 0 + assert result["active_window_delivery_latency"]["TELEMETRY"]["count"] == 0 + assert result["delivery_latency_until_drain_end"]["CONTROL"]["max_ms"] == pytest.approx(105.64) + assert result["drain_end_seconds"] > result["source_duration_seconds"]