Lab047: transmit JPEG over real RF link

This commit is contained in:
LittleSam129
2026-09-03 13:13:23 +03:00
parent b0111d2232
commit 6fb7e4ec54
11 changed files with 2858 additions and 17 deletions

View File

@@ -1097,3 +1097,176 @@ passed и 0 failed, `git diff --check` прошёл.
STOP. Новый RF, JPEG, видео, QPSK, 180 МГц и FEC 12+3 без отдельной постановки
не начинать.
# Запись 033
## Дата
2 сентября 2026 года
## Тема
Lab047: программная подготовка первой передачи настоящего JPEG через
существующий транспорт Lab046.
## Выполнено
Проведён аудит `protocol/image_fragments.py` и Lab046-Lab046C. Подтверждено,
что транспорт принимает произвольный непустой двоичный объект, а размер 16384
байта был только параметром прежнего опыта. JPEG можно передавать без изменения
протокола, Lab045, FEC и физического уровня; эти части не изменялись.
Для демонстрации выбрана существующая настоящая фотография
`data/raw/lab011_scene.jpg`. Исходный файл сохранён без изменений. По одной
фиксированной процедуре подготовлен JPEG RGB 320 x 240: LANCZOS, quality 35,
4:2:0, без optimize и progressive. Получено 8724 байта и SHA-256
`e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a`.
Файл распознаётся Pillow как полный JPEG.
Выполнен программный сквозной проход через фрагментацию Lab046, 36 защищённых
кадров Lab045, идеальный BPSK-канал, PHY/FEC/CRC и независимую сборку объекта.
Получено 36 CRC PASS, 0 CRC FAIL, принято 36 фрагментов, пропущено 0,
`OBJECT COMPLETE = YES`. Восстановленный `recovered_image.jpg` имеет 8724
байта, совпадающую SHA-256, 0 ошибочных байтов и 0 ошибочных битов. Pillow
декодирует восстановленный JPEG как RGB 320 x 240.
По фактическому размеру рассчитан будущий поток: 36 кадров, 83 030 400
комплексных отсчётов, 166 060 800 байт `cs8`, 10,3788 с и 6724,48 бит/с
полезных данных. RX запланирован на 15,0 с: 120 000 000 отсчётов и 240 000 000
байт, начало на 0,75 с раньше TX.
Подготовлен отдельный аппаратный исполнитель с фиксированными параметрами
успешной точки Lab046C: 1400 МГц, 8 МГц, BPSK 20 ксимв/с, 400 отсчётов на
символ, +50 кГц, TX VGA 10 дБ, RX LNA/VGA 24/24 дБ, усилители и Bias-Tee
выключены, MASTER и SLAVE закреплены по серийным номерам. Аппаратный режим
закрыт тремя явными ключами и отдельным параметром разрешения функции,
запрещает автоматический повтор и использует проверку общей опоры с reg29
`0x26`.
Для будущей новой RX-записи подготовлена динамическая калибровка первого
каскада только по фону до передачи: Q99.9 локальных максимумов плюс 4 MAD.
Порог фиксируется до полного декодирования; фон после передачи остаётся
независимой контрольной выборкой. Строгий каскад PHY/FEC/CRC и исторический
порог 0.65 не изменены. Интерфейс независимого приёмника не принимает TX
offsets, ожидаемое число кадров, индексы TX-фрагментов, исходный JPEG, пакеты
или кодированные биты. TX reference разрешён отдельной диагностической функции
только после сохранённого полного независимого вердикта.
Созданы малые демонстрационные артефакты в `data/processed/lab047`: переданный
и восстановленный JPEG, JSON и краткий текстовый результат. Большие потоки
`cs8`, сырые IQ и аппаратные данные не создавались. HackRF не запускался,
`RF TX = 0`. `python tools/quick_gate.py` прошёл с 524 проверками pytest и 10
быстрыми функциональными проверками лабораторных; полный запуск
`python -m pytest tests/ -q` дал 524 passed и 0 failed; `git diff --check`
прошёл. Git add, commit и push не выполнялись.
## Следующий этап
STOP. До любой аппаратной операции запросить отдельное новое разрешение на
один непрерывный процесс передачи одного JPEG при 1400 МГц. Старое разрешение
Lab046C не использовать. Автоматические повторы запрещены.
# Запись 034
## Дата
3 сентября 2026 года
## Тема
Lab047: единственная аппаратная передача JPEG и автономное восстановление
исходной RX-записи после устранения ограничения `MAX_CANDIDATES`.
## Выполнено
После отдельного точного разрешения выполнен один непрерывный RF TX без
повторов при 1400 МГц. Переданы 36 защищённых кадров с JPEG размером 8724
байта. Фактическая длительность TX составила 10,38030 с. RX продолжался
15,00875 с и сохранил 120000000 комплексных отсчётов, или 240000000 байт;
RX overruns 0, TX underruns 0. Общий опорный генератор прошёл проверку.
`RF TX COUNT = 1`; повтор передачи не выполнялся.
До декодирования по фону до передачи зафиксирован порог первого каскада
`Q99.9 + 4 * MAD`: Q99.9 `0.020810436457395554`, MAD
`0.002538509201258421`, threshold `0.030964473262429237`. На независимом
контрольном фоне maximum равен `0.026494961231946945`, кандидатов 0. Порог
после аппаратного результата и при автономной обработке не менялся.
Первоначальный независимый приём остановился после 71113 локальных максимумов
и накопления списка из 10000 кандидатов. Сработало
`MAX_CANDIDATES_REACHED`; строгий каскад PHY/FEC/CRC вообще не запускался.
Исторический результат сохранён без переименования:
**ORIGINAL HARDWARE RECEIVE = INCOMPLETE DUE TO MAX_CANDIDATES_REACHED**. Это
не PHY FAIL, FEC FAIL, CRC FAIL и не доказательство отказа передачи JPEG.
Аудит `lab046c_offline_recovery.py` показал, что прежняя двухступенчатая схема
сначала накапливала кандидатов всего файла и при достижении предела передавала
строгому каскаду пустой список. Один кандидат занимает приблизительно 108
байт; основную память использовал 16-миллионный блок IQ. Проверка смещений
выявила ожидаемые боковые максимумы периодической PRBS7-преамбулы, но не
дубликаты на границах блоков.
Реализована последовательная схема: кандидаты каждого блока идут по
возрастанию смещения и немедленно проходят строгую проверку. Защитный предел
100000 теперь ограничивает число выполненных строгих проверок и не зависит от
числа кадров JPEG. После CRC PASS позиция перескакивает за область принятого
кадра; после плохого кандидата продвигается на один отсчёт. Подавляется только
точный повтор смещения или кандидат, принадлежащий уже принятому кадру.
Произвольный радиус подавления и глобальное отношение первого пика ко второму
не применяются. Добавлены проверки для потока более 10000 кандидатов,
немедленной обработки, прохождения настоящего кадра среди шума, владения
принятым кадром, перекрытий блоков, близкого кандидата после плохого,
защитного предела, неизменности порога и отсутствия ожидаемого числа кадров в
интерфейсе.
После программного шлюза выполнен ровно один полный автономный проход всей
исходной 15-секундной RX-записи. Смещения и период кадров передатчика,
ожидаемое число кадров, номера TX-фрагментов, исходный JPEG, пакеты и
кодированные биты не использовались. Получено 185828 локальных максимумов,
32414 кандидатов первого каскада и 69 строгих проверок; 32345 кандидатов
пропущены как принадлежащие уже принятому локальному событию, точных повторов
на перекрытиях 0. PHY-заголовок PASS/FAIL `36/33`, FEC
attempts/PASS/FAIL `36/36/0`, CRC PASS/FAIL `36/0`, действительных кадров и
фрагментов `36/36`, отсутствующих фрагментов 0. До открытия опорных данных
передатчика получено **OBJECT COMPLETE = YES**.
Все восемь блоков оценки были исчерпаны, защитные пределы не сработали. Полное
время обработки составило 37,42391 с, строгая ступень — 32,459969 с, в среднем
0,470434 с на строгую проверку. Приблизительный максимальный временный объём
памяти равен 1545755352 байтам, около 1,44 ГиБ; пиковая память списка
кандидатов — 49588 байт.
После единственного полного прохода обнаружена только ошибка учёта признака
завершённости: последнее допустимое начало корреляции было принято за конец
обработанной области IQ, поэтому при исчерпанных восьми блоках поле
`completed` ошибочно осталось ложным. Полный поиск не повторялся, исходный JSON
с SHA-256 `2a16741aa7fd20cfec179a7340300d0884095ab9f781b877230136ea8d249caf`
сохранён неизменным. В отдельном исправленном JSON после проверки числа блоков
граница покрытия изменена с 119471220 на 120000000 и `completed` на `true`;
`full_decoder_rerun = false`, `threshold_changed = false`.
Только после сохранения завершённого независимого вердикта открыты опорные
данные передатчика и диагностически повторно расшифрованы 36 уже принятых окон,
без второго поиска по capture. Совпали 36/36 кадров, пропущено 0, ложных CRC
PASS 0, дубликатов 0. До FEC обнаружено 19 ошибок кодированных бит, после FEC
0 ошибок протокольных бит. В независимый результат кадры не добавлялись.
Восстановленный JPEG имеет 8724 байта, 0 ошибочных байтов и 0 ошибочных бит.
SHA-256 восстановленного и переданного файлов совпадает:
`e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a`.
Pillow полностью декодирует JPEG как RGB 320 x 240. Итог:
**LAB047 OFFLINE RECOVERY OF HARDWARE CAPTURE = PASS**. Единственный RF-сеанс
фактически передал JPEG успешно; первоначальный приём не завершился только
из-за вычислительного ограничения `MAX_CANDIDATES`.
На этапе исправления и автономной обработки HackRF не запускался, `RF TX = 0`.
`python tools/quick_gate.py` прошёл с 540 проверками pytest и 10 быстрыми
функциональными проверками лабораторных; `python -m pytest tests/ -q` дал 540
passed и 0 failed; `git diff --check` прошёл. Git add, commit, push, pull и
fetch не выполнялись.
## Следующий этап
STOP. Разрешение на единственный аппаратный процесс Lab047 израсходовано.
Новый RF, повтор передачи, изменение порога, PHY, FEC, CRC или Lab045 без
отдельной новой постановки не выполнять. `RF TX = 0`; HackRF не запускать.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,89 @@
{
"stage": "LAB047_SOFTWARE_PREPARATION",
"source": "data\\raw\\lab011_scene.jpg",
"jpeg": {
"path": "data\\processed\\lab047\\tx_image.jpg",
"file_bytes": 8724,
"width": 320,
"height": 240,
"mode": "RGB",
"format": "JPEG",
"sha256": "e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a",
"quality": 35,
"subsampling": "4:2:0",
"optimize": false,
"progressive": false
},
"transport": {
"jpeg_file_bytes": 8724,
"fragment_count": 36,
"rf_frame_count": 36,
"samples_per_frame": 2306400,
"tx_sample_count": 83030400,
"tx_file_bytes": 166060800,
"estimated_airtime_seconds": 10.3788,
"useful_goodput_bits_per_second": 6724.476818129264
},
"software_roundtrip": {
"crc_pass": 36,
"crc_fail": 0,
"valid_fragments": 36,
"missing_fragments": [],
"object_complete": true,
"tx_sha256": "e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a",
"recovered_sha256": "e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a",
"sha_match": true,
"byte_errors": 0,
"bit_errors": 0
},
"software_image_decode": "PASS",
"recovered_jpeg": {
"path": "data\\processed\\lab047\\recovered_image.jpg",
"file_bytes": 8724,
"width": 320,
"height": 240,
"mode": "RGB",
"format": "JPEG",
"sha256": "e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a"
},
"planned_rx": {
"duration_seconds": 15.0,
"sample_count": 120000000,
"file_bytes": 240000000,
"lead_seconds": 0.75
},
"hardware": {
"master_tx_serial": "000000000000000097c063dc323b97d3",
"slave_rx_serial": "000000000000000097c063dc2e5327d3",
"frequency_hz": 1400000000,
"sample_rate_hz": 8000000,
"symbol_rate": 20000,
"samples_per_symbol": 400,
"digital_carrier_hz": 50000,
"tx_vga_db": 10,
"tx_rf_amp": false,
"tx_bias_tee": false,
"rx_lna_db": 24,
"rx_vga_db": 24,
"rx_rf_amp": false,
"rx_bias_tee": false,
"master_clkout_to_slave_clkin": true,
"slave_clkout": false,
"expected_reg29": 38
},
"candidate_rule": {
"method": "pre-TX local-peak Q99.9 + 4 * local-peak MAD",
"quantile": 0.999,
"mad_multiplier": 4.0,
"fixed_before_full_decode": true
},
"receiver_oracles": {
"expected_frame_count": false,
"tx_coded_bits": false,
"tx_fragment_indexes": false,
"tx_frame_offsets": false,
"tx_image": false,
"tx_packets": false
},
"rf_tx": 0
}

