3 Commits

Author SHA1 Message Date
LittleSam129
cc38900cae Lab052: suppress false preamble candidates 2026-09-08 17:29:52 +03:00
LittleSam129
9f33607d3a Lab051: accelerate 500 ksymbol/s streaming receiver 2026-09-08 16:04:09 +03:00
LittleSam129
60691a09f1 Lab050: model fresh-frame SDR video stream 2026-09-08 14:15:36 +03:00
45 changed files with 88861 additions and 12 deletions

View File

@@ -1641,3 +1641,365 @@ 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 МГц.
# Запись 040
## Дата
8 сентября 2026 года
## Тема
Lab051 — алгоритмическое ускорение потокового BPSK-приёмника на 500 ксимв/с
и проверка свежести JPEG при измеренной стоимости обработки
## Выполнено
Перед новой разработкой проверены HEAD, status, diff stat и diff check.
По прямому разрешению текущего задания Lab050 закреплена коммитом
60691a09f193f36983c7174c5252ed0897c559e9:
Lab050: model fresh-frame SDR video stream.
В показанный staged scope вошли девять файлов: код и тесты Lab050, необходимые
изменения прежних модулей, отчёт, журнал, индекс и небольшой receiver_benchmarks.json.
Большой result.json, JPEG-профили и посторонние файлы не добавлялись. Push не выполнялся.
Lab051 начата от нового HEAD. Аудит прочитал Lab046B/C, Lab047/048/050,
связанные функции Lab043/044/045 и convolutional_fec. Рабочая точка строго
500000 символов/с, Fs=8000000, 16 отсчётов/символ; профиль C 128×96 RGB q25,
1740–1893 байта. Управление ELRS/CRSF и локальная защита остаются отдельно.
Ни один файл protocol в Lab051 не менялся.
Воспроизведён прежний короткий шумный опыт Lab050: три JPEG, 24 пакета,
seed50050, coded-bit Eb/N0=7 дБ, CFO+10 Гц, фаза0,73 и чередование полярности.
Историческая стоимость 55,157553 с / 0,323745 с IQ =170,373647.
Свежий инструментированный baseline занял49,614327 с, отношение153,251430;
это отдельный замер, с влиянием настольной нагрузки/инструментации.
Обнаружены2374 кандидата:24 valid и2350 false; средние72,967/20,099 мс.
Главная стоимость ложных кандидатов — timing:41,833 с из47,232 с.
При nominal416 и диапазоне±800 прежняя функция возвращалась к Python-перебору
1601 смещения. Matched filter на ложных кандидатах занимал0,934 с, уточнение2,797 с.
Ранний отказ PHY header уже существовал и не запускал тело/Viterbi/CRC.
Добавлен отдельный вычислительный адаптер Lab051. Повторно используется внутренняя
часть децимации с точным совпадением отсчётов; пересчитываются новые края.
Общая корреляция уточнения и общий перенос/RRC обслуживают срезы кандидатов.
Сохранены локальный DC и нулевые края. Результат извлечения кэшируется по
уточнённому положению/длине в пределах блока. Timing вычисляет все прежние1601
смещения через корреляцию и накопленные энергии, без сужения диапазона.
Для неразделимых FFT-пиков и постоянного IQ сохранён исходный выбор первого максимума.
Viterbi использует предвычисленные четыре пары евклидовых метрик и сравнение
двух ветвей с прежним tie→branch0. K7,R1/2,171/133,tail6,интерливер,CRC и
результаты декодирования сохранены. CFO-оценка прежняя; кэшируются векторы переноса.
Новый порог отсева, GPU, multiprocessing и зависимости не добавлены.
Фиксированный порог кандидатов0.030964473262429237 не изменялся.
Финальный короткий контроль:24/24 пакета,3/3 JPEG, SHA MATCH; совпали число
кандидатов и все причины отказов. Медиана трёх проходов1,144176 с,
processing/RF3,534193 — ускорение48,21 раза относительно исторической базы.
Средний valid candidate17,7675 мс, false0,187317 мс; false-медиана0,0605 мс.
Полная стоимость с ложными кандидатами/фоном47,674 мс на физический пакет.
Основной опыт:30 изображений, вход5/с, окно6 с, LATEST_ONLY, MAX_VIDEO_AGE200 мс.
Реально поданы6,044416 с синтетического IQ блоками; receiver не получал TX-границы,
payload или ожидаемый счётчик. Все240/240 пакетов и30/30 JPEG восстановлены точно.
Фактическая стоимость каждого push, сборки и JPEG decode использована в событийной
шкале CPU. Суммарно38,630132 с, processing/RF6,391045,160,959 мс/пакет.
Получены147082 кандидата,12239 новых окон синхронизации и135057 cache hits.
Ни один JPEG не уложился в200 мс: DISPLAYED=FRESH DISPLAYED=0,FRESH DISPLAY FPS=0.
Цель5 свежих/с и минимальная цель>0 НЕ достигнуты.
Переданы30 изображений, до TX удалено0, после decode по возрасту удалено30.
Возраст декодированных/отброшенных JPEG min/median/max:
276,515/15691,203/31150,009 мс. Это не измерение камеры/экрана: их задержки UNKNOWN.
Capture lookup используется только после независимого JPEG для отчётной метрики;
в прежнем fragment format нет capture timestamp. Live-контроль возраста не доказан.
Видеочередь≤1, однако непрерывный IQ-источник потребовал бы внешнего backlog
2088473848 байт при отставании32,632404 с. Такой буфер не реализован и не скрыт.
REAL-TIME CAPABLE=NO. Поток воспроизводится офлайн с обратным давлением источнику.
Пик удерживаемого IQ3177728 байт(3,031 МиБ), проверяемый предел3398912 байт.
Инженерная оценка рабочих массивов136,161 МиБ; это не универсальный предел и не RSS.
RSS процесса после потока154,105 МиБ, peak178,004 МиБ, включая Python/генератор/кэши.
Длинный тест4096 блоков(33,554432 с,нулевой фон) не показал роста IQ и децимации:
2653440 и397216 байт после прогрева. RSS156,199–156,250 МиБ. Finish освобождает буферы.
В отдельном replay посчитана нижняя граница наблюдаемых ndarray/владельцев и
tracemalloc peak. Внутренние C-аллокации не объявляются полностью измеренными;
холодные общие массивы могут занимать больше памяти, чем отдельное старое окно.
Сохранённая Lab047-запись20 ксимв/с повторно разобрана штатным Lab048:
36/36,FEC/CRC PASS,JPEG SHA MATCH,19 ошибок до FEC и0 после. Исходные IQ/JPEG сохранены.
Новый адаптер рассчитан только на500 ксимв/с; аппаратных запусков не было.
38 целевых тестов PASS. Verifier независимо нашёл два FFT tie-расхождения;
оба исправлены и повторно воспроизведены с совпадением старого результата.
Adversary проверил malformed PHY без body/Viterbi, дубликаты/старые ID,
лимиты объекта, усечение, восстановление подмен и арифметику возраста/backlog.
Таблицы отчёта сверены с JSON. Документ проверен в UTF-8, ссылки существуют.
quick_gate PASS:688 тестов,17 функциональных проверок,13 штатных пропусков.
Полный pytest:688 passed за72,92 с,0 failed; одно предупреждение доступа к .pytest_cache.
git diff --check PASS. Lab051 git add/commit и push не выполнялись; индекс пуст.
RF TX=0; HackRF,180 МГц,QPSK,новый FEC/кодек и аппаратные500 ксимв/с не запускались.
Следующий этап: STOP. Рекомендуемый Lab052 — отдельное программное исследование
стоимости уникальных фоновых кандидатов в полном режиме5 изображений/с,
с неизменными PHY/FEC/CRC и независимыми положительными/отрицательными регрессиями.
До отдельного разрешения не выполнять RF или аппаратные500 ксимв/с.
# Запись 041
## Дата
8 сентября 2026 года
## Тема
Lab052 — уменьшение числа ложных кандидатов преамбулы при 500 ксимв/с,
сохранение слабых пакетов и повторная проверка свежести JPEG
## Выполнено
Перед новой разработкой проверены root/main/HEAD/status, diff stat/check,
фактический код Lab051, отчёт, индекс и запись 040. Повторные шлюзы Lab051:
quick_gate PASS, pytest 688 passed, 0 failed. Verifier подтвердил scope.
По прямому разрешению нового задания показан staged scope из 13 файлов,
создан коммит 9f33607d3ae82992c595de37dd64e99d7e4dea73:
Lab051: accelerate 500 ksymbol/s streaming receiver.
9695 добавлений; код, тесты, документация и шесть итоговых JSON Lab051.
IQ-окна, промежуточные результаты и посторонние файлы не добавлены; push не было.
Lab052 начата от этого фактического HEAD.
Рабочий режим сохранён: BPSK, 500000 символов/с, Fs=8000000, 16 отсчётов/символ,
JPEG C 128×96 RGB q25, 1740–1893 байта. Управление ELRS/CRSF остаётся отдельно.
Ни один файл protocol, старые модули PHY/FEC/CRC или формат фрагмента не изменён.
Уточнена исходная метрика: исторические 147082 Lab051 — все строгие кандидаты,
включая 240 valid; ложных было 146842. Новые timing-окна 12239 также включают
настоящие. Прежняя длинная сетка зависела от несохранённых encode_seconds.
В Lab052 тот же генератор с seed50050/CFO10/фазой0,73/чередованием полярности
использует один сохранённый замер готовности JPEG; SHA всех изображений проверяется.
Все сравниваемые варианты получают одну и ту же новую воспроизводимую сетку.
Точное историческое размещение пакетов не объявляется восстановленным.
Профиль прежнего Lab051 на 6,044416 с IQ:1675842 локальных пика,
392785 выше первого порога,147005 строгих проверок,240true/146765false.
24281,089 ложных проверок на секунду IQ. Ложные кандидаты дают11387 уникальных
уточнённых позиций и135378 повторных. Низкий нормированный порог пропускает шум
независимо от его абсолютной амплитуды; повторная PRBS7 создаёт сильные боковые пики.
Построены распределения нормированной корреляции, median/MAD и квантили по блокам.
Медиана false score0,035464; главный true peak0,841691. В357 блоках чистого фона,
выделенных только после независимых решений, медиана блочных медиан0,016353,
MAD0,006184; фон не идеально стационарен. Адаптивный median/MAD-порог рассмотрен,
но не выбран: он не устраняет структуру повторных пиков, а чувствительность
при смене фона требует отдельного доказательства. Нормировка энергией каждой
половины использует только наблюдаемый IQ, без TX reference.
Первый вариант подавлял меньшие пики в радиусе преамбулы и давал0false на основном
потоке, но adversary воспроизвёл потерю слабого VALID рядом с сильной ложной
преамбулой без правильного заголовка. Этот вариант отвергнут. Исправление:
сначала общие дешёвые корреляции всех локальных кандидатов по частям, затем
ограниченная полная ширина группы; FAIL представителя сохраняет альтернативы.
Прежний контрпример и его перенос через границы блоков больше не теряют пакет.
Основное правило: обе половины преамбулы проходят нормированную корреляцию
не ниже0,08474038063392896. Порог выбран заранее из номинальной модели
Beta(1,N−1),N640,alpha0,01; точная вероятность после фильтрации/отбора пиков
не утверждается. Две FFT частей общие на блок, per-candidate timing не запускается
на отклонённом шуме. Исходный первый порог0.030964473262429237 сохранён.
Фаза/полярность неизвестны, используется модуль; новый фазовый CFO-gate не введён.
Группа имеет полную ширину≤20480 отсчётов от первого пика, без цепочного расширения.
Минимальный допустимый кадр26720 отсчётов;20480<26720−2×2008=22704.
Это разделяет центральные кандидаты двух последовательных неперекрывающихся
кадров в диапазоне прежней точности локализации. Представитель — наибольшая
корреляция, tie→правый; при отказе проверяется следующая альтернатива.
Только настоящий PHY/FEC/CRC PASS разрешает пропуск принятого физического кадра.
Граница IQ-блока может добавить проверки, но не скрывает следующий кандидат.
Сравнены0/1/2/3 части. Ложных строгих проверок145863/332/15/6, во всех вариантах
сохранены те же240 уникальных уточнённых стартов и30 JPEG SHA MATCH.
Выбраны две части: минимальная проверка распределённой структуры,9784,33×
сокращение false. Третья часть уменьшает15до6, но дороже общего этапа.
Группировка без дешёвого фильтра и с корректным возвратом альтернатив почти
не снижает нагрузку распределённого шума. Глобальный peak/second не используется.
Обнаружены734 лишних построения неизменной опорной формы на каждый проход.
Введены локальные постоянные кэши опоры,RRC taps,PRBS bits,171140 байт.
Массивы побитно сравнены с прежними, глобальные функции возвращаются в finally.
Сами PHY,16 samples/symbol,все1601 timing-offset,soft Viterbi и CRC не менялись.
Три чистых полных прохода:processing/RF1,257688/1,219199/1,219273,
в каждом240/240 пакетов,30/30 точных JPEG,15false,0 потерь. Итог — целиком
медианный третий проход:7,369795 с обработки /6,044416 с IQ=1,219273.
Свежий прежний Lab051 без наблюдателя на той же сетке41,633312 с,ratio6,887897;
полное ускорение5,649×, относительно исторической Lab051 —5,242×.
Итоговые счётчики:392785 первого этапа,388407 отклонены дешёво,4378 сохранены,
384 группы,243 строгих представителя+12 альтернатив,15false/240true.
4123 кандидата пропущены внутри уже принятого физического кадра.
Строгих проверок42,187699/с IQ,ложных2,481629/с; новых soft-окон488.
Настоящие строгие проверки3,996005 с,среднее16,650 мс; ложные всего28,884 мс.
Общая децимация/корреляция1,790240 с,проверка частей0,446616 с.
INPUT5fps/30images;RECEIVED30;STALE30;DISPLAYED=FRESH DISPLAYED=0;FRESH FPS=0.
Возраст декодированных, затем отброшенных JPEG234,520/834,077/1549,595 мс.
MAX_VIDEO_AGE200 мс. Реальная измеренная стоимость каждого push и сборки/decode
использована в прежней событийной шкале. Нулевая стоимость RX не подставлялась.
Цель реального времени и5 свежих кадров/с НЕ достигнута. Теперь ограничение —
настоящие пакеты вместе с децимацией,FFT и копированием IQ,а не ложные кандидаты.
Финальный отдельный cProfile:FFT1,650 с,FIR1,295 с,Viterbi1,397 с собственного
времени. Вложенные matched/refine не суммируются повторно.
Камера/экран UNKNOWN;capture lookup применяется после JPEG только для отчётной
модели вывода. В неизменном fragment format нет capture timestamp, независимая
live-проверка возраста не реализована. Отставание1,426779 с соответствовало бы
91313848 байтам внешнего IQ backlog. Такой буфер не хранится офлайн-источником;
bounded state не объявляется готовой live-очередью без overflow/gap policy.
Пик удерживаемого IQ3177728 байт(3,031 МиБ),проверяемый предел3398912 байт.
Инженерная оценка рабочих массивов136,161 МиБ,не универсальный предел C-аллокаций.
RSS выбранного процесса после потока149393408 байт,peak168804352(160,984 МиБ).
Короткий отдельный replay измерил6754 наблюдаемых ndarray(6681 владельцев),
37378942 суммарных байта владельцев иtracemalloc peak11586792 байта.
Число массивов — нижняя граница Python-наблюдения; tracing не входит в CPU benchmark.
Длинный тест2048 блоков,16,777216 с распределённого шума со сменой амплитуды:
4783206 пиков,1068038 кандидатов,одна строгая ложная проверка,0 принятых пакетов.
Во всех контрольных точках после прогрева IQ2653440 байт,децимация397216,
сохранённых кандидатов0. RSS после прогрева кэшей стабилизируется.
После finish IQ-буферы освобождены. Основной поток:не более23 eligible пиков
и3 групп на блок; история наблюдателя существует только вне рабочего RX.
45 слабых сценариев Eb/N0=0/1/2/3/7 дБ,CFO−100/+10/+100 Гц,триseed/масштаба:
старый и новый правильно приняли37/45,0 дополнительных потерь;8 не приняты обоими.
Старая Lab047-запись20 ксимв/с штатным Lab048:36/36,CRC PASS,JPEG SHA MATCH,
19 ошибок до FEC,0 после,0 байтовых ошибок. Аппаратных запусков не было.
30 целевых тестов PASS,verifier иadversary независимо подтвердили исправление
ложного подавления,границы блоков,плохую CRC,эквивалентность частей и finally.
ФинальныеJSON,выбор медианного прохода,UTF-8,ссылки и график проверены независимо.
quick_gate PASS:718 тестов,20 функциональных проверок,13 штатных пропусков.
Полный pytest:718 passed за77,15 с,0 failed,одно предупреждение .pytest_cache.
Новый helper наблюдения массивов отдельно выполнен;три модуля проверены
py_compile и run_functional_tests после его добавления. git diff --check PASS.
Обязательные регрессии в acceptance.json PASS;real-time/5-fps target явно FAIL.
Lab052 git add/commit иpush не выполнялись;индекс пуст. RF TX=0.
Следующий этап: STOP. Для отдельно согласованной Lab053 исследовать стоимость
настоящих пакетов, общей децимации/FFT/копирования IQ и задержку JPEG-пачки.
Не переходить автоматически к10fps,QPSK,180 МГц,аппаратным500 ксимв/с или RF.

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

@@ -0,0 +1,209 @@
{
"benchmark": {
"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": 49.61432680033613,
"processing_per_rf_second": 153.25143019852803,
"processing_per_payload_airtime_second": 179.26323419013804,
"coarse_seconds": 0.4982285003643483,
"strict_seconds": 49.04027030314319,
"assembly_jpeg_decode_seconds": 0.0013099998468533158,
"processing_seconds_per_rf_packet": 2.067263616680672,
"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
},
"profile": {
"shared_stages": {
"phy_header": {
"calls": 24,
"seconds": 0.0005645002238452435,
"exclusive_seconds": 0.0005645002238452435
},
"coarse_localization": {
"calls": 36,
"seconds": 0.4980395002057776,
"exclusive_seconds": 0.4980395002057776
}
},
"valid": {
"count": 24,
"mean_seconds": 0.07296675831700365,
"median_seconds": 0.07252859993604943,
"total_seconds": 1.7512021996080875,
"stages": {
"refinement": {
"calls": 24,
"seconds": 0.028973900014534593,
"exclusive_seconds": 0.028973900014534593
},
"matched_filter": {
"calls": 48,
"seconds": 0.1906564999371767,
"exclusive_seconds": 0.1906564999371767
},
"timing": {
"calls": 48,
"seconds": 0.9146315004909411,
"exclusive_seconds": 0.0007350004743784666
},
"timing_fallback": {
"calls": 48,
"seconds": 0.9138965000165626,
"exclusive_seconds": 0.9138965000165626
},
"phase_diagnostics": {
"calls": 48,
"seconds": 0.019832400372251868,
"exclusive_seconds": 0.008994300849735737
},
"cfo_gain": {
"calls": 48,
"seconds": 0.010838099522516131,
"exclusive_seconds": 0.010838099522516131
},
"phy_header": {
"calls": 48,
"seconds": 0.0017112999921664596,
"exclusive_seconds": 0.0017112999921664596
},
"body_pipeline": {
"calls": 24,
"seconds": 0.49711450026370585,
"exclusive_seconds": 0.011535500641912222
},
"viterbi": {
"calls": 24,
"seconds": 0.4843809995800257,
"exclusive_seconds": 0.4843809995800257
},
"crc_parser": {
"calls": 24,
"seconds": 0.0002698999596759677,
"exclusive_seconds": 0.0002698999596759677
}
}
},
"false": {
"count": 2350,
"mean_seconds": 0.02009875251049612,
"median_seconds": 0.019996499991975725,
"total_seconds": 47.23206839966588,
"stages": {
"refinement": {
"calls": 2350,
"seconds": 2.7974041998386383,
"exclusive_seconds": 2.7974041998386383
},
"matched_filter": {
"calls": 2336,
"seconds": 0.9339654004434124,
"exclusive_seconds": 0.9339654004434124
},
"timing": {
"calls": 2336,
"seconds": 41.83316580078099,
"exclusive_seconds": 0.028656100272201
},
"timing_fallback": {
"calls": 2336,
"seconds": 41.80450970050879,
"exclusive_seconds": 41.80450970050879
},
"phase_diagnostics": {
"calls": 2336,
"seconds": 0.910528302541934,
"exclusive_seconds": 0.4247559007490054
},
"cfo_gain": {
"calls": 2336,
"seconds": 0.4857724017929286,
"exclusive_seconds": 0.4857724017929286
},
"phy_header": {
"calls": 2298,
"seconds": 0.06713570095598698,
"exclusive_seconds": 0.06713570095598698
}
}
}
},
"rf_tx": 0,
"array_observation": {
"valid": {
"observed_array_objects": 14361,
"observed_owned_arrays": 8704,
"observed_owned_bytes_total": 16681890,
"tracemalloc_retained_bytes": 1842,
"tracemalloc_peak_bytes": 4787123,
"array_count_is_lower_bound": true
}
},
"process_memory_after_profiling": {
"rss_bytes": 156041216,
"peak_rss_bytes": 169754624
}
}

View File

@@ -0,0 +1,419 @@
{
"repeats": [
{
"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": 1.2077819011174142,
"processing_per_rf_second": 3.7306624044103103,
"processing_per_payload_airtime_second": 4.363878414836304,
"coarse_seconds": 0.0927895000204444,
"strict_seconds": 0.9242713990388438,
"assembly_jpeg_decode_seconds": 0.0011941003613173962,
"processing_seconds_per_rf_packet": 0.05032424587989226,
"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": 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": 1.144175999914296,
"processing_per_rf_second": 3.534193038461398,
"processing_per_payload_airtime_second": 4.134061740932101,
"coarse_seconds": 0.08686010027304292,
"strict_seconds": 0.8724833994638175,
"assembly_jpeg_decode_seconds": 0.0011348994448781013,
"processing_seconds_per_rf_packet": 0.047673999996428996,
"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": 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": 1.1161368001485243,
"processing_per_rf_second": 3.4475840337071983,
"processing_per_payload_airtime_second": 4.032752341847773,
"coarse_seconds": 0.08814430015627295,
"strict_seconds": 0.841466698795557,
"assembly_jpeg_decode_seconds": 0.0011885995045304298,
"processing_seconds_per_rf_packet": 0.04650570000618851,
"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
}
],
"median_processing_seconds": 1.144175999914296,
"profile": {
"shared_stages": {
"phy_header": {
"calls": 24,
"seconds": 0.00042649987153708935,
"exclusive_seconds": 0.00042649987153708935
},
"coarse_localization": {
"calls": 36,
"seconds": 0.08918269968125969,
"exclusive_seconds": 0.08918269968125969
}
},
"valid": {
"count": 24,
"mean_seconds": 0.01776749997710188,
"median_seconds": 0.017930250032804906,
"total_seconds": 0.4264199994504452,
"stages": {
"refinement": {
"calls": 24,
"seconds": 0.09387810016050935,
"exclusive_seconds": 0.09387810016050935
},
"matched_filter": {
"calls": 48,
"seconds": 0.1028753004502505,
"exclusive_seconds": 0.1028753004502505
},
"timing": {
"calls": 48,
"seconds": 0.04353450005874038,
"exclusive_seconds": 0.04353450005874038
},
"phase_diagnostics": {
"calls": 48,
"seconds": 0.016765999956987798,
"exclusive_seconds": 0.008323899819515646
},
"cfo_gain": {
"calls": 48,
"seconds": 0.008442100137472153,
"exclusive_seconds": 0.008442100137472153
},
"phy_header": {
"calls": 48,
"seconds": 0.001451600226573646,
"exclusive_seconds": 0.001451600226573646
},
"body_pipeline": {
"calls": 24,
"seconds": 0.14421880000736564,
"exclusive_seconds": 0.011199799599125981
},
"viterbi": {
"calls": 24,
"seconds": 0.1321190003072843,
"exclusive_seconds": 0.1321190003072843
},
"crc_parser": {
"calls": 24,
"seconds": 0.00021099986042827368,
"exclusive_seconds": 0.00021099986042827368
}
}
},
"false": {
"count": 2350,
"mean_seconds": 0.00018731676512695057,
"median_seconds": 6.049999501556158e-05,
"total_seconds": 0.4401943980483338,
"stages": {
"refinement": {
"calls": 2350,
"seconds": 0.11974260024726391,
"exclusive_seconds": 0.11974260024726391
},
"matched_filter": {
"calls": 174,
"seconds": 0.026502199470996857,
"exclusive_seconds": 0.026502199470996857
},
"timing": {
"calls": 174,
"seconds": 0.13858849939424545,
"exclusive_seconds": 0.13858849939424545
},
"phase_diagnostics": {
"calls": 174,
"seconds": 0.05482260056305677,
"exclusive_seconds": 0.028438000474125147
},
"cfo_gain": {
"calls": 174,
"seconds": 0.02638460008893162,
"exclusive_seconds": 0.02638460008893162
},
"phy_header": {
"calls": 2298,
"seconds": 0.03204370115417987,
"exclusive_seconds": 0.03204370115417987
}
}
}
},
"profiled_benchmark": {
"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": 1.16440529958345,
"processing_per_rf_second": 3.5966783991655458,
"processing_per_payload_airtime_second": 4.207152920798105,
"coarse_seconds": 0.08932610007468611,
"strict_seconds": 0.8848743984708562,
"assembly_jpeg_decode_seconds": 0.0011538001708686352,
"processing_seconds_per_rf_packet": 0.048516887482643746,
"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
},
"rf_tx": 0
}

View File

@@ -0,0 +1,67 @@
{
"blocks": 4096,
"model_seconds": 33.554432,
"background": "zeros; noisy background covered by six-second integration",
"peak_iq_bytes": 3177728,
"iq_limit_bytes": 3398912,
"retained_after_finish": 0,
"checkpoints": [
{
"block": 512,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"rss_bytes": 163840000,
"peak_rss_bytes": 186650624
},
{
"block": 1024,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"rss_bytes": 163840000,
"peak_rss_bytes": 186650624
},
{
"block": 1536,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"rss_bytes": 163786752,
"peak_rss_bytes": 186650624
},
{
"block": 2048,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"rss_bytes": 163786752,
"peak_rss_bytes": 186650624
},
{
"block": 2560,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"rss_bytes": 163786752,
"peak_rss_bytes": 186650624
},
{
"block": 3072,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"rss_bytes": 163786752,
"peak_rss_bytes": 186650624
},
{
"block": 3584,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"rss_bytes": 163786752,
"peak_rss_bytes": 186650624
},
{
"block": 4096,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"rss_bytes": 163790848,
"peak_rss_bytes": 186650624
}
],
"rf_tx": 0
}

View File

