Lab045: demonstrate real RF BPSK error correction

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
LittleSam129
2026-09-01 10:49:00 +03:00
parent 72f933a32b
commit 62a376c811
6 changed files with 2354 additions and 0 deletions

View File

@@ -631,3 +631,61 @@ Common-clock preflight PASS: MASTER reg29 = 0x26, SLAVE reg29 = 0x26, SLAVE CLKI
## Следующий этап
Защитить критические поля кадра от одиночных битовых ошибок при BER ~0.6 %: FEC либо избыточное/помехоустойчивое кодирование PHY LENGTH и protocol-заголовка (ошибка сорвала разбор именно в длине, а не в CRC полезной нагрузки). Только после этого имеет смысл повторный аппаратный прогон Lab044 и переход к более длинным/структурированным передачам.
# Запись 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 в этой сессии не запускаются.

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

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