View File

@@ -0,0 +1,12 @@
LAB047 SOFTWARE PREPARATION = PASS
JPEG FILE = data\processed\lab047\tx_image.jpg
JPEG SIZE = 8724
IMAGE DIMENSIONS = 320 x 240
JPEG SHA-256 = e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a
FRAGMENTS = 36
RF FRAMES = 36
ESTIMATED RF AIRTIME = 10.378800 s
SOFTWARE ROUNDTRIP = PASS
SOFTWARE SHA MATCH = YES
SOFTWARE JPEG DECODE = PASS
RF TX = 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -148,6 +148,34 @@
`C:\Users\user\Documents\SDR_Rover_Lab046_raw\20260902_151819_1400MHz_full\`.
- **Журнал:** записи 028-032 ([`PROJECT_LOG.md`](../PROJECT_LOG.md)).
## Lab047
- **Цель:** передача настоящего JPEG как обычного двоичного объекта через уже
проверенный транспорт Lab046 и восстановление файла с проверкой SHA-256 и
декодирования изображения.
- **Статус:** завершена успешно. В одном непрерывном аппаратном сеансе при
1400 МГц передан настоящий JPEG: 36/36 кадров, 19 ошибок кодированных бит до
FEC, 0 ошибок после FEC, CRC PASS/FAIL 36/0. Объект собран полностью,
SHA-256 совпал, Pillow декодирует JPEG RGB 320 x 240, ошибок байтов и битов
нет. `REAL RF JPEG TRANSPORT = PASS`.
- **Основной эксперимент:**
[`experiments/lab047_jpeg_object_transport.py`](../experiments/lab047_jpeg_object_transport.py).
- **Основные проверки:**
[`tests/test_lab047_jpeg_object_transport.py`](../tests/test_lab047_jpeg_object_transport.py).
- **Документ состояния:**
[`docs/lab047_current_state.md`](lab047_current_state.md).
- **Обработанные данные:** малые демонстрационные JPEG и отчёты находятся в
[`data/processed/lab047`](../data/processed/lab047). Большие потоки `cs8` и
сырые IQ там не хранятся.
- **Внешние аппаратные данные:** исходная RX-запись и результаты находятся в
`C:\Users\user\Documents\SDR_Rover_Lab047_raw\20260903_115747`; большие
потоки `cs8` и сырые IQ в Git не добавляются.
- **История приёма:** первоначальная обработка остановилась до PHY/FEC/CRC по
`MAX_CANDIDATES_REACHED`. После исправления последовательной обработки один
полный автономный анализ исходной записи восстановил JPEG точно; порог
`0.030964473262429237` не менялся, второй RF-сеанс не выполнялся.
- **Журнал:** записи 033-034 ([`PROJECT_LOG.md`](../PROJECT_LOG.md)).
## Правило хранения данных
Большие записи IQ, потоки `cs8`, бинарные объекты и другие аппаратные исходные

View File

@@ -0,0 +1,244 @@
# Lab047: аппаратная передача и восстановление одного JPEG
## Состояние
Программная подготовка и единственный разрешённый аппаратный сеанс завершены.
Первоначальный независимый приём остался незавершённым из-за вычислительного
ограничения `MAX_CANDIDATES_REACHED`, до PHY/FEC/CRC он не дошёл. Это
историческое состояние сохранено и не переименовано в успешный приём.
После исправления только порядка обработки кандидатов выполнен ровно один
полный автономный повторный анализ исходной RX-записи без нового RF. Он собрал
JPEG полностью; последующая диагностическая сверка подтвердила точное
совпадение с переданным файлом. На этапе повторной обработки `RF TX = 0`,
HackRF не запускался.
## Аудит транспорта
Существующий транспорт Lab046 принимает произвольный непустой двоичный объект.
Размер 16384 байта был параметром Lab046C, а не ограничением протокола.
Число фрагментов вычисляется из фактического размера объекта, длина последнего
фрагмента восстанавливается из его собственных метаданных.
JPEG поэтому передаётся как обычная последовательность байтов. Изменения в
`protocol/`, Lab045, FEC и физическом уровне не потребовались и не выполнялись.
## Демонстрационный JPEG
Выбрана существующая настоящая фотография
`data/raw/lab011_scene.jpg`: поле, грунтовая дорога и деревья. Исходный файл не
изменялся. Подготовка полностью фиксирована:
- цветовой режим RGB;
- размер 320 x 240 пикселей;
- фильтр уменьшения LANCZOS;
- JPEG quality 35;
- цветовая субдискретизация 4:2:0;
- `optimize = false`;
- `progressive = false`.
Полученный файл `data/processed/lab047/tx_image.jpg` имеет 8724 байта,
формат JPEG, режим RGB, размер 320 x 240 и SHA-256
`e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a`.
Pillow полностью читает файл и не обнаруживает усечения.
## Программный сквозной проход
Выполнена цепочка:
`tx_image.jpg -> bytes -> Lab046 fragmentation -> Lab045 FEC/BPSK ->`
`идеальный канал -> PHY/FEC/CRC -> ObjectAssembler -> recovered_image.jpg`.
Фактический результат:
- фрагментов: 36;
- защищённых BPSK-кадров: 36;
- CRC PASS / FAIL: 36 / 0;
- действительных фрагментов: 36;
- пропущенных фрагментов: 0;
- `OBJECT COMPLETE = YES`;
- SHA-256 восстановленного файла:
`e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a`;
- `SHA MATCH = YES`;
- ошибочных байтов: 0;
- ошибочных битов: 0;
- Pillow распознаёт восстановленный файл как JPEG 320 x 240 RGB;
- `IMAGE DECODE = PASS`.
## Учёт будущего радиопотока
Каждый полный кадр Lab045 содержит 2 306 400 комплексных отсчётов. Для
фактических 8724 байт JPEG рассчитано:
- число отсчётов TX: 83 030 400;
- размер будущего `tx_stream.cs8`: 166 060 800 байт;
- расчётная длительность TX: 10,3788 с;
- полезная скорость по данным JPEG: 6724,48 бит/с;
- плановая длительность RX: 15,0 с;
- объём RX: 120 000 000 комплексных отсчётов, 240 000 000 байт;
- упреждение запуска RX: 0,75 с.
Большие `tx_stream.cs8`, `rx_stream.cs8` и сырые IQ в Git не создавались и не
добавлялись.
## Подготовленный аппаратный запуск
`experiments/lab047_jpeg_object_transport.py` содержит отдельные режимы для
формирования будущего TX-потока без RF, одного аппаратного захвата и одного
независимого полного анализа. Аппаратный режим требует одновременно три явных
ключа командной строки и обязательный новый признак разрешения внутри функции.
До попытки создаётся одноразовый файл-свидетель; автоматического повтора нет.
Параметры будущего опыта зафиксированы:
- MASTER/TX: `000000000000000097c063dc323b97d3`;
- SLAVE/RX: `000000000000000097c063dc2e5327d3`;
- центральная частота: 1 400 000 000 Гц;
- частота дискретизации: 8 МГц;
- BPSK: 20 ксимв/с, 400 отсчётов на символ;
- цифровое смещение несущей: +50 кГц;
- TX VGA: 10 дБ;
- RX LNA/VGA: 24/24 дБ;
- RF amplifier и Bias-Tee: выключены на обоих устройствах;
- общий генератор: TCXO 10 МГц -> MASTER -> CLKOUT -> SLAVE CLKIN;
- ожидаемый регистр 29 обоих устройств после проверки: `0x26`.
## Независимое обнаружение
Для каждой новой записи первый порог вычисляется заново только по фону до TX:
`threshold = Q99.9 локальных максимумов + 4 x MAD локальных максимумов`.
Интервал калибровки равен 0,0-0,5 с записи, затем порог фиксируется до полного
декодирования. Независимый контрольный фон расположен в интервале 12,0-15,0 с.
Строгий каскад остаётся прежним:
`точная синхронизация -> CFO -> фаза -> PHY header -> FEC -> CRC`.
Кадр принимается только при `CRC PASS`. Исторический
`IQ_PREAMBLE_THRESHOLD = 0.65` не изменён. Интерфейс независимого приёмника
получает только путь к RX-записи и выходной каталог. Он не получает смещения
TX, ожидаемое число кадров, индексы фрагментов передатчика, исходный JPEG,
пакеты или кодированные биты. Сравнение с TX-файлом вынесено в отдельную
функцию, которая открывается только после сохранённого полного независимого
вердикта.
## Единственный аппаратный сеанс
3 сентября 2026 года выполнен один непрерывный RF TX без повторов. Частота
равна 1400 МГц, фактическая длительность TX — 10,38030 с. RX продолжался
15,00875 с и сохранил 120000000 комплексных отсчётов, или 240000000 байт.
RX overruns 0, TX underruns 0. Проверка общего опорного генератора прошла.
Повторная передача не выполнялась: `RF TX COUNT = 1` за аппаратный сеанс и
`RF TX = 0` на этапе автономной обработки.
По фону до передачи до декодирования зафиксированы:
- Q99.9 локальных максимумов: `0.020810436457395554`;
- MAD локальных максимумов: `0.002538509201258421`;
- порог `Q99.9 + 4 * MAD`: `0.030964473262429237`.
На независимом контрольном фоне после передачи maximum равен
`0.026494961231946945`, кандидатов 0. Порог после аппаратного результата и
при повторной обработке не менялся.
Первоначальный приём остановился после 71113 локальных максимумов и заполнения
промежуточного списка из 10000 кандидатов. Сработало
`MAX_CANDIDATES_REACHED`; строгая ступень не запускалась. Поэтому сохранённый
исторический результат сформулирован точно:
`ORIGINAL HARDWARE RECEIVE = INCOMPLETE DUE TO MAX_CANDIDATES_REACHED`, а не
PHY FAIL, FEC FAIL, CRC FAIL или отказ передачи JPEG.
## Потоковая обработка кандидатов
В `lab046c_offline_recovery.py` устранено предварительное накопление всех
кандидатов. Блоки IQ и локальные максимумы идут по возрастанию смещения, а
каждый кандидат сразу передаётся строгой ступени. Защитный предел сохранён и
теперь ограничивает число фактически выполненных строгих проверок; его
значение 100000 не зависит от числа кадров JPEG.
После принятого по CRC кадра позиция перескакивает за принадлежащую ему область.
После плохого кандидата она продвигается на один отсчёт, поэтому близкий
настоящий кадр не скрывается. Точный повтор одного смещения подавляется;
произвольного радиуса подавления и глобального отношения первого пика ко
второму нет. Перекрытия блоков имеют однозначное владение и не породили
дубликатов.
Для списка кандидатов измерено около 108 байт на запись; при потоковой
обработке пиковый объём такого списка составил 49588 байт. Основной временный
объём определяет обработка блока IQ: приблизительно 1545755352 байта
(около 1,44 ГиБ).
## Автономный результат исходной RX-записи
После программного шлюза выполнен ровно один полный независимый проход всей
15-секундной записи. Передаточные смещения, период кадров, ожидаемое число
кадров, номера фрагментов, исходный JPEG, пакеты и кодированные биты не
использовались. Получено:
- всего локальных максимумов: 185828;
- кандидатов первого каскада: 32414;
- строгих проверок: 69;
- кандидатов, пропущенных как принадлежащие уже принятому локальному событию:
32345;
- точных дубликатов на перекрытиях: 0;
- PHY-заголовок PASS/FAIL: `36/33`;
- FEC attempts/PASS/FAIL: `36/36/0`;
- CRC PASS/FAIL: `36/0`;
- действительных кадров и фрагментов: `36/36`;
- отсутствующих фрагментов: 0;
- `OBJECT COMPLETE = YES`.
Все восемь блоков оценки были исчерпаны, защитные пределы не сработали. Полное
время равно 37,42391 с, из них строгая ступень заняла 32,459969 с; среднее
время одной строгой проверки — 0,470434 с. Первоначально поле `completed`
осталось ложным из-за отдельной ошибки учёта границы: последнее допустимое
начало корреляции было принято за конец обработанной области IQ. Исходный JSON
результата не изменён, полный поиск не повторяли. В отдельном исправленном JSON
после проверки исчерпания всех восьми блоков граница изменена с 119471220 на
120000000 и `completed` на `true`; порог не менялся,
`full_decoder_rerun = false`.
Только после сохранения завершённого независимого вердикта открыты опорные
данные передатчика. Совпали 36 из 36 кадров, пропущено 0, ложных CRC PASS 0.
До FEC обнаружено 19 ошибок кодированных бит, после FEC — 0 ошибок
протокольных бит. В независимый результат кадры не добавлялись.
Восстановленный `recovered_image.jpg` имеет 8724 байта. Его SHA-256 и SHA-256
переданного файла одинаковы:
`e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a`.
Ошибочных байтов 0, ошибочных битов 0. Pillow полностью декодирует JPEG как
изображение RGB 320 x 240. Итог:
`LAB047 OFFLINE RECOVERY OF HARDWARE CAPTURE = PASS`; единственный RF-сеанс
фактически передал JPEG успешно, а первоначальную обработку остановил
вычислительный предел.
## Артефакты
- `data/processed/lab047/tx_image.jpg`;
- `data/processed/lab047/recovered_image.jpg`;
- `data/processed/lab047/result.json`;
- `data/processed/lab047/result.txt`.
Большая RX-запись и результаты аппаратного сеанса оставлены вне Git в
`C:\Users\user\Documents\SDR_Rover_Lab047_raw\20260903_115747`. Исходный
`independent_reprocess_result.json` сохранён неизменным; исправление только
признака полного покрытия находится в
`independent_reprocess_result_corrected.json`, а разрешённая послерезультатная
сверка — в `post_verdict_diagnostics.json`.
## Проверки
- `python tools/quick_gate.py`: PASS, 540 проверок pytest и 10 быстрых
функциональных проверок лабораторных;
- `python -m pytest tests/ -q`: 540 passed, 0 failed;
- `git diff --check`: PASS.
Предупреждение pytest о невозможности создать служебный `.pytest_cache` не
относится к тестам и не изменило код возврата.
## Следующий этап
STOP. Разрешение на единственный аппаратный процесс Lab047 израсходовано.
Новый RF, повтор передачи, изменение порога, PHY, FEC, CRC или Lab045 без
отдельной новой постановки не выполнять. `RF TX = 0`; HackRF не запускать.

View File

@@ -54,8 +54,14 @@ BACKGROUND_1400MHZ_LOCAL_PEAK_QUANTILE = 0.999
BACKGROUND_1400MHZ_MAD_MULTIPLIER = 4.0
FIXED_1400MHZ_CANDIDATE_THRESHOLD = 0.03361873421818018
DETECTION_CHUNK_SAMPLES = 16_000_000
MAX_CANDIDATES = 10_000
MAX_SEARCH_ITERATIONS = 10_000
# В потоковом проходе это предел реально выполненных строгих проверок, а не
# размер промежуточного списка. Он не зависит от ожидаемого числа кадров.
MAX_CANDIDATES = 100_000
MAX_STORED_COARSE_CANDIDATES = 10_000
MAX_STORED_OUTCOMES = 4_096
MAX_STORED_CURSOR_POSITIONS = MAX_STORED_OUTCOMES + 1
COARSE_CANDIDATE_MEMORY_BYTES = 108
MAX_SEARCH_ITERATIONS = MAX_CANDIDATES
MAX_VALID_FRAMES = 256
REFINEMENT_MARGIN_SAMPLES = 4_000
# resample_poly/fftconvolve одновременно создают несколько крупных временных
@@ -143,6 +149,12 @@ class CoarseSearchResult:
elapsed_seconds: float
termination_reason: str
max_candidates_reached: bool
candidate_count: int = 0
candidates_skipped_as_same_local_event: int = 0
exact_duplicate_candidates_skipped: int = 0
blocks_processed: int = 0
processed_until_sample: int = 0
candidate_memory_bytes_peak: int = 0
@dataclass(frozen=True)
@@ -197,6 +209,8 @@ class StrictSearchResult:
termination_reason: str
max_search_iterations_reached: bool
max_valid_frames_reached: bool
outcomes_truncated: bool = False
cursor_history_truncated: bool = False
@dataclass(frozen=True)
@@ -524,7 +538,7 @@ def coarse_search_capture(
capture_path: Path,
rule: CandidateRule,
*,
max_candidates: int = MAX_CANDIDATES,
max_candidates: int = MAX_STORED_COARSE_CANDIDATES,
chunk_samples: int = DETECTION_CHUNK_SAMPLES,
) -> CoarseSearchResult:
"""Найти first-stage candidates во всём capture без TX offsets/count."""
@@ -538,6 +552,8 @@ def coarse_search_capture(
total_local_peaks = 0
maximum_window = 0
maximum_temporary = 0
blocks_processed = 0
processed_until_sample = 0
termination = "END_OF_CAPTURE"
max_reached = False
decimation = lab046c.lab044.lab043.DETECTION_DECIMATION
@@ -546,6 +562,11 @@ def coarse_search_capture(
whole,
chunk_samples=chunk_samples,
):
blocks_processed += 1
processed_until_sample = min(
sample_count,
window_start + int(scores.size) * decimation,
)
total_local_peaks += int(indexes.size)
maximum_window = max(maximum_window, window_samples)
maximum_temporary = max(maximum_temporary, temporary_bytes)
@@ -571,6 +592,10 @@ def coarse_search_capture(
elapsed_seconds=time.perf_counter() - started,
termination_reason=termination,
max_candidates_reached=max_reached,
candidate_count=len(candidates),
blocks_processed=blocks_processed,
processed_until_sample=processed_until_sample,
candidate_memory_bytes_peak=len(candidates) * COARSE_CANDIDATE_MEMORY_BYTES,
)
@@ -872,6 +897,216 @@ def strict_process_candidates(
)
def stream_process_capture(
capture_path: Path,
rule: CandidateRule,
*,
max_candidates: int = MAX_CANDIDATES,
max_valid_frames: int = MAX_VALID_FRAMES,
chunk_samples: int = DETECTION_CHUNK_SAMPLES,
decoder: CandidateDecoder = decode_candidate_from_memmap,
) -> tuple[CoarseSearchResult, StrictSearchResult]:
"""Последовательно находить и сразу строго проверять кандидатов.
``max_candidates`` ограничивает число вызовов строгого декодера за весь
capture. Кандидаты всего файла не накапливаются. После CRC PASS курсор
владеет областью до конца принятого кадра; после отказа пропускается только
текущая позиция. Точный повтор sample offset подавляется без радиуса,
который мог бы скрыть соседний настоящий кадр.
"""
if max_candidates < 0 or max_valid_frames < 0:
raise ValueError("Safety limits не могут быть отрицательными")
raw, sample_count = _validate_capture(capture_path)
whole = SampleInterval("FULL_CAPTURE", 0, sample_count)
started = time.perf_counter()
decimation = lab046c.lab044.lab043.DETECTION_DECIMATION
total_local_peaks = 0
first_stage_candidates = 0
candidates_skipped_as_same_local_event = 0
exact_duplicate_candidates_skipped = 0
blocks_processed = 0
processed_until_sample = 0
maximum_window = 0
maximum_temporary = 0
candidate_memory_peak = 0
last_candidate_offset: int | None = None
cursor = 0
cursor_history = [0]
cursor_history_truncated = False
stored_outcomes: list[CandidateOutcome] = []
outcomes_truncated = False
accepted_frames: list[lab046b.IqFrameReception] = []
strict_elapsed = 0.0
maximum_strict_window = 0
counters = {
"candidates_processed": 0,
"rejected_during_refinement": 0,
"refinement_pass": 0,
"timing_pass": 0,
"carrier_pass": 0,
"phy_header_pass": 0,
"phy_header_fail": 0,
"fec_attempts": 0,
"fec_pass": 0,
"fec_fail": 0,
"protocol_parse_pass": 0,
"crc_pass": 0,
"crc_fail": 0,
"valid_frames": 0,
}
termination = "END_OF_CAPTURE"
max_candidates_reached = False
max_valid_frames_reached = False
stop = False
for window_start, scores, indexes, window_samples, temporary_bytes in _score_blocks(
raw,
whole,
chunk_samples=chunk_samples,
):
blocks_processed += 1
processed_until_sample = min(
sample_count,
window_start + int(scores.size) * decimation,
)
total_local_peaks += int(indexes.size)
maximum_window = max(maximum_window, window_samples)
selected = indexes[scores[indexes] >= np.float32(rule.threshold)]
maximum_temporary = max(
maximum_temporary,
temporary_bytes + int(selected.nbytes) + COARSE_CANDIDATE_MEMORY_BYTES,
)
candidate_memory_peak = max(
candidate_memory_peak,
int(selected.nbytes) + COARSE_CANDIDATE_MEMORY_BYTES,
)
for index in selected:
sample_offset = window_start + int(index) * decimation
if last_candidate_offset is not None:
if sample_offset < last_candidate_offset:
raise RuntimeError("Кандидаты потокового поиска нарушили порядок offset")
if sample_offset == last_candidate_offset:
exact_duplicate_candidates_skipped += 1
continue
last_candidate_offset = sample_offset
first_stage_candidates += 1
if sample_offset < cursor:
candidates_skipped_as_same_local_event += 1
continue
if counters["candidates_processed"] >= max_candidates:
termination = "MAX_CANDIDATES_REACHED"
max_candidates_reached = True
stop = True
break
if len(accepted_frames) >= max_valid_frames:
termination = "MAX_VALID_FRAMES_REACHED"
max_valid_frames_reached = True
stop = True
break
candidate = CoarseCandidate(
sample_offset=sample_offset,
score=float(scores[index]),
)
outcome = decoder(
raw,
sample_count,
candidate,
counters["candidates_processed"] + 1,
)
counters["candidates_processed"] += 1
counters["rejected_during_refinement"] += int(not outcome.refinement_pass)
counters["refinement_pass"] += int(outcome.refinement_pass)
counters["timing_pass"] += int(outcome.timing_pass)
counters["carrier_pass"] += int(outcome.carrier_pass)
counters["phy_header_pass"] += int(outcome.phy_header_pass)
counters["phy_header_fail"] += int(
outcome.phy_header_attempted and not outcome.phy_header_pass
)
counters["fec_attempts"] += int(outcome.fec_attempted)
counters["fec_pass"] += int(outcome.fec_pass)
counters["fec_fail"] += int(outcome.fec_attempted and not outcome.fec_pass)
counters["protocol_parse_pass"] += int(outcome.protocol_parse_pass)
counters["crc_pass"] += int(outcome.crc == lab046c.lab045.CRC_PASS)
counters["crc_fail"] += int(outcome.crc == lab046c.lab045.CRC_FAIL)
strict_elapsed += outcome.elapsed_seconds
maximum_strict_window = max(
maximum_strict_window,
outcome.maximum_local_window_samples,
)
accepted = bool(
outcome.accepted
and outcome.frame is not None
and outcome.crc == lab046c.lab045.CRC_PASS
)
if accepted:
accepted_frames.append(outcome.frame)
counters["valid_frames"] += 1
if len(stored_outcomes) < MAX_STORED_OUTCOMES or accepted:
stored_outcomes.append(outcome)
else:
outcomes_truncated = True
next_cursor = int(outcome.next_sample) if accepted else sample_offset + 1
cursor = max(cursor + 1, next_cursor)
if len(cursor_history) < MAX_STORED_CURSOR_POSITIONS:
cursor_history.append(cursor)
else:
cursor_history_truncated = True
if stop:
break
# Если генератор блоков исчерпан без защитной остановки, обработан весь
# интервал. Последний допустимый старт корреляции закономерно раньше конца
# IQ на длину опорной последовательности и не является границей покрытия.
if not stop:
processed_until_sample = sample_count
total_elapsed = time.perf_counter() - started
coarse = CoarseSearchResult(
sample_count=sample_count,
total_local_peaks=total_local_peaks,
candidates=(),
maximum_window_samples=maximum_window,
estimated_maximum_temporary_bytes=maximum_temporary,
elapsed_seconds=max(0.0, total_elapsed - strict_elapsed),
termination_reason=termination,
max_candidates_reached=max_candidates_reached,
candidate_count=first_stage_candidates,
candidates_skipped_as_same_local_event=candidates_skipped_as_same_local_event,
exact_duplicate_candidates_skipped=exact_duplicate_candidates_skipped,
blocks_processed=blocks_processed,
processed_until_sample=processed_until_sample,
candidate_memory_bytes_peak=candidate_memory_peak,
)
strict = StrictSearchResult(
counters=StrictCounters(**counters),
accepted_frames=tuple(accepted_frames),
outcomes=tuple(stored_outcomes),
cursor_history=tuple(cursor_history),
elapsed_seconds=strict_elapsed,
average_candidate_seconds=(
strict_elapsed / counters["candidates_processed"]
if counters["candidates_processed"]
else 0.0
),
maximum_local_window_samples=maximum_strict_window,
maximum_local_window_bytes=(
maximum_strict_window * np.dtype(np.complex64).itemsize
),
termination_reason=termination,
max_search_iterations_reached=max_candidates_reached,
max_valid_frames_reached=max_valid_frames_reached,
outcomes_truncated=outcomes_truncated,
cursor_history_truncated=cursor_history_truncated,
)
return coarse, strict
def assemble_independent_object(
frames: tuple[lab046b.IqFrameReception, ...],
) -> ObjectVerdict:
@@ -914,24 +1149,15 @@ def _run_with_fixed_candidate_rule(
capture_path = Path(capture_path)
_, sample_count = _validate_capture(capture_path)
coarse = coarse_search_capture(capture_path, rule)
if coarse.max_candidates_reached:
strict = strict_process_candidates(
capture_path,
(),
max_search_iterations=0,
max_valid_frames=0,
)
verdict = assemble_independent_object(())
else:
strict = strict_process_candidates(capture_path, coarse.candidates)
coarse, strict = stream_process_capture(capture_path, rule)
verdict = assemble_independent_object(strict.accepted_frames)
elapsed = time.perf_counter() - full_started
completed = bool(
not coarse.max_candidates_reached
and not strict.max_search_iterations_reached
and not strict.max_valid_frames_reached
and strict.termination_reason == "END_OF_CANDIDATES"
and strict.termination_reason == "END_OF_CAPTURE"
and coarse.processed_until_sample == sample_count
)
return IndependentRecoveryResult(
completed=completed,
@@ -1212,6 +1438,8 @@ def _independent_summary(result: IndependentRecoveryResult) -> dict:
"termination_reason": result.strict.termination_reason,
"max_search_iterations_reached": result.strict.max_search_iterations_reached,
"max_valid_frames_reached": result.strict.max_valid_frames_reached,
"outcomes_truncated": result.strict.outcomes_truncated,
"cursor_history_truncated": result.strict.cursor_history_truncated,
"outcomes": [
{
key: value

File diff suppressed because it is too large Load Diff

View File

@@ -428,6 +428,211 @@ def test_cursor_is_strictly_monotonic(tmp_path: Path) -> None:
assert all(after > before for before, after in zip(result.cursor_history, result.cursor_history[1:]))
def test_streaming_more_than_ten_thousand_candidates_reach_strict_stage(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capture = _capture_file(tmp_path / "many_candidates.cs8", 250_000)
candidate_count = 10_001
def blocks(raw, interval, chunk_samples):
del raw, interval, chunk_samples
scores = np.ones(candidate_count, dtype=np.float32)
yield 0, scores, np.arange(candidate_count), 250_000, 1_000
processed = 0
def reject(raw, sample_count, candidate, number):
nonlocal processed
del raw, sample_count, number
processed += 1
return _candidate_outcome(
candidate,
accepted=False,
next_sample=candidate.sample_offset + 1,
)
monkeypatch.setattr(offline, "_score_blocks", blocks)
coarse, strict = offline.stream_process_capture(
capture,
offline.CandidateRule("fixed", 0.999, 0.5),
max_candidates=20_000,
decoder=reject,
)
assert coarse.candidate_count == candidate_count
assert coarse.max_candidates_reached is False
assert strict.counters.candidates_processed == candidate_count
assert processed == candidate_count
assert strict.outcomes_truncated is True
def test_streaming_candidates_are_checked_before_next_block(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capture = _capture_file(tmp_path / "stream_order.cs8", 500)
events: list[str] = []
def blocks(raw, interval, chunk_samples):
del raw, interval, chunk_samples
yield 0, np.ones(2, np.float32), np.array([0, 1]), 100, 100
assert events == ["decode:0", "decode:20"]
events.append("second_block")
yield 100, np.ones(1, np.float32), np.array([0]), 100, 100
def reject(raw, sample_count, candidate, number):
del raw, sample_count, number
events.append(f"decode:{candidate.sample_offset}")
return _candidate_outcome(candidate, accepted=False, next_sample=0)
monkeypatch.setattr(offline, "_score_blocks", blocks)
offline.stream_process_capture(
capture,
offline.CandidateRule("fixed", 0.999, 0.5),
decoder=reject,
)
assert events == ["decode:0", "decode:20", "second_block", "decode:100"]
def test_streaming_accepted_frame_owns_following_local_maxima(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capture = _capture_file(tmp_path / "owned_event.cs8", 1_000)
def blocks(raw, interval, chunk_samples):
del raw, interval, chunk_samples
scores = np.ones(31, np.float32)
yield 0, scores, np.array([5, 6, 10, 24, 30]), 1_000, 100
def decoder(raw, sample_count, candidate, number):
del raw, sample_count, number
return _candidate_outcome(
candidate,
accepted=candidate.sample_offset == 100,
next_sample=500 if candidate.sample_offset == 100 else 0,
)
monkeypatch.setattr(offline, "_score_blocks", blocks)
coarse, strict = offline.stream_process_capture(
capture,
offline.CandidateRule("fixed", 0.999, 0.5),
decoder=decoder,
)
assert strict.counters.valid_frames == 1
assert strict.counters.candidates_processed == 2
assert coarse.candidates_skipped_as_same_local_event == 3
def test_streaming_exact_overlap_duplicate_is_checked_once(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capture = _capture_file(tmp_path / "exact_overlap.cs8", 500)
def blocks(raw, interval, chunk_samples):
del raw, interval, chunk_samples
yield 0, np.ones(6, np.float32), np.array([5]), 100, 100
yield 100, np.ones(1, np.float32), np.array([0]), 100, 100
monkeypatch.setattr(offline, "_score_blocks", blocks)
coarse, strict = offline.stream_process_capture(
capture,
offline.CandidateRule("fixed", 0.999, 0.5),
decoder=lambda raw, count, candidate, number: _candidate_outcome(
candidate,
accepted=False,
next_sample=0,
),
)
assert coarse.candidate_count == 1
assert coarse.exact_duplicate_candidates_skipped == 1
assert strict.counters.candidates_processed == 1
def test_streaming_bad_candidate_cannot_hide_later_valid_frame(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capture = _capture_file(tmp_path / "bad_then_valid.cs8", 500)
def blocks(raw, interval, chunk_samples):
del raw, interval, chunk_samples
yield 0, np.ones(3, np.float32), np.array([0, 1, 2]), 100, 100
def decoder(raw, sample_count, candidate, number):
del raw, sample_count, number
return _candidate_outcome(
candidate,
accepted=candidate.sample_offset == 20,
next_sample=10_000,
)
monkeypatch.setattr(offline, "_score_blocks", blocks)
_, strict = offline.stream_process_capture(
capture,
offline.CandidateRule("fixed", 0.999, 0.5),
decoder=decoder,
)
assert strict.counters.candidates_processed == 2
assert strict.counters.valid_frames == 1
assert strict.accepted_frames[0].refined_start_sample == 20
def test_streaming_max_candidates_limits_strict_checks(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capture = _capture_file(tmp_path / "stream_limit.cs8", 500)
def blocks(raw, interval, chunk_samples):
del raw, interval, chunk_samples
yield 0, np.ones(4, np.float32), np.arange(4), 100, 100
monkeypatch.setattr(offline, "_score_blocks", blocks)
coarse, strict = offline.stream_process_capture(
capture,
offline.CandidateRule("fixed", 0.999, 0.5),
max_candidates=2,
decoder=lambda raw, count, candidate, number: _candidate_outcome(
candidate,
accepted=False,
next_sample=0,
),
)
assert coarse.max_candidates_reached is True
assert coarse.termination_reason == "MAX_CANDIDATES_REACHED"
assert strict.counters.candidates_processed == 2
assert strict.max_search_iterations_reached is True
def test_streaming_exhausted_blocks_cover_full_capture_not_last_correlation_start(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capture = _capture_file(tmp_path / "full_coverage.cs8", 500)
def blocks(raw, interval, chunk_samples):
del raw, interval, chunk_samples
yield 0, np.zeros(1, np.float32), np.array([], dtype=np.int64), 500, 100
monkeypatch.setattr(offline, "_score_blocks", blocks)
coarse, strict = offline.stream_process_capture(
capture,
offline.CandidateRule("fixed", 0.999, 0.5),
)
assert coarse.processed_until_sample == 500
assert coarse.termination_reason == "END_OF_CAPTURE"
assert strict.termination_reason == "END_OF_CAPTURE"
def test_max_candidates_fails_closed(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
@@ -572,9 +777,26 @@ def test_safety_limited_independent_run_does_not_unlock_tx_reference(
"MAX_CANDIDATES_REACHED",
True,
)
strict = offline.StrictSearchResult(
counters=offline.StrictCounters(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
accepted_frames=(),
outcomes=(),
cursor_history=(0,),
elapsed_seconds=0.0,
average_candidate_seconds=0.0,
maximum_local_window_samples=0,
maximum_local_window_bytes=0,
termination_reason="MAX_CANDIDATES_REACHED",
max_search_iterations_reached=True,
max_valid_frames_reached=False,
)
monkeypatch.setattr(offline, "calibrate_candidate_rule", lambda path: (rule, background))
monkeypatch.setattr(offline, "evaluate_control_background", lambda path, fixed: background)
monkeypatch.setattr(offline, "coarse_search_capture", lambda path, fixed: coarse)
monkeypatch.setattr(
offline,
"stream_process_capture",
lambda path, fixed: (coarse, strict),
)
result = offline.run_independent_recovery(capture)
assert result.completed is False
with pytest.raises(RuntimeError, match="verdict"):

View File

@@ -0,0 +1,434 @@
from __future__ import annotations
import inspect
import json
from pathlib import Path
import subprocess
import sys
from types import SimpleNamespace
import numpy as np
from PIL import Image
import pytest
from experiments import lab047_jpeg_object_transport as lab047
def _test_source(path: Path) -> Path:
y, x = np.mgrid[0:96, 0:128]
pixels = np.stack(
((3 * x + y) % 256, (x + 5 * y) % 256, (7 * x + 2 * y) % 256),
axis=-1,
).astype(np.uint8)
Image.fromarray(pixels, mode="RGB").save(path, format="PNG")
return path
def test_fixed_jpeg_preparation_is_reproducible(tmp_path: Path) -> None:
source = _test_source(tmp_path / "source.png")
first = lab047.prepare_jpeg(source, tmp_path / "first.jpg")
second = lab047.prepare_jpeg(source, tmp_path / "second.jpg")
assert first.sha256 == second.sha256
assert first.file_bytes == second.file_bytes
assert (first.width, first.height, first.mode, first.format) == (320, 240, "RGB", "JPEG")
def test_selected_photo_produces_target_size_reproducibly(tmp_path: Path) -> None:
first = lab047.prepare_jpeg(lab047.SOURCE_IMAGE_PATH, tmp_path / "first.jpg")
second = lab047.prepare_jpeg(lab047.SOURCE_IMAGE_PATH, tmp_path / "second.jpg")
assert 8 * 1024 <= first.file_bytes <= 15 * 1024
assert first.sha256 == second.sha256
def test_jpeg_passes_full_software_transport(tmp_path: Path) -> None:
source = _test_source(tmp_path / "source.png")
jpeg_path = tmp_path / "tx.jpg"
lab047.prepare_jpeg(source, jpeg_path)
transmitted = jpeg_path.read_bytes()
result = lab047.software_roundtrip_jpeg(transmitted)
assert result.object_complete is True
assert result.crc_pass == len(lab047.lab046.build_object_frames(transmitted, lab047.OBJECT_ID))
assert result.crc_fail == 0
assert result.sha_match is True
assert result.byte_errors == 0
assert result.bit_errors == 0
assert result.recovered_bytes == transmitted
recovered_path = tmp_path / "recovered.jpg"
recovered_path.write_bytes(result.recovered_bytes or b"")
assert lab047.validate_jpeg(recovered_path).format == "JPEG"
def test_frame_is_not_accepted_without_crc_pass(monkeypatch: pytest.MonkeyPatch) -> None:
data = bytes(245)
monkeypatch.setattr(
lab047.lab045,
"analyze_post_preamble_soft",
lambda soft: SimpleNamespace(
analysis={"crc": lab047.lab045.CRC_FAIL},
payload_bytes=bytes(lab047.lab045.PAYLOAD_SIZE_BYTES),
),
)
result = lab047.software_roundtrip_jpeg(data)
assert result.crc_pass == 0
assert result.crc_fail == 2
assert result.valid_fragments == 0
assert result.object_complete is False
def test_accounting_uses_actual_object_size() -> None:
accounting = lab047.transport_accounting(bytes(8_724))
assert accounting.fragment_count == 36
assert accounting.rf_frame_count == 36
assert accounting.samples_per_frame == 2_306_400
assert accounting.tx_sample_count == 83_030_400
assert accounting.tx_file_bytes == 166_060_800
assert accounting.estimated_airtime_seconds == pytest.approx(10.3788)
def test_dynamic_threshold_is_q999_plus_four_local_peak_mad(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
scores = np.linspace(0.001, 0.02, 20, dtype=np.float32)
peaks = np.array([0.01, 0.02, 0.03, 0.04, 0.05], dtype=np.float32)
monkeypatch.setattr(
lab047.offline,
"_background_peak_scores",
lambda capture_path, interval: (scores, peaks, 0.25),
)
rule, statistics = lab047.calibrate_dynamic_candidate_rule(tmp_path / "unused.cs8")
expected = lab047.offline.candidate_threshold_from_local_peaks(
peaks,
quantile=0.999,
mad_multiplier=4.0,
)
assert rule.threshold == expected
assert rule.local_peak_quantile == 0.999
assert statistics.interval == lab047.CALIBRATION_INTERVAL
assert statistics.local_peak_mad == pytest.approx(0.01)
def test_independent_receiver_api_has_no_tx_oracles() -> None:
parameters = set(inspect.signature(lab047.run_independent_capture_recovery).parameters)
assert parameters == {"capture_path", "output_directory"}
assert not parameters & {
"tx_offsets",
"expected_frame_count",
"tx_image",
"tx_packets",
"tx_coded_bits",
}
assert all(value is False for value in lab047.receiver_oracle_declaration().values())
reprocess_parameters = set(
inspect.signature(lab047.run_saved_capture_streaming_recovery).parameters
)
assert reprocess_parameters == {
"capture_path",
"original_result_path",
"output_directory",
}
assert "expected_frame_count" not in reprocess_parameters
def test_historical_strict_threshold_and_fixed_hardware_are_unchanged() -> None:
assert lab047.lab046b.IQ_PREAMBLE_THRESHOLD == 0.65
assert lab047.hardware_parameters() == {
"master_tx_serial": "000000000000000097c063dc323b97d3",
"slave_rx_serial": "000000000000000097c063dc2e5327d3",
"frequency_hz": 1_400_000_000,
"sample_rate_hz": 8_000_000,
"symbol_rate": 20_000,
"samples_per_symbol": 400,
"digital_carrier_hz": 50_000,
"tx_vga_db": 10,
"tx_rf_amp": False,
"tx_bias_tee": False,
"rx_lna_db": 24,
"rx_vga_db": 24,
"rx_rf_amp": False,
"rx_bias_tee": False,
"master_clkout_to_slave_clkin": True,
"slave_clkout": False,
"expected_reg29": 0x26,
}
def test_hardware_function_is_closed_without_new_authorization(tmp_path: Path) -> None:
called = False
def runner(command: list[str]) -> subprocess.CompletedProcess[str]:
nonlocal called
called = True
return subprocess.CompletedProcess(command, 0, "", "")
with pytest.raises(PermissionError, match="отдельного разрешения"):
lab047.run_hardware_capture_once(tmp_path, runner=runner)
assert called is False
def test_hardware_cli_requires_all_three_keys(tmp_path: Path) -> None:
script = Path(lab047.__file__).resolve()
completed = subprocess.run(
[sys.executable, str(script), "--hardware", "--output-directory", str(tmp_path)],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
assert completed.returncode == 2
assert "RF TX = 0" in completed.stdout
def test_tx_reference_diagnostics_require_saved_complete_verdict(tmp_path: Path) -> None:
verdict_path = tmp_path / "independent_result.json"
verdict_path.write_text(
json.dumps({"completed": True, "object_verdict": {"object_complete": False}}),
encoding="utf-8",
)
with pytest.raises(RuntimeError, match="не разрешает"):
lab047.diagnose_jpeg_after_independent_verdict(
verdict_path,
tmp_path / "recovered.jpg",
tmp_path / "tx.jpg",
)
def _original_incomplete_result(capture_path: Path) -> dict:
background_common = {
"correlation_score_count": 100,
"local_peak_count": 10,
"correlation_median": 0.006,
"correlation_mad": 0.002,
"local_peak_quantiles": {
"0.99": 0.018,
"0.999": 0.020810436457395554,
"1.0": 0.025,
},
"candidate_count": 0,
"candidates_per_second": 0.0,
"elapsed_seconds": 0.1,
"local_peak_median": 0.008,
"local_peak_mad": 0.002538509201258421,
}
return {
"completed": False,
"capture_path": str(capture_path),
"rule": {
"method": "pre-TX local-peak Q99.9 + 4 * local-peak MAD",
"local_peak_quantile": 0.999,
"threshold": lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD,
},
"calibration": {
**background_common,
"interval": {
"name": "PRE_TX_BACKGROUND_LAB047",
"start_sample": 0,
"end_sample": 4_000_000,
},
},
"control": {
**background_common,
"interval": {
"name": "POST_TX_BACKGROUND_LAB047",
"start_sample": 96_000_000,
"end_sample": 120_000_000,
},
},
"coarse": {
"termination_reason": "MAX_CANDIDATES_REACHED",
"max_candidates_reached": True,
},
}
def test_saved_hardware_threshold_is_loaded_without_change(tmp_path: Path) -> None:
capture_path = tmp_path / "capture.cs8"
capture_path.write_bytes(bytes(20))
result_path = tmp_path / "independent_result.json"
result_path.write_text(
json.dumps(_original_incomplete_result(capture_path)),
encoding="utf-8",
)
rule, calibration, control = lab047.load_fixed_hardware_candidate_rule(
capture_path,
result_path,
)
assert rule.threshold == 0.030964473262429237
assert calibration.interval == lab047.CALIBRATION_INTERVAL
assert control.interval == lab047.CONTROL_INTERVAL
def test_changed_saved_hardware_threshold_is_rejected(tmp_path: Path) -> None:
capture_path = tmp_path / "capture.cs8"
capture_path.write_bytes(bytes(20))
value = _original_incomplete_result(capture_path)
value["rule"]["threshold"] = 0.031
result_path = tmp_path / "independent_result.json"
result_path.write_text(json.dumps(value), encoding="utf-8")
with pytest.raises(RuntimeError, match="threshold был изменён"):
lab047.load_fixed_hardware_candidate_rule(capture_path, result_path)
def test_streaming_limit_is_not_derived_from_expected_jpeg_frames() -> None:
assert lab047.offline.MAX_CANDIDATES == 100_000
source = inspect.getsource(lab047.offline.stream_process_capture)
assert "36" not in source
assert "expected_frame_count" not in source
def test_reprocessing_persists_verdict_before_tx_diagnostics() -> None:
source = inspect.getsource(lab047.main)
independent_position = source.index("run_saved_capture_streaming_recovery")
diagnostic_position = source.index("diagnose_hardware_jpeg_after_independent_verdict")
assert independent_position < diagnostic_position
def test_completion_accounting_can_be_corrected_without_decoder_rerun(
tmp_path: Path,
) -> None:
capture_path = tmp_path / "capture.cs8"
capture_path.write_bytes(bytes(1_000))
original_path = tmp_path / "reprocess.json"
original = {
"completed": False,
"capture_path": str(capture_path),
"capture_samples": 500,
"rule": {"threshold": lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD},
"coarse": {
"termination_reason": "END_OF_CAPTURE",
"max_candidates_reached": False,
"blocks_processed": 1,
"processed_until_sample": 400,
},
"strict": {
"termination_reason": "END_OF_CAPTURE",
"max_search_iterations_reached": False,
"max_valid_frames_reached": False,
},
}
original_path.write_text(json.dumps(original), encoding="utf-8")
corrected_path = tmp_path / "corrected.json"
corrected = lab047.correct_saved_streaming_completion(
original_path,
corrected_path,
capture_path,
)
assert corrected["completed"] is True
assert corrected["coarse"]["processed_until_sample"] == 500
assert corrected["completion_metadata_correction"]["full_decoder_rerun"] is False
assert json.loads(original_path.read_text(encoding="utf-8"))["completed"] is False
def test_completion_accounting_refuses_different_capture_with_same_size(
tmp_path: Path,
) -> None:
saved_capture = tmp_path / "saved_capture.cs8"
other_capture = tmp_path / "other_capture.cs8"
saved_capture.write_bytes(bytes(1_000))
other_capture.write_bytes(bytes([1]) * 1_000)
original_path = tmp_path / "reprocess.json"
original_path.write_text(
json.dumps(
{
"completed": False,
"capture_path": str(saved_capture),
"capture_samples": 500,
"rule": {"threshold": lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD},
"coarse": {
"termination_reason": "END_OF_CAPTURE",
"max_candidates_reached": False,
"blocks_processed": 1,
"processed_until_sample": 400,
},
"strict": {
"termination_reason": "END_OF_CAPTURE",
"max_search_iterations_reached": False,
"max_valid_frames_reached": False,
},
}
),
encoding="utf-8",
)
corrected_path = tmp_path / "corrected.json"
with pytest.raises(RuntimeError, match="не доказывает полное покрытие"):
lab047.correct_saved_streaming_completion(
original_path,
corrected_path,
other_capture,
)
assert not corrected_path.exists()
def test_finalize_mode_corrects_result_before_opening_tx_reference() -> None:
source = inspect.getsource(lab047.main)
correction_position = source.index("correct_saved_streaming_completion")
diagnostic_position = source.index("diagnose_saved_reprocess_after_corrected_verdict")
assert correction_position < diagnostic_position
def test_saved_diagnostics_refuse_changed_threshold_before_capture_access(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
corrected_path = tmp_path / "corrected.json"
corrected_path.write_text(
json.dumps(
{
"completed": True,
"rule": {"threshold": lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD},
"object_verdict": {"object_complete": True},
"completion_metadata_correction": {
"full_decoder_rerun": False,
"threshold_changed": True,
},
}
),
encoding="utf-8",
)
def fail_if_capture_is_touched(*_args: object, **_kwargs: object) -> None:
raise AssertionError("RX-запись не должна открываться")
monkeypatch.setattr(lab047.offline, "_validate_capture", fail_if_capture_is_touched)
with pytest.raises(RuntimeError, match="Исправленный полный verdict не подтверждён"):
lab047.diagnose_saved_reprocess_after_corrected_verdict(
corrected_path,
tmp_path / "capture.cs8",
tmp_path / "recovered.jpg",
tmp_path / "tx.jpg",
)
def test_hardware_diagnostics_refuse_incomplete_in_memory_verdict(tmp_path: Path) -> None:
independent = object.__new__(lab047.offline.IndependentRecoveryResult)
object.__setattr__(independent, "completed", False)
with pytest.raises(RuntimeError, match="не открывает TX reference"):
lab047.diagnose_hardware_jpeg_after_independent_verdict(
independent,
tmp_path / "tx.jpg",
tmp_path / "recovered.jpg",
)
def test_functional_checks() -> None:
lab047.run_functional_tests()