Lab050: model fresh-frame SDR video stream

This commit is contained in:
LittleSam129
2026-09-08 14:15:36 +03:00
parent cdf5396e80
commit 60691a09f1
9 changed files with 1769 additions and 12 deletions

View File

@@ -1641,3 +1641,116 @@ Git add/commit/push/pull/fetch не выполнялись.
и проверкой выполнимости 5 кадров/с. Сопряжение CRSF и его сценарии отказов
требуют отдельной постановки и уточнения аппаратуры. Нового RF, QPSK,
CRSF/ELRS-протокола, перекодирования JPEG или переноса на 180 МГц здесь нет.
# Запись 039
## Дата
8 сентября 2026 года.
## Тема
Lab050: потоковая архитектура JPEG, ограничение возраста и блочный IQ-приём.
## Выполнено
По явному заданию проверены HEAD, status, diff/stat/check и staged scope Lab049.
Создан коммит cdf5396e807b83f82a264632dc46f71428fcd616 с сообщением
Lab049: define ELRS control and SDR video architecture. Вошли 12 файлов:
код, тесты, документы, журнал, индекс и указатель README. Сгенерированные данные,
настройки окружения и посторонние локальные файлы не добавлялись. Push не выполнялся.
Lab050 начата от нового HEAD и оставлена без staging/commit.
Аудит Lab034–037 подтвердил возможность переиспользовать существующий
protocol/video_frame_scheduler.py: активное изображение завершается,
ожидающее заменяется самым свежим, перед началом прогнозируется завершение.
Добавлены готовность JPEG отдельно от времени съёмки, произвольный MAX_VIDEO_AGE,
прогноз задержки после RF и счётчик максимальной ожидающей очереди.
Пять прежних политик сохраняют поведение на проверенном сценарии видео+CONTROL.
В самой Lab050 SDR-очередь содержит только VIDEO; управление ELRS отдельно.
Verifier/adversary воспроизвели ошибку первой реализации: позднее завершение
кодирования старого снимка вытесняло более свежий, либо возвращало старый снимок
после опустошения очереди. Исправлено сравнением времени съёмки и ID с сохранением
самого свежего виденного значения. Две регрессии и повторная независимая проверка PASS.
Из data/raw/lab011_scene.jpg созданы четыре фиксированных профиля:
A 320×240 RGB q35 — 8724 байта; B 160×120 RGB q35 — 2771;
C 128×96 RGB q25 — 1740; D 160×120 grayscale q30 — 2028.
Первый A совпал с SHA Lab047. По 30 детерминированных сдвигов фото, не съёмка камеры;
размеры соответственно 8724–9250, 2771–3031, 1740–1893, 2028–2233 байта.
PSNR относительно несжатого изображения того же профиля:
28,12 / 28,11 / 27,17 / 30,15 dB. Это не оценка пригодности для управления.
Нынешний пакет несёт 244 байта JPEG и занимает 5766 символьных интервалов.
Точный предел Jmax=244*floor(Rs/(5766*fps)) включает фрагментацию, добивку,
FEC, PHY header, преамбулу, защитные интервалы и фильтр.
На 500/800/1000 ксимв/с для 5 кадров/с предел 4148/6588/8296 байт;
для 10 — 1952/3172/4148 байт. BPSK, FEC R=1/2 K=7, CRC и форматы сохранены.
QPSK, новый кодек и 12+3 стирающий FEC не вводились.
Выполнены 240 событийных сценариев: четыре профиля, вход 3/5/10 кадров/с,
Rs=200/400/500/800/1000 ксимв/с при Fs=8 Мвыб/с, предел возраста
200/333/500/1000 мс. Ожидающая очередь не превышает один снимок.
Сохраняются capture/encode/queue/TX/RX/complete/decode/display timestamps;
не выполненные стадии имеют null. FPS внутри окна и после опустошения разделены.
Идеальная модель C/500k/5fps/200ms передаёт и показывает 30/30 за 6 с,
без удалений, FRESH DISPLAY FPS=5, возраст min/median/max
92,563 / 92,590 / 92,629 мс. Это модель нулевой стоимости RX и экрана.
При C/200k, камере 10 кадров/с и возрасте 333 мс передано 14, заменено 16;
внутри 3 с показано 13, FRESH DISPLAY FPS=4,333, ещё один после окна.
При возрасте 200 мс все 30 отклонены: радио само требует 230,64 мс.
Добавлен StreamingReceiver.push_iq_block с выдачей пакетов до finish.
Корреляция и строгий PHY/FEC/CRC взяты из Lab048; в старый декодер добавлен
необязательный IQ-reader без изменения поведения по умолчанию.
Буфер удерживает блоки, левый запас и максимальный допустимый PHY-кадр;
TX-границы, payload, ожидаемые ID и число кадров RX не получает.
Децимация поиска 20/10/8/5/4 сохраняет две выборки/символ, порог Lab047
0.030964473262429237 не меняется. Адаптер последовательный, live-драйвер не подключён.
На каждой скорости выполнен шумный опыт трёх JPEG C, 24 RF-пакета:
единый coded-bit Eb/N0=7 dB, seed=50050, CFO +10 Гц, фаза 0,73 рад, смена полярности.
Все пять опытов дали 24/24 пакета и 3/3 точных JPEG до finish.
Большой IQ-файл не создавался; SHA проверялся после независимого CRC/сборки.
Обработка заняла 35,12417 / 59,47086 / 55,15755 / 60,95497 / 55,92195 с
при IQ 0,792483 / 0,401868 / 0,323745 / 0,206561 / 0,167499 с.
Отношения 44,32166 / 147,98602 / 170,37365 / 295,09484 / 333,86514:
приёмник не успевает в реальном времени. На 500k строгая обработка 54,586 с
против 0,492 с грубого поиска, 2374 кандидата и 24 принятых пакета.
После исправления вызовов API packet в модели итоговая матрица пересчитана
через --reuse-benchmarks; пять тяжёлых замеров сохранены без повторения.
Пик удерживаемых IQ 2,13–6,51 МиБ; расчёт массивов с запасом 47,5–143,4 МиБ,
не RSS процесса. Предел IQ-буфера не зависит от длительности сессии.
История событий симулятора хранится для отчёта и не выдаётся за постоянную
память бесконечной камеры. Потери блоков/переполнение live-драйвера не реализованы.
При подстановке измеренной стоимости RX все 30 кадров C/5fps отклоняются до TX,
FRESH DISPLAY FPS=0 для всех проверенных пределов возраста.
Условный вариант: JPEG ≤1952 байт, 500 ксимв/с, MAX_VIDEO_AGE=200 мс.
При стоимости RX-пакета ≤16 мс модель даёт 5 кадров/с и возраст около 192,5 мс;
это требует примерно 144-кратного ускорения данного замера.
Для 10 кадров/с нужны ≤12 мс и примерно 192-кратное ускорение; возраст около 160,5 мс,
29 показов в первые 3 с (9,667 кадра/с), ещё один после окна, интервалы около 10 кадров/с.
Ускорения не достигнуты и не обещаются. Обе цели без QPSK — CONDITIONAL;
нынешний RX — NO по реальному времени. Камера, экран, jitter и запас ещё не измерены.
Добавлены experiments/lab050_video_stream.py, lab050_streaming_receiver.py,
tests/test_lab050_video_stream.py, docs/lab050_current_state.md и локальные
результаты data/processed/lab050. Обновлён LABS_INDEX. Модули аварии,
управления и сеансов не менялись; при потере ELRS видео не продлевает движение.
44 целевых теста PASS. Verifier независимо проверил границы JPEG, 60 сценариев
очереди, 32 прогноза RX, исходные IQ-числа и все итоговые временные отметки JSON.
Adversary подтвердил устранение найденной регрессии свежести.
quick_gate PASS: 650 тестов и 14 функциональных проверок.
pytest: 650 passed за 78,73 с, одно предупреждение о доступе к .pytest_cache.
git diff --check PASS. RF TX=0, оборудование не запускалось.
Lab050 git add/commit и push/pull/fetch не выполнялись.
Следующий этап: STOP. Рекомендуемый Lab051 — программное профилирование
и ускорение строгой проверки ложных кандидатов на C/500k с сохранением
независимости RX и FEC/CRC. До доказанной скорости и свежести не выполнять
RF, QPSK, новый видеокодек или перенос SDR на 180 МГц.

View File

