Lab045: demonstrate real RF BPSK error correction
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -631,3 +631,61 @@ Common-clock preflight PASS: MASTER reg29 = 0x26, SLAVE reg29 = 0x26, SLAVE CLKI
|
|||||||
## Следующий этап
|
## Следующий этап
|
||||||
|
|
||||||
Защитить критические поля кадра от одиночных битовых ошибок при BER ~0.6 %: FEC либо избыточное/помехоустойчивое кодирование PHY LENGTH и protocol-заголовка (ошибка сорвала разбор именно в длине, а не в CRC полезной нагрузки). Только после этого имеет смысл повторный аппаратный прогон Lab044 и переход к более длинным/структурированным передачам.
|
Защитить критические поля кадра от одиночных битовых ошибок при 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 в этой сессии не запускаются.
|
||||||
|
|||||||
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 и выполнить один
|
||||||
|
ограниченный аппаратный прогон.
|
||||||
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
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()
|
||||||
Reference in New Issue
Block a user