diff --git a/PROJECT_LOG.md b/PROJECT_LOG.md index 7a665c4..e53c49d 100644 --- a/PROJECT_LOG.md +++ b/PROJECT_LOG.md @@ -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 не запускать. diff --git a/data/processed/lab047/recovered_image.jpg b/data/processed/lab047/recovered_image.jpg new file mode 100644 index 0000000..6e43702 Binary files /dev/null and b/data/processed/lab047/recovered_image.jpg differ diff --git a/data/processed/lab047/result.json b/data/processed/lab047/result.json new file mode 100644 index 0000000..74c9e30 --- /dev/null +++ b/data/processed/lab047/result.json @@ -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 +} \ No newline at end of file diff --git a/data/processed/lab047/result.txt b/data/processed/lab047/result.txt new file mode 100644 index 0000000..c5cc2f3 --- /dev/null +++ b/data/processed/lab047/result.txt @@ -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 diff --git a/data/processed/lab047/tx_image.jpg b/data/processed/lab047/tx_image.jpg new file mode 100644 index 0000000..6e43702 Binary files /dev/null and b/data/processed/lab047/tx_image.jpg differ diff --git a/docs/LABS_INDEX.md b/docs/LABS_INDEX.md index c5ec126..7ed51c9 100644 --- a/docs/LABS_INDEX.md +++ b/docs/LABS_INDEX.md @@ -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`, бинарные объекты и другие аппаратные исходные diff --git a/docs/lab047_current_state.md b/docs/lab047_current_state.md new file mode 100644 index 0000000..5a159a7 --- /dev/null +++ b/docs/lab047_current_state.md @@ -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 не запускать. diff --git a/experiments/lab046c_offline_recovery.py b/experiments/lab046c_offline_recovery.py index ac20644..fee9a70 100644 --- a/experiments/lab046c_offline_recovery.py +++ b/experiments/lab046c_offline_recovery.py @@ -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) - verdict = assemble_independent_object(strict.accepted_frames) + 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 diff --git a/experiments/lab047_jpeg_object_transport.py b/experiments/lab047_jpeg_object_transport.py new file mode 100644 index 0000000..110e1dd --- /dev/null +++ b/experiments/lab047_jpeg_object_transport.py @@ -0,0 +1,1411 @@ +"""Lab047: подготовка передачи одного JPEG через транспорт Lab046. + +Текущий основной режим полностью программный: существующая фотография +воспроизводимо преобразуется в небольшой JPEG, проходит фрагментацию, +защищённые кадры Lab045, идеальный BPSK-канал и независимую сборку объекта. + +Аппаратная часть только подготовлена. Большой поток ``tx_stream.cs8`` можно +сформировать отдельным режимом без RF. Единственный будущий аппаратный запуск +закрыт тремя явными ключами, не содержит повторов и не вызывается из +программного режима. Независимый анализ IQ не принимает TX-объект, смещения +или ожидаемое число кадров. +""" + +from __future__ import annotations + +import argparse +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +import hashlib +import json +import math +from pathlib import Path +import shutil +import sys +import time +from types import SimpleNamespace +from typing import Callable + +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 lab044_hackrf_bpsk_packet as lab044 +from experiments import lab045_hackrf_bpsk_fec as lab045 +from experiments import lab046_bpsk_object_transport as lab046 +from experiments import lab046b_continuous_iq as lab046b +from experiments import lab046c_offline_recovery as offline + + +SOURCE_IMAGE_PATH = Path("data/raw/lab011_scene.jpg") +OUTPUT_DIRECTORY = Path("data/processed/lab047") +TX_IMAGE_NAME = "tx_image.jpg" +RECOVERED_IMAGE_NAME = "recovered_image.jpg" +RESULT_JSON_NAME = "result.json" +RESULT_TEXT_NAME = "result.txt" + +IMAGE_WIDTH = 320 +IMAGE_HEIGHT = 240 +IMAGE_MODE = "RGB" +JPEG_QUALITY = 35 +JPEG_SUBSAMPLING = 2 # 4:2:0 в Pillow +JPEG_OPTIMIZE = False +JPEG_PROGRESSIVE = False +MIN_DEMO_JPEG_BYTES = 8 * 1024 +MAX_DEMO_JPEG_BYTES = 15 * 1024 + +OBJECT_ID = 0x4C303437 # "L047" + +MASTER_SERIAL = lab044.lab043.MASTER_SERIAL +SLAVE_SERIAL = lab044.lab043.SLAVE_SERIAL +RF_HZ = 1_400_000_000 +SAMPLE_RATE_HZ = 8_000_000 +SYMBOL_RATE = 20_000 +SAMPLES_PER_SYMBOL = 400 +CARRIER_OFFSET_HZ = 50_000 +TX_VGA_DB = 10 +RX_LNA_DB = 24 +RX_VGA_DB = 24 + +RX_CAPTURE_SECONDS = 15.0 +RX_CAPTURE_SAMPLE_COUNT = int(RX_CAPTURE_SECONDS * SAMPLE_RATE_HZ) +RX_CAPTURE_FILE_BYTES = 2 * RX_CAPTURE_SAMPLE_COUNT +RX_LEAD_SECONDS = 0.75 +RX_WAIT_TIMEOUT_SECONDS = 25.0 + +CALIBRATION_INTERVAL = offline.SampleInterval( + "PRE_TX_BACKGROUND_LAB047", + 0, + 4_000_000, +) +CONTROL_INTERVAL = offline.SampleInterval( + "POST_TX_BACKGROUND_LAB047", + 96_000_000, + RX_CAPTURE_SAMPLE_COUNT, +) +BACKGROUND_LOCAL_PEAK_QUANTILE = 0.999 +BACKGROUND_LOCAL_PEAK_MAD_MULTIPLIER = 4.0 +FIXED_HARDWARE_CANDIDATE_THRESHOLD = 0.030964473262429237 +REPROCESS_ATTEMPT_NAME = "independent_reprocess_attempt.json" +REPROCESS_RESULT_NAME = "independent_reprocess_result.json" +POST_VERDICT_DIAGNOSTICS_NAME = "post_verdict_diagnostics.json" +CORRECTED_REPROCESS_RESULT_NAME = "independent_reprocess_result_corrected.json" + +RECEIVER_ORACLE_KEYS = { + "tx_frame_offsets", + "expected_frame_count", + "tx_fragment_indexes", + "tx_image", + "tx_packets", + "tx_coded_bits", +} + + +@dataclass(frozen=True) +class JpegInformation: + path: Path + file_bytes: int + width: int + height: int + mode: str + format: str + sha256: str + + +@dataclass(frozen=True) +class TransportAccounting: + jpeg_file_bytes: int + fragment_count: int + rf_frame_count: int + samples_per_frame: int + tx_sample_count: int + tx_file_bytes: int + estimated_airtime_seconds: float + useful_goodput_bits_per_second: float + + +@dataclass(frozen=True) +class SoftwareRoundtrip: + recovered_bytes: bytes | None + crc_pass: int + crc_fail: int + valid_fragments: int + missing_fragments: tuple[int, ...] + object_complete: bool + tx_sha256: str + recovered_sha256: str | None + sha_match: bool + byte_errors: int | None + bit_errors: int | None + + +@dataclass(frozen=True) +class PreparedHardwareTransmission: + output_directory: Path + image_path: Path + stream_path: Path + metadata_path: Path + image_sha256: str + stream_sha256: str + frame_count: int + frame_samples: int + stream_sample_count: int + stream_file_bytes: int + stream_duration_seconds: float + + +def _sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + while block := stream.read(1024 * 1024): + digest.update(block) + return digest.hexdigest() + + +def validate_jpeg(path: Path) -> JpegInformation: + """Полностью проверить JPEG и вернуть его фактические свойства.""" + + path = Path(path) + with Image.open(path) as image: + image.verify() + with Image.open(path) as image: + image.load() + information = JpegInformation( + path=path, + file_bytes=path.stat().st_size, + width=image.width, + height=image.height, + mode=image.mode, + format=str(image.format), + sha256=_sha256_file(path), + ) + if information.format != "JPEG": + raise ValueError(f"{path} не распознан как JPEG") + return information + + +def prepare_jpeg(source_path: Path, destination_path: Path) -> JpegInformation: + """Создать JPEG с фиксированными параметрами, не изменяя оригинал.""" + + source_path = Path(source_path) + destination_path = Path(destination_path) + if source_path.resolve() == destination_path.resolve(): + raise ValueError("Исходное изображение нельзя перезаписывать") + with Image.open(source_path) as source: + source.load() + prepared = source.convert(IMAGE_MODE).resize( + (IMAGE_WIDTH, IMAGE_HEIGHT), + Image.Resampling.LANCZOS, + ) + destination_path.parent.mkdir(parents=True, exist_ok=True) + prepared.save( + destination_path, + format="JPEG", + quality=JPEG_QUALITY, + subsampling=JPEG_SUBSAMPLING, + optimize=JPEG_OPTIMIZE, + progressive=JPEG_PROGRESSIVE, + ) + information = validate_jpeg(destination_path) + if (information.width, information.height, information.mode) != ( + IMAGE_WIDTH, + IMAGE_HEIGHT, + IMAGE_MODE, + ): + raise RuntimeError("Подготовленный JPEG имеет неверные свойства") + return information + + +def transport_accounting(data: bytes) -> TransportAccounting: + """Рассчитать объём реального потока по фактическому размеру объекта.""" + + frames = lab046.build_object_frames(data, OBJECT_ID) + frame_samples = frames[0].plan.waveform_sample_count + if any(frame.plan.waveform_sample_count != frame_samples for frame in frames): + raise RuntimeError("Кадры Lab045 имеют разные длины waveform") + sample_count = sum(frame.plan.waveform_sample_count for frame in frames) + airtime = sample_count / SAMPLE_RATE_HZ + return TransportAccounting( + jpeg_file_bytes=len(data), + fragment_count=len(frames), + rf_frame_count=len(frames), + samples_per_frame=frame_samples, + tx_sample_count=sample_count, + tx_file_bytes=2 * sample_count, + estimated_airtime_seconds=airtime, + useful_goodput_bits_per_second=len(data) * 8 / airtime, + ) + + +def software_roundtrip_jpeg(data: bytes) -> SoftwareRoundtrip: + """Пропустить JPEG через Lab046, Lab045 и идеальный BPSK-канал.""" + + frames = lab046.build_object_frames(data, OBJECT_ID) + assembler = lab046.ObjectAssembler() + crc_pass = 0 + crc_fail = 0 + for frame_number, frame in enumerate(frames): + soft = lab046.ideal_channel(frame.plan, frame_number) + reception = lab045.analyze_post_preamble_soft(soft) + if reception.analysis["crc"] != lab045.CRC_PASS: + crc_fail += 1 + continue + crc_pass += 1 + fragment = ( + lab046.recover_fragment_from_payload(reception.payload_bytes) + if reception.payload_bytes is not None + else None + ) + assembler.offer(fragment) + + recovered = assembler.recovered_object() + recovered_sha = _sha256_bytes(recovered) if recovered is not None else None + transmitted_sha = _sha256_bytes(data) + byte_errors: int | None = None + bit_errors: int | None = None + if recovered is not None and len(recovered) == len(data): + difference = np.bitwise_xor( + np.frombuffer(data, dtype=np.uint8), + np.frombuffer(recovered, dtype=np.uint8), + ) + byte_errors = int(np.count_nonzero(difference)) + bit_errors = int(np.unpackbits(difference).sum()) + return SoftwareRoundtrip( + recovered_bytes=recovered, + crc_pass=crc_pass, + crc_fail=crc_fail, + valid_fragments=assembler.counters.accepted, + missing_fragments=tuple(assembler.missing_indexes()), + object_complete=assembler.object_complete(), + tx_sha256=transmitted_sha, + recovered_sha256=recovered_sha, + sha_match=recovered is not None and recovered_sha == transmitted_sha, + byte_errors=byte_errors, + bit_errors=bit_errors, + ) + + +def hardware_parameters() -> dict: + return { + "master_tx_serial": MASTER_SERIAL, + "slave_rx_serial": SLAVE_SERIAL, + "frequency_hz": RF_HZ, + "sample_rate_hz": SAMPLE_RATE_HZ, + "symbol_rate": SYMBOL_RATE, + "samples_per_symbol": SAMPLES_PER_SYMBOL, + "digital_carrier_hz": CARRIER_OFFSET_HZ, + "tx_vga_db": TX_VGA_DB, + "tx_rf_amp": False, + "tx_bias_tee": False, + "rx_lna_db": RX_LNA_DB, + "rx_vga_db": RX_VGA_DB, + "rx_rf_amp": False, + "rx_bias_tee": False, + "master_clkout_to_slave_clkin": True, + "slave_clkout": False, + "expected_reg29": lab044.lab043.REG29_EXTERNAL_CLOCK, + } + + +def receiver_oracle_declaration() -> dict[str, bool]: + return {key: False for key in sorted(RECEIVER_ORACLE_KEYS)} + + +def _validate_fixed_settings() -> None: + actual_phy = ( + lab045.SAMPLE_RATE_HZ, + lab045.SYMBOL_RATE, + lab045.SAMPLES_PER_SYMBOL, + lab045.CARRIER_OFFSET_HZ, + lab045.TX_VGA_DB, + lab045.RX_LNA_DB, + lab045.RX_VGA_DB, + ) + expected_phy = ( + SAMPLE_RATE_HZ, + SYMBOL_RATE, + SAMPLES_PER_SYMBOL, + CARRIER_OFFSET_HZ, + TX_VGA_DB, + RX_LNA_DB, + RX_VGA_DB, + ) + if actual_phy != expected_phy: + raise RuntimeError(f"Параметры Lab045 {actual_phy} не совпали с {expected_phy}") + if lab046b.IQ_PREAMBLE_THRESHOLD != 0.65: + raise RuntimeError("Исторический строгий порог 0.65 был изменён") + if RX_CAPTURE_SAMPLE_COUNT != int(RX_CAPTURE_SECONDS * SAMPLE_RATE_HZ): + raise RuntimeError("Расчёт длительности RX не совпал") + if CONTROL_INTERVAL.end_sample != RX_CAPTURE_SAMPLE_COUNT: + raise RuntimeError("Контрольный фон не заканчивается вместе с RX") + + +def run_software_preparation( + source_path: Path = SOURCE_IMAGE_PATH, + output_directory: Path = OUTPUT_DIRECTORY, +) -> dict: + """Создать малые демонстрационные артефакты Lab047 без RF.""" + + _validate_fixed_settings() + output_directory = Path(output_directory) + output_directory.mkdir(parents=True, exist_ok=True) + tx_path = output_directory / TX_IMAGE_NAME + recovered_path = output_directory / RECOVERED_IMAGE_NAME + jpeg = prepare_jpeg(source_path, tx_path) + if not MIN_DEMO_JPEG_BYTES <= jpeg.file_bytes <= MAX_DEMO_JPEG_BYTES: + raise RuntimeError( + f"Фиксированная подготовка дала {jpeg.file_bytes} байт вне 8-15 KiB" + ) + + transmitted = tx_path.read_bytes() + accounting = transport_accounting(transmitted) + roundtrip = software_roundtrip_jpeg(transmitted) + if not roundtrip.object_complete or roundtrip.recovered_bytes is None: + raise RuntimeError("Программная сборка JPEG не завершилась") + recovered_path.write_bytes(roundtrip.recovered_bytes) + recovered_jpeg = validate_jpeg(recovered_path) + image_decode_pass = ( + recovered_jpeg.width == jpeg.width + and recovered_jpeg.height == jpeg.height + and recovered_jpeg.mode == jpeg.mode + and recovered_jpeg.format == jpeg.format + ) + if not ( + roundtrip.sha_match + and roundtrip.byte_errors == 0 + and roundtrip.bit_errors == 0 + and image_decode_pass + ): + raise RuntimeError("Программный сквозной проход JPEG не прошёл") + + result = { + "stage": "LAB047_SOFTWARE_PREPARATION", + "source": str(Path(source_path)), + "jpeg": { + **asdict(jpeg), + "path": str(jpeg.path), + "quality": JPEG_QUALITY, + "subsampling": "4:2:0", + "optimize": JPEG_OPTIMIZE, + "progressive": JPEG_PROGRESSIVE, + }, + "transport": asdict(accounting), + "software_roundtrip": { + key: value + for key, value in asdict(roundtrip).items() + if key != "recovered_bytes" + }, + "software_image_decode": "PASS" if image_decode_pass else "FAIL", + "recovered_jpeg": { + **asdict(recovered_jpeg), + "path": str(recovered_jpeg.path), + }, + "planned_rx": { + "duration_seconds": RX_CAPTURE_SECONDS, + "sample_count": RX_CAPTURE_SAMPLE_COUNT, + "file_bytes": RX_CAPTURE_FILE_BYTES, + "lead_seconds": RX_LEAD_SECONDS, + }, + "hardware": hardware_parameters(), + "candidate_rule": { + "method": "pre-TX local-peak Q99.9 + 4 * local-peak MAD", + "quantile": BACKGROUND_LOCAL_PEAK_QUANTILE, + "mad_multiplier": BACKGROUND_LOCAL_PEAK_MAD_MULTIPLIER, + "fixed_before_full_decode": True, + }, + "receiver_oracles": receiver_oracle_declaration(), + "rf_tx": 0, + } + result_path = output_directory / RESULT_JSON_NAME + result_path.write_text( + json.dumps(result, ensure_ascii=False, indent=2, default=str), + encoding="utf-8", + ) + text_path = output_directory / RESULT_TEXT_NAME + text_path.write_text( + "\n".join( + ( + "LAB047 SOFTWARE PREPARATION = PASS", + f"JPEG FILE = {tx_path}", + f"JPEG SIZE = {jpeg.file_bytes}", + f"IMAGE DIMENSIONS = {jpeg.width} x {jpeg.height}", + f"JPEG SHA-256 = {jpeg.sha256}", + f"FRAGMENTS = {accounting.fragment_count}", + f"RF FRAMES = {accounting.rf_frame_count}", + f"ESTIMATED RF AIRTIME = {accounting.estimated_airtime_seconds:.6f} s", + "SOFTWARE ROUNDTRIP = PASS", + "SOFTWARE SHA MATCH = YES", + "SOFTWARE JPEG DECODE = PASS", + "RF TX = 0", + ) + ) + + "\n", + encoding="utf-8", + ) + return result + + +def default_hardware_directory() -> Path: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + return Path.home() / "Documents" / "SDR_Rover_Lab047_raw" / timestamp + + +def prepare_hardware_transmission( + output_directory: Path, + tx_image_path: Path = OUTPUT_DIRECTORY / TX_IMAGE_NAME, +) -> PreparedHardwareTransmission: + """Создать будущий TX-поток без RF и без удержания всего потока в памяти.""" + + _validate_fixed_settings() + output_directory = Path(output_directory) + output_directory.mkdir(parents=True, exist_ok=False) + image_path = output_directory / TX_IMAGE_NAME + stream_path = output_directory / "tx_stream.cs8" + metadata_path = output_directory / "tx_metadata.json" + shutil.copyfile(tx_image_path, image_path) + image = validate_jpeg(image_path) + if not MIN_DEMO_JPEG_BYTES <= image.file_bytes <= MAX_DEMO_JPEG_BYTES: + raise RuntimeError("Аппаратный JPEG находится вне зафиксированного диапазона 8-15 KiB") + if (image.width, image.height, image.mode) != (IMAGE_WIDTH, IMAGE_HEIGHT, IMAGE_MODE): + raise RuntimeError("Аппаратный JPEG имеет незапланированные свойства") + data = image_path.read_bytes() + frames = lab046.build_object_frames(data, OBJECT_ID) + frame_samples = frames[0].plan.waveform_sample_count + stream_sample_count = sum(frame.plan.waveform_sample_count for frame in frames) + stream_duration_seconds = stream_sample_count / SAMPLE_RATE_HZ + control_start_seconds = CONTROL_INTERVAL.start_sample / SAMPLE_RATE_HZ + if RX_LEAD_SECONDS + stream_duration_seconds >= control_start_seconds: + raise RuntimeError("TX не оставляет защитного интервала до контрольного фона") + + stream_digest_builder = hashlib.sha256() + stream_file_bytes = 0 + with stream_path.open("xb") as stream: + for frame in frames: + waveform = lab045.build_tx_cs8(frame.plan) + if len(waveform) != 2 * frame.plan.waveform_sample_count: + raise RuntimeError("Форма Lab045 не прошла учёт cs8") + stream.write(waveform) + stream_digest_builder.update(waveform) + stream_file_bytes += len(waveform) + if stream_file_bytes != 2 * stream_sample_count: + raise RuntimeError("Размер TX-потока не совпал с числом отсчётов") + stream_sha = stream_digest_builder.hexdigest() + if _sha256_file(stream_path) != stream_sha: + raise RuntimeError("Контрольная сумма TX-потока не воспроизвелась") + + metadata = { + "created_utc": datetime.now(timezone.utc).isoformat(), + "stage": "LAB047_PREPARATION_ONLY", + "rf_tx_count": 0, + "jpeg": { + **asdict(image), + "path": str(image_path), + "object_id": OBJECT_ID, + }, + "fragmentation": { + "fragment_data_bytes": lab046.FRAGMENT_DATA_SIZE, + "fragment_count": len(frames), + "last_fragment_data_bytes": ( + frames[-1].encoded_payload_length - lab046.fragments.FRAGMENT_HEADER_SIZE + ), + }, + "tx_stream": { + "path": str(stream_path), + "format": "cs8 interleaved signed I/Q", + "frame_count": len(frames), + "samples_per_frame": frame_samples, + "sample_count": stream_sample_count, + "file_bytes": stream_file_bytes, + "duration_seconds": stream_duration_seconds, + "sha256": stream_sha, + "inter_frame_samples_added": 0, + }, + "planned_rx": { + "duration_seconds": RX_CAPTURE_SECONDS, + "sample_count": RX_CAPTURE_SAMPLE_COUNT, + "file_bytes": RX_CAPTURE_FILE_BYTES, + "lead_seconds": RX_LEAD_SECONDS, + }, + "hardware": hardware_parameters(), + "receiver_oracles": receiver_oracle_declaration(), + } + metadata_path.write_text( + json.dumps(metadata, ensure_ascii=False, indent=2, default=str), + encoding="utf-8", + ) + return PreparedHardwareTransmission( + output_directory=output_directory, + image_path=image_path, + stream_path=stream_path, + metadata_path=metadata_path, + image_sha256=image.sha256, + stream_sha256=stream_sha, + frame_count=len(frames), + frame_samples=frame_samples, + stream_sample_count=stream_sample_count, + stream_file_bytes=stream_file_bytes, + stream_duration_seconds=stream_duration_seconds, + ) + + +def _load_and_validate_prepared( + output_directory: Path, +) -> PreparedHardwareTransmission: + output_directory = Path(output_directory) + metadata_path = output_directory / "tx_metadata.json" + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + image_path = output_directory / TX_IMAGE_NAME + stream_path = output_directory / "tx_stream.cs8" + if metadata.get("stage") != "LAB047_PREPARATION_ONLY": + raise RuntimeError("Неверный этап в метаданных Lab047") + if metadata.get("rf_tx_count") != 0: + raise RuntimeError("Метаданные уже объявляют RF TX") + if metadata.get("hardware") != hardware_parameters(): + raise RuntimeError("Аппаратные параметры изменены") + if metadata.get("receiver_oracles") != receiver_oracle_declaration(): + raise RuntimeError("В метаданных разрешён оракул приёмника") + + image = validate_jpeg(image_path) + if not MIN_DEMO_JPEG_BYTES <= image.file_bytes <= MAX_DEMO_JPEG_BYTES: + raise RuntimeError("Подготовленный аппаратный JPEG вне диапазона 8-15 KiB") + if (image.width, image.height, image.mode) != (IMAGE_WIDTH, IMAGE_HEIGHT, IMAGE_MODE): + raise RuntimeError("Подготовленный аппаратный JPEG имеет неверные свойства") + data = image_path.read_bytes() + frames = lab046.build_object_frames(data, OBJECT_ID) + stream = metadata["tx_stream"] + expected_samples = sum(frame.plan.waveform_sample_count for frame in frames) + expected_bytes = 2 * expected_samples + expected_duration = expected_samples / SAMPLE_RATE_HZ + if RX_LEAD_SECONDS + expected_duration >= CONTROL_INTERVAL.start_sample / SAMPLE_RATE_HZ: + raise RuntimeError("Подготовленный TX перекрывает контрольный фон") + exact = ( + int(metadata["jpeg"]["object_id"]) == OBJECT_ID + and metadata["jpeg"]["sha256"] == image.sha256 + and int(metadata["fragmentation"]["fragment_count"]) == len(frames) + and int(stream["frame_count"]) == len(frames) + and int(stream["samples_per_frame"]) == frames[0].plan.waveform_sample_count + and int(stream["sample_count"]) == expected_samples + and int(stream["file_bytes"]) == expected_bytes + and int(stream["inter_frame_samples_added"]) == 0 + and stream_path.stat().st_size == expected_bytes + and _sha256_file(stream_path) == stream["sha256"] + ) + if not exact: + raise RuntimeError("Подготовленные артефакты Lab047 не прошли точную проверку") + return PreparedHardwareTransmission( + output_directory=output_directory, + image_path=image_path, + stream_path=stream_path, + metadata_path=metadata_path, + image_sha256=image.sha256, + stream_sha256=str(stream["sha256"]), + frame_count=len(frames), + frame_samples=frames[0].plan.waveform_sample_count, + stream_sample_count=expected_samples, + stream_file_bytes=expected_bytes, + stream_duration_seconds=expected_duration, + ) + + +def run_hardware_capture_once( + output_directory: Path, + *, + authorization: bool = False, + runner: lab044.lab043.CommandRunner | None = None, + process_starter: lab044.lab043.ProcessStarter | None = None, + sleep_function: lab044.lab043.SleepFunction = time.sleep, + process_checker: Callable[[], list[str]] = lab045._running_hackrf_transfer_processes, +) -> dict: + """Выполнить ровно один будущий TX и RX без автоматического повтора.""" + + if not authorization: + raise PermissionError("Нет отдельного разрешения на один JPEG RF TX") + _validate_fixed_settings() + runner = runner or lab044.lab043._default_command_runner + process_starter = process_starter or lab044.lab043._default_process_starter + output_directory = Path(output_directory) + prepared = _load_and_validate_prepared(output_directory) + attempt_path = output_directory / "hardware_attempt.json" + with attempt_path.open("x", encoding="utf-8") as stream: + json.dump( + { + "started_utc": datetime.now(timezone.utc).isoformat(), + "authorized_once": True, + "automatic_retry": False, + }, + stream, + ensure_ascii=False, + indent=2, + ) + + guard = lab044.lab043.TransmitGuard() + rx_process: lab044.lab043.RunningCommand | None = None + cleanup: dict = {} + result: dict | None = None + try: + if process_checker(): + raise RuntimeError("До Lab047 уже запущен hackrf_transfer") + inventory = lab045._verify_device_inventory(runner) + preflight = lab044.lab043.common_clock_preflight(runner, guard) + transfer_tool = str(lab044.lab043.HACKRF_TOOLS_DIR / "hackrf_transfer.exe") + rx_path = output_directory / "rx_stream.cs8" + rx_stdout_path = output_directory / "rx_stdout.txt" + rx_stderr_path = output_directory / "rx_stderr.txt" + tx_stdout_path = output_directory / "tx_stdout.txt" + tx_stderr_path = output_directory / "tx_stderr.txt" + rx_command = [ + transfer_tool, + "-d", SLAVE_SERIAL, + "-r", str(rx_path), + "-f", str(RF_HZ), + "-s", str(SAMPLE_RATE_HZ), + "-n", str(RX_CAPTURE_SAMPLE_COUNT), + "-l", str(RX_LNA_DB), + "-g", str(RX_VGA_DB), + "-a", "0", + "-p", "0", + "-B", + ] + rx_process = process_starter(rx_command, rx_stdout_path, rx_stderr_path) + sleep_function(RX_LEAD_SECONDS) + if rx_process.poll() is not None: + rx_process.close_logs() + detail = rx_stderr_path.read_text(encoding="utf-8", errors="replace") + raise RuntimeError(f"RX завершился до TX: {detail.strip()}") + tx_command = [ + transfer_tool, + "-d", MASTER_SERIAL, + "-t", str(prepared.stream_path), + "-f", str(RF_HZ), + "-s", str(SAMPLE_RATE_HZ), + "-n", str(prepared.stream_sample_count), + "-x", str(TX_VGA_DB), + "-a", "0", + "-p", "0", + "-B", + ] + guard.register_transmit() + tx_result = runner(tx_command) + tx_stdout_path.write_text(tx_result.stdout or "", encoding="utf-8") + tx_stderr_path.write_text(tx_result.stderr or "", encoding="utf-8") + if tx_result.returncode != 0: + raise RuntimeError(f"Единственный TX завершился с кодом {tx_result.returncode}") + if rx_process.wait(RX_WAIT_TIMEOUT_SECONDS) != 0: + raise RuntimeError("RX завершился с ошибкой") + if rx_path.stat().st_size != RX_CAPTURE_FILE_BYTES: + raise RuntimeError("RX-файл имеет неверный размер") + rx_log = rx_stdout_path.read_text(encoding="utf-8", errors="replace") + rx_stderr_path.read_text( + encoding="utf-8", errors="replace" + ) + tx_log = (tx_result.stdout or "") + (tx_result.stderr or "") + if not lab044.lab043._transfer_log_is_clean(rx_log): + raise RuntimeError("RX-журнал содержит overrun/underrun") + if not lab044.lab043._transfer_log_is_clean(tx_log): + raise RuntimeError("TX-журнал содержит overrun/underrun") + result = { + "stage": "LAB047_CAPTURE_COMPLETE_ANALYSIS_NOT_STARTED", + "rf_tx_count": guard.transmit_calls, + "automatic_retry": False, + "inventory": inventory, + "preflight": preflight, + "rx_path": str(rx_path), + "rx_sha256": _sha256_file(rx_path), + "rx_samples": RX_CAPTURE_SAMPLE_COUNT, + "rx_duration_seconds": RX_CAPTURE_SECONDS, + "tx_frames": prepared.frame_count, + "tx_duration_seconds": prepared.stream_duration_seconds, + } + finally: + if rx_process is not None: + try: + rx_process.terminate() + except Exception as error: + cleanup.setdefault("rx_process_errors", []).append(str(error)) + cleanup["clock_output_errors"] = lab044.lab043._disable_clock_outputs(runner) + try: + cleanup["lingering_hackrf_transfer"] = process_checker() + except Exception as error: + cleanup["process_check_error"] = str(error) + if result is None: + raise RuntimeError("Lab047 завершилась до результата; повтор запрещён") + result["cleanup"] = cleanup + result["cleanup_passed"] = bool( + not cleanup.get("clock_output_errors") + and cleanup.get("lingering_hackrf_transfer") == [] + and "process_check_error" not in cleanup + ) + (output_directory / "capture_result.json").write_text( + json.dumps(result, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return result + + +def calibrate_dynamic_candidate_rule( + capture_path: Path, + interval: offline.SampleInterval = CALIBRATION_INTERVAL, +) -> tuple[offline.CandidateRule, offline.BackgroundStatistics]: + """Вычислить Q99.9 + 4 MAD только по фону до передачи.""" + + scores, peaks, elapsed = offline._background_peak_scores(capture_path, interval) + threshold = offline.candidate_threshold_from_local_peaks( + peaks, + quantile=BACKGROUND_LOCAL_PEAK_QUANTILE, + mad_multiplier=BACKGROUND_LOCAL_PEAK_MAD_MULTIPLIER, + ) + rule = offline.CandidateRule( + method="pre-TX local-peak Q99.9 + 4 * local-peak MAD", + local_peak_quantile=BACKGROUND_LOCAL_PEAK_QUANTILE, + threshold=threshold, + ) + statistics = offline._background_statistics( + interval, + scores, + peaks, + threshold, + elapsed, + ) + return rule, statistics + + +def run_independent_capture_recovery( + capture_path: Path, + output_directory: Path, +) -> offline.IndependentRecoveryResult: + """Один полный RX-only проход без сведений передатчика.""" + + capture_path = Path(capture_path) + output_directory = Path(output_directory) + output_directory.mkdir(parents=True, exist_ok=True) + result_path = output_directory / "independent_result.json" + attempt_path = output_directory / "independent_attempt.json" + if result_path.exists() or attempt_path.exists(): + raise FileExistsError("Независимый проход уже запускался; второй проход запрещён") + offline._validate_capture(capture_path) + with attempt_path.open("x", encoding="utf-8") as stream: + json.dump( + { + "started_utc": datetime.now(timezone.utc).isoformat(), + "capture_path": str(capture_path), + "candidate_rule": "pre-TX local-peak Q99.9 + 4 * local-peak MAD", + "automatic_retry": False, + "receiver_oracles": receiver_oracle_declaration(), + }, + stream, + ensure_ascii=False, + indent=2, + ) + started = time.perf_counter() + rule, calibration = calibrate_dynamic_candidate_rule(capture_path) + control = offline.evaluate_control_background( + capture_path, + rule, + interval=CONTROL_INTERVAL, + ) + independent = offline._run_with_fixed_candidate_rule( + capture_path, + rule, + calibration, + control, + full_started=started, + ) + result_path.write_text( + json.dumps(offline._independent_summary(independent), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + if independent.object_verdict.object_complete: + recovered = independent.object_verdict.recovered_object + if recovered is None: + raise RuntimeError("OBJECT COMPLETE без восстановленных байтов") + recovered_path = output_directory / RECOVERED_IMAGE_NAME + recovered_path.write_bytes(recovered) + validate_jpeg(recovered_path) + return independent + + +def _background_statistics_from_json(value: dict) -> offline.BackgroundStatistics: + interval = value["interval"] + return offline.BackgroundStatistics( + interval=offline.SampleInterval( + name=str(interval["name"]), + start_sample=int(interval["start_sample"]), + end_sample=int(interval["end_sample"]), + ), + correlation_score_count=int(value["correlation_score_count"]), + local_peak_count=int(value["local_peak_count"]), + correlation_median=float(value["correlation_median"]), + correlation_mad=float(value["correlation_mad"]), + local_peak_quantiles={ + str(key): float(number) + for key, number in value["local_peak_quantiles"].items() + }, + candidate_count=int(value["candidate_count"]), + candidates_per_second=float(value["candidates_per_second"]), + elapsed_seconds=float(value["elapsed_seconds"]), + local_peak_median=float(value["local_peak_median"]), + local_peak_mad=float(value["local_peak_mad"]), + ) + + +def load_fixed_hardware_candidate_rule( + capture_path: Path, + original_result_path: Path, +) -> tuple[ + offline.CandidateRule, + offline.BackgroundStatistics, + offline.BackgroundStatistics, +]: + """Загрузить уже зафиксированный порог без повторной калибровки.""" + + capture_path = Path(capture_path) + original = json.loads(Path(original_result_path).read_text(encoding="utf-8")) + if Path(original["capture_path"]).resolve() != capture_path.resolve(): + raise RuntimeError("Первоначальный результат относится к другому RX capture") + if original.get("completed") is not False: + raise RuntimeError("Ожидался первоначальный незавершённый результат") + coarse = original.get("coarse", {}) + if ( + coarse.get("termination_reason") != "MAX_CANDIDATES_REACHED" + or coarse.get("max_candidates_reached") is not True + ): + raise RuntimeError("Первоначальная остановка не подтверждает MAX_CANDIDATES_REACHED") + rule_value = original["rule"] + rule = offline.CandidateRule( + method=str(rule_value["method"]), + local_peak_quantile=float(rule_value["local_peak_quantile"]), + threshold=float(rule_value["threshold"]), + ) + calibration = _background_statistics_from_json(original["calibration"]) + control = _background_statistics_from_json(original["control"]) + reproduced = ( + calibration.local_peak_quantiles["0.999"] + + BACKGROUND_LOCAL_PEAK_MAD_MULTIPLIER * calibration.local_peak_mad + ) + if not math.isclose( + rule.threshold, + FIXED_HARDWARE_CANDIDATE_THRESHOLD, + rel_tol=0.0, + abs_tol=1e-15, + ): + raise RuntimeError("Зафиксированный аппаратный threshold был изменён") + if not math.isclose(reproduced, rule.threshold, rel_tol=0.0, abs_tol=1e-15): + raise RuntimeError("Q99.9 + 4 MAD не воспроизводит зафиксированный threshold") + if calibration.interval != CALIBRATION_INTERVAL or control.interval != CONTROL_INTERVAL: + raise RuntimeError("Фоновые интервалы аппаратного результата были изменены") + if control.candidate_count != 0: + raise RuntimeError("Первоначальный контрольный фон содержал кандидаты") + return rule, calibration, control + + +def run_saved_capture_streaming_recovery( + capture_path: Path, + original_result_path: Path, + output_directory: Path, +) -> offline.IndependentRecoveryResult: + """Ровно один потоковый повторный анализ сохранённого RX без TX-оракулов.""" + + capture_path = Path(capture_path) + output_directory = Path(output_directory) + output_directory.mkdir(parents=True, exist_ok=True) + attempt_path = output_directory / REPROCESS_ATTEMPT_NAME + result_path = output_directory / REPROCESS_RESULT_NAME + if attempt_path.exists() or result_path.exists(): + raise FileExistsError("Повторная автономная обработка уже запускалась") + offline._validate_capture(capture_path) + rule, calibration, control = load_fixed_hardware_candidate_rule( + capture_path, + original_result_path, + ) + with attempt_path.open("x", encoding="utf-8") as stream: + json.dump( + { + "started_utc": datetime.now(timezone.utc).isoformat(), + "capture_path": str(capture_path), + "original_result_path": str(original_result_path), + "threshold": rule.threshold, + "threshold_changed": False, + "streaming_candidate_processing": True, + "max_candidates_processed": offline.MAX_CANDIDATES, + "automatic_retry": False, + "receiver_oracles": receiver_oracle_declaration(), + "rf_tx": 0, + }, + stream, + ensure_ascii=False, + indent=2, + ) + started = time.perf_counter() + independent = offline._run_with_fixed_candidate_rule( + capture_path, + rule, + calibration, + control, + full_started=started, + ) + result_path.write_text( + json.dumps(offline._independent_summary(independent), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + if independent.object_verdict.object_complete: + recovered = independent.object_verdict.recovered_object + if recovered is None: + raise RuntimeError("OBJECT COMPLETE без восстановленных байтов") + recovered_path = output_directory / RECOVERED_IMAGE_NAME + if recovered_path.exists(): + raise FileExistsError("Восстановленный JPEG уже существует") + recovered_path.write_bytes(recovered) + validate_jpeg(recovered_path) + return independent + + +def diagnose_hardware_jpeg_after_independent_verdict( + independent: offline.IndependentRecoveryResult, + tx_image_path: Path, + recovered_image_path: Path, +) -> dict: + """Сопоставить RX с TX только после полного независимого вердикта.""" + + if not independent.completed: + raise RuntimeError("Незавершённый независимый verdict не открывает TX reference") + transmitted = Path(tx_image_path).read_bytes() + tx_frames = { + frame.fragment_index: frame + for frame in lab046.build_object_frames(transmitted, OBJECT_ID) + } + matched_indexes: set[int] = set() + duplicate_valid_detections = 0 + false_crc_pass = 0 + total_pre_fec_errors = 0 + total_post_fec_errors = 0 + for frame in independent.strict.accepted_frames: + payload = frame.reception.payload_bytes + fragment = ( + lab046.recover_fragment_from_payload(payload) + if payload is not None + else None + ) + if fragment is None or fragment.image_id != OBJECT_ID: + false_crc_pass += 1 + continue + expected = tx_frames.get(fragment.fragment_index) + if expected is None or frame.reception.packet_bytes != expected.plan.packet: + false_crc_pass += 1 + continue + if fragment.fragment_index in matched_indexes: + duplicate_valid_detections += 1 + continue + matched_indexes.add(fragment.fragment_index) + comparison = lab045.diagnostic_pre_and_post_fec( + frame.reception, + frame.post_preamble_soft, + expected.plan, + ) + pre_fec = comparison["pre_fec_coded"]["bit_errors"] + post_fec = comparison["post_fec_packet"]["bit_errors"] + if pre_fec is not None: + total_pre_fec_errors += int(pre_fec) + if post_fec is not None: + total_post_fec_errors += int(post_fec) + + recovered = independent.object_verdict.recovered_object + recovered_sha: str | None = None + byte_errors: int | None = None + bit_errors: int | None = None + jpeg_decode = "NOT_AVAILABLE" + width: int | None = None + height: int | None = None + if recovered is not None: + recovered_sha = _sha256_bytes(recovered) + if len(recovered) == len(transmitted): + difference = np.bitwise_xor( + np.frombuffer(transmitted, dtype=np.uint8), + np.frombuffer(recovered, dtype=np.uint8), + ) + byte_errors = int(np.count_nonzero(difference)) + bit_errors = int(np.unpackbits(difference).sum()) + recovered_jpeg = validate_jpeg(recovered_image_path) + jpeg_decode = "PASS" + width = recovered_jpeg.width + height = recovered_jpeg.height + return { + "stage": "AFTER_COMPLETED_INDEPENDENT_VERDICT_ONLY", + "expected_tx_frames": len(tx_frames), + "matched_valid_frames": len(matched_indexes), + "missed_tx_frames": sorted(set(tx_frames) - matched_indexes), + "false_crc_pass": false_crc_pass, + "duplicate_valid_detections": duplicate_valid_detections, + "total_pre_fec_coded_bit_errors": total_pre_fec_errors, + "total_post_fec_protocol_bit_errors": total_post_fec_errors, + "jpeg_size": len(recovered) if recovered is not None else None, + "recovered_sha256": recovered_sha, + "tx_sha256": _sha256_bytes(transmitted), + "sha_match": recovered is not None and recovered_sha == _sha256_bytes(transmitted), + "byte_errors": byte_errors, + "bit_errors": bit_errors, + "jpeg_decode": jpeg_decode, + "width": width, + "height": height, + } + + +def _expected_streaming_block_count(sample_count: int) -> int: + reference_samples = lab044._preamble_reference_samples().size + overlap = offline._aligned_overlap(reference_samples) + step = offline.DETECTION_CHUNK_SAMPLES - overlap + blocks = 0 + for window_start in range(0, sample_count, step): + blocks += 1 + if min(sample_count, window_start + offline.DETECTION_CHUNK_SAMPLES) == sample_count: + break + return blocks + + +def correct_saved_streaming_completion( + original_reprocess_result_path: Path, + corrected_result_path: Path, + capture_path: Path, +) -> dict: + """Исправить только ошибочную границу покрытия без нового RX-прохода.""" + + original_path = Path(original_reprocess_result_path) + corrected_path = Path(corrected_result_path) + capture_path = Path(capture_path) + value = json.loads(original_path.read_text(encoding="utf-8")) + _, sample_count = offline._validate_capture(capture_path) + saved_capture_path = Path(value.get("capture_path", "")) + try: + same_capture = saved_capture_path.is_file() and saved_capture_path.samefile(capture_path) + except OSError: + same_capture = False + coarse = value["coarse"] + strict = value["strict"] + safe_to_correct = bool( + value.get("completed") is False + and same_capture + and int(value["capture_samples"]) == sample_count + and float(value["rule"]["threshold"]) == FIXED_HARDWARE_CANDIDATE_THRESHOLD + and coarse.get("termination_reason") == "END_OF_CAPTURE" + and coarse.get("max_candidates_reached") is False + and int(coarse.get("blocks_processed", -1)) == _expected_streaming_block_count(sample_count) + and strict.get("termination_reason") == "END_OF_CAPTURE" + and strict.get("max_search_iterations_reached") is False + and strict.get("max_valid_frames_reached") is False + ) + if not safe_to_correct: + raise RuntimeError("Результат не доказывает полное покрытие без повторного прохода") + original_sha = _sha256_file(original_path) + original_processed_until = int(coarse["processed_until_sample"]) + coarse["processed_until_sample"] = sample_count + value["completed"] = True + value["completion_metadata_correction"] = { + "reason": ( + "last valid correlation start was mistaken for the end of owned IQ; " + "all expected score blocks were exhausted" + ), + "original_result_path": str(original_path), + "original_result_sha256": original_sha, + "original_completed": False, + "corrected_completed": True, + "original_processed_until_sample": original_processed_until, + "corrected_processed_until_sample": sample_count, + "full_decoder_rerun": False, + "threshold_changed": False, + } + with corrected_path.open("x", encoding="utf-8") as stream: + json.dump(value, stream, ensure_ascii=False, indent=2) + return value + + +def diagnose_saved_reprocess_after_corrected_verdict( + corrected_result_path: Path, + capture_path: Path, + recovered_image_path: Path, + tx_image_path: Path, +) -> dict: + """Повторно извлечь только принятые RX-кадры и затем сравнить с TX.""" + + verdict = json.loads(Path(corrected_result_path).read_text(encoding="utf-8")) + if ( + verdict.get("completed") is not True + or verdict.get("object_verdict", {}).get("object_complete") is not True + or verdict.get("completion_metadata_correction", {}).get("full_decoder_rerun") is not False + or verdict.get("completion_metadata_correction", {}).get("threshold_changed") is not False + or float(verdict.get("rule", {}).get("threshold", math.nan)) + != FIXED_HARDWARE_CANDIDATE_THRESHOLD + ): + raise RuntimeError("Исправленный полный verdict не подтверждён") + accepted_rows = [ + row + for row in verdict["strict"]["outcomes"] + if row.get("accepted") and row.get("crc") == lab045.CRC_PASS + ] + if len(accepted_rows) != int(verdict["strict"]["counters"]["valid_frames"]): + raise RuntimeError("Сохранённые принятые outcomes неполны") + + raw, sample_count = offline._validate_capture(capture_path) + diagnostic_frames: list[lab046b.IqFrameReception] = [] + diagnostic_started = time.perf_counter() + for number, row in enumerate(accepted_rows, start=1): + candidate = offline.CoarseCandidate( + sample_offset=int(row["candidate_offset"]), + score=float("nan"), + ) + outcome = offline.decode_candidate_from_memmap( + raw, + sample_count, + candidate, + number, + ) + if not ( + outcome.accepted + and outcome.crc == lab045.CRC_PASS + and outcome.frame is not None + ): + raise RuntimeError("Диагностическое повторное извлечение принятого кадра не совпало") + diagnostic_frames.append(outcome.frame) + diagnostic_decode_seconds = time.perf_counter() - diagnostic_started + + recovered = Path(recovered_image_path).read_bytes() + independent_view = SimpleNamespace( + completed=True, + strict=SimpleNamespace(accepted_frames=tuple(diagnostic_frames)), + object_verdict=SimpleNamespace(recovered_object=recovered), + ) + diagnostics = diagnose_hardware_jpeg_after_independent_verdict( + independent_view, + tx_image_path, + recovered_image_path, + ) + diagnostics["accepted_candidates_diagnostically_redecoded"] = len(diagnostic_frames) + diagnostics["diagnostic_redecode_seconds"] = diagnostic_decode_seconds + diagnostics["full_candidate_search_repeated"] = False + return diagnostics + + +def diagnose_jpeg_after_independent_verdict( + independent_result_path: Path, + recovered_image_path: Path, + tx_image_path: Path, +) -> dict: + """Сравнить JPEG с TX только после сохранённого независимого вердикта.""" + + verdict = json.loads(Path(independent_result_path).read_text(encoding="utf-8")) + object_verdict = verdict.get("object_verdict", {}) + if not verdict.get("completed") or not object_verdict.get("object_complete"): + raise RuntimeError("Независимый полный вердикт не разрешает открывать TX reference") + recovered_path = Path(recovered_image_path) + tx_path = Path(tx_image_path) + recovered = recovered_path.read_bytes() + transmitted = tx_path.read_bytes() + byte_errors: int | None = None + bit_errors: int | None = None + if len(recovered) == len(transmitted): + difference = np.bitwise_xor( + np.frombuffer(transmitted, dtype=np.uint8), + np.frombuffer(recovered, dtype=np.uint8), + ) + byte_errors = int(np.count_nonzero(difference)) + bit_errors = int(np.unpackbits(difference).sum()) + recovered_jpeg = validate_jpeg(recovered_path) + tx_jpeg = validate_jpeg(tx_path) + return { + "stage": "AFTER_INDEPENDENT_VERDICT_ONLY", + "tx_bytes": len(transmitted), + "recovered_bytes": len(recovered), + "tx_sha256": _sha256_bytes(transmitted), + "recovered_sha256": _sha256_bytes(recovered), + "sha_match": _sha256_bytes(transmitted) == _sha256_bytes(recovered), + "byte_errors": byte_errors, + "bit_errors": bit_errors, + "image_decode": "PASS", + "image_dimensions_match": ( + tx_jpeg.width == recovered_jpeg.width + and tx_jpeg.height == recovered_jpeg.height + ), + } + + +def run_functional_tests() -> None: + _validate_fixed_settings() + accounting = transport_accounting(bytes(245)) + assert accounting.fragment_count == 2 + assert accounting.rf_frame_count == 2 + assert accounting.tx_file_bytes == 2 * accounting.tx_sample_count + assert receiver_oracle_declaration() == { + key: False for key in sorted(RECEIVER_ORACLE_KEYS) + } + assert CALIBRATION_INTERVAL.end_sample / SAMPLE_RATE_HZ < RX_LEAD_SECONDS + assert CONTROL_INTERVAL.start_sample / SAMPLE_RATE_HZ > ( + RX_LEAD_SECONDS + 36 * 2_306_400 / SAMPLE_RATE_HZ + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--prepare-software", action="store_true") + parser.add_argument("--prepare-hardware", action="store_true") + parser.add_argument("--hardware", action="store_true") + parser.add_argument("--analyze-capture", type=Path) + parser.add_argument("--reprocess-saved-capture", type=Path) + parser.add_argument("--finalize-saved-reprocess", action="store_true") + parser.add_argument("--original-independent-result", type=Path) + parser.add_argument("--i-understand-hardware", action="store_true") + parser.add_argument("--authorize-one-jpeg-rf-tx", action="store_true") + parser.add_argument("--confirm-1400mhz-permitted", action="store_true") + parser.add_argument("--source", type=Path, default=SOURCE_IMAGE_PATH) + parser.add_argument( + "--tx-image", + type=Path, + default=OUTPUT_DIRECTORY / TX_IMAGE_NAME, + help="подготовленный JPEG для формирования будущего cs8", + ) + parser.add_argument("--output-directory", type=Path) + arguments = parser.parse_args() + selected_modes = sum( + ( + arguments.prepare_software, + arguments.prepare_hardware, + arguments.hardware, + arguments.analyze_capture is not None, + arguments.reprocess_saved_capture is not None, + arguments.finalize_saved_reprocess, + ) + ) + if selected_modes > 1: + parser.error("Выберите только один режим") + if arguments.hardware: + authorized = bool( + arguments.i_understand_hardware + and arguments.authorize_one_jpeg_rf_tx + and arguments.confirm_1400mhz_permitted + ) + if not authorized: + print("Аппаратный режим заблокирован без трёх явных ключей; RF TX = 0") + return 2 + if arguments.output_directory is None: + parser.error("--hardware требует --output-directory") + print(json.dumps( + run_hardware_capture_once(arguments.output_directory, authorization=True), + ensure_ascii=False, + indent=2, + )) + return 0 + if arguments.prepare_hardware: + output_directory = arguments.output_directory or default_hardware_directory() + prepared = prepare_hardware_transmission(output_directory, arguments.tx_image) + print(json.dumps({ + "output_directory": str(prepared.output_directory), + "image_sha256": prepared.image_sha256, + "frame_count": prepared.frame_count, + "stream_sample_count": prepared.stream_sample_count, + "stream_file_bytes": prepared.stream_file_bytes, + "stream_duration_seconds": prepared.stream_duration_seconds, + "rf_tx": 0, + }, ensure_ascii=False, indent=2)) + return 0 + if arguments.analyze_capture is not None: + if arguments.output_directory is None: + parser.error("--analyze-capture требует --output-directory") + independent = run_independent_capture_recovery( + arguments.analyze_capture, + arguments.output_directory, + ) + print(json.dumps(offline._independent_summary(independent), ensure_ascii=False, indent=2)) + return 0 + if arguments.reprocess_saved_capture is not None: + if arguments.output_directory is None: + parser.error("--reprocess-saved-capture требует --output-directory") + original_result_path = ( + arguments.original_independent_result + or arguments.output_directory / "independent_result.json" + ) + independent = run_saved_capture_streaming_recovery( + arguments.reprocess_saved_capture, + original_result_path, + arguments.output_directory, + ) + diagnostics = None + if independent.completed: + diagnostics = diagnose_hardware_jpeg_after_independent_verdict( + independent, + arguments.output_directory / TX_IMAGE_NAME, + arguments.output_directory / RECOVERED_IMAGE_NAME, + ) + diagnostic_path = arguments.output_directory / POST_VERDICT_DIAGNOSTICS_NAME + with diagnostic_path.open("x", encoding="utf-8") as stream: + json.dump(diagnostics, stream, ensure_ascii=False, indent=2) + print(json.dumps({ + "independent": { + "completed": independent.completed, + "threshold": independent.rule.threshold, + "capture_samples": independent.capture_samples, + "total_local_peaks": independent.coarse.total_local_peaks, + "first_stage_candidates": independent.coarse.candidate_count, + "candidates_skipped_as_same_local_event": ( + independent.coarse.candidates_skipped_as_same_local_event + ), + "exact_duplicate_candidates_skipped": ( + independent.coarse.exact_duplicate_candidates_skipped + ), + "processed_until_sample": independent.coarse.processed_until_sample, + "termination_reason": independent.coarse.termination_reason, + "strict_counters": asdict(independent.strict.counters), + "object_verdict": { + key: value + for key, value in asdict(independent.object_verdict).items() + if key != "recovered_object" + }, + "full_elapsed_seconds": independent.full_elapsed_seconds, + "estimated_maximum_temporary_bytes": ( + independent.coarse.estimated_maximum_temporary_bytes + ), + }, + "post_verdict_diagnostics": diagnostics, + "rf_tx": 0, + }, ensure_ascii=False, indent=2)) + return 0 + if arguments.finalize_saved_reprocess: + if arguments.output_directory is None: + parser.error("--finalize-saved-reprocess требует --output-directory") + output_directory = arguments.output_directory + capture_path = output_directory / "rx_stream.cs8" + corrected_path = output_directory / CORRECTED_REPROCESS_RESULT_NAME + corrected = correct_saved_streaming_completion( + output_directory / REPROCESS_RESULT_NAME, + corrected_path, + capture_path, + ) + diagnostics = diagnose_saved_reprocess_after_corrected_verdict( + corrected_path, + capture_path, + output_directory / RECOVERED_IMAGE_NAME, + output_directory / TX_IMAGE_NAME, + ) + diagnostic_path = output_directory / POST_VERDICT_DIAGNOSTICS_NAME + with diagnostic_path.open("x", encoding="utf-8") as stream: + json.dump(diagnostics, stream, ensure_ascii=False, indent=2) + print(json.dumps({ + "corrected_completed": corrected["completed"], + "completion_metadata_correction": corrected["completion_metadata_correction"], + "post_verdict_diagnostics": diagnostics, + "rf_tx": 0, + }, ensure_ascii=False, indent=2)) + return 0 + + result = run_software_preparation( + arguments.source, + arguments.output_directory or OUTPUT_DIRECTORY, + ) + print(json.dumps(result, ensure_ascii=False, indent=2, default=str)) + print("RF TX = 0") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_lab046c_offline_recovery.py b/tests/test_lab046c_offline_recovery.py index c1ddc72..e04b6ce 100644 --- a/tests/test_lab046c_offline_recovery.py +++ b/tests/test_lab046c_offline_recovery.py @@ -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"): diff --git a/tests/test_lab047_jpeg_object_transport.py b/tests/test_lab047_jpeg_object_transport.py new file mode 100644 index 0000000..3bb87c5 --- /dev/null +++ b/tests/test_lab047_jpeg_object_transport.py @@ -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()