@@ -0,0 +1,198 @@
{
"stage": "LAB048_OFFLINE_RECEIVER_PERFORMANCE",
"baseline": {
"capture_seconds": 15.0,
"full_processing_seconds": 37.423910000012256,
"strict_stage_seconds": 32.45996900013415,
"average_strict_candidate_seconds": 0.47043433333527757,
"approximate_peak_bytes": 1545755352
},
"optimized": {
"capture_seconds": 15.0,
"full_processing_seconds": 12.978691199910827,
"coarse_search_seconds": 4.4797875001095235,
"strict_stage_seconds": 8.487128499895334,
"average_strict_candidate_seconds": 0.12300186231732368,
"approximate_peak_bytes": 1545755352,
"stage_measurements": {
"cs8_to_complex64": {
"calls": 113,
"cumulative_seconds": 1.306003100471571
},
"refine_start": {
"calls": 69,
"cumulative_seconds": 2.930933100869879
},
"matched_filter": {
"calls": 105,
"cumulative_seconds": 2.9355400999775156
},
"timing": {
"calls": 105,
"cumulative_seconds": 1.2902987005654722
},
"cfo_phase_soft": {
"calls": 105,
"cumulative_seconds": 0.039461099775508046
},
"phy_header": {
"calls": 105,
"cumulative_seconds": 0.00410620030015707
},
"post_preamble_fec_crc": {
"calls": 36,
"cumulative_seconds": 0.6347944003064185
},
"viterbi": {
"calls": 36,
"cumulative_seconds": 0.6168836002470925
}
},
"memory_arrays": [
{
"name": "coarse_iq_window",
"dtype": "complex64",
"shape": [
16000000
],
"bytes": 128000000,
"mib": 122.0703125
},
{
"name": "coarse_decimated",
"dtype": "complex64",
"shape": [
800000
],
"bytes": 6400000,
"mib": 6.103515625
},
{
"name": "coarse_downconversion_vector",
"dtype": "complex64",
"shape": [
800000
],
"bytes": 6400000,
"mib": 6.103515625
},
{
"name": "coarse_correlation_scores",
"dtype": "float32",
"shape": [
773561
],
"bytes": 3094244,
"mib": 2.9509010314941406
},
{
"name": "strict_full_iq_window",
"dtype": "complex64",
"shape": [
2306400
],
"bytes": 18451200,
"mib": 17.596435546875
},
{
"name": "strict_cached_carrier_vector",
"dtype": "complex64",
"shape": [
2306400
],
"bytes": 18451200,
"mib": 17.596435546875
},
{
"name": "strict_baseband",
"dtype": "complex64",
"shape": [
2306400
],
"bytes": 18451200,
"mib": 17.596435546875
},
{
"name": "strict_matched_output",
"dtype": "complex64",
"shape": [
2310400
],
"bytes": 18483200,
"mib": 17.626953125
},
{
"name": "matched_filter_taps",
"dtype": "float32",
"shape": [
4001
],
"bytes": 16004,
"mib": 0.015262603759765625
}
]
},
"speedup": 2.883488745018403,
"processing_to_capture_ratio": 0.8652460799940551,
"real_time_capable": true,
"threshold": 0.030964473262429237,
"threshold_changed": false,
"coarse": {
"total_local_peaks": 185828,
"first_stage_candidates": 32414,
"skipped_as_owned_event": 32345,
"exact_duplicates": 0
},
"strict": {
"candidates_processed": 69,
"rejected_during_refinement": 0,
"refinement_pass": 69,
"timing_pass": 69,
"carrier_pass": 69,
"phy_header_pass": 36,
"phy_header_fail": 33,
"fec_attempts": 36,
"fec_pass": 36,
"fec_fail": 0,
"protocol_parse_pass": 36,
"crc_pass": 36,
"crc_fail": 0,
"valid_frames": 36
},
"object": {
"valid_fragments": 36,
"missing_fragments": [],
"object_complete": true
},
"after_independent_verdict": {
"stage": "AFTER_COMPLETED_INDEPENDENT_VERDICT_ONLY",
"expected_tx_frames": 36,
"matched_valid_frames": 36,
"missed_tx_frames": [],
"false_crc_pass": 0,
"duplicate_valid_detections": 0,
"total_pre_fec_coded_bit_errors": 19,
"total_post_fec_protocol_bit_errors": 0,
"jpeg_size": 8724,
"recovered_sha256": "e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a",
"tx_sha256": "e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a",
"sha_match": true,
"byte_errors": 0,
"bit_errors": 0,
"jpeg_decode": "PASS",
"width": 320,
"height": 240
},
"receiver_oracles": {
"expected_frame_count": false,
"tx_coded_bits": false,
"tx_fragment_indexes": false,
"tx_frame_offsets": false,
"tx_image": false,
"tx_packets": false
},
"phy_changed": false,
"fec_changed": false,
"bpsk_changed": false,
"rf_tx": 0
}

View File

