Compare commits
3 Commits
72f933a32b
...
e8d85ae4b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8d85ae4b7 | ||
|
|
1027eef53d | ||
|
|
62a376c811 |
118
PROJECT_LOG.md
118
PROJECT_LOG.md
@@ -631,3 +631,121 @@ Common-clock preflight PASS: MASTER reg29 = 0x26, SLAVE reg29 = 0x26, SLAVE CLKI
|
||||
## Следующий этап
|
||||
|
||||
Защитить критические поля кадра от одиночных битовых ошибок при BER ~0.6 %: FEC либо избыточное/помехоустойчивое кодирование PHY LENGTH и protocol-заголовка (ошибка сорвала разбор именно в длине, а не в CRC полезной нагрузки). Только после этого имеет смысл повторный аппаратный прогон Lab044 и переход к более длинным/структурированным передачам.
|
||||
|
||||
# Запись 023
|
||||
|
||||
## Дата
|
||||
|
||||
31 августа 2026 года
|
||||
|
||||
## Тема
|
||||
|
||||
Lab045: программная подготовка bit-level FEC для реального BPSK-пакета.
|
||||
|
||||
## Выполнено
|
||||
|
||||
Реализован свёрточный код R=1/2, K=7 с многочленами 171 и 133 octal, нулевым начальным состоянием и шестью нулевыми tail bits. Soft-decision decoder Витерби использует евклидову branch metric по вещественным BPSK-решениям до hard slicing. Соглашение BPSK сохранено от существующего тракта: бит 0 соответствует минус 1, бит 1 соответствует плюс 1.
|
||||
|
||||
Добавлен детерминированный блочный interleaver с записью строками и чтением столбцами. Перестановка не теряет элементы на неполной последней строке, проверяется как полная биекция и одинаково применяется к coded bits и soft values. Существующий GF(256) erasure FEC 12+3 не менялся и остаётся механизмом более высокого уровня для известных потерянных fragments.
|
||||
|
||||
Весь существующий protocol packet длиной 268 байт, включая 8-байтовый заголовок, 256 байт произвольного payload и CRC32, преобразуется в 2144 information bits и кодируется вместе с шестью tail bits в 4300 coded bits. Protocol parser вызывается только после deinterleave и soft Viterbi; существующий CRC32 остаётся независимым end-to-end решением.
|
||||
|
||||
PHY header расширен до magic, версии, длины packet и 8-битного checksum. Его 48 бит защищены побитовым повторением b,b,b до 144 бит. Majority decoder fail-closed проверяет magic, версию, checksum, предел длины и наличие рассчитанного числа coded symbols до запуска FEC decoder.
|
||||
|
||||
Подготовлен IQ-адаптер поверх доказанного Lab044: независимый PRBS7 detector, timing, CFO и phase/polarity recovery переиспользуются без TX payload, а FEC получает normalized.real до hard slicing. Диагностическое сравнение с TX разрешено отдельной функцией только после независимого CRC verdict.
|
||||
|
||||
Точный кадр Lab045 содержит 5724 BPSK-символа. При 400 samples per symbol и существующих guard/RRC параметрах Lab044 форма содержит 2 306 400 samples и длится 288,3 мс при 8 MS/s. Для сравнения Lab044 содержал 3456 символов, 1 399 200 samples и 174,9 мс.
|
||||
|
||||
Синтетический software-тракт прошёл. В детерминированном опыте с 28 ошибками из 4300 coded bits, BER 0,6512 процента, после FEC осталось 0 ошибок protocol packet, CRC PASS и payload восстановлен точно. Пачка из 14 соседних channel symbols также исправлена. Отдельный AWGN-тест подтвердил реальное использование soft-амплитуд: soft Viterbi дал 0 information-bit errors, тогда как hard baseline по тем же значениям дал 5.
|
||||
|
||||
Полный программный шлюз: `python tools/quick_gate.py` PASS, `python -m pytest tests/ -q` дал 417 passed, 0 failed, 0 skipped, `git diff --check` чист. Подробности, формат кадра и ограничения записаны в `docs/lab045_current_state.md`.
|
||||
|
||||
Аппаратные утилиты в этой сессии не запускались. RF TX = 0, RF RX = 0, hardware validation не выполнялась. Git add, commit и push не выполнялись.
|
||||
|
||||
Следующий этап: после отдельного разрешения подготовить ограниченный аппаратный runner Lab045 на базе common-clock preflight, one-TX guard, артефактов и cleanup Lab044, повторно пройти software gate и только затем рассматривать один аппаратный FEC-прогон.
|
||||
|
||||
# Запись 024
|
||||
|
||||
## Дата
|
||||
|
||||
1 сентября 2026 года
|
||||
|
||||
## Тема
|
||||
|
||||
Lab045: первый аппаратный RF-прогон bit-level FEC — RESULT A, реальная помехоустойчивая коррекция в эфире доказана.
|
||||
|
||||
## Выполнено
|
||||
|
||||
Перед эфиром исправлена устаревшая цифра в Записи 023 (414 → 417 passed) и в документации явно разделены две величины: информационные символы кадра (5724 символа, 2 289 600 отсчётов, 286,2 мс) и фактически сгенерированная форма сигнала (2 306 400 отсчётов, 288,3 мс). Разница в 42 символа (16 800 отсчётов) подтверждена прямо по формирователю shape_frame_like_lab042: 32 нулевых защитных символа (guard, по 16 с каждой стороны) плюс 10 символов длины корневого приподнятого косинуса (переходный хвост фильтра, режим full). Это учётная величина, а не дефект сигнала; форма не менялась. Тот же механизм даёт 1 399 200 отсчётов в Lab044.
|
||||
|
||||
Программный шлюз перед эфиром: quick_gate PASS, pytest 417 passed, git diff --check чист. Обе платы видны, серийники соответствуют ролям.
|
||||
|
||||
Выполнен ровно один аппаратный прогон `python experiments/lab045_hackrf_bpsk_fec.py --hardware --i-understand-hardware`. RF TX count = 1, без повторов. Рабочая точка Lab043/Lab044 без изменений: RF 1080 МГц, 8 MS/s, BPSK 20 ксимв/с, digital carrier +50 кГц, TX VGA 10 дБ, RX LNA/VGA 24/24 дБ, RF amp и bias OFF. Общий опорник: MASTER reg29 = 0x26, SLAVE reg29 = 0x26, SLAVE CLKIN = DETECTED (за 2 опроса, 141,8 мс). RX стартовал до TX; логи TX/RX чистые: 0 overruns, 0 underruns; clipping 0.
|
||||
|
||||
Результат — **RESULT A** с флагом **REAL RF FEC CORRECTION DEMONSTRATED = YES**. Канал в этом прогоне оказался существенно шумнее рабочей точки Lab044: SNR всего 2,63 дБ, ошибок преамбулы 39/1280 (3,05 %). Независимый приёмник (детектор преамбулы, timing, CFO, phase/polarity, мажоритарный PHY header) без доступа к переданным данным извлёк мягкие BPSK-значения. Диагностическое сравнение с TX, разрешённое только после независимого CRC-решения, показало 155 ошибок из 4300 кодированных бит (BER 3,60 %) до FEC. После deinterleave и мягкого декодера Витерби: 0 ошибок пакета (0/2144), 0 ошибок payload (0/2048), 0 байтовых ошибок. CRC PASS, protocol header корректен (SYNC 0xAA55, версия 1, тип 2, sequence 44, payload_length 256). Восстановленный 256-байтный произвольный payload побитово точен: SHA-256 rx_payload.bin совпал с tx_payload.bin (cb9d06fe…), rx_packet.bin совпал с tx_packet.bin (ed5a5713…). Это прямое доказательство: при SNR 2,63 дБ незащищённый пакет Lab044 не прошёл бы CRC, а свёрточный код R=1/2 K=7 с мягким Витерби исправил все реальные ошибки эфира.
|
||||
|
||||
Артефакты: `C:\Users\user\Documents\SDR_Rover_Lab045_raw\20260901_103434_bpsk_fec\`. SHA-256 всех реально созданных бинарных файлов вычислены дважды и совпали; фиктивных файлов не создавалось. Cleanup: MASTER/SLAVE CLKOUT OFF (SLAVE CLKIN «no clock signal detected»), зависших hackrf_transfer нет. Пост-RF шлюз повторно пройден: quick_gate PASS, pytest 417 passed, git diff --check чист. Второй TX не выполнялся. Git add/commit/push не выполнялись: результат сначала смотрит ChatGPT.
|
||||
|
||||
## Следующий этап
|
||||
|
||||
Проанализировать первый аппаратный результат Lab045 (в частности, почему SNR упал до 2,63 дБ против 7,19 дБ в Lab044 — вероятно антенна/связь/положение) и по решению ChatGPT определить, что дальше: закрепление рабочей точки, переход к более длинным/структурированным передачам или к сжатому изображению. Ни FEC/QPSK/JPEG/video, ни второй TX в этой сессии не запускаются.
|
||||
|
||||
# Запись 025
|
||||
|
||||
## Дата
|
||||
|
||||
1 сентября 2026 года
|
||||
|
||||
## Тема
|
||||
|
||||
Lab046: многопакетная передача большого бинарного объекта — только программно, RF TX = 0, аппаратная проверка не проводилась.
|
||||
|
||||
## Выполнено
|
||||
|
||||
Статус: **SOFTWARE VALIDATED. RF TX = 0. HARDWARE VALIDATION = NOT PERFORMED.** HackRF и hackrf_transfer не запускались; ни JPEG, ни видео, ни QPSK.
|
||||
|
||||
Сначала аудит. Установлено, что оба нужных уровня уже есть: `protocol/image_fragments` даёт семантику фрагмента (object_id, индекс, число фрагментов, общий размер) и fail-closed `reassemble_image`, а Lab045 даёт кадр целиком (build_transmission_plan, analyze_post_preamble_soft, simulate_soft_channel, диагностика pre/post-FEC). Отдельный модуль формата фрагмента не создавался — переиспользован 12-байтовый заголовок image_fragments (>IHHI). Два уровня строго разделены: уровень 1 (внутри кадра) — свёрточный код R=1/2 K=7 + перемежитель + мягкий Витерби + CRC (Lab045); уровень 2 (объект) — фрагментация/нумерация/сборка. Erasure-код 12+3 GF(256) `protocol/packet_erasure_fec` НЕ использован и НЕ изменён (остаётся будущим расширением для потери целых фрагментов, а не для битовых ошибок).
|
||||
|
||||
Минимальный новый слой в `experiments/lab046_bpsk_object_transport.py`: упаковка фрагмента в 256-байтную нагрузку Lab045 (FRAGMENT_DATA_SIZE = 256 − 12 = 244) с обрезкой добивки на приёме по длине из полей заголовка (expected_fragment_data_length) и fail-closed ObjectAssembler (offer возвращает accepted/duplicate/conflicting/rejected, никогда не бросает). Приёмник без оракула: TX-объект/TX-биты не используются для приёма, FEC, метаданных и решения о завершении; сравнение с оригиналом — только в диагностике после независимого CRC и независимой сборки. При недостающем/повреждённом фрагменте OBJECT COMPLETE = NO и recovered_object = None, фиктивный объект не создаётся. original_size проверяется на обе границы, чтобы поле не управляло размером аллокаций.
|
||||
|
||||
Детерминированный объект 16384 байта (16 KiB, не JPEG; numpy default_rng(seed=46)), SHA-256 748013b5…3408d515; в Git не добавлялся. Разбит на 68 фрагментов, последний короткий (36 байт данных). Метрики (идеальный канал): протокольный пакет 268 байт, 68 BPSK-кадров, 389 232 информационных символа (5724 × 68), 156 835 200 отсчётов формы (2 306 400 × 68), эфирное время 19,60 с, полезный goodput 835,7 байт/с = 6,69 кбит/с (≈0,337 полезного бита на символ). Накладные расходы: фрагментация 12 байт/пакет (816 байт), протокольный header+CRC 12 байт/пакет (816 байт), свёрточный код R=1/2, PHY преамбула+header 1424 бита/кадр, guard/ФРК 42 символо-времени/кадр, добивка последнего фрагмента 208 байт.
|
||||
|
||||
Шесть программных сценариев (seed фиксирован): A идеальный — объект точен (CRC 68/0, sha совпал, 0 байт-ошибок); **B распределённые битовые ошибки** — честный гауссов шум sigma=0.40 даёт 1805 сырых ошибок кодированных бит до FEC, post-FEC 0, все 68 CRC PASS, объект побитово точен (уровень sigma выбран так, чтобы мягкий Витерби исправлял все кадры; при sigma≥0.45 отдельный кадр не декодируется и без erasure-FEC объект неполон — это по сути сценарий E, а не подгон); C приём в обратном порядке — объект точен; D дубликаты кадров — учтены, объект точен; E потерян кадр 1 — OBJECT COMPLETE = NO, кадр 1 в missing, recovered SHA = None; F повреждён кадр 0 (инверсия coded-потока) — CRC FAIL, объекта нет.
|
||||
|
||||
Новый набор `tests/test_lab046_bpsk_object_transport.py` (фрагмент туда-обратно, короткий последний фрагмент, чужой object_id, невозможный индекс, несогласованный счётчик, усечённые и абсурдные метаданные без крупной аллокации, полный тракт PHY A–F, отсутствие оракула, детерминизм/SHA). Один тест на верхнюю границу original_size выявил реальный пробел в expected_fragment_data_length (проверялась только нижняя граница) — добавлена проверка `original_size ≤ fragment_count·244`. Документация: `docs/lab046_current_state.md`.
|
||||
|
||||
Software gate: quick_gate PASS (438 тестов ядра, 6 функциональных проверок лабораторий выполнено, 13 штатно пропущено); `pytest tests/ -q` 438 passed, 0 failed, 0 errors, 0 skipped. Единственное предупреждение связано с отсутствием прав на создание `.pytest_cache` и на результат тестов не повлияло. `git diff --check` чист. Git add/commit/push до финальной проверки не выполнялись.
|
||||
|
||||
## Следующий этап
|
||||
|
||||
По решению ChatGPT после анализа Lab046: либо оптимизация накладных расходов серии кадров (преамбула/guard на кадр доминируют в goodput), либо подключение erasure-кода 12+3 для восстановления потери целых фрагментов поверх уже готового bit-level FEC, либо аппаратный прогон многопакетной передачи. RF, QPSK, JPEG и видео в этой сессии не запускались.
|
||||
|
||||
# Запись 026
|
||||
|
||||
## Дата
|
||||
|
||||
1 сентября 2026 года
|
||||
|
||||
## Тема
|
||||
|
||||
Lab046A: continuous multi-frame receive для 16 KiB объекта в одном soft-stream - только программно, RF TX = 0, аппаратная проверка не проводилась.
|
||||
|
||||
## Выполнено
|
||||
|
||||
Статус: **CONTINUOUS STREAM VALIDATION = PASS (NORMALIZED SOFT-SYMBOL LEVEL). CONTINUOUS SAMPLE/IQ VALIDATION = NOT PERFORMED. RF TX = 0. HARDWARE VALIDATION = NOT PERFORMED.** HackRF, hackrf_transfer, RF RX/TX, QPSK, JPEG и видео не запускались.
|
||||
|
||||
Проведён аудит Lab043-Lab046. Установлено, что предыдущий Lab046 уже собирал 16 KiB объект из 68 Lab045 кадров, но делал это по отдельным кадрам из TX-плана и не доказывал поиск нескольких кадров в одном continuous receive stream. Добавлен Lab046A слой в `experiments/lab046_bpsk_object_transport.py`: приёмник получает только один `float32` normalized soft-symbol stream, самостоятельно ищет PRBS7 candidates, fail-closed декодирует защищённый PHY header, вычисляет длину coded body только из принятого header, запускает существующие Lab045 FEC/CRC и передаёт fragment в `ObjectAssembler`.
|
||||
|
||||
TX offsets, список длин кадров, ожидаемое число 68, порядок/индексы TX, исходный объект, TX packets и coded bits scanner-у не передаются. TX-side `frame_starts` остаётся только в генераторе stream и используется тестами только для постфактум-сравнения после независимого receive. Scanner fail-closed: rejected candidate не останавливает поиск; cursor монотонен; `MAX_FRAMES` и `MAX_SEARCH_ITERATIONS` дают явную причину остановки.
|
||||
|
||||
Verifier read-only нашёл реальный дефект первого варианта: глобальный non-maximum suppression мог удалить настоящий старт кадра, если перед ним был более сильный ложный PRBS7 peak. Исправление: список candidates теперь содержит все позиции выше порога в порядке stream; повторный decode уже принятого кадра предотвращается только монотонным cursor после accepted frame. Добавлен регрессионный тест `test_stronger_false_peak_does_not_delete_overlapping_later_frame`.
|
||||
|
||||
Фактические continuous-сценарии для детерминированного объекта 16384 байта, SHA-256 `748013b5f670c82bd52650be5454555355dbd8528538753df4558f1c3408d515`: ideal continuous - candidates 68, valid headers 68, FEC PASS 68, CRC PASS 68/68, fragments 68, complete=True, SHA совпал, 0 байт-ошибок; background до/между/после 37/37/43 soft-символа - valid frames 68, SHA совпал; noisy continuous sigma=0.40 seed=46001 - pre-FEC 1791, post-FEC 0, CRC 68/0, complete=True, SHA совпал; malformed preamble + bad protected header - candidates 71, rejected 3, последующие valid frames 68, complete=True, SHA совпал.
|
||||
|
||||
Учёт: 68 фрагментов, 389 232 soft-symbols, 156 835 200 эквивалентных waveform samples, baseline airtime 19,604400 с при 8 MS/s, useful goodput 6,685846 кбит/с. Основной soft stream занимает 1 556 928 байт; correlation scores 1 551 812 байт; cumulative-energy 3 113 864 байта. Полный continuous `complex64` IQ stream не материализуется; при 156 835 200 samples он занял бы около 1,17 GiB до временных копий.
|
||||
|
||||
Документация обновлена в `docs/lab046_current_state.md`. Прямой запуск `python experiments/lab046_bpsk_object_transport.py --object-size 300 --sigma 0.4` проверен и работает. Software gate после исправлений: `python tools/quick_gate.py` PASS, `python -m pytest tests/ -q` 454 passed, 0 failed, 0 errors, 0 skipped, `git diff --check` чист. Git add/commit/fetch/pull/push не выполнялись.
|
||||
|
||||
## Следующий этап
|
||||
|
||||
Сначала ChatGPT смотрит Lab046A software result. После отдельного явного разрешения можно выбирать следующий шаг: аппаратная проверка многокадровой передачи, оптимизация накладных расходов серии кадров или erasure-FEC для потери целых фрагментов. RF TX, QPSK, JPEG и video автоматически не запускать.
|
||||
|
||||
217
docs/lab045_current_state.md
Normal file
217
docs/lab045_current_state.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# Lab045: текущее состояние
|
||||
|
||||
## Статус
|
||||
|
||||
**РЕАЛИЗАЦИЯ ГОТОВА. ПРОГРАММНО ПРОВЕРЕНО. АППАРАТНАЯ ПРОВЕРКА ПРОЙДЕНА
|
||||
(RESULT A). РЕАЛЬНАЯ FEC-КОРРЕКЦИЯ В ЭФИРЕ ДОКАЗАНА.**
|
||||
|
||||
Работа выполнена от фактического HEAD
|
||||
`72f933a32b0dfaef3a4af5bd2cb4a2c` (`Lab044: validate arbitrary BPSK
|
||||
packet reception`). 1 сентября 2026 года выполнен **ровно один** аппаратный
|
||||
RF-прогон Lab045: RF TX count = 1, без повторов.
|
||||
|
||||
## Аппаратный результат (1 сентября 2026 года) — RESULT A
|
||||
|
||||
Каталог артефактов:
|
||||
`C:\Users\user\Documents\SDR_Rover_Lab045_raw\20260901_103434_bpsk_fec\`
|
||||
|
||||
- **RF TX count = 1** (ровно одна передача, `TransmitGuard`, без повторов).
|
||||
- **Инвентарь плат** подтверждён по серийникам: MASTER/TX `…323b97d3`,
|
||||
SLAVE/RX `…2e5327d3`.
|
||||
- **Общий опорный генератор PASS**: MASTER reg29 = 0x26 (38), SLAVE reg29 =
|
||||
0x26 (38), SLAVE CLKIN = DETECTED (за 2 опроса, 141,8 мс).
|
||||
- **Независимая синхронизация по преамбуле PASS**: peak/median +34,3 дБ,
|
||||
peak/second +12,7 дБ, payload не использовался.
|
||||
- **Защищённый PHY header PASS** (packet length = 268 восстановлена
|
||||
мажоритарно, checksum сошёлся).
|
||||
- **Канал был реально шумным**: SNR всего **2,63 дБ**, ошибок преамбулы
|
||||
**39/1280** (BER 3,05 %), сырых ошибок кодированного потока
|
||||
**155/4300** (BER **3,60 %**).
|
||||
- **Свёрточный декодер Витерби исправил все ошибки**: post-FEC ошибок
|
||||
пакета — **0/2144**, ошибок payload — **0/2048**, байтовых ошибок — 0.
|
||||
- **CRC = PASS**, protocol header PASS (SYNC 0xAA55, версия 1, тип 2
|
||||
(телеметрия), sequence 44, payload_length 256).
|
||||
- **Payload восстановлен побитово точно**: SHA-256 `rx_payload.bin`
|
||||
совпал с `tx_payload.bin` (`cb9d06fe…`); `rx_packet.bin` совпал с
|
||||
`tx_packet.bin` (`ed5a5713…`).
|
||||
|
||||
### Главный научный итог
|
||||
|
||||
**REAL RF FEC CORRECTION DEMONSTRATED = YES.** До декодера в реальном
|
||||
принятом сигнале присутствовали 155 ошибок кодированных бит (3,60 %),
|
||||
после мягкого декодера Витерби осталось 0 ошибок, CRC пройден, произвольный
|
||||
256-байтный payload восстановлен без единой ошибки. Это прямое
|
||||
доказательство работы помехоустойчивого кода на реальном радиоканале — при
|
||||
таком SNR незащищённый пакет Lab044 не имел бы шансов пройти CRC.
|
||||
|
||||
### Радиометрика прогона
|
||||
|
||||
CFO 0,128 Гц (общий опорник), phase −133,9°, separation 2,707,
|
||||
timing offset −2 отсчёта. ADC: peak_complex 5,83 code, peak_component 5,0,
|
||||
p99.9 ≈ 4,12 code (headroom 122). Clipping 0, near-int8 0. RX overruns 0,
|
||||
TX underruns 0. BEFORE/DURING/AFTER: RMS после DC 0,832 / 0,874 / 0,846 —
|
||||
на интервале кадра (0,288 с) сигнал слегка выше фона, хвост p99.9 during
|
||||
4,21 против 4,12, перегрузки АЦП нет.
|
||||
|
||||
### Cleanup
|
||||
|
||||
MASTER CLKOUT OFF (SLAVE CLKIN «no clock signal detected»), SLAVE CLKOUT
|
||||
OFF, зависших `hackrf_transfer.exe` нет. SHA-256 всех реально созданных
|
||||
бинарных артефактов вычислены дважды и совпали.
|
||||
|
||||
Пост-RF программный шлюз повторно пройден: quick_gate PASS,
|
||||
`pytest` 417 passed, `git diff --check` чист. Второй TX не выполнялся.
|
||||
|
||||
## Назначение
|
||||
|
||||
Lab045 добавляет исправление неизвестных битовых ошибок внутри принятого
|
||||
радиопакета. Существующий `protocol/packet_erasure_fec.py` не менялся: его
|
||||
GF(256) 12+3 предназначен для восстановления известных потерянных shards на
|
||||
более высоком уровне, а не для исправления неизвестных позиций ошибочных битов.
|
||||
|
||||
Программно доказан тракт:
|
||||
|
||||
`256 произвольных байт -> существующий protocol packet и CRC32 -> MSB-first
|
||||
bits -> convolutional R=1/2, K=7 -> interleaver -> noisy soft BPSK ->
|
||||
deinterleaver -> soft Viterbi -> существующий parser/CRC -> исходный payload`.
|
||||
|
||||
## Свёрточный код
|
||||
|
||||
- `K = 7`, память `m = 6`, 64 состояния.
|
||||
- Скорость `R = 1/2`.
|
||||
- Порождающие многочлены в порядке выходных битов: `G0 = 171 octal`,
|
||||
`G1 = 133 octal`.
|
||||
- Полный регистр: `(input_bit << 6) | state`; в `state` находятся шесть
|
||||
предыдущих входных битов, самый свежий занимает бит 5.
|
||||
- Начальное состояние 0.
|
||||
- К информационным битам добавляются шесть нулевых tail bits.
|
||||
- Финальное состояние решётки 0; traceback начинается только из состояния 0;
|
||||
tail bits удаляются из результата.
|
||||
- BPSK-соглашение совпадает с `protocol.bpsk_radio`: `0 -> -1`, `1 -> +1`.
|
||||
- Soft Viterbi получает вещественные значения до hard slicing. Branch metric:
|
||||
сумма квадратов евклидовых расстояний до ожидаемой пары `{-1,+1}`.
|
||||
|
||||
Encoder проверен не только round trip с decoder: есть фиксированные короткие
|
||||
эталонные векторы и второй независимо записанный reference encoder.
|
||||
|
||||
## Перемежитель
|
||||
|
||||
Детерминированный блочный interleaver записывает индексы строками и читает по
|
||||
столбцам. Для Lab045 используется 64 столбца. Неполная последняя строка не
|
||||
дополняется: отсутствующие ячейки пропускаются, поэтому длина не меняется.
|
||||
Одна и та же permutation применяется к TX bits и обратно к RX soft values.
|
||||
Перед применением проверяется, что каждый индекс встречается ровно один раз.
|
||||
|
||||
## Защищённый PHY header
|
||||
|
||||
Незащищённый header имеет 6 байт, 48 бит:
|
||||
|
||||
- magic `0xD391`, 16 бит;
|
||||
- версия режима Lab045 `1`, 8 бит;
|
||||
- длина существующего protocol packet в байтах, 16 бит;
|
||||
- младшие 8 бит CRC32 предыдущих пяти байт, 8 бит.
|
||||
|
||||
Каждый отдельный header bit передаётся буквально как `b,b,b`; формат не
|
||||
является конкатенацией `header|header|header`. После soft majority выполняется
|
||||
fail-closed проверка magic, версии, checksum и диапазона длины `12..1024`
|
||||
(12 байт равны минимальным header + CRC существующего protocol packet).
|
||||
По принятой длине вычисляется точное число coded symbols. Если их физически не
|
||||
хватает, Viterbi и protocol parser не запускаются. Лишние trailing soft values
|
||||
детерминированно игнорируются.
|
||||
|
||||
## Независимый порядок приёма
|
||||
|
||||
Аппаратный адаптер подготовлен, но не запускался. Он принимает только IQ и
|
||||
переиспользует из Lab044 независимый PRBS7 detector, timing, CFO и
|
||||
phase/polarity recovery. На выходе берётся `normalized.real` до строки hard
|
||||
slicing Lab044. Далее порядок фиксирован:
|
||||
|
||||
`PHY sync -> protected PHY header -> deinterleave soft -> soft Viterbi ->
|
||||
reconstructed protocol packet -> existing parse_packet -> existing CRC`.
|
||||
|
||||
RX analyzer не принимает TX packet или payload. Сравнение pre-FEC, post-FEC и
|
||||
payload с TX вынесено в отдельную диагностическую функцию, вызываемую только
|
||||
после независимого CRC verdict.
|
||||
|
||||
## Точный размер кадра
|
||||
|
||||
| Величина | Значение |
|
||||
|---|---:|
|
||||
| Protocol packet | 268 bytes |
|
||||
| Information bits | 2144 |
|
||||
| Tail bits | 6 |
|
||||
| Входных битов encoder с tail | 2150 |
|
||||
| Coded bits | 4300 |
|
||||
| Interleaver elements | 4300 |
|
||||
| Незащищённый PHY header | 48 bits |
|
||||
| Защищённый PHY header | 144 bits |
|
||||
| PRBS7 preamble | 1280 bits |
|
||||
| Всего BPSK-символов кадра | 5724 |
|
||||
| Samples per symbol | 400 |
|
||||
|
||||
Учёт кадра и формы сигнала — это две разные величины, и их нужно различать:
|
||||
|
||||
| Величина | Символы | Отсчёты | Длительность при 8 MS/s |
|
||||
|---|---|---|---|
|
||||
| **Только информационные BPSK-символы кадра** | 5724 | 2 289 600 | 286,2 мс |
|
||||
| **Фактически сгенерированная форма сигнала** | 5766 | 2 306 400 | 288,3 мс |
|
||||
| Разница | 42 | 16 800 | 2,1 мс |
|
||||
|
||||
Откуда берётся разница в 42 символа (16 800 отсчётов) — подтверждено прямо по
|
||||
формирователю `shape_frame_like_lab042` (свёртка `guard + upsampled + guard`
|
||||
с ФРК-фильтром в режиме `full`, длина = `(N + 2·guard + span)·sps`):
|
||||
|
||||
- **32 символа** — защитные интервалы Lab042 (`LAB042_GUARD_SYMBOL_COUNT = 16`
|
||||
с каждой стороны, до и после кадра);
|
||||
- **10 символов** — длина корневого приподнятого косинуса (ФРК)
|
||||
`LAB042_RRC_SPAN_SYMBOLS = 10` (переходный хвост фильтра от режима `full`).
|
||||
|
||||
Это не guard/RRC «на веру» и не дефект сигнала: это ровно тот же механизм
|
||||
формирования, что и в проверенном Lab044, где 3456 символов кадра дают
|
||||
1 399 200 отсчётов = (3456 + 42)·400. Форму сигнала менять не нужно.
|
||||
|
||||
Для сравнения Lab044: кадр 3456 символов, форма 1 399 200 отсчётов, 174,9 мс.
|
||||
Lab045 добавляет к кадру 2268 символов. Старая оценка около 280 мс для Lab044
|
||||
не используется.
|
||||
|
||||
## Фактические программные результаты
|
||||
|
||||
- Новый набор `tests/test_lab045_hackrf_bpsk_fec.py`: 41 passed.
|
||||
- Идеальный soft BPSK: CRC PASS, payload совпал побайтово.
|
||||
- Детерминированный канал с `bit_error_probability=0.006`, seed 7: до FEC
|
||||
28 ошибок из 4300 coded bits (`BER = 0.0065116`), после FEC 0 ошибок в
|
||||
protocol packet, CRC PASS, payload точный.
|
||||
- Пачка из 14 соседних channel symbols: после interleaver/FEC CRC PASS,
|
||||
payload точный.
|
||||
- Детерминированный Gaussian noise с `sigma=0.45`, seed 45: CRC PASS.
|
||||
- На отдельном честном AWGN-примере из 128 information bits hard slicing дал
|
||||
15 channel-bit errors и после hard Viterbi осталось 5 information-bit
|
||||
errors; soft Viterbi по тем же значениям дал 0 information-bit errors.
|
||||
Это доказывает использование амплитудной информации, но не является
|
||||
измерением выигрыша в dB реального канала.
|
||||
- Синтетическая cs8 IQ-форма прошла тот же detector/timing/CFO/phase adapter,
|
||||
что Lab044: PHY sync PASS, CRC PASS, payload точный.
|
||||
- Majority, checksum, bad magic, bad version, impossible length, truncated
|
||||
header/body, extra trailing data, CRC corruption и отсутствие TX-data
|
||||
leakage покрыты отдельными тестами.
|
||||
|
||||
## Software gate
|
||||
|
||||
- `python tools/quick_gate.py`: PASS; 414 тестов ядра, 5 функциональных
|
||||
проверок лаб, 13 штатно пропущенных проверок.
|
||||
- `python -m pytest tests/ -q`: 414 passed, 0 failed, 0 skipped.
|
||||
- `git diff --check`: PASS.
|
||||
- Pytest сообщил только предупреждение о невозможности создать `.pytest_cache`
|
||||
из-за прав каталога; на выполнение и итог тестов это не повлияло.
|
||||
|
||||
## Ограничения и следующий шаг
|
||||
|
||||
`run_hardware_experiment()` намеренно завершается `NotImplementedError`, а CLI
|
||||
с `--hardware` возвращает ненулевой код и печатает `RF TX = 0`. Аппаратный
|
||||
Lab045, повтор Lab044, gain tuning, QPSK, JPEG и video в этой сессии не
|
||||
выполнялись.
|
||||
|
||||
Следующий этап возможен только по отдельному разрешению: дополнить аппаратную
|
||||
обвязку Lab045 доказанными common-clock preflight, one-TX guard, artifact и
|
||||
cleanup механизмами Lab044, повторно пройти software gate и выполнить один
|
||||
ограниченный аппаратный прогон.
|
||||
250
docs/lab046_current_state.md
Normal file
250
docs/lab046_current_state.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Lab046: текущее состояние
|
||||
|
||||
## Статус
|
||||
|
||||
**LAB046 SOFTWARE OBJECT TRANSPORT = COMPLETE.**
|
||||
|
||||
**CONTINUOUS STREAM VALIDATION = PASS (NORMALIZED SOFT-SYMBOL LEVEL). RF TX = 0.
|
||||
CONTINUOUS SAMPLE/IQ VALIDATION = NOT PERFORMED.
|
||||
HARDWARE VALIDATION = NOT PERFORMED.**
|
||||
|
||||
Lab046 — только программная лаба. HackRF и `hackrf_transfer` не
|
||||
запускались, эфирной передачи нет. Ни JPEG, ни видео, ни QPSK.
|
||||
|
||||
## Назначение
|
||||
|
||||
Lab046 переходит от одиночной 256-байтной полезной нагрузки к передаче
|
||||
произвольного бинарного объекта последовательностью независимо защищённых
|
||||
радиокадров. Два уровня строго разделены и не смешиваются:
|
||||
|
||||
- **Уровень 1 (внутри одного кадра) — Lab045.** Свёрточный код R=1/2 K=7,
|
||||
перемежитель, мягкий декодер Витерби, CRC пакета. Исправляет неизвестные
|
||||
битовые ошибки внутри принятого кадра.
|
||||
- **Уровень 2 (большой объект) — существующий `protocol/image_fragments`.**
|
||||
Фрагментация, нумерация фрагментов, идентификатор объекта, сборка. Собирает
|
||||
объект из нескольких независимо принятых кадров.
|
||||
|
||||
Новый код в `experiments/lab046_bpsk_object_transport.py` минимален: он
|
||||
связывает два готовых уровня и добавляет fail-closed сборщик объекта.
|
||||
Отдельный модуль формата фрагмента НЕ создавался: переиспользован 12-байтовый
|
||||
заголовок `image_fragments` (`>IHHI`: object_id u32, fragment_index u16,
|
||||
fragment_count u16, original_size u32).
|
||||
|
||||
## Аудит: что переиспользовано, чего не хватало, минимальный новый слой
|
||||
|
||||
1. **Переиспользовано.** `protocol/image_fragments` уже даёт нужную семантику
|
||||
фрагмента: идентификатор объекта, индекс, число фрагментов, общий размер,
|
||||
а также fail-closed `reassemble_image` (несовпадение id/count/size,
|
||||
конфликтующие дубликаты, недостающие фрагменты, финальная проверка длины).
|
||||
Слой Lab045 даёт кадр целиком: `build_transmission_plan`,
|
||||
`analyze_post_preamble_soft`, `simulate_soft_channel`, диагностику pre/post-FEC.
|
||||
2. **Чего не хватало.** Не было (а) упаковки фрагмента в 256-байтную нагрузку
|
||||
Lab045 с обрезкой добивки на приёме по длине из заголовка и (б) сборщика,
|
||||
который принимает по одному независимо декодированному кадру и **никогда не
|
||||
бросает** на плохом входе, возвращая `object_complete = False` вместо
|
||||
фиктивного объекта.
|
||||
3. **Минимальный новый слой.** `recover_fragment_from_payload` (fail-closed
|
||||
декод + обрезка добивки) и класс `ObjectAssembler` (offer/missing/complete/
|
||||
recovered). Erasure-код 12+3 GF(256) (`protocol/packet_erasure_fec`) НЕ
|
||||
использован и НЕ изменён — он остаётся будущим расширением для потери целых
|
||||
фрагментов, а не для битовых ошибок (это работа свёрточного кода).
|
||||
|
||||
После этого Lab046A закрыла оставшийся пробел: до неё Lab046 передавала каждый
|
||||
кадр отдельным вызовом и выбирала его из TX-плана по известному индексу. Функции
|
||||
поиска нескольких кадров в одном потоке не было. Теперь
|
||||
`scan_continuous_soft_stream` получает только один массив soft-символов,
|
||||
самостоятельно ищет PRBS7 после монотонного cursor, проверяет защищённый PHY
|
||||
header, вычисляет длину coded body из принятого packet length, выполняет готовые
|
||||
FEC/CRC Lab045 и передаёт принятый fragment в `ObjectAssembler`.
|
||||
|
||||
TX offsets, список длин кадров, ожидаемое число 68, порядок/индексы TX,
|
||||
исходный объект, TX packets и coded bits scanner-у не передаются. TX-side
|
||||
`frame_starts` остаётся только в генераторе потока и используется тестом для
|
||||
сравнения уже после независимого receive.
|
||||
|
||||
## Граница доказательства Lab046A
|
||||
|
||||
Lab046A работает на уровне **одного непрерывного нормализованного soft BPSK
|
||||
stream** (`float32`). Это разрешённый интерфейс после уже проверенного в Lab045
|
||||
sample-level front-end: PRBS7 detection, matched filtering, timing, CFO и
|
||||
phase/polarity recovery. Внутри нового длинного теста эти sample-level операции
|
||||
заново не моделируются для каждого кадра. Поэтому результат доказывает
|
||||
самостоятельный поиск границ, header-driven длину, FEC/CRC и сборку объекта в
|
||||
непрерывном soft stream, но не является аппаратной или непрерывной IQ-проверкой.
|
||||
|
||||
Каждый фрагмент содержит полный активный Lab045 PHY frame: 1280 бит PRBS7,
|
||||
144 бита защищённого header и 4300 coded bits. Точная полная sample-level форма
|
||||
с guard и хвостом ФРК учитывается как 2 306 400 отсчётов на кадр, однако все
|
||||
156 835 200 IQ-отсчётов одновременно в памяти не создаются. Это намеренное
|
||||
ограничение памяти, а не использование известных границ приёмником.
|
||||
|
||||
Scanner fail-closed:
|
||||
|
||||
- cursor всегда растёт, после принятого кадра переносится за вычисленный конец;
|
||||
- все PRBS7 candidates выше порога рассматриваются в порядке stream; после
|
||||
принятого кадра cursor переносится за вычисленный конец и не даёт
|
||||
декодировать этот кадр повторно;
|
||||
- плохой header, усечённое body, ошибка FEC/CRC отвергают candidate, но поиск
|
||||
следующего продолжается;
|
||||
- `MAX_FRAMES` и `MAX_SEARCH_ITERATIONS` дают явную причину остановки, а не
|
||||
частичный объект, объявленный полным;
|
||||
- scan продолжается до конца capture и не останавливается на числе 68.
|
||||
|
||||
## Сценарии continuous stream
|
||||
|
||||
| Сценарий | Получено |
|
||||
|---|---|
|
||||
| Идеальный, кадры подряд | candidates 68, valid headers 68, FEC PASS 68, CRC PASS 68/68, fragments 68, complete=True, SHA совпал, 0 байт-ошибок |
|
||||
| Фон до/между/после | 37/37/43 soft-символа, valid frames 68, complete=True, SHA совпал |
|
||||
| Непрерывный AWGN, sigma=0.40, seed=46001 | frames 68, pre-FEC 1791, post-FEC 0, CRC 68/0, complete=True, SHA совпал |
|
||||
| Valid-looking preamble + bad protected header | candidates 71, rejected 3, последующие valid frames 68, complete=True, SHA совпал |
|
||||
|
||||
Фон между кадрами добавлен только после кадров 7, 23 и 51: 11, 19 и 7
|
||||
soft-символов, всего 37. В baseline дополнительного inter-frame gap нет.
|
||||
Отдельный тест с валидным header для 12-байтного packet и повреждённым body
|
||||
проверяет, что scanner берёт длину именно из принятого header, отвергает этот
|
||||
candidate и затем находит настоящий кадр.
|
||||
|
||||
Verifier нашёл опасный вариант прежнего глобального non-maximum suppression:
|
||||
более сильный ложный PRBS7 peak мог удалить более поздний настоящий старт
|
||||
кадра. Поэтому suppression убран из списка кандидатов, а добавленный
|
||||
регрессионный тест проверяет, что ложный peak не скрывает настоящий кадр.
|
||||
|
||||
## Память continuous stream
|
||||
|
||||
Основной 68-кадровый soft stream содержит 389 232 значения `float32` и занимает
|
||||
1 556 928 байт (1,485 MiB). Массив correlation scores занимает 1 551 812 байт,
|
||||
накопленная энергия `float64` — 3 113 864 байта. FFT и denominator создают
|
||||
временное рабочее пространство, размер которого зависит от SciPy, но стратегия
|
||||
остается в пределах десятков MiB, а не нескольких GiB. Для сравнения, один
|
||||
материализованный continuous `complex64` IQ stream из 156 835 200 отсчётов
|
||||
занял бы около 1,17 GiB ещё до временных копий.
|
||||
|
||||
## Детерминированный объект
|
||||
|
||||
- Размер **16384 байта (16 KiB)**. Это не JPEG, а воспроизводимый
|
||||
псевдослучайный поток `numpy.random.default_rng(seed).integers(0,256,...)`.
|
||||
- **Выбор размера.** 16 KiB — компромисс: достаточно велик, чтобы кадров было
|
||||
много (68) и учёт фрагментации/сборки был содержательным, но достаточно мал,
|
||||
чтобы программный прогон 68 кадров с мягким Витерби считался секунды. Ровный
|
||||
размер (не кратный 244) специально оставляет короткий последний фрагмент.
|
||||
- Seed объекта по умолчанию `46`, идентификатор объекта `0x4C303436` («L046»).
|
||||
- SHA-256 объекта по умолчанию:
|
||||
`748013b5f670c82bd52650be5454555355dbd8528538753df4558f1c3408d515`.
|
||||
- Объект не сохраняется в Git (крупный генерируемый артефакт); при
|
||||
необходимости он детерминированно восстанавливается по seed.
|
||||
|
||||
## Раскладка нагрузки пакета
|
||||
|
||||
Полезная нагрузка Lab045 остаётся 256 байт (доказанная рабочая точка):
|
||||
|
||||
[12 байт метаданных фрагмента][244 байта данных фрагмента]
|
||||
|
||||
`FRAGMENT_DATA_SIZE = 256 − 12 = 244`. Последний фрагмент короче; его нагрузка
|
||||
добивается нулями до 256 байт перед Lab045 и обрезается на приёме по длине,
|
||||
вычисленной **только из полей заголовка** (`expected_fragment_data_length`).
|
||||
Для 16384 байт последний, 68-й фрагмент несёт 36 байт данных (16384 − 67·244).
|
||||
|
||||
## Fail-closed сборка (без оракула)
|
||||
|
||||
Приёмник независим: переданный объект и TX-биты не используются ни для приёма
|
||||
пакета, ни для FEC, ни для восстановления метаданных, ни для решения о
|
||||
завершении объекта. Сравнение с оригиналом делается только в диагностике,
|
||||
**после** независимого CRC-решения и независимой сборки.
|
||||
|
||||
`ObjectAssembler.offer(fragment)` возвращает `accepted/duplicate/conflicting/
|
||||
rejected` и никогда не бросает. Отвергаются: чужой object_id, несогласованные
|
||||
count/original_size, невозможный индекс, конфликтующий дубликат (тот же индекс,
|
||||
другие данные), malformed/усечённые метаданные. Идентичный дубликат
|
||||
игнорируется. Пока не собраны все индексы, `object_complete()` = False, а
|
||||
`recovered_object()` = None — фиктивный объект не создаётся. Поле original_size
|
||||
проверяется на обе границы (`(count−1)·244 < original_size ≤ count·244`), чтобы
|
||||
абсурдное значение не управляло размером аллокаций.
|
||||
|
||||
## Программные сценарии (seed фиксирован)
|
||||
|
||||
| Сценарий | Условие | Ожидалось | Получено |
|
||||
|---|---|---|---|
|
||||
| A. Идеальный канал | шума нет | объект точен | complete=True, sha совпал, 0 байт-ошибок, CRC 68/0 |
|
||||
| B. Распределённые битовые ошибки | гауссов шум sigma=0.40 | pre-FEC>0, post-FEC=0, все CRC PASS, объект точен | pre-FEC 1805, post-FEC 0, CRC 68/0, complete=True, sha совпал |
|
||||
| C. Приём не по порядку | обратный порядок кадров | объект точен | complete=True, sha совпал |
|
||||
| D. Дубликаты фрагментов | повтор кадров 0,2,2 | объект точен, дубликаты учтены | duplicates≥1, complete=True, sha совпал |
|
||||
| E. Один потерянный фрагмент | кадр 1 не дошёл | OBJECT COMPLETE = NO | complete=False, 1 в missing, recovered SHA = None |
|
||||
| F. Повреждённый пакет | инверсия coded-потока кадра 0 | CRC FAIL, объекта нет | crc_fail≥1, complete=False, recovered SHA = None |
|
||||
|
||||
Сценарий B — честная точка: уровень sigma выбран так, чтобы мягкий Витерби
|
||||
исправлял **все** 68 кадров (при sigma=0.45+ отдельный кадр не декодируется, и
|
||||
без erasure-FEC объект оказывается неполным — это по сути сценарий E, а не
|
||||
подгон). Числа не подкручивались ради прохождения: до FEC присутствуют 1805
|
||||
реальных ошибок кодированных бит, после мягкого Витерби — ноль, объект
|
||||
побитово точен.
|
||||
|
||||
## Метрики (объект 16384 байта, идеальный канал)
|
||||
|
||||
| Величина | Значение |
|
||||
|---|---:|
|
||||
| Размер объекта | 16384 байта |
|
||||
| SHA-256 объекта | `748013b5…3408d515` |
|
||||
| Размер метаданных фрагмента | 12 байт/пакет |
|
||||
| Размер данных фрагмента | 244 байта (последний 36) |
|
||||
| Число фрагментов | 68 |
|
||||
| Размер протокольного пакета | 268 байт (256 нагрузка + 8 header + 4 CRC) |
|
||||
| Число BPSK-кадров | 68 |
|
||||
| Всего информационных BPSK-символов | 389 232 (5724 × 68) |
|
||||
| Всего отсчётов формы сигнала | 156 835 200 (2 306 400 × 68) |
|
||||
| Baseline: дополнительный inter-frame gap | 0 отсчётов |
|
||||
| Baseline: полная длительность | 19,604400 с |
|
||||
| Baseline: полезный goodput | 835,7 байт/с = 6,685846 кбит/с |
|
||||
| Фоновый тест: leading/inter-frame/trailing | 14 800 / 14 800 / 17 200 отсчётов |
|
||||
| Фоновый тест: полная длина и длительность | 156 882 000 отсчётов, 19,610250 с |
|
||||
| Фоновый тест: фактический goodput | 6,683852 кбит/с |
|
||||
| Полезных бит на символ | ≈ 0,337 |
|
||||
|
||||
Учёт кадра совпадает с Lab045: 2144 информационных бита, 6 tail-бит, 4300
|
||||
кодированных бит (R=1/2), перемежитель 4300, PHY header 48→144 бит (×3),
|
||||
преамбула PRBS7 1280 бит, итого 5724 информационных символа кадра. Форма
|
||||
сигнала добавляет 42 символо-времени (32 нулевых guard + 10 хвост ФРК,
|
||||
`shape_frame_like_lab042`, режим `full`): 5766 символо-времён = 2 306 400
|
||||
отсчётов = 288,3 мс на кадр при 8 MS/s.
|
||||
|
||||
### Разбор накладных расходов
|
||||
|
||||
- **Фрагментация (уровень 2):** 12 байт метаданных на пакет × 68 = 816 байт.
|
||||
- **Протокольный header+CRC (уровень 1):** 12 байт на пакет × 68 = 816 байт.
|
||||
- **Свёрточный код R=1/2:** удваивает число кодированных бит (2144→4300 с tail).
|
||||
- **PHY преамбула и защищённый header:** 1280 + 144 = 1424 бита на кадр.
|
||||
- **Guard/фильтр:** 42 символо-времени на кадр (32 guard + 10 хвост ФРК).
|
||||
- **Добивка последнего фрагмента:** 208 нулевых байт (только 68-й кадр).
|
||||
|
||||
Goodput 6,69 кбит/с — это честная baseline при 20 ксимв/с с полной
|
||||
преамбулой и guard на каждый кадр; в реальной серии кадров преамбула на кадр
|
||||
доминирует в накладных расходах, и это направление для будущей оптимизации,
|
||||
а не дефект.
|
||||
|
||||
## Фактические программные результаты
|
||||
|
||||
- Новый набор `tests/test_lab046_bpsk_object_transport.py`: все проверки
|
||||
проходят (фрагмент туда-обратно, короткий последний фрагмент, чужой
|
||||
object_id, невозможный индекс, несогласованный счётчик, усечённые и абсурдные
|
||||
метаданные без крупной аллокации, полный тракт PHY для сценариев A–F,
|
||||
отсутствие оракула, последовательный continuous scan, rejected candidate,
|
||||
MAX_FRAMES/MAX_SEARCH_ITERATIONS, регрессия против удаления настоящего кадра
|
||||
ложным PRBS7 peak, детерминизм и SHA объекта).
|
||||
- Полный тракт `объект → фрагменты → кадр Lab045 → канал → приём → сборка`
|
||||
проверен для всех шести сценариев значениями выше.
|
||||
|
||||
## Software gate
|
||||
|
||||
- `python tools/quick_gate.py`: PASS; 454 теста ядра, 6 функциональных
|
||||
проверок лабораторий выполнено, 13 штатно пропущено.
|
||||
- `python -m pytest tests/ -q`: 454 passed, 0 failed, 0 errors, 0 skipped.
|
||||
Единственное предупреждение связано с отсутствием прав на создание
|
||||
`.pytest_cache`; на выполнение и результат тестов оно не повлияло.
|
||||
- `git diff --check`: чист.
|
||||
|
||||
## Ограничения и следующий шаг
|
||||
|
||||
RF TX = 0, аппаратная проверка не проводилась. Erasure-код 12+3 GF(256) не
|
||||
задействован (будущее восстановление потери целых фрагментов). Оптимизация
|
||||
преамбулы/guard на серию кадров, QPSK, JPEG и видео в этой лабе не делались.
|
||||
Следующий этап определяется после анализа (Lab046 первым смотрит ChatGPT).
|
||||
1072
experiments/lab045_hackrf_bpsk_fec.py
Normal file
1072
experiments/lab045_hackrf_bpsk_fec.py
Normal file
File diff suppressed because it is too large
Load Diff
1064
experiments/lab046_bpsk_object_transport.py
Normal file
1064
experiments/lab046_bpsk_object_transport.py
Normal file
File diff suppressed because it is too large
Load Diff
86
protocol/bit_interleaver.py
Normal file
86
protocol/bit_interleaver.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Детерминированный блочный перемежитель (запись строками, чтение столбцами).
|
||||
|
||||
Перемежитель декоррелирует пачки ошибок канала: соседние по входу элементы
|
||||
после перестановки оказываются разнесены, а пачка подряд идущих ошибок
|
||||
канала после обратной перестановки распадается на разрозненные одиночные
|
||||
ошибки, которые свёрточный код исправляет лучше всего.
|
||||
|
||||
Схема. Для длины ``length`` и числа столбцов ``columns`` элементы условно
|
||||
записываются построчно в матрицу ``rows × columns`` (``rows =
|
||||
ceil(length/columns)``) и считываются по столбцам. Если длина не кратна
|
||||
размеру матрицы, недостающие ячейки последнего ряда просто пропускаются —
|
||||
ни один элемент не теряется и не выдумывается. Результат выражен как
|
||||
перестановка индексов ``permutation`` длиной ``length``: перемежённый
|
||||
поток ``output[k] = input[permutation[k]]``. Обратная операция применяет
|
||||
обратную перестановку и работает как с битами, так и напрямую с soft-
|
||||
значениями BPSK на приёме. Перестановка зависит только от ``length`` и
|
||||
``columns`` и не зависит от передаваемых данных.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def _validated_permutation(permutation: np.ndarray, length: int) -> np.ndarray:
|
||||
"""Проверить, что перестановка содержит каждый допустимый индекс один раз."""
|
||||
|
||||
raw_permutation = np.asarray(permutation)
|
||||
if raw_permutation.ndim != 1:
|
||||
raise ValueError("permutation должен быть одномерным")
|
||||
if len(raw_permutation) != length:
|
||||
raise ValueError("длина values не совпадает с длиной перестановки")
|
||||
if not np.issubdtype(raw_permutation.dtype, np.integer):
|
||||
raise ValueError("permutation должен содержать целые индексы")
|
||||
checked = raw_permutation.astype(np.int64, copy=False)
|
||||
if not np.array_equal(np.sort(checked), np.arange(length, dtype=np.int64)):
|
||||
raise ValueError("permutation не является полной перестановкой индексов")
|
||||
return checked
|
||||
|
||||
|
||||
def build_permutation(length: int, columns: int) -> np.ndarray:
|
||||
"""Построить перестановку чтения по столбцам для блока длиной ``length``.
|
||||
|
||||
Возвращает одномерный массив индексов длиной ``length``: ``output[k] =
|
||||
input[permutation[k]]``. Гарантируется, что каждый индекс 0..length-1
|
||||
встречается ровно один раз.
|
||||
"""
|
||||
|
||||
if not isinstance(length, (int, np.integer)) or length < 1:
|
||||
raise ValueError("length должен быть положительным целым")
|
||||
if not isinstance(columns, (int, np.integer)) or columns < 1:
|
||||
raise ValueError("columns должен быть положительным целым")
|
||||
columns = int(columns)
|
||||
length = int(length)
|
||||
rows = (length + columns - 1) // columns
|
||||
permutation = [
|
||||
row * columns + column
|
||||
for column in range(columns)
|
||||
for row in range(rows)
|
||||
if row * columns + column < length
|
||||
]
|
||||
result = np.asarray(permutation, dtype=np.int64)
|
||||
assert len(result) == length
|
||||
return result
|
||||
|
||||
|
||||
def interleave(values: np.ndarray, permutation: np.ndarray) -> np.ndarray:
|
||||
"""Применить перестановку: ``output[k] = values[permutation[k]]``."""
|
||||
|
||||
values = np.asarray(values)
|
||||
if values.ndim != 1:
|
||||
raise ValueError("values должен быть одномерным")
|
||||
checked_permutation = _validated_permutation(permutation, len(values))
|
||||
return values[checked_permutation]
|
||||
|
||||
|
||||
def deinterleave(values: np.ndarray, permutation: np.ndarray) -> np.ndarray:
|
||||
"""Обратить перестановку без потери типа (работает и с soft-значениями)."""
|
||||
|
||||
values = np.asarray(values)
|
||||
if values.ndim != 1:
|
||||
raise ValueError("values должен быть одномерным")
|
||||
checked_permutation = _validated_permutation(permutation, len(values))
|
||||
restored = np.empty_like(values)
|
||||
restored[checked_permutation] = values
|
||||
return restored
|
||||
216
protocol/convolutional_fec.py
Normal file
216
protocol/convolutional_fec.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""Свёрточный код R=1/2, K=7 и soft-decision декодер Витерби.
|
||||
|
||||
Настоящий помехоустойчивый код уровня бит для исправления неизвестных
|
||||
битовых ошибок реального BPSK-канала (Lab044 показал BER ≈0.5–0.6%, при
|
||||
котором незащищённый пакет практически не проходит CRC). В отличие от
|
||||
``protocol/packet_erasure_fec`` (GF(256), только стирания), этот код
|
||||
исправляет случайные битовые ошибки без знания их позиций.
|
||||
|
||||
Параметры зафиксированы и не передаются в каждом кадре:
|
||||
|
||||
- constraint length ``K = 7``; память ``m = 6``; число состояний ``2**6 = 64``;
|
||||
- скорость ``R = 1/2`` (два выходных бита на один входной);
|
||||
- порождающие многочлены ``G0 = 0o171``, ``G1 = 0o133`` (стандарт NASA/Voyager);
|
||||
- начальное состояние регистра ``0``;
|
||||
- терминирование: к информационным битам добавляются ``m = 6`` нулевых
|
||||
хвостовых бита, так что финальное состояние тоже ``0``.
|
||||
|
||||
Соглашение сдвигового регистра. Полный 7-битный регистр для вычисления
|
||||
выхода собирается как ``full = (input_bit << 6) | state``, где ``state`` —
|
||||
6 предыдущих входных битов, самый свежий в бите 5, самый старый в бите 0.
|
||||
Порождающий многочлен применяется как битовая маска этого 7-битного
|
||||
регистра, выходной бит = чётность числа единиц. Новое состояние =
|
||||
``full >> 1`` (самый старый бит вытесняется). Такое соглашение проверяется
|
||||
независимо посчитанными эталонными векторами в
|
||||
``tests/test_lab045_hackrf_bpsk_fec.py``.
|
||||
|
||||
Соответствие BPSK (совпадает с ``protocol/bpsk_radio.bpsk_modulate``):
|
||||
бит ``0`` → символ ``-1``, бит ``1`` → символ ``+1``. Декодер работает с
|
||||
soft-значениями BPSK (проекция принятого символа на решающую ось,
|
||||
``normalized.real`` в Lab044) и использует евклидову ветвевую метрику к
|
||||
ожидаемым символам ``{-1, +1}``. Hard-decision — частный случай: знаки
|
||||
soft-значений.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
CONSTRAINT_LENGTH = 7
|
||||
MEMORY = CONSTRAINT_LENGTH - 1 # 6
|
||||
STATE_COUNT = 1 << MEMORY # 64
|
||||
GENERATOR_POLYNOMIALS_OCTAL = (0o171, 0o133) # (171, 133) octal
|
||||
OUTPUTS_PER_INPUT = len(GENERATOR_POLYNOMIALS_OCTAL) # 2
|
||||
TAIL_BIT_COUNT = MEMORY # 6
|
||||
CODE_RATE = (1, OUTPUTS_PER_INPUT) # 1/2
|
||||
|
||||
_STATE_MASK = STATE_COUNT - 1 # 0x3F
|
||||
_INPUT_BIT_POSITION = MEMORY # входной бит занимает бит 6 полного регистра
|
||||
_LOWER_STATE_MASK = (1 << (MEMORY - 1)) - 1 # 0x1F, младшие 5 бит состояния
|
||||
|
||||
|
||||
def _parity(value: int) -> int:
|
||||
"""Чётность числа установленных битов (0 или 1)."""
|
||||
|
||||
return bin(value).count("1") & 1
|
||||
|
||||
|
||||
def _next_state(state: int, input_bit: int) -> int:
|
||||
full = (input_bit << _INPUT_BIT_POSITION) | state
|
||||
return full >> 1
|
||||
|
||||
|
||||
def _output_bits(state: int, input_bit: int) -> tuple[int, ...]:
|
||||
full = (input_bit << _INPUT_BIT_POSITION) | state
|
||||
return tuple(_parity(full & generator) for generator in GENERATOR_POLYNOMIALS_OCTAL)
|
||||
|
||||
|
||||
def _build_decode_tables() -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Предвычислить предшественников и ожидаемые символы для решётки.
|
||||
|
||||
Для каждого следующего состояния ``next_state`` вход, приведший в него,
|
||||
однозначен и равен старшему биту состояния (бит 5). У ``next_state``
|
||||
ровно два предшественника, различающиеся вытесненным младшим битом.
|
||||
Возвращает ``predecessors[next_state, branch]`` и
|
||||
``symbols[next_state, branch, output_index]`` со значениями в {-1, +1}.
|
||||
"""
|
||||
|
||||
predecessors = np.zeros((STATE_COUNT, 2), dtype=np.int64)
|
||||
symbols = np.zeros((STATE_COUNT, 2, OUTPUTS_PER_INPUT), dtype=np.float64)
|
||||
for next_state in range(STATE_COUNT):
|
||||
input_bit = next_state >> (MEMORY - 1) # старший бит состояния
|
||||
base = (next_state & _LOWER_STATE_MASK) << 1
|
||||
for branch in range(2):
|
||||
previous_state = base | branch
|
||||
assert _next_state(previous_state, input_bit) == next_state
|
||||
predecessors[next_state, branch] = previous_state
|
||||
output = _output_bits(previous_state, input_bit)
|
||||
for output_index, bit in enumerate(output):
|
||||
symbols[next_state, branch, output_index] = 2.0 * bit - 1.0
|
||||
return predecessors, symbols
|
||||
|
||||
|
||||
_PREDECESSORS, _BRANCH_SYMBOLS = _build_decode_tables()
|
||||
_BRANCH_SYMBOL_0 = _BRANCH_SYMBOLS[:, :, 0]
|
||||
_BRANCH_SYMBOL_1 = _BRANCH_SYMBOLS[:, :, 1]
|
||||
|
||||
|
||||
def _validate_information_bits(information_bits: np.ndarray) -> np.ndarray:
|
||||
raw_bits = np.asarray(information_bits)
|
||||
if raw_bits.ndim != 1:
|
||||
raise ValueError("information_bits должен быть одномерным")
|
||||
if raw_bits.size == 0:
|
||||
raise ValueError("information_bits не должен быть пустым")
|
||||
if not np.all((raw_bits == 0) | (raw_bits == 1)):
|
||||
raise ValueError("information_bits должен содержать только 0 и 1")
|
||||
return raw_bits.astype(np.uint8, copy=False)
|
||||
|
||||
|
||||
def encoded_bit_count(information_bit_count: int) -> int:
|
||||
"""Число кодовых битов для заданного числа информационных битов."""
|
||||
|
||||
if not isinstance(information_bit_count, (int, np.integer)):
|
||||
raise TypeError("information_bit_count должен быть целым")
|
||||
if information_bit_count < 0:
|
||||
raise ValueError("information_bit_count не может быть отрицательным")
|
||||
return OUTPUTS_PER_INPUT * (information_bit_count + TAIL_BIT_COUNT)
|
||||
|
||||
|
||||
def encode(information_bits: np.ndarray) -> np.ndarray:
|
||||
"""Свёрточно закодировать биты, добавив внутреннее терминирование.
|
||||
|
||||
Вход — одномерные биты {0,1}. К ним добавляются ``TAIL_BIT_COUNT``
|
||||
нулевых хвостовых бита, регистр стартует из нуля и заканчивает в нуле.
|
||||
Выход — одномерные кодовые биты {0,1} длиной ``2*(len+6)``.
|
||||
"""
|
||||
|
||||
bits = _validate_information_bits(information_bits)
|
||||
terminated = np.concatenate((bits, np.zeros(TAIL_BIT_COUNT, dtype=np.uint8)))
|
||||
output = np.empty(OUTPUTS_PER_INPUT * len(terminated), dtype=np.uint8)
|
||||
state = 0
|
||||
for index, input_bit in enumerate(int(bit) for bit in terminated):
|
||||
full = (input_bit << _INPUT_BIT_POSITION) | state
|
||||
for output_index, generator in enumerate(GENERATOR_POLYNOMIALS_OCTAL):
|
||||
output[OUTPUTS_PER_INPUT * index + output_index] = _parity(full & generator)
|
||||
state = full >> 1
|
||||
assert state == 0
|
||||
return output
|
||||
|
||||
|
||||
def viterbi_decode_soft(
|
||||
soft_values: np.ndarray,
|
||||
information_bit_count: int,
|
||||
) -> np.ndarray:
|
||||
"""Декодировать soft-значения BPSK терминированным Витерби.
|
||||
|
||||
``soft_values`` — одномерный массив вещественных решающих значений
|
||||
(в идеале ±1, реально ``normalized.real``), длиной ровно
|
||||
``encoded_bit_count(information_bit_count)``. Метрика ветви — евклидово
|
||||
расстояние до ожидаемых символов {-1, +1}. Решётка терминирована:
|
||||
старт и финиш в состоянии 0, хвостовые биты отбрасываются. Возвращает
|
||||
``information_bit_count`` информационных битов. TX-данные не используются.
|
||||
"""
|
||||
|
||||
if not isinstance(information_bit_count, (int, np.integer)):
|
||||
raise TypeError("information_bit_count должен быть целым")
|
||||
if information_bit_count <= 0:
|
||||
raise ValueError("information_bit_count должен быть положительным")
|
||||
information_bit_count = int(information_bit_count)
|
||||
values = np.asarray(soft_values, dtype=np.float64)
|
||||
if values.ndim != 1:
|
||||
raise ValueError("soft_values должен быть одномерным")
|
||||
if not np.all(np.isfinite(values)):
|
||||
raise ValueError("soft_values должен содержать только конечные значения")
|
||||
expected_length = encoded_bit_count(information_bit_count)
|
||||
if len(values) != expected_length:
|
||||
raise ValueError(
|
||||
f"soft_values имеет длину {len(values)}, ожидалось {expected_length}"
|
||||
)
|
||||
|
||||
step_count = information_bit_count + TAIL_BIT_COUNT
|
||||
received = values.reshape(step_count, OUTPUTS_PER_INPUT)
|
||||
|
||||
infinity = np.float64(1e18)
|
||||
path_metric = np.full(STATE_COUNT, infinity, dtype=np.float64)
|
||||
path_metric[0] = 0.0
|
||||
branch_choice = np.empty((step_count, STATE_COUNT), dtype=np.uint8)
|
||||
|
||||
for step in range(step_count):
|
||||
received_0 = received[step, 0]
|
||||
received_1 = received[step, 1]
|
||||
branch_metric = (
|
||||
(received_0 - _BRANCH_SYMBOL_0) ** 2
|
||||
+ (received_1 - _BRANCH_SYMBOL_1) ** 2
|
||||
) # (STATE_COUNT, 2)
|
||||
candidate = path_metric[_PREDECESSORS] + branch_metric # (STATE_COUNT, 2)
|
||||
chosen = np.argmin(candidate, axis=1).astype(np.uint8)
|
||||
path_metric = candidate[np.arange(STATE_COUNT), chosen]
|
||||
branch_choice[step] = chosen
|
||||
|
||||
# Терминированный трекбэк из состояния 0.
|
||||
decoded = np.empty(step_count, dtype=np.uint8)
|
||||
current_state = 0
|
||||
for step in range(step_count - 1, -1, -1):
|
||||
branch = branch_choice[step, current_state]
|
||||
decoded[step] = current_state >> (MEMORY - 1)
|
||||
current_state = int(_PREDECESSORS[current_state, branch])
|
||||
assert current_state == 0
|
||||
return decoded[:information_bit_count]
|
||||
|
||||
|
||||
def viterbi_decode_hard(
|
||||
coded_bits: np.ndarray,
|
||||
information_bit_count: int,
|
||||
) -> np.ndarray:
|
||||
"""Hard-decision вариант: биты {0,1} отображаются в ±1 и декодируются.
|
||||
|
||||
Служит базовой линией для честного сравнения soft и hard в тестах.
|
||||
"""
|
||||
|
||||
raw_bits = np.asarray(coded_bits)
|
||||
if raw_bits.ndim != 1:
|
||||
raise ValueError("coded_bits должен быть одномерным")
|
||||
if not np.all((raw_bits == 0) | (raw_bits == 1)):
|
||||
raise ValueError("coded_bits должен содержать только 0 и 1")
|
||||
bits = raw_bits.astype(np.uint8, copy=False)
|
||||
return viterbi_decode_soft(2.0 * bits.astype(np.float64) - 1.0, information_bit_count)
|
||||
705
tests/test_lab045_hackrf_bpsk_fec.py
Normal file
705
tests/test_lab045_hackrf_bpsk_fec.py
Normal file
@@ -0,0 +1,705 @@
|
||||
"""Синтетические end-to-end тесты Lab045 со свёрточной FEC поверх BPSK.
|
||||
|
||||
Без аппаратуры и данных эксперимента. Свёрточный код проверяется вторым,
|
||||
независимо записанным encoder и вручную рассчитанными reference vectors, а не
|
||||
только своим decoder. Полный тракт проверяется на идеальном, ошибочном,
|
||||
пакетном и noisy soft каналах.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from experiments import lab044_hackrf_bpsk_packet as lab044
|
||||
from experiments import lab045_hackrf_bpsk_fec as lab045
|
||||
from protocol import bit_interleaver as interleaver
|
||||
from protocol import bpsk_radio as radio
|
||||
from protocol import convolutional_fec as conv
|
||||
from protocol.packet import HEADER_SIZE, build_packet
|
||||
|
||||
|
||||
def deterministic_payload() -> bytes:
|
||||
return bytes(range(256))
|
||||
|
||||
|
||||
# ---- Независимый reference encoder с другой реализацией ----------------------
|
||||
|
||||
|
||||
def independent_convolutional_encode(information_bits: list[int]) -> list[int]:
|
||||
"""Второй вручную записанный encoder для независимой проверки.
|
||||
|
||||
Список state содержит шесть предыдущих входных битов, начиная с самого
|
||||
свежего. Регистр шага равен [current, prev0..prev4, prev5], а биты
|
||||
многочлена читаются MSB-first.
|
||||
"""
|
||||
|
||||
generators = (0o171, 0o133)
|
||||
bits = list(information_bits) + [0] * conv.TAIL_BIT_COUNT
|
||||
state = [0] * conv.MEMORY
|
||||
output: list[int] = []
|
||||
for current in bits:
|
||||
register = [current] + state # 7 entries, index 0 = current (bit 6)
|
||||
for generator in generators:
|
||||
generator_bits = [(generator >> k) & 1 for k in range(conv.CONSTRAINT_LENGTH - 1, -1, -1)]
|
||||
output.append(sum(a & b for a, b in zip(register, generator_bits)) & 1)
|
||||
state = register[: conv.MEMORY] # drop oldest -> new state
|
||||
return output
|
||||
|
||||
|
||||
# ---- Корректность свёрточного кода -------------------------------------------
|
||||
|
||||
|
||||
def test_encode_matches_hand_computed_impulse_response() -> None:
|
||||
# Импульсная характеристика (171,133) и 6 tail zeros, рассчитана вручную.
|
||||
expected = [1, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1]
|
||||
result = conv.encode(np.array([1], dtype=np.uint8))
|
||||
assert result.tolist() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("information", "expected_coded"),
|
||||
[
|
||||
([0], "00000000000000"),
|
||||
([1, 0], "1110111100011100"),
|
||||
([0, 1], "0011101111000111"),
|
||||
([1, 1], "1101010011011011"),
|
||||
([1, 0, 1], "111000011110110111"),
|
||||
],
|
||||
)
|
||||
def test_encode_matches_short_reference_vectors(
|
||||
information: list[int],
|
||||
expected_coded: str,
|
||||
) -> None:
|
||||
"""Короткие фиксированные векторы защищают соглашение независимо от decoder."""
|
||||
|
||||
result = conv.encode(np.asarray(information, dtype=np.uint8))
|
||||
assert "".join(str(bit) for bit in result.tolist()) == expected_coded
|
||||
|
||||
|
||||
def test_encode_all_zero_is_all_zero() -> None:
|
||||
result = conv.encode(np.zeros(20, dtype=np.uint8))
|
||||
assert result.tolist() == [0] * conv.encoded_bit_count(20)
|
||||
|
||||
|
||||
def test_encode_all_one_matches_independent_reference() -> None:
|
||||
information = [1] * 17
|
||||
assert conv.encode(np.array(information, dtype=np.uint8)).tolist() == independent_convolutional_encode(information)
|
||||
|
||||
|
||||
def test_encode_random_matches_independent_reference() -> None:
|
||||
generator = np.random.default_rng(45)
|
||||
for _ in range(8):
|
||||
information = generator.integers(0, 2, generator.integers(1, 300)).astype(np.uint8)
|
||||
assert conv.encode(information).tolist() == independent_convolutional_encode(information.tolist())
|
||||
|
||||
|
||||
def test_encode_rejects_empty_and_invalid() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
conv.encode(np.array([], dtype=np.uint8))
|
||||
with pytest.raises(ValueError):
|
||||
conv.encode(np.array([2, 0, 1], dtype=np.uint8))
|
||||
with pytest.raises(ValueError):
|
||||
conv.encode(np.zeros((2, 3), dtype=np.uint8))
|
||||
with pytest.raises(ValueError):
|
||||
conv.encode(np.array([256, 0, 1]))
|
||||
with pytest.raises(ValueError):
|
||||
conv.encode(np.array([-1, 0, 1]))
|
||||
|
||||
|
||||
def test_ideal_soft_round_trip_is_exact() -> None:
|
||||
generator = np.random.default_rng(7)
|
||||
information = generator.integers(0, 2, 2144).astype(np.uint8)
|
||||
coded = conv.encode(information)
|
||||
soft = 2.0 * coded.astype(np.float64) - 1.0
|
||||
decoded = conv.viterbi_decode_soft(soft, len(information))
|
||||
assert np.array_equal(decoded, information)
|
||||
|
||||
|
||||
def test_tail_termination_lengths_are_exact() -> None:
|
||||
information = np.ones(100, dtype=np.uint8)
|
||||
coded = conv.encode(information)
|
||||
assert len(coded) == conv.encoded_bit_count(100) == 2 * (100 + 6)
|
||||
decoded = conv.viterbi_decode_soft(2.0 * coded - 1.0, 100)
|
||||
assert len(decoded) == 100
|
||||
|
||||
|
||||
def test_viterbi_rejects_wrong_length() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
conv.viterbi_decode_soft(np.zeros(10, dtype=np.float64), 100)
|
||||
with pytest.raises(ValueError):
|
||||
conv.viterbi_decode_soft(np.zeros(12, dtype=np.float64), 0)
|
||||
with pytest.raises(ValueError):
|
||||
conv.viterbi_decode_soft(np.array([np.nan] * 14), 1)
|
||||
with pytest.raises(ValueError):
|
||||
conv.viterbi_decode_hard(np.array([256] * 14), 1)
|
||||
|
||||
|
||||
def test_bpsk_mapping_matches_existing_radio_primitive() -> None:
|
||||
bits = np.array([0, 1, 1, 0], dtype=np.uint8)
|
||||
expected = np.array([-1.0, 1.0, 1.0, -1.0])
|
||||
assert np.array_equal(lab045.modulate_bits_to_ideal_soft(bits), expected)
|
||||
assert np.array_equal(radio.bpsk_modulate(bits), expected)
|
||||
|
||||
|
||||
def test_single_and_multiple_coded_errors_are_corrected() -> None:
|
||||
generator = np.random.default_rng(11)
|
||||
information = generator.integers(0, 2, 512).astype(np.uint8)
|
||||
coded = conv.encode(information)
|
||||
soft = 2.0 * coded.astype(np.float64) - 1.0
|
||||
for error_count in (1, 3, 8):
|
||||
corrupted = soft.copy()
|
||||
positions = generator.choice(len(corrupted), error_count, replace=False)
|
||||
corrupted[positions] = -corrupted[positions]
|
||||
assert np.array_equal(conv.viterbi_decode_soft(corrupted, len(information)), information)
|
||||
|
||||
|
||||
# ---- Перемежитель ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_interleaver_inverse_and_permutation_are_exact() -> None:
|
||||
for length, columns in [(1, 1), (2, 3), (7, 3), (97, 10), (101, 13), (4300, 64)]:
|
||||
permutation = interleaver.build_permutation(length, columns)
|
||||
assert np.array_equal(np.sort(permutation), np.arange(length))
|
||||
values = np.arange(length, dtype=np.float64) - 0.5
|
||||
restored = interleaver.deinterleave(interleaver.interleave(values, permutation), permutation)
|
||||
assert np.array_equal(restored, values)
|
||||
|
||||
|
||||
def test_interleaver_rejects_malformed_permutations() -> None:
|
||||
values = np.arange(4)
|
||||
with pytest.raises(ValueError):
|
||||
interleaver.interleave(values, np.array([0, 1, 1, 3]))
|
||||
with pytest.raises(ValueError):
|
||||
interleaver.deinterleave(values, np.array([0, 1, 2, 4]))
|
||||
with pytest.raises(ValueError):
|
||||
interleaver.interleave(values, np.array([0.0, 1.0, 2.0, 3.0]))
|
||||
|
||||
|
||||
# ---- Полный тракт Lab045 -----------------------------------------------------
|
||||
|
||||
|
||||
def test_full_chain_ideal_recovers_payload_and_crc() -> None:
|
||||
plan = lab045.build_transmission_plan(deterministic_payload())
|
||||
assert plan.frame_symbol_count == 1280 + 144 + 4300 # 5724
|
||||
soft = lab045.simulate_soft_channel(plan)
|
||||
reception = lab045.analyze_post_preamble_soft(soft)
|
||||
assert reception.analysis["classification"] == "A"
|
||||
assert reception.analysis["crc"] == lab045.CRC_PASS
|
||||
assert reception.payload_bytes == plan.payload
|
||||
assert plan.waveform_sample_count == 2_306_400
|
||||
assert plan.airtime_seconds == pytest.approx(0.2883)
|
||||
assert plan.frame_symbol_count / lab045.SYMBOL_RATE == pytest.approx(0.2862)
|
||||
assert (
|
||||
plan.frame_symbol_count
|
||||
+ 2 * lab045.GUARD_SYMBOL_COUNT
|
||||
+ lab045.RRC_SPAN_SYMBOLS
|
||||
) == 5766
|
||||
|
||||
|
||||
def test_actual_lab044_waveform_builder_has_exact_length() -> None:
|
||||
plan = lab045.build_transmission_plan(deterministic_payload())
|
||||
tx_cs8 = lab045.build_tx_cs8(plan)
|
||||
assert len(tx_cs8) == 2 * 2_306_400
|
||||
assert np.max(np.abs(np.frombuffer(tx_cs8, dtype=np.int8).astype(np.int16))) < 127
|
||||
|
||||
|
||||
def test_ideal_iq_adapter_reuses_lab044_sync_and_recovers_crc() -> None:
|
||||
plan = lab045.build_transmission_plan(deterministic_payload())
|
||||
waveform = lab044.signed_iq_bytes_to_complex64(lab045.build_tx_cs8(plan))
|
||||
capture = np.concatenate(
|
||||
(
|
||||
np.zeros(50_000, dtype=np.complex64),
|
||||
waveform,
|
||||
np.zeros(50_000, dtype=np.complex64),
|
||||
)
|
||||
)
|
||||
reception = lab045.analyze_capture_independently(capture)
|
||||
assert reception.analysis["phy_sync_pass"] is True
|
||||
assert reception.analysis["crc"] == lab045.CRC_PASS
|
||||
assert reception.payload_bytes == plan.payload
|
||||
|
||||
|
||||
def test_distributed_errors_are_corrected_and_prove_fec() -> None:
|
||||
plan = lab045.build_transmission_plan(deterministic_payload())
|
||||
soft = lab045.simulate_soft_channel(plan, bit_error_probability=0.006, seed=7)
|
||||
reception = lab045.analyze_post_preamble_soft(soft)
|
||||
diagnostic = lab045.diagnostic_pre_and_post_fec(reception, soft, plan)
|
||||
assert diagnostic["pre_fec_coded"]["bit_errors"] > 0 # raw channel has errors
|
||||
assert reception.analysis["crc"] == lab045.CRC_PASS
|
||||
assert diagnostic["post_fec_payload"]["bit_perfect"] is True # FEC fixed them
|
||||
|
||||
|
||||
def test_short_burst_survives_interleaver() -> None:
|
||||
plan = lab045.build_transmission_plan(deterministic_payload())
|
||||
burst = tuple(range(lab045.PHY_HEADER_BIT_COUNT + 500, lab045.PHY_HEADER_BIT_COUNT + 500 + 14))
|
||||
soft = lab045.simulate_soft_channel(plan, burst_positions=burst)
|
||||
reception = lab045.analyze_post_preamble_soft(soft)
|
||||
assert reception.analysis["crc"] == lab045.CRC_PASS
|
||||
assert reception.payload_bytes == plan.payload
|
||||
|
||||
|
||||
def test_soft_ambiguous_symbols_near_zero_are_handled() -> None:
|
||||
plan = lab045.build_transmission_plan(deterministic_payload())
|
||||
soft = lab045.simulate_soft_channel(plan)
|
||||
generator = np.random.default_rng(3)
|
||||
weak = generator.choice(len(soft), 40, replace=False)
|
||||
soft[weak] = 1e-6 * np.sign(soft[weak] + 1e-9) # near-zero magnitude, defined behavior
|
||||
reception = lab045.analyze_post_preamble_soft(soft)
|
||||
assert reception.analysis["crc"] == lab045.CRC_PASS
|
||||
|
||||
|
||||
def test_exact_zero_soft_values_are_deterministic() -> None:
|
||||
plan = lab045.build_transmission_plan(deterministic_payload())
|
||||
soft = lab045.simulate_soft_channel(plan)
|
||||
soft[lab045.PHY_HEADER_BIT_COUNT + 100 : lab045.PHY_HEADER_BIT_COUNT + 106] = 0.0
|
||||
first = lab045.analyze_post_preamble_soft(soft)
|
||||
second = lab045.analyze_post_preamble_soft(soft.copy())
|
||||
assert first.analysis == second.analysis
|
||||
assert first.packet_bytes == second.packet_bytes
|
||||
|
||||
|
||||
def test_extra_trailing_soft_is_ignored_deterministically() -> None:
|
||||
plan = lab045.build_transmission_plan(deterministic_payload())
|
||||
soft = lab045.simulate_soft_channel(plan)
|
||||
padded = np.concatenate((soft, np.zeros(1000, dtype=np.float64)))
|
||||
reception = lab045.analyze_post_preamble_soft(padded)
|
||||
assert reception.analysis["crc"] == lab045.CRC_PASS
|
||||
assert reception.payload_bytes == plan.payload
|
||||
|
||||
|
||||
def test_truncated_coded_frame_fails_closed() -> None:
|
||||
plan = lab045.build_transmission_plan(deterministic_payload())
|
||||
soft = lab045.simulate_soft_channel(plan)
|
||||
truncated = soft[: lab045.PHY_HEADER_BIT_COUNT + 200]
|
||||
reception = lab045.analyze_post_preamble_soft(truncated)
|
||||
assert reception.analysis["phy_header_pass"] is True
|
||||
assert reception.analysis["packet_recovered"] is False
|
||||
assert reception.analysis["crc"] == lab045.CRC_NOT_REACHED
|
||||
assert reception.packet_bytes is None
|
||||
|
||||
|
||||
def test_crc_corruption_after_valid_fec_decode_is_crc_fail() -> None:
|
||||
payload = deterministic_payload()
|
||||
packet = bytearray(build_packet(payload, lab045.MESSAGE_TYPE, lab045.SEQUENCE_NUMBER))
|
||||
# Меняется payload bit; sync/version/length остаются верны, CRC расходится.
|
||||
packet[HEADER_SIZE + 5] ^= 0x01
|
||||
information_bits = lab044.bytes_to_bits(bytes(packet))
|
||||
coded = conv.encode(information_bits)
|
||||
permutation = interleaver.build_permutation(len(coded), lab045.INTERLEAVER_COLUMNS)
|
||||
interleaved = interleaver.interleave(coded, permutation)
|
||||
header = lab045.encode_phy_header(len(packet))
|
||||
soft = lab045.modulate_bits_to_ideal_soft(np.concatenate((header, interleaved)))
|
||||
reception = lab045.analyze_post_preamble_soft(soft)
|
||||
assert reception.analysis["crc"] == lab045.CRC_FAIL
|
||||
assert reception.analysis["classification"] == "B"
|
||||
assert reception.payload_bytes is None
|
||||
|
||||
|
||||
# ---- Защищённый PHY header ---------------------------------------------------
|
||||
|
||||
|
||||
def _header_soft(packet_length: int) -> np.ndarray:
|
||||
return lab045.modulate_bits_to_ideal_soft(lab045.encode_phy_header(packet_length))
|
||||
|
||||
|
||||
def test_header_majority_survives_one_error_per_repetition_group() -> None:
|
||||
soft = _header_soft(268)
|
||||
# В каждой побитовой тройке b,b,b повреждена ровно одна копия.
|
||||
soft[0::lab045.HEADER_REPETITION] *= -1.0
|
||||
header = lab045.decode_phy_header_soft(soft)
|
||||
assert header["passed"] is True
|
||||
assert header["packet_bytes"] == 268
|
||||
|
||||
|
||||
def test_header_two_errors_in_one_group_are_caught_by_checksum() -> None:
|
||||
soft = _header_soft(268)
|
||||
# Две из трёх копий одного бита повреждены: majority меняет этот бит.
|
||||
target = 32 # бит внутри поля длины
|
||||
group_start = target * lab045.HEADER_REPETITION
|
||||
soft[group_start] = -soft[group_start]
|
||||
soft[group_start + 1] = -soft[group_start + 1]
|
||||
header = lab045.decode_phy_header_soft(soft)
|
||||
assert header["passed"] is False
|
||||
assert header["reason"] in {"CHECKSUM_MISMATCH", "LENGTH_OUT_OF_RANGE", "MAGIC_MISMATCH"}
|
||||
|
||||
|
||||
def test_header_bad_magic_fails_closed() -> None:
|
||||
fields = struct.pack(lab045.PHY_HEADER_FIELDS_FORMAT, 0x1234, lab045.PHY_HEADER_VERSION, 268,
|
||||
lab045._phy_header_checksum(0x1234, lab045.PHY_HEADER_VERSION, 268))
|
||||
bits = np.repeat(lab044.bytes_to_bits(fields), lab045.HEADER_REPETITION)
|
||||
header = lab045.decode_phy_header_soft(lab045.modulate_bits_to_ideal_soft(bits))
|
||||
assert header["passed"] is False
|
||||
assert header["reason"] == "MAGIC_MISMATCH"
|
||||
|
||||
|
||||
def test_header_impossible_length_fails_closed() -> None:
|
||||
bad_length = lab045.MAX_PACKET_BYTES + 500
|
||||
fields = struct.pack(lab045.PHY_HEADER_FIELDS_FORMAT, lab045.PHY_MAGIC, lab045.PHY_HEADER_VERSION,
|
||||
bad_length, lab045._phy_header_checksum(lab045.PHY_MAGIC, lab045.PHY_HEADER_VERSION, bad_length))
|
||||
bits = np.repeat(lab044.bytes_to_bits(fields), lab045.HEADER_REPETITION)
|
||||
header = lab045.decode_phy_header_soft(lab045.modulate_bits_to_ideal_soft(bits))
|
||||
assert header["passed"] is False
|
||||
assert header["reason"] == "LENGTH_OUT_OF_RANGE"
|
||||
|
||||
|
||||
def test_header_truncated_fails_closed() -> None:
|
||||
header = lab045.decode_phy_header_soft(np.zeros(10, dtype=np.float64))
|
||||
assert header["passed"] is False
|
||||
assert header["reason"] == "TRUNCATED"
|
||||
|
||||
|
||||
def test_header_too_short_protocol_length_fails_closed() -> None:
|
||||
short_length = lab045.MIN_PACKET_BYTES - 1
|
||||
fields = struct.pack(
|
||||
lab045.PHY_HEADER_FIELDS_FORMAT,
|
||||
lab045.PHY_MAGIC,
|
||||
lab045.PHY_HEADER_VERSION,
|
||||
short_length,
|
||||
lab045._phy_header_checksum(
|
||||
lab045.PHY_MAGIC,
|
||||
lab045.PHY_HEADER_VERSION,
|
||||
short_length,
|
||||
),
|
||||
)
|
||||
bits = np.repeat(lab044.bytes_to_bits(fields), lab045.HEADER_REPETITION)
|
||||
header = lab045.decode_phy_header_soft(lab045.modulate_bits_to_ideal_soft(bits))
|
||||
assert header["passed"] is False
|
||||
assert header["reason"] == "LENGTH_OUT_OF_RANGE"
|
||||
with pytest.raises(ValueError):
|
||||
lab045.encode_phy_header(short_length)
|
||||
|
||||
|
||||
def test_header_unknown_version_fails_closed() -> None:
|
||||
version = lab045.PHY_HEADER_VERSION + 1
|
||||
fields = struct.pack(
|
||||
lab045.PHY_HEADER_FIELDS_FORMAT,
|
||||
lab045.PHY_MAGIC,
|
||||
version,
|
||||
268,
|
||||
lab045._phy_header_checksum(lab045.PHY_MAGIC, version, 268),
|
||||
)
|
||||
bits = np.repeat(lab044.bytes_to_bits(fields), lab045.HEADER_REPETITION)
|
||||
header = lab045.decode_phy_header_soft(lab045.modulate_bits_to_ideal_soft(bits))
|
||||
assert header["passed"] is False
|
||||
assert header["reason"] == "VERSION_MISMATCH"
|
||||
|
||||
|
||||
# ---- Детерминизм и отсутствие утечки TX data --------------------------------
|
||||
|
||||
|
||||
def test_frame_build_is_deterministic() -> None:
|
||||
payload = deterministic_payload()
|
||||
first = lab045.build_transmission_plan(payload)
|
||||
second = lab045.build_transmission_plan(payload)
|
||||
assert np.array_equal(first.coded_bits, second.coded_bits)
|
||||
assert np.array_equal(first.interleaved_bits, second.interleaved_bits)
|
||||
assert np.array_equal(first.frame_bits, second.frame_bits)
|
||||
|
||||
|
||||
def test_analyzer_does_not_receive_tx_payload() -> None:
|
||||
# Независимый analyzer принимает только soft values. TX data поступают
|
||||
# лишь в отдельную диагностику ПОСЛЕ CRC-решения.
|
||||
import inspect
|
||||
|
||||
analyze_parameters = list(inspect.signature(lab045.analyze_post_preamble_soft).parameters)
|
||||
assert analyze_parameters == ["post_preamble_soft"]
|
||||
capture_parameters = list(inspect.signature(lab045.analyze_capture_independently).parameters)
|
||||
assert capture_parameters == ["samples"]
|
||||
diagnostic_parameters = list(inspect.signature(lab045.diagnostic_pre_and_post_fec).parameters)
|
||||
assert "plan" in diagnostic_parameters # TX reference only here
|
||||
|
||||
|
||||
def test_no_leakage_order_independent_then_diagnostic() -> None:
|
||||
events: list[str] = []
|
||||
plan = lab045.build_transmission_plan(deterministic_payload())
|
||||
soft = lab045.simulate_soft_channel(plan, bit_error_probability=0.006, seed=5)
|
||||
|
||||
events.append("independent_decode")
|
||||
reception = lab045.analyze_post_preamble_soft(soft)
|
||||
assert reception.analysis["crc"] in {lab045.CRC_PASS, lab045.CRC_FAIL, lab045.CRC_NOT_REACHED}
|
||||
|
||||
events.append("diagnostic_read")
|
||||
diagnostic = lab045.diagnostic_pre_and_post_fec(reception, soft, plan)
|
||||
assert diagnostic["receiver_knew_payload_before_decode"] is False
|
||||
assert events == ["independent_decode", "diagnostic_read"]
|
||||
|
||||
|
||||
# ---- Научная проверка soft против hard decision ------------------------------
|
||||
|
||||
|
||||
def test_soft_decision_beats_hard_on_a_real_noisy_case() -> None:
|
||||
"""Детерминированный AWGN-случай, где амплитуды дают реальную пользу."""
|
||||
|
||||
information = np.random.default_rng(123).integers(0, 2, 128, dtype=np.uint8)
|
||||
coded = conv.encode(information)
|
||||
permutation = interleaver.build_permutation(len(coded), 32)
|
||||
interleaved_bits = interleaver.interleave(coded, permutation)
|
||||
soft = (
|
||||
lab045.modulate_bits_to_ideal_soft(interleaved_bits)
|
||||
+ np.random.default_rng(0).normal(0.0, 0.7, size=len(interleaved_bits))
|
||||
)
|
||||
|
||||
soft_decoded = conv.viterbi_decode_soft(
|
||||
interleaver.deinterleave(soft, permutation),
|
||||
len(information),
|
||||
)
|
||||
hard_decoded = conv.viterbi_decode_hard(
|
||||
interleaver.deinterleave((soft >= 0.0).astype(np.uint8), permutation),
|
||||
len(information),
|
||||
)
|
||||
|
||||
assert np.array_equal(soft_decoded, information)
|
||||
assert not np.array_equal(hard_decoded, information)
|
||||
|
||||
|
||||
def test_full_chain_with_deterministic_gaussian_noise_passes_crc() -> None:
|
||||
plan = lab045.build_transmission_plan(deterministic_payload())
|
||||
soft = lab045.simulate_soft_channel(plan, noise_sigma=0.45, seed=45)
|
||||
reception = lab045.analyze_post_preamble_soft(soft)
|
||||
assert reception.analysis["crc"] == lab045.CRC_PASS
|
||||
assert reception.payload_bytes == plan.payload
|
||||
|
||||
|
||||
def test_direct_invocation_smoke() -> None:
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(project_root / "experiments" / "lab045_hackrf_bpsk_fec.py")],
|
||||
cwd=project_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "total symbols = 5724" in result.stdout
|
||||
assert "waveform samples = 2306400" in result.stdout
|
||||
assert "synthetic CRC = PASS" in result.stdout
|
||||
|
||||
|
||||
def test_hardware_entry_point_is_fail_closed() -> None:
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(project_root / "experiments" / "lab045_hackrf_bpsk_fec.py"),
|
||||
"--hardware",
|
||||
],
|
||||
cwd=project_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 2
|
||||
assert "RF TX = 0" in result.stdout
|
||||
|
||||
|
||||
# ---- Fail-closed аппаратная обвязка без реального RF -------------------------
|
||||
|
||||
|
||||
class HardwareFakeRunner:
|
||||
def __init__(self) -> None:
|
||||
self.commands: list[list[str]] = []
|
||||
|
||||
def __call__(self, command: list[str]):
|
||||
import subprocess
|
||||
|
||||
self.commands.append(command)
|
||||
if command[0].lower().endswith("hackrf_info.exe"):
|
||||
output = (
|
||||
f"Serial number: {lab044.lab043.MASTER_SERIAL}\n"
|
||||
f"Serial number: {lab044.lab043.SLAVE_SERIAL}\n"
|
||||
)
|
||||
elif "-t" in command:
|
||||
output = "0 underruns\n"
|
||||
else:
|
||||
output = ""
|
||||
return subprocess.CompletedProcess(command, 0, output, "")
|
||||
|
||||
|
||||
class HardwareFakeProcess:
|
||||
def __init__(self, startup_failed: bool = False) -> None:
|
||||
self.startup_failed = startup_failed
|
||||
self.terminated = False
|
||||
|
||||
def poll(self) -> int | None:
|
||||
return 1 if self.startup_failed else (0 if self.terminated else None)
|
||||
|
||||
def wait(self, timeout: float) -> int:
|
||||
del timeout
|
||||
return 0
|
||||
|
||||
def terminate(self) -> None:
|
||||
self.terminated = True
|
||||
|
||||
def close_logs(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class HardwareFakeStarter:
|
||||
def __init__(self, startup_failed: bool = False) -> None:
|
||||
self.startup_failed = startup_failed
|
||||
self.processes: list[HardwareFakeProcess] = []
|
||||
|
||||
def __call__(self, command: list[str], stdout_path, stderr_path):
|
||||
from pathlib import Path
|
||||
|
||||
stdout_path.write_text("0 overruns\n", encoding="utf-8")
|
||||
stderr_path.write_text("", encoding="utf-8")
|
||||
raw_path = Path(command[command.index("-r") + 1])
|
||||
raw_path.write_bytes(bytes(2 * lab045.CAPTURE_SAMPLE_COUNT))
|
||||
process = HardwareFakeProcess(self.startup_failed)
|
||||
self.processes.append(process)
|
||||
return process
|
||||
|
||||
|
||||
def allow_hardware_preflight(runner, guard) -> dict:
|
||||
del runner
|
||||
guard.allow()
|
||||
return {
|
||||
"passed": True,
|
||||
"master_reg29": 0x26,
|
||||
"slave_reg29": 0x26,
|
||||
"slave_clkin_status": "DETECTED",
|
||||
}
|
||||
|
||||
|
||||
def prepare_small_hardware_mock(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(lab045, "CAPTURE_SAMPLE_COUNT", 16)
|
||||
monkeypatch.setattr(lab045, "SAMPLES_PER_SYMBOL", 1)
|
||||
monkeypatch.setattr(lab045, "GUARD_SYMBOL_COUNT", 0)
|
||||
monkeypatch.setattr(lab045, "RRC_SPAN_SYMBOLS", 0)
|
||||
monkeypatch.setattr(
|
||||
lab045,
|
||||
"build_tx_cs8",
|
||||
lambda plan: bytes(2 * plan.waveform_sample_count),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lab044.lab043,
|
||||
"common_clock_preflight",
|
||||
allow_hardware_preflight,
|
||||
)
|
||||
monkeypatch.setattr(lab044.lab043, "_disable_clock_outputs", lambda runner: [])
|
||||
|
||||
|
||||
def test_hardware_common_clock_failure_means_zero_tx(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
prepare_small_hardware_mock(monkeypatch)
|
||||
runner = HardwareFakeRunner()
|
||||
monkeypatch.setattr(
|
||||
lab044.lab043,
|
||||
"common_clock_preflight",
|
||||
lambda runner, guard: (_ for _ in ()).throw(RuntimeError("clock")),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="clock"):
|
||||
lab045.run_hardware_experiment(
|
||||
tmp_path,
|
||||
runner=runner,
|
||||
payload_factory=lambda count: bytes(count),
|
||||
process_checker=lambda: [],
|
||||
)
|
||||
assert not any("-t" in command for command in runner.commands)
|
||||
assert (tmp_path / "failure.json").is_file()
|
||||
|
||||
|
||||
def test_hardware_rx_startup_failure_means_zero_tx(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
prepare_small_hardware_mock(monkeypatch)
|
||||
runner = HardwareFakeRunner()
|
||||
starter = HardwareFakeStarter(startup_failed=True)
|
||||
with pytest.raises(RuntimeError, match="RX завершился"):
|
||||
lab045.run_hardware_experiment(
|
||||
tmp_path,
|
||||
runner=runner,
|
||||
process_starter=starter,
|
||||
sleep_function=lambda seconds: None,
|
||||
payload_factory=lambda count: bytes(count),
|
||||
process_checker=lambda: [],
|
||||
)
|
||||
assert not any("-t" in command for command in runner.commands)
|
||||
assert starter.processes[0].terminated is True
|
||||
|
||||
|
||||
def test_hardware_one_tx_cleanup_and_artifacts(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
prepare_small_hardware_mock(monkeypatch)
|
||||
runner = HardwareFakeRunner()
|
||||
starter = HardwareFakeStarter()
|
||||
payload = deterministic_payload()
|
||||
packet = build_packet(payload, lab045.MESSAGE_TYPE, lab045.SEQUENCE_NUMBER)
|
||||
reception = lab045.IndependentReception(
|
||||
{
|
||||
"classification": "A",
|
||||
"crc": lab045.CRC_PASS,
|
||||
"phy_sync_pass": True,
|
||||
"phy_header_pass": True,
|
||||
"fec_decode_pass": True,
|
||||
"protocol_header_pass": True,
|
||||
},
|
||||
packet,
|
||||
payload,
|
||||
)
|
||||
events: list[str] = []
|
||||
|
||||
def analyzer(samples: np.ndarray) -> lab045.IndependentReception:
|
||||
del samples
|
||||
events.append("independent_decode")
|
||||
return reception
|
||||
|
||||
def comparer(result, samples, tx_payload_path, tx_packet_path) -> dict:
|
||||
del result, samples
|
||||
assert events == ["independent_decode"]
|
||||
assert tx_payload_path.read_bytes() == payload
|
||||
assert tx_packet_path.read_bytes() == packet
|
||||
events.append("diagnostic_read")
|
||||
return {
|
||||
"stage": "AFTER_INDEPENDENT_CRC_DECISION",
|
||||
"receiver_knew_payload_before_decode": False,
|
||||
"pre_fec_coded": {"bit_errors": 3, "ber": 3 / 4300},
|
||||
"post_fec_packet": {"bit_errors": 0, "ber": 0.0},
|
||||
"post_fec_payload": {
|
||||
"bit_errors": 0,
|
||||
"byte_errors": 0,
|
||||
"bit_perfect": True,
|
||||
},
|
||||
}
|
||||
|
||||
result = lab045.run_hardware_experiment(
|
||||
tmp_path,
|
||||
runner=runner,
|
||||
process_starter=starter,
|
||||
sleep_function=lambda seconds: None,
|
||||
payload_factory=lambda count: payload,
|
||||
analyzer=analyzer,
|
||||
comparer=comparer,
|
||||
process_checker=lambda: [],
|
||||
)
|
||||
assert result["classification"] == "A"
|
||||
assert result["analysis"]["real_rf_fec_correction_demonstrated"] is True
|
||||
assert sum("-t" in command for command in runner.commands) == 1
|
||||
assert events == ["independent_decode", "diagnostic_read"]
|
||||
assert starter.processes[0].terminated is True
|
||||
metadata = json.loads((tmp_path / "metadata.json").read_text(encoding="utf-8"))
|
||||
assert metadata["rf_tx_count"] == 1
|
||||
assert metadata["cleanup"]["clock_outputs_off"] is True
|
||||
assert metadata["cleanup"]["no_lingering_hackrf_transfer"] is True
|
||||
assert metadata["artifact_sha256_verified"] is True
|
||||
for name, digest in metadata["artifact_sha256"].items():
|
||||
path = tmp_path / name
|
||||
if path.is_file():
|
||||
assert digest == hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
467
tests/test_lab046_bpsk_object_transport.py
Normal file
467
tests/test_lab046_bpsk_object_transport.py
Normal file
@@ -0,0 +1,467 @@
|
||||
"""Быстрые проверки Lab046: фрагментация большого объекта и fail-closed сборка.
|
||||
|
||||
Тесты не трогают оборудование, не создают файлов и не запускают RF. Полный
|
||||
тракт PHY (кадр Lab045 -> канал -> приём -> сборка) проверяется на маленьких
|
||||
объектах, чтобы оставаться быстрым.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import struct
|
||||
|
||||
import numpy as np
|
||||
|
||||
from experiments import lab046_bpsk_object_transport as lab046
|
||||
from protocol import image_fragments as fragments
|
||||
|
||||
|
||||
# ------------------------------------------------------------ Детерминированный объект
|
||||
|
||||
|
||||
def test_deterministic_object_is_reproducible_and_hashable() -> None:
|
||||
first = lab046.make_deterministic_object(4096, seed=46)
|
||||
second = lab046.make_deterministic_object(4096, seed=46)
|
||||
assert first == second
|
||||
assert len(first) == 4096
|
||||
other = lab046.make_deterministic_object(4096, seed=47)
|
||||
assert other != first
|
||||
assert len(lab046.object_sha256(first)) == 64
|
||||
|
||||
|
||||
# ------------------------------------------------------------ Фрагмент туда-обратно
|
||||
|
||||
|
||||
def test_fragment_round_trip_exact_without_channel() -> None:
|
||||
obj = lab046.make_deterministic_object(3000, seed=1)
|
||||
pieces = fragments.split_image_bytes(obj, lab046.DEFAULT_OBJECT_ID, lab046.FRAGMENT_DATA_SIZE)
|
||||
assert fragments.reassemble_image(pieces) == obj
|
||||
|
||||
|
||||
def test_last_short_fragment_recovered_by_header_length() -> None:
|
||||
# 3000 не кратно 244, значит последний фрагмент короче.
|
||||
obj = lab046.make_deterministic_object(3000, seed=2)
|
||||
pieces = fragments.split_image_bytes(obj, lab046.DEFAULT_OBJECT_ID, lab046.FRAGMENT_DATA_SIZE)
|
||||
last = pieces[-1]
|
||||
assert len(last.data) < lab046.FRAGMENT_DATA_SIZE
|
||||
padded = lab046._pad_payload_to_packet(fragments.encode_image_fragment(last))
|
||||
assert len(padded) == lab046.PACKET_PAYLOAD_SIZE
|
||||
recovered = lab046.recover_fragment_from_payload(padded)
|
||||
assert recovered is not None
|
||||
assert recovered.data == last.data
|
||||
|
||||
|
||||
def _make_payload(object_id: int, index: int, count: int, original_size: int, data: bytes) -> bytes:
|
||||
header = struct.pack(
|
||||
fragments.FRAGMENT_HEADER_FORMAT,
|
||||
object_id,
|
||||
index,
|
||||
count,
|
||||
original_size,
|
||||
)
|
||||
return lab046._pad_payload_to_packet(header + data)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ recover: fail-closed
|
||||
|
||||
|
||||
def test_recover_rejects_truncated_metadata() -> None:
|
||||
# Короче заголовка -> декод не проходит, объект не собирается.
|
||||
assert lab046.recover_fragment_from_payload(b"\x00\x03") is None
|
||||
|
||||
|
||||
def test_recover_rejects_impossible_index() -> None:
|
||||
payload = _make_payload(lab046.DEFAULT_OBJECT_ID, index=5, count=3, original_size=600, data=b"\x01" * 200)
|
||||
assert lab046.recover_fragment_from_payload(payload) is None
|
||||
|
||||
|
||||
def test_recover_rejects_inconsistent_count_and_size() -> None:
|
||||
# count=1, но original_size больше одного фрагмента -> несогласовано.
|
||||
payload = _make_payload(lab046.DEFAULT_OBJECT_ID, index=0, count=1, original_size=10_000, data=b"\x01" * 244)
|
||||
assert lab046.recover_fragment_from_payload(payload) is None
|
||||
|
||||
|
||||
def test_recover_rejects_absurd_original_size_without_huge_alloc() -> None:
|
||||
payload = _make_payload(lab046.DEFAULT_OBJECT_ID, index=0, count=2, original_size=2**31, data=b"\x01" * 244)
|
||||
assert lab046.recover_fragment_from_payload(payload) is None
|
||||
|
||||
|
||||
def test_expected_length_returns_none_on_bad_fields() -> None:
|
||||
assert lab046.expected_fragment_data_length(0, 0, 100) is None
|
||||
assert lab046.expected_fragment_data_length(-1, 3, 100) is None
|
||||
assert lab046.expected_fragment_data_length(0, 1, 0) is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------ Сборщик: fail-closed
|
||||
|
||||
|
||||
def test_assembler_rejects_foreign_object_id() -> None:
|
||||
obj = lab046.make_deterministic_object(600, seed=3)
|
||||
pieces = fragments.split_image_bytes(obj, lab046.DEFAULT_OBJECT_ID, lab046.FRAGMENT_DATA_SIZE)
|
||||
assembler = lab046.ObjectAssembler()
|
||||
assert assembler.offer(pieces[0]) == "accepted"
|
||||
foreign = fragments.ImageFragment(
|
||||
image_id=0xDEADBEEF,
|
||||
fragment_index=pieces[1].fragment_index,
|
||||
fragment_count=pieces[1].fragment_count,
|
||||
original_size=pieces[1].original_size,
|
||||
data=pieces[1].data,
|
||||
)
|
||||
assert assembler.offer(foreign) == "rejected"
|
||||
assert not assembler.object_complete()
|
||||
|
||||
|
||||
def test_assembler_identical_duplicate_ignored_conflicting_rejected() -> None:
|
||||
obj = lab046.make_deterministic_object(600, seed=4)
|
||||
pieces = fragments.split_image_bytes(obj, lab046.DEFAULT_OBJECT_ID, lab046.FRAGMENT_DATA_SIZE)
|
||||
assembler = lab046.ObjectAssembler()
|
||||
assert assembler.offer(pieces[0]) == "accepted"
|
||||
assert assembler.offer(pieces[0]) == "duplicate"
|
||||
conflicting = fragments.ImageFragment(
|
||||
image_id=pieces[0].image_id,
|
||||
fragment_index=pieces[0].fragment_index,
|
||||
fragment_count=pieces[0].fragment_count,
|
||||
original_size=pieces[0].original_size,
|
||||
data=bytes(len(pieces[0].data)),
|
||||
)
|
||||
assert assembler.offer(conflicting) == "conflicting"
|
||||
|
||||
|
||||
def test_assembler_none_is_rejected_not_raised() -> None:
|
||||
assembler = lab046.ObjectAssembler()
|
||||
assert assembler.offer(None) == "rejected"
|
||||
assert assembler.recovered_object() is None
|
||||
|
||||
|
||||
def test_assembler_incomplete_returns_no_object() -> None:
|
||||
obj = lab046.make_deterministic_object(600, seed=5)
|
||||
pieces = fragments.split_image_bytes(obj, lab046.DEFAULT_OBJECT_ID, lab046.FRAGMENT_DATA_SIZE)
|
||||
assembler = lab046.ObjectAssembler()
|
||||
assembler.offer(pieces[0]) # намеренно не все
|
||||
assert not assembler.object_complete()
|
||||
assert assembler.recovered_object() is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------ Полный тракт PHY: сценарии
|
||||
|
||||
|
||||
def test_scenario_a_ideal_channel_exact_object() -> None:
|
||||
obj = lab046.make_deterministic_object(2048, seed=46)
|
||||
result = lab046.run_object_transport(obj, channel=lab046.ideal_channel)
|
||||
assert result.object_complete
|
||||
assert result.sha_match
|
||||
assert result.object_byte_errors == 0
|
||||
assert result.crc_fail_count == 0
|
||||
assert result.recovered_object_sha256 == result.object_sha256
|
||||
|
||||
|
||||
def test_scenario_b_distributed_bit_errors_corrected() -> None:
|
||||
# Честный гауссов шум: до FEC ошибки есть, после FEC ноль, объект точен.
|
||||
obj = lab046.make_deterministic_object(2048, seed=46)
|
||||
channel = lab046.make_noisy_channel(noise_sigma=0.40, base_seed=46000)
|
||||
result = lab046.run_object_transport(obj, channel=channel)
|
||||
assert result.pre_fec_coded_bit_errors > 0
|
||||
assert result.post_fec_protocol_bit_errors == 0
|
||||
assert result.crc_fail_count == 0
|
||||
assert result.object_complete
|
||||
assert result.sha_match
|
||||
assert result.object_byte_errors == 0
|
||||
|
||||
|
||||
def test_scenario_c_out_of_order_delivery_still_exact() -> None:
|
||||
obj = lab046.make_deterministic_object(2048, seed=46)
|
||||
frames = lab046.build_object_frames(obj)
|
||||
order = list(reversed(range(len(frames))))
|
||||
result = lab046.run_object_transport(obj, channel=lab046.ideal_channel, delivery_order=order)
|
||||
assert result.object_complete
|
||||
assert result.sha_match
|
||||
|
||||
|
||||
def test_scenario_d_duplicate_fragments_still_exact() -> None:
|
||||
obj = lab046.make_deterministic_object(2048, seed=46)
|
||||
result = lab046.run_object_transport(
|
||||
obj,
|
||||
channel=lab046.ideal_channel,
|
||||
duplicate_indexes=(0, 2, 2),
|
||||
)
|
||||
assert result.duplicates >= 1
|
||||
assert result.object_complete
|
||||
assert result.sha_match
|
||||
|
||||
|
||||
def test_scenario_e_missing_fragment_means_not_complete() -> None:
|
||||
obj = lab046.make_deterministic_object(2048, seed=46)
|
||||
result = lab046.run_object_transport(
|
||||
obj,
|
||||
channel=lab046.ideal_channel,
|
||||
drop_indexes=(1,),
|
||||
)
|
||||
assert not result.object_complete
|
||||
assert 1 in result.missing_indexes
|
||||
assert result.recovered_object_sha256 is None
|
||||
assert not result.sha_match
|
||||
|
||||
|
||||
def test_scenario_f_corrupted_packet_rejected_no_fake_object() -> None:
|
||||
obj = lab046.make_deterministic_object(2048, seed=46)
|
||||
result = lab046.run_object_transport(
|
||||
obj,
|
||||
channel=lab046.ideal_channel,
|
||||
corrupt_indexes=(0,),
|
||||
)
|
||||
assert result.crc_fail_count >= 1
|
||||
assert not result.object_complete
|
||||
assert result.recovered_object_sha256 is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------ Метрики кадра
|
||||
|
||||
|
||||
def test_object_metrics_match_lab045_frame() -> None:
|
||||
obj = lab046.make_deterministic_object(16384, seed=46)
|
||||
frames = lab046.build_object_frames(obj)
|
||||
assert len(frames) == 68 # ceil(16384 / 244)
|
||||
assert all(f.plan.frame_symbol_count == 5724 for f in frames)
|
||||
assert all(len(f.plan.packet) == 268 for f in frames)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ Нет оракула
|
||||
|
||||
|
||||
def test_receiver_ignores_original_object_on_corruption() -> None:
|
||||
# Если бы приёмник подглядывал в оригинал, повреждённый кадр «восстановился»
|
||||
# бы. Проверяем, что этого не происходит: объект остаётся неполным.
|
||||
obj = lab046.make_deterministic_object(2048, seed=46)
|
||||
result = lab046.run_object_transport(
|
||||
obj,
|
||||
channel=lab046.ideal_channel,
|
||||
corrupt_indexes=tuple(range(len(lab046.build_object_frames(obj)))),
|
||||
)
|
||||
assert result.crc_pass_count == 0
|
||||
assert not result.object_complete
|
||||
assert result.recovered_object_sha256 is None
|
||||
|
||||
|
||||
def test_functional_self_check_runs() -> None:
|
||||
lab046.run_functional_tests()
|
||||
|
||||
|
||||
# ------------------------------------------------------------ Lab046A: continuous stream
|
||||
|
||||
|
||||
def _small_continuous_stream(
|
||||
*,
|
||||
leading: int = 0,
|
||||
gaps: dict[int, int] | None = None,
|
||||
trailing: int = 0,
|
||||
) -> tuple[bytes, lab046.ContinuousSoftStreamPlan]:
|
||||
obj = lab046.make_deterministic_object(300, seed=460)
|
||||
plan = lab046.build_continuous_soft_stream(
|
||||
lab046.build_object_frames(obj),
|
||||
leading_background_symbols=leading,
|
||||
inter_frame_background=gaps,
|
||||
trailing_background_symbols=trailing,
|
||||
seed=461,
|
||||
)
|
||||
return obj, plan
|
||||
|
||||
|
||||
def test_two_sequential_frames_found_without_known_offsets() -> None:
|
||||
obj, stream_plan = _small_continuous_stream()
|
||||
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
|
||||
assert received.valid_frame_count == 2
|
||||
assert received.recovered_object == obj
|
||||
# Сравнение с TX offsets разрешено только здесь, после независимого receive.
|
||||
assert received.candidate_starts == stream_plan.frame_starts
|
||||
|
||||
|
||||
def test_many_sequential_frames_are_found() -> None:
|
||||
obj = lab046.make_deterministic_object(244 * 10, seed=462)
|
||||
result = lab046.run_continuous_object_transport(obj)
|
||||
assert result.receive.valid_frame_count == 10
|
||||
assert result.receive.preamble_candidates == 10
|
||||
assert result.sha_match
|
||||
|
||||
|
||||
def test_first_frame_need_not_start_at_zero() -> None:
|
||||
obj, stream_plan = _small_continuous_stream(leading=37)
|
||||
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
|
||||
assert received.candidate_starts[0] == 37
|
||||
assert received.recovered_object == obj
|
||||
|
||||
|
||||
def test_trailing_noise_is_ignored() -> None:
|
||||
obj, stream_plan = _small_continuous_stream(trailing=43)
|
||||
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
|
||||
assert received.valid_frame_count == 2
|
||||
assert received.preamble_candidates == 2
|
||||
assert received.recovered_object == obj
|
||||
|
||||
|
||||
def test_inter_frame_background_does_not_hide_next_frame() -> None:
|
||||
obj, stream_plan = _small_continuous_stream(gaps={0: 19})
|
||||
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
|
||||
assert received.candidate_starts[1] - received.candidate_starts[0] == 5724 + 19
|
||||
assert received.recovered_object == obj
|
||||
|
||||
|
||||
def test_same_frame_is_not_decoded_twice() -> None:
|
||||
_, stream_plan = _small_continuous_stream()
|
||||
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
|
||||
assert len(received.candidate_starts) == len(set(received.candidate_starts))
|
||||
assert received.preamble_candidates == received.valid_frame_count == 2
|
||||
|
||||
|
||||
def test_malformed_candidate_rejected_and_later_good_frame_found() -> None:
|
||||
obj = lab046.make_deterministic_object(300, seed=463)
|
||||
result = lab046.run_continuous_object_transport(
|
||||
obj,
|
||||
prepend_malformed_candidate=True,
|
||||
)
|
||||
assert result.receive.rejected_candidates > 0
|
||||
assert result.receive.valid_frame_count == 2
|
||||
assert result.receive.candidate_starts[0] == 0
|
||||
assert result.sha_match
|
||||
|
||||
|
||||
def test_stronger_false_peak_does_not_delete_overlapping_later_frame() -> None:
|
||||
obj = lab046.make_deterministic_object(100, seed=999)
|
||||
frame = lab046.build_object_frames(obj)[0]
|
||||
preamble = lab046._bits_to_float32_soft(lab046.lab045.lab044.preamble_bits())
|
||||
true_frame = lab046._bits_to_float32_soft(frame.plan.frame_bits)
|
||||
true_start = 508 # четыре периода PRBS7 после ложного старта
|
||||
stream = np.zeros(true_start + true_frame.size, dtype=np.float32)
|
||||
stream[:preamble.size] = preamble
|
||||
stream[true_start:true_start + true_frame.size] = true_frame
|
||||
stream[preamble.size:true_start + preamble.size] *= np.float32(0.7)
|
||||
|
||||
scores = lab046._normalized_soft_preamble_correlation(stream)
|
||||
assert scores[0] > scores[true_start]
|
||||
received = lab046.scan_continuous_soft_stream(stream)
|
||||
assert received.rejected_candidates > 0
|
||||
assert true_start in received.candidate_starts
|
||||
assert received.valid_frame_count == 1
|
||||
assert received.recovered_object == obj
|
||||
|
||||
|
||||
def test_validated_header_drives_candidate_body_length() -> None:
|
||||
obj, stream_plan = _small_continuous_stream()
|
||||
packet_bytes = lab046.lab045.MIN_PACKET_BYTES
|
||||
coded_symbols = lab046.lab045.conv.encoded_bit_count(packet_bytes * 8)
|
||||
prefix_bits = np.concatenate((
|
||||
lab046.lab045.lab044.preamble_bits(),
|
||||
lab046.lab045.encode_phy_header(packet_bytes),
|
||||
np.zeros(coded_symbols, dtype=np.uint8),
|
||||
))
|
||||
stream = np.concatenate((
|
||||
lab046._bits_to_float32_soft(prefix_bits),
|
||||
stream_plan.soft_symbols,
|
||||
))
|
||||
received = lab046.scan_continuous_soft_stream(stream)
|
||||
assert received.frame_receptions[0].packet_bytes == packet_bytes
|
||||
assert received.frame_receptions[0].frame_end == len(prefix_bits)
|
||||
assert received.rejected_candidates >= 1
|
||||
assert received.recovered_object == obj
|
||||
|
||||
|
||||
def test_cursor_is_strictly_monotonic() -> None:
|
||||
_, stream_plan = _small_continuous_stream(leading=7, gaps={0: 13}, trailing=5)
|
||||
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
|
||||
assert all(
|
||||
later > earlier
|
||||
for earlier, later in zip(received.cursor_history, received.cursor_history[1:])
|
||||
)
|
||||
|
||||
|
||||
def test_max_search_iterations_is_fail_closed() -> None:
|
||||
_, stream_plan = _small_continuous_stream()
|
||||
received = lab046.scan_continuous_soft_stream(
|
||||
stream_plan.soft_symbols,
|
||||
max_search_iterations=1,
|
||||
)
|
||||
assert received.max_search_iterations_reached
|
||||
assert received.termination_reason == "MAX_SEARCH_ITERATIONS_REACHED"
|
||||
assert received.valid_frame_count == 1
|
||||
assert not received.object_complete
|
||||
|
||||
|
||||
def test_max_frames_is_fail_closed() -> None:
|
||||
_, stream_plan = _small_continuous_stream()
|
||||
received = lab046.scan_continuous_soft_stream(
|
||||
stream_plan.soft_symbols,
|
||||
max_frames=1,
|
||||
)
|
||||
assert received.max_frames_reached
|
||||
assert received.termination_reason == "MAX_FRAMES_REACHED"
|
||||
assert received.valid_frame_count == 1
|
||||
assert not received.object_complete
|
||||
|
||||
|
||||
def test_continuous_ideal_16_kib_object_is_exact() -> None:
|
||||
obj = lab046.make_deterministic_object()
|
||||
result = lab046.run_continuous_object_transport(obj)
|
||||
assert result.receive.preamble_candidates == 68
|
||||
assert result.receive.valid_phy_headers == 68
|
||||
assert result.receive.fec_pass_count == 68
|
||||
assert result.receive.crc_pass_count == 68
|
||||
assert result.receive.valid_fragments == 68
|
||||
assert result.receive.object_complete
|
||||
assert result.sha_match
|
||||
assert result.object_byte_errors == 0
|
||||
assert result.stream_plan.soft_symbols.dtype == np.float32
|
||||
assert result.stream_plan.soft_symbols.nbytes < 2_000_000
|
||||
|
||||
|
||||
def test_continuous_background_16_kib_object_is_exact() -> None:
|
||||
obj = lab046.make_deterministic_object()
|
||||
result = lab046.run_continuous_object_transport(
|
||||
obj,
|
||||
leading_background_symbols=37,
|
||||
inter_frame_background={7: 11, 23: 19, 51: 7},
|
||||
trailing_background_symbols=43,
|
||||
seed=464,
|
||||
)
|
||||
assert result.stream_plan.leading_background_symbols == 37
|
||||
assert result.stream_plan.inter_frame_background_symbols == 37
|
||||
assert result.stream_plan.trailing_background_symbols == 43
|
||||
assert result.receive.valid_frame_count == 68
|
||||
assert result.receive.object_complete
|
||||
assert result.sha_match
|
||||
|
||||
|
||||
def test_continuous_noisy_16_kib_object_is_exact_after_fec() -> None:
|
||||
obj = lab046.make_deterministic_object()
|
||||
result = lab046.run_continuous_object_transport(
|
||||
obj,
|
||||
noise_sigma=0.40,
|
||||
seed=46001,
|
||||
)
|
||||
assert result.receive.valid_frame_count == 68
|
||||
assert result.pre_fec_coded_bit_errors > 0
|
||||
assert result.post_fec_protocol_bit_errors == 0
|
||||
assert result.receive.crc_pass_count == 68
|
||||
assert result.receive.crc_fail_count == 0
|
||||
assert result.receive.object_complete
|
||||
assert result.sha_match
|
||||
|
||||
|
||||
def test_continuous_receiver_has_no_tx_oracle_parameters(monkeypatch) -> None:
|
||||
obj, stream_plan = _small_continuous_stream(leading=5)
|
||||
parameters = set(inspect.signature(lab046.scan_continuous_soft_stream).parameters)
|
||||
forbidden = {
|
||||
"frame_plans",
|
||||
"frame_starts",
|
||||
"frame_count",
|
||||
"fragment_indexes",
|
||||
"packets",
|
||||
"coded_bits",
|
||||
"original_object",
|
||||
}
|
||||
assert parameters.isdisjoint(forbidden)
|
||||
|
||||
def forbidden_tx_lookup(*args, **kwargs):
|
||||
raise AssertionError("scanner обратился к TX-side plan")
|
||||
|
||||
monkeypatch.setattr(lab046, "build_object_frames", forbidden_tx_lookup)
|
||||
received = lab046.scan_continuous_soft_stream(stream_plan.soft_symbols)
|
||||
assert received.recovered_object == obj
|
||||
Reference in New Issue
Block a user