@@ -0,0 +1,385 @@
[
{
"symbol_rate": 200000,
"samples_per_symbol": 40,
"detection_decimation": 20,
"input_images": 3,
"tx_rf_packets": 24,
"accepted_rf_packets": 24,
"completed_images": 3,
"all_sha_match": true,
"packets_before_finish": 24,
"images_before_finish": 3,
"capture_seconds": 0.792483125,
"payload_airtime_seconds": 0.69192,
"processing_seconds": 35.12416689947713,
"processing_per_rf_second": 44.32165908829558,
"processing_per_payload_airtime_second": 50.76333521140758,
"coarse_seconds": 2.0254860994173214,
"strict_seconds": 32.75070630130358,
"assembly_jpeg_decode_seconds": 0.0013477999018505216,
"processing_seconds_per_rf_packet": 1.4635069541448804,
"peak_iq_buffer_samples": 853027,
"iq_buffer_limit_samples": 853556,
"working_arrays_estimate_bytes": 150378080,
"memory_is_process_rss_measurement": false,
"peak_object_payload_bytes": 1821,
"candidate_count": 2191,
"peak_candidates_in_block": 2145,
"rejected_reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 2146,
"TRUNCATED_IQ_WINDOW": 21
},
"buffer_release_delay_upper_seconds": 0.106195,
"noisy": true,
"coded_bit_ebn0_db": 7,
"cfo_hz": 10,
"candidate_threshold": 0.030964473262429237,
"completed": [
{
"image_id": 1,
"sha256": "15723773a8788c9d610c874707ad2d513582322ee1d42009b0b39d3de336e10d",
"bytes": 1740,
"dimensions": [
128,
96
],
"first_sample": 4321,
"last_sample": 1854754,
"emitted_after_sample": 2382902
},
{
"image_id": 2,
"sha256": "66404b14ca9e3a6627871ad9d3b5dd4311915d7820a99dfa182fc3a20fbe9d85",
"bytes": 1790,
"dimensions": [
128,
96
],
"first_sample": 1855061,
"last_sample": 3705166,
"emitted_after_sample": 4299138
},
{
"image_id": 3,
"sha256": "166d33d72820bf3385e0d30823d991b453147c84ca75f788ab8819ad5e3c3ced",
"bytes": 1821,
"dimensions": [
128,
96
],
"first_sample": 3705569,
"last_sample": 5555346,
"emitted_after_sample": 6145669
}
],
"rf_tx": 0
},
{
"symbol_rate": 400000,
"samples_per_symbol": 20,
"detection_decimation": 10,
"input_images": 3,
"tx_rf_packets": 24,
"accepted_rf_packets": 24,
"completed_images": 3,
"all_sha_match": true,
"packets_before_finish": 24,
"images_before_finish": 3,
"capture_seconds": 0.401868125,
"payload_airtime_seconds": 0.34596,
"processing_seconds": 59.47086290002335,
"processing_per_rf_second": 147.98601630826866,
"processing_per_payload_airtime_second": 171.90097959308403,
"coarse_seconds": 0.6804420000407845,
"strict_seconds": 58.67772239865735,
"assembly_jpeg_decode_seconds": 0.0011727997334674,
"processing_seconds_per_rf_packet": 2.4779526208343063,
"peak_iq_buffer_samples": 493587,
"iq_buffer_limit_samples": 496316,
"working_arrays_estimate_bytes": 87503840,
"memory_is_process_rss_measurement": false,
"peak_object_payload_bytes": 1821,
"candidate_count": 2575,
"peak_candidates_in_block": 2364,
"rejected_reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 2516,
"TRUNCATED_SYMBOLS": 17,
"TRUNCATED_IQ_WINDOW": 18
},
"buffer_release_delay_upper_seconds": 0.06154,
"noisy": true,
"coded_bit_ebn0_db": 7,
"cfo_hz": 10,
"candidate_threshold": 0.030964473262429237,
"completed": [
{
"image_id": 1,
"sha256": "15723773a8788c9d610c874707ad2d513582322ee1d42009b0b39d3de336e10d",
"bytes": 1740,
"dimensions": [
128,
96
],
"first_sample": 4321,
"last_sample": 932194,
"emitted_after_sample": 1229702
},
{
"image_id": 2,
"sha256": "66404b14ca9e3a6627871ad9d3b5dd4311915d7820a99dfa182fc3a20fbe9d85",
"bytes": 1790,
"dimensions": [
128,
96
],
"first_sample": 932501,
"last_sample": 1860046,
"emitted_after_sample": 2157842
},
{
"image_id": 3,
"sha256": "166d33d72820bf3385e0d30823d991b453147c84ca75f788ab8819ad5e3c3ced",
"bytes": 1821,
"dimensions": [
128,
96
],
"first_sample": 1860449,
"last_sample": 2787666,
"emitted_after_sample": 3050309
}
],
"rf_tx": 0
},
{
"symbol_rate": 500000,
"samples_per_symbol": 16,
"detection_decimation": 8,
"input_images": 3,
"tx_rf_packets": 24,
"accepted_rf_packets": 24,
"completed_images": 3,
"all_sha_match": true,
"packets_before_finish": 24,
"images_before_finish": 3,
"capture_seconds": 0.323744625,
"payload_airtime_seconds": 0.276768,
"processing_seconds": 55.15755260013975,
"processing_per_rf_second": 170.37364743936598,
"processing_per_payload_airtime_second": 199.29165438251442,
"coarse_seconds": 0.49211360013578087,
"strict_seconds": 54.585659996955656,
"assembly_jpeg_decode_seconds": 0.001369199831970036,
"processing_seconds_per_rf_packet": 2.2982313583391565,
"peak_iq_buffer_samples": 424309,
"iq_buffer_limit_samples": 424864,
"working_arrays_estimate_bytes": 74928288,
"memory_is_process_rss_measurement": false,
"peak_object_payload_bytes": 1821,
"candidate_count": 2374,
"peak_candidates_in_block": 2114,
"rejected_reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 2298,
"TRUNCATED_SYMBOLS": 38,
"TRUNCATED_IQ_WINDOW": 14
},
"buffer_release_delay_upper_seconds": 0.052608,
"noisy": true,
"coded_bit_ebn0_db": 7,
"cfo_hz": 10,
"candidate_threshold": 0.030964473262429237,
"completed": [
{
"image_id": 1,
"sha256": "15723773a8788c9d610c874707ad2d513582322ee1d42009b0b39d3de336e10d",
"bytes": 1740,
"dimensions": [
128,
96
],
"first_sample": 4321,
"last_sample": 747682,
"emitted_after_sample": 1025782
},
{
"image_id": 2,
"sha256": "66404b14ca9e3a6627871ad9d3b5dd4311915d7820a99dfa182fc3a20fbe9d85",
"bytes": 1790,
"dimensions": [
128,
96
],
"first_sample": 747989,
"last_sample": 1491022,
"emitted_after_sample": 1742690
},
{
"image_id": 3,
"sha256": "166d33d72820bf3385e0d30823d991b453147c84ca75f788ab8819ad5e3c3ced",
"bytes": 1821,
"dimensions": [
128,
96
],
"first_sample": 1491425,
"last_sample": 2234130,
"emitted_after_sample": 2496773
}
],
"rf_tx": 0
},
{
"symbol_rate": 800000,
"samples_per_symbol": 10,
"detection_decimation": 5,
"input_images": 3,
"tx_rf_packets": 24,
"accepted_rf_packets": 24,
"completed_images": 3,
"all_sha_match": true,
"packets_before_finish": 24,
"images_before_finish": 3,
"capture_seconds": 0.206560625,
"payload_airtime_seconds": 0.17298,
"processing_seconds": 60.9549745998811,
"processing_per_rf_second": 295.09484007361567,
"processing_per_payload_airtime_second": 352.38163140178693,
"coarse_seconds": 0.2952118997927755,
"strict_seconds": 60.60142269870266,
"assembly_jpeg_decode_seconds": 0.0011926002334803343,
"processing_seconds_per_rf_packet": 2.539790608328379,
"peak_iq_buffer_samples": 308819,
"iq_buffer_limit_samples": 317696,
"working_arrays_estimate_bytes": 56066720,
"memory_is_process_rss_measurement": false,
"peak_object_payload_bytes": 1821,
"candidate_count": 3113,
"peak_candidates_in_block": 2707,
"rejected_reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 3002,
"TRUNCATED_SYMBOLS": 87
},
"buffer_release_delay_upper_seconds": 0.0392125,
"noisy": true,
"coded_bit_ebn0_db": 7,
"cfo_hz": 10,
"candidate_threshold": 0.030964473262429237,
"completed": [
{
"image_id": 1,
"sha256": "15723773a8788c9d610c874707ad2d513582322ee1d42009b0b39d3de336e10d",
"bytes": 1740,
"dimensions": [
128,
96
],
"first_sample": 4321,
"last_sample": 470914,
"emitted_after_sample": 645226
},
{
"image_id": 2,
"sha256": "66404b14ca9e3a6627871ad9d3b5dd4311915d7820a99dfa182fc3a20fbe9d85",
"bytes": 1790,
"dimensions": [
128,
96
],
"first_sample": 471221,
"last_sample": 937486,
"emitted_after_sample": 1112086
},
{
"image_id": 3,
"sha256": "166d33d72820bf3385e0d30823d991b453147c84ca75f788ab8819ad5e3c3ced",
"bytes": 1821,
"dimensions": [
128,
96
],
"first_sample": 937889,
"last_sample": 1403826,
"emitted_after_sample": 1600933
}
],
"rf_tx": 0
},
{
"symbol_rate": 1000000,
"samples_per_symbol": 8,
"detection_decimation": 4,
"input_images": 3,
"tx_rf_packets": 24,
"accepted_rf_packets": 24,
"completed_images": 3,
"all_sha_match": true,
"packets_before_finish": 24,
"images_before_finish": 3,
"capture_seconds": 0.167498625,
"payload_airtime_seconds": 0.138384,
"processing_seconds": 55.921951099997386,
"processing_per_rf_second": 333.86513531079663,
"processing_per_payload_airtime_second": 404.1070578968478,
"coarse_seconds": 0.20818859990686178,
"strict_seconds": 55.66615750128403,
"assembly_jpeg_decode_seconds": 0.0011203999165445566,
"processing_seconds_per_rf_packet": 2.3300812958332244,
"peak_iq_buffer_samples": 279589,
"iq_buffer_limit_samples": 281968,
"working_arrays_estimate_bytes": 49778592,
"memory_is_process_rss_measurement": false,
"peak_object_payload_bytes": 1821,
"candidate_count": 2995,
"peak_candidates_in_block": 2361,
"rejected_reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 2832,
"TRUNCATED_SYMBOLS": 96,
"TRUNCATED_IQ_WINDOW": 43
},
"buffer_release_delay_upper_seconds": 0.034746,
"noisy": true,
"coded_bit_ebn0_db": 7,
"cfo_hz": 10,
"candidate_threshold": 0.030964473262429237,
"completed": [
{
"image_id": 1,
"sha256": "15723773a8788c9d610c874707ad2d513582322ee1d42009b0b39d3de336e10d",
"bytes": 1740,
"dimensions": [
128,
96
],
"first_sample": 4321,
"last_sample": 378658,
"emitted_after_sample": 565220
},
{
"image_id": 2,
"sha256": "66404b14ca9e3a6627871ad9d3b5dd4311915d7820a99dfa182fc3a20fbe9d85",
"bytes": 1790,
"dimensions": [
128,
96
],
"first_sample": 378965,
"last_sample": 752974,
"emitted_after_sample": 892978
},
{
"image_id": 3,
"sha256": "166d33d72820bf3385e0d30823d991b453147c84ca75f788ab8819ad5e3c3ced",
"bytes": 1821,
"dimensions": [
128,
96
],
"first_sample": 753377,
"last_sample": 1127058,
"emitted_after_sample": 1324165
}
],
"rf_tx": 0
}
]

View File

@@ -240,6 +240,41 @@
- **Далее:** программный Lab050 — профиль RX, поток видео и ограничение возраста очереди.
- **RF:** RF TX = 0, STOP.
## Lab050
- **Цель:** последовательность JPEG через отдельный SDR-транспорт, ограничение
возраста изображения и блочный независимый IQ-приём. Управление ELRS отдельно.
- **База:** Lab049 закреплена коммитом
`cdf5396e807b83f82a264632dc46f71428fcd616`; Lab050 не добавлена в индекс и не коммитилась.
- **Переиспользовано:** планировщик Lab035 с LATEST_ONLY и завершением активного
изображения. Добавлены готовность JPEG отдельно от съёмки, произвольный предел
возраста и прогноз RX. Поздно готовый старый снимок не вытесняет свежий.
- **Профили:** A 320×240 RGB q35 — 8724 байта; B 160×120 RGB q35 — 2771;
C 128×96 RGB q25 — 1740; D 160×120 grayscale q30 — 2028. По 30
детерминированных сдвигов исходного фото; это не видео камеры.
- **Матрица:** 240 сценариев, вход 3/5/10 кадров/с, Rs=200/400/500/800/1000 ксимв/с,
MAX_VIDEO_AGE=200/333/500/1000 мс. Ожидающая очередь ≤1.
- **Идеальная модель:** C/500k — 5 или 10 свежих кадров/с, возраст около 92,6 мс.
При камере 10 и Rs=200k, возрасте 333 мс: 14 передач, 16 замен, 4,333 свежих
кадра/с внутри окна и один дополнительный после него.
- **Потоковый RX:** 24/24 RF-пакета и 3/3 точных JPEG до `finish` на каждой скорости;
IQ-память ограничена. В шумном опыте processing/RF = 44,322 / 147,986 /
170,374 / 295,095 / 333,865. Реального времени нет.
- **Память:** удерживаемые IQ 2,13–6,51 МиБ; расчёт массивов 47,5–143,4 МиБ,
не измеренный RSS. Состояние RX не растёт вместе с длиной сессии.
- **Достижимость:** 5/10 кадров/с без QPSK — CONDITIONAL. C≤1952 байт,
500 ксимв/с, стоимость RX-пакета ≤16/12 мс для модели возраста 200 мс;
требуется ещё примерно 144/192-кратное ускорение относительно этого замера.
- **Код:** [видеомодель](../experiments/lab050_video_stream.py),
[потоковый RX](../experiments/lab050_streaming_receiver.py).
- **Проверки:** [44 теста Lab050](../tests/test_lab050_video_stream.py),
quick_gate — 650 тестов и 14 функциональных проверок; независимые verifier/adversary.
- **Отчёт:** [состояние](lab050_current_state.md),
[JSON](../data/processed/lab050/result.json),
[замеры RX](../data/processed/lab050/receiver_benchmarks.json). Журнал: запись 039.
- **Далее:** Lab051 — профиль и ускорение строгой обработки ложных кандидатов.
- **RF:** RF TX = 0. Lab050 commit/push не выполнялись. STOP.
## Правило хранения данных
Большие записи IQ, потоки `cs8`, бинарные объекты и другие аппаратные исходные

View File