@@ -0,0 +1,40 @@
{
"false": {
"reason": "PHY_HEADER_MAGIC_MISMATCH",
"baseline_arrays": {
"observed_array_objects": 2888,
"observed_owned_arrays": 61,
"observed_owned_bytes_total": 6612268,
"tracemalloc_retained_bytes": 1481,
"tracemalloc_peak_bytes": 2104129,
"array_count_is_lower_bound": true
},
"optimized_arrays": {
"observed_array_objects": 76,
"observed_owned_arrays": 61,
"observed_owned_bytes_total": 5906101,
"tracemalloc_retained_bytes": 1936,
"tracemalloc_peak_bytes": 5524392,
"array_count_is_lower_bound": true
}
},
"valid": {
"reason": "CRC_PASS",
"baseline_arrays": {
"observed_array_objects": 14361,
"observed_owned_arrays": 8704,
"observed_owned_bytes_total": 16681890,
"tracemalloc_retained_bytes": 2130,
"tracemalloc_peak_bytes": 5885299,
"array_count_is_lower_bound": true
},
"optimized_arrays": {
"observed_array_objects": 6594,
"observed_owned_arrays": 6560,
"observed_owned_bytes_total": 12345244,
"tracemalloc_retained_bytes": 2645,
"tracemalloc_peak_bytes": 7771675,
"array_count_is_lower_bound": true
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
{
"checks": {
"median_whole_timeline": true,
"all_240_packets": true,
"all_30_exact_jpegs": true,
"same_240_unique_refined_starts": true,
"same_first_stage_counts": true,
"false_count_15": true,
"no_sensitivity_losses": true,
"bounded_long_iq": true,
"longer_than_main": true,
"long_noise_accepts_zero": true,
"finish_clears_iq_and_candidates": true,
"old20k_36_crc_and_sha": true,
"measured_cpu_sum": true,
"radio_protocol_files_unchanged": true
},
"required_regressions_pass": true,
"software_5fps_target_pass": false,
"real_time_capable": false,
"fresh_display_fps": 0.0,
"processing_per_rf_second": 1.2192732766011487,
"speedup_same_fixture": 5.649181959197251,
"false_candidate_reduction": 9784.333333333334,
"rf_tx": 0,
"protocol_check_command": "git status --porcelain --untracked-files=all -- protocol"
}

View File

@@ -0,0 +1,13 @@
{
"observed_array_objects": 6754,
"observed_owned_arrays": 6681,
"observed_owned_bytes_total": 37378942,
"tracemalloc_retained_bytes": 6461,
"tracemalloc_peak_bytes": 11586792,
"array_count_is_lower_bound": true,
"iq_seconds": 0.063184125,
"packets": 1,
"interpretation": "observed Python ndarray lower bound; tracked RX allocation peak; input IQ prepared before tracing",
"timing_not_used_as_benchmark": true,
"rf_tx": 0
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 239 KiB

View File

@@ -0,0 +1,74 @@
7786130 function calls (7783174 primitive calls) in 12.641 seconds
Ordered by: internal time
List reduced from 336 to 30 due to restriction <30>
ncalls tottime percall cumtime percall filename:lineno(function)
7779 2.401 0.000 2.401 0.000 {built-in method scipy.fft._duccfft.pyduccfft.c2c}
240 1.355 0.006 1.363 0.006 lab051_streaming_receiver.py:139(fast_viterbi)
1467 1.290 0.001 1.301 0.001 _upfirdn.py:91(apply_filter)
312654 1.160 0.000 2.039 0.000 numeric.py:2337(isclose)
738 0.665 0.001 12.616 0.017 lab050_streaming_receiver.py:133(push_iq_block)
974 0.519 0.001 2.622 0.003 bpsk_radio.py:1079(root_raised_cosine_taps)
734 0.515 0.001 3.251 0.004 lab044_hackrf_bpsk_packet.py:189(_scale_and_carrier)
2746 0.327 0.000 0.327 0.000 {method 'copy' of 'numpy.ndarray' objects}
1455 0.267 0.000 0.269 0.000 lab043_pluto_to_rtlsdr.py:516(pilot_prbs7)
1563270 0.251 0.000 0.360 0.000 numeric.py:2429(<genexpr>)
2593 0.228 0.000 3.133 0.001 _signaltools.py:485(_freq_domain_conv)
1934 0.160 0.000 0.160 0.000 {method 'cumsum' of 'numpy.ndarray' objects}
7779 0.155 0.000 0.261 0.000 helper.py:146(_fix_shape)
16722 0.153 0.000 0.153 0.000 {built-in method numpy.zeros}
1624470 0.152 0.000 0.163 0.000 {built-in method builtins.isinstance}
29235 0.144 0.000 0.149 0.000 {built-in method numpy.array}
313134 0.128 0.000 0.251 0.000 _ufunc_config.py:440(__enter__)
480 0.125 0.000 1.649 0.003 lab051_streaming_receiver.py:258(matched_window)
12394 0.113 0.000 0.113 0.000 {method 'reduce' of 'numpy.ufunc' objects}
480 0.095 0.000 2.613 0.005 lab051_streaming_receiver.py:287(extract)
734 0.093 0.000 0.093 0.000 {built-in method scipy.ndimage._nd_image.min_or_max_filter1d}
784660 0.076 0.000 0.076 0.000 {built-in method builtins.abs}
734 0.074 0.000 0.540 0.001 lab043_hackrf_bpsk.py:248(_normalized_sliding_correlation)
313134 0.074 0.000 0.074 0.000 {built-in method numpy._core._multiarray_umath._make_extobj}
313134 0.071 0.000 0.124 0.000 _ufunc_config.py:456(__exit__)
885 0.070 0.000 0.075 0.000 _arraypad_impl.py:86(_pad_simple)
480 0.070 0.000 0.210 0.000 lab043_hackrf_bpsk.py:530(_evaluate_symbols)
5894 0.061 0.000 0.061 0.000 {method 'reshape' of 'numpy.ndarray' objects}
734 0.060 0.000 11.916 0.016 lab052_candidate_detection.py:142(_scan)
734 0.057 0.000 2.708 0.004 lab043_pluto_to_rtlsdr.py:458(shape_frame_like_lab042)
7786130 function calls (7783174 primitive calls) in 12.641 seconds
Ordered by: cumulative time
List reduced from 336 to 30 due to restriction <30>
ncalls tottime percall cumtime percall filename:lineno(function)
738 0.665 0.001 12.616 0.017 lab050_streaming_receiver.py:133(push_iq_block)
734 0.060 0.000 11.916 0.016 lab052_candidate_detection.py:142(_scan)
240 0.022 0.000 5.697 0.024 lab046c_offline_recovery.py:637(decode_candidate_from_memmap)
734 0.004 0.000 3.548 0.005 lab051_streaming_receiver.py:185(__init__)
734 0.007 0.000 3.544 0.005 lab044_hackrf_bpsk_packet.py:257(_preamble_reference_samples)
734 0.515 0.001 3.251 0.004 lab044_hackrf_bpsk_packet.py:189(_scale_and_carrier)
2593 0.228 0.000 3.133 0.001 _signaltools.py:485(_freq_domain_conv)
7779 0.012 0.000 2.869 0.000 _backend.py:19(__ua_function__)
7779 0.020 0.000 2.847 0.000 _basic_backend.py:52(_execute_nD)
7779 0.030 0.000 2.824 0.000 basic.py:126(c2cn)
2188 0.036 0.000 2.806 0.001 _signaltools.py:589(fftconvolve)
734 0.057 0.000 2.708 0.004 lab043_pluto_to_rtlsdr.py:458(shape_frame_like_lab042)
974 0.519 0.001 2.622 0.003 bpsk_radio.py:1079(root_raised_cosine_taps)
480 0.095 0.000 2.613 0.005 lab051_streaming_receiver.py:287(extract)
7779 2.401 0.000 2.401 0.000 {built-in method scipy.fft._duccfft.pyduccfft.c2c}
734 0.025 0.000 2.057 0.003 lab052_candidate_detection.py:112(coarse_scores)
312654 1.160 0.000 2.039 0.000 numeric.py:2337(isclose)
5186 0.005 0.000 1.855 0.000 _basic_backend.py:113(fftn)
480 0.125 0.000 1.649 0.003 lab051_streaming_receiver.py:258(matched_window)
240 0.023 0.000 1.508 0.006 lab045_hackrf_bpsk_fec.py:476(analyze_post_preamble_soft)
240 0.052 0.000 1.497 0.006 lab051_streaming_receiver.py:222(refine)
734 0.030 0.000 1.415 0.002 lab051_streaming_receiver.py:113(get)
1467 0.003 0.000 1.383 0.001 lab051_streaming_receiver.py:120(filtered)
1467 0.026 0.000 1.379 0.001 _signaltools.py:3884(resample_poly)
240 1.355 0.006 1.363 0.006 lab051_streaming_receiver.py:139(fast_viterbi)
1467 0.005 0.000 1.331 0.001 _upfirdn.py:108(upfirdn)
1467 1.290 0.001 1.301 0.001 _upfirdn.py:91(apply_filter)
2593 0.002 0.000 0.999 0.000 _basic_backend.py:120(ifftn)
405 0.022 0.000 0.797 0.002 _signaltools.py:850(oaconvolve)
240 0.001 0.000 0.661 0.003 lab043_hackrf_bpsk.py:366(_matched_filter_taps)

View File

@@ -0,0 +1,115 @@
{
"blocks": 2048,
"iq_seconds": 16.777216,
"background_scales": [
0.002,
0.2,
20,
0.02
],
"accepted_noise_packets": 0,
"peak_retained_iq_bytes": 3177728,
"iq_limit_bytes": 3398912,
"retained_after_finish_bytes": 0,
"constant_template_bytes": 171140,
"candidate_state_after_finish": 0,
"peak_eligible_candidates": 1,
"peak_cluster_count": 1,
"detector": {
"total_local_peaks": 4783206,
"first_stage_candidates": 1068038,
"cheap_stage_checked": 1068038,
"cheap_stage_rejected": 1068037,
"clusters": 1,
"representative_checks": 1
},
"strict_candidates": 1,
"processing_seconds": 8.453774801455438,
"checkpoints": [
{
"block": 256,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"retained_candidates": 0,
"peak_eligible_candidates": 0,
"counter_keys": 5,
"rss_bytes": 137297920,
"peak_rss_bytes": 168501248
},
{
"block": 512,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"retained_candidates": 0,
"peak_eligible_candidates": 1,
"counter_keys": 7,
"rss_bytes": 139948032,
"peak_rss_bytes": 168501248
},
{
"block": 768,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"retained_candidates": 0,
"peak_eligible_candidates": 1,
"counter_keys": 7,
"rss_bytes": 140001280,
"peak_rss_bytes": 168501248
},
{
"block": 1024,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"retained_candidates": 0,
"peak_eligible_candidates": 1,
"counter_keys": 7,
"rss_bytes": 140533760,
"peak_rss_bytes": 168501248
},
{
"block": 1280,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"retained_candidates": 0,
"peak_eligible_candidates": 1,
"counter_keys": 7,
"rss_bytes": 140533760,
"peak_rss_bytes": 168501248
},
{
"block": 1536,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"retained_candidates": 0,
"peak_eligible_candidates": 1,
"counter_keys": 7,
"rss_bytes": 140541952,
"peak_rss_bytes": 168501248
},
{
"block": 1792,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"retained_candidates": 0,
"peak_eligible_candidates": 1,
"counter_keys": 7,
"rss_bytes": 140550144,
"peak_rss_bytes": 168501248
},
{
"block": 2048,
"iq_bytes": 2653440,
"decimated_bytes": 397216,
"retained_candidates": 0,
"peak_eligible_candidates": 1,
"counter_keys": 7,
"rss_bytes": 140550144,
"peak_rss_bytes": 168501248
}
],
"process_memory": {
"rss_bytes": 135602176,
"peak_rss_bytes": 168501248
},
"rf_tx": 0
}

View File

@@ -0,0 +1,74 @@
1173653 function calls (1170697 primitive calls) in 8.087 seconds
Ordered by: internal time
List reduced from 324 to 30 due to restriction <30>
ncalls tottime percall cumtime percall filename:lineno(function)
10251 1.650 0.000 1.650 0.000 {built-in method scipy.fft._duccfft.pyduccfft.c2c}
240 1.397 0.006 1.405 0.006 lab051_streaming_receiver.py:139(fast_viterbi)
1467 1.295 0.001 1.308 0.001 _upfirdn.py:91(apply_filter)
738 0.553 0.001 8.065 0.011 lab050_streaming_receiver.py:133(push_iq_block)
2701 0.313 0.000 0.313 0.000 {method 'copy' of 'numpy.ndarray' objects}
14414 0.167 0.000 0.167 0.000 {built-in method numpy.zeros}
3417 0.157 0.000 2.311 0.001 _signaltools.py:485(_freq_domain_conv)
734 0.152 0.000 7.483 0.010 lab052_candidate_detection.py:181(_scan)
2687 0.145 0.000 0.145 0.000 {method 'cumsum' of 'numpy.ndarray' objects}
11504 0.113 0.000 0.113 0.000 {method 'reduce' of 'numpy.ufunc' objects}
488 0.098 0.000 0.865 0.002 lab051_streaming_receiver.py:258(matched_window)
734 0.095 0.000 0.531 0.001 lab052_candidate_detection.py:125(split_scores)
35184 0.091 0.000 0.097 0.000 {built-in method numpy.array}
495 0.085 0.000 1.733 0.004 lab051_streaming_receiver.py:287(extract)
488 0.075 0.000 0.225 0.000 lab043_hackrf_bpsk.py:530(_evaluate_symbols)
734 0.069 0.000 7.321 0.010 lab052_candidate_detection.py:185(_scan_with_templates)
10251 0.068 0.000 0.222 0.000 helper.py:146(_fix_shape)
734 0.062 0.000 0.372 0.001 lab043_hackrf_bpsk.py:248(_normalized_sliding_correlation)
240 0.059 0.000 0.083 0.000 bit_interleaver.py:41(build_permutation)
972 0.052 0.000 0.055 0.000 _arraypad_impl.py:86(_pad_simple)
488 0.051 0.000 0.533 0.001 lab051_streaming_receiver.py:41(fast_preamble_timing)
734 0.049 0.000 1.457 0.002 lab051_streaming_receiver.py:113(get)
255 0.049 0.000 1.084 0.004 lab051_streaming_receiver.py:222(refine)
13668 0.045 0.000 0.107 0.000 helper.py:47(_init_nd_shape_and_axes)
488 0.043 0.000 0.119 0.000 lab043_hackrf_bpsk.py:454(_estimate_cfo_gain)
2933 0.040 0.000 2.010 0.001 _signaltools.py:589(fftconvolve)
31006 0.040 0.000 0.040 0.000 {built-in method numpy.asarray}
5366 0.038 0.000 0.038 0.000 {method 'reshape' of 'numpy.ndarray' objects}
10251 0.034 0.000 2.061 0.000 basic.py:126(c2cn)
1467 0.032 0.000 1.401 0.001 _signaltools.py:3884(resample_poly)
1173653 function calls (1170697 primitive calls) in 8.087 seconds
Ordered by: cumulative time
List reduced from 324 to 30 due to restriction <30>
ncalls tottime percall cumtime percall filename:lineno(function)
738 0.553 0.001 8.065 0.011 lab050_streaming_receiver.py:133(push_iq_block)
734 0.152 0.000 7.483 0.010 lab052_candidate_detection.py:181(_scan)
734 0.069 0.000 7.321 0.010 lab052_candidate_detection.py:185(_scan_with_templates)
255 0.026 0.000 4.417 0.017 lab046c_offline_recovery.py:637(decode_candidate_from_memmap)
3417 0.157 0.000 2.311 0.001 _signaltools.py:485(_freq_domain_conv)
10251 0.014 0.000 2.110 0.000 _backend.py:19(__ua_function__)
10251 0.021 0.000 2.086 0.000 _basic_backend.py:52(_execute_nD)
10251 0.034 0.000 2.061 0.000 basic.py:126(c2cn)
2933 0.040 0.000 2.010 0.001 _signaltools.py:589(fftconvolve)
734 0.025 0.000 1.932 0.003 lab052_candidate_detection.py:111(coarse_scores)
495 0.085 0.000 1.733 0.004 lab051_streaming_receiver.py:287(extract)
10251 1.650 0.000 1.650 0.000 {built-in method scipy.fft._duccfft.pyduccfft.c2c}
240 0.026 0.000 1.558 0.006 lab045_hackrf_bpsk_fec.py:476(analyze_post_preamble_soft)
734 0.049 0.000 1.457 0.002 lab051_streaming_receiver.py:113(get)
1467 0.004 0.000 1.406 0.001 lab051_streaming_receiver.py:120(filtered)
240 1.397 0.006 1.405 0.006 lab051_streaming_receiver.py:139(fast_viterbi)
1467 0.032 0.000 1.401 0.001 _signaltools.py:3884(resample_poly)
6834 0.005 0.000 1.360 0.000 _basic_backend.py:113(fftn)
1467 0.005 0.000 1.342 0.001 _upfirdn.py:108(upfirdn)
1467 1.295 0.001 1.308 0.001 _upfirdn.py:91(apply_filter)
255 0.049 0.000 1.084 0.004 lab051_streaming_receiver.py:222(refine)
488 0.098 0.000 0.865 0.002 lab051_streaming_receiver.py:258(matched_window)
3417 0.002 0.000 0.733 0.000 _basic_backend.py:120(ifftn)
484 0.022 0.000 0.712 0.001 _signaltools.py:850(oaconvolve)
488 0.002 0.000 0.535 0.001 lab051_streaming_receiver.py:283(timing)
488 0.051 0.000 0.533 0.001 lab051_streaming_receiver.py:41(fast_preamble_timing)
734 0.095 0.000 0.531 0.001 lab052_candidate_detection.py:125(split_scores)
734 0.062 0.000 0.372 0.001 lab043_hackrf_bpsk.py:248(_normalized_sliding_correlation)
2701 0.313 0.000 0.313 0.000 {method 'copy' of 'numpy.ndarray' objects}
488 0.075 0.000 0.225 0.000 lab043_hackrf_bpsk.py:530(_evaluate_symbols)

View File

@@ -0,0 +1,198 @@
{
"stage": "LAB048_OFFLINE_RECEIVER_PERFORMANCE",
"baseline": {
"capture_seconds": 15.0,
"full_processing_seconds": 37.423910000012256,
"strict_stage_seconds": 32.45996900013415,
"average_strict_candidate_seconds": 0.47043433333527757,
"approximate_peak_bytes": 1545755352
},
"optimized": {
"capture_seconds": 15.0,
"full_processing_seconds": 13.213345700060017,
"coarse_search_seconds": 4.5940155000425875,
"strict_stage_seconds": 8.609627199941315,
"average_strict_candidate_seconds": 0.12477720579625094,
"approximate_peak_bytes": 1545755352,
"stage_measurements": {
"cs8_to_complex64": {
"calls": 113,
"cumulative_seconds": 1.3091708993306383
},
"refine_start": {
"calls": 69,
"cumulative_seconds": 2.8543391003040597
},
"matched_filter": {
"calls": 105,
"cumulative_seconds": 3.088835699832998
},
"timing": {
"calls": 105,
"cumulative_seconds": 1.2946644995827228
},
"cfo_phase_soft": {
"calls": 105,
"cumulative_seconds": 0.0416537004057318
},
"phy_header": {
"calls": 105,
"cumulative_seconds": 0.004346300149336457
},
"post_preamble_fec_crc": {
"calls": 36,
"cumulative_seconds": 0.6405917998636141
},
"viterbi": {
"calls": 36,
"cumulative_seconds": 0.6221252002287656
}
},
"memory_arrays": [
{
"name": "coarse_iq_window",
"dtype": "complex64",
"shape": [
16000000
],
"bytes": 128000000,
"mib": 122.0703125
},
{
"name": "coarse_decimated",
"dtype": "complex64",
"shape": [
800000
],
"bytes": 6400000,
"mib": 6.103515625
},
{
"name": "coarse_downconversion_vector",
"dtype": "complex64",
"shape": [
800000
],
"bytes": 6400000,
"mib": 6.103515625
},
{
"name": "coarse_correlation_scores",
"dtype": "float32",
"shape": [
773561
],
"bytes": 3094244,
"mib": 2.9509010314941406
},
{
"name": "strict_full_iq_window",
"dtype": "complex64",
"shape": [
2306400
],
"bytes": 18451200,
"mib": 17.596435546875
},
{
"name": "strict_cached_carrier_vector",
"dtype": "complex64",
"shape": [
2306400
],
"bytes": 18451200,
"mib": 17.596435546875
},
{
"name": "strict_baseband",
"dtype": "complex64",
"shape": [
2306400
],
"bytes": 18451200,
"mib": 17.596435546875
},
{
"name": "strict_matched_output",
"dtype": "complex64",
"shape": [
2310400
],
"bytes": 18483200,
"mib": 17.626953125
},
{
"name": "matched_filter_taps",
"dtype": "float32",
"shape": [
4001
],
"bytes": 16004,
"mib": 0.015262603759765625
}
]
},
"speedup": 2.8322811534282546,
"processing_to_capture_ratio": 0.8808897133373345,
"real_time_capable": true,
"threshold": 0.030964473262429237,
"threshold_changed": false,
"coarse": {
"total_local_peaks": 185828,
"first_stage_candidates": 32414,
"skipped_as_owned_event": 32345,
"exact_duplicates": 0
},
"strict": {
"candidates_processed": 69,
"rejected_during_refinement": 0,
"refinement_pass": 69,
"timing_pass": 69,
"carrier_pass": 69,
"phy_header_pass": 36,
"phy_header_fail": 33,
"fec_attempts": 36,
"fec_pass": 36,
"fec_fail": 0,
"protocol_parse_pass": 36,
"crc_pass": 36,
"crc_fail": 0,
"valid_frames": 36
},
"object": {
"valid_fragments": 36,
"missing_fragments": [],
"object_complete": true
},
"after_independent_verdict": {
"stage": "AFTER_COMPLETED_INDEPENDENT_VERDICT_ONLY",
"expected_tx_frames": 36,
"matched_valid_frames": 36,
"missed_tx_frames": [],
"false_crc_pass": 0,
"duplicate_valid_detections": 0,
"total_pre_fec_coded_bit_errors": 19,
"total_post_fec_protocol_bit_errors": 0,
"jpeg_size": 8724,
"recovered_sha256": "e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a",
"tx_sha256": "e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a",
"sha_match": true,
"byte_errors": 0,
"bit_errors": 0,
"jpeg_decode": "PASS",
"width": 320,
"height": 240
},
"receiver_oracles": {
"expected_frame_count": false,
"tx_coded_bits": false,
"tx_fragment_indexes": false,
"tx_frame_offsets": false,
"tx_image": false,
"tx_packets": false
},
"phy_changed": false,
"fec_changed": false,
"bpsk_changed": false,
"rf_tx": 0
}

View File

@@ -0,0 +1,124 @@
{
"rules": [
{
"rule": {
"parts": 0,
"radius_samples": 20480,
"part_threshold": null
},
"received_images": 30,
"object_jpeg_integrity": true,
"true_candidates_lost": 0,
"false_candidate_reduction": 263.9658273381295,
"detector": {
"total_local_peaks": 1675842,
"first_stage_candidates": 390639,
"clusters": 900,
"cheap_stage_checked": 796,
"inside_accepted_frame": 104,
"strict_true_candidates": 240,
"strict_false_candidates": 556,
"strict_checks_per_rf_second": 131.691796196688,
"cheap_seconds": 0.0009583993814885616,
"cluster_seconds": 0.15876410226337612,
"peak_cluster_count": 7,
"candidate_state_after_finish": 0,
"detection_arrays_peak_estimate_bytes": 1243896
},
"processing_per_rf_second": 2.6724659422897794,
"fresh_display_fps": 0.0
},
{
"rule": {
"parts": 1,
"radius_samples": 20480,
"part_threshold": 0.05995104488778001
},
"received_images": 30,
"object_jpeg_integrity": true,
"true_candidates_lost": 0,
"false_candidate_reduction": 1298.8053097345132,
"detector": {
"total_local_peaks": 1675842,
"first_stage_candidates": 390639,
"clusters": 900,
"cheap_stage_checked": 796,
"inside_accepted_frame": 104,
"cheap_stage_rejected": 443,
"strict_true_candidates": 240,
"strict_false_candidates": 113,
"strict_checks_per_rf_second": 58.40101012240058,
"cheap_seconds": 0.03248489915858954,
"cluster_seconds": 0.16109760117251426,
"peak_cluster_count": 7,
"candidate_state_after_finish": 0,
"detection_arrays_peak_estimate_bytes": 1243896
},
"processing_per_rf_second": 2.059989517566315,
"fresh_display_fps": 0.0
},
{
"rule": {
"parts": 2,
"radius_samples": 20480,
"part_threshold": 0.08474038063392896
},
"received_images": 30,
"object_jpeg_integrity": true,
"true_candidates_lost": 0,
"false_candidate_reduction": null,
"detector": {
"total_local_peaks": 1675842,
"first_stage_candidates": 390639,
"clusters": 900,
"cheap_stage_checked": 796,
"inside_accepted_frame": 104,
"cheap_stage_rejected": 556,
"strict_true_candidates": 240,
"strict_false_candidates": 0,
"strict_checks_per_rf_second": 39.706069205031554,
"cheap_seconds": 0.035114401835016906,
"cluster_seconds": 0.16016869980376214,
"peak_cluster_count": 7,
"candidate_state_after_finish": 0,
"detection_arrays_peak_estimate_bytes": 1243896
},
"processing_per_rf_second": 1.8833212008846116,
"fresh_display_fps": 0.0,
"false_candidate_reduction_fraction": 1.0
},
{
"rule": {
"parts": 3,
"radius_samples": 20480,
"part_threshold": 0.10381329338988134
},
"received_images": 30,
"object_jpeg_integrity": true,
"true_candidates_lost": 0,
"false_candidate_reduction": null,
"detector": {
"total_local_peaks": 1675842,
"first_stage_candidates": 390639,
"clusters": 900,
"cheap_stage_checked": 796,
"inside_accepted_frame": 104,
"cheap_stage_rejected": 556,
"strict_true_candidates": 240,
"strict_false_candidates": 0,
"strict_checks_per_rf_second": 39.706069205031554,
"cheap_seconds": 0.03812550043221563,
"cluster_seconds": 0.16675639932509512,
"peak_cluster_count": 7,
"candidate_state_after_finish": 0,
"detection_arrays_peak_estimate_bytes": 1243896
},
"processing_per_rf_second": 1.8967992111861114,
"fresh_display_fps": 0.0,
"false_candidate_reduction_fraction": 1.0
}
],
"rf_tx": 0,
"status": "REJECTED: strong invalid preamble suppresses neighboring valid packet",
"timing_includes_observer": true
}

View File

@@ -0,0 +1,121 @@
{
"rules": [
{
"rule": {
"parts": 0,
"width_samples": 20480,
"part_threshold": null
},
"received_images": 30,
"object_jpeg_integrity": true,
"true_candidates_lost": 0,
"false_candidate_reduction": 1.0061838848782763,
"detector": {
"total_local_peaks": 1675842,
"first_stage_candidates": 392785,
"cheap_stage_checked": 392785,
"cheap_stage_rejected": 0,
"clusters": 2777,
"inside_accepted_frame": 246682,
"strict_true_candidates": 240,
"strict_false_candidates": 145863,
"strict_checks_per_rf_second": 24171.56595442802,
"cheap_seconds": 0.0041361990151926875,
"cluster_seconds": 0.009281201055273414,
"peak_cluster_count": 15,
"candidate_state_after_finish": 0,
"detection_arrays_peak_estimate_bytes": 1236912
},
"processing_per_rf_second": 5.978309517500444,
"fresh_display_fps": 0.0
},
{
"rule": {
"parts": 1,
"width_samples": 20480,
"part_threshold": 0.05995104488778001
},
"received_images": 30,
"object_jpeg_integrity": true,
"true_candidates_lost": 0,
"false_candidate_reduction": 442.06325301204816,
"detector": {
"total_local_peaks": 1675842,
"first_stage_candidates": 392785,
"cheap_stage_checked": 392785,
"cheap_stage_rejected": 366683,
"clusters": 1354,
"inside_accepted_frame": 25530,
"strict_true_candidates": 240,
"strict_false_candidates": 332,
"strict_checks_per_rf_second": 94.63279827199187,
"cheap_seconds": 0.32703390275128186,
"cluster_seconds": 0.005597901064902544,
"peak_cluster_count": 4,
"candidate_state_after_finish": 0,
"detection_arrays_peak_estimate_bytes": 1330708
},
"processing_per_rf_second": 1.5535515426894586,
"fresh_display_fps": 0.0
},
{
"rule": {
"parts": 2,
"width_samples": 20480,
"part_threshold": 0.08474038063392896
},
"received_images": 30,
"object_jpeg_integrity": true,
"true_candidates_lost": 0,
"false_candidate_reduction": 9784.333333333334,
"detector": {
"total_local_peaks": 1675842,
"first_stage_candidates": 392785,
"cheap_stage_checked": 392785,
"cheap_stage_rejected": 388407,
"clusters": 384,
"inside_accepted_frame": 4123,
"strict_true_candidates": 240,
"strict_false_candidates": 15,
"strict_checks_per_rf_second": 42.187698530346026,
"cheap_seconds": 0.530722099239938,
"cluster_seconds": 0.0033054009545594454,
"peak_cluster_count": 3,
"candidate_state_after_finish": 0,
"detection_arrays_peak_estimate_bytes": 1480664
},
"processing_per_rf_second": 1.3386680530144077,
"fresh_display_fps": 0.0
},
{
"rule": {
"parts": 3,
"width_samples": 20480,
"part_threshold": 0.10381329338988134
},
"received_images": 30,
"object_jpeg_integrity": true,
"true_candidates_lost": 0,
"false_candidate_reduction": 24460.833333333332,
"detector": {
"total_local_peaks": 1675842,
"first_stage_candidates": 392785,
"cheap_stage_checked": 392785,
"cheap_stage_rejected": 390636,
"clusters": 281,
"inside_accepted_frame": 1903,
"strict_true_candidates": 240,
"strict_false_candidates": 6,
"strict_checks_per_rf_second": 40.69872093515734,
"cheap_seconds": 0.8188061001710594,
"cluster_seconds": 0.0032444989774376154,
"peak_cluster_count": 2,
"candidate_state_after_finish": 0,
"detection_arrays_peak_estimate_bytes": 1630812
},
"processing_per_rf_second": 1.410843181335602,
"fresh_display_fps": 0.0
}
],
"rf_tx": 0
}

View File

@@ -0,0 +1,905 @@
{
"cases": [
{
"ebn0_db": 0,
"cfo_hz": -100,
"seed": 52031,
"amplitude_scale": 0.002,
"baseline": {
"accepted": 0,
"payload_match": false,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 1045,
"CRC_FAIL": 31
}
},
"optimized": {
"accepted": 0,
"payload_match": false,
"reasons": {
"CRC_FAIL": 2,
"PHY_HEADER_MAGIC_MISMATCH": 8
}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 0,
"cfo_hz": -100,
"seed": 52032,
"amplitude_scale": 0.2,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 71
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 0,
"cfo_hz": -100,
"seed": 52033,
"amplitude_scale": 1.0,
"baseline": {
"accepted": 0,
"payload_match": false,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 1029,
"CRC_NOT_REACHED": 11,
"TRUNCATED_SYMBOLS": 20
}
},
"optimized": {
"accepted": 0,
"payload_match": false,
"reasons": {
"CRC_NOT_REACHED": 2,
"PHY_HEADER_MAGIC_MISMATCH": 10
}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 0,
"cfo_hz": 10,
"seed": 52031,
"amplitude_scale": 0.002,
"baseline": {
"accepted": 0,
"payload_match": false,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 1115,
"PHY_HEADER_CHECKSUM_MISMATCH": 33,
"TRUNCATED_SYMBOLS": 10
}
},
"optimized": {
"accepted": 0,
"payload_match": false,
"reasons": {
"PHY_HEADER_CHECKSUM_MISMATCH": 1,
"PHY_HEADER_MAGIC_MISMATCH": 8
}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 0,
"cfo_hz": 10,
"seed": 52032,
"amplitude_scale": 0.2,
"baseline": {
"accepted": 0,
"payload_match": false,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 974,
"PHY_HEADER_CHECKSUM_MISMATCH": 14,
"TRUNCATED_SYMBOLS": 25
}
},
"optimized": {
"accepted": 0,
"payload_match": false,
"reasons": {
"PHY_HEADER_CHECKSUM_MISMATCH": 2,
"PHY_HEADER_MAGIC_MISMATCH": 10
}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 0,
"cfo_hz": 10,
"seed": 52033,
"amplitude_scale": 1.0,
"baseline": {
"accepted": 0,
"payload_match": false,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 1046,
"PHY_HEADER_CHECKSUM_MISMATCH": 14,
"TRUNCATED_SYMBOLS": 13
}
},
"optimized": {
"accepted": 0,
"payload_match": false,
"reasons": {
"PHY_HEADER_CHECKSUM_MISMATCH": 3,
"PHY_HEADER_MAGIC_MISMATCH": 11
}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 0,
"cfo_hz": 100,
"seed": 52031,
"amplitude_scale": 0.002,
"baseline": {
"accepted": 0,
"payload_match": false,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 1034,
"TRUNCATED_SYMBOLS": 52
}
},
"optimized": {
"accepted": 0,
"payload_match": false,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 9
}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 0,
"cfo_hz": 100,
"seed": 52032,
"amplitude_scale": 0.2,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 77
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 0,
"cfo_hz": 100,
"seed": 52033,
"amplitude_scale": 1.0,
"baseline": {
"accepted": 0,
"payload_match": false,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 967,
"TRUNCATED_SYMBOLS": 18
}
},
"optimized": {
"accepted": 0,
"payload_match": false,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 11
}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 1,
"cfo_hz": -100,
"seed": 52031,
"amplitude_scale": 0.002,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"TRUNCATED_SYMBOLS": 1,
"PHY_HEADER_MAGIC_MISMATCH": 98
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 1,
"cfo_hz": -100,
"seed": 52032,
"amplitude_scale": 0.2,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 65
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 1,
"cfo_hz": -100,
"seed": 52033,
"amplitude_scale": 1.0,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 76
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 1,
"cfo_hz": 10,
"seed": 52031,
"amplitude_scale": 0.002,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"TRUNCATED_SYMBOLS": 1,
"PHY_HEADER_MAGIC_MISMATCH": 101
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 1,
"cfo_hz": 10,
"seed": 52032,
"amplitude_scale": 0.2,
"baseline": {
"accepted": 0,
"payload_match": false,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 1009,
"PHY_HEADER_CHECKSUM_MISMATCH": 13,
"TRUNCATED_SYMBOLS": 1
}
},
"optimized": {
"accepted": 0,
"payload_match": false,
"reasons": {
"PHY_HEADER_CHECKSUM_MISMATCH": 3,
"PHY_HEADER_MAGIC_MISMATCH": 11
}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 1,
"cfo_hz": 10,
"seed": 52033,
"amplitude_scale": 1.0,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 75
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 1,
"cfo_hz": 100,
"seed": 52031,
"amplitude_scale": 0.002,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"TRUNCATED_SYMBOLS": 1,
"PHY_HEADER_MAGIC_MISMATCH": 100
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 1,
"cfo_hz": 100,
"seed": 52032,
"amplitude_scale": 0.2,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 67
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 1,
"cfo_hz": 100,
"seed": 52033,
"amplitude_scale": 1.0,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 74
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 2,
"cfo_hz": -100,
"seed": 52031,
"amplitude_scale": 0.002,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"TRUNCATED_SYMBOLS": 1,
"PHY_HEADER_MAGIC_MISMATCH": 79
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 2,
"cfo_hz": -100,
"seed": 52032,
"amplitude_scale": 0.2,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 56
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 2,
"cfo_hz": -100,
"seed": 52033,
"amplitude_scale": 1.0,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 68
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 2,
"cfo_hz": 10,
"seed": 52031,
"amplitude_scale": 0.002,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"TRUNCATED_SYMBOLS": 1,
"PHY_HEADER_MAGIC_MISMATCH": 83
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 2,
"cfo_hz": 10,
"seed": 52032,
"amplitude_scale": 0.2,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 58
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 2,
"cfo_hz": 10,
"seed": 52033,
"amplitude_scale": 1.0,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 70
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 2,
"cfo_hz": 100,
"seed": 52031,
"amplitude_scale": 0.002,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"TRUNCATED_SYMBOLS": 1,
"PHY_HEADER_MAGIC_MISMATCH": 85
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 2,
"cfo_hz": 100,
"seed": 52032,
"amplitude_scale": 0.2,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 57
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 2,
"cfo_hz": 100,
"seed": 52033,
"amplitude_scale": 1.0,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 67
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 3,
"cfo_hz": -100,
"seed": 52031,
"amplitude_scale": 0.002,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 71
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 3,
"cfo_hz": -100,
"seed": 52032,
"amplitude_scale": 0.2,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 49
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 3,
"cfo_hz": -100,
"seed": 52033,
"amplitude_scale": 1.0,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 64
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 3,
"cfo_hz": 10,
"seed": 52031,
"amplitude_scale": 0.002,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 74
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 3,
"cfo_hz": 10,
"seed": 52032,
"amplitude_scale": 0.2,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 53
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 3,
"cfo_hz": 10,
"seed": 52033,
"amplitude_scale": 1.0,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 64
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 3,
"cfo_hz": 100,
"seed": 52031,
"amplitude_scale": 0.002,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 76
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 3,
"cfo_hz": 100,
"seed": 52032,
"amplitude_scale": 0.2,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 53
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 3,
"cfo_hz": 100,
"seed": 52033,
"amplitude_scale": 1.0,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 63
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 7,
"cfo_hz": -100,
"seed": 52031,
"amplitude_scale": 0.002,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 50
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 7,
"cfo_hz": -100,
"seed": 52032,
"amplitude_scale": 0.2,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 42
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 7,
"cfo_hz": -100,
"seed": 52033,
"amplitude_scale": 1.0,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 51
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 7,
"cfo_hz": 10,
"seed": 52031,
"amplitude_scale": 0.002,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 50
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 7,
"cfo_hz": 10,
"seed": 52032,
"amplitude_scale": 0.2,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 42
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 7,
"cfo_hz": 10,
"seed": 52033,
"amplitude_scale": 1.0,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 51
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 7,
"cfo_hz": 100,
"seed": 52031,
"amplitude_scale": 0.002,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 48
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 7,
"cfo_hz": 100,
"seed": 52032,
"amplitude_scale": 0.2,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 42
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
},
{
"ebn0_db": 7,
"cfo_hz": 100,
"seed": 52033,
"amplitude_scale": 1.0,
"baseline": {
"accepted": 1,
"payload_match": true,
"reasons": {
"PHY_HEADER_MAGIC_MISMATCH": 44
}
},
"optimized": {
"accepted": 1,
"payload_match": true,
"reasons": {}
},
"lost_baseline_packet": false
}
],
"count": 45,
"baseline_correct": 37,
"optimized_correct": 37,
"lost_baseline_packets": 0,
"rf_tx": 0
}

View File

@@ -0,0 +1,186 @@
{
"seed": 50050,
"description": "Lab051 generator; frozen measured JPEG readiness",
"images": [
{
"image_id": 1,
"bytes": 1740,
"encode_seconds": 0.00024159997701644897,
"sha256": "15723773a8788c9d610c874707ad2d513582322ee1d42009b0b39d3de336e10d"
},
{
"image_id": 2,
"bytes": 1790,
"encode_seconds": 0.000175100052729249,
"sha256": "66404b14ca9e3a6627871ad9d3b5dd4311915d7820a99dfa182fc3a20fbe9d85"
},
{
"image_id": 3,
"bytes": 1821,
"encode_seconds": 0.00018400000408291817,
"sha256": "166d33d72820bf3385e0d30823d991b453147c84ca75f788ab8819ad5e3c3ced"
},
{
"image_id": 4,
"bytes": 1844,
"encode_seconds": 0.0001865000231191516,
"sha256": "38052148ffbc024d2ba28049ea20b1172eebe9c990cb2fd94b25d2eaf9b46584"
},
{
"image_id": 5,
"bytes": 1871,
"encode_seconds": 0.00015390000771731138,
"sha256": "1e135c2854bef0f7dffb84cf8eb71e200c958d7d7426d24d16bb36d471b52c1d"
},
{
"image_id": 6,
"bytes": 1889,
"encode_seconds": 0.00016799999866634607,
"sha256": "b59b7e834374fa00757abcb57282a1b7c46c08b41e0adb1cb5803135b009cb7f"
},
{
"image_id": 7,
"bytes": 1893,
"encode_seconds": 0.00022439996246248484,
"sha256": "8f6452262ea5a82d195de8cd23e3d21ab186ce5a20b031acf8295c30daa98572"
},
{
"image_id": 8,
"bytes": 1877,
"encode_seconds": 0.00017830007709562778,
"sha256": "eb0f69a3e57c1860d98e79591ee9b472ac3a0929883be2a2992397f594d176f4"
},
{
"image_id": 9,
"bytes": 1844,
"encode_seconds": 0.00019099994096904993,
"sha256": "22c41e53d1d086baf886e6f65c011e1360ef26ad7781eced4655664cb7adcf06"
},
{
"image_id": 10,
"bytes": 1826,
"encode_seconds": 0.00017690006643533707,
"sha256": "e907decbbf66cc21c6b6e767b6c9baef856754d3cc95625cca38fd07aa5408c1"
},
{
"image_id": 11,
"bytes": 1878,
"encode_seconds": 0.00019090005662292242,
"sha256": "ab681a9cce96a8aaa71f3a612634748a2df743f1c76092f5a674e0dd36ca7caf"
},
{
"image_id": 12,
"bytes": 1805,
"encode_seconds": 0.00017190002836287022,
"sha256": "9c3d4258ddf1085f8da6ca87b6c3071aa031d58a5c42739a5527a27e6c2f17af"
},
{
"image_id": 13,
"bytes": 1819,
"encode_seconds": 0.0001813999842852354,
"sha256": "6615eb4fe58fb4d0854c3c856e65a0990eba2361fc977c524acf9f0a5ab7accb"
},
{
"image_id": 14,
"bytes": 1866,
"encode_seconds": 0.0002503000432625413,
"sha256": "8fbb4fa0fce1e3deedb3fd838d69835b7670d73d793b792b2ffde69d733bbb40"
},
{
"image_id": 15,
"bytes": 1892,
"encode_seconds": 0.00020739994943141937,
"sha256": "9109553288f7b07311be951bd36cc389034496b6665b874dd92cfea84ac60d13"
},
{
"image_id": 16,
"bytes": 1886,
"encode_seconds": 0.00019369996152818203,
"sha256": "5b264bae760e7103f645f4a4203c435dfb8bd7555ec679b750d4c4cefe642f3a"
},
{
"image_id": 17,
"bytes": 1881,
"encode_seconds": 0.00017360004130750895,
"sha256": "df7d2804878be2b11c0233cee2401504d3545fad2f856fedb5e3aedfc1a0bf36"
},
{
"image_id": 18,
"bytes": 1862,
"encode_seconds": 0.00019259995315223932,
"sha256": "9fc4dded148ed750d53cadf0abe059cb01acab50999ebf9f521b2685c77c3025"
},
{
"image_id": 19,
"bytes": 1838,
"encode_seconds": 0.00017380004283040762,
"sha256": "820d8d0916c53b1937d002e6b0632100b7a2d0a216f1022cdc55ba5fd0844cb5"
},
{
"image_id": 20,
"bytes": 1859,
"encode_seconds": 0.0001799999736249447,
"sha256": "ef9903b6c8d9ab6f572a7fa54af7570f6c321346117f9c4e156e1b71c97c46fd"
},
{
"image_id": 21,
"bytes": 1892,
"encode_seconds": 0.00019259995315223932,
"sha256": "6605b523265e1229959f94fdad35d672037a902e9c7c585e22aafb875fb9e287"
},
{
"image_id": 22,
"bytes": 1783,
"encode_seconds": 0.00022350007202476263,
"sha256": "8cf09da5a66ca3b3b18c1e4db63c8d57ed5044ba6f758368f7bcfb04f86a3839"
},
{
"image_id": 23,
"bytes": 1826,
"encode_seconds": 0.0001976999919861555,
"sha256": "282ee75b1d6c2963fec0ecaed77f5663d9a49bcfa1561c5b0f40ea93089e06f1"
},
{
"image_id": 24,
"bytes": 1870,
"encode_seconds": 0.00019099994096904993,
"sha256": "ba6c6a8aeae49674442d1079d15f4b0fa4202e7cc72cb66d2853c312ced16132"
},
{
"image_id": 25,
"bytes": 1872,
"encode_seconds": 0.0001868000254034996,
"sha256": "bd7841541d47d9a9844e6302825db98f3c72e5258946c3d2d0a2d2b7f0cee92e"
},
{
"image_id": 26,
"bytes": 1872,
"encode_seconds": 0.00018989993259310722,
"sha256": "70cdf837f756084e3d5a6dd438b615d71e669936c4d17c9ed7be88f4b4dcf965"
},
{
"image_id": 27,
"bytes": 1870,
"encode_seconds": 0.0001943999668583274,
"sha256": "a31da69de44bff031631997cc27724025f293528b4d97f2825d34a704ec224af"
},
{
"image_id": 28,
"bytes": 1848,
"encode_seconds": 0.00018319999799132347,
"sha256": "4380eae166bc37a7de7367642559b948cf1cc77939d1c84406de7cff372b6a76"
},
{
"image_id": 29,
"bytes": 1871,
"encode_seconds": 0.00016330007929354906,
"sha256": "c2e1a913dc48d622e1097f31543208ce610ce42d266857d8a76961f143dd9a4b"
},
{
"image_id": 30,
"bytes": 1869,
"encode_seconds": 0.0001724000321701169,
"sha256": "bbbd15eac04b06ea05ec548f4c95b577b8ffc280c00fc473fb2a6914c40f7939"
}
]
}

View File

@@ -0,0 +1,107 @@
{
"classification": "strict PHY/FEC/CRC verdict first; no diagnostic feedback into RX",
"baseline_true_unique_starts": 240,
"optimized_true_unique_starts": 240,
"missing_true_starts": [],
"unexpected_true_starts": [],
"true_main_peak_scores": {
"count": 240,
"min": 0.8300548791885376,
"median": 0.8416910767555237,
"mad": 0.0021253228187561035,
"p90": 0.8455655992031097,
"p99": 0.8483891654014587,
"p999": 0.8496067104935646,
"max": 0.8499454259872437
},
"false_strict_scores": {
"count": 146765,
"min": 0.03096448816359043,
"median": 0.03546393662691116,
"mad": 0.0029666051268577576,
"p90": 0.04409027695655823,
"p99": 0.0558026286959648,
"p999": 0.5325132031440795,
"max": 0.8059508800506592
},
"baseline_false_refined_positions": 11387,
"baseline_false_repeated_refinements": 135378,
"noise_only_blocks": 357,
"noise_block_statistics": {
"median": {
"count": 357,
"min": 0.006450756685808301,
"median": 0.016353150829672813,
"mad": 0.00045966170728206635,
"p90": 0.016945500299334525,
"p99": 0.017328095547854902,
"p999": 0.017409235779196026,
"max": 0.017421899363398552
},
"mad": {
"count": 357,
"min": 0.002472435589879751,
"median": 0.006183675490319729,
"mad": 0.0002206573262810707,
"p90": 0.006455647945404053,
"p99": 0.006683134622871876,
"p999": 0.006718997411429882,
"max": 0.006726322695612907
},
"p90": {
"count": 357,
"min": 0.011724081635475166,
"median": 0.029762874729931357,
"mad": 0.0008111353963613475,
"p90": 0.0307584797218442,
"p99": 0.03153721953928471,
"p999": 0.03181600074619056,
"max": 0.03183051086962224
},
"p99": {
"count": 357,
"min": 0.016392295099794868,
"median": 0.041875139921903615,
"mad": 0.001360640600323683,
"p90": 0.043799061246216305,
"p99": 0.04486697037369015,
"p999": 0.045995346079021705,
"max": 0.04610396962612869
},
"p999": {
"count": 357,
"min": 0.019085338560864334,
"median": 0.05054646224901104,
"mad": 0.002620417140424082,
"p90": 0.05440113909915092,
"p99": 0.05857364904597424,
"p999": 0.06542779265359067,
"max": 0.06649338830262444
}
},
"split_minimum_true_strict": {
"count": 240,
"min": 0.8250381350517273,
"median": 0.8373404443264008,
"mad": 0.0029025375843048096,
"p90": 0.8425169467926026,
"p99": 0.8459141814708709,
"p999": 0.8461976568698883,
"max": 0.8462259769439697
},
"split_minimum_other_candidates": {
"count": 392545,
"min": 3.158521940349601e-05,
"median": 0.02823474630713463,
"mad": 0.008037906140089035,
"p90": 0.04402520880103111,
"p99": 0.08912204891443251,
"p999": 0.662676521778107,
"max": 0.7688561081886292
},
"strict_false_reduction": 9784.333333333334,
"strict_false_reduction_fraction": 0.9998977957960004,
"first_stage_threshold_unchanged": 0.030964473262429237,
"part_threshold": 0.08474038063392896,
"rf_tx": 0
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -240,6 +240,114 @@
- **Далее:** программный 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.
## Lab051
- **Цель:** алгоритмически ускорить независимый блочный RX на 500 ксимв/с,
16 отсчётов/символ, Fs=8 Мвыб/с; проверить 5 свежих JPEG C в секунду.
- **База:** Lab050 закреплена разрешённым коммитом
`60691a09f193f36983c7174c5252ed0897c559e9`.
- **Профиль:** главная стоимость Lab050 — fallback timing, 41,833 с на ложных
кандидатах. Полный диапазон 1601 смещения сохранён и вычисляется через FFT
и накопленные энергии, с исходным выбором на неразделимых пиках.
- **Изменения:** общий front-end, точное повторное использование децимации,
кэш результатов уточнённого окна, короткий probe до PHY PASS, предвычисленные
метрики прежнего Viterbi. Порог, PHY/FEC/CRC и protocol не менялись.
- **Короткий контроль:** 24/24 пакета, 3/3 точных JPEG; медиана трёх повторов
1,144176 с вместо исторических 55,157553 с. Processing/RF=3,534193.
- **Основной поток:** 30 JPEG за 6 с, 240/240 пакетов, SHA MATCH 30/30.
Обработка 6,044416 с IQ заняла 38,630132 с, processing/RF=6,391045.
При MAX_VIDEO_AGE=200 мс все 30 JPEG просрочены; FRESH DISPLAY FPS=0.
Цель реального времени не достигнута, включая минимальную свежесть >0.
- **Память:** пик удерживаемого IQ 3,031 МиБ, RSS процесса в опыте измерен.
Тест 33,55 с не показывает роста IQ/кэша с длительностью. Это не готовая live-
очередь: непрерывный источник потребовал бы около 2,09 ГБ внешнего backlog.
- **Регрессия:** сохранённая Lab047-запись 20 ксимв/с штатным Lab048:
36/36, CRC PASS, JPEG SHA MATCH; 19 ошибок до FEC и 0 после.
- **Код:** [приёмник](../experiments/lab051_streaming_receiver.py),
[профилировщик](../experiments/lab051_receiver_profile.py),
[полный поток](../experiments/lab051_receiver_performance.py),
[38 тестов](../tests/test_lab051_streaming_receiver.py).
- **Отчёт:** [состояние](lab051_current_state.md),
[измеренный поток](../data/processed/lab051/stream.json). Журнал: запись 040.
- **Далее:** отдельное программное исследование стоимости уникальных фоновых
кандидатов в полном потоке 5 кадров/с с тем же PHY/FEC/CRC.
- **RF / Git:** RF TX=0; Lab051 git add/commit и push не выполнялись. STOP.
## Lab052
- **Цель:** снизить число ложных строгих проверок на 500 ксимв/с при сохранении
BPSK, PRBS7, PHY/FEC/CRC, фрагментации и всех принимаемых настоящих пакетов.
- **База:** Lab051 закреплена разрешённым коммитом
`9f33607d3ae82992c595de37dd64e99d7e4dea73`; 13 файлов, без push.
- **Профиль:** 1675842 локальных пика, 392785 кандидатов первого каскада;
прежний RX выполнил 147005 строгих проверок: 146765 ложных и 240 истинных.
Исторические 147082 Lab051 означали все кандидаты, а не только ложные.
- **Алгоритм:** общий дешёвый расчёт корреляций двух половин преамбулы,
локальная нормировка каждой половины; ограниченная полная ширина группы
20480 отсчётов, максимум первым, альтернативы сохраняются после FAIL.
Первый порог прежний; VALID только после штатного PHY/FEC/CRC.
- **Отвергнутый вариант:** безусловное подавление соседних меньших пиков
теряло слабый пакет рядом с сильной ложной преамбулой. Контрпример adversary
исправлен и закреплён тестами, включая границы блоков и плохую CRC.
- **Кандидаты:** 388407 отклонены дешёво, 384 группы, 243 представителя +12
альтернативных строгих проверок; 15 ложных, 240 настоящих, 0 потерь.
Сокращение ложных строгих проверок 9784,33×. Все 240 уникальных стартов совпали.
- **Полный поток:** C/5 JPEG в секунду, 30 изображений, 240/240 пакетов,
30/30 JPEG SHA MATCH. Медианный полный проход 7,369795 с /6,044416 с IQ
=1,219273. Возраст 234,520/834,077/1549,595 мс, все 30 просрочены,
**FRESH DISPLAY FPS=0; REAL-TIME CAPABLE=NO**.
- **Остаточная нагрузка:** настоящие пакеты 3,996005 с, общая децимация/
корреляция 1,790240 с. Все ложные проверки вместе лишь 28,884 мс.
- **Память:** удерживаемый IQ 3,031 МиБ, инженерная оценка массивов 136,161 МиБ,
измеренный process RSS peak 160,984 МиБ. Шумовой тест 16,78 с не накапливает
IQ/кандидатов; внешний live-backlog остаётся нереализованным.
- **Регрессии:** 37/45 слабых сценариев у обоих приёмников, 0 дополнительных
потерь. Старая запись 20 ксимв/с: 36/36, CRC PASS, JPEG SHA MATCH.
- **Код:** [обнаружитель](../experiments/lab052_candidate_detection.py),
[опыты](../experiments/lab052_detector_study.py),
[статистика](../experiments/lab052_detection_statistics.py),
[30 тестов](../tests/test_lab052_candidate_detection.py).
- **Отчёт:** [состояние](lab052_current_state.md),
[основной поток](../data/processed/lab052/stream.json). Журнал: запись 041.
- **Далее:** отдельное исследование истинных пакетов, общей обработки IQ и
задержки JPEG-пачки; текущий этап завершить на программных результатах.
- **RF / Git:** RF TX=0; Lab052 git add/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

@@ -0,0 +1,281 @@
# Lab051 — вычислительная архитектура потокового BPSK-приёмника
Дата: 8 сентября 2026 года. SOFTWARE ONLY. RF TX = 0.
## Результат и граница вывода
**Программный эксперимент завершён; цели реального времени и свежести не достигнуты.**
В полном шестисекундном потоке получены 240/240 физических пакетов и 30/30 JPEG
с точным содержимым. Обработка 6,044416 с синтетического IQ заняла **38,630132 с**,
отношение **6,391045**. При MAX_VIDEO_AGE=200 мс **FRESH DISPLAY FPS=0**;
не выполнен даже минимальный критерий >0.
Короткий шумный сценарий Lab050 ускорен примерно в **48,21 раза** относительно
исторического замера: 55,157553 с → медиана **1,144176 с** трёх проходов.
Его processing/RF = **3,534193**, поэтому и этот вариант не работает в реальном времени.
Среднее успешного кандидата — **17,7675 мс**; полная стоимость короткого опыта —
**47,674 мс/пакет**, а длинного потока — **160,959 мс/пакет**.
Последние величины включают обработку ложных кандидатов и фона.
Модуляция BPSK, PRBS7, PHY-заголовок, фрагментация, интерливер, FEC R=1/2 K=7,
171/133 octal, soft-метрика и CRC не менялись. GPU и multiprocessing не использовались.
Новых зависимостей нет. Аппаратура не перечислялась и не запускалась.
## Git и аудит до реализации
Исходный main: `cdf5396e807b83f82a264632dc46f71428fcd616`.
Проверены HEAD/status/diff stat/diff check, затем показан точный staged scope.
По прямому разрешению пункта 0 задания создан коммит:
`60691a09f193f36983c7174c5252ed0897c559e9`
`Lab050: model fresh-frame SDR video stream`
В него вошли ровно девять файлов: PROJECT_LOG.md, docs/LABS_INDEX.md,
experiments/lab046c_offline_recovery.py, protocol/video_frame_scheduler.py,
docs/lab050_current_state.md, experiments/lab050_streaming_receiver.py,
experiments/lab050_video_stream.py, tests/test_lab050_video_stream.py,
data/processed/lab050/receiver_benchmarks.json. 1769 добавлений, 12 удалений.
Большой result.json, профили JPEG и посторонние локальные файлы не добавлялись.
Lab051 начата от этого HEAD. Изучены Lab046B/C, Lab047, Lab048, Lab050,
Lab043/044/045 и свёрточный декодер. Ядро protocol в Lab051 не менялось.
Планировщик LATEST_ONLY и ObjectAssembler переиспользованы. Новая видеочередь не создана.
## Профиль до оптимизации
Рабочая точка: 500000 символов/с, Fs=8000000 отсчётов/с, 16 отсчётов/символ.
Профиль C: 128×96 RGB, JPEG quality 25. Короткий контроль: три изображения,
24 пакета, seed=50050, coded-bit Eb/N0=7 дБ, CFO=+10 Гц, фаза 0,73 рад,
чередование полярности, исходное CS8-квантование. Генератор точно прежний, из Lab050.
Путь кандидата: IQ → грубая нормированная корреляция → уточнение старта →
согласованный RRC-фильтр → timing → оценка частотного рассогласования CFO →
компенсация CFO и фазы → мягкие решения → PHY header → deinterleave →
soft Viterbi → protocol parser → CRC. Только полный PHY/FEC/CRC PASS выдаёт пакет.
TX-границы, ожидаемое число кадров и содержимое не входят в RX API.
Историческая база Lab050: 55,157553 / 0,323745 = 170,373647.
Свежий инструментированный baseline Lab051: **49,614327 с**, processing/RF=153,251430.
Настольная нагрузка и инструментация влияют на время; эти замеры не подменяют друг друга.
Все 2374 кандидата: 24 valid, 2350 false. Средние времена — **72,967 / 20,099 мс**.
**Главная причина замедления — fallback timing.** Номинальное положение равно 416,
а диапазон остался −800…+800 отсчётов. Начало выходит за ноль, и прежний быстрый
путь возвращается к Python-перебору 1601 смещения. У ложных кандидатов timing
занял 41,833 с из 47,232 с строгой обработки. Их matched filter — 0,934 с,
уточнение — 2,797 с. Прежняя реализация уже останавливала кандидата при PHY FAIL:
тело, Viterbi и CRC на таких входах не запускались.
Для преамбулы и заголовка достаточно 23456 входных отсчётов и 23616 отсчётов
после RRC. Полный физический пакет с payload 256 байт требует 92256 входных
отсчётов. Длина тела определяется только проверенным PHY-заголовком.
## Изменения вычислений
1. Децимация сохраняет внутреннюю часть конечного импульсного отклика FIR.
При сдвиге буфера заново считаются левый нулевой край и новые правые отсчёты.
Коэффициенты resample_poly/kaiser(8) прежние; сравнение с исходной функцией
побитно точное. Среднее по всему прежнему окну сохраняется. Корреляция считается
только для принадлежащих блоку стартов плюс правый сосед максимума.
2. Корреляция известного prefix для уточнения считается один раз для общего участка.
Кандидаты используют срезы, с прежней поправкой локального DC.
Постоянный probe и неразделимые FFT-пики сохраняют исходный выбор первого
максимума; при необходимости вызывается старое уточнение.
3. Компенсация известного +50 кГц и RRC выполняются для общего участка.
Локальное вычитание среднего и оба нулевых края изолированного окна
восстановлены отдельно. Без этих поправок общая фильтрация меняла бы алгоритм.
Тело за пределами общего участка фильтруется локально только после PHY PASS.
4. Извлечение символов кэшируется по уточнённому положению и длине внутри одного блока.
Усечённое окно не подменяется ранее сохранённым полным окном.
Кэш уничтожается вместе с блоком; история сессии не сохраняется.
5. Timing сохраняет весь прежний диапазон 1601 смещения. Корреляция с разреженной
известной преамбулой и разности накопленных энергий заменяют Python-перебор.
Тело за последним нужным отсчётом не участвует в этом расчёте. При пиках,
неразделимых с точностью float32, возвращается исходный расчёт и его tie-правило.
6. CFO по прежним преамбульным символам не изменён. Векторы переноса кэшируются;
вектор остаточной частоты строится на символах локального окна. Gain/фаза для
probe и полного кадра не объединены: у них различается локальное среднее IQ.
7. Viterbi после устранения главного повтора остался существенной долей принятого
пакета. Предвычислены четыре различные пары евклидовых branch metrics;
две ветви сравниваются непосредственно, с прежним выбором branch 0 при равенстве.
Решётка, старт/финиш 0, шесть хвостовых битов и декодированные биты сохранены.
Дополнительный порог преамбулы/новый критерий отсева не введён. Фиксированный
порог первого каскада **0.030964473262429237** сохранён. В коротком опыте совпали
число кандидатов и все причины отказа с исходным RX. Параллелизм и CUDA не добавлялись.
## Профиль стадий
Ячейка: **число вызовов / суммарные миллисекунды** по соответствующей группе.
Это не длительность одного вызова. Общая работа front-end учитывается в том
кандидате, который впервые запросил её. Фаза показана без вложенного CFO;
время body pipeline не складывается повторно со временем Viterbi/CRC.
| Стадия | Baseline valid (24) | Optimized valid (24) | Baseline false (2350) | Optimized false (2350) |
|---|---:|---:|---:|---:|
| Точное уточнение | 24 / 28.974 | 24 / 93.878 | 2350 / 2797.404 | 2350 / 119.743 |
| Согласованная RRC-фильтрация | 48 / 190.656 | 48 / 102.875 | 2336 / 933.965 | 174 / 26.502 |
| Timing: весь диапазон | 48 / 914.632 | 48 / 43.535 | 2336 / 41833.166 | 174 / 138.588 |
| CFO и комплексное усиление | 48 / 10.838 | 48 / 8.442 | 2336 / 485.772 | 174 / 26.385 |
| Фаза/полярность и диагностика | 48 / 8.994 | 48 / 8.324 | 2336 / 424.756 | 174 / 28.438 |
| PHY header | 48 / 1.711 | 48 / 1.452 | 2298 / 67.136 | 2298 / 32.044 |
| Viterbi | 24 / 484.381 | 24 / 132.119 | 0 / 0 | 0 / 0 |
| Protocol parser / CRC | 24 / 0.270 | 24 / 0.211 | 0 / 0 | 0 / 0 |
Грубая локализация — общая стадия: 36 вызовов, baseline 498,040 мс,
optimized **89,183 мс**. На один false candidate среднее **0,187317 мс**,
медиана **0,060500 мс**; это включает cache hits. Холодное новое окно дороже.
## Массивы и память
Число всех аллокаций внутри NumPy/SciPy C не измерено. Line tracer наблюдает
массивы в локальных переменных проектного Python: число ниже — **нижняя граница**,
включающая views. Владельцы данных выделены отдельно. Временные выражения и
внутренние C-массивы не перечислены. Отдельный tracemalloc-проход измеряет пик
отслеживаемой памяти. Эти медленные проходы не включены в CPU benchmark.
| Кандидат / алгоритм | Наблюдаемые ndarray | Из них владельцы | Суммарные байты наблюдаемых владельцев | Пик tracemalloc, байт |
|---|---:|---:|---:|---:|
| false / baseline | 2888 | 61 | 6612268 | 2104129 |
| false / optimized | 76 | 61 | 5906101 | 5524392 |
| valid / baseline | 14361 | 8704 | 16681890 | 5885299 |
| valid / optimized | 6594 | 6560 | 12345244 | 7771675 |
Пик холодного кандидата вырос из-за общих промежуточных данных: ускорение не
объявляется уменьшением каждого временного массива. В рабочем потоке:
- Пик удерживаемых IQ: **3177728 байт = 3,031 МиБ**.
- Проверяемый предел IQ: 3398912 байт; кэш децимации хранится отдельно.
- Инженерная оценка рабочих массивов: **136,161 МиБ**. Использован запас прежних
22 размеров IQ-окна плюс 20 размеров на новые массивы и кэши. Это оценка для
данного опыта, не доказанный универсальный предел всех аллокаций и не RSS.
- Windows process RSS после потока: **154,105 МиБ**; peak RSS процесса —
**178,004 МиБ**. В него входят Python, библиотеки, генератор, предыдущие замеры и аллокатор.
- Текущий объект ограничен 65536 байтами / 269 фрагментами; C фактически ≤1893 байт.
Длинный тест: **4096 блоков, 33,554432 с модельного времени**. Фон нулевой;
шум отдельно проверен в основном опыте. После прогрева удерживаются 2653440 байт
IQ и 397216 байт децимированных данных; значения не растут.
RSS контрольных точек: **156,199–156,250 МиБ**, без роста вместе с длительностью.
После finish эти буферы освобождены. История JSON относится к отчёту, не к RX.
## Непрерывный поток 5 изображений/с
30 воспроизводимых сдвигов исходного data/raw/lab011_scene.jpg, профиль C,
1740–1893 байта. Это искусственная последовательность, не видео камеры.
Видеопланировщик LATEST_ONLY использует время capture и завершает активное JPEG.
Передатчик знает нижнюю задержку радио/буфера; он не получает будущую стоимость
CPU или результаты приёма. Приёмник получает лишь IQ-блоки по 65536 отсчётов.
Шум, фаза, CFO и полярность заданы, но готовность JPEG берётся из измеренного encode.
Поэтому точное размещение пакетов и число фоновых кандидатов длинного опыта могут
немного меняться между запусками. Короткий baseline использует точно прежний
детерминированный сценарий Lab050.
Время CPU-работника для каждого блока:
`start=max(previous_end, block_arrival); end=start+measured_push_time`.
Сборка/JPEG decode добавляют своё измеренное время. Генерация TX/IQ, SHA-сравнение
с передатчиком и запись JSON исключены. Длительность RX не заменена нулём.
Все данные реально прошли независимый PHY/FEC/CRC и JPEG decode.
| Метрика | Итог |
|---|---:|
| INPUT FPS / FRAMES | 5 / 30 |
| TX IMAGES / PHYSICAL PACKETS | 30 / 240 |
| DROPPED BEFORE TX | 0 |
| MAX WAITING VIDEO FRAMES | 1 |
| RX PHYSICAL PACKETS / IMAGES | 240 / 30 |
| JPEG SHA MATCH | 30 / 30 |
| DROPPED STALE AT DISPLAY | 30 |
| DISPLAYED / FRESH DISPLAYED | 0 / 0 |
| RAW RECEIVED FPS в первых 6 с CPU-шкалы | 0.833333 |
| RAW DISPLAY FPS / FRESH DISPLAY FPS | 0 / 0 |
| Возраст декодированных JPEG: min / median / max | 276.515 / 15691.203 / 31150.009 мс |
| MAX_VIDEO_AGE | 200 мс |
| Строгих кандидатов | 147082 |
| Полное время обработки / IQ | 38.630132 / 6.044416 с |
| PROCESSING / RF TIME | 6.391045 |
Возраст относится к **декодированным, затем отброшенным JPEG**. Показанных изображений нет.
RAW DISPLAY и FRESH DISPLAY совпадают, потому что политика не выводит просроченные
изображения; RAW RECEIVED приведён отдельно.
Физическое время одного JPEG C: 8×5766/500000 = **92,256 мс**. Очередь ожидающих
JPEG в этом режиме не задерживает готовые изображения. Время RX за опыт:
38,619085 с; сборка 5,451 мс; JPEG decode 5,596 мс суммарно на 30 JPEG.
RF и CPU перекрываются по событийной шкале: эти суммы нельзя просто сложить
и назвать измеренной задержкой камеры→экрана. Camera capture latency и display
latency **UNKNOWN**; экран в отчётной модели мгновенный.
Capture lookup используется только **после** независимой сборки JPEG для метрики
возраста/решения модели вывода. В существующем fragment format нет времени съёмки.
Готовый независимый аппаратный контроль возраста этим не доказан; формат в Lab051
не расширялся. ID монотонны в одной конечной сессии; wrap/restart не реализованы.
Внешняя IQ-очередь также не реализована. Офлайн-источник подаёт следующий блок
по запросу и физически не хранит многосекундный backlog. Если бы вход шёл непрерывно,
максимальное отставание **32,632404 с** потребовало бы около **2088473848 байт**
дополнительного complex64 IQ. Поэтому bounded RX state нельзя выдавать за готовую
ограниченную live-систему: при таких затратах потребуются потери/переполнение.
REAL-TIME CAPABLE = NO.
В длительном опыте встречено значительно больше фоновых кандидатов, чем в коротком:
147082 против 2374. Кэш дал 135057 повторных использований, но осталось 12239 новых
окон синхронизации. Следующее ограничение — стоимость этих фоновых событий.
Одного ускорения Viterbi недостаточно; подгонять отсев по TX запрещено.
## Регрессии и независимая проверка
38 целевых тестов: весь диапазон timing, ненулевые и нулевые ties, локальные
DC/края общего RRC, точная децимация при движении окна, Viterbi noisy/tie/invalid,
совпадение старого/нового блочного приёма, усечение, буферы и finally.
Verifier нашёл два случая округления FFT (timing и refine); оба исправлены,
воспроизведены повторно и закреплены тестами. Adversary отдельно проверил
malformed PHY (0 body/Viterbi), дубликаты и старые ID, лимиты объекта,
усечение и восстановление глобальных подмен.
Сохранённая Lab047-запись 20 ксимв/с повторно обработана штатным Lab048:
**36/36, FEC/CRC PASS, JPEG SHA MATCH**, 19 ошибок до FEC, 0 после.
Исходный путь и SHA JPEG сохранены в regression20k/result.json.
Старый режим работает прежним декодером; адаптер Lab051 принимает только 500 ксимв/с
и не подменяет аппаратную проверку этого режима.
Итог: quick_gate PASS — 688 тестов, 17 функциональных проверок, 13 штатных пропусков.
Полный pytest — 688 passed за 72,92 с, 0 failed; одно предупреждение доступа к
`.pytest_cache`. git diff --check PASS. Подробности — в записи 040 журнала.
Управление ELRS/CRSF, локальная защита и ядро protocol не менялись.
## Файлы и воспроизведение
- [Потоковый RX](../experiments/lab051_streaming_receiver.py)
- [Профилировщик](../experiments/lab051_receiver_profile.py)
- [Сценарии и событийная шкала](../experiments/lab051_receiver_performance.py)
- [Тесты](../tests/test_lab051_streaming_receiver.py)
- [Baseline](../data/processed/lab051/baseline_profile.json)
- [Три коротких замера и профиль](../data/processed/lab051/benchmark.json)
- [Основной поток и все 739 блоков](../data/processed/lab051/stream.json)
- [Длинный тест памяти](../data/processed/lab051/memory.json)
- [Наблюдение массивов](../data/processed/lab051/replay.json)
- [20 ксимв/с](../data/processed/lab051/regression20k/result.json)
```powershell
python experiments/lab051_receiver_profile.py
python experiments/lab051_receiver_performance.py --stage all
python -m pytest tests/test_lab051_streaming_receiver.py -q -p no:cacheprovider
python tools/quick_gate.py
python -m pytest tests/ -q
git diff --check
```
Старая запись:
```powershell
python experiments/lab048_receiver_performance.py --run-saved-capture --capture 'C:\Users\user\Documents\SDR_Rover_Lab047_raw\20260903_115747\rx_stream.cs8' --original-result 'C:\Users\user\Documents\SDR_Rover_Lab047_raw\20260903_115747\independent_result.json' --tx-image data/processed/lab047/tx_image.jpg --output-directory data/processed/lab051/regression20k
```
Локальные replay .npy — ограниченные окна, не полная IQ-запись; в Git не добавлены.
Новый полный CS8/IQ файл не создавался. Lab051 git add/commit и push не выполнялись.
Следующий рекомендуемый этап — отдельное программное исследование стоимости
уникальных фоновых кандидатов в полном режиме 5 кадров/с с тем же PHY/FEC/CRC.
RF, QPSK, новый FEC/кодек, 180 МГц и аппаратные 500 ксимв/с не выполнялись. STOP.

View File

@@ -0,0 +1,390 @@
# Lab052 — снижение числа ложных кандидатов при 500 ксимв/с
Дата: 8 сентября 2026 года. SOFTWARE ONLY. RF TX = 0.
## Результат
Ложные строгие проверки сокращены с **146765 до 15**, в **9784,33 раза**
(99,9898%). Сохранены все **240 уникальных физических пакетов**, получены
**30/30 JPEG с точными байтами**. Программное исследование завершено.
**Реальное время и 5 свежих изображений/с не достигнуты.** Медианный из трёх
полных проходов: **7,369795 с обработки / 6,044416 с IQ = 1,219273**.
Возраст декодированных изображений: **234,520 / 834,077 / 1549,595 мс**
(min / median / max). При MAX_VIDEO_AGE=200 мс все 30 просрочены,
**DISPLAYED=FRESH DISPLAYED=0, FRESH DISPLAY FPS=0**.
В этих условиях ложные проверки уже не определяют скорость: они занимают
28,884 мс за весь опыт, 0,392% полной обработки. Дальнейшее удаление только
этих проверок не закроет оставшееся отставание.
Рабочая точка: BPSK, 500000 символов/с, Fs=8000000 отсчётов/с, 16 отсчётов/символ.
JPEG C: 128×96 RGB, quality 25, 1740–1893 байта. PRBS7, PHY, FEC, CRC,
интерливер, формат пакета и фрагмента не менялись. Файлы protocol не изменены.
Управление RadioMaster → ExpressLRS → штатный ELRS RX → CRSF → контроллер
остаётся отдельным физическим каналом; в SDR-поток управление не добавлялось.
Временные подмены старых функций рассчитаны на один последовательный RX-worker;
потокобезопасность и одновременная работа нескольких таких приёмников не реализованы.
## Git и аудит
Проверены root/main/HEAD/status, diff stat/check, отчёт Lab051, запись 040,
индекс лабораторных, три новых вычислительных модуля, тесты и итоговые JSON.
До новой реализации quick_gate PASS; pytest **688 passed, 0 failed**.
Verifier независимо подтвердил состав и согласованность данных.
По прямому разрешению пункта 0 задания показаны staged stat/status и создан:
`9f33607d3ae82992c595de37dd64e99d7e4dea73`
`Lab051: accelerate 500 ksymbol/s streaming receiver`
Ровно 13 файлов, 9695 добавлений: код/тесты/отчёт Lab051, журнал и индекс,
baseline_profile.json, benchmark.json, stream.json, memory.json, replay.json,
regression20k/result.json. IQ-окна, промежуточные замеры, JPEG и посторонние
файлы не добавлялись. Push не выполнялся. Это committed HEAD всей Lab052.
## Что означают исходные 147 тысяч
В исходном JSON Lab051 **147082 — все строгие кандидаты**, включая 240 принятых;
ложных было **146842**. Число 12239 — новые soft/timing-окна, включая настоящие.
Исторический processing/RF=6,391045, fresh FPS=0. Называть 147082 ложными неверно.
Lab051 не сохраняла измеренные encode_seconds каждого JPEG. Её длинная временная
сетка немного менялась между запусками. Поэтому в Lab052 тот же генератор,
noise seed=50050, coded-bit Eb/N0=7 дБ, CFO=+10 Гц, фаза 0,73, чередование
полярности и CS8-квантование используют **однократно измеренную и сохранённую
готовность JPEG** в source_fixture.json. Проверяются SHA всех 30 JPEG.
Все новые варианты получают одну и ту же последовательность IQ; сравнение
потерь и кандидатов ниже относится к этой общей сетке. Историческая сетка
Lab051 не объявляется побитно воспроизведённой.
Свежий профиль прежнего Lab051, после независимых решений:
| Метрика | Значение |
|---|---:|
| IQ / Fs | 6,044416 с / 8 Мвыб/с |
| TOTAL LOCAL PEAKS | 1675842 |
| FIRST-STAGE CANDIDATES | 392785 |
| STRICT CANDIDATES | 147005 |
| TRUE CANDIDATES | 240 |
| FALSE CANDIDATES | 146765 |
| CANDIDATES / RF SECOND | 24320,795 |
| FALSE CANDIDATES / RF SECOND | 24281,089 |
| Уникальных уточнённых позиций ложных кандидатов | 11387 |
| Повторных уточнённых позиций | 135378 |
Отдельный свежий проход старого Lab051 **без наблюдателя**, на этой же
зафиксированной сетке: **41,633312 с, processing/RF=6,887897**, 240/240 пакетов
и 30/30 точных JPEG. Сравнение с итоговыми 7,369795 с даёт около **5,65×**
ускорения всего приёмника. Относительно исторических 38,630132 с — **5,24×**.
Инструментированный профиль и чистый контроль хранятся раздельно; настольная
нагрузка и прогрев влияют на временные числа.
Сначала детектор получает каждый локальный максимум выше низкого фиксированного
порога. Только затем пропускает область уже принятого пакета. На продолжительном
шуме число проб растёт с числом входных отсчётов. Нормировка на энергию означает,
что малая абсолютная мощность шума сама по себе не уменьшает корреляционную
статистику. Более 135 тысяч проверок вновь приходят к уже встречавшимся позициям.
PRBS7 повторяется через 127 символов, то есть через 2032 отсчёта в рабочем режиме.
Преамбула содержит 1280 символов. Поэтому рядом с ней возможны сильные боковые
корреляционные пики, помимо случайных шумовых максимумов. Только проверка
структуры и последующий PHY/FEC/CRC различают их надёжнее одного большого пика.
## Распределения и фон
Истинные главные пики определены после CRC PASS: берётся максимум в прежней
области уточнения около восстановленного старта. Это диагностическая метрика,
не дополнительный вход алгоритма. Числа истинных кадров также сверены по
множеству всех 240 уникальных уточнённых стартов, а не только по счётчику.
| Корреляционная статистика | min | median | p99 | max |
|---|---:|---:|---:|---:|
| Главный пик настоящей преамбулы, 240 | 0,830055 | 0,841691 | 0,848389 | 0,849945 |
| Ложные строгие кандидаты, 146765 | 0,030964 | 0,035464 | 0,055803 | 0,805951 |
| Меньшая корреляция половин у принятых кандидатов | 0,825038 | 0,837340 | 0,845914 | 0,846226 |
Отдельно после приёма выделены 357 блоков, чьи корреляционные окна не пересекают
ни одного декодированного физического кадра. Медиана блочных медиан =0,016353,
медиана MAD =0,006184, медиана квантилей 99% =0,041875.
Диапазон медиан 0,006451–0,017422 показывает, что фон не идеально стационарен.
MAD — медиана абсолютного отклонения от медианы; здесь это мера разброса.
Полные min/median/MAD/p90/p99/p999/max по блокам сохранены в JSON.
Адаптацию порога по median/MAD рассмотрели, но не выбрали: она не устраняет
повторные структурные пики и потребовала бы отдельно доказывать чувствительность
при смене фона. Основное правило использует **локальную энергию каждой половины**
наблюдаемого IQ. Глобальный median/second или TX reference в решении не участвуют.
На меняющемся фоне и в слабых положительных регрессиях дополнительные потери
не наблюдались; универсальная чувствительность при любом нестационарном канале
этим конечным исследованием не доказана.
![Распределения обнаружителя](../data/processed/lab052/detector_distributions.png)
## Выбранное правило
1. Прежняя грубая PRBS7-корреляция на двух отсчётах/символ и прежний первый
порог **0.030964473262429237**. Все локальные пики считаются одинаково с baseline.
2. На уже децимированном общем входе блока считаются корреляции двух половин
опорной формы. Полная форма содержит 2644 отсчёта, каждая половина — 1322.
Для каждой позиции обе величины нормируются **своими** энергиями IQ и опоры.
Требуются обе корреляции ≥**0.08474038063392896**. Одиночная сильная половина
этого условия не выполняет. Неизвестная фаза/полярность сохраняется через
модуль; согласование фаз половин, сужающее CFO, не вводилось.
3. Прошедшие пики объединяются в группы с полной шириной ≤20480 отсчётов
**от первого пика**, без продления по цепочке следующих соседей. На границе
блока группа обрывается: это может добавить работу, но не скрывает следующий пик.
4. Представитель — максимум нормированной корреляции; при равенстве правый пик.
Если он не проходит строгий тракт, проверяется следующая альтернатива группы.
Только реальный CRC PASS разрешает прежний пропуск принятого физического кадра.
5. VALID остаётся исключительно прежним **PHY → FEC → CRC PASS**.
Корреляции частей — общие FFT на блок, не новое timing-окно для каждого пика.
Это дополнительные согласованные фильтры частей преамбулы на децимированном
сигнале. Повторно использовать полный RRC-выход для предварительного решения
не потребовалось: его создание на шумовых блоках дороже этой проверки.
Полный RRC/timing и все 1601 прежних смещения остались в строгом тракте.
Порог половины выбран до положительных сравнений из номинальной шумовой модели
`|rho|² ~ Beta(1,N−1)`, N=640, alpha=0,01 на часть:
`sqrt(1−alpha**(1/(N−1)))`. Это инженерная исходная настройка, **не** доказанная
вероятность ложного принятия после фильтрации и выбора максимумов. Корреляция
отсчётов и отбор пиков нарушают независимость; фактические отказы измерены отдельно.
Ни порог, ни ширина не выбираются по TX offsets.
Обоснование ширины для двух разных последовательных физических кадров:
`MIN_FRAME_SAMPLES=(1280+144+2*(12*8+6)+32+10)*16=26720`.
Точность прежнего локального уточнения ±2000 плюс сетка децимации 8:
`20480 < 26720−2*2008 = 22704`.
Поэтому два центральных кандидата неперекрывающихся минимальных кадров,
локализованных в этом диапазоне, не попадают в одну группу. Это обоснование
для последовательного потока, не обещание разрешить любые радиоколлизии.
## Почему первый вариант был отвергнут
Изначальное подавление всех меньших пиков в радиусе одной преамбулы дало
0 ложных строгих проверок на основном опыте. Adversary воспроизвёл потерю:
ложная сильная преамбула на отсчёте 10000 без правильного заголовка подавляла
настоящий слабый пакет на 30000. Lab051 его принимала, новый вариант — нет.
Одного обоснования минимальной дистанцией двух **настоящих** кадров недостаточно
для случая сильного ложного пика. Этот вариант **отвергнут**.
Исправление: дешёвая проверка всех локальных кандидатов до выбора представителя,
ограниченная полная ширина группы и сохранение альтернатив после FAIL.
Контрпример, его сдвиги через границу IQ-блока и сильный пакет с плохой CRC
перепроверены независимо. Корректный слабый пакет теперь сохраняется.
Итог Lab052 — **15**, а не предварительные 0 ложных проверок.
## Сравнение числа частей
Во всех четырёх вариантах сохранены те же 240 уникальных стартов и 30 точных JPEG.
Наблюдатель собирал полные распределения; время этих проходов не является
основным измерением производительности.
| Правило | Порог части | Ложные строгие проверки | Сокращение | Потеря настоящих |
|---|---:|---:|---:|---:|
| Только группы, с альтернативами после FAIL | — | 145863 | 1,006× | 0 |
| Одна часть, контроль скалярного порога | 0,059951 | 332 | 442,06× | 0 |
| **Две части, основной вариант** | **0,084740** | **15** | **9784,33×** | **0** |
| Три части | 0,103813 | 6 | 24460,83× | 0 |
Одна часть — только сравнительный контроль, не принятый ответ «поднять порог».
Третья часть убирает ещё девять проверок, но увеличивает стоимость общего
этапа: 0,531 → 0,819 с в инструментированных проходах. Две части — минимальный
вариант с проверкой распределённой структуры и большим запасом к ориентиру 50×.
Группировка сама по себе с корректным возвратом альтернатив почти не помогает
на распределённом шуме: отказы нельзя использовать для безусловного удаления соседей.
## Окончательные вычисления и свежесть
Три отдельных полных прохода без наблюдателя: processing/RF
**1,257688 / 1,219199 / 1,219273**, в каждом 240/240 пакетов, 30/30 JPEG,
15 ложных проверок и fresh FPS=0. Выбран целиком третий проход — медианный
по суммарному времени, без смешивания временных отметок разных запусков.
| Стадия / счётчик | Итог |
|---|---:|
| TOTAL LOCAL PEAKS | 1675842 |
| FIRST-STAGE / CHEAP CHECKED | 392785 |
| CHEAP REJECTED | 388407 |
| CHEAP RETAINED | 4378 |
| CLUSTERS, включая группы внутри уже принятых кадров | 384 |
| REPRESENTATIVE / ALTERNATIVE STRICT CHECKS | 243 / 12 |
| Пропущено внутри принятого физического кадра | 4123 |
| STRICT FALSE / TRUE | 15 / 240 |
| STRICT CHECKS / RF SECOND | 42,187699 |
| FALSE CHECKS / RF SECOND | 2,481629 |
| Новые soft/timing-окна | 488 |
| Грубая корреляция и децимация | 1,790240 с |
| Общие проверки частей | 0,446616 с |
| Группировка | 0,003113 с |
| Строгие настоящие / ложные | 3,996005 / 0,028884 с |
| Среднее настоящего кандидата | 16,650 мс |
| Суммарно RX / сборка / JPEG decode | 7,358399 / 0,006008 / 0,005388 с |
| Полная обработка | 7,369795 с |
| PROCESSING / RF | 1,219273 |
| INPUT FPS / FRAMES | 5 / 30 |
| RX PACKETS / IMAGES | 240 / 30 |
| DROPPED BEFORE TX / STALE AFTER DECODE | 0 / 30 |
| DISPLAYED / FRESH DISPLAYED / FRESH FPS | 0 / 0 / 0 |
| RAW RECEIVED FPS в первые 6 с шкалы работника | 4 |
| Возраст декодированных JPEG min / median / max | 234,520 / 834,077 / 1549,595 мс |
| MAX_VIDEO_AGE | 200 мс |
После устранения основной массы ложных кандидатов обнаружена лишняя подготовка:
старый адаптер очищал кэш и 734 раза строил ту же опорную форму. В начальном
cProfile это занимало 3,544 с. В Lab052 три неизменных массива — опора, RRC taps
и PRBS7 bits — хранятся в самом приёмнике. Они побитно сравнены со старым
расчётом; функции возвращаются в finally до выхода старого контекста режима.
Это не новая PHY и не передача данных TX в RX.
Финальный отдельный cProfile только вокруг push/finish показывает следующие
узкие места: FFT 1,650 с собственного времени, FIR-децимация 1,295 с,
Viterbi 1,397 с; уточнение 1,084 с и matched 0,865 с включают свои FFT,
поэтому эти строки нельзя складывать повторно. Копирование ndarray — 0,313 с,
прочие расходы push — также измерены. Данные cProfile не заменяют чистый замер.
Теперь основное ограничение — **настоящие пакеты вместе с общей обработкой IQ**.
40 пакетов/с ×16,650 мс требуют около 666 мс обработки в секунду; к ним добавляются
децимация/корреляция, общий промежуточный этап, копирование и организация блоков.
Во время JPEG-пачки пакет приходит каждые 11,532 мс, быстрее средней строгой
обработки настоящего пакета. Нужны и дальнейшее снижение общей стоимости,
и проверка задержки пачки. Одного среднего processing/RF<1 недостаточно для
доказательства MAX_VIDEO_AGE=200 мс при неравномерной нагрузке.
Событийная шкала повторно использует Lab051:
`worker_start=max(previous_worker_end, block_arrival); worker_end=start+measured_push`.
Сборка/JPEG decode добавляют измеренные затраты. RF и CPU перекрываются;
стоимость RX не заменяется нулём. Генерация IQ, сравнение SHA и JSON I/O исключены.
Восстановленные байты сверяются с TX только после принятия, сборки и JPEG decode.
Возраст относится к **декодированным, затем отброшенным** изображениям.
Камера и физический экран не измерялись, их задержки UNKNOWN. Capture lookup
используется после вердикта для отчётной модели вывода. В неизменном формате
фрагмента нет capture timestamp; независимый live-контроль возраста не реализован.
Максимальное отставание 1,426779 с соответствует примерно 91313848 байтам
внешнего complex64 IQ. Этот backlog вычислен, но не хранится офлайн-источником.
REAL-TIME CAPABLE=NO; 5 FPS SOFTWARE REAL-TIME TARGET=FAIL.
## Память и длительное состояние
- Пик удерживаемого IQ: **3177728 байт = 3,031 МиБ**; проверяемый предел 3398912.
- Инженерная оценка рабочих массивов: **142775456 байт = 136,161 МиБ**.
Это прежний запас Lab051, включающий буферы; не доказанный универсальный
предел C-аллокаций и не отдельная величина для сложения с RSS.
- Оценка массивов именно обнаружителя в опыте: **1480664 байта**;
постоянные новые шаблоны — **171140 байт**. Временные FFT отдельно целиком
не перечислены, они покрываются общей инженерной оценкой с запасом.
- RSS выбранного полного прохода: **149393408 байт** после потока;
process RSS peak **168804352 байта = 160,984 МиБ**. Это весь процесс Python,
включая библиотеки, генератор и аллокатор, а не только массивы RX.
- В основном опыте одновременно максимум **23** прошедших дешёвый этап пика
и **3** группы на блок; между вызовами и после finish состояние кандидатов =0.
- Предел числа кандидатов задаётся числом локальных максимумов ограниченного
IQ-окна. Списки групп и альтернатив уничтожаются на выходе, не копятся в сессии.
Дополнительно измерен отдельный RX replay длительностью 0,063184 с IQ с одним
независимо восстановленным пакетом. Наблюдаются 6754 ndarray, из них 6681 владеют
данными; суммарные байты наблюдаемых владельцев за проход — 37378942 (не пик).
**tracemalloc peak=11586792 байта**, после выхода остаётся 6461 байт отслеживаемых
выделений. Входной IQ подготовлен до tracing. Это измерение отслеживаемой памяти
RX, включая Python, и нижняя граница числа массивов в локальных переменных;
временные C/SciPy-аллокации полностью не перечислены. Короткий replay не заменяет
RSS полного опыта. Подсчёт ndarray и tracemalloc выполняются в разных проходах;
tracemalloc измеряет следующий прогретый проход. Его tracing-время не используется
для оценки скорости, а пик не объявляется верхней границей холодного RX.
Длинный тест: **2048 блоков, 16,777216 с** распределённого шума с амплитудными
масштабами 0,002 / 0,2 / 20 / 0,02. Получены 4783206 локальных пиков,
1068038 кандидатов первого каскада; 1068037 отклонены дешёво, один дошёл
до строгого отказа. **Ни одного принятого шумового пакета**.
После прогрева в контрольных точках IQ=2653440 байт, децимация=397216 байт,
сохранённых кандидатов=0. RSS сначала растёт при прогреве кэшей, затем
стабилизируется; последние четыре точки 140533760–140550144 байта.
После finish оба IQ-буфера освобождены, константы ограниченного размера остаются.
Полная диагностическая история NPZ/JSON принадлежит конечному эксперименту,
не приёмнику. Она выключена в окончательном измерении скорости и длинном тесте.
Ограниченный RX не подменяет отсутствующую живую входную очередь с overflow/gap
policy: аппаратный драйвер и переполнения в этой лабораторной не реализованы.
## Регрессии и проверка
45 дополнительных слабых сценариев: Eb/N0=0/1/2/3/7 дБ, CFO=−100/+10/+100 Гц,
три seed, масштабы амплитуды 0,002/0,2/1 и разные фазы/полярности.
Старый и новый RX правильно приняли **37/45**; **0** потерянных пакетов baseline.
Восемь случаев не принимаются обоими, поэтому это не объявляется 45/45 успешным
декодированием. Основной поток отдельно даёт 240/240 и 30/30 SHA MATCH.
30 быстрых целевых тестов покрывают шумовые группы, равные пики, отсутствие
цепочного расширения, два минимальных соседних кадра, слабый кадр, одну сильную
ложную половину, фазу/масштаб, распределённый и меняющийся шум, границы блоков,
освобождение состояния, эквивалентность общих корреляций отдельным vdot,
ложную преамбулу и восстановление функций. Verifier и adversary независимо
подтвердили исправление воспроизведённой потери; обхода PHY/FEC/CRC не найдено.
Сохранённая Lab047-запись **20 ксимв/с** повторно разобрана прежним Lab048:
**36/36, CRC PASS, JPEG SHA MATCH**, 19 ошибок до FEC, 0 после, 0 байтовых ошибок.
SHA: `e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a`.
Обработка 15 с старого IQ заняла 13,213346 с. Новый адаптер принимает только
500 ксимв/с; режим 20 ксимв/с, его порог и вычислительный тракт не менялись.
Итоговый quick_gate PASS: **718 тестов, 20 функциональных проверок,
13 штатных пропусков**. Полный pytest: **718 passed за 77,15 с, 0 failed**;
одно предупреждение доступа к .pytest_cache не влияет на результат тестов.
Дополнительный helper измерения массивов выполнен отдельно, все три модуля
после его добавления проверены py_compile и своими run_functional_tests.
git diff --check PASS. Машинная сверка итоговых JSON — в acceptance.json:
обязательные регрессии PASS, real-time и 5-fps target явно FAIL.
## Файлы и воспроизведение
- [Обнаружитель и потоковый адаптер](../experiments/lab052_candidate_detection.py)
- [Генератор сравнений, измерений и регрессий](../experiments/lab052_detector_study.py)
- [Статистика и график](../experiments/lab052_detection_statistics.py)
- [Целевые тесты](../tests/test_lab052_candidate_detection.py)
- [Профиль старого RX](../data/processed/lab052/baseline_profile.json)
- [Старый RX без наблюдателя на том же источнике](../data/processed/lab052/baseline_stream.json)
- [Сравнение правил](../data/processed/lab052/rule_comparison.json)
- [Статистика после решений](../data/processed/lab052/statistics.json)
- [Итоговый полный поток](../data/processed/lab052/stream.json)
- [Длинное состояние](../data/processed/lab052/memory.json)
- [Измеренные массивы и tracemalloc](../data/processed/lab052/allocations.json)
- [Слабые сигналы](../data/processed/lab052/sensitivity.json)
- [Отдельный профиль CPU](../data/processed/lab052/receiver_cpu.txt)
- [Старая запись 20 ксимв/с](../data/processed/lab052/regression20k/result.json)
- [Сверка критериев и отрицательного результата скорости](../data/processed/lab052/acceptance.json)
```powershell
python experiments/lab052_detector_study.py --stage profile
python experiments/lab052_detector_study.py --stage baseline
python experiments/lab052_detector_study.py --stage rules
python experiments/lab052_detector_study.py --stage stream
python experiments/lab052_detector_study.py --stage cpu
python experiments/lab052_detector_study.py --stage memory
python experiments/lab052_detector_study.py --stage sensitivity
python experiments/lab052_detection_statistics.py
python -m pytest tests/test_lab052_candidate_detection.py -q -p no:cacheprovider
python tools/quick_gate.py
python -m pytest tests/ -q
git diff --check
```
Старый IQ: команда Lab048 с параметрами `--run-saved-capture`,
`--capture C:\Users\user\Documents\SDR_Rover_Lab047_raw\20260903_115747\rx_stream.cs8`,
`--original-result C:\Users\user\Documents\SDR_Rover_Lab047_raw\20260903_115747\independent_result.json`,
`--tx-image data/processed/lab047/tx_image.jpg`,
`--output-directory data/processed/lab052/regression20k`.
Первый запуск фиксирует измеренную готовность JPEG; последующие используют
source_fixture.json. Между запусками меняются фактические CPU timings и RSS,
поэтому временные числа могут отличаться. Основной приём и набор кандидатов
на зафиксированном источнике воспроизводятся. Большой новый IQ-файл не создаётся.
Lab052 git add/commit и push не выполнялись. RF TX=0; HackRF и другое
оборудование не перечислялись и не запускались. QPSK, 180 МГц, аппаратные
500 ксимв/с и переход к 10 fps не выполнялись. Следующий рекомендуемый этап —
отдельная оптимизация истинных пакетов/общего IQ и задержки JPEG-пачки. 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

@@ -0,0 +1,320 @@
"""Lab051: измеренный блочный RX и возраст JPEG в шестисекундном потоке.
Источник синтетический, из реального фото. RF не выполняется. Виртуальное
время работника использует фактическое время каждого push/сборки/JPEG decode.
Это офлайн-воспроизведение; отставание явно означает требуемый внешний IQ backlog.
"""
from __future__ import annotations
import argparse
from collections import Counter
from dataclasses import asdict
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
from experiments import lab050_video_stream as video
from experiments import lab051_streaming_receiver as fast
from experiments.lab051_receiver_profile import OUTPUT, ROOT, StageProfiler, array_observation, process_memory
from protocol.video_frame_scheduler import FramePolicy, schedule_video_frames
def write_result(name: str, result: dict) -> None:
OUTPUT.mkdir(parents=True, exist_ok=True)
(OUTPUT / name).write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
class ImageSink:
"""Только текущий объект; никаких ожидаемых ID, размеров JPEG или TX bytes."""
def __init__(self):
self.object_id = -1
self.last_completed_id = -1
self.assembler = None
self.first_rf_sample = None
self.first_rx_time = None
self.retained_payload_peak = 0
self.assembly_seconds = self.jpeg_decode_seconds = 0.0
self.rejected = Counter()
def offer(self, packet, received_time: float) -> dict | None:
started = time.perf_counter()
fragment = video.transport.recover_fragment_from_payload(packet.payload)
if fragment is None or fragment.original_size > 65536 or fragment.fragment_count > 269:
self.rejected["invalid_fragment"] += 1
self.assembly_seconds += time.perf_counter() - started
return None
if fragment.image_id < self.object_id or fragment.image_id <= self.last_completed_id:
self.rejected["older_object"] += 1
self.assembly_seconds += time.perf_counter() - started
return None
if fragment.image_id > self.object_id:
self.object_id = fragment.image_id
self.assembler = video.transport.ObjectAssembler()
self.first_rf_sample = packet.start_sample
self.first_rx_time = received_time
self.assembler.offer(fragment)
self.retained_payload_peak = max(self.retained_payload_peak, fragment.original_size)
jpeg = self.assembler.recovered_object()
self.assembly_seconds += time.perf_counter() - started
if jpeg is None:
return None
decode_started = time.perf_counter()
try:
with Image.open(BytesIO(jpeg)) as image:
image.load()
dimensions = image.size
except (OSError, ValueError):
self.rejected["jpeg_decode"] += 1
return None
finally:
self.jpeg_decode_seconds += time.perf_counter() - decode_started
self.last_completed_id = self.object_id
self.assembler = None
return {"image_id": self.object_id, "jpeg": jpeg, "dimensions": dimensions,
"first_rf_sample": self.first_rf_sample, "last_rf_sample": packet.end_sample,
"first_rx_time": self.first_rx_time}
def run_stream(sequence: list[video.EncodedImage]) -> dict:
"""Вход RX — только последовательные IQ-блоки; TX-диагностика после JPEG."""
mode = video.phy.PhyMode(500000)
receiver = fast.StreamingReceiver()
sink = ImageSink()
random_generator = np.random.default_rng(50050)
frames = video.make_frame_groups(sequence, 5)
packet_time = video.FRAME_INTERVALS / mode.symbol_rate
bitrate = (256 + video.HEADER_SIZE) * 8 / packet_time
# TX использует известную нижнюю задержку радио/буфера, без предсказания
# будущего CPU и без знания RX verdict. Перегрузка RX измеряется ниже.
buffer_delay = (receiver.lookahead + 2*receiver.core_samples) / mode.sample_rate_hz
schedule = schedule_video_frames(frames, (), FramePolicy.LATEST_ONLY, bitrate,
max_video_age_seconds=.2, completion_latency_seconds=buffer_delay)
starts = {}
for sent in schedule.transmitted:
starts.setdefault(sent.item.composite_frame_id, sent.start_seconds)
horizon = len(sequence) / 5
encoded_lookup = {image.image_id: image for image in sequence}
capture_lookup = {frame.composite_frame_id: frame.generation_time_seconds for frame in frames}
pending = np.empty(0, np.complex64)
total_samples = tx_packets = 0
worker_time = rx_processing = max_lag = 0.0
completed = []
block_metrics = []
rss_samples = []
def consume_block(block, *, final=False):
nonlocal total_samples, worker_time, rx_processing, max_lag
total_samples += block.size
arrival = total_samples / mode.sample_rate_hz
worker_start = max(worker_time, arrival)
started = time.perf_counter()
packets = receiver.finish() if final else receiver.push_iq_block(block)
cpu = time.perf_counter() - started
rx_processing += cpu
worker_time = worker_start + cpu
for packet in packets:
started = time.perf_counter()
item = sink.offer(packet, worker_time)
cost = time.perf_counter() - started
worker_time += cost
if item is not None:
# Приём/сборка/JPEG уже завершены. TX используется только теперь.
jpeg = item.pop("jpeg")
image_id = item["image_id"]
capture = capture_lookup.get(image_id)
age = None if capture is None else worker_time - capture
completed.append({**item, "decode_complete_timestamp": worker_time,
"capture_timestamp": capture, "image_age_seconds": age,
"fresh": age is not None and 0 <= age <= .2,
"sha256": hashlib.sha256(jpeg).hexdigest(),
"sha_match": image_id in encoded_lookup and jpeg == encoded_lookup[image_id].jpeg})
lag = max(0., worker_time - arrival)
max_lag = max(max_lag, lag)
block_metrics.append({"samples_received": total_samples, "arrival_time": arrival,
"worker_start": worker_start, "worker_end": worker_time,
"rx_cpu_seconds": cpu, "packets": len(packets), "lag_seconds": lag})
if len(block_metrics) % 64 == 0:
rss_samples.append({"samples": total_samples, **process_memory()})
def push(values):
nonlocal pending
offset = 0
if pending.size:
needed = receiver.block_samples - pending.size
joined = np.concatenate((pending, values[:needed]))
offset = min(needed, values.size)
if joined.size < receiver.block_samples:
pending = joined
return
consume_block(joined)
pending = np.empty(0, np.complex64)
while offset + receiver.block_samples <= values.size:
consume_block(values[offset:offset + receiver.block_samples])
offset += receiver.block_samples
pending = values[offset:].copy()
def background(count):
while count:
amount = min(count, receiver.block_samples)
values = .2 * (random_generator.normal(size=amount) + 1j*random_generator.normal(size=amount))
push(values.astype(np.complex64))
count -= amount
for image in sequence:
if image.image_id not in starts:
continue
target = math.ceil(starts[image.image_id] * mode.sample_rate_hz)
background(max(0, target - total_samples - pending.size))
for frame in video.transport.build_object_frames(image.jpeg, image.image_id):
raw = video.phy.build_parameterized_tx_cs8(frame.plan, mode)
components = np.frombuffer(raw, np.int8).astype(np.float32)
values = components[0::2] + 1j*components[1::2]
indexes = np.arange(values.size)
values = values * np.exp(1j*(.73 + 2*np.pi*10*indexes/mode.sample_rate_hz)) * (-1 if tx_packets % 2 else 1)
energy = float(np.sum(abs(values)**2)) / frame.plan.frame_symbol_count
sigma = math.sqrt(energy / (2*10**(.7)))
values += sigma*(random_generator.normal(size=values.size) + 1j*random_generator.normal(size=values.size))
push(values.astype(np.complex64))
tx_packets += 1
background(max(0, math.ceil(horizon * mode.sample_rate_hz) + receiver.lookahead + receiver.core_samples
- total_samples - pending.size))
if pending.size:
consume_block(pending)
consume_block(np.empty(0,np.complex64), final=True)
fresh = [row for row in completed if row["fresh"]]
displayed_in_window = [row for row in fresh if row["decode_complete_timestamp"] <= horizon]
decoded_in_window = [row for row in completed if row["decode_complete_timestamp"] <= horizon]
ages = [row["image_age_seconds"] for row in completed if row["image_age_seconds"] is not None]
fresh_ages = [row["image_age_seconds"] for row in fresh]
processing = rx_processing + sink.assembly_seconds + sink.jpeg_decode_seconds
return {
"scope": "continuous synthetic IQ; measured per-block CPU event replay; camera/display latency UNKNOWN",
"symbol_rate":500000,"samples_per_symbol":16,"input_fps":5,"input_frames":len(sequence),
"observation_seconds":horizon,"iq_seconds":total_samples/mode.sample_rate_hz,
"tx_images":len(starts),"tx_physical_packets":tx_packets,
"dropped_before_tx":len(schedule.dropped_frames),"maximum_waiting_frames":schedule.maximum_waiting_frames,
"rx_physical_packets":receiver.packet_count,"received_images":len(completed),
"object_jpeg_integrity":len(completed)==len(starts) and all(row["sha_match"] for row in completed),
"dropped_stale_at_display":len(completed)-len(fresh),"displayed":len(fresh),"fresh_displayed":len(fresh),
"raw_received_fps":len(decoded_in_window)/horizon,
"raw_display_fps":len(displayed_in_window)/horizon,"fresh_display_fps":len(displayed_in_window)/horizon,
"drain_fresh_displayed":len(fresh)-len(displayed_in_window),
"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,
"fresh_image_age_seconds":fresh_ages,"max_video_age_seconds":.2,
"processing_seconds":processing,"rx_processing_seconds":rx_processing,
"assembly_seconds":sink.assembly_seconds,"jpeg_decode_seconds":sink.jpeg_decode_seconds,
"processing_per_rf_second":processing/(total_samples/mode.sample_rate_hz),
"physical_packet_processing_seconds":processing/tx_packets if tx_packets else None,
"maximum_cpu_lag_seconds":max_lag,
"implied_external_iq_backlog_bytes":math.ceil(max_lag*mode.sample_rate_hz)*8,
"live_external_queue_implemented":False,
"retained_iq_peak_bytes":receiver.peak_buffer_samples*8,
"working_arrays_upper_estimate_bytes":receiver.memory_estimate_bytes + 20*receiver.buffer_limit_samples*8,
"object_payload_peak_bytes":sink.retained_payload_peak,"process_memory":process_memory(),
"rss_samples":rss_samples,"candidate_count":receiver.candidate_count,
"rejected_reasons":dict(receiver.rejected_reasons),"front_statistics":dict(receiver.front_statistics),
"decimated_samples_reused":receiver.decimator.reused_samples,
"actual_decimator_input_samples":receiver.decimator.filtered_input_samples,
"images":completed,"blocks":block_metrics,"rf_tx":0,
}
def run_memory_test(block_count: int = 4096) -> dict:
"""33,55 с без истории IQ/пакетов; нулевой фон отделён от noisy IQ опыта."""
receiver = fast.StreamingReceiver()
values = np.zeros(receiver.block_samples,np.complex64)
checkpoints=[]
for index in range(block_count):
assert receiver.push_iq_block(values) == []
assert receiver.buffer.size <= receiver.buffer_limit_samples
if (index+1) % 512 == 0:
checkpoints.append({"block":index+1,"iq_bytes":receiver.buffer.nbytes,
"decimated_bytes":receiver.decimator.values.nbytes,**process_memory()})
assert receiver.finish()==[]
return {"blocks":block_count,"model_seconds":block_count*values.size/8000000,
"background":"zeros; noisy background covered by six-second integration",
"peak_iq_bytes":receiver.peak_buffer_samples*8,"iq_limit_bytes":receiver.buffer_limit_samples*8,
"retained_after_finish":receiver.buffer.nbytes+receiver.decimator.values.nbytes,
"checkpoints":checkpoints,"rf_tx":0}
def run_candidate_replay() -> dict:
metadata=json.loads((OUTPUT/"optimized_examples.json").read_text(encoding="utf-8"))
result={}
for kind, entry in metadata.items():
raw=np.load(OUTPUT/f"optimized_{kind}_window.npy")
candidate=fast.offline.CoarseCandidate(entry["offset"],entry["score"])
def call(optimized=False):
with fast.previous.streaming_mode(video.phy.PhyMode(500000)):
def decode():
return fast.offline.decode_candidate_from_memmap(raw,entry["sample_count"],candidate,1,
read_iq_window=lambda data,start,end:data[2*start:2*end].view(np.complex64))
if not optimized:
return decode()
front=fast.BlockFrontEnd(raw.view(np.complex64),65536,Counter())
with fast.optimized_context(front): return decode()
old=call(False)
new=call(True)
assert (old.accepted,old.reason,old.refined_start_sample)==(new.accepted,new.reason,new.refined_start_sample)
result[kind]={"reason":new.reason,"baseline_arrays":array_observation(lambda:call(False)),
"optimized_arrays":array_observation(lambda:call(True))}
return result
def run_benchmark(sequence) -> dict:
original=video.StreamingReceiver
try:
video.StreamingReceiver=fast.StreamingReceiver
repeats=[video.benchmark_receiver(sequence[:3],500000) for unused in range(3)]
profiler=StageProfiler()
with profiler.measure(optimized=True): profiled=video.benchmark_receiver(sequence[:3],500000)
metadata={}
for kind,(raw,count,candidate) in profiler.examples.items():
np.save(OUTPUT/f"optimized_{kind}_window.npy",raw)
metadata[kind]={"sample_count":count,"offset":candidate.sample_offset,"score":candidate.score}
write_result("optimized_examples.json",metadata)
return {"repeats":repeats,"median_processing_seconds":statistics.median(row["processing_seconds"] for row in repeats),
"profile":profiler.summary(),"profiled_benchmark":profiled,"rf_tx":0}
finally:
video.StreamingReceiver=original
def run_functional_tests() -> None:
assert video.maximum_jpeg_bytes(500000,5)==4148
assert fast.lab045.conv.CONSTRAINT_LENGTH==7
def main() -> None:
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument("--stage",choices=("benchmark","stream","memory","replay","all"),default="all")
args=parser.parse_args()
OUTPUT.mkdir(parents=True,exist_ok=True)
with Image.open(ROOT/"data/raw/lab011_scene.jpg") as source:
sequence=video.encode_sequence(source,video.PROFILES[2],30)
for name,function in (("benchmark",lambda:run_benchmark(sequence)),("stream",lambda:run_stream(sequence)),
("memory",run_memory_test),("replay",run_candidate_replay)):
if args.stage not in ("all",name): continue
print(f"Lab051 {name}: START; RF TX=0",flush=True)
result=function()
write_result(f"{name}.json",result)
print(json.dumps({key:value for key,value in result.items() if key not in (
"blocks","images","repeats","profiled_benchmark","rss_samples")},ensure_ascii=False),flush=True)
if __name__=="__main__":
main()

View File

@@ -0,0 +1,246 @@
"""Lab051: воспроизводимое профилирование приёмника, без оборудования.
Измерение времени отделено от медленного наблюдения массивов/tracemalloc.
Классификация valid/false происходит только после штатного PHY/FEC/CRC.
"""
from __future__ import annotations
from collections import defaultdict
from contextlib import contextmanager
from dataclasses import asdict, dataclass
import ctypes
from ctypes import wintypes
import json
from pathlib import Path
import statistics
import sys
import time
import tracemalloc
import weakref
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
from PIL import Image
from experiments import lab046c_offline_recovery as offline
from experiments import lab050_video_stream as video
from experiments.lab050_streaming_receiver import streaming_mode
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "data/processed/lab051"
lab045 = video.phy.lab045
lab043 = lab045.lab044.lab043
def process_memory() -> dict:
"""Working set Windows без psutil; чужие процессы не перечисляются."""
if sys.platform != "win32":
return {"rss_bytes": None, "peak_rss_bytes": None}
class Counters(ctypes.Structure):
_fields_ = [("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD)] + [
(name, ctypes.c_size_t) for name in (
"PeakWorkingSetSize", "WorkingSetSize", "QuotaPeakPagedPoolUsage",
"QuotaPagedPoolUsage", "QuotaPeakNonPagedPoolUsage", "QuotaNonPagedPoolUsage",
"PagefileUsage", "PeakPagefileUsage")]
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
psapi = ctypes.WinDLL("psapi", use_last_error=True)
kernel.GetCurrentProcess.restype = wintypes.HANDLE
psapi.GetProcessMemoryInfo.argtypes = [wintypes.HANDLE, ctypes.POINTER(Counters), wintypes.DWORD]
counters = Counters()
counters.cb = ctypes.sizeof(counters)
if not psapi.GetProcessMemoryInfo(kernel.GetCurrentProcess(), ctypes.byref(counters), counters.cb):
return {"rss_bytes": None, "peak_rss_bytes": None, "error": ctypes.get_last_error()}
return {"rss_bytes": counters.WorkingSetSize, "peak_rss_bytes": counters.PeakWorkingSetSize}
@dataclass
class Stage:
calls: int = 0
seconds: float = 0.0
exclusive_seconds: float = 0.0
class StageProfiler:
"""Только последовательное профилирование; подмены возвращаются в finally."""
def __init__(self):
self.rows = []
self.current = None
self.shared = defaultdict(Stage)
self.stack = []
self.examples = {}
@contextmanager
def measure(self, *, optimized: bool = False):
patches = []
targets = (
(offline, "_correlation_scores", "coarse_localization"),
(lab043, "_refine_frame_start", "refinement"),
(lab043, "_matched_waveform", "matched_filter"),
(lab043, "_preamble_timing_offset", "timing"),
(lab043, "_timing_offsets", "timing_fallback"),
(lab043, "_estimate_cfo_gain", "cfo_gain"),
(lab043, "_evaluate_symbols", "phase_diagnostics"),
(lab045, "decode_phy_header_soft", "phy_header"),
(lab045, "analyze_post_preamble_soft", "body_pipeline"),
(lab045.conv, "viterbi_decode_soft", "viterbi"),
(lab045, "parse_packet", "crc_parser"),
)
if optimized:
from experiments import lab051_streaming_receiver as fast
targets += ((fast.BlockFrontEnd, "refine", "refinement"),
(fast.BlockFrontEnd, "coarse_scores", "coarse_localization"),
(fast.BlockFrontEnd, "matched_window", "matched_filter"),
(fast.BlockFrontEnd, "timing", "timing"),
(fast, "fast_viterbi", "viterbi"))
def wrap(function, name):
def measured(*args, **kwargs):
scope = self.shared if self.current is None else self.current
stage = scope[name]
started = time.perf_counter()
entry = [0.0]
self.stack.append(entry)
try:
return function(*args, **kwargs)
finally:
elapsed = time.perf_counter() - started
self.stack.pop()
if self.stack:
self.stack[-1][0] += elapsed
stage.calls += 1
stage.seconds += elapsed
stage.exclusive_seconds += elapsed - entry[0]
return measured
for owner, name, stage in targets:
original = getattr(owner, name)
patches.append((owner, name, original))
setattr(owner, name, wrap(original, stage))
original_decode = offline.decode_candidate_from_memmap
def candidate(raw, sample_count, coarse, number, **kwargs):
self.current = defaultdict(Stage)
started = time.perf_counter()
try:
outcome = original_decode(raw, sample_count, coarse, number, **kwargs)
elapsed = time.perf_counter() - started
kind = "valid" if outcome.accepted else "false"
self.rows.append({"kind": kind, "seconds": elapsed, "reason": outcome.reason,
"offset": coarse.sample_offset, "refined": outcome.refined_start_sample,
"stages": {name: asdict(row) for name, row in self.current.items()}})
# По одному примеру после решения, только для отдельного replay.
if kind not in self.examples and (kind == "valid" or outcome.reason.startswith("PHY_HEADER_MAGIC")):
self.examples[kind] = (raw.copy(), sample_count, coarse)
return outcome
finally:
self.current = None
offline.decode_candidate_from_memmap = candidate
try:
yield self
finally:
offline.decode_candidate_from_memmap = original_decode
for owner, name, original in reversed(patches):
setattr(owner, name, original)
def summary(self) -> dict:
result = {"shared_stages": {name: asdict(row) for name, row in self.shared.items()}}
for kind in ("valid", "false"):
selected = [row for row in self.rows if row["kind"] == kind]
stages = defaultdict(Stage)
for row in selected:
for name, stage in row["stages"].items():
stages[name].calls += stage["calls"]
stages[name].seconds += stage["seconds"]
stages[name].exclusive_seconds += stage["exclusive_seconds"]
times = [row["seconds"] for row in selected]
result[kind] = {"count": len(times), "mean_seconds": statistics.mean(times) if times else None,
"median_seconds": statistics.median(times) if times else None,
"total_seconds": sum(times),
"stages": {name: asdict(row) for name, row in stages.items()}}
return result
def array_observation(function) -> dict:
"""Нижняя граница числа массивов в локальных переменных проектного Python.
Временные выражения и внутренности C/SciPy не видны line tracer-у.
tracemalloc отдельно считает пик отслеживаемой памяти, включая NumPy.
Эти проходы НИКОГДА не используются как измерение производительности.
"""
seen = {}
counters = {"observed_array_objects": 0, "observed_owned_arrays": 0, "observed_owned_bytes_total": 0}
def trace(frame, event, argument):
filename = frame.f_code.co_filename.replace("\\", "/")
if not ("/experiments/" in filename or "/protocol/" in filename):
return None
if event in ("line", "return"):
for value in frame.f_locals.values():
if not isinstance(value, np.ndarray):
continue
identity = id(value)
if identity in seen and seen[identity]() is value:
continue
seen[identity] = weakref.ref(value)
counters["observed_array_objects"] += 1
if value.flags.owndata:
counters["observed_owned_arrays"] += 1
counters["observed_owned_bytes_total"] += value.nbytes
return trace
previous = sys.gettrace()
try:
sys.settrace(trace)
function()
finally:
sys.settrace(previous)
tracemalloc.start()
try:
function()
current, peak = tracemalloc.get_traced_memory()
finally:
tracemalloc.stop()
return {**counters, "tracemalloc_retained_bytes": current, "tracemalloc_peak_bytes": peak,
"array_count_is_lower_bound": True}
def run_functional_tests() -> None:
assert video.phy.PhyMode(500_000).samples_per_symbol == 16
def main() -> None:
OUTPUT.mkdir(parents=True, exist_ok=True)
with Image.open(ROOT / "data/raw/lab011_scene.jpg") as source:
sequence = video.encode_sequence(source, video.PROFILES[2], 3)
profiler = StageProfiler()
with profiler.measure():
benchmark = video.benchmark_receiver(sequence, 500_000)
result = {"benchmark": benchmark, "profile": profiler.summary(), "rf_tx": 0}
(OUTPUT / "baseline_profile.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
print(json.dumps(result["profile"], indent=2), flush=True)
# Небольшие ограниченные окна, не полная запись. В Git не включать.
metadata = {}
for kind, (raw, count, coarse) in profiler.examples.items():
np.save(OUTPUT / f"baseline_{kind}_window.npy", raw)
metadata[kind] = {"sample_count": count, "offset": coarse.sample_offset, "score": coarse.score}
with streaming_mode(video.phy.PhyMode(500_000)):
call = lambda: offline.decode_candidate_from_memmap(
raw, count, coarse, 1,
read_iq_window=lambda data, start, end: data[2*start:2*end].view(np.complex64))
result.setdefault("array_observation", {})[kind] = array_observation(call)
(OUTPUT / "baseline_examples.json").write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
result["process_memory_after_profiling"] = process_memory()
(OUTPUT / "baseline_profile.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
print(json.dumps(result["array_observation"]), flush=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,364 @@
"""Lab051: общий front-end и эквивалентный timing на 500 ксимв/с.
PHY, FEC, CRC и порог первого каскада заимствованы без изменения правил.
Подмены старых функций действуют только внутри последовательного push.
"""
from __future__ import annotations
from collections import Counter
from contextlib import contextmanager
from functools import lru_cache
import math
import time
import numpy as np
from scipy.signal import convolve, fftconvolve, oaconvolve, firwin, resample_poly
from experiments import lab046b_continuous_iq as iq
from experiments import lab046c_offline_recovery as offline
from experiments import lab050_streaming_receiver as previous
phy = previous.phy
lab045 = phy.lab045
lab043 = lab045.lab044.lab043
ORIGINAL_REFINE = lab043._refine_frame_start
ORIGINAL_TIMING = lab043._preamble_timing_offset
ORIGINAL_MATCHED = lab043._matched_waveform
ORIGINAL_VITERBI = lab045.conv.viterbi_decode_soft
@lru_cache(maxsize=4)
def timing_kernel(samples_per_symbol: int) -> np.ndarray:
"""Те же символы преамбулы на исходной сетке отсчётов."""
expected = iq.radio.bpsk_modulate(lab045.lab044.preamble_bits())
kernel = np.zeros((expected.size - 1) * samples_per_symbol + 1, np.complex64)
kernel[::samples_per_symbol] = np.conj(expected)
return kernel[::-1].copy()
def fast_preamble_timing(matched: np.ndarray, nominal: int, expected_symbols: np.ndarray) -> int:
"""Все прежние 1601 смещения; отрицательные/усечённые окна имеют score=0.
Корреляция с разреженной преамбулой эквивалентна vdot каждой выборки.
Энергии получаются разностями сумм на каждой из 16 фаз. Диапазон не сужен.
"""
samples_per_symbol = lab045.SAMPLES_PER_SYMBOL
offsets = np.arange(-800, 801)
starts = nominal + offsets
count = len(expected_symbols)
# Тело не участвует ни в одном из прежних 1601 timing-окон.
matched = matched[:nominal + 800 + (count - 1) * samples_per_symbol + 1]
valid = (starts >= 0) & (starts + (count - 1) * samples_per_symbol < matched.size)
scores = np.zeros(offsets.size, np.float64)
if np.any(valid):
kernel = timing_kernel(samples_per_symbol)
correlations = fftconvolve(matched, kernel, mode="valid")
padding = (-matched.size) % samples_per_symbol
power = np.pad(np.abs(matched) ** 2, (0, padding)).reshape(-1, samples_per_symbol)
cumulative = np.concatenate((np.zeros((1, samples_per_symbol)),
np.cumsum(power, axis=0, dtype=np.float64))).ravel()
selected = starts[valid]
energy = cumulative[selected + count * samples_per_symbol] - cumulative[selected]
scores[valid] = np.abs(correlations[selected]) / np.sqrt(count * np.maximum(energy, 0) + 1e-30)
largest = np.partition(scores, -2)[-2:]
if largest[-1] > 0 and largest[-1] - largest[-2] <= 32 * np.finfo(np.float32).eps * largest[-1]:
# FFT меняет последние разряды. На неразделимых пиках сохраняем
# исходное суммирование и его tie→первый offset, включая чистый тон.
return ORIGINAL_TIMING(matched, nominal, expected_symbols)
return int(offsets[int(np.argmax(scores))])
@lru_cache(maxsize=8)
def carrier_rotation(length: int) -> np.ndarray:
"""Кэш переносов не очищается при каждом входном блоке старым адаптером."""
indexes = np.arange(length)
return np.exp(-2j * np.pi * lab043.CARRIER_OFFSET_HZ * indexes / lab045.SAMPLE_RATE_HZ).astype(np.complex64)
@lru_cache(maxsize=8)
def dc_response(length: int, samples_per_symbol: int) -> np.ndarray:
"""Отклик локального окна постоянной составляющей после переноса/RRC."""
rotation = carrier_rotation(length)
return oaconvolve(rotation, lab043._matched_filter_taps(), mode="full")
@lru_cache(maxsize=4)
def decimation_taps(decimation: int) -> np.ndarray:
"""Штатные коэффициенты resample_poly(up=1, window=('kaiser', 8))."""
return firwin(20 * decimation + 1, 1 / decimation, window=("kaiser", 8)).astype(np.float32)
@lru_cache(maxsize=8)
def detection_rotation(length: int, decimation: int) -> np.ndarray:
return np.exp(-2j * np.pi * lab045.CARRIER_OFFSET_HZ * np.arange(length)
/ (lab045.SAMPLE_RATE_HZ / decimation)).astype(np.complex64)
class DecimationCache:
"""Повторно использовать внутреннюю часть FIR, пересчитать только края.
resample_poly имеет поддержку ±10 выходных отсчётов. После сдвига окна
неизменная внутренняя область даёт ровно те же значения; левый нулевой
край и новое продолжение считаются с прежними коэффициентами заново.
"""
def __init__(self, decimation: int):
self.decimation = decimation
self.base = self.input_count = 0
self.values = np.empty(0, np.complex64)
self.reused_samples = self.filtered_input_samples = 0
def get(self, samples: np.ndarray, base: int) -> np.ndarray:
stride = self.decimation
count = math.ceil(samples.size / stride)
shift = (base - self.base) // stride
low = max(10, -shift + 10)
high = min(count - 10, self.values.size - 11 - shift)
def filtered(array):
self.filtered_input_samples += array.size
return resample_poly(array, 1, stride, window=decimation_taps(stride))
if not self.values.size or (base - self.base) % stride or high <= low:
output = filtered(samples)
else:
output = np.empty(count, np.complex64)
output[low:high] = self.values[low + shift:high + shift]
self.reused_samples += high - low
prefix_end = min(samples.size, (low + 11) * stride)
output[:low] = filtered(samples[:prefix_end])[:low]
suffix_start = (high - 11) * stride
output[high:] = filtered(samples[suffix_start:])[11:11 + count - high]
self.values = output
self.base, self.input_count = base, samples.size
return output
def fast_viterbi(soft_values: np.ndarray, information_bit_count: int) -> np.ndarray:
"""Та же евклидова метрика и tie→branch 0, с готовыми метриками ветвей."""
conv = lab045.conv
if not isinstance(information_bit_count, (int, np.integer)) or information_bit_count <= 0:
return ORIGINAL_VITERBI(soft_values, information_bit_count)
values = np.asarray(soft_values, dtype=np.float64)
if (values.ndim != 1 or values.size != conv.encoded_bit_count(information_bit_count)
or not np.all(np.isfinite(values))):
return ORIGINAL_VITERBI(soft_values, information_bit_count)
step_count = int(information_bit_count) + conv.TAIL_BIT_COUNT
received = values.reshape(step_count, 2)
# Всего четыре различных пары BPSK-символов. Порядок арифметики исходный.
negative = (received + 1.0) ** 2
positive = (received - 1.0) ** 2
metrics = np.column_stack((negative[:, 0] + negative[:, 1], negative[:, 0] + positive[:, 1],
positive[:, 0] + negative[:, 1], positive[:, 0] + positive[:, 1]))
symbols = conv._BRANCH_SYMBOLS
codes = (2 * (symbols[:, :, 0] > 0) + (symbols[:, :, 1] > 0)).astype(np.intp)
metric_zero = metrics[:, codes[:, 0]]
metric_one = metrics[:, codes[:, 1]]
predecessor_zero = conv._PREDECESSORS[:, 0]
predecessor_one = conv._PREDECESSORS[:, 1]
path = np.full(conv.STATE_COUNT, 1e18)
path[0] = 0.0
choices = np.empty((step_count, conv.STATE_COUNT), np.uint8)
for step in range(step_count):
zero = path[predecessor_zero] + metric_zero[step]
one = path[predecessor_one] + metric_one[step]
choices[step] = one < zero
path = np.minimum(zero, one)
decoded = np.empty(step_count, np.uint8)
state = 0
for step in range(step_count - 1, -1, -1):
decoded[step] = state >> (conv.MEMORY - 1)
state = int(conv._PREDECESSORS[state, choices[step, state]])
assert state == 0
return decoded[:information_bit_count]
class BlockFrontEnd:
"""Один ограниченный общий участок; кандидаты читают его представления.
Конечные края и локальное вычитание среднего восстановлены отдельно:
нельзя без этого подменять фильтрацию изолированного окна непрерывной.
"""
def __init__(self, samples: np.ndarray, owned_end: int, statistics: Counter,
*, decimator: DecimationCache | None = None, base: int = 0):
self.samples = samples
self.statistics = statistics
self.decimator, self.base = decimator, base
self.reference = lab045.lab044._preamble_reference_samples()
self.reference_length = (16 + lab043.PILOT_SYMBOL_COUNT + 10) * lab045.SAMPLES_PER_SYMBOL
self.probe_symbols = lab045.PREAMBLE_BIT_COUNT + lab045.PHY_HEADER_BIT_COUNT
self.probe_waveform = (self.probe_symbols + 42) * lab045.SAMPLES_PER_SYMBOL
self.front_length = min(samples.size, owned_end + self.probe_waveform + offline.REFINEMENT_MARGIN_SAMPLES)
self.owned_end = owned_end
self.prefix_correlation = None
self.changes = None
self.matched = None
self.baseband = None
self.soft_cache = {}
def coarse_scores(self, samples: np.ndarray, reference: np.ndarray) -> np.ndarray:
"""Сохранить DC всего старого окна, считать корреляцию только owned+1."""
decimation = lab043.DETECTION_DECIMATION
needed = math.ceil(self.owned_end / decimation) + reference.size + 1
raw = (resample_poly(samples, 1, decimation, window=decimation_taps(decimation)) if self.decimator is None
else self.decimator.get(samples, self.base))
mean = np.mean(raw, dtype=np.complex128).astype(np.complex64)
values = raw[:needed].copy()
values -= mean
values *= detection_rotation(values.size, decimation)
if values.size < reference.size:
return np.empty(0, np.float32)
scores = lab043._normalized_sliding_correlation(values, reference).astype(np.float32)
scores[~np.isfinite(scores)] = 0
np.clip(scores, 0, 1, out=scores)
return scores
def local_start(self, array: np.ndarray) -> int:
return (array.ctypes.data - self.samples.ctypes.data) // self.samples.itemsize
def refine(self, samples: np.ndarray, reference: np.ndarray, coarse_start: int) -> int:
start = self.local_start(samples)
low = max(0, coarse_start - 2000)
high = min(samples.size, coarse_start + self.reference_length + 2000)
if high - low < self.reference_length or start < 0:
return ORIGINAL_REFINE(samples, reference, coarse_start)
if self.changes is None:
self.changes = np.empty(self.front_length, np.int64)
self.changes[0] = 0
np.cumsum(self.samples[1:self.front_length] != self.samples[:self.front_length-1],
dtype=np.int64, out=self.changes[1:])
if self.changes[start + samples.size - 1] == self.changes[start]:
# После вычитания локального среднего точно нулевой сигнал.
# Общая FFT может оставить шум округления вместо нулевого tie.
return low
if self.prefix_correlation is None:
centered = self.reference[:self.reference_length] - np.mean(self.reference[:self.reference_length])
self.centered_reference_sum = np.sum(np.conj(centered), dtype=np.complex128)
self.prefix_correlation = fftconvolve(
self.samples[:self.front_length], np.conj(centered[::-1]), mode="valid")
self.statistics["shared_refinement_correlations"] += 1
first = start + low
last = start + high - self.reference_length + 1
correlation = self.prefix_correlation[first:last]
if not correlation.size:
return ORIGINAL_REFINE(samples, reference, coarse_start)
# Та же DC-поправка, что у корреляции (samples - mean(probe)).
correlation = correlation - np.mean(samples) * self.centered_reference_sum
magnitudes = np.abs(correlation)
if magnitudes.size > 1:
largest = np.partition(magnitudes, -2)[-2:]
if largest[-1] - largest[-2] <= 32*np.finfo(np.float32).eps*largest[-1]:
self.statistics["refinement_tie_fallbacks"] += 1
return ORIGINAL_REFINE(samples, reference, coarse_start)
return low + int(np.argmax(magnitudes))
def matched_window(self, samples: np.ndarray, start: int, length: int):
absolute = self.local_start(samples) + start
taps = lab043._matched_filter_taps()
edge = taps.size - 1
if absolute < 0 or absolute + length + edge > self.front_length:
self.statistics["local_body_matched_calls"] += 1
segment = samples[start:start + length] - np.mean(samples)
baseband = segment * carrier_rotation(length)
return oaconvolve(baseband, taps, mode="full"), 16 * lab045.SAMPLES_PER_SYMBOL + edge
if self.matched is None:
self.baseband = self.samples[:self.front_length] * carrier_rotation(self.front_length)
self.matched = oaconvolve(self.baseband, taps, mode="full")
self.statistics["shared_matched_calls"] += 1
matched = self.matched[absolute:absolute + length + edge].copy()
# Восстановить нулевые края исходного локального окна, без соседнего IQ.
matched[:edge] = convolve(self.baseband[absolute:absolute + edge], taps, method="direct")[:edge]
matched[-edge:] = convolve(self.baseband[absolute + length - edge:absolute + length], taps,
method="direct")[edge:]
phase = np.complex64(np.exp(2j * np.pi * lab043.CARRIER_OFFSET_HZ * absolute / lab045.SAMPLE_RATE_HZ))
matched *= phase
mean = np.mean(samples)
matched -= mean * dc_response(length, lab045.SAMPLES_PER_SYMBOL)
nominal = 16 * lab045.SAMPLES_PER_SYMBOL + edge
return matched, nominal
def timing(self, matched, nominal, expected):
"""Локальный FFT оказался дешевле дополнительных общих timing-массивов."""
return fast_preamble_timing(matched, nominal, expected)
def extract(self, samples: np.ndarray, frame_start: int, symbol_count: int) -> iq.SoftWindow:
key = (self.local_start(samples) + frame_start, symbol_count)
# Усечение зависит и от доступности текущего probe, а не только от start.
length = (symbol_count + 42) * lab045.SAMPLES_PER_SYMBOL
if frame_start < 0 or frame_start + length > samples.size:
return iq.SoftWindow(False, np.empty(0), 0, 0, 0, {}, "TRUNCATED_IQ_WINDOW")
if key in self.soft_cache:
self.statistics["soft_window_cache_hits"] += 1
return self.soft_cache[key]
segment = samples[frame_start:frame_start + length]
matched, nominal = lab043._matched_waveform(segment, 0, length)
bits = lab045.lab044.preamble_bits()
expected = iq.radio.bpsk_modulate(bits)
timing = lab043._preamble_timing_offset(matched, nominal, expected)
carrier = lab043._evaluate_symbols(matched, nominal, timing, bits, lab045.PREAMBLE_BIT_COUNT)
if carrier["abs_h"] == 0:
result = iq.SoftWindow(False, np.empty(0), timing, 0, nominal, carrier, "ZERO_GAIN")
else:
symbols = matched[nominal + timing:nominal + timing + symbol_count * lab045.SAMPLES_PER_SYMBOL:
lab045.SAMPLES_PER_SYMBOL].astype(np.complex128)
if symbols.size != symbol_count:
result = iq.SoftWindow(False, np.empty(0), timing, 0, nominal, carrier, "TRUNCATED_SYMBOLS")
else:
symbol_time = (np.arange(symbol_count, dtype=np.float64) + 0.5) / lab045.SYMBOL_RATE
normalized = symbols * np.exp(-2j * math.pi * carrier["cfo_hz"] * symbol_time)
normalized /= carrier["abs_h"] * np.exp(1j * carrier["phase_rad"])
result = iq.SoftWindow(True, normalized.real, int(timing),
int((frame_start + nominal + timing) % lab045.SAMPLES_PER_SYMBOL),
nominal, carrier, None)
self.soft_cache[key] = result
self.statistics["unique_soft_windows"] += 1
return result
@contextmanager
def optimized_context(front: BlockFrontEnd):
targets = ((lab043, "_refine_frame_start", front.refine),
(lab043, "_matched_waveform", front.matched_window),
(lab043, "_preamble_timing_offset", front.timing),
(iq, "_extract_soft_window", front.extract),
(lab045.conv, "viterbi_decode_soft", fast_viterbi),
(offline, "_correlation_scores", front.coarse_scores))
originals = [(owner, name, getattr(owner, name)) for owner, name, unused in targets]
try:
for owner, name, function in targets:
setattr(owner, name, function)
yield
finally:
for owner, name, function in reversed(originals):
setattr(owner, name, function)
class StreamingReceiver(previous.StreamingReceiver):
"""Совместимый API Lab050, ускорение только для рабочей точки 500 ксимв/с."""
def __init__(self, symbol_rate: int = 500_000, *, block_samples: int = 65_536):
if symbol_rate != 500_000:
raise ValueError("Lab051 исследует только 500 ксимв/с")
super().__init__(symbol_rate, block_samples=block_samples)
self.front_statistics = Counter()
self.decimator = DecimationCache(self.decimation)
def _scan(self, owned_end: int) -> list[previous.ReceivedPacket]:
front = BlockFrontEnd(self.buffer, owned_end - self.base_sample, self.front_statistics,
decimator=self.decimator, base=self.base_sample)
with optimized_context(front):
return super()._scan(owned_end)
def finish(self):
result = super().finish()
self.decimator.values = np.empty(0, np.complex64)
return result
def run_functional_tests() -> None:
receiver = StreamingReceiver()
assert receiver.mode.samples_per_symbol == 16
assert receiver.finish() == []

View File

@@ -0,0 +1,287 @@
"""Lab052: локальные группы пиков и дешёвая проверка частей PRBS7.
Кандидат никогда не становится пакетом без прежних PHY/FEC/CRC. Параметры
заданы длинами PHY и моделью шума, без ожидаемых стартов и содержимого TX.
"""
from __future__ import annotations
from collections import Counter
from contextlib import contextmanager
from dataclasses import dataclass
import math
from pathlib import Path
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 fftconvolve
from experiments import lab051_streaming_receiver as previous
phy = previous.phy
offline = previous.offline
SAMPLES_PER_SYMBOL = 16
PREAMBLE_SAMPLES = phy.lab045.PREAMBLE_BIT_COUNT * SAMPLES_PER_SYMBOL
COARSE_ERROR_SAMPLES = 2000 + 8 # исходное уточнение ±2000 плюс сетка децимации
MIN_FRAME_SAMPLES = (phy.lab045.PREAMBLE_BIT_COUNT + phy.lab045.PHY_HEADER_BIT_COUNT
+ phy.lab045.conv.encoded_bit_count(phy.lab045.MIN_PACKET_BYTES * 8)
+ 2 * phy.lab045.GUARD_SYMBOL_COUNT + phy.lab045.RRC_SPAN_SYMBOLS) * SAMPLES_PER_SYMBOL
CLUSTER_WIDTH_SAMPLES = PREAMBLE_SAMPLES
PART_FALSE_ALARM = .01
@dataclass(frozen=True)
class DetectorRule:
"""parts=0 оставляет только группировку; 1/2/3 нужны для сравнения правил."""
parts: int = 2
cluster_width_samples: int = CLUSTER_WIDTH_SAMPLES
def __post_init__(self):
if type(self.parts) is not int or not 0 <= self.parts <= 3:
raise ValueError("Нужно от нуля до трёх частей")
if (type(self.cluster_width_samples) is not int or self.cluster_width_samples < 0
or self.cluster_width_samples % 8
or self.cluster_width_samples >= MIN_FRAME_SAMPLES - 2 * COARSE_ERROR_SAMPLES):
raise ValueError("Окно должно различать два минимальных физических кадра")
def peak_groups(indexes: np.ndarray, width: int):
"""Полная ширина от первого пика, без продолжения по цепочке соседей.
На границе IQ-блока группа обрывается: возможна лишняя проверка, но ни
один следующий пик не теряется из-за представителя из другого блока.
"""
cursor = 0
while cursor < len(indexes):
end = int(np.searchsorted(indexes, indexes[cursor] + width, side="right"))
yield indexes[cursor:end]
cursor = end
def cluster_peak_indexes(scores: np.ndarray, width: int, threshold: float) -> np.ndarray:
"""Первые представители ограниченных групп; равенство → правый пик."""
peaks = offline._all_local_peak_indexes(scores)
peaks = peaks[scores[peaks] >= threshold]
return np.asarray([group[len(group) - 1 - int(np.argmax(scores[group[::-1]]))]
for group in peak_groups(peaks, width)], dtype=np.int64)
def part_correlations(values: np.ndarray, reference: np.ndarray, count: int) -> np.ndarray:
"""Несколько vdot на уже децимированном согласованном входе, без timing.
Модуль сохраняет неизвестную фазу/полярность и не вводит ограничения CFO
через согласование фаз между половинами. Каждая часть нормируется своей
энергией, поэтому усиление и смена мощности фона не требуют TX reference.
"""
if values.size != reference.size or count < 1:
return np.zeros(max(count, 0))
boundaries = np.linspace(0, reference.size, count + 1, dtype=int)
output = np.zeros(count)
for number, (low, high) in enumerate(zip(boundaries[:-1], boundaries[1:])):
part = values[low:high]
expected = reference[low:high]
energy = float(np.vdot(part, part).real) * float(np.vdot(expected, expected).real)
output[number] = abs(np.vdot(expected, part)) / math.sqrt(max(energy, 1e-30))
return output
def part_threshold(count: int) -> float:
"""Номинальная H0-модель: независимые комплексные отсчёты по символам.
|rho|²~Beta(1,N-1), alpha=0.01 на часть. Это исходная инженерная настройка,
не обещание точной вероятности после RRC/выбора пиков: фон проверяется отдельно.
Она фиксируется до положительных опытов и не подгоняется под их offsets.
"""
symbols = phy.lab045.PREAMBLE_BIT_COUNT // count
return math.sqrt(1 - PART_FALSE_ALARM ** (1 / (symbols - 1)))
class CandidateFrontEnd(previous.BlockFrontEnd):
"""Общая корреляция — согласованный фильтр преамбулы на двух выборках/символ.
Промежуточному этапу достаточно его исходных данных; полный общий RRC-
выход создаётся прежним строгим трактом только при прошедшем кандидате.
"""
def coarse_scores(self, samples, reference):
raw = self.decimator.get(samples, self.base)
mean = np.mean(raw, dtype=np.complex128).astype(np.complex64)
needed = math.ceil(self.owned_end / 8) + reference.size + 1
self.detection_values = raw[:needed].copy()
self.detection_values -= mean
self.detection_values *= previous.detection_rotation(self.detection_values.size, 8)
if self.detection_values.size < reference.size:
return np.empty(0, np.float32)
scores = previous.lab043._normalized_sliding_correlation(self.detection_values, reference).astype(np.float32)
scores[~np.isfinite(scores)] = 0
np.clip(scores, 0, 1, out=scores)
return scores
def split_scores(self, reference, count, score_count):
"""Общие корреляции частей для всех кандидатов, без per-candidate FFT.
Проверка выполняется ДО выбора представителя: сильный случайный пик
одной половины не может скрыть согласованный слабый кандидат рядом.
"""
output = np.empty((score_count, count), np.float32)
boundaries = np.linspace(0, reference.size, count + 1, dtype=int)
energy = np.concatenate(([0.], np.cumsum(np.abs(self.detection_values) ** 2, dtype=np.float64)))
for number, (low, high) in enumerate(zip(boundaries[:-1], boundaries[1:])):
expected = reference[low:high]
correlation = fftconvolve(self.detection_values[low:], np.conj(expected[::-1]), mode="valid")[:score_count]
local_energy = energy[high:high + score_count] - energy[low:low + score_count]
denominator = np.sqrt(np.maximum(local_energy * float(np.vdot(expected, expected).real), 1e-30))
output[:, number] = np.abs(correlation) / denominator
return output
class StreamingReceiver(previous.StreamingReceiver):
"""Ограниченный IQ и кандидаты только одного блока, API получает только IQ."""
def __init__(self, symbol_rate: int = 500000, *, block_samples: int = 65536,
rule: DetectorRule = DetectorRule(), observer=None):
super().__init__(symbol_rate, block_samples=block_samples)
self.rule, self.observer = rule, observer
self.detection_statistics = Counter()
self.cheap_seconds = self.cluster_seconds = 0.0
self.true_strict_seconds = self.false_strict_seconds = 0.0
self.peak_cluster_count = self.peak_eligible_count = self.candidate_state_count = 0
self.working_detection_arrays_peak_bytes = 0
with previous.previous.streaming_mode(self.mode):
self.raw_reference = phy.lab045.lab044._preamble_reference_samples().copy()
self.matched_taps = previous.lab043._matched_filter_taps().copy()
self.preamble_bits = phy.lab045.lab044.preamble_bits().copy()
for values in (self.raw_reference, self.matched_taps, self.preamble_bits):
values.flags.writeable = False
@contextmanager
def constant_templates(self):
"""Старый адаптер очищает lru на каждом push; эти три константы локальны.
Возвращаем именно функции до выхода streaming_mode, чтобы его
cache_clear и соседний старый режим работали по прежним правилам.
"""
targets = ((phy.lab045.lab044, "_preamble_reference_samples", lambda: self.raw_reference),
(previous.lab043, "_matched_filter_taps", lambda: self.matched_taps),
(phy.lab045.lab044, "preamble_bits", lambda: self.preamble_bits))
originals = [(owner, name, getattr(owner, name)) for owner, name, function in targets]
try:
for owner, name, function in targets:
setattr(owner, name, function)
yield
finally:
for owner, name, function in reversed(originals):
setattr(owner, name, function)
def _scan(self, owned_end):
with self.constant_templates():
return self._scan_with_templates(owned_end)
def _scan_with_templates(self, owned_end):
width = self.rule.cluster_width_samples // self.decimation
front = CandidateFrontEnd(self.buffer, owned_end - self.base_sample,
self.front_statistics, decimator=self.decimator, base=self.base_sample)
started = time.perf_counter()
scores = front.coarse_scores(self.buffer, self.reference)
self.coarse_seconds += time.perf_counter() - started
threshold = np.float32(phy.lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD)
peaks = offline._all_local_peak_indexes(scores)
offsets = self.base_sample + peaks * self.decimation
owned = (offsets >= self.scan_sample) & (offsets < owned_end)
owned_peaks = peaks[owned]
first_stage = owned_peaks[scores[owned_peaks] >= threshold]
self.detection_statistics["total_local_peaks"] += len(owned_peaks)
self.detection_statistics["first_stage_candidates"] += len(first_stage)
self.peak_candidate_count = max(self.peak_candidate_count, len(first_stage))
if self.observer is not None:
self.observer.scores(scores, self.base_sample, self.scan_sample, owned_end, self.decimation)
started = time.perf_counter()
parts = (front.split_scores(self.reference, self.rule.parts, len(scores))
if self.rule.parts and len(scores) else np.empty((len(scores), 0)))
passed = (np.all(parts[first_stage] >= part_threshold(self.rule.parts), axis=1)
if self.rule.parts else np.ones(len(first_stage), dtype=bool))
self.cheap_seconds += time.perf_counter() - started
self.detection_statistics["cheap_stage_checked"] += len(first_stage)
self.detection_statistics["cheap_stage_rejected"] += int(np.sum(~passed))
indexes = first_stage[passed]
if self.observer is not None and hasattr(self.observer, "cheap"):
for index, keep in zip(first_stage, passed):
self.observer.cheap(self.base_sample + int(index) * self.decimation,
float(scores[index]), parts[index], bool(keep))
started = time.perf_counter()
groups = list(peak_groups(indexes, width))
self.cluster_seconds += time.perf_counter() - started
self.detection_statistics["clusters"] += len(groups)
self.peak_cluster_count = max(self.peak_cluster_count, len(groups))
self.candidate_state_count = len(indexes)
self.peak_eligible_count = max(self.peak_eligible_count, len(indexes))
self.working_detection_arrays_peak_bytes = max(self.working_detection_arrays_peak_bytes,
front.detection_values.nbytes + scores.nbytes * 4 + parts.nbytes + (len(peaks) + len(indexes)) * 24)
emitted = []
try:
with previous.optimized_context(front):
# Внутри группы — самый сильный первым. Отказ не даёт права
# подавить альтернативы: пробуем следующий. Только CRC PASS
# разрешает прежний пропуск уже принятого физического кадра.
ordered = [group[np.argsort(scores[group], kind="stable")[::-1]] for group in groups]
attempted_groups = set()
for group_number, index in ((number, item) for number, group in enumerate(ordered) for item in group):
absolute = self.base_sample + int(index) * self.decimation
# Сетка грубого поиска может округлить следующий стык на
# одну выборку назад. Не терять соседний физический кадр.
if absolute < self.accepted_until - self.decimation:
self.detection_statistics["inside_accepted_frame"] += 1
continue
self.detection_statistics["alternative_checks" if group_number in attempted_groups
else "representative_checks"] += 1
attempted_groups.add(group_number)
self.candidate_count += 1
candidate = offline.CoarseCandidate(int(index) * self.decimation, float(scores[index]))
started = time.perf_counter()
outcome = offline.decode_candidate_from_memmap(
self.buffer.view(np.float32), self.buffer.size, candidate, self.candidate_count,
read_iq_window=lambda raw, low, high: raw[2 * low:2 * high].view(np.complex64))
elapsed = time.perf_counter() - started
self.strict_seconds += elapsed
if self.observer is not None:
self.observer.outcome(self.base_sample, candidate, outcome)
if outcome.accepted and outcome.crc == phy.lab045.CRC_PASS and outcome.frame is not None:
self.true_strict_seconds += elapsed
self.accepted_until = self.base_sample + outcome.next_sample
self.packet_count += 1
emitted.append(previous.previous.ReceivedPacket(
self.base_sample + outcome.refined_start_sample, self.accepted_until,
self.total_samples, bytes(outcome.frame.reception.payload_bytes)))
else:
self.false_strict_seconds += elapsed
self.rejected_reasons[outcome.reason] += 1
finally:
self.candidate_state_count = 0
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 run_functional_tests() -> None:
assert CLUSTER_WIDTH_SAMPLES < MIN_FRAME_SAMPLES - 2 * COARSE_ERROR_SAMPLES
assert .08 < part_threshold(2) < .09
assert StreamingReceiver().finish() == []
def main() -> None:
run_functional_tests()
print("Lab052 detector functional checks PASS; RF TX=0")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,157 @@
"""Lab052: распределения и численные проверки после независимых решений RX."""
from __future__ import annotations
import json
from pathlib import Path
import sys
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
from experiments.lab052_detector_study import OUTPUT, quantiles, write_result
from experiments import lab052_candidate_detection as detection
from experiments.lab051_receiver_profile import array_observation
def measure_arrays() -> dict:
"""Отдельный короткий RX replay, без смешивания tracing с замером скорости.
ndarray в Python-locals даёт нижнюю границу наблюдаемых массивов; C/SciPy
могут иметь невидимые временные выделения. tracemalloc peak — отдельная
измеренная величина, не RSS и не универсальная верхняя граница памяти.
"""
physical = detection.phy.lab045
payload = bytes(range(256))
plan = physical.build_transmission_plan(payload)
raw = detection.phy.build_parameterized_tx_cs8(plan, detection.phy.PhyMode(500000))
components = np.frombuffer(raw, np.int8).astype(np.float32)
values = components[0::2] + 1j * components[1::2]
random_generator = np.random.default_rng(52040)
sigma = np.sqrt(float(np.sum(abs(values)**2)) / plan.frame_symbol_count / (2 * 10**.7))
values += sigma * (random_generator.normal(size=len(values)) + 1j*random_generator.normal(size=len(values)))
samples = .2 * (random_generator.normal(size=63217 + len(values) + 350000)
+ 1j * random_generator.normal(size=63217 + len(values) + 350000))
samples[63217:63217 + len(values)] = values
samples = samples.astype(np.complex64)
def replay():
receiver = detection.StreamingReceiver()
packets = []
for start in range(0, len(samples), receiver.block_samples):
packets.extend(receiver.push_iq_block(samples[start:start + receiver.block_samples]))
packets.extend(receiver.finish())
assert len(packets) == 1 and packets[0].payload == payload
result = {**array_observation(replay), "iq_seconds": len(samples) / 8000000,
"packets": 1, "interpretation": "observed Python ndarray lower bound; tracked RX allocation peak; input IQ prepared before tracing",
"timing_not_used_as_benchmark": True, "rf_tx": 0}
write_result("allocations.json", result)
return result
def analyze() -> dict:
baseline = json.loads((OUTPUT / "baseline_profile.json").read_text(encoding="utf-8"))
optimized = json.loads((OUTPUT / "parts2.json").read_text(encoding="utf-8"))
before = np.load(OUTPUT / "baseline_observations.npz")
after = np.load(OUTPUT / "parts2_observations.npz")
peaks, outcomes = before["peaks"], before["outcomes"]
true = outcomes[outcomes[:, 3] == 1]
false = outcomes[outcomes[:, 3] == 0]
starts = np.sort(true[:, 2].astype(np.int64))
new_starts = np.sort(after["outcomes"][after["outcomes"][:, 3] == 1, 2].astype(np.int64))
missing = np.setdiff1d(starts, new_starts)
unexpected = np.setdiff1d(new_starts, starts)
main_scores = []
for start in starts:
low, high = np.searchsorted(peaks[:, 0], [start - detection.COARSE_ERROR_SAMPLES,
start + detection.COARSE_ERROR_SAMPLES], side="left")
main_scores.append(float(peaks[low:high, 1].max()))
# Окна чистого фона выделяются ТОЛЬКО после независимых принятых кадров.
# В этом опыте decoded PHY packet 268B => waveform 92256 samples.
frame_samples = (detection.phy.lab045.PREAMBLE_BIT_COUNT + detection.phy.lab045.PHY_HEADER_BIT_COUNT
+ detection.phy.lab045.conv.encoded_bit_count((256 + detection.phy.lab045.MIN_PACKET_BYTES) * 8)
+ 42) * 16
blocks = baseline["detection_profile"]["blocks"]
noise_blocks = []
for block in blocks:
low, high = block["start_sample"], block["end_sample"] + (1280 + 42) * 16
if not np.any((starts < high) & (starts + frame_samples > low)):
noise_blocks.append(block)
noise_statistics = {name: quantiles([block["score_statistics"][name] for block in noise_blocks])
for name in ("median", "mad", "p90", "p99", "p999")}
cheap = np.load(OUTPUT / "parts2_cheap.npy")
accepted_coarse = after["outcomes"][after["outcomes"][:, 3] == 1, 0]
is_true = np.isin(cheap[:, 0], accepted_coarse)
part_min = cheap[:, 3:].min(axis=1)
false_count = len(false)
new_false = optimized["detector"]["strict_false_candidates"]
result = {
"classification": "strict PHY/FEC/CRC verdict first; no diagnostic feedback into RX",
"baseline_true_unique_starts": len(np.unique(starts)),
"optimized_true_unique_starts": len(np.unique(new_starts)),
"missing_true_starts": missing.tolist(), "unexpected_true_starts": unexpected.tolist(),
"true_main_peak_scores": quantiles(main_scores), "false_strict_scores": quantiles(false[:, 1]),
"baseline_false_refined_positions": len(np.unique(false[:, 2])),
"baseline_false_repeated_refinements": len(false) - len(np.unique(false[:, 2])),
"noise_only_blocks": len(noise_blocks), "noise_block_statistics": noise_statistics,
"split_minimum_true_strict": quantiles(part_min[is_true]),
"split_minimum_other_candidates": quantiles(part_min[~is_true]),
"strict_false_reduction": false_count / new_false,
"strict_false_reduction_fraction": 1 - new_false / false_count,
"first_stage_threshold_unchanged": detection.phy.lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD,
"part_threshold": detection.part_threshold(2), "rf_tx": 0,
}
write_result("statistics.json", result)
# Научные статические рисунки сохраняются самостоятельными артефактами.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
figure, axes = plt.subplots(2, 2, figsize=(12, 8), constrained_layout=True)
axis = axes[0, 0]
bins = np.linspace(0, 1, 101)
axis.hist(false[:, 1], bins, histtype="step", label="Ложные строгие кандидаты", log=True)
axis.hist(main_scores, bins, histtype="step", label="Главный пик настоящей преамбулы", log=True)
axis.axvline(result["first_stage_threshold_unchanged"], color="gray", linestyle="--", label="Первый порог")
axis.set(xlabel="Нормированная корреляция", ylabel="Количество", title="Классификация после PHY/FEC/CRC")
axis.legend(fontsize=8)
axis = axes[0, 1]
for name, label in (("median", "Медиана"), ("mad", "MAD"), ("p99", "Квантиль 99%")):
axis.plot([block["start_sample"] / 8000000 for block in noise_blocks],
[block["score_statistics"][name] for block in noise_blocks], ".", markersize=2, label=label)
axis.set(xlabel="Время IQ, с", ylabel="Корреляционная статистика", title="Блоки чистого фона, определённые после RX")
axis.legend(fontsize=8)
axis = axes[1, 0]
axis.hist(part_min[~is_true], bins, histtype="step", log=True, label="Остальные локальные кандидаты")
axis.hist(part_min[is_true], bins, histtype="step", log=True, label="Кандидаты с последующим CRC PASS")
axis.axvline(detection.part_threshold(2), color="gray", linestyle="--", label="Порог каждой половины")
axis.set(xlabel="Меньшая корреляция двух половин", ylabel="Количество", title="Дешёвая проверка обеих частей")
axis.legend(fontsize=8)
axis = axes[1, 1]
rule_data = json.loads((OUTPUT / "rule_comparison.json").read_text(encoding="utf-8"))["rules"]
counts = [row["detector"]["strict_false_candidates"] for row in rule_data]
bars = axis.bar(["Группы", "1 часть", "2 части", "3 части"], counts)
axis.set_yscale("log")
for bar, count in zip(bars, counts):
axis.text(bar.get_x() + bar.get_width()/2, count*1.15, str(count), ha="center", fontsize=9)
axis.set(ylabel="Ложные строгие проверки", title="Все четыре варианта сохранили 240 пакетов")
figure.savefig(OUTPUT / "detector_distributions.png", dpi=160)
figure.savefig(OUTPUT / "detector_distributions.svg")
plt.close(figure)
return result
def run_functional_tests() -> None:
assert detection.part_threshold(2) < detection.part_threshold(3)
def main() -> None:
result = analyze()
print(json.dumps(result, ensure_ascii=False, indent=2))
print(json.dumps(measure_arrays(), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,374 @@
"""Lab052: воспроизводимый профиль обнаружителя, только программный IQ.
Диагностическая история принадлежит эксперименту. Приёмник не получает её
обратно; эталон используется только после независимых решений PHY/FEC/CRC.
"""
from __future__ import annotations
import argparse
import cProfile
from collections import Counter
from dataclasses import replace
import hashlib
import json
from pathlib import Path
import sys
import time
from types import SimpleNamespace
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
from PIL import Image
from experiments import lab051_streaming_receiver as previous
from experiments import lab051_receiver_performance as performance
from experiments.lab051_receiver_profile import process_memory
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "data/processed/lab052"
def write_result(name: str, value: dict) -> None:
OUTPUT.mkdir(parents=True, exist_ok=True)
(OUTPUT / name).write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def sequence_fixture():
"""Тот же генератор Lab051, готовность JPEG фиксируется первым замером.
Прежний опыт не сохранял encode_seconds, поэтому побитную историческую
временную сетку восстановить нельзя. Все новые сравнения используют одну
сохранённую сетку, один seed и те же байты JPEG, включая реальную encode cost.
"""
with Image.open(ROOT / "data/raw/lab011_scene.jpg") as source:
sequence = performance.video.encode_sequence(source, performance.video.PROFILES[2], 30)
path = OUTPUT / "source_fixture.json"
if path.exists():
fixture = json.loads(path.read_text(encoding="utf-8"))
for item, recorded in zip(sequence, fixture["images"], strict=True):
assert hashlib.sha256(item.jpeg).hexdigest() == recorded["sha256"]
return [replace(item, encode_seconds=recorded["encode_seconds"])
for item, recorded in zip(sequence, fixture["images"], strict=True)]
write_result(path.name, {"seed": 50050, "description": "Lab051 generator; frozen measured JPEG readiness",
"images": [{"image_id": item.image_id, "bytes": len(item.jpeg),
"encode_seconds": item.encode_seconds,
"sha256": hashlib.sha256(item.jpeg).hexdigest()} for item in sequence]})
return sequence
def quantiles(values) -> dict:
values = np.asarray(values, dtype=np.float64)
if not values.size:
return {"count": 0}
median = float(np.median(values))
return {"count": values.size, "min": float(values.min()), "median": median,
"mad": float(np.median(np.abs(values - median))),
"p90": float(np.quantile(values, .9)), "p99": float(np.quantile(values, .99)),
"p999": float(np.quantile(values, .999)), "max": float(values.max())}
class DetectionObserver:
"""Полная история только для конечного лабораторного профиля, вне RX."""
def __init__(self):
self.peaks = []
self.blocks = []
self.outcomes = []
self.cheap_rows = []
def scores(self, scores, base, low, high, decimation):
indexes = previous.offline._all_local_peak_indexes(scores)
offsets = base + indexes * decimation
selected = (offsets >= low) & (offsets < high)
self.peaks.append(np.column_stack((offsets[selected], scores[indexes[selected]])))
first = max(0, (low - base + decimation - 1) // decimation)
last = min(len(scores), (high - base + decimation - 1) // decimation)
self.blocks.append({"start_sample": low, "end_sample": high,
"score_statistics": quantiles(scores[first:last]),
"peak_statistics": quantiles(scores[indexes[selected]])})
def outcome(self, base, candidate, outcome):
self.outcomes.append((base + candidate.sample_offset, candidate.score,
base + outcome.refined_start_sample, int(outcome.accepted)))
def cheap(self, absolute, score, parts, passed):
self.cheap_rows.append([absolute, score, int(passed), *parts])
def save(self, name, result):
peaks = np.concatenate(self.peaks) if self.peaks else np.empty((0, 2))
outcomes = np.asarray(self.outcomes).reshape(-1, 4)
np.savez_compressed(OUTPUT / f"{name}_observations.npz", peaks=peaks, outcomes=outcomes)
if self.cheap_rows:
np.save(OUTPUT / f"{name}_cheap.npy", np.asarray(self.cheap_rows))
threshold = previous.phy.lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD
valid = outcomes[:, 3] == 1
# Сначала строгий вердикт, затем классификация корреляционной статистики.
result["detection_profile"] = {
"total_local_peaks": len(peaks), "first_stage_candidates": int(np.sum(peaks[:, 1] >= threshold)),
"strict_candidates": len(outcomes), "strict_true_candidates": int(np.sum(valid)),
"strict_false_candidates": int(np.sum(~valid)),
"candidates_per_rf_second": len(outcomes) / result["iq_seconds"],
"false_candidates_per_rf_second": int(np.sum(~valid)) / result["iq_seconds"],
"all_local_peaks": quantiles(peaks[:, 1]),
"strict_true_scores": quantiles(outcomes[valid, 1]),
"strict_false_scores": quantiles(outcomes[~valid, 1]),
"blocks": self.blocks,
}
class ProfileBaseline(previous.StreamingReceiver):
"""Штатный Lab051 с наблюдением после корреляции и после решения."""
def __init__(self, observer):
super().__init__()
self.observer = observer
def _scan(self, owned_end):
front = previous.BlockFrontEnd(self.buffer, owned_end - self.base_sample, self.front_statistics,
decimator=self.decimator, base=self.base_sample)
original_scores = front.coarse_scores
def scores(samples, reference):
values = original_scores(samples, reference)
self.observer.scores(values, self.base_sample, self.scan_sample, owned_end, self.decimation)
return values
front.coarse_scores = scores
original_decode = previous.offline.decode_candidate_from_memmap
def decode(raw, count, candidate, number, **kwargs):
outcome = original_decode(raw, count, candidate, number, **kwargs)
self.observer.outcome(self.base_sample, candidate, outcome)
return outcome
try:
previous.offline.decode_candidate_from_memmap = decode
with previous.optimized_context(front):
return previous.previous.StreamingReceiver._scan(self, owned_end)
finally:
previous.offline.decode_candidate_from_memmap = original_decode
def measured_stream(sequence, factory):
"""Повторно использовать исходную событийную шкалу с фактическими push times."""
instances = []
def create():
receiver = factory()
instances.append(receiver)
return receiver
original = performance.fast
try:
performance.fast = SimpleNamespace(StreamingReceiver=create)
result = performance.run_stream(sequence)
finally:
performance.fast = original
receiver = instances[0]
result["coarse_seconds"] = receiver.coarse_seconds
result["strict_seconds"] = receiver.strict_seconds
result["peak_candidates_in_block"] = receiver.peak_candidate_count
if hasattr(receiver, "detection_statistics"):
result["detector"] = {**dict(receiver.detection_statistics),
"strict_true_candidates": receiver.packet_count,
"strict_false_candidates": receiver.candidate_count - receiver.packet_count,
"strict_checks_per_rf_second": receiver.candidate_count / result["iq_seconds"],
"cheap_seconds": receiver.cheap_seconds, "cluster_seconds": receiver.cluster_seconds,
"true_strict_seconds": receiver.true_strict_seconds,
"false_strict_seconds": receiver.false_strict_seconds,
"peak_cluster_count": receiver.peak_cluster_count,
"peak_eligible_count": receiver.peak_eligible_count,
"candidate_state_after_finish": receiver.candidate_state_count,
"detection_arrays_peak_estimate_bytes": receiver.working_detection_arrays_peak_bytes}
return result, receiver
def profile_baseline(sequence):
observer = DetectionObserver()
result, receiver = measured_stream(sequence, lambda: ProfileBaseline(observer))
observer.save("baseline", result)
result["timing_includes_diagnostic_observer"] = True
write_result("baseline_profile.json", result)
print(json.dumps({key: value for key, value in result.items() if key in (
"candidate_count", "received_images", "rx_physical_packets", "processing_per_rf_second",
"coarse_seconds", "strict_seconds")}), flush=True)
print(json.dumps({key: value for key, value in result["detection_profile"].items() if key != "blocks"}), flush=True)
def unprofiled_baseline(sequence):
result, receiver = measured_stream(sequence, previous.StreamingReceiver)
result["timing_includes_diagnostic_observer"] = False
write_result("baseline_stream.json", result)
print(json.dumps({key: result[key] for key in ("candidate_count", "processing_seconds",
"processing_per_rf_second", "rx_physical_packets", "received_images", "object_jpeg_integrity")}), flush=True)
def run_functional_tests() -> None:
assert quantiles([1, 2, 3])["median"] == 2
def compare_rules(sequence):
from experiments import lab052_candidate_detection as detection
baseline = json.loads((OUTPUT / "baseline_profile.json").read_text(encoding="utf-8"))
results = []
for parts in (0, 1, 2, 3):
observer = DetectionObserver()
rule = detection.DetectorRule(parts=parts)
result, receiver = measured_stream(sequence, lambda: detection.StreamingReceiver(rule=rule, observer=observer))
observer.save(f"parts{parts}", result)
result["rule"] = {"parts": parts, "width_samples": rule.cluster_width_samples,
"part_threshold": detection.part_threshold(parts) if parts else None}
result["true_candidates_lost"] = baseline["rx_physical_packets"] - result["rx_physical_packets"]
false_count = result["detector"]["strict_false_candidates"]
baseline_false = baseline["detection_profile"]["strict_false_candidates"]
result["false_candidate_reduction"] = baseline_false / false_count if false_count else None
result["false_candidate_reduction_fraction"] = 1 - false_count / baseline_false
result["timing_includes_diagnostic_observer"] = True
write_result(f"parts{parts}.json", result)
compact = {key: result[key] for key in ("rule", "received_images", "object_jpeg_integrity",
"true_candidates_lost", "false_candidate_reduction", "detector",
"processing_per_rf_second", "fresh_display_fps")}
results.append(compact)
print(json.dumps(compact), flush=True)
write_result("rule_comparison.json", {"rules": results, "rf_tx": 0})
def optimized_stream(sequence):
from experiments import lab052_candidate_detection as detection
results = []
for number in range(1, 4):
result, receiver = measured_stream(sequence, detection.StreamingReceiver)
result["timing_includes_diagnostic_observer"] = False
write_result(f"stream_repeat_{number}.json", result)
results.append(result)
print(json.dumps({"repeat": number, "ratio": result["processing_per_rf_second"],
"fresh_fps": result["fresh_display_fps"], "packets": result["rx_physical_packets"]}), flush=True)
result = sorted(results, key=lambda row: row["processing_seconds"])[1]
result["selection"] = "median processing time of three complete measured passes; one whole timeline"
write_result("stream.json", result)
print(json.dumps({key: value for key, value in result.items() if key not in (
"blocks", "images", "rss_samples", "fresh_image_age_seconds")}), flush=True)
def profile_cpu(sequence):
from experiments import lab052_candidate_detection as detection
import pstats
from io import StringIO
profiler = cProfile.Profile()
class ProfiledReceiver(detection.StreamingReceiver):
def push_iq_block(self, samples):
return profiler.runcall(super().push_iq_block, samples)
def finish(self):
return profiler.runcall(super().finish)
result, receiver = measured_stream(sequence, ProfiledReceiver)
profiler.dump_stats(str(OUTPUT / "receiver_cpu.prof"))
report = StringIO()
stats = pstats.Stats(profiler, stream=report).strip_dirs()
stats.sort_stats("tottime").print_stats(30)
stats.sort_stats("cumtime").print_stats(30)
(OUTPUT / "receiver_cpu.txt").write_text(report.getvalue(), encoding="utf-8")
result["timing_includes_cprofile"] = True
write_result("cpu_profile.json", result)
print(report.getvalue(), flush=True)
def long_state_test(sequence=None, block_count=2048):
"""16,78 с распределённого шума, меняющаяся мощность, без истории в RX."""
from experiments import lab052_candidate_detection as detection
receiver = detection.StreamingReceiver()
random_generator = np.random.default_rng(52030)
checkpoints = []
accepted = 0
for number in range(block_count):
components = random_generator.standard_normal((2, receiver.block_samples), dtype=np.float32)
scale = (.002, .2, 20., .02)[(number // 257) % 4]
values = (components[0] + 1j * components[1]) * scale
accepted += len(receiver.push_iq_block(values))
assert receiver.buffer.size <= receiver.buffer_limit_samples
assert receiver.candidate_state_count == 0
if (number + 1) % 256 == 0:
checkpoints.append({"block": number + 1, "iq_bytes": receiver.buffer.nbytes,
"decimated_bytes": receiver.decimator.values.nbytes,
"retained_candidates": receiver.candidate_state_count,
"peak_eligible_candidates": receiver.peak_eligible_count,
"counter_keys": len(receiver.detection_statistics) + len(receiver.rejected_reasons),
**process_memory()})
accepted += len(receiver.finish())
result = {"blocks": block_count, "iq_seconds": block_count * receiver.block_samples / 8000000,
"background_scales": [.002, .2, 20, .02], "accepted_noise_packets": accepted,
"peak_retained_iq_bytes": receiver.peak_buffer_samples * 8,
"iq_limit_bytes": receiver.buffer_limit_samples * 8,
"retained_after_finish_bytes": receiver.buffer.nbytes + receiver.decimator.values.nbytes,
"constant_template_bytes": receiver.raw_reference.nbytes + receiver.matched_taps.nbytes + receiver.preamble_bits.nbytes,
"candidate_state_after_finish": receiver.candidate_state_count,
"peak_eligible_candidates": receiver.peak_eligible_count,
"peak_cluster_count": receiver.peak_cluster_count,
"detector": dict(receiver.detection_statistics), "strict_candidates": receiver.candidate_count,
"processing_seconds": receiver.processing_seconds, "checkpoints": checkpoints,
"process_memory": process_memory(), "rf_tx": 0}
write_result("memory.json", result)
print(json.dumps(result), flush=True)
def sensitivity_test(sequence=None):
"""До/после на одинаковом IQ около границы работоспособности FEC."""
from experiments import lab052_candidate_detection as detection
physical = previous.phy.lab045
payload = bytes(range(256))
plan = physical.build_transmission_plan(payload)
raw = previous.phy.build_parameterized_tx_cs8(plan, previous.phy.PhyMode(500000))
components = np.frombuffer(raw, np.int8).astype(np.float32)
clean = components[0::2] + 1j * components[1::2]
energy = float(np.sum(np.abs(clean) ** 2)) / plan.frame_symbol_count
rows = []
for ebn0 in (0, 1, 2, 3, 7):
for cfo in (-100, 10, 100):
for seed, scale in ((52031, .002), (52032, .2), (52033, 1.)):
random_generator = np.random.default_rng(seed)
sigma = np.sqrt(energy / (2 * 10 ** (ebn0 / 10)))
phase = .73 + (seed % 2) * np.pi
values = clean * np.exp(1j * (phase + 2*np.pi*cfo*np.arange(len(clean))/8000000))
values += sigma * (random_generator.normal(size=len(clean)) + 1j*random_generator.normal(size=len(clean)))
samples = np.concatenate((np.zeros(63217, np.complex64), (values * scale).astype(np.complex64),
np.zeros(350000, np.complex64)))
outcomes = []
for constructor in (previous.StreamingReceiver, detection.StreamingReceiver):
receiver = constructor()
packets = []
for start in range(0, len(samples), receiver.block_samples):
packets.extend(receiver.push_iq_block(samples[start:start + receiver.block_samples]))
packets.extend(receiver.finish())
# Оценка содержимого только после завершения независимого RX.
outcomes.append({"accepted": len(packets),
"payload_match": len(packets) == 1 and packets[0].payload == payload,
"reasons": dict(receiver.rejected_reasons)})
rows.append({"ebn0_db": ebn0, "cfo_hz": cfo, "seed": seed, "amplitude_scale": scale,
"baseline": outcomes[0], "optimized": outcomes[1],
"lost_baseline_packet": outcomes[0]["payload_match"] and not outcomes[1]["payload_match"]})
result = {"cases": rows, "count": len(rows),
"baseline_correct": sum(row["baseline"]["payload_match"] for row in rows),
"optimized_correct": sum(row["optimized"]["payload_match"] for row in rows),
"lost_baseline_packets": sum(row["lost_baseline_packet"] for row in rows), "rf_tx": 0}
write_result("sensitivity.json", result)
print(json.dumps({key: value for key, value in result.items() if key != "cases"}), flush=True)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--stage", choices=("profile", "baseline", "rules", "stream", "cpu", "memory", "sensitivity"), default="profile")
arguments = parser.parse_args()
OUTPUT.mkdir(parents=True, exist_ok=True)
sequence = sequence_fixture()
print(f"Lab052 {arguments.stage}: START; RF TX=0", flush=True)
{"profile": profile_baseline, "baseline": unprofiled_baseline, "rules": compare_rules, "stream": optimized_stream,
"cpu": profile_cpu, "memory": long_state_test, "sensitivity": sensitivity_test}[arguments.stage](sequence)
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

View File

@@ -0,0 +1,160 @@
"""Регрессии вычислительной архитектуры Lab051, без файлов/оборудования."""
from __future__ import annotations
from collections import Counter
from dataclasses import replace
import numpy as np
import pytest
from scipy.signal import resample_poly
from experiments import lab051_streaming_receiver as lab
from experiments.lab050_streaming_receiver import StreamingReceiver as PreviousReceiver
from experiments.lab050_streaming_receiver import streaming_mode
@pytest.mark.parametrize("seed", range(8))
def test_all_timing_offsets_preserved_including_negative_start(seed):
random_generator = np.random.default_rng(seed)
samples = (random_generator.normal(size=23616) + 1j * random_generator.normal(size=23616)).astype(np.complex64)
with streaming_mode(lab.phy.PhyMode(500000)):
expected = lab.iq.radio.bpsk_modulate(lab.lab045.lab044.preamble_bits())
if seed % 2:
samples[416 + 47:416 + 47 + len(expected)*16:16] += expected * 3
old = lab.ORIGINAL_TIMING(samples, 416, expected)
assert lab.fast_preamble_timing(samples, 416, expected) == old
def test_zero_timing_tie_is_same_first_offset():
with streaming_mode(lab.phy.PhyMode(500000)):
expected = lab.iq.radio.bpsk_modulate(lab.lab045.lab044.preamble_bits())
assert lab.fast_preamble_timing(np.zeros(23616, np.complex64), 416, expected) == -800
@pytest.mark.parametrize("value", [1, 1j, 1+1j, 44])
def test_equal_nonzero_timing_scores_preserve_original_tie(value):
with streaming_mode(lab.phy.PhyMode(500000)):
expected = lab.iq.radio.bpsk_modulate(lab.lab045.lab044.preamble_bits())
samples = np.full(23616, value, np.complex64)
assert lab.fast_preamble_timing(samples, 416, expected) == lab.ORIGINAL_TIMING(samples,416,expected)
@pytest.mark.parametrize("value", [1, 1j, 44])
def test_refinement_constant_iq_preserves_zero_after_local_dc(value):
with streaming_mode(lab.phy.PhyMode(500000)):
samples=np.full(100000,value,np.complex64)
front=lab.BlockFrontEnd(samples,65536,Counter())
window=samples[1000:28456]
assert front.refine(window,front.reference,2000)==lab.ORIGINAL_REFINE(window,front.reference,2000)==0
def test_refinement_equal_nonzero_peaks_preserve_first_maximum():
with streaming_mode(lab.phy.PhyMode(500000)):
samples=np.zeros(100000,np.complex64)
samples[1200]=samples[2200]=1
front=lab.BlockFrontEnd(samples,65536,Counter())
reference=np.zeros(front.reference_length,np.complex64)
reference[0],reference[1]=1,-1
front.reference=reference
window=samples[1000:28456]
assert front.refine(window,reference,2000)==lab.ORIGINAL_REFINE(window,reference,2000)==199
@pytest.mark.parametrize("seed", range(5))
def test_shared_matched_preserves_local_dc_and_both_zero_padded_edges(seed):
random_generator = np.random.default_rng(seed)
values = (random_generator.normal(size=110000) + 1j * random_generator.normal(size=110000) + 4 + 3j).astype(np.complex64)
with streaming_mode(lab.phy.PhyMode(500000)):
front = lab.BlockFrontEnd(values, 70000, Counter())
segment = values[5000 + seed:5000 + seed + 23456]
expected, nominal = lab.ORIGINAL_MATCHED(segment, 0, len(segment))
actual, actual_nominal = front.matched_window(segment, 0, len(segment))
assert actual_nominal == nominal
np.testing.assert_allclose(actual, expected, atol=2e-5, rtol=2e-5)
def test_reused_decimation_is_bit_exact_for_moving_and_truncated_windows():
random_generator = np.random.default_rng(51001)
values = (random_generator.normal(size=1800000) + 1j * random_generator.normal(size=1800000)).astype(np.complex64)
cache = lab.DecimationCache(8)
for base in range(0, len(values) - 10000, 65536):
window = values[base:base + 400123]
expected = resample_poly(window, 1, 8, window=("kaiser", 8))
np.testing.assert_array_equal(cache.get(window, base), expected)
assert cache.values.size <= 50016
assert cache.reused_samples > 1000000
@pytest.mark.parametrize("seed,count", [(0, 1), (1, 32), (2, 244*8), (3, 268*8), (4, 1024*8)])
def test_viterbi_exact_bits_for_noisy_metrics_and_ties(seed, count):
random_generator = np.random.default_rng(seed)
for values in (np.zeros(lab.lab045.conv.encoded_bit_count(count)),
random_generator.normal(size=lab.lab045.conv.encoded_bit_count(count)),
random_generator.choice([-1., 1.], size=lab.lab045.conv.encoded_bit_count(count))):
np.testing.assert_array_equal(lab.fast_viterbi(values, count), lab.ORIGINAL_VITERBI(values, count))
@pytest.mark.parametrize("values,count", [(np.array([np.nan, 0.]), 1), (np.zeros((2,2)), 1),
(np.zeros(12), 1), (np.zeros(12), 0), (np.zeros(12), 1.5)])
def test_viterbi_rejects_same_invalid_input(values, count):
with pytest.raises((ValueError, TypeError)):
lab.fast_viterbi(values, count)
def signal_with_unknown_offsets(seed=51):
random_generator = np.random.default_rng(seed)
plan = lab.lab045.build_transmission_plan(bytes(range(256)))
values = lab.phy.build_parameterized_tx_complex(plan, lab.phy.PhyMode(500000)).astype(np.complex64)
values *= np.exp(1j * (0.73 + 2*np.pi*10*np.arange(len(values))/8000000)).astype(np.complex64)
values += (random_generator.normal(size=len(values)) + 1j*random_generator.normal(size=len(values))).astype(np.complex64)
return np.concatenate((np.zeros(63217,np.complex64), values, np.zeros(450000,np.complex64)))
@pytest.mark.parametrize("block", [32749, 65536])
def test_independent_stream_packets_equal_old_decoder_across_block_boundaries(block):
samples = signal_with_unknown_offsets()
results = []
for receiver in (PreviousReceiver(500000), lab.StreamingReceiver()):
output = []
for start in range(0,len(samples),block):
output.extend(receiver.push_iq_block(samples[start:start + block]))
assert [item.payload for item in output] == [bytes(range(256))]
output.extend(receiver.finish())
results.append([(item.start_sample,item.end_sample,item.payload) for item in output])
assert results[0] == results[1]
def test_invalid_push_and_finish_preserve_bounded_state():
receiver = lab.StreamingReceiver()
for invalid in (np.array([complex(float("nan"),0)]), np.zeros((1,2)), np.zeros(65537)):
with pytest.raises(ValueError): receiver.push_iq_block(invalid)
assert receiver.total_samples == 0
for unused in range(100):
assert receiver.push_iq_block(np.zeros(65536, np.complex64)) == []
assert receiver.buffer.size <= receiver.buffer_limit_samples
assert receiver.decimator.values.size <= (receiver.buffer_limit_samples + 7)//8
assert receiver.finish() == []
assert receiver.buffer.size == receiver.decimator.values.size == 0
with pytest.raises(RuntimeError): receiver.finish()
with pytest.raises(RuntimeError): receiver.push_iq_block(np.zeros(1,np.complex64))
def test_truncated_frame_cannot_reach_crc_pass():
samples = signal_with_unknown_offsets()[:100000]
receiver = lab.StreamingReceiver()
output = []
for start in range(0,len(samples),65536):
output.extend(receiver.push_iq_block(samples[start:start+65536]))
assert output + receiver.finish() == []
def test_context_restores_functions_even_on_exception():
originals = (lab.lab043._matched_waveform, lab.iq._extract_soft_window,
lab.lab045.conv.viterbi_decode_soft, lab.offline._correlation_scores)
with streaming_mode(lab.phy.PhyMode(500000)):
front = lab.BlockFrontEnd(np.zeros(100000,np.complex64),65536,Counter())
with pytest.raises(RuntimeError):
with lab.optimized_context(front): raise RuntimeError("test")
assert originals == (lab.lab043._matched_waveform, lab.iq._extract_soft_window,
lab.lab045.conv.viterbi_decode_soft, lab.offline._correlation_scores)

View File

@@ -0,0 +1,211 @@
"""Свойства локального обнаружителя Lab052 без оборудования и внешних данных."""
from __future__ import annotations
import numpy as np
import pytest
from types import SimpleNamespace
from experiments import lab052_candidate_detection as lab
from experiments.lab050_streaming_receiver import streaming_mode
def waveform(payload: bytes, *, seed: int = 52001, ebn0_db: float = 7, phase: float = .73,
cfo_hz: float = 10, scale: float = 1) -> np.ndarray:
random_generator = np.random.default_rng(seed)
# Генератор Lab045 ограничен payload=256, но приёмный PHY допускает
# packet 12..1024. Собираем короткий кадр штатными кодерами для границ.
physical = lab.phy.lab045
packet = physical.build_packet(payload, physical.MESSAGE_TYPE, physical.SEQUENCE_NUMBER)
coded = physical.conv.encode(physical.lab044.bytes_to_bits(packet))
permutation = physical.interleaver.build_permutation(len(coded), physical.INTERLEAVER_COLUMNS)
bits = np.concatenate((physical.lab044.preamble_bits(), physical.encode_phy_header(len(packet)),
physical.interleaver.interleave(coded, permutation)))
plan = SimpleNamespace(frame_bits=bits, frame_symbol_count=len(bits))
raw = lab.phy.build_parameterized_tx_cs8(plan, lab.phy.PhyMode(500000))
components = np.frombuffer(raw, np.int8).astype(np.float32)
values = components[0::2] + 1j * components[1::2]
values *= np.exp(1j * (phase + 2 * np.pi * cfo_hz * np.arange(len(values)) / 8000000)).astype(np.complex64)
energy = float(np.sum(abs(values) ** 2)) / plan.frame_symbol_count
sigma = np.sqrt(energy / (2 * 10 ** (ebn0_db / 10)))
values += sigma * (random_generator.normal(size=len(values)) + 1j * random_generator.normal(size=len(values)))
return (values * scale).astype(np.complex64)
def decode(samples, receiver):
packets = []
for start in range(0, len(samples), receiver.block_samples):
packets.extend(receiver.push_iq_block(samples[start:start + receiver.block_samples]))
assert receiver.candidate_state_count == 0 if hasattr(receiver, "candidate_state_count") else True
packets.extend(receiver.finish())
return packets
def test_cluster_of_false_peaks_has_one_strongest_representative():
scores = np.zeros(1600, np.float32)
scores[[600, 620, 654, 700, 730]] = [.05, .07, .12, .06, .04]
assert lab.cluster_peak_indexes(scores, 256, .03).tolist() == [654]
def test_two_minimum_frames_do_not_compete_even_at_opposite_coarse_errors():
distance = lab.MIN_FRAME_SAMPLES - 2 * lab.COARSE_ERROR_SAMPLES
scores = np.zeros(8000, np.float32)
indexes = [1000, 1000 + distance // 8]
scores[indexes] = [.3, .9]
assert lab.cluster_peak_indexes(scores, lab.CLUSTER_WIDTH_SAMPLES // 8, .03).tolist() == indexes
def test_equal_peaks_use_reproducible_local_tie():
scores = np.zeros(1000, np.float32)
scores[[100, 120, 600]] = .2
assert lab.cluster_peak_indexes(scores, 100, .03).tolist() == [120, 600]
def test_equal_peak_chain_does_not_extend_cluster_width():
scores = np.zeros(500, np.float32)
scores[[100, 180, 260]] = .2
assert lab.cluster_peak_indexes(scores, 100, .03).tolist() == [180, 260]
def test_single_loud_half_cannot_pass_split_check():
random_generator = np.random.default_rng(52002)
reference = random_generator.choice([-1., 1.], 2560).astype(np.complex64)
samples = (random_generator.normal(size=2560) + 1j * random_generator.normal(size=2560)).astype(np.complex64)
samples[:1280] = reference[:1280] * 100
assert lab.part_correlations(samples, reference, 1)[0] > lab.part_threshold(1)
assert np.any(lab.part_correlations(samples, reference, 2) < lab.part_threshold(2))
@pytest.mark.parametrize("phase,scale", [(0, .001), (.73, 1), (np.pi, 100), (-1.6, .1)])
def test_true_preamble_preserves_unknown_gain_phase_and_polarity(phase, scale):
random_generator = np.random.default_rng(52003)
reference = random_generator.choice([-1., 1.], 2560).astype(np.complex64)
samples = reference * np.exp(1j * phase) * scale
assert np.all(lab.part_correlations(samples, reference, 2) >= lab.part_threshold(2))
def test_noise_is_rejected_before_strict_timing(monkeypatch):
random_generator = np.random.default_rng(52004)
samples = (random_generator.normal(size=750000) + 1j * random_generator.normal(size=750000)).astype(np.complex64)
def forbidden(*args, **kwargs):
pytest.fail("Чистый шум этого seed не должен достигнуть строгого тракта")
monkeypatch.setattr(lab.offline, "decode_candidate_from_memmap", forbidden)
receiver = lab.StreamingReceiver()
assert decode(samples, receiver) == []
assert receiver.detection_statistics["cheap_stage_rejected"] > 0
assert receiver.front_statistics["unique_soft_windows"] == 0
@pytest.mark.parametrize("ebn0_db,scale,cfo", [(7, 1, 10), (2, .02, -100), (1, .2, 100)])
def test_weak_true_packet_matches_lab051(ebn0_db, scale, cfo):
payload = bytes(range(128))
values = waveform(payload, seed=52005, ebn0_db=ebn0_db, scale=scale, cfo_hz=cfo)
samples = np.concatenate((np.zeros(63217, np.complex64), values, np.zeros(350000, np.complex64)))
baseline = decode(samples, lab.previous.StreamingReceiver())
optimized = decode(samples, lab.StreamingReceiver())
assert [packet.payload for packet in optimized] == [packet.payload for packet in baseline]
assert [packet.payload for packet in baseline] == ([payload] if ebn0_db >= 2 else [])
@pytest.mark.parametrize("block_samples,prefix", [(32749, 32749 - 3), (65536, 65536 - 8),
(65536, 65536 + 7)])
def test_minimum_adjacent_frames_at_block_boundary_are_both_decoded(block_samples, prefix):
# Минимальный packet: пустой payload, заголовок+CRC=12 байт.
first = waveform(b"", seed=52006, phase=.4)
second = waveform(b"x", seed=52007, phase=-2)
assert len(first) == lab.MIN_FRAME_SAMPLES
samples = np.concatenate((np.zeros(prefix, np.complex64), first, second,
np.zeros(350000, np.complex64)))
receiver = lab.StreamingReceiver(block_samples=block_samples)
assert [packet.payload for packet in decode(samples, receiver)] == [b"", b"x"]
def test_changing_background_with_weak_packet():
random_generator = np.random.default_rng(52008)
samples = (random_generator.normal(size=700000) + 1j * random_generator.normal(size=700000)).astype(np.complex64)
samples[:180000] *= .001
samples[180000:300000] *= 20
samples[300000:] *= .03
payload = b"varying local background"
values = waveform(payload, seed=52009, ebn0_db=2, scale=.02)
samples[370013:370013 + len(values)] = values
assert [packet.payload for packet in decode(samples, lab.StreamingReceiver())] == [payload]
@pytest.mark.parametrize("true_start", [30000, 65536 - 8, 65536 + 7])
def test_false_strong_preamble_does_not_hide_weak_valid_packet(true_start):
decoy_start = true_start - 20000
samples = np.zeros(700000, np.complex64)
decoy = waveform(b"", seed=52022, ebn0_db=100)[:lab.PREAMBLE_SAMPLES + 512]
payload = b"actual valid short packet"
true = waveform(payload, seed=52021, ebn0_db=7, scale=.2)
samples[decoy_start:decoy_start + len(decoy)] += decoy
samples[true_start:true_start + len(true)] += true
expected = decode(samples, lab.previous.StreamingReceiver())
actual = decode(samples, lab.StreamingReceiver())
assert [packet.payload for packet in expected] == [payload]
assert [(packet.start_sample, packet.payload) for packet in actual] == [
(packet.start_sample, packet.payload) for packet in expected]
def test_constant_templates_are_exact_and_restored_on_exception():
receiver = lab.StreamingReceiver()
owner = lab.phy.lab045.lab044
originals = (owner._preamble_reference_samples, owner.preamble_bits, lab.previous.lab043._matched_filter_taps)
with streaming_mode(lab.phy.PhyMode(500000)):
np.testing.assert_array_equal(receiver.raw_reference, originals[0]())
np.testing.assert_array_equal(receiver.preamble_bits, originals[1]())
np.testing.assert_array_equal(receiver.matched_taps, originals[2]())
with pytest.raises(RuntimeError):
with receiver.constant_templates():
raise RuntimeError("restore templates")
assert originals == (owner._preamble_reference_samples, owner.preamble_bits, lab.previous.lab043._matched_filter_taps)
@pytest.mark.parametrize("count", [1, 2, 3])
def test_shared_split_scores_equal_individual_vdot(count):
random_generator = np.random.default_rng(52024)
values = (random_generator.normal(size=9000) + 1j * random_generator.normal(size=9000)).astype(np.complex64)
values[4000:] *= .003
reference = random_generator.choice([-1., 1.], size=2560).astype(np.complex64)
front = object.__new__(lab.CandidateFrontEnd)
front.detection_values = values
scores = front.split_scores(reference, count, len(values) - len(reference) + 1)
for index in (0, 1001, 3999, len(scores) - 1):
np.testing.assert_allclose(scores[index], lab.part_correlations(values[index:index+len(reference)], reference, count),
atol=2e-6, rtol=2e-5)
def test_bounded_candidate_state_and_finish():
random_generator = np.random.default_rng(52010)
receiver = lab.StreamingReceiver()
samples = (random_generator.normal(size=65536) + 1j * random_generator.normal(size=65536)).astype(np.complex64)
for index in range(120):
receiver.push_iq_block(samples * (.01 if index % 2 else 10))
assert receiver.candidate_state_count == 0
assert receiver.buffer.size <= receiver.buffer_limit_samples
assert receiver.decimator.values.size <= (receiver.buffer_limit_samples + 7) // 8
receiver.finish()
assert receiver.buffer.size == receiver.decimator.values.size == receiver.candidate_state_count == 0
assert receiver.peak_cluster_count <= 20
assert receiver.observer is None
def test_20k_does_not_enter_new_filter_and_mode_is_restored():
old = lab.previous.lab045.SYMBOL_RATE
with pytest.raises(ValueError):
lab.StreamingReceiver(20000)
lab.run_functional_tests()
assert lab.previous.lab045.SYMBOL_RATE == old
with streaming_mode(lab.phy.PhyMode(20000)):
assert lab.previous.lab045.SAMPLES_PER_SYMBOL == 400
assert lab.previous.lab045.SYMBOL_RATE == old
@pytest.mark.parametrize("rule", [dict(parts=4), dict(parts=-1), dict(cluster_width_samples=26720),
dict(cluster_width_samples=3)])
def test_invalid_rule_rejected(rule):
with pytest.raises(ValueError):
lab.DetectorRule(**rule)