3 Commits

Author SHA1 Message Date
LittleSam129
e8d85ae4b7 Lab046A: validate continuous soft-symbol frame scanning 2026-09-01 13:18:42 +03:00
LittleSam129
1027eef53d Lab046: validate fragmented BPSK object transport 2026-09-01 11:39:45 +03:00
LittleSam129
62a376c811 Lab045: demonstrate real RF BPSK error correction
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 10:49:00 +03:00
9 changed files with 4195 additions and 0 deletions

View File

@@ -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 и переход к более длинным/структурированным передачам. Защитить критические поля кадра от одиночных битовых ошибок при 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 автоматически не запускать.

View 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 и выполнить один
ограниченный аппаратный прогон.

View 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).

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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

View 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)

View 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()

View 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