Lab047: transmit JPEG over real RF link
This commit is contained in:
173
PROJECT_LOG.md
173
PROJECT_LOG.md
@@ -1097,3 +1097,176 @@ passed и 0 failed, `git diff --check` прошёл.
|
|||||||
|
|
||||||
STOP. Новый RF, JPEG, видео, QPSK, 180 МГц и FEC 12+3 без отдельной постановки
|
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 не запускать.
|
||||||
|
|||||||
BIN
data/processed/lab047/recovered_image.jpg
Normal file
BIN
data/processed/lab047/recovered_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
89
data/processed/lab047/result.json
Normal file
89
data/processed/lab047/result.json
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"stage": "LAB047_SOFTWARE_PREPARATION",
|
||||||
|
"source": "data\\raw\\lab011_scene.jpg",
|
||||||
|
"jpeg": {
|
||||||
|
"path": "data\\processed\\lab047\\tx_image.jpg",
|
||||||
|
"file_bytes": 8724,
|
||||||
|
"width": 320,
|
||||||
|
"height": 240,
|
||||||
|
"mode": "RGB",
|
||||||
|
"format": "JPEG",
|
||||||
|
"sha256": "e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a",
|
||||||
|
"quality": 35,
|
||||||
|
"subsampling": "4:2:0",
|
||||||
|
"optimize": false,
|
||||||
|
"progressive": false
|
||||||
|
},
|
||||||
|
"transport": {
|
||||||
|
"jpeg_file_bytes": 8724,
|
||||||
|
"fragment_count": 36,
|
||||||
|
"rf_frame_count": 36,
|
||||||
|
"samples_per_frame": 2306400,
|
||||||
|
"tx_sample_count": 83030400,
|
||||||
|
"tx_file_bytes": 166060800,
|
||||||
|
"estimated_airtime_seconds": 10.3788,
|
||||||
|
"useful_goodput_bits_per_second": 6724.476818129264
|
||||||
|
},
|
||||||
|
"software_roundtrip": {
|
||||||
|
"crc_pass": 36,
|
||||||
|
"crc_fail": 0,
|
||||||
|
"valid_fragments": 36,
|
||||||
|
"missing_fragments": [],
|
||||||
|
"object_complete": true,
|
||||||
|
"tx_sha256": "e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a",
|
||||||
|
"recovered_sha256": "e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a",
|
||||||
|
"sha_match": true,
|
||||||
|
"byte_errors": 0,
|
||||||
|
"bit_errors": 0
|
||||||
|
},
|
||||||
|
"software_image_decode": "PASS",
|
||||||
|
"recovered_jpeg": {
|
||||||
|
"path": "data\\processed\\lab047\\recovered_image.jpg",
|
||||||
|
"file_bytes": 8724,
|
||||||
|
"width": 320,
|
||||||
|
"height": 240,
|
||||||
|
"mode": "RGB",
|
||||||
|
"format": "JPEG",
|
||||||
|
"sha256": "e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a"
|
||||||
|
},
|
||||||
|
"planned_rx": {
|
||||||
|
"duration_seconds": 15.0,
|
||||||
|
"sample_count": 120000000,
|
||||||
|
"file_bytes": 240000000,
|
||||||
|
"lead_seconds": 0.75
|
||||||
|
},
|
||||||
|
"hardware": {
|
||||||
|
"master_tx_serial": "000000000000000097c063dc323b97d3",
|
||||||
|
"slave_rx_serial": "000000000000000097c063dc2e5327d3",
|
||||||
|
"frequency_hz": 1400000000,
|
||||||
|
"sample_rate_hz": 8000000,
|
||||||
|
"symbol_rate": 20000,
|
||||||
|
"samples_per_symbol": 400,
|
||||||
|
"digital_carrier_hz": 50000,
|
||||||
|
"tx_vga_db": 10,
|
||||||
|
"tx_rf_amp": false,
|
||||||
|
"tx_bias_tee": false,
|
||||||
|
"rx_lna_db": 24,
|
||||||
|
"rx_vga_db": 24,
|
||||||
|
"rx_rf_amp": false,
|
||||||
|
"rx_bias_tee": false,
|
||||||
|
"master_clkout_to_slave_clkin": true,
|
||||||
|
"slave_clkout": false,
|
||||||
|
"expected_reg29": 38
|
||||||
|
},
|
||||||
|
"candidate_rule": {
|
||||||
|
"method": "pre-TX local-peak Q99.9 + 4 * local-peak MAD",
|
||||||
|
"quantile": 0.999,
|
||||||
|
"mad_multiplier": 4.0,
|
||||||
|
"fixed_before_full_decode": true
|
||||||
|
},
|
||||||
|
"receiver_oracles": {
|
||||||
|
"expected_frame_count": false,
|
||||||
|
"tx_coded_bits": false,
|
||||||
|
"tx_fragment_indexes": false,
|
||||||
|
"tx_frame_offsets": false,
|
||||||
|
"tx_image": false,
|
||||||
|
"tx_packets": false
|
||||||
|
},
|
||||||
|
"rf_tx": 0
|
||||||
|
}
|
||||||
12
data/processed/lab047/result.txt
Normal file
12
data/processed/lab047/result.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
LAB047 SOFTWARE PREPARATION = PASS
|
||||||
|
JPEG FILE = data\processed\lab047\tx_image.jpg
|
||||||
|
JPEG SIZE = 8724
|
||||||
|
IMAGE DIMENSIONS = 320 x 240
|
||||||
|
JPEG SHA-256 = e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a
|
||||||
|
FRAGMENTS = 36
|
||||||
|
RF FRAMES = 36
|
||||||
|
ESTIMATED RF AIRTIME = 10.378800 s
|
||||||
|
SOFTWARE ROUNDTRIP = PASS
|
||||||
|
SOFTWARE SHA MATCH = YES
|
||||||
|
SOFTWARE JPEG DECODE = PASS
|
||||||
|
RF TX = 0
|
||||||
BIN
data/processed/lab047/tx_image.jpg
Normal file
BIN
data/processed/lab047/tx_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
@@ -148,6 +148,34 @@
|
|||||||
`C:\Users\user\Documents\SDR_Rover_Lab046_raw\20260902_151819_1400MHz_full\`.
|
`C:\Users\user\Documents\SDR_Rover_Lab046_raw\20260902_151819_1400MHz_full\`.
|
||||||
- **Журнал:** записи 028-032 ([`PROJECT_LOG.md`](../PROJECT_LOG.md)).
|
- **Журнал:** записи 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`, бинарные объекты и другие аппаратные исходные
|
Большие записи IQ, потоки `cs8`, бинарные объекты и другие аппаратные исходные
|
||||||
|
|||||||
244
docs/lab047_current_state.md
Normal file
244
docs/lab047_current_state.md
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
# Lab047: аппаратная передача и восстановление одного JPEG
|
||||||
|
|
||||||
|
## Состояние
|
||||||
|
|
||||||
|
Программная подготовка и единственный разрешённый аппаратный сеанс завершены.
|
||||||
|
Первоначальный независимый приём остался незавершённым из-за вычислительного
|
||||||
|
ограничения `MAX_CANDIDATES_REACHED`, до PHY/FEC/CRC он не дошёл. Это
|
||||||
|
историческое состояние сохранено и не переименовано в успешный приём.
|
||||||
|
|
||||||
|
После исправления только порядка обработки кандидатов выполнен ровно один
|
||||||
|
полный автономный повторный анализ исходной RX-записи без нового RF. Он собрал
|
||||||
|
JPEG полностью; последующая диагностическая сверка подтвердила точное
|
||||||
|
совпадение с переданным файлом. На этапе повторной обработки `RF TX = 0`,
|
||||||
|
HackRF не запускался.
|
||||||
|
|
||||||
|
## Аудит транспорта
|
||||||
|
|
||||||
|
Существующий транспорт Lab046 принимает произвольный непустой двоичный объект.
|
||||||
|
Размер 16384 байта был параметром Lab046C, а не ограничением протокола.
|
||||||
|
Число фрагментов вычисляется из фактического размера объекта, длина последнего
|
||||||
|
фрагмента восстанавливается из его собственных метаданных.
|
||||||
|
|
||||||
|
JPEG поэтому передаётся как обычная последовательность байтов. Изменения в
|
||||||
|
`protocol/`, Lab045, FEC и физическом уровне не потребовались и не выполнялись.
|
||||||
|
|
||||||
|
## Демонстрационный JPEG
|
||||||
|
|
||||||
|
Выбрана существующая настоящая фотография
|
||||||
|
`data/raw/lab011_scene.jpg`: поле, грунтовая дорога и деревья. Исходный файл не
|
||||||
|
изменялся. Подготовка полностью фиксирована:
|
||||||
|
|
||||||
|
- цветовой режим RGB;
|
||||||
|
- размер 320 x 240 пикселей;
|
||||||
|
- фильтр уменьшения LANCZOS;
|
||||||
|
- JPEG quality 35;
|
||||||
|
- цветовая субдискретизация 4:2:0;
|
||||||
|
- `optimize = false`;
|
||||||
|
- `progressive = false`.
|
||||||
|
|
||||||
|
Полученный файл `data/processed/lab047/tx_image.jpg` имеет 8724 байта,
|
||||||
|
формат JPEG, режим RGB, размер 320 x 240 и SHA-256
|
||||||
|
`e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a`.
|
||||||
|
Pillow полностью читает файл и не обнаруживает усечения.
|
||||||
|
|
||||||
|
## Программный сквозной проход
|
||||||
|
|
||||||
|
Выполнена цепочка:
|
||||||
|
|
||||||
|
`tx_image.jpg -> bytes -> Lab046 fragmentation -> Lab045 FEC/BPSK ->`
|
||||||
|
`идеальный канал -> PHY/FEC/CRC -> ObjectAssembler -> recovered_image.jpg`.
|
||||||
|
|
||||||
|
Фактический результат:
|
||||||
|
|
||||||
|
- фрагментов: 36;
|
||||||
|
- защищённых BPSK-кадров: 36;
|
||||||
|
- CRC PASS / FAIL: 36 / 0;
|
||||||
|
- действительных фрагментов: 36;
|
||||||
|
- пропущенных фрагментов: 0;
|
||||||
|
- `OBJECT COMPLETE = YES`;
|
||||||
|
- SHA-256 восстановленного файла:
|
||||||
|
`e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a`;
|
||||||
|
- `SHA MATCH = YES`;
|
||||||
|
- ошибочных байтов: 0;
|
||||||
|
- ошибочных битов: 0;
|
||||||
|
- Pillow распознаёт восстановленный файл как JPEG 320 x 240 RGB;
|
||||||
|
- `IMAGE DECODE = PASS`.
|
||||||
|
|
||||||
|
## Учёт будущего радиопотока
|
||||||
|
|
||||||
|
Каждый полный кадр Lab045 содержит 2 306 400 комплексных отсчётов. Для
|
||||||
|
фактических 8724 байт JPEG рассчитано:
|
||||||
|
|
||||||
|
- число отсчётов TX: 83 030 400;
|
||||||
|
- размер будущего `tx_stream.cs8`: 166 060 800 байт;
|
||||||
|
- расчётная длительность TX: 10,3788 с;
|
||||||
|
- полезная скорость по данным JPEG: 6724,48 бит/с;
|
||||||
|
- плановая длительность RX: 15,0 с;
|
||||||
|
- объём RX: 120 000 000 комплексных отсчётов, 240 000 000 байт;
|
||||||
|
- упреждение запуска RX: 0,75 с.
|
||||||
|
|
||||||
|
Большие `tx_stream.cs8`, `rx_stream.cs8` и сырые IQ в Git не создавались и не
|
||||||
|
добавлялись.
|
||||||
|
|
||||||
|
## Подготовленный аппаратный запуск
|
||||||
|
|
||||||
|
`experiments/lab047_jpeg_object_transport.py` содержит отдельные режимы для
|
||||||
|
формирования будущего TX-потока без RF, одного аппаратного захвата и одного
|
||||||
|
независимого полного анализа. Аппаратный режим требует одновременно три явных
|
||||||
|
ключа командной строки и обязательный новый признак разрешения внутри функции.
|
||||||
|
До попытки создаётся одноразовый файл-свидетель; автоматического повтора нет.
|
||||||
|
|
||||||
|
Параметры будущего опыта зафиксированы:
|
||||||
|
|
||||||
|
- MASTER/TX: `000000000000000097c063dc323b97d3`;
|
||||||
|
- SLAVE/RX: `000000000000000097c063dc2e5327d3`;
|
||||||
|
- центральная частота: 1 400 000 000 Гц;
|
||||||
|
- частота дискретизации: 8 МГц;
|
||||||
|
- BPSK: 20 ксимв/с, 400 отсчётов на символ;
|
||||||
|
- цифровое смещение несущей: +50 кГц;
|
||||||
|
- TX VGA: 10 дБ;
|
||||||
|
- RX LNA/VGA: 24/24 дБ;
|
||||||
|
- RF amplifier и Bias-Tee: выключены на обоих устройствах;
|
||||||
|
- общий генератор: TCXO 10 МГц -> MASTER -> CLKOUT -> SLAVE CLKIN;
|
||||||
|
- ожидаемый регистр 29 обоих устройств после проверки: `0x26`.
|
||||||
|
|
||||||
|
## Независимое обнаружение
|
||||||
|
|
||||||
|
Для каждой новой записи первый порог вычисляется заново только по фону до TX:
|
||||||
|
|
||||||
|
`threshold = Q99.9 локальных максимумов + 4 x MAD локальных максимумов`.
|
||||||
|
|
||||||
|
Интервал калибровки равен 0,0-0,5 с записи, затем порог фиксируется до полного
|
||||||
|
декодирования. Независимый контрольный фон расположен в интервале 12,0-15,0 с.
|
||||||
|
Строгий каскад остаётся прежним:
|
||||||
|
|
||||||
|
`точная синхронизация -> CFO -> фаза -> PHY header -> FEC -> CRC`.
|
||||||
|
|
||||||
|
Кадр принимается только при `CRC PASS`. Исторический
|
||||||
|
`IQ_PREAMBLE_THRESHOLD = 0.65` не изменён. Интерфейс независимого приёмника
|
||||||
|
получает только путь к RX-записи и выходной каталог. Он не получает смещения
|
||||||
|
TX, ожидаемое число кадров, индексы фрагментов передатчика, исходный JPEG,
|
||||||
|
пакеты или кодированные биты. Сравнение с TX-файлом вынесено в отдельную
|
||||||
|
функцию, которая открывается только после сохранённого полного независимого
|
||||||
|
вердикта.
|
||||||
|
|
||||||
|
## Единственный аппаратный сеанс
|
||||||
|
|
||||||
|
3 сентября 2026 года выполнен один непрерывный RF TX без повторов. Частота
|
||||||
|
равна 1400 МГц, фактическая длительность TX — 10,38030 с. RX продолжался
|
||||||
|
15,00875 с и сохранил 120000000 комплексных отсчётов, или 240000000 байт.
|
||||||
|
RX overruns 0, TX underruns 0. Проверка общего опорного генератора прошла.
|
||||||
|
Повторная передача не выполнялась: `RF TX COUNT = 1` за аппаратный сеанс и
|
||||||
|
`RF TX = 0` на этапе автономной обработки.
|
||||||
|
|
||||||
|
По фону до передачи до декодирования зафиксированы:
|
||||||
|
|
||||||
|
- Q99.9 локальных максимумов: `0.020810436457395554`;
|
||||||
|
- MAD локальных максимумов: `0.002538509201258421`;
|
||||||
|
- порог `Q99.9 + 4 * MAD`: `0.030964473262429237`.
|
||||||
|
|
||||||
|
На независимом контрольном фоне после передачи maximum равен
|
||||||
|
`0.026494961231946945`, кандидатов 0. Порог после аппаратного результата и
|
||||||
|
при повторной обработке не менялся.
|
||||||
|
|
||||||
|
Первоначальный приём остановился после 71113 локальных максимумов и заполнения
|
||||||
|
промежуточного списка из 10000 кандидатов. Сработало
|
||||||
|
`MAX_CANDIDATES_REACHED`; строгая ступень не запускалась. Поэтому сохранённый
|
||||||
|
исторический результат сформулирован точно:
|
||||||
|
`ORIGINAL HARDWARE RECEIVE = INCOMPLETE DUE TO MAX_CANDIDATES_REACHED`, а не
|
||||||
|
PHY FAIL, FEC FAIL, CRC FAIL или отказ передачи JPEG.
|
||||||
|
|
||||||
|
## Потоковая обработка кандидатов
|
||||||
|
|
||||||
|
В `lab046c_offline_recovery.py` устранено предварительное накопление всех
|
||||||
|
кандидатов. Блоки IQ и локальные максимумы идут по возрастанию смещения, а
|
||||||
|
каждый кандидат сразу передаётся строгой ступени. Защитный предел сохранён и
|
||||||
|
теперь ограничивает число фактически выполненных строгих проверок; его
|
||||||
|
значение 100000 не зависит от числа кадров JPEG.
|
||||||
|
|
||||||
|
После принятого по CRC кадра позиция перескакивает за принадлежащую ему область.
|
||||||
|
После плохого кандидата она продвигается на один отсчёт, поэтому близкий
|
||||||
|
настоящий кадр не скрывается. Точный повтор одного смещения подавляется;
|
||||||
|
произвольного радиуса подавления и глобального отношения первого пика ко
|
||||||
|
второму нет. Перекрытия блоков имеют однозначное владение и не породили
|
||||||
|
дубликатов.
|
||||||
|
|
||||||
|
Для списка кандидатов измерено около 108 байт на запись; при потоковой
|
||||||
|
обработке пиковый объём такого списка составил 49588 байт. Основной временный
|
||||||
|
объём определяет обработка блока IQ: приблизительно 1545755352 байта
|
||||||
|
(около 1,44 ГиБ).
|
||||||
|
|
||||||
|
## Автономный результат исходной RX-записи
|
||||||
|
|
||||||
|
После программного шлюза выполнен ровно один полный независимый проход всей
|
||||||
|
15-секундной записи. Передаточные смещения, период кадров, ожидаемое число
|
||||||
|
кадров, номера фрагментов, исходный JPEG, пакеты и кодированные биты не
|
||||||
|
использовались. Получено:
|
||||||
|
|
||||||
|
- всего локальных максимумов: 185828;
|
||||||
|
- кандидатов первого каскада: 32414;
|
||||||
|
- строгих проверок: 69;
|
||||||
|
- кандидатов, пропущенных как принадлежащие уже принятому локальному событию:
|
||||||
|
32345;
|
||||||
|
- точных дубликатов на перекрытиях: 0;
|
||||||
|
- PHY-заголовок PASS/FAIL: `36/33`;
|
||||||
|
- FEC attempts/PASS/FAIL: `36/36/0`;
|
||||||
|
- CRC PASS/FAIL: `36/0`;
|
||||||
|
- действительных кадров и фрагментов: `36/36`;
|
||||||
|
- отсутствующих фрагментов: 0;
|
||||||
|
- `OBJECT COMPLETE = YES`.
|
||||||
|
|
||||||
|
Все восемь блоков оценки были исчерпаны, защитные пределы не сработали. Полное
|
||||||
|
время равно 37,42391 с, из них строгая ступень заняла 32,459969 с; среднее
|
||||||
|
время одной строгой проверки — 0,470434 с. Первоначально поле `completed`
|
||||||
|
осталось ложным из-за отдельной ошибки учёта границы: последнее допустимое
|
||||||
|
начало корреляции было принято за конец обработанной области IQ. Исходный JSON
|
||||||
|
результата не изменён, полный поиск не повторяли. В отдельном исправленном JSON
|
||||||
|
после проверки исчерпания всех восьми блоков граница изменена с 119471220 на
|
||||||
|
120000000 и `completed` на `true`; порог не менялся,
|
||||||
|
`full_decoder_rerun = false`.
|
||||||
|
|
||||||
|
Только после сохранения завершённого независимого вердикта открыты опорные
|
||||||
|
данные передатчика. Совпали 36 из 36 кадров, пропущено 0, ложных CRC PASS 0.
|
||||||
|
До FEC обнаружено 19 ошибок кодированных бит, после FEC — 0 ошибок
|
||||||
|
протокольных бит. В независимый результат кадры не добавлялись.
|
||||||
|
|
||||||
|
Восстановленный `recovered_image.jpg` имеет 8724 байта. Его SHA-256 и SHA-256
|
||||||
|
переданного файла одинаковы:
|
||||||
|
`e913dab9ac32795250880a4be67fbbbf89a4f96536842476ab591d1acaafc02a`.
|
||||||
|
Ошибочных байтов 0, ошибочных битов 0. Pillow полностью декодирует JPEG как
|
||||||
|
изображение RGB 320 x 240. Итог:
|
||||||
|
`LAB047 OFFLINE RECOVERY OF HARDWARE CAPTURE = PASS`; единственный RF-сеанс
|
||||||
|
фактически передал JPEG успешно, а первоначальную обработку остановил
|
||||||
|
вычислительный предел.
|
||||||
|
|
||||||
|
## Артефакты
|
||||||
|
|
||||||
|
- `data/processed/lab047/tx_image.jpg`;
|
||||||
|
- `data/processed/lab047/recovered_image.jpg`;
|
||||||
|
- `data/processed/lab047/result.json`;
|
||||||
|
- `data/processed/lab047/result.txt`.
|
||||||
|
|
||||||
|
Большая RX-запись и результаты аппаратного сеанса оставлены вне Git в
|
||||||
|
`C:\Users\user\Documents\SDR_Rover_Lab047_raw\20260903_115747`. Исходный
|
||||||
|
`independent_reprocess_result.json` сохранён неизменным; исправление только
|
||||||
|
признака полного покрытия находится в
|
||||||
|
`independent_reprocess_result_corrected.json`, а разрешённая послерезультатная
|
||||||
|
сверка — в `post_verdict_diagnostics.json`.
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
- `python tools/quick_gate.py`: PASS, 540 проверок pytest и 10 быстрых
|
||||||
|
функциональных проверок лабораторных;
|
||||||
|
- `python -m pytest tests/ -q`: 540 passed, 0 failed;
|
||||||
|
- `git diff --check`: PASS.
|
||||||
|
|
||||||
|
Предупреждение pytest о невозможности создать служебный `.pytest_cache` не
|
||||||
|
относится к тестам и не изменило код возврата.
|
||||||
|
|
||||||
|
## Следующий этап
|
||||||
|
|
||||||
|
STOP. Разрешение на единственный аппаратный процесс Lab047 израсходовано.
|
||||||
|
Новый RF, повтор передачи, изменение порога, PHY, FEC, CRC или Lab045 без
|
||||||
|
отдельной новой постановки не выполнять. `RF TX = 0`; HackRF не запускать.
|
||||||
@@ -54,8 +54,14 @@ BACKGROUND_1400MHZ_LOCAL_PEAK_QUANTILE = 0.999
|
|||||||
BACKGROUND_1400MHZ_MAD_MULTIPLIER = 4.0
|
BACKGROUND_1400MHZ_MAD_MULTIPLIER = 4.0
|
||||||
FIXED_1400MHZ_CANDIDATE_THRESHOLD = 0.03361873421818018
|
FIXED_1400MHZ_CANDIDATE_THRESHOLD = 0.03361873421818018
|
||||||
DETECTION_CHUNK_SAMPLES = 16_000_000
|
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
|
MAX_VALID_FRAMES = 256
|
||||||
REFINEMENT_MARGIN_SAMPLES = 4_000
|
REFINEMENT_MARGIN_SAMPLES = 4_000
|
||||||
# resample_poly/fftconvolve одновременно создают несколько крупных временных
|
# resample_poly/fftconvolve одновременно создают несколько крупных временных
|
||||||
@@ -143,6 +149,12 @@ class CoarseSearchResult:
|
|||||||
elapsed_seconds: float
|
elapsed_seconds: float
|
||||||
termination_reason: str
|
termination_reason: str
|
||||||
max_candidates_reached: bool
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -197,6 +209,8 @@ class StrictSearchResult:
|
|||||||
termination_reason: str
|
termination_reason: str
|
||||||
max_search_iterations_reached: bool
|
max_search_iterations_reached: bool
|
||||||
max_valid_frames_reached: bool
|
max_valid_frames_reached: bool
|
||||||
|
outcomes_truncated: bool = False
|
||||||
|
cursor_history_truncated: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -524,7 +538,7 @@ def coarse_search_capture(
|
|||||||
capture_path: Path,
|
capture_path: Path,
|
||||||
rule: CandidateRule,
|
rule: CandidateRule,
|
||||||
*,
|
*,
|
||||||
max_candidates: int = MAX_CANDIDATES,
|
max_candidates: int = MAX_STORED_COARSE_CANDIDATES,
|
||||||
chunk_samples: int = DETECTION_CHUNK_SAMPLES,
|
chunk_samples: int = DETECTION_CHUNK_SAMPLES,
|
||||||
) -> CoarseSearchResult:
|
) -> CoarseSearchResult:
|
||||||
"""Найти first-stage candidates во всём capture без TX offsets/count."""
|
"""Найти first-stage candidates во всём capture без TX offsets/count."""
|
||||||
@@ -538,6 +552,8 @@ def coarse_search_capture(
|
|||||||
total_local_peaks = 0
|
total_local_peaks = 0
|
||||||
maximum_window = 0
|
maximum_window = 0
|
||||||
maximum_temporary = 0
|
maximum_temporary = 0
|
||||||
|
blocks_processed = 0
|
||||||
|
processed_until_sample = 0
|
||||||
termination = "END_OF_CAPTURE"
|
termination = "END_OF_CAPTURE"
|
||||||
max_reached = False
|
max_reached = False
|
||||||
decimation = lab046c.lab044.lab043.DETECTION_DECIMATION
|
decimation = lab046c.lab044.lab043.DETECTION_DECIMATION
|
||||||
@@ -546,6 +562,11 @@ def coarse_search_capture(
|
|||||||
whole,
|
whole,
|
||||||
chunk_samples=chunk_samples,
|
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)
|
total_local_peaks += int(indexes.size)
|
||||||
maximum_window = max(maximum_window, window_samples)
|
maximum_window = max(maximum_window, window_samples)
|
||||||
maximum_temporary = max(maximum_temporary, temporary_bytes)
|
maximum_temporary = max(maximum_temporary, temporary_bytes)
|
||||||
@@ -571,6 +592,10 @@ def coarse_search_capture(
|
|||||||
elapsed_seconds=time.perf_counter() - started,
|
elapsed_seconds=time.perf_counter() - started,
|
||||||
termination_reason=termination,
|
termination_reason=termination,
|
||||||
max_candidates_reached=max_reached,
|
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(
|
def assemble_independent_object(
|
||||||
frames: tuple[lab046b.IqFrameReception, ...],
|
frames: tuple[lab046b.IqFrameReception, ...],
|
||||||
) -> ObjectVerdict:
|
) -> ObjectVerdict:
|
||||||
@@ -914,24 +1149,15 @@ def _run_with_fixed_candidate_rule(
|
|||||||
|
|
||||||
capture_path = Path(capture_path)
|
capture_path = Path(capture_path)
|
||||||
_, sample_count = _validate_capture(capture_path)
|
_, sample_count = _validate_capture(capture_path)
|
||||||
coarse = coarse_search_capture(capture_path, rule)
|
coarse, strict = stream_process_capture(capture_path, rule)
|
||||||
if coarse.max_candidates_reached:
|
verdict = assemble_independent_object(strict.accepted_frames)
|
||||||
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)
|
|
||||||
elapsed = time.perf_counter() - full_started
|
elapsed = time.perf_counter() - full_started
|
||||||
completed = bool(
|
completed = bool(
|
||||||
not coarse.max_candidates_reached
|
not coarse.max_candidates_reached
|
||||||
and not strict.max_search_iterations_reached
|
and not strict.max_search_iterations_reached
|
||||||
and not strict.max_valid_frames_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(
|
return IndependentRecoveryResult(
|
||||||
completed=completed,
|
completed=completed,
|
||||||
@@ -1212,6 +1438,8 @@ def _independent_summary(result: IndependentRecoveryResult) -> dict:
|
|||||||
"termination_reason": result.strict.termination_reason,
|
"termination_reason": result.strict.termination_reason,
|
||||||
"max_search_iterations_reached": result.strict.max_search_iterations_reached,
|
"max_search_iterations_reached": result.strict.max_search_iterations_reached,
|
||||||
"max_valid_frames_reached": result.strict.max_valid_frames_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": [
|
"outcomes": [
|
||||||
{
|
{
|
||||||
key: value
|
key: value
|
||||||
|
|||||||
1411
experiments/lab047_jpeg_object_transport.py
Normal file
1411
experiments/lab047_jpeg_object_transport.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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:]))
|
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(
|
def test_max_candidates_fails_closed(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -572,9 +777,26 @@ def test_safety_limited_independent_run_does_not_unlock_tx_reference(
|
|||||||
"MAX_CANDIDATES_REACHED",
|
"MAX_CANDIDATES_REACHED",
|
||||||
True,
|
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, "calibrate_candidate_rule", lambda path: (rule, background))
|
||||||
monkeypatch.setattr(offline, "evaluate_control_background", lambda path, fixed: 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)
|
result = offline.run_independent_recovery(capture)
|
||||||
assert result.completed is False
|
assert result.completed is False
|
||||||
with pytest.raises(RuntimeError, match="verdict"):
|
with pytest.raises(RuntimeError, match="verdict"):
|
||||||
|
|||||||
434
tests/test_lab047_jpeg_object_transport.py
Normal file
434
tests/test_lab047_jpeg_object_transport.py
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from experiments import lab047_jpeg_object_transport as lab047
|
||||||
|
|
||||||
|
|
||||||
|
def _test_source(path: Path) -> Path:
|
||||||
|
y, x = np.mgrid[0:96, 0:128]
|
||||||
|
pixels = np.stack(
|
||||||
|
((3 * x + y) % 256, (x + 5 * y) % 256, (7 * x + 2 * y) % 256),
|
||||||
|
axis=-1,
|
||||||
|
).astype(np.uint8)
|
||||||
|
Image.fromarray(pixels, mode="RGB").save(path, format="PNG")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixed_jpeg_preparation_is_reproducible(tmp_path: Path) -> None:
|
||||||
|
source = _test_source(tmp_path / "source.png")
|
||||||
|
first = lab047.prepare_jpeg(source, tmp_path / "first.jpg")
|
||||||
|
second = lab047.prepare_jpeg(source, tmp_path / "second.jpg")
|
||||||
|
|
||||||
|
assert first.sha256 == second.sha256
|
||||||
|
assert first.file_bytes == second.file_bytes
|
||||||
|
assert (first.width, first.height, first.mode, first.format) == (320, 240, "RGB", "JPEG")
|
||||||
|
|
||||||
|
|
||||||
|
def test_selected_photo_produces_target_size_reproducibly(tmp_path: Path) -> None:
|
||||||
|
first = lab047.prepare_jpeg(lab047.SOURCE_IMAGE_PATH, tmp_path / "first.jpg")
|
||||||
|
second = lab047.prepare_jpeg(lab047.SOURCE_IMAGE_PATH, tmp_path / "second.jpg")
|
||||||
|
|
||||||
|
assert 8 * 1024 <= first.file_bytes <= 15 * 1024
|
||||||
|
assert first.sha256 == second.sha256
|
||||||
|
|
||||||
|
|
||||||
|
def test_jpeg_passes_full_software_transport(tmp_path: Path) -> None:
|
||||||
|
source = _test_source(tmp_path / "source.png")
|
||||||
|
jpeg_path = tmp_path / "tx.jpg"
|
||||||
|
lab047.prepare_jpeg(source, jpeg_path)
|
||||||
|
transmitted = jpeg_path.read_bytes()
|
||||||
|
|
||||||
|
result = lab047.software_roundtrip_jpeg(transmitted)
|
||||||
|
|
||||||
|
assert result.object_complete is True
|
||||||
|
assert result.crc_pass == len(lab047.lab046.build_object_frames(transmitted, lab047.OBJECT_ID))
|
||||||
|
assert result.crc_fail == 0
|
||||||
|
assert result.sha_match is True
|
||||||
|
assert result.byte_errors == 0
|
||||||
|
assert result.bit_errors == 0
|
||||||
|
assert result.recovered_bytes == transmitted
|
||||||
|
recovered_path = tmp_path / "recovered.jpg"
|
||||||
|
recovered_path.write_bytes(result.recovered_bytes or b"")
|
||||||
|
assert lab047.validate_jpeg(recovered_path).format == "JPEG"
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_is_not_accepted_without_crc_pass(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
data = bytes(245)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
lab047.lab045,
|
||||||
|
"analyze_post_preamble_soft",
|
||||||
|
lambda soft: SimpleNamespace(
|
||||||
|
analysis={"crc": lab047.lab045.CRC_FAIL},
|
||||||
|
payload_bytes=bytes(lab047.lab045.PAYLOAD_SIZE_BYTES),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = lab047.software_roundtrip_jpeg(data)
|
||||||
|
|
||||||
|
assert result.crc_pass == 0
|
||||||
|
assert result.crc_fail == 2
|
||||||
|
assert result.valid_fragments == 0
|
||||||
|
assert result.object_complete is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_accounting_uses_actual_object_size() -> None:
|
||||||
|
accounting = lab047.transport_accounting(bytes(8_724))
|
||||||
|
|
||||||
|
assert accounting.fragment_count == 36
|
||||||
|
assert accounting.rf_frame_count == 36
|
||||||
|
assert accounting.samples_per_frame == 2_306_400
|
||||||
|
assert accounting.tx_sample_count == 83_030_400
|
||||||
|
assert accounting.tx_file_bytes == 166_060_800
|
||||||
|
assert accounting.estimated_airtime_seconds == pytest.approx(10.3788)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_threshold_is_q999_plus_four_local_peak_mad(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
scores = np.linspace(0.001, 0.02, 20, dtype=np.float32)
|
||||||
|
peaks = np.array([0.01, 0.02, 0.03, 0.04, 0.05], dtype=np.float32)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
lab047.offline,
|
||||||
|
"_background_peak_scores",
|
||||||
|
lambda capture_path, interval: (scores, peaks, 0.25),
|
||||||
|
)
|
||||||
|
|
||||||
|
rule, statistics = lab047.calibrate_dynamic_candidate_rule(tmp_path / "unused.cs8")
|
||||||
|
expected = lab047.offline.candidate_threshold_from_local_peaks(
|
||||||
|
peaks,
|
||||||
|
quantile=0.999,
|
||||||
|
mad_multiplier=4.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rule.threshold == expected
|
||||||
|
assert rule.local_peak_quantile == 0.999
|
||||||
|
assert statistics.interval == lab047.CALIBRATION_INTERVAL
|
||||||
|
assert statistics.local_peak_mad == pytest.approx(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def test_independent_receiver_api_has_no_tx_oracles() -> None:
|
||||||
|
parameters = set(inspect.signature(lab047.run_independent_capture_recovery).parameters)
|
||||||
|
|
||||||
|
assert parameters == {"capture_path", "output_directory"}
|
||||||
|
assert not parameters & {
|
||||||
|
"tx_offsets",
|
||||||
|
"expected_frame_count",
|
||||||
|
"tx_image",
|
||||||
|
"tx_packets",
|
||||||
|
"tx_coded_bits",
|
||||||
|
}
|
||||||
|
assert all(value is False for value in lab047.receiver_oracle_declaration().values())
|
||||||
|
reprocess_parameters = set(
|
||||||
|
inspect.signature(lab047.run_saved_capture_streaming_recovery).parameters
|
||||||
|
)
|
||||||
|
assert reprocess_parameters == {
|
||||||
|
"capture_path",
|
||||||
|
"original_result_path",
|
||||||
|
"output_directory",
|
||||||
|
}
|
||||||
|
assert "expected_frame_count" not in reprocess_parameters
|
||||||
|
|
||||||
|
|
||||||
|
def test_historical_strict_threshold_and_fixed_hardware_are_unchanged() -> None:
|
||||||
|
assert lab047.lab046b.IQ_PREAMBLE_THRESHOLD == 0.65
|
||||||
|
assert lab047.hardware_parameters() == {
|
||||||
|
"master_tx_serial": "000000000000000097c063dc323b97d3",
|
||||||
|
"slave_rx_serial": "000000000000000097c063dc2e5327d3",
|
||||||
|
"frequency_hz": 1_400_000_000,
|
||||||
|
"sample_rate_hz": 8_000_000,
|
||||||
|
"symbol_rate": 20_000,
|
||||||
|
"samples_per_symbol": 400,
|
||||||
|
"digital_carrier_hz": 50_000,
|
||||||
|
"tx_vga_db": 10,
|
||||||
|
"tx_rf_amp": False,
|
||||||
|
"tx_bias_tee": False,
|
||||||
|
"rx_lna_db": 24,
|
||||||
|
"rx_vga_db": 24,
|
||||||
|
"rx_rf_amp": False,
|
||||||
|
"rx_bias_tee": False,
|
||||||
|
"master_clkout_to_slave_clkin": True,
|
||||||
|
"slave_clkout": False,
|
||||||
|
"expected_reg29": 0x26,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_hardware_function_is_closed_without_new_authorization(tmp_path: Path) -> None:
|
||||||
|
called = False
|
||||||
|
|
||||||
|
def runner(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return subprocess.CompletedProcess(command, 0, "", "")
|
||||||
|
|
||||||
|
with pytest.raises(PermissionError, match="отдельного разрешения"):
|
||||||
|
lab047.run_hardware_capture_once(tmp_path, runner=runner)
|
||||||
|
assert called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_hardware_cli_requires_all_three_keys(tmp_path: Path) -> None:
|
||||||
|
script = Path(lab047.__file__).resolve()
|
||||||
|
completed = subprocess.run(
|
||||||
|
[sys.executable, str(script), "--hardware", "--output-directory", str(tmp_path)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert completed.returncode == 2
|
||||||
|
assert "RF TX = 0" in completed.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_tx_reference_diagnostics_require_saved_complete_verdict(tmp_path: Path) -> None:
|
||||||
|
verdict_path = tmp_path / "independent_result.json"
|
||||||
|
verdict_path.write_text(
|
||||||
|
json.dumps({"completed": True, "object_verdict": {"object_complete": False}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="не разрешает"):
|
||||||
|
lab047.diagnose_jpeg_after_independent_verdict(
|
||||||
|
verdict_path,
|
||||||
|
tmp_path / "recovered.jpg",
|
||||||
|
tmp_path / "tx.jpg",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _original_incomplete_result(capture_path: Path) -> dict:
|
||||||
|
background_common = {
|
||||||
|
"correlation_score_count": 100,
|
||||||
|
"local_peak_count": 10,
|
||||||
|
"correlation_median": 0.006,
|
||||||
|
"correlation_mad": 0.002,
|
||||||
|
"local_peak_quantiles": {
|
||||||
|
"0.99": 0.018,
|
||||||
|
"0.999": 0.020810436457395554,
|
||||||
|
"1.0": 0.025,
|
||||||
|
},
|
||||||
|
"candidate_count": 0,
|
||||||
|
"candidates_per_second": 0.0,
|
||||||
|
"elapsed_seconds": 0.1,
|
||||||
|
"local_peak_median": 0.008,
|
||||||
|
"local_peak_mad": 0.002538509201258421,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"completed": False,
|
||||||
|
"capture_path": str(capture_path),
|
||||||
|
"rule": {
|
||||||
|
"method": "pre-TX local-peak Q99.9 + 4 * local-peak MAD",
|
||||||
|
"local_peak_quantile": 0.999,
|
||||||
|
"threshold": lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD,
|
||||||
|
},
|
||||||
|
"calibration": {
|
||||||
|
**background_common,
|
||||||
|
"interval": {
|
||||||
|
"name": "PRE_TX_BACKGROUND_LAB047",
|
||||||
|
"start_sample": 0,
|
||||||
|
"end_sample": 4_000_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"control": {
|
||||||
|
**background_common,
|
||||||
|
"interval": {
|
||||||
|
"name": "POST_TX_BACKGROUND_LAB047",
|
||||||
|
"start_sample": 96_000_000,
|
||||||
|
"end_sample": 120_000_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"coarse": {
|
||||||
|
"termination_reason": "MAX_CANDIDATES_REACHED",
|
||||||
|
"max_candidates_reached": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_hardware_threshold_is_loaded_without_change(tmp_path: Path) -> None:
|
||||||
|
capture_path = tmp_path / "capture.cs8"
|
||||||
|
capture_path.write_bytes(bytes(20))
|
||||||
|
result_path = tmp_path / "independent_result.json"
|
||||||
|
result_path.write_text(
|
||||||
|
json.dumps(_original_incomplete_result(capture_path)),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
rule, calibration, control = lab047.load_fixed_hardware_candidate_rule(
|
||||||
|
capture_path,
|
||||||
|
result_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rule.threshold == 0.030964473262429237
|
||||||
|
assert calibration.interval == lab047.CALIBRATION_INTERVAL
|
||||||
|
assert control.interval == lab047.CONTROL_INTERVAL
|
||||||
|
|
||||||
|
|
||||||
|
def test_changed_saved_hardware_threshold_is_rejected(tmp_path: Path) -> None:
|
||||||
|
capture_path = tmp_path / "capture.cs8"
|
||||||
|
capture_path.write_bytes(bytes(20))
|
||||||
|
value = _original_incomplete_result(capture_path)
|
||||||
|
value["rule"]["threshold"] = 0.031
|
||||||
|
result_path = tmp_path / "independent_result.json"
|
||||||
|
result_path.write_text(json.dumps(value), encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="threshold был изменён"):
|
||||||
|
lab047.load_fixed_hardware_candidate_rule(capture_path, result_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_streaming_limit_is_not_derived_from_expected_jpeg_frames() -> None:
|
||||||
|
assert lab047.offline.MAX_CANDIDATES == 100_000
|
||||||
|
source = inspect.getsource(lab047.offline.stream_process_capture)
|
||||||
|
assert "36" not in source
|
||||||
|
assert "expected_frame_count" not in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_reprocessing_persists_verdict_before_tx_diagnostics() -> None:
|
||||||
|
source = inspect.getsource(lab047.main)
|
||||||
|
independent_position = source.index("run_saved_capture_streaming_recovery")
|
||||||
|
diagnostic_position = source.index("diagnose_hardware_jpeg_after_independent_verdict")
|
||||||
|
assert independent_position < diagnostic_position
|
||||||
|
|
||||||
|
|
||||||
|
def test_completion_accounting_can_be_corrected_without_decoder_rerun(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
capture_path = tmp_path / "capture.cs8"
|
||||||
|
capture_path.write_bytes(bytes(1_000))
|
||||||
|
original_path = tmp_path / "reprocess.json"
|
||||||
|
original = {
|
||||||
|
"completed": False,
|
||||||
|
"capture_path": str(capture_path),
|
||||||
|
"capture_samples": 500,
|
||||||
|
"rule": {"threshold": lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD},
|
||||||
|
"coarse": {
|
||||||
|
"termination_reason": "END_OF_CAPTURE",
|
||||||
|
"max_candidates_reached": False,
|
||||||
|
"blocks_processed": 1,
|
||||||
|
"processed_until_sample": 400,
|
||||||
|
},
|
||||||
|
"strict": {
|
||||||
|
"termination_reason": "END_OF_CAPTURE",
|
||||||
|
"max_search_iterations_reached": False,
|
||||||
|
"max_valid_frames_reached": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
original_path.write_text(json.dumps(original), encoding="utf-8")
|
||||||
|
corrected_path = tmp_path / "corrected.json"
|
||||||
|
|
||||||
|
corrected = lab047.correct_saved_streaming_completion(
|
||||||
|
original_path,
|
||||||
|
corrected_path,
|
||||||
|
capture_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert corrected["completed"] is True
|
||||||
|
assert corrected["coarse"]["processed_until_sample"] == 500
|
||||||
|
assert corrected["completion_metadata_correction"]["full_decoder_rerun"] is False
|
||||||
|
assert json.loads(original_path.read_text(encoding="utf-8"))["completed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_completion_accounting_refuses_different_capture_with_same_size(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
saved_capture = tmp_path / "saved_capture.cs8"
|
||||||
|
other_capture = tmp_path / "other_capture.cs8"
|
||||||
|
saved_capture.write_bytes(bytes(1_000))
|
||||||
|
other_capture.write_bytes(bytes([1]) * 1_000)
|
||||||
|
original_path = tmp_path / "reprocess.json"
|
||||||
|
original_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"completed": False,
|
||||||
|
"capture_path": str(saved_capture),
|
||||||
|
"capture_samples": 500,
|
||||||
|
"rule": {"threshold": lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD},
|
||||||
|
"coarse": {
|
||||||
|
"termination_reason": "END_OF_CAPTURE",
|
||||||
|
"max_candidates_reached": False,
|
||||||
|
"blocks_processed": 1,
|
||||||
|
"processed_until_sample": 400,
|
||||||
|
},
|
||||||
|
"strict": {
|
||||||
|
"termination_reason": "END_OF_CAPTURE",
|
||||||
|
"max_search_iterations_reached": False,
|
||||||
|
"max_valid_frames_reached": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
corrected_path = tmp_path / "corrected.json"
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="не доказывает полное покрытие"):
|
||||||
|
lab047.correct_saved_streaming_completion(
|
||||||
|
original_path,
|
||||||
|
corrected_path,
|
||||||
|
other_capture,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not corrected_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_mode_corrects_result_before_opening_tx_reference() -> None:
|
||||||
|
source = inspect.getsource(lab047.main)
|
||||||
|
correction_position = source.index("correct_saved_streaming_completion")
|
||||||
|
diagnostic_position = source.index("diagnose_saved_reprocess_after_corrected_verdict")
|
||||||
|
assert correction_position < diagnostic_position
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_diagnostics_refuse_changed_threshold_before_capture_access(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
corrected_path = tmp_path / "corrected.json"
|
||||||
|
corrected_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"completed": True,
|
||||||
|
"rule": {"threshold": lab047.FIXED_HARDWARE_CANDIDATE_THRESHOLD},
|
||||||
|
"object_verdict": {"object_complete": True},
|
||||||
|
"completion_metadata_correction": {
|
||||||
|
"full_decoder_rerun": False,
|
||||||
|
"threshold_changed": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def fail_if_capture_is_touched(*_args: object, **_kwargs: object) -> None:
|
||||||
|
raise AssertionError("RX-запись не должна открываться")
|
||||||
|
|
||||||
|
monkeypatch.setattr(lab047.offline, "_validate_capture", fail_if_capture_is_touched)
|
||||||
|
with pytest.raises(RuntimeError, match="Исправленный полный verdict не подтверждён"):
|
||||||
|
lab047.diagnose_saved_reprocess_after_corrected_verdict(
|
||||||
|
corrected_path,
|
||||||
|
tmp_path / "capture.cs8",
|
||||||
|
tmp_path / "recovered.jpg",
|
||||||
|
tmp_path / "tx.jpg",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hardware_diagnostics_refuse_incomplete_in_memory_verdict(tmp_path: Path) -> None:
|
||||||
|
independent = object.__new__(lab047.offline.IndependentRecoveryResult)
|
||||||
|
object.__setattr__(independent, "completed", False)
|
||||||
|
with pytest.raises(RuntimeError, match="не открывает TX reference"):
|
||||||
|
lab047.diagnose_hardware_jpeg_after_independent_verdict(
|
||||||
|
independent,
|
||||||
|
tmp_path / "tx.jpg",
|
||||||
|
tmp_path / "recovered.jpg",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_functional_checks() -> None:
|
||||||
|
lab047.run_functional_tests()
|
||||||
Reference in New Issue
Block a user