@@ -0,0 +1,379 @@
# Lab050: потоковая архитектура видео и возраст изображения
8 сентября 2026 года. SOFTWARE ONLY. RF TX = 0. Оборудование не запускалось
и не инвентаризировалось. Управление ELRS/CRSF и SDR-видео остаются раздельными.
**Получен блочный независимый RX с ограниченным IQ-буфером и модель свежей
видеопередачи. Текущий RX не обеспечивает 5 или 10 свежих изображений/с.**
По времени радио оба уровня возможны на уменьшенном JPEG без QPSK; итог
полной системы — CONDITIONAL, с конкретными ещё не выполненными условиями ниже.
## Фиксация Lab049 и исходный аудит
Исходный HEAD `ac6cc792b896e34f7dd6d291955a0387fdafcf41`, ветка main.
Проверены status, diff/stat, diff/check и точный staged scope. В отдельный
коммит вошли 12 файлов Lab049: три скрипта, три файла тестов, три отчёта,
README, PROJECT_LOG и LABS_INDEX. Сгенерированные данные, `.codex`, локальные
AGENTS/CLAUDE и посторонние файлы не добавлены.
Создан разрешённый коммит:
`cdf5396e807b83f82a264632dc46f71428fcd616`
— `Lab049: define ELRS control and SDR video architecture`.
От него выполнена Lab050; её изменения не добавлялись в индекс и не коммитились.
Push/pull/fetch не выполнялись.
Аудит Lab034–037 и связанных модулей показал:
| Уже существовало | Как используется в Lab050 |
|---|---|
| `image_fragments.py`, Lab046: image ID, индекс/число фрагментов, размер объекта | Неизменная фрагментация JPEG, отдельный ID каждого снимка |
| Lab034: группировка изображения и изоляция стирающего FEC | Переиспользована семантика границ изображения; старый 12+3 FEC не включён |
| Lab035, `video_frame_scheduler.py`: активный кадр, очередь ожидающих, LATEST_ONLY, прогноз завершения | Расширен существующий планировщик; новая независимая очередь не создана |
| Lab036–037: один самый свежий ожидающий кадр, завершение активного | Сохранена политика; очередь ожидающих не больше одного изображения |
| Lab048: корреляционный поиск и строгий PHY/FEC/CRC | Использованы в блочном API; чтение IQ отделено от memmap |
Изменения кода: `protocol/video_frame_scheduler.py` получает готовность JPEG
отдельно от времени съёмки, произвольный MAX_VIDEO_AGE и прогноз задержки после
радио. Старые вызовы и все пять прежних политик сохраняют поведение.
`lab046c_offline_recovery.decode_candidate_from_memmap` принимает необязательный
читатель IQ; прежнее чтение CS8 остаётся поведением по умолчанию.
Новые файлы: [модель и эксперимент](../experiments/lab050_video_stream.py),
[блочный RX](../experiments/lab050_streaming_receiver.py),
[тесты](../tests/test_lab050_video_stream.py).
## Политика свежести
Активное изображение является выбранной атомарной единицей обслуживания:
после допуска оно завершается целиком. Отдельный физический пакет тем более
не прерывается. Во время передачи каждого пакета принимаются события готовности
новых JPEG и заменяется ожидающий снимок. При постоянной скорости, отсутствии
CONTROL в SDR и известном числе пакетов точный прогноз допуска позволяет
не начинать изображение, уже не укладывающееся в возраст.
Свежесть определяется парой `(capture_timestamp, image_id)`, а не порядком
завершения кодирования. Поздно готовый старый снимок не вытесняет более свежий
и не возвращается на экран после опустошения очереди. Обнаруженный независимыми
проверяющими отказ этого правила исправлен и покрыт двумя регрессиями.
Причины удаления различаются: `replaced_by_newest`, `older_capture_arrived_late`,
`prediction_reject`; при оценке загруженного RX возможен также отказ допуска
к обработке. Если уже нижняя оценка задержки RX делает кадр просроченным,
его отклоняет TX-допуск, до занятия эфира. Удаление снимков — нормальная
политика видео, а не потеря обязательной управляющей команды.
Планировщик здесь остаётся событийной моделью с известными входами и историей
для отчёта. Его массив входов/результатов зависит от числа исследуемых кадров;
**логическая ожидающая очередь** ограничена одним снимком. Готового адаптера
живой камеры с ограниченным журналом пока нет. Независимость памяти от длины
сессии реализована и проверена непосредственно для IQ-приёмника.
## Профили JPEG и источник
Источник — реальное фото `data/raw/lab011_scene.jpg`. Для каждого профиля
созданы 30 исходных кадров: первый повторяет исходную подготовку, остальные
получены детерминированным циклическим сдвигом `(7i mod 61, 3i mod 31)` пикселей
до уменьшения. Это искусственная последовательность одного фото, не съёмка камеры.
Разрешение, цвет и quality фиксированы; размеры файлов не подбирались вручную.
Во всех профилях subsampling=2, optimize=False, progressive=False.
| Профиль | Разрешение | Цвет | Quality | Первый JPEG, байт | Диапазон 30 JPEG, байт | PSNR первого, dB |
|---|---|---|---:|---:|---:|---:|
| [A](../data/processed/lab050/profile_A.jpg) | 320×240 | RGB | 35 | 8724 | 8724–9250 | 28,12 |
| [B](../data/processed/lab050/profile_B.jpg) | 160×120 | RGB | 35 | 2771 | 2771–3031 | 28,11 |
| [C](../data/processed/lab050/profile_C.jpg) | 128×96 | RGB | 25 | 1740 | 1740–1893 | 27,17 |
| [D](../data/processed/lab050/profile_D.jpg) | 160×120 | grayscale | 30 | 2028 | 2028–2233 | 30,15 |
PSNR = 10 log10(255²/MSE); сравнение с несжатым изображением **того же
разрешения и цветового режима** после преобразования. Оно характеризует
JPEG-искажение внутри профиля, не потерю деталей при уменьшении и не
пригодность картинки для управления. Между RGB и grayscale эти числа не
доказывают превосходство качества. Четыре выходных JPEG визуально проверены;
субъективный вердикт пригодности для оператора не присваивался.
Первый A побайтно совпал с Lab047, SHA-256:
`e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a`.
Исходный контрольный JPEG не изменён. Перекодирование новых профилей разрешено
постановкой Lab050; новый видеокодек не разрабатывался.
## Точная ёмкость нынешнего PHY
Один RF-пакет переносит 244 байта JPEG плюс 12 байт метаданных фрагмента,
дополняется до 256 байт нагрузки packet и до 268 байт вместе с packet header/CRC.
FEC R=1/2 K=7, шесть хвостовых бит, перемежитель, PHY header, PRBS7 и BPSK
сохранены. RF-пакет занимает 5724 передаваемых бита и 42 дополнительных
символьных интервала, итого **5766**. Проверено по настоящему плану Lab045.
Для JPEG размера J: N=ceil(J/244), T_RF=N×5766/Rs.
Точный максимальный размер для f кадров/с:
`Jmax = 244 × floor(Rs/(5766×f))`.
Это ступенчатая граница с добивкой, не деление полезного битрейта на восемь.
| Rs, ксимв/с | Выборок/символ при 8 Мвыб/с | Jmax для 3 кадров/с | Jmax для 5 кадров/с | Jmax для 10 кадров/с |
|---:|---:|---:|---:|---:|
| 200 | 40 | 2684 | 1464 | 732 |
| 400 | 20 | 5612 | 3172 | 1464 |
| 500 | 16 | 6832 | 4148 | 1952 |
| 800 | 10 | 11224 | 6588 | 3172 |
| 1000 | 8 | 13908 | 8296 | 4148 |
Все размеры в байтах. На пределе нет запаса для потерь, пауз, объёмных данных
или роста JPEG. Никакие ELRS CONTROL/TELEMETRY из этого бюджета не вычитаются.
Физическая стоимость старого LinkPacket используется лишь как эквивалентная
шкала времени существующего симулятора: его длительность принудительно равна
5766/Rs. LinkPacket не добавлен в реальную PHY-упаковку Lab046.
Максимум изображений/с по времени радио для **первого** JPEG каждого профиля:
| Rs, ксимв/с | A, 36 пакетов | B, 12 пакетов | C, 8 пакетов | D, 9 пакетов |
|---:|---:|---:|---:|---:|
| 200 | 0,964 | 2,891 | 4,336 | 3,854 |
| 400 | 1,927 | 5,781 | 8,672 | 7,708 |
| 500 | 2,409 | 7,226 | 10,839 | 9,635 |
| 800 | 3,854 | 11,562 | 17,343 | 15,416 |
| 1000 | 4,818 | 14,453 | 21,679 | 19,270 |
По всей последовательности A использует до 38 пакетов, B до 13, D до 10;
C остаётся в восьми пакетах. Именно C удобен для следующего воспроизводимого
опыта. Профиль B также укладывается по радио в 5 кадров/с на 400 ксимв/с.
JPEG A не достигает 5 кадров/с даже на 1 Мсимв/с: 8724 > 8296 байт.
## Матрица очередей и фактический смысл FPS
Выполнено 240 сценариев: четыре профиля × входные 3/5/10 кадров/с × пять Rs
× пределы возраста 200/333/500/1000 мс. По 30 входных изображений в каждом.
Идеальный пакетный путь сериализует packet, проверяет CRC и собирает точные
JPEG. Стоимость FEC/поиска IQ в этой матрице сначала равна нулю **по модели**;
её реальный замер выполнен отдельно, ниже. Ошибок радио в матрице нет.
Окно наблюдения H=30/input_fps: 10, 6 или 3 с. Основные RAW RECEIVED FPS и
FRESH DISPLAY FPS считают только события внутри H. Полученные при дальнейшем
опустошении кадры показываются отдельно. Свежим считается только показанный
снимок с возрастом ≤MAX_VIDEO_AGE. Просроченное изображение на экран не выпускается,
поэтому raw display FPS совпадает с fresh display FPS; повторный показ старого
изображения не засчитывается новым обновлением.
| Модель | Возраст | Вход | Передано | Удалено до TX | Получено / показано всего | Fresh FPS в H | После H |
|---|---:|---:|---:|---:|---:|---:|---:|
| C, камера 5, Rs=500k | 200 мс | 30 | 30 | 0 | 30 / 30 | **5,000** | 0 |
| C, камера 10, Rs=500k | 200 мс | 30 | 30 | 0 | 30 / 30 | **10,000** | 0 |
| B, камера 5, Rs=400k | 200 мс | 30 | 30 | 0 | 30 / 30 | **5,000** | 0 |
| C, камера 10, Rs=200k | 200 мс | 30 | 0 | 30, прогноз возраста | 0 / 0 | **0** | 0 |
| C, камера 10, Rs=200k | 333 мс | 30 | 14 | 16, замена свежим | 14 / 14 | **4,333** | 1 |
| A, камера 5, Rs=1000k | 200 мс | 30 | 0 | 30, прогноз возраста | 0 / 0 | **0** | 0 |
Для перегруженного C/10/200k при 333 мс возраст показанных снимков примерно
231–329 мс, ожидающая очередь ≤1. Увеличение предела до 500/1000 мс в данном
сценарии не меняет 14 передач и 16 замен; оно не создаёт дополнительную ёмкость.
При 200 мс уже само радио требует 230,64 мс и все снимки обоснованно отклонены.
Результат C/5/500k: IMAGE AGE min/median/max = **92,563 / 92,590 / 92,629 мс**.
Это возраст **в модели идеального RX и мгновенного экрана**, не камера→монитор.
Вход 30, transmitted=received=displayed=30, dropped=0, H=6 с,
RAW RECEIVED FPS=RAW DISPLAY FPS=FRESH DISPLAY FPS=5.
## Блочный приёмник
```mermaid
flowchart LR
I[IQ-блок до 65536 выборок] --> B[Ограниченный буфер и хвост]
B --> C[Грубый поиск в принадлежащем блоку интервале]
C --> S[Штатная строгая проверка PHY/FEC/CRC]
S --> P[Выдача принятого пакета из push_iq_block]
P --> A[Сборщик текущего image ID]
A --> J[JPEG decode; далее проверка возраста и экран]
```
`StreamingReceiver.push_iq_block()` принимает непрерывные complex64-блоки
без границ TX, payload, ожидаемых ID или числа кадров. `finish()` проверяет
остаток, не дополняет недостающую запись нулями. Выдача пакетов не ждёт конца
сессии. Глобальные индексы сохраняются; принятая область исключается из
повторной строгой проверки. Список кандидатов относится лишь к одному блоку.
Внутри RX нет истории принятых пакетов; вызывающий код должен сразу их забирать.
Размер удерживаемого хвоста определяется максимально допустимым PHY packet
1024 байта, а не известными передатчику границами. Сохраняются левый запас
уточнения, опорная последовательность, счётчики и текущие IQ. Порог Lab047
`0.030964473262429237` не меняется и не подстраивается по успеху JPEG.
Скалярная статистика корреляций не объявляется новой калибровкой фона.
Децимация 20 старого поиска непригодна как универсальная константа для
высоких скоростей. В адаптере она равна min(20, samples_per_symbol//2):
**20/10/8/5/4** для 200/400/500/800/1000 ксимв/с, оставляя две выборки/символ.
Меняются параметры поиска, но не FEC, CRC, модуляция или порог.
Константы старых лабораторий восстанавливаются после вызова; это
последовательный программный адаптер, не потокобезопасная многопоточная библиотека.
IQ-блоки должны быть непрерывными. При будущих потерях блоков драйвера нужно
явно сбросить незавершённый поиск/объект и обозначить разрыв; live-подключение,
обработка таких разрывов и управление переполнением драйвера здесь не реализованы.
Синхронный `push` создаёт обратное давление программному источнику; при текущей
скорости RX настоящий источник 8 Мвыб/с потребовал бы потерь/переполнения.
## Интеграционный опыт IQ и производительность
На **каждой** из пяти скоростей поданы три JPEG C, 24 физических пакета,
с неизвестным началом, межкадровыми интервалами и границами входных блоков.
CS8-квантование передатчика сохранено. Шум один для всех: coded-bit Eb/N0=7 dB,
seed=50050, остаточная частота +10 Гц, фаза 0,73 рад и чередование полярности.
После добавления шума поток complex64 не переквантован. Фон также генерируется
детерминированно. TX создаёт лишь очередной RF-пакет, целая IQ-запись не хранится.
**Все пять проходов: 24/24 пакета, 3/3 JPEG, точный SHA; все JPEG выданы до finish.**
Сборщик принимает ID/индексы только из проверенных CRC пакетов, сопоставление
с TX-хешами происходит после сборки. В этом опыте ID монотонны в одной сессии;
wrap/restart видеосеанса не реализован. Разрешён только текущий объект,
ограничены его размер (64 КиБ) и число фрагментов; журнал результатов опыта
отделён от памяти RX. Дополнительно пройдены интеграционные тесты идеального
канала на всех скоростях, границ двух блоков, усечённого потока и длинного фона.
| Rs, ксимв/с | IQ, с | Обработка, с | Обработка / RF-секунда | Кандидатов строгой проверки | Оценка массивов RX, МиБ |
|---:|---:|---:|---:|---:|---:|
| 200 | 0,792483 | 35,124167 | **44,322** | 2191 | 143,412 |
| 400 | 0,401868 | 59,470863 | **147,986** | 2575 | 83,450 |
| 500 | 0,323745 | 55,157553 | **170,374** | 2374 | 71,457 |
| 800 | 0,206561 | 60,954975 | **295,095** | 3113 | 53,469 |
| 1000 | 0,167499 | 55,921951 | **333,865** | 2995 | 47,473 |
Знаменатель — все поданные IQ, включая фон/хвост. В JSON отдельно есть время
чистой передачи полезных RF-пакетов и соответствующее ещё худшее отношение.
CPU-замер включает push/finish, сборку и JPEG decode; генерация TX/IQ,
преобразование исходной фотографии и сравнение TX-хешей исключены.
Это однократные настольные wall-clock замеры, не норматив CPU и не оценка
предельной дальности. Они не сравниваются напрямую с Lab048 на другой записи.
На 500 ксимв/с грубый поиск занял 0,492 с, строгая обработка — 54,586 с.
Из 2374 кандидатов приняты 24; большинство остальных отклонено по magic
PHY-заголовка. Новый ограниченный буфер **не устранил** стоимость ложных
кандидатов. Увеличение Rs не делает этот приёмник быстрее реального времени.
Факт Lab048 «15 с → 13,966 с» остаётся характеристикой прежней записи.
Пять тяжёлых проходов выполнены один раз. Первый запуск после сохранения
замеров остановился на несовпадении API `build_packet/parse_packet` в модели
очереди; вызовы исправлены и проверены тестами. Итоговая матрица построена
с `--reuse-benchmarks`, без повторения RF/IQ-замеров. Режим проверяет скорости,
параметры шума/порог и хеши восстановленных контрольных объектов.
## Память
Предел блока 65536 выборок. Для каждого Rs буфер ограничен выражением:
`core + maximum_PHY_waveform + refinement_margin + left_margin + input_block`.
Число кадров в сессии в формулу не входит. В тесте 100 последовательных
блоков сохраняется тот же предел и не появляется история IQ.
| Rs, ксимв/с | Измеренный пик удерживаемых IQ, выборок | Проверяемый предел, выборок | IQ в пике, МиБ |
|---:|---:|---:|---:|
| 200 | 853027 | 853556 | 6,508 |
| 400 | 493587 | 496316 | 3,766 |
| 500 | 424309 | 424864 | 3,237 |
| 800 | 308819 | 317696 | 2,356 |
| 1000 | 279589 | 281968 | 2,133 |
С запасом на копирование, грубый и строгий поиск расчёт массивов даёт
47,5–143,4 МиБ против исторической оценки Lab048 около 1,44 ГиБ.
Это **расчёт массивов, не измеренный RSS процесса**: Python/SciPy, аллокатор,
кэши и вызывающая программа добавляют память. История отчёта и заранее
подготовленные 30 JPEG относятся к эксперименту, не к обязательному IQ-состоянию.
## Задержка и условие достижимости
Для каждого снимка JSON хранит capture, encode_complete, queue_enter,
first_tx, last_tx, first_rx, object_complete, decode_complete и display.
У не переданных/не допущенных стадий стоит null, причина удаления явна.
QUEUE AGE=first_tx−queue_enter; RF AGE=last_tx−first_tx;
END-TO-END IMAGE AGE=display−capture.
| Слагаемое | Статус |
|---|---|
| T_CAPTURE | Реальная экспозиция/считывание неизвестны; источник задан событиями, длительность моделируется нулём |
| T_ENCODE | Измерено CPU-кодирование подготовленных изображений; C медиана около 0,143 мс; уменьшение/сдвиг вне этого замера |
| T_QUEUE | Рассчитано существующим планировщиком; не измерение живой очереди |
| T_RF | Рассчитано по настоящему формату; C на 500k — 92,256 мс |
| T_RX_PROCESSING | Измерено в IQ-опыте, включая взаимодействие с буфером; в модели подставляется отдельно как стоимость пакета |
| T_DECODE | Измерено CPU JPEG decode; C медиана около 0,187 мс; в общем IQ-замере уже включено, повторно туда не прибавляется |
| T_DISPLAY | Экран не подключён; длительность моделируется нулём |
В конвейере RF и обработка ранних пакетов перекрываются. Нельзя прибавлять
полный CPU service time к полному RF-времени и называть это измеренной
сквозной задержкой. Прогноз без предыдущей очереди RX после last_tx:
`lookahead + p + (N−1)×max(0,p−T_packet) + JPEG_decode`, где p — стоимость
пакета. Формула независимо проверена последовательным расчётом 32 наборов
событий и допуском по обе стороны точной границы возраста.
На 500k консервативный предел выдачи из буфера без стоимости CPU — 52,608 мс.
Средняя измеренная стоимость полного RX-опыта — 2,298 с на переданный RF-пакет
(включает фон и отвергнутых кандидатов, не только принятый FEC). При подстановке
этой стоимости все 30 кадров C/5fps отклоняются ещё до TX при любом проверенном
MAX_VIDEO_AGE; **FRESH DISPLAY FPS=0**. Это прогноз с измеренной стоимостью,
не эксперимент с живым экраном.
Проверены также два явно условных варианта ускоренного RX:
| Условие | 5 кадров/с | 10 кадров/с |
|---|---:|---:|
| Выбранный JPEG | ≤1952 байт, 8 пакетов; C фактически ≤1893 | То же |
| Выбранный Rs | 500 ксимв/с, 16 выборок/символ | То же |
| Нижняя граница только по радио для 8 пакетов | 230,640 ксимв/с | 461,280 ксимв/с |
| Предположенная стоимость RX-пакета | ≤16 мс | ≤12 мс |
| Ускорение относительно данного замера | **≥143,64 раза** | **≥191,52 раза** |
| MAX_VIDEO_AGE | 200 мс | 200 мс |
| Расчётный возраст min/median/max | 192,447 / 192,474 / 192,513 мс | 160,447 / 160,474 / 160,513 мс |
| Свежих в исходном окне | 30/6 с = **5** | 29/3 с = **9,667** |
| С учётом опустошения | 30; без дополнительных | 30; один после 3 с |
| Частота по интервалам последовательных показов | около 5 | около 10 |
| FEASIBLE | **CONDITIONAL** | **CONDITIONAL** |
Для 10 кадров/с стартовая задержка превышает период камеры, поэтому первый
трёхсекундный отчёт честно даёт 9,667, а не 10 с включением позднего кадра.
Частота около 10 относится к установившимся интервалам. Условия не означают,
что такое ускорение достигнуто или достижимо только небольшой правкой.
Живые capture/display, входной jitter, потери и запас CPU ещё должны уложиться
в оставшийся бюджет. Для 5 кадров/с запас возраста этого примера лишь около 7,5 мс.
Итак, **по пропускной способности BPSK достаточно**, без QPSK и нового FEC;
**с нынешним измеренным приёмником — нет**. Аппаратная работоспособность
HackRF на этих Rs в данном задании не проверялась. 3 кадра/с остаются
промежуточным уровнем, цель проекта >3, ориентиры 5 и 10.
## ELRS и следующий опыт
CONTROL LINK OK/LOST — внешнее состояние для контроллера ровера. Видеопланировщик
вообще не принимает его как радиопакет. Проверка на существующем TwoStageFailsafe:
при отсутствии новых команд 100 мс → замедление, 200 мс → торможение,
а результат видеомодели остаётся тем же. Это проверка разделения моделей,
не готовое сопряжение CRSF. Safety/session/emergency-модули не менялись.
Рекомендуемый Lab051 — программное профилирование и ускорение строгого
обработчика ложных кандидатов на сохранённом детерминированном сценарии
C/500k, с отдельной проверкой фоновых ложных принятий и неизменными FEC/CRC.
Измерять стоимость уточнения, оценки частоты, заголовка и FEC; сохранить
независимость от TX и корректность на границах блоков. Не объявлять 5 кадров/с
до полного непрерывного опыта со свежими JPEG и устойчивым временем обработки.
RF, QPSK, новый кодек, 12+3 FEC и перенос на 180 МГц этим этапом не разрешаются.
## Воспроизведение
```powershell
python experiments/lab050_video_stream.py
# Только пересчёт матрицы с уже сохранёнными локальными замерами IQ:
python experiments/lab050_video_stream.py --reuse-benchmarks
python -m pytest tests/test_lab050_video_stream.py -q -p no:cacheprovider
python tools/quick_gate.py
python -m pytest tests/ -q
git diff --check
```
Полный первый запуск занимает минуты; новые RF-сеансы в нём отсутствуют.
[result.json](../data/processed/lab050/result.json) содержит 240 сценариев,
все временные отметки, профили, расчёты ёмкости и отдельные условные модели.
[receiver_benchmarks.json](../data/processed/lab050/receiver_benchmarks.json)
содержит исходные пять IQ-замеров. Большие IQ-файлы не создавались.
44 целевых теста проходят. Verifier/adversary подтвердили исправление
поздно завершённого старого кадра; verifier независимо проверил границы
ёмкости, 60 сценариев очереди, 32 прогноза RX и арифметику IQ-замеров.
Итог: quick_gate PASS — 650 тестов и 14 функциональных проверок;
полный pytest — 650 passed за 78,73 с, одно предупреждение о доступе
к `.pytest_cache`; git diff --check PASS. Подробности — в записи 039 журнала.
LAB050 GIT ADD = NOT PERFORMED. LAB050 COMMIT = NOT PERFORMED.
PUSH = NOT PERFORMED. RF TX = 0. STOP.

View File

@@ -639,10 +639,13 @@ def decode_candidate_from_memmap(
sample_count: int,
candidate: CoarseCandidate,
detection_number: int,
*,
read_iq_window: Callable | None = None,
) -> CandidateOutcome:
"""Строго проверить один candidate локальными IQ-окнами."""
started = time.perf_counter()
window_reader = lab046c._cs8_window if read_iq_window is None else read_iq_window
reference = lab046c.lab044._preamble_reference_samples()
probe_symbols = lab046c.lab045.PREAMBLE_BIT_COUNT + lab046c.lab045.PHY_HEADER_BIT_COUNT
probe_waveform = (
@@ -655,7 +658,7 @@ def decode_candidate_from_memmap(
sample_count,
candidate.sample_offset + probe_waveform + REFINEMENT_MARGIN_SAMPLES,
)
probe_iq = lab046c._cs8_window(raw, probe_start, probe_end)
probe_iq = window_reader(raw, probe_start, probe_end)
maximum_window = int(probe_iq.size)
local_coarse = candidate.sample_offset - probe_start
try:
@@ -740,7 +743,7 @@ def decode_candidate_from_memmap(
maximum_window=maximum_window,
started=started,
)
full_iq = lab046c._cs8_window(raw, refined_global, full_end)
full_iq = window_reader(raw, refined_global, full_end)
maximum_window = max(maximum_window, int(full_iq.size))
full = lab046b._extract_soft_window(full_iq, 0, frame_symbols)
if not full.passed:

View File

@@ -0,0 +1,163 @@
"""Lab050: независимый приём IQ-блоков с ограниченной памятью, без RF.
Использует корреляцию и строгий PHY/FEC/CRC Lab048. Принятые пакеты сразу
возвращаются вызывающему коду; история IQ/пакетов внутри не накапливается.
Адаптер констант старых лабораторий рассчитан на последовательный вызов.
"""
from __future__ import annotations
from collections import Counter
from contextlib import contextmanager
from dataclasses import dataclass
import math
import time
import numpy as np
from experiments import lab046c_offline_recovery as offline
from experiments import lab049_bpsk_symbol_rate as phy
from experiments.lab049_independent_iq import receiver_mode
@contextmanager
def streaming_mode(mode: phy.PhyMode):
"""Оставить не менее двух выборок/символ в грубом поиске."""
module = phy.lab045.lab044.lab043
original = module.DETECTION_DECIMATION
with receiver_mode(mode):
module.DETECTION_DECIMATION = min(20, mode.samples_per_symbol // 2)
try:
yield
finally:
module.DETECTION_DECIMATION = original
@dataclass(frozen=True)
class ReceivedPacket:
start_sample: int
end_sample: int
emitted_after_sample: int
payload: bytes
class StreamingReceiver:
"""push_iq_block выдаёт пакеты до конца потока; finish только опустошает хвост.
Размер кадра для удержания IQ определяется максимальной допустимой PHY
length (1024 байта), а не TX-границами/числом ожидаемых пакетов.
Порог Lab047 остаётся фиксированным. Источник и размеры объекта неизвестны.
"""
def __init__(self, symbol_rate: int, *, block_samples: int = 65_536):
self.mode = phy.PhyMode(symbol_rate)
if self.mode.samples_per_symbol < 4:
raise ValueError("Для этого адаптера нужно не менее четырёх выборок/символ")
if type(block_samples) is not int or block_samples < 1024:
raise ValueError("Размер блока должен быть целым и не меньше 1024")
self.block_samples = block_samples
self.decimation = min(20, self.mode.samples_per_symbol // 2)
self.core_samples = math.ceil(block_samples / self.decimation) * self.decimation
margin = offline.REFINEMENT_MARGIN_SAMPLES
self.margin = math.ceil(margin / self.decimation) * self.decimation
maximum_symbols = (phy.lab045.PREAMBLE_BIT_COUNT + phy.lab045.PHY_HEADER_BIT_COUNT
+ phy.lab045.conv.encoded_bit_count(phy.lab045.MAX_PACKET_BYTES * 8)
+ 2 * phy.lab045.GUARD_SYMBOL_COUNT + phy.lab045.RRC_SPAN_SYMBOLS)
self.lookahead = maximum_symbols * self.mode.samples_per_symbol + self.margin
self.buffer_limit_samples = self.core_samples + self.lookahead + self.margin + block_samples
self.buffer = np.empty(0, np.complex64)
self.base_sample = self.scan_sample = self.total_samples = self.accepted_until = 0
self.peak_buffer_samples = self.peak_candidate_count = 0
self.packet_count = self.candidate_count = self.blocks_processed = 0
self.processing_seconds = self.coarse_seconds = self.strict_seconds = 0.0
self.score_count = 0
self.score_sum = 0.0
self.rejected_reasons: Counter = Counter()
self.closed = False
with streaming_mode(self.mode):
self.reference = offline.lab046b._decimate_then_downconvert(
phy.lab045.lab044._preamble_reference_samples()).copy()
@property
def memory_estimate_bytes(self) -> int:
"""Консервативная оценка массивов, не измеренный RSS Python/SciPy."""
window_bytes = self.buffer_limit_samples * np.dtype(np.complex64).itemsize
# Два буфера при копировании + прежний коэффициент временных массивов
# Lab048 + запас для строгого matched filter и индексов кандидатов.
return 2 * window_bytes + 12 * window_bytes + 8 * window_bytes + self.reference.nbytes
def _scan(self, owned_end: int) -> list[ReceivedPacket]:
started = time.perf_counter()
scores = offline._correlation_scores(self.buffer, self.reference)
self.coarse_seconds += time.perf_counter() - started
indexes = offline._all_local_peak_indexes(scores)
offsets = self.base_sample + indexes * self.decimation
indexes = indexes[(offsets >= self.scan_sample) & (offsets < owned_end)]
self.score_count += len(indexes)
self.score_sum += float(np.sum(scores[indexes], dtype=np.float64))
indexes = indexes[scores[indexes] >= np.float32(phy.lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD)]
self.peak_candidate_count = max(self.peak_candidate_count, len(indexes))
emitted = []
for index in indexes:
absolute = self.base_sample + int(index) * self.decimation
if absolute < self.accepted_until:
continue
self.candidate_count += 1
started = time.perf_counter()
outcome = offline.decode_candidate_from_memmap(
self.buffer.view(np.float32), self.buffer.size,
offline.CoarseCandidate(int(index) * self.decimation, float(scores[index])),
self.candidate_count,
read_iq_window=lambda raw, start, end: raw[2*start:2*end].view(np.complex64),
)
self.strict_seconds += time.perf_counter() - started
if outcome.accepted and outcome.crc == phy.lab045.CRC_PASS and outcome.frame is not None:
end = self.base_sample + outcome.next_sample
self.accepted_until = end
self.packet_count += 1
emitted.append(ReceivedPacket(
self.base_sample + outcome.refined_start_sample, end, self.total_samples,
bytes(outcome.frame.reception.payload_bytes),
))
else:
self.rejected_reasons[outcome.reason] += 1
self.blocks_processed += 1
self.scan_sample = owned_end
retain_start = max(0, self.scan_sample - self.margin)
trim = retain_start - self.base_sample
if trim > 0:
self.buffer = self.buffer[trim:].copy()
self.base_sample = retain_start
return emitted
def push_iq_block(self, samples: np.ndarray) -> list[ReceivedPacket]:
if self.closed:
raise RuntimeError("Поток уже закрыт")
samples = np.asarray(samples, dtype=np.complex64)
if samples.ndim != 1 or samples.size > self.block_samples or not np.all(np.isfinite(samples)):
raise ValueError("Нужен конечный одномерный IQ-блок не больше block_samples")
started = time.perf_counter()
self.total_samples += samples.size
self.buffer = np.concatenate((self.buffer, samples))
self.peak_buffer_samples = max(self.peak_buffer_samples, self.buffer.size)
if self.buffer.size > self.buffer_limit_samples:
raise RuntimeError("Превышен предел буфера IQ")
emitted = []
with streaming_mode(self.mode):
while self.total_samples >= self.scan_sample + self.core_samples + self.lookahead:
emitted.extend(self._scan(self.scan_sample + self.core_samples))
self.processing_seconds += time.perf_counter() - started
return emitted
def finish(self) -> list[ReceivedPacket]:
if self.closed:
raise RuntimeError("Поток уже закрыт")
started = time.perf_counter()
emitted = []
with streaming_mode(self.mode):
if self.scan_sample < self.total_samples:
emitted = self._scan(self.total_samples)
self.buffer = np.empty(0, np.complex64)
self.closed = True
self.processing_seconds += time.perf_counter() - started
return emitted

View File

@@ -0,0 +1,470 @@
"""Lab050: JPEG-профили, свежесть видеопотока и блочный RX без оборудования."""
from __future__ import annotations
import argparse
from collections import Counter
from dataclasses import asdict, dataclass
import hashlib
from io import BytesIO
import json
import math
from pathlib import Path
import statistics
import sys
import time
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
from PIL import Image, ImageChops
from experiments import lab046_bpsk_object_transport as transport
from experiments import lab049_bpsk_symbol_rate as phy
from experiments.lab050_streaming_receiver import StreamingReceiver
from protocol import packet as packet_protocol
from protocol.link_packet import Direction, LinkPacket, TrafficClass, HEADER_SIZE
from protocol.video_age_policy import AgePolicyPacket
from protocol.video_frame_scheduler import FramePolicy, VideoFrameGroup, schedule_video_frames
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "data/processed/lab050"
SYMBOL_RATES = (200_000, 400_000, 500_000, 800_000, 1_000_000)
INPUT_RATES = (3, 5, 10)
AGE_LIMITS = (0.200, 0.333, 0.500, 1.000)
FRAME_INTERVALS = 5766
FRAGMENT_BYTES = transport.FRAGMENT_DATA_SIZE
@dataclass(frozen=True)
class JpegProfile:
name: str
width: int
height: int
color_mode: str
quality: int
PROFILES = (
JpegProfile("A", 320, 240, "RGB", 35),
JpegProfile("B", 160, 120, "RGB", 35),
JpegProfile("C", 128, 96, "RGB", 25),
JpegProfile("D", 160, 120, "L", 30),
)
@dataclass(frozen=True)
class EncodedImage:
image_id: int
jpeg: bytes
encode_seconds: float
decode_seconds: float
psnr_db: float
def fragment_count(jpeg_bytes: int) -> int:
if type(jpeg_bytes) is not int or jpeg_bytes <= 0:
raise ValueError("Размер JPEG должен быть положительным целым")
return (jpeg_bytes + FRAGMENT_BYTES - 1) // FRAGMENT_BYTES
def maximum_jpeg_bytes(symbol_rate: int, fps: int) -> int:
"""Точный ступенчатый предел с добивкой последнего физического кадра."""
phy.PhyMode(symbol_rate)
if type(fps) is not int or fps <= 0:
raise ValueError("Частота изображений должна быть положительным целым")
return (symbol_rate // (fps * FRAME_INTERVALS)) * FRAGMENT_BYTES
def encode_sequence(source: Image.Image, profile: JpegProfile, count: int = 30) -> list[EncodedImage]:
"""Детерминированные сдвиги фотографии; это не видео реальной камеры."""
if count < 1:
raise ValueError("Нужна непустая последовательность")
sequence = []
for index in range(count):
# Первый кадр совпадает с подготовкой Lab047. Остальные — циклический
# сдвиг исходного фото до уменьшения; амплитуда задана, не подобрана по байтам.
shifted = source if index == 0 else ImageChops.offset(source, (index * 7) % 61, (index * 3) % 31)
reference = shifted.convert(profile.color_mode).resize(
(profile.width, profile.height), Image.Resampling.LANCZOS)
started = time.perf_counter()
stream = BytesIO()
reference.save(stream, format="JPEG", quality=profile.quality, subsampling=2,
optimize=False, progressive=False)
jpeg = stream.getvalue()
encode_seconds = time.perf_counter() - started
started = time.perf_counter()
with Image.open(BytesIO(jpeg)) as image:
image.load()
decoded = np.asarray(image).copy()
decode_seconds = time.perf_counter() - started
difference = np.asarray(reference, dtype=np.float64) - decoded.astype(np.float64)
mean_square = float(np.mean(difference * difference))
psnr = 10 * math.log10(255**2 / mean_square) if mean_square else 999.0
sequence.append(EncodedImage(index + 1, jpeg, encode_seconds, decode_seconds, psnr))
return sequence
def make_frame_groups(sequence: list[EncodedImage], input_fps: int) -> list[VideoFrameGroup]:
"""Использовать существующий планировщик; LinkPacket здесь только модель времени.
В эфире остаются packet/fragment Lab046, без LinkPacket и без стирающего FEC.
Все элементы планировщика имеют 256 байт, его эквивалентная скорость ниже
выбирается так, чтобы каждый занимал ровно 5766/Rs секунд.
"""
if input_fps <= 0:
raise ValueError("Частота камеры должна быть положительной")
frames = []
order = 0
for index, image in enumerate(sequence):
capture_us = round(index / input_fps * 1_000_000)
ready_us = capture_us + math.ceil(image.encode_seconds * 1_000_000)
fragments = transport.fragments.split_image_bytes(image.jpeg, image.image_id, FRAGMENT_BYTES)
packets = []
for fragment in fragments:
payload = transport.fragments.encode_image_fragment(fragment)
payload += bytes(256 - len(payload))
model_packet = LinkPacket(TrafficClass.VIDEO, Direction.ROVER_TO_GROUND,
50, order, capture_us, 0, payload)
packets.append(AgePolicyPacket(model_packet, order, ready_us, image.image_id))
order += 1
frames.append(VideoFrameGroup(image.image_id, capture_us, tuple(packets), ready_us))
return frames
def simulate_sequence(sequence: list[EncodedImage], input_fps: int, symbol_rate: int,
max_video_age: float, *, rx_seconds_per_packet: float = 0.0,
receiver_lookahead_seconds: float = 0.0) -> dict:
"""Очередь и идеальная доставка пакетов, не измерение эфира/камеры/экрана.
Измеренная стоимость RX может быть подставлена как отдельная оценка:
последовательный обработчик пакетов, устаревший объект не допускается
к обработке. Эта оценка не заменяет живой IQ-прогон.
"""
phy.PhyMode(symbol_rate)
if not math.isfinite(rx_seconds_per_packet) or rx_seconds_per_packet < 0:
raise ValueError("Нужна конечная неотрицательная стоимость RX")
if not math.isfinite(receiver_lookahead_seconds) or receiver_lookahead_seconds < 0:
raise ValueError("Нужна конечная неотрицательная задержка буфера RX")
frames = make_frame_groups(sequence, input_fps)
image_lookup = {image.image_id: image for image in sequence}
equivalent_bitrate = (256 + HEADER_SIZE) * 8 * symbol_rate / FRAME_INTERVALS
def minimum_post_rf_latency(frame: VideoFrameGroup) -> float:
# Идеальный конвейер RF -> последовательный RX без предыдущего backlog.
# Если уже эта нижняя оценка не укладывается в возраст, TX бесполезен.
packet_time = FRAME_INTERVALS / symbol_rate
return (receiver_lookahead_seconds + rx_seconds_per_packet
+ (len(frame.packets)-1) * max(0.0, rx_seconds_per_packet-packet_time)
+ image_lookup[frame.composite_frame_id].decode_seconds)
schedule = schedule_video_frames(
frames, (), FramePolicy.LATEST_ONLY, equivalent_bitrate,
max_video_age_seconds=max_video_age,
completion_latency_seconds=minimum_post_rf_latency,
)
arrivals: dict[int, list] = {}
for sent in schedule.transmitted:
arrivals.setdefault(sent.item.composite_frame_id, []).append(sent)
frame_lookup = {frame.composite_frame_id: frame for frame in frames}
horizon = len(sequence) / input_fps
rx_cursor = 0.0
displayed = []
traces = []
received = 0
discarded_rx = 0
for frame_id, sent in arrivals.items():
frame = frame_lookup[frame_id]
image = image_lookup[frame_id]
first_tx, last_tx = sent[0].start_seconds, sent[-1].end_seconds
# Идеальный packet-level путь действительно проверяет сериализованный
# CRC и собирает JPEG; FEC/IQ проверяется отдельным интеграционным опытом.
assembler = transport.ObjectAssembler()
for item in sent:
wire = packet_protocol.build_packet(item.item.packet.payload,
phy.lab045.MESSAGE_TYPE, phy.lab045.SEQUENCE_NUMBER)
parsed = packet_protocol.parse_packet(wire)
assembler.offer(transport.recover_fragment_from_payload(parsed.payload))
if assembler.recovered_object() != image.jpeg:
raise RuntimeError("Идеальная сборка изменила JPEG")
first_rx = sent[0].end_seconds
predicted_end = rx_cursor
for item in sent:
predicted_end = max(predicted_end, item.end_seconds + receiver_lookahead_seconds) + rx_seconds_per_packet
predicted_end += image.decode_seconds
dropped = predicted_end - frame.generation_time_seconds > max_video_age + 1e-12
if dropped:
discarded_rx += 1
object_complete = decode_complete = display_time = None
else:
received += 1
rx_cursor = predicted_end
decode_complete = predicted_end
object_complete = decode_complete - image.decode_seconds
display_time = decode_complete # Только модель мгновенного экрана.
displayed.append((frame_id, display_time, display_time - frame.generation_time_seconds))
traces.append({
"image_id": frame_id, "capture_timestamp": frame.generation_time_seconds,
"encode_complete_timestamp": frame.available_time_seconds,
"queue_enter_timestamp": frame.available_time_seconds,
"first_tx_timestamp": first_tx, "last_tx_timestamp": last_tx,
"first_rx_timestamp": first_rx, "object_complete_timestamp": object_complete,
"decode_complete_timestamp": decode_complete, "display_timestamp": display_time,
"queue_age_seconds": first_tx - frame.available_time_seconds,
"rf_age_seconds": last_tx - first_tx,
"end_to_end_image_age_seconds": None if display_time is None else display_time - frame.generation_time_seconds,
"dropped_at_rx_admission": dropped,
})
dropped_before = {drop.frame.composite_frame_id: drop for drop in schedule.dropped_frames}
for frame in frames:
if frame.composite_frame_id not in dropped_before:
continue
drop = dropped_before[frame.composite_frame_id]
traces.append({"image_id": frame.composite_frame_id,
"capture_timestamp": frame.generation_time_seconds,
"encode_complete_timestamp": frame.available_time_seconds,
"queue_enter_timestamp": frame.available_time_seconds,
**{key: None for key in ("first_tx_timestamp", "last_tx_timestamp", "first_rx_timestamp",
"object_complete_timestamp", "decode_complete_timestamp", "display_timestamp",
"queue_age_seconds", "rf_age_seconds", "end_to_end_image_age_seconds")},
"drop_reason": drop.reason, "drop_timestamp": drop.drop_time_seconds})
active_received = sum(row["object_complete_timestamp"] is not None
and row["object_complete_timestamp"] <= horizon + 1e-12 for row in traces)
active_displayed = [item for item in displayed if item[1] <= horizon + 1e-12]
ages = [item[2] for item in displayed]
drops = Counter(drop.reason for drop in schedule.dropped_frames)
return {
"scope": "packet-level ideal RF model, measured JPEG CPU durations; display/camera not measured",
"input_fps": input_fps, "symbol_rate": symbol_rate, "max_video_age_seconds": max_video_age,
"input_frames": len(sequence), "transmitted_frames": len(arrivals),
"dropped_before_tx": len(schedule.dropped_frames), "drop_reasons": dict(drops),
"rf_delivered_objects": len(arrivals), "received_frames": received,
"dropped_at_rx_admission": discarded_rx,
"displayed_frames": len(displayed), "maximum_waiting_frames": schedule.maximum_waiting_frames,
"observation_seconds": horizon, "active_received_frames": active_received,
"active_displayed_frames": len(active_displayed),
"raw_received_fps": active_received / horizon,
"raw_display_fps": len(active_displayed) / horizon,
"fresh_display_fps": len(active_displayed) / horizon,
"steady_fresh_display_fps": ((len(displayed)-1)/(displayed[-1][1]-displayed[0][1])
if len(displayed) > 1 else 0.0),
"drain_displayed_frames": len(displayed) - len(active_displayed),
"image_age_min_seconds": min(ages) if ages else None,
"image_age_median_seconds": statistics.median(ages) if ages else None,
"image_age_max_seconds": max(ages) if ages else None,
"rx_cost_seconds_per_packet_modeled": rx_seconds_per_packet,
"receiver_lookahead_seconds_modeled": receiver_lookahead_seconds,
"elrs_control_in_video_queue": False,
"frames": sorted(traces, key=lambda row: row["image_id"]),
}
def benchmark_receiver(sequence: list[EncodedImage], symbol_rate: int, *, noisy: bool = True) -> dict:
"""Подать несколько JPEG частями IQ без TX-метаданных в RX, замерить CPU.
TX хранит лишь очередной физический кадр, RX — несколько блоков.
Проверка SHA производится после независимой выдачи и сборки объекта.
"""
mode = phy.PhyMode(symbol_rate)
receiver = StreamingReceiver(symbol_rate)
random_generator = np.random.default_rng(50_050)
completed = []
current_assembler = None
current_id = None
last_completed_id = -1
object_first_sample = None
assembly_decode_seconds = 0.0
retained_object_bytes_peak = 0
packets_before_finish = 0
emitted_jpegs_before_finish = 0
def consume(packets):
nonlocal current_assembler, current_id, last_completed_id, object_first_sample
nonlocal assembly_decode_seconds, retained_object_bytes_peak
started = time.perf_counter()
for packet in packets:
fragment = transport.recover_fragment_from_payload(packet.payload)
if fragment is None or fragment.original_size > 65536 or fragment.fragment_count > 269:
continue
# Для этой конечной сессии ID возрастают, без wrap/restart. Строгий
# transport assembler защищает согласованность индексов/дубликатов.
if fragment.image_id <= last_completed_id or (current_id is not None and fragment.image_id < current_id):
continue
if current_id != fragment.image_id:
current_id = fragment.image_id
current_assembler = transport.ObjectAssembler()
object_first_sample = packet.start_sample
current_assembler.offer(fragment)
retained_object_bytes_peak = max(retained_object_bytes_peak, fragment.original_size)
recovered = current_assembler.recovered_object()
if recovered is not None:
with Image.open(BytesIO(recovered)) as image:
image.load()
dimensions = image.size
completed.append({"image_id": current_id, "sha256": hashlib.sha256(recovered).hexdigest(),
"bytes": len(recovered), "dimensions": dimensions,
"first_sample": object_first_sample, "last_sample": packet.end_sample,
"emitted_after_sample": packet.emitted_after_sample})
last_completed_id = current_id
current_id = current_assembler = None
assembly_decode_seconds += time.perf_counter() - started
def push_array(values):
for offset in range(0, values.size, receiver.block_samples):
consume(receiver.push_iq_block(values[offset:offset + receiver.block_samples]))
# Ненулевые несогласованные с символом интервалы, неизвестные RX.
push_array((random_generator.normal(0, 0.2, 4321) + 1j * random_generator.normal(0, 0.2, 4321)).astype(np.complex64))
transmitted_packets = 0
air_samples = 0
for image in sequence:
for frame in transport.build_object_frames(image.jpeg, image.image_id):
cs8 = phy.build_parameterized_tx_cs8(frame.plan, mode)
components = np.frombuffer(cs8, dtype=np.int8).astype(np.float32)
values = components[0::2] + 1j * components[1::2]
air_samples += values.size
if noisy:
indexes = np.arange(values.size, dtype=np.float64)
values = values * np.exp(1j * (0.73 + 2*np.pi*10*indexes/mode.sample_rate_hz)) * (-1 if transmitted_packets % 2 else 1)
energy = float(np.sum(abs(values)**2)) / frame.plan.frame_symbol_count
sigma = math.sqrt(energy / (2 * 10**(7/10)))
values += sigma * (random_generator.normal(size=values.size) + 1j * random_generator.normal(size=values.size))
push_array(np.asarray(values, np.complex64))
transmitted_packets += 1
gap = 211 + (transmitted_packets * 137) % 1000
push_array((random_generator.normal(0, 0.2, gap) + 1j * random_generator.normal(0, 0.2, gap)).astype(np.complex64))
# Хвост — ограниченный поток фона, не вся запись. Позволяет проверить
# выдачу последнего JPEG именно из push, до finish.
left = receiver.lookahead + receiver.core_samples
while left:
count = min(left, receiver.block_samples)
push_array((random_generator.normal(0, 0.2, count) + 1j * random_generator.normal(0, 0.2, count)).astype(np.complex64))
left -= count
packets_before_finish = receiver.packet_count
emitted_jpegs_before_finish = len(completed)
consume(receiver.finish())
expected = {image.image_id: hashlib.sha256(image.jpeg).hexdigest() for image in sequence}
exact = {item["image_id"]: item["sha256"] for item in completed} == expected
processing = receiver.processing_seconds + assembly_decode_seconds
capture_seconds = receiver.total_samples / mode.sample_rate_hz
return {
"symbol_rate": symbol_rate, "samples_per_symbol": mode.samples_per_symbol,
"detection_decimation": receiver.decimation, "input_images": len(sequence),
"tx_rf_packets": transmitted_packets, "accepted_rf_packets": receiver.packet_count,
"completed_images": len(completed), "all_sha_match": exact,
"packets_before_finish": packets_before_finish, "images_before_finish": emitted_jpegs_before_finish,
"capture_seconds": capture_seconds, "payload_airtime_seconds": air_samples / mode.sample_rate_hz,
"processing_seconds": processing, "processing_per_rf_second": processing / capture_seconds,
"processing_per_payload_airtime_second": processing / (air_samples / mode.sample_rate_hz),
"coarse_seconds": receiver.coarse_seconds, "strict_seconds": receiver.strict_seconds,
"assembly_jpeg_decode_seconds": assembly_decode_seconds,
"processing_seconds_per_rf_packet": processing / transmitted_packets,
"peak_iq_buffer_samples": receiver.peak_buffer_samples,
"iq_buffer_limit_samples": receiver.buffer_limit_samples,
"working_arrays_estimate_bytes": receiver.memory_estimate_bytes + 2 * 65536,
"memory_is_process_rss_measurement": False,
"peak_object_payload_bytes": retained_object_bytes_peak,
"candidate_count": receiver.candidate_count, "peak_candidates_in_block": receiver.peak_candidate_count,
"rejected_reasons": dict(receiver.rejected_reasons),
"buffer_release_delay_upper_seconds": (receiver.lookahead + 2*receiver.core_samples) / mode.sample_rate_hz,
"noisy": noisy, "coded_bit_ebn0_db": 7 if noisy else None, "cfo_hz": 10 if noisy else 0,
"candidate_threshold": phy.lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD,
"completed": completed, "rf_tx": 0,
}
def run_functional_tests() -> None:
assert maximum_jpeg_bytes(500_000, 5) == 4148
assert maximum_jpeg_bytes(1_000_000, 10) == 4148
assert fragment_count(244) == 1 and fragment_count(245) == 2
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--reuse-benchmarks", action="store_true", help="Переиспользовать текущие локальные замеры IQ")
args = parser.parse_args()
run_functional_tests()
OUTPUT.mkdir(parents=True, exist_ok=True)
source_path = ROOT / "data/raw/lab011_scene.jpg"
with Image.open(source_path) as source:
source.load()
sequences = {profile.name: encode_sequence(source, profile) for profile in PROFILES}
control = sequences["A"][0].jpeg
if hashlib.sha256(control).hexdigest() != phy.EXPECTED_JPEG_SHA256:
raise RuntimeError("Профиль A не воспроизвёл контрольный JPEG Lab047")
summaries = []
for profile in PROFILES:
sequence = sequences[profile.name]
(OUTPUT / f"profile_{profile.name}.jpg").write_bytes(sequence[0].jpeg)
summaries.append({**asdict(profile), "first_jpeg_bytes": len(sequence[0].jpeg),
"min_jpeg_bytes": min(len(item.jpeg) for item in sequence),
"max_jpeg_bytes": max(len(item.jpeg) for item in sequence),
"first_psnr_db": sequence[0].psnr_db,
"median_encode_seconds": statistics.median(item.encode_seconds for item in sequence),
"median_decode_seconds": statistics.median(item.decode_seconds for item in sequence),
"sha256_first": hashlib.sha256(sequence[0].jpeg).hexdigest(),
"airtime_capacity": {str(rate): rate/(fragment_count(len(sequence[0].jpeg))*FRAME_INTERVALS) for rate in SYMBOL_RATES}})
print(json.dumps(summaries, ensure_ascii=False), flush=True)
benchmark_path = OUTPUT / "receiver_benchmarks.json"
if args.reuse_benchmarks:
benchmarks = json.loads(benchmark_path.read_text(encoding="utf-8"))
expected_hashes = {image.image_id: hashlib.sha256(image.jpeg).hexdigest() for image in sequences["C"][:3]}
if [row["symbol_rate"] for row in benchmarks] != list(SYMBOL_RATES):
raise ValueError("Сохранены замеры других скоростей")
for row in benchmarks:
if (row["input_images"] != 3 or row["coded_bit_ebn0_db"] != 7
or row["candidate_threshold"] != phy.lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD
or any(expected_hashes.get(item["image_id"]) != item["sha256"] for item in row["completed"])):
raise ValueError("Сохранённые замеры относятся к другому сценарию")
else:
benchmarks = []
for rate in SYMBOL_RATES:
print(f"IQ benchmark {rate}: 3 JPEG profile C", flush=True)
result = benchmark_receiver(sequences["C"][:3], rate)
benchmarks.append(result)
print(json.dumps({key: result[key] for key in ("symbol_rate", "completed_images", "all_sha_match", "processing_per_rf_second", "working_arrays_estimate_bytes")}), flush=True)
benchmark_path.write_text(json.dumps(benchmarks, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
matrix = []
receiver_estimates = []
for profile in PROFILES:
for fps in INPUT_RATES:
for rate in SYMBOL_RATES:
for age in AGE_LIMITS:
row = simulate_sequence(sequences[profile.name], fps, rate, age)
matrix.append({"profile": profile.name, **row})
for measured in benchmarks:
if not measured["all_sha_match"]:
continue
for age in AGE_LIMITS:
receiver_estimates.append({"profile": "C", **simulate_sequence(sequences["C"], 5,
measured["symbol_rate"], age, rx_seconds_per_packet=measured["processing_seconds_per_rf_packet"],
receiver_lookahead_seconds=measured["buffer_release_delay_upper_seconds"])})
result = {
"stage": "Lab050 software video stream", "lab049_commit": "cdf5396e807b83f82a264632dc46f71428fcd616",
"source_sha256": hashlib.sha256(source_path.read_bytes()).hexdigest(),
"sequence": "30 deterministic cyclic shifts of one project photo, not camera video",
"profiles": summaries,
"maximum_jpeg_bytes": {str(rate): {str(fps): maximum_jpeg_bytes(rate, fps) for fps in INPUT_RATES} for rate in SYMBOL_RATES},
"receiver_benchmarks": benchmarks, "packet_model_matrix": matrix,
"measured_rx_cost_model": receiver_estimates,
"conditional_accelerated_models": [
{"profile": "C", "is_current_receiver_performance": False,
"required_speedup_vs_measured_cost": next(row["processing_seconds_per_rf_packet"]
for row in benchmarks if row["symbol_rate"] == 500000) / cost,
**simulate_sequence(sequences["C"], fps, 500000, .2, rx_seconds_per_packet=cost,
receiver_lookahead_seconds=next(row["buffer_release_delay_upper_seconds"]
for row in benchmarks if row["symbol_rate"] == 500000))}
for fps, cost in ((5, .016), (10, .012))
],
"modeled_unknown_latencies": {"capture": "unknown, modeled zero", "display": "unknown, modeled zero",
"encode": "JPEG encode CPU measured; source preparation outside timing",
"decode": "JPEG decode CPU measured", "rf": "calculated waveform airtime"},
"elrs_control_path": "SEPARATE", "rf_tx": 0,
}
(OUTPUT / "result.json").write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
selected = next(row for row in matrix if row["profile"] == "C" and row["input_fps"] == 5 and row["symbol_rate"] == 500000 and row["max_video_age_seconds"] == .2)
print(json.dumps({key: value for key, value in selected.items() if key != "frames"}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View File

@@ -4,7 +4,8 @@ from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Iterable
import math
from typing import Callable, Iterable
from protocol.link_packet import TrafficClass, encode_link_packet
from protocol.priority_scheduler import transmission_duration_seconds
@@ -35,6 +36,12 @@ class VideoFrameGroup:
composite_frame_id: int
generation_time_us: int
packets: tuple[AgePolicyPacket, ...]
available_time_us: int | None = None
@property
def available_time_seconds(self) -> float:
"""Готовность закодированного изображения отдельно от момента съёмки."""
return (self.generation_time_us if self.available_time_us is None else self.available_time_us) / 1_000_000.0
@property
def generation_time_seconds(self) -> float:
@@ -90,6 +97,7 @@ class FrameScheduleResult:
started_frame_ids: tuple[int, ...]
completed_frame_ids: tuple[int, ...]
admissions: tuple[FrameAdmission, ...]
maximum_waiting_frames: int = 0
def _replace_state(
@@ -195,13 +203,23 @@ def schedule_video_frames(
high_priority_packets: Iterable[AgePolicyPacket],
policy: FramePolicy,
channel_bitrate_bps: float,
*,
max_video_age_seconds: float | None = None,
completion_latency_seconds: float | Callable[[VideoFrameGroup], float] = 0.0,
) -> FrameScheduleResult:
"""Run strict-priority service while keeping video frames contiguous."""
policy = FramePolicy(policy)
if channel_bitrate_bps <= 0.0:
raise ValueError("channel bitrate must be positive")
frame_arrivals = tuple(sorted(frames, key=lambda frame: (frame.generation_time_seconds, frame.composite_frame_id)))
if max_video_age_seconds is not None and (not math.isfinite(max_video_age_seconds) or max_video_age_seconds <= 0):
raise ValueError("Возраст видео должен быть конечным положительным числом")
if not callable(completion_latency_seconds) and (not math.isfinite(completion_latency_seconds) or completion_latency_seconds < 0):
raise ValueError("Задержка после RF должна быть конечной и неотрицательной")
frame_arrivals = tuple(sorted(frames, key=lambda frame: (frame.available_time_seconds, frame.composite_frame_id)))
for frame in frame_arrivals:
if not frame.packets or frame.available_time_seconds < frame.generation_time_seconds:
raise ValueError("Нужны непустые изображения, готовые не раньше съёмки")
high_arrivals = tuple(sorted(high_priority_packets, key=lambda item: (item.available_time_seconds, item.arrival_order)))
ready_high: list[AgePolicyPacket] = []
pending_frames: list[VideoFrameGroup] = []
@@ -214,15 +232,17 @@ def schedule_video_frames(
actual_by_frame: dict[int, float] = {}
active: VideoFrameGroup | None = None
active_index = 0
maximum_waiting = 0
newest_capture_key: tuple[int, int] | None = None
cursor = 0.0
frame_index = high_index = 0
blocker_by_order: dict[int, tuple[AgePolicyPacket, float]] = {}
def drop_frame(frame: VideoFrameGroup, reason: str) -> None:
dropped.append(FrameDrop(frame, cursor, reason))
def drop_frame(frame: VideoFrameGroup, reason: str, when: float | None = None) -> None:
dropped.append(FrameDrop(frame, cursor if when is None else when, reason))
def admit(now: float, active_packet: AgePolicyPacket | None = None, active_end: float = 0.0) -> None:
nonlocal frame_index, high_index
nonlocal frame_index, high_index, maximum_waiting, newest_capture_key
while high_index < len(high_arrivals) and high_arrivals[high_index].available_time_seconds <= now + TIME_EPSILON_SECONDS:
item = high_arrivals[high_index]
high_index += 1
@@ -232,12 +252,19 @@ def schedule_video_frames(
max(0.0, active_end - item.available_time_seconds),
)
_replace_state(ready_high, item, replacements)
while frame_index < len(frame_arrivals) and frame_arrivals[frame_index].generation_time_seconds <= now + TIME_EPSILON_SECONDS:
while frame_index < len(frame_arrivals) and frame_arrivals[frame_index].available_time_seconds <= now + TIME_EPSILON_SECONDS:
frame = frame_arrivals[frame_index]
frame_index += 1
if policy is FramePolicy.LATEST_ONLY:
capture_key = (frame.generation_time_us, frame.composite_frame_id)
# Позднее завершение кодирования не делает старый снимок новым.
# Помним свежесть и когда очередь уже опустела/новый кадр активен.
if newest_capture_key is not None and capture_key < newest_capture_key:
drop_frame(frame, "older_capture_arrived_late", frame.available_time_seconds)
continue
newest_capture_key = capture_key
for old in pending_frames:
drop_frame(old, "replaced_by_newest")
drop_frame(old, "replaced_by_newest", frame.available_time_seconds)
pending_frames[:] = [frame]
elif policy is FramePolicy.TWO_WAITING:
pending_frames.append(frame)
@@ -245,6 +272,7 @@ def schedule_video_frames(
drop_frame(pending_frames.pop(0), "waiting_limit")
else:
pending_frames.append(frame)
maximum_waiting = max(maximum_waiting, len(pending_frames))
while (
frame_index < len(frame_arrivals)
@@ -256,7 +284,7 @@ def schedule_video_frames(
if not ready_high and not pending_frames and active is None:
next_times = []
if frame_index < len(frame_arrivals):
next_times.append(frame_arrivals[frame_index].generation_time_seconds)
next_times.append(frame_arrivals[frame_index].available_time_seconds)
if high_index < len(high_arrivals):
next_times.append(high_arrivals[high_index].available_time_seconds)
cursor = max(cursor, min(next_times))
@@ -273,7 +301,7 @@ def schedule_video_frames(
if active is None:
while pending_frames and active is None:
candidate = pending_frames.pop(0)
deadline = policy.deadline_seconds
deadline = max_video_age_seconds if max_video_age_seconds is not None else policy.deadline_seconds
if deadline is not None:
future_high = high_arrivals[high_index:]
predicted = predict_frame_completion(
@@ -283,7 +311,11 @@ def schedule_video_frames(
tuple(ready_high),
future_high,
)
if predicted - candidate.generation_time_seconds > deadline + TIME_EPSILON_SECONDS:
post_latency = (completion_latency_seconds(candidate) if callable(completion_latency_seconds)
else completion_latency_seconds)
if not math.isfinite(post_latency) or post_latency < 0:
raise ValueError("Прогноз задержки должен быть конечным и неотрицательным")
if predicted + post_latency - candidate.generation_time_seconds > deadline + TIME_EPSILON_SECONDS:
drop_frame(candidate, "prediction_reject")
continue
predicted_by_frame[candidate.composite_frame_id] = predicted
@@ -327,4 +359,5 @@ def schedule_video_frames(
started_frame_ids=tuple(started),
completed_frame_ids=tuple(completed),
admissions=admissions,
maximum_waiting_frames=maximum_waiting,
)

View File

@@ -0,0 +1,176 @@
"""Свежесть изображений и непрерывный IQ, без файлов эксперимента и RF."""
from __future__ import annotations
import numpy as np
import pytest
from experiments import lab049_bpsk_symbol_rate as phy
from experiments import lab050_video_stream as lab
from experiments.lab050_streaming_receiver import StreamingReceiver
from protocol.two_stage_failsafe import TwoStageFailsafe, ThresholdPolicy, DecelerationProfile, SafetyState
def sequence(size: int = 1740, count: int = 30) -> list[lab.EncodedImage]:
return [lab.EncodedImage(index+1, bytes([index % 256])*size, .001, .001, 0.0) for index in range(count)]
@pytest.mark.parametrize("rate,fps,maximum", [(500000,3,6832),(500000,5,4148),(500000,10,1952),
(800000,3,11224),(800000,5,6588),(800000,10,3172),
(1000000,3,13908),(1000000,5,8296),(1000000,10,4148)])
def test_maximum_jpeg_is_exact_at_fragment_boundary(rate, fps, maximum):
assert lab.maximum_jpeg_bytes(rate, fps) == maximum
assert lab.fragment_count(maximum) * 5766 * fps <= rate
assert lab.fragment_count(maximum+1) * 5766 * fps > rate
@pytest.mark.parametrize("fps", [3, 5, 10])
@pytest.mark.parametrize("size", [244, 245, 1740, 8724])
def test_newest_queue_is_bounded_and_accounting_conserves_inputs(fps, size):
result = lab.simulate_sequence(sequence(size), fps, 200000, 1.0)
assert result["maximum_waiting_frames"] <= 1
assert result["input_frames"] == result["transmitted_frames"] + result["dropped_before_tx"]
assert result["received_frames"] == result["transmitted_frames"]
assert result["displayed_frames"] == result["received_frames"]
assert result["image_age_max_seconds"] is None or result["image_age_max_seconds"] <= 1.0
def test_newer_unstarted_frame_replaces_old_without_damaging_active_image():
result = lab.simulate_sequence(sequence(), 10, 200000, 1.0)
assert result["drop_reasons"]["replaced_by_newest"] > 0
sent = [frame for frame in result["frames"] if frame["first_tx_timestamp"] is not None]
assert all(frame["rf_age_seconds"] == pytest.approx(8*5766/200000) for frame in sent)
assert all(frame["last_tx_timestamp"] <= next_frame["first_tx_timestamp"] for frame,next_frame in zip(sent,sent[1:]))
@pytest.mark.parametrize("limit", [.2,.333,.5,1.0])
def test_age_limit_predictively_drops_impossible_jpeg(limit):
result = lab.simulate_sequence(sequence(8724), 5, 200000, limit)
assert result["transmitted_frames"] == 0
assert result["dropped_before_tx"] == 30
assert result["fresh_display_fps"] == 0
def test_source_time_not_encoding_completion_defines_age():
images=[lab.EncodedImage(1,bytes(244),.3,.001,0)]
result = lab.simulate_sequence(images,5,1000000,.2)
assert result["transmitted_frames"] == 0
def test_late_old_encoding_does_not_replace_fresher_waiting_image():
images = [lab.EncodedImage(index+1,bytes(1740),delay,.001,0)
for index,delay in enumerate((.001,.12,.001))]
result=lab.simulate_sequence(images,10,200000,1.0)
shown=[row["image_id"] for row in result["frames"] if row["display_timestamp"] is not None]
assert shown==[1,3]
assert result["drop_reasons"]["older_capture_arrived_late"]==1
def test_late_old_encoding_does_not_regress_display_after_queue_drains():
images = [lab.EncodedImage(index+1,bytes(244),delay,.001,0)
for index,delay in enumerate((.5,.001,.001))]
result=lab.simulate_sequence(images,5,1000000,1.0)
shown=[row["image_id"] for row in result["frames"] if row["display_timestamp"] is not None]
assert shown==[2,3]
assert result["drop_reasons"]["older_capture_arrived_late"]==1
def test_five_fresh_images_per_second_and_all_timestamps():
result = lab.simulate_sequence(sequence(),5,500000,.2)
assert result["fresh_display_fps"] == 5
assert result["raw_display_fps"] == 5
assert result["image_age_max_seconds"] == pytest.approx(.001+8*5766/500000+.001)
for row in result["frames"]:
names = ["capture_timestamp","encode_complete_timestamp","queue_enter_timestamp","first_tx_timestamp",
"first_rx_timestamp","last_tx_timestamp","object_complete_timestamp","decode_complete_timestamp","display_timestamp"]
values = [row[name] for name in names]
assert values == sorted(values)
def test_slow_rx_drops_before_cpu_and_does_not_claim_reception():
result = lab.simulate_sequence(sequence(),5,500000,.2,rx_seconds_per_packet=.1)
assert result["rf_delivered_objects"] == 0
assert result["received_frames"] == result["displayed_frames"] == 0
assert result["dropped_before_tx"] == 30
@pytest.mark.parametrize("fps,cost", [(5,.016),(10,.012)])
def test_conditional_receiver_speed_meets_freshness_and_rate(fps,cost):
result=lab.simulate_sequence(sequence(),fps,500000,.2,rx_seconds_per_packet=cost,
receiver_lookahead_seconds=.052608)
assert result["fresh_display_fps"] == (5 if fps==5 else 29/3)
assert result["steady_fresh_display_fps"] == pytest.approx(fps)
assert result["displayed_frames"] == 30
assert result["drain_displayed_frames"] == (0 if fps==5 else 1)
assert result["image_age_max_seconds"] < .2
def test_sufficient_cpu_throughput_alone_does_not_guarantee_age():
# 8*.02=.16 с/изображение укладывается в период .2 с, но сквозной возраст
# с накоплением нужного хвоста IQ уже превышает .2 с.
result=lab.simulate_sequence(sequence(),5,500000,.2,rx_seconds_per_packet=.02,
receiver_lookahead_seconds=.052608)
assert result["transmitted_frames"] == 0
def test_elrs_loss_is_external_to_video_queue():
before=lab.simulate_sequence(sequence(count=3),5,500000,.2)
safety=TwoStageFailsafe(ThresholdPolicy("test",.1,.2),DecelerationProfile("test",1,2))
assert safety.apply_watchdog(.2) is SafetyState.STAGE2_BRAKING
after=lab.simulate_sequence(sequence(count=3),5,500000,.2)
assert before==after
assert not after["elrs_control_in_video_queue"]
@pytest.mark.parametrize("rate", [200000,400000,500000,800000,1000000])
def test_stream_crosses_blocks_and_emits_before_finish(rate):
payload=bytes(range(256))
plan=phy.lab045.build_transmission_plan(payload)
receiver=StreamingReceiver(rate)
waveform=phy.build_parameterized_tx_complex(plan,phy.PhyMode(rate)).astype(np.complex64)
signal=np.concatenate((np.zeros(63217,np.complex64),waveform,
np.zeros(receiver.lookahead+2*receiver.core_samples,np.complex64)))
outputs=[]
chunk=32749
for start in range(0,len(signal),chunk):
outputs.extend(receiver.push_iq_block(signal[start:start+chunk]))
assert receiver.buffer.size <= receiver.buffer_limit_samples
assert [item.payload for item in outputs] == [payload]
assert outputs[0].start_sample == pytest.approx(63217,abs=100)
assert not receiver.finish()
assert receiver.buffer.size == 0
def test_memory_bound_for_long_session_without_retained_history():
receiver=StreamingReceiver(1000000)
block=np.zeros(receiver.block_samples,np.complex64)
limits=[]
for _ in range(100):
assert receiver.push_iq_block(block)==[]
limits.append(receiver.buffer.size)
assert max(limits) <= receiver.buffer_limit_samples
assert receiver.total_samples > 20*receiver.buffer_limit_samples
assert receiver.packet_count == 0
assert not receiver.finish()
def test_truncated_stream_does_not_accept_partial_frame():
plan=phy.lab045.build_transmission_plan(bytes(256))
receiver=StreamingReceiver(1000000)
waveform=phy.build_parameterized_tx_complex(plan,phy.PhyMode(1000000)).astype(np.complex64)
receiver.push_iq_block(waveform[:len(waveform)//2])
assert receiver.finish()==[]
with pytest.raises(RuntimeError): receiver.push_iq_block(np.zeros(1,np.complex64))
def test_input_limit_and_nonfinite_rejected_without_advancing_stream():
receiver=StreamingReceiver(1000000)
for block in [np.zeros(receiver.block_samples+1,np.complex64),np.array([complex(float("nan"),0)])]:
with pytest.raises(ValueError): receiver.push_iq_block(block)
assert receiver.total_samples==0
def test_fixed_formats_match_actual_plan():
frame=lab.transport.build_object_frames(bytes(1740))[0]
assert frame.plan.frame_symbol_count+42==lab.FRAME_INTERVALS
assert lab.transport.FRAGMENT_DATA_SIZE==244