Lab046: validate fragmented BPSK object transport
This commit is contained in:
@@ -689,3 +689,33 @@ Lab045: первый аппаратный RF-прогон bit-level FEC — RESU
|
|||||||
## Следующий этап
|
## Следующий этап
|
||||||
|
|
||||||
Проанализировать первый аппаратный результат Lab045 (в частности, почему SNR упал до 2,63 дБ против 7,19 дБ в Lab044 — вероятно антенна/связь/положение) и по решению ChatGPT определить, что дальше: закрепление рабочей точки, переход к более длинным/структурированным передачам или к сжатому изображению. Ни FEC/QPSK/JPEG/video, ни второй TX в этой сессии не запускаются.
|
Проанализировать первый аппаратный результат 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 и видео в этой сессии не запускались.
|
||||||
|
|||||||
170
docs/lab046_current_state.md
Normal file
170
docs/lab046_current_state.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
# Lab046: текущее состояние
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
**SOFTWARE VALIDATED. RF TX = 0.
|
||||||
|
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`) НЕ
|
||||||
|
использован и НЕ изменён — он остаётся будущим расширением для потери целых
|
||||||
|
фрагментов, а не для битовых ошибок (это работа свёрточного кода).
|
||||||
|
|
||||||
|
## Детерминированный объект
|
||||||
|
|
||||||
|
- Размер **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) |
|
||||||
|
| Оценка эфирного времени | 19,60 с |
|
||||||
|
| Полезный goodput | 835,7 байт/с = 6,69 кбит/с |
|
||||||
|
| Полезных бит на символ | ≈ 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,
|
||||||
|
отсутствие оракула, детерминизм и SHA объекта).
|
||||||
|
- Полный тракт `объект → фрагменты → кадр Lab045 → канал → приём → сборка`
|
||||||
|
проверен для всех шести сценариев значениями выше.
|
||||||
|
|
||||||
|
## Software gate
|
||||||
|
|
||||||
|
- `python tools/quick_gate.py`: PASS; 438 тестов ядра, 6 функциональных
|
||||||
|
проверок лабораторий выполнено, 13 штатно пропущено.
|
||||||
|
- `python -m pytest tests/ -q`: 438 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).
|
||||||
523
experiments/lab046_bpsk_object_transport.py
Normal file
523
experiments/lab046_bpsk_object_transport.py
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
"""Lab046: многопакетная передача большого бинарного объекта поверх Lab045.
|
||||||
|
|
||||||
|
Разделены два уровня, они не смешиваются:
|
||||||
|
|
||||||
|
- Уровень 1 (внутри одного радиокадра) — Lab045: свёрточный код R=1/2 K=7,
|
||||||
|
перемежитель, мягкий декодер Витерби, CRC пакета. Исправляет битовые
|
||||||
|
ошибки внутри принятого кадра.
|
||||||
|
- Уровень 2 (большой объект) — существующий ``protocol/image_fragments``:
|
||||||
|
фрагментация, нумерация фрагментов, идентификатор объекта, сборка. Собирает
|
||||||
|
объект из нескольких независимо переданных кадров.
|
||||||
|
|
||||||
|
Новый код здесь минимален: он только связывает эти два готовых уровня и
|
||||||
|
добавляет fail-closed сборщик объекта, который при недостающем или
|
||||||
|
повреждённом фрагменте возвращает ``object_complete = False`` без исключений
|
||||||
|
и без фиктивного объекта. Формат фрагмента не дублируется — используется
|
||||||
|
существующий 12-байтовый заголовок ``image_fragments`` (``>IHHI``:
|
||||||
|
object_id, fragment_index, fragment_count, original_size).
|
||||||
|
|
||||||
|
Раскладка нагрузки пакета (256 байт, как в доказанной точке Lab045):
|
||||||
|
|
||||||
|
[12 байт метаданных фрагмента][244 байта данных фрагмента]
|
||||||
|
|
||||||
|
Последний фрагмент короче: его нагрузка добивается нулями до 256 байт перед
|
||||||
|
Lab045 и обрезается на приёме по длине, вычисляемой из полей заголовка
|
||||||
|
(object size и fragment count). Erasure-код 12+3 здесь НЕ используется — это
|
||||||
|
отдельное будущее расширение для потери целых фрагментов.
|
||||||
|
|
||||||
|
RF в этой лабе не запускается: RF TX = 0, только программная проверка.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from experiments import lab045_hackrf_bpsk_fec as lab045
|
||||||
|
from protocol import image_fragments as fragments
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ Константы Lab046
|
||||||
|
|
||||||
|
PACKET_PAYLOAD_SIZE = lab045.PAYLOAD_SIZE_BYTES # 256, доказанная точка Lab045
|
||||||
|
FRAGMENT_DATA_SIZE = PACKET_PAYLOAD_SIZE - fragments.FRAGMENT_HEADER_SIZE # 244
|
||||||
|
DEFAULT_OBJECT_SIZE = 16384 # 16 KiB, ровный размер, не JPEG
|
||||||
|
DEFAULT_OBJECT_ID = 0x4C303436 # "L046"
|
||||||
|
DEFAULT_OBJECT_SEED = 46
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ Детерминированный объект
|
||||||
|
|
||||||
|
|
||||||
|
def make_deterministic_object(
|
||||||
|
size: int = DEFAULT_OBJECT_SIZE,
|
||||||
|
seed: int = DEFAULT_OBJECT_SEED,
|
||||||
|
) -> bytes:
|
||||||
|
"""Собрать детерминированный псевдослучайный бинарный объект (не JPEG)."""
|
||||||
|
|
||||||
|
if size <= 0:
|
||||||
|
raise ValueError("Размер объекта должен быть положительным")
|
||||||
|
generator = np.random.default_rng(seed)
|
||||||
|
return generator.integers(0, 256, size=size, dtype=np.uint8).tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def object_sha256(data: bytes) -> str:
|
||||||
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ Уровень объекта -> кадры
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FramePlan:
|
||||||
|
"""Один фрагмент объекта, уже упакованный в кадр Lab045."""
|
||||||
|
|
||||||
|
fragment_index: int
|
||||||
|
fragment_count: int
|
||||||
|
encoded_payload_length: int # реальная длина 12+data до добивки нулями
|
||||||
|
plan: lab045.TransmissionPlan
|
||||||
|
|
||||||
|
|
||||||
|
def _pad_payload_to_packet(payload: bytes) -> bytes:
|
||||||
|
if len(payload) > PACKET_PAYLOAD_SIZE:
|
||||||
|
raise ValueError("Нагрузка фрагмента больше размера пакета Lab045")
|
||||||
|
return payload + bytes(PACKET_PAYLOAD_SIZE - len(payload))
|
||||||
|
|
||||||
|
|
||||||
|
def build_object_frames(
|
||||||
|
data: bytes,
|
||||||
|
object_id: int = DEFAULT_OBJECT_ID,
|
||||||
|
) -> list[FramePlan]:
|
||||||
|
"""Разбить объект и упаковать каждый фрагмент в кадр Lab045."""
|
||||||
|
|
||||||
|
object_fragments = fragments.split_image_bytes(
|
||||||
|
data,
|
||||||
|
object_id,
|
||||||
|
fragment_data_size=FRAGMENT_DATA_SIZE,
|
||||||
|
)
|
||||||
|
frame_plans: list[FramePlan] = []
|
||||||
|
for fragment in object_fragments:
|
||||||
|
payload = fragments.encode_image_fragment(fragment)
|
||||||
|
packet_payload = _pad_payload_to_packet(payload)
|
||||||
|
plan = lab045.build_transmission_plan(packet_payload)
|
||||||
|
frame_plans.append(
|
||||||
|
FramePlan(
|
||||||
|
fragment_index=fragment.fragment_index,
|
||||||
|
fragment_count=fragment.fragment_count,
|
||||||
|
encoded_payload_length=len(payload),
|
||||||
|
plan=plan,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return frame_plans
|
||||||
|
|
||||||
|
|
||||||
|
def expected_fragment_data_length(
|
||||||
|
fragment_index: int,
|
||||||
|
fragment_count: int,
|
||||||
|
original_size: int,
|
||||||
|
) -> int | None:
|
||||||
|
"""Длина данных фрагмента, вычисленная только из полей заголовка.
|
||||||
|
|
||||||
|
Возвращает None при несогласованных полях (fail-closed), не бросая.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if fragment_count <= 0 or original_size <= 0:
|
||||||
|
return None
|
||||||
|
if not 0 <= fragment_index < fragment_count:
|
||||||
|
return None
|
||||||
|
full = FRAGMENT_DATA_SIZE
|
||||||
|
# Верхняя граница: объект из fragment_count кусков по full байт не может
|
||||||
|
# быть больше fragment_count*full. Это отсекает абсурдный original_size
|
||||||
|
# для любого индекса и не даёт полю управлять размером аллокаций.
|
||||||
|
if original_size > fragment_count * full:
|
||||||
|
return None
|
||||||
|
if (fragment_count - 1) * full >= original_size and fragment_count > 1:
|
||||||
|
return None
|
||||||
|
if fragment_index < fragment_count - 1:
|
||||||
|
return full
|
||||||
|
last = original_size - (fragment_count - 1) * full
|
||||||
|
if not 1 <= last <= full:
|
||||||
|
return None
|
||||||
|
return last
|
||||||
|
|
||||||
|
|
||||||
|
def recover_fragment_from_payload(packet_payload: bytes) -> fragments.ImageFragment | None:
|
||||||
|
"""Снять добивку и разобрать фрагмент из 256-байтной нагрузки. Fail-closed.
|
||||||
|
|
||||||
|
Использует только принятую нагрузку. Переданный объект неизвестен.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
fragment = fragments.decode_image_fragment(packet_payload)
|
||||||
|
except (fragments.ImageFragmentError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
real_length = expected_fragment_data_length(
|
||||||
|
fragment.fragment_index,
|
||||||
|
fragment.fragment_count,
|
||||||
|
fragment.original_size,
|
||||||
|
)
|
||||||
|
if real_length is None or len(fragment.data) < real_length:
|
||||||
|
return None
|
||||||
|
return replace(fragment, data=fragment.data[:real_length])
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ Fail-closed сборщик
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AssemblerCounters:
|
||||||
|
accepted: int = 0
|
||||||
|
duplicates: int = 0
|
||||||
|
conflicting: int = 0
|
||||||
|
rejected: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectAssembler:
|
||||||
|
"""Собрать объект из независимо принятых фрагментов, ничего не бросая.
|
||||||
|
|
||||||
|
Любой плохой вход (чужой object_id, несогласованный счётчик/размер,
|
||||||
|
невозможный индекс, конфликтующий дубликат, malformed) отвергается и не
|
||||||
|
выходит наружу исключением. Пока не собраны все индексы —
|
||||||
|
``object_complete()`` равен False, ``recovered_object()`` равен None.
|
||||||
|
Фиктивный объект не создаётся.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._object_id: int | None = None
|
||||||
|
self._fragment_count: int | None = None
|
||||||
|
self._original_size: int | None = None
|
||||||
|
self._by_index: dict[int, fragments.ImageFragment] = {}
|
||||||
|
self.counters = AssemblerCounters()
|
||||||
|
|
||||||
|
def offer(self, fragment: fragments.ImageFragment | None) -> str:
|
||||||
|
"""Предложить фрагмент. Вернуть accepted/duplicate/conflicting/rejected."""
|
||||||
|
|
||||||
|
if fragment is None:
|
||||||
|
self.counters.rejected += 1
|
||||||
|
return "rejected"
|
||||||
|
try:
|
||||||
|
index = int(fragment.fragment_index)
|
||||||
|
count = int(fragment.fragment_count)
|
||||||
|
size = int(fragment.original_size)
|
||||||
|
object_id = int(fragment.image_id)
|
||||||
|
if count <= 0 or size <= 0 or not 0 <= index < count:
|
||||||
|
self.counters.rejected += 1
|
||||||
|
return "rejected"
|
||||||
|
if len(fragment.data) < 1:
|
||||||
|
self.counters.rejected += 1
|
||||||
|
return "rejected"
|
||||||
|
if self._object_id is None:
|
||||||
|
self._object_id = object_id
|
||||||
|
self._fragment_count = count
|
||||||
|
self._original_size = size
|
||||||
|
elif (
|
||||||
|
object_id != self._object_id
|
||||||
|
or count != self._fragment_count
|
||||||
|
or size != self._original_size
|
||||||
|
):
|
||||||
|
self.counters.rejected += 1
|
||||||
|
return "rejected"
|
||||||
|
existing = self._by_index.get(index)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.data == fragment.data:
|
||||||
|
self.counters.duplicates += 1
|
||||||
|
return "duplicate"
|
||||||
|
self.counters.conflicting += 1
|
||||||
|
return "conflicting"
|
||||||
|
self._by_index[index] = fragment
|
||||||
|
self.counters.accepted += 1
|
||||||
|
return "accepted"
|
||||||
|
except Exception:
|
||||||
|
self.counters.rejected += 1
|
||||||
|
return "rejected"
|
||||||
|
|
||||||
|
def missing_indexes(self) -> list[int]:
|
||||||
|
if self._fragment_count is None:
|
||||||
|
return []
|
||||||
|
return [i for i in range(self._fragment_count) if i not in self._by_index]
|
||||||
|
|
||||||
|
def object_complete(self) -> bool:
|
||||||
|
return (
|
||||||
|
self._fragment_count is not None
|
||||||
|
and len(self._by_index) == self._fragment_count
|
||||||
|
and not self.missing_indexes()
|
||||||
|
)
|
||||||
|
|
||||||
|
def recovered_object(self) -> bytes | None:
|
||||||
|
"""Собрать объект, только если он полон. Иначе None. Не бросает."""
|
||||||
|
|
||||||
|
if not self.object_complete():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return fragments.reassemble_image(list(self._by_index.values()))
|
||||||
|
except fragments.ImageFragmentError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ Канал (программный)
|
||||||
|
|
||||||
|
|
||||||
|
ChannelFunction = Callable[[lab045.TransmissionPlan, int], np.ndarray]
|
||||||
|
|
||||||
|
|
||||||
|
def ideal_channel(plan: lab045.TransmissionPlan, frame_index: int) -> np.ndarray:
|
||||||
|
del frame_index
|
||||||
|
return lab045.modulate_bits_to_ideal_soft(plan.post_preamble_bits)
|
||||||
|
|
||||||
|
|
||||||
|
def make_noisy_channel(
|
||||||
|
noise_sigma: float,
|
||||||
|
base_seed: int,
|
||||||
|
) -> ChannelFunction:
|
||||||
|
"""Детерминированный гауссов канал на уровне soft BPSK-символов.
|
||||||
|
|
||||||
|
Гауссов шум реалистичнее жёстких переворотов знака и сохраняет мягкую
|
||||||
|
информацию, которую использует декодер Витерби. Каждый кадр получает свой
|
||||||
|
воспроизводимый seed ``base_seed + frame_index``. Уровень sigma выбран
|
||||||
|
так, чтобы soft Viterbi исправлял все кадры (см. docs/lab046_current_state):
|
||||||
|
при большем sigma отдельный кадр может не декодироваться, и без erasure-
|
||||||
|
FEC объект окажется неполным — это отдельный сценарий, а не подгон.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def channel(plan: lab045.TransmissionPlan, frame_index: int) -> np.ndarray:
|
||||||
|
return lab045.simulate_soft_channel(
|
||||||
|
plan,
|
||||||
|
noise_sigma=noise_sigma,
|
||||||
|
seed=base_seed + frame_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ Прогон объекта
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ObjectTransportResult:
|
||||||
|
object_size: int
|
||||||
|
object_sha256: str
|
||||||
|
fragment_count: int
|
||||||
|
protocol_packet_bytes: int
|
||||||
|
bpsk_frame_count: int
|
||||||
|
total_bpsk_symbols: int
|
||||||
|
total_waveform_samples: int
|
||||||
|
airtime_seconds: float
|
||||||
|
crc_pass_count: int
|
||||||
|
crc_fail_count: int
|
||||||
|
pre_fec_coded_bit_errors: int
|
||||||
|
post_fec_protocol_bit_errors: int
|
||||||
|
valid_fragments: int
|
||||||
|
duplicates: int
|
||||||
|
conflicting: int
|
||||||
|
rejected: int
|
||||||
|
missing_indexes: list[int]
|
||||||
|
object_complete: bool
|
||||||
|
recovered_object_sha256: str | None
|
||||||
|
object_byte_errors: int | None
|
||||||
|
sha_match: bool
|
||||||
|
|
||||||
|
|
||||||
|
def run_object_transport(
|
||||||
|
data: bytes,
|
||||||
|
object_id: int = DEFAULT_OBJECT_ID,
|
||||||
|
channel: ChannelFunction = ideal_channel,
|
||||||
|
delivery_order: list[int] | None = None,
|
||||||
|
duplicate_indexes: tuple[int, ...] = (),
|
||||||
|
drop_indexes: tuple[int, ...] = (),
|
||||||
|
corrupt_indexes: tuple[int, ...] = (),
|
||||||
|
) -> ObjectTransportResult:
|
||||||
|
"""Полный программный тракт объект -> кадры -> канал -> приём -> сборка.
|
||||||
|
|
||||||
|
Приёмник независим: TX-объект и TX-биты не используются для решений о
|
||||||
|
приёме, FEC, метаданных или завершении объекта. Диагностическое сравнение
|
||||||
|
с оригиналом делается только после независимой сборки.
|
||||||
|
"""
|
||||||
|
|
||||||
|
frame_plans = build_object_frames(data, object_id)
|
||||||
|
frame_by_index = {frame.fragment_index: frame for frame in frame_plans}
|
||||||
|
|
||||||
|
order = delivery_order if delivery_order is not None else [f.fragment_index for f in frame_plans]
|
||||||
|
|
||||||
|
assembler = ObjectAssembler()
|
||||||
|
crc_pass = 0
|
||||||
|
crc_fail = 0
|
||||||
|
pre_fec_errors = 0
|
||||||
|
post_fec_errors = 0
|
||||||
|
|
||||||
|
def deliver(frame_index: int, corrupt: bool) -> None:
|
||||||
|
nonlocal crc_pass, crc_fail, pre_fec_errors, post_fec_errors
|
||||||
|
frame = frame_by_index[frame_index]
|
||||||
|
soft = channel(frame.plan, frame_index)
|
||||||
|
if corrupt:
|
||||||
|
# Детерминированно инвертируем весь coded-поток (после защищённого
|
||||||
|
# PHY header): CRC гарантированно не пройдёт, пакет отвергается.
|
||||||
|
soft = soft.copy()
|
||||||
|
soft[lab045.PHY_HEADER_BIT_COUNT:] = -soft[lab045.PHY_HEADER_BIT_COUNT:]
|
||||||
|
reception = lab045.analyze_post_preamble_soft(soft)
|
||||||
|
# Диагностика pre/post-FEC только после независимого CRC-решения.
|
||||||
|
diagnostic = lab045.diagnostic_pre_and_post_fec(reception, soft, frame.plan)
|
||||||
|
pre = diagnostic["pre_fec_coded"]["bit_errors"]
|
||||||
|
post = diagnostic["post_fec_packet"]["bit_errors"]
|
||||||
|
if pre is not None:
|
||||||
|
pre_fec_errors += pre
|
||||||
|
if post is not None:
|
||||||
|
post_fec_errors += post
|
||||||
|
if reception.analysis["crc"] == lab045.CRC_PASS and reception.payload_bytes is not None:
|
||||||
|
crc_pass += 1
|
||||||
|
fragment = recover_fragment_from_payload(reception.payload_bytes)
|
||||||
|
assembler.offer(fragment)
|
||||||
|
else:
|
||||||
|
crc_fail += 1
|
||||||
|
|
||||||
|
for frame_index in order:
|
||||||
|
deliver(frame_index, corrupt=frame_index in corrupt_indexes)
|
||||||
|
for frame_index in duplicate_indexes:
|
||||||
|
deliver(frame_index, corrupt=False)
|
||||||
|
for frame_index in drop_indexes:
|
||||||
|
assembler._by_index.pop(frame_index, None)
|
||||||
|
|
||||||
|
recovered = assembler.recovered_object()
|
||||||
|
recovered_sha = object_sha256(recovered) if recovered is not None else None
|
||||||
|
original_sha = object_sha256(data)
|
||||||
|
if recovered is not None and len(recovered) == len(data):
|
||||||
|
byte_errors = int(np.count_nonzero(
|
||||||
|
np.frombuffer(recovered, dtype=np.uint8) != np.frombuffer(data, dtype=np.uint8)
|
||||||
|
))
|
||||||
|
elif recovered is not None:
|
||||||
|
byte_errors = max(len(recovered), len(data))
|
||||||
|
else:
|
||||||
|
byte_errors = None
|
||||||
|
|
||||||
|
total_symbols = sum(f.plan.frame_symbol_count for f in frame_plans)
|
||||||
|
total_samples = sum(f.plan.waveform_sample_count for f in frame_plans)
|
||||||
|
airtime = sum(f.plan.airtime_seconds for f in frame_plans)
|
||||||
|
|
||||||
|
return ObjectTransportResult(
|
||||||
|
object_size=len(data),
|
||||||
|
object_sha256=original_sha,
|
||||||
|
fragment_count=len(frame_plans),
|
||||||
|
protocol_packet_bytes=len(frame_plans[0].plan.packet),
|
||||||
|
bpsk_frame_count=len(frame_plans),
|
||||||
|
total_bpsk_symbols=total_symbols,
|
||||||
|
total_waveform_samples=total_samples,
|
||||||
|
airtime_seconds=airtime,
|
||||||
|
crc_pass_count=crc_pass,
|
||||||
|
crc_fail_count=crc_fail,
|
||||||
|
pre_fec_coded_bit_errors=pre_fec_errors,
|
||||||
|
post_fec_protocol_bit_errors=post_fec_errors,
|
||||||
|
valid_fragments=assembler.counters.accepted,
|
||||||
|
duplicates=assembler.counters.duplicates,
|
||||||
|
conflicting=assembler.counters.conflicting,
|
||||||
|
rejected=assembler.counters.rejected,
|
||||||
|
missing_indexes=assembler.missing_indexes(),
|
||||||
|
object_complete=assembler.object_complete(),
|
||||||
|
recovered_object_sha256=recovered_sha,
|
||||||
|
object_byte_errors=byte_errors,
|
||||||
|
sha_match=(recovered_sha == original_sha and recovered is not None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ Airtime / goodput
|
||||||
|
|
||||||
|
|
||||||
|
def goodput_report(result: ObjectTransportResult) -> dict:
|
||||||
|
"""Честная baseline-оценка эфирного времени и полезной пропускной способности."""
|
||||||
|
|
||||||
|
airtime = result.airtime_seconds
|
||||||
|
object_bytes = result.object_size
|
||||||
|
goodput_bytes_per_second = object_bytes / airtime if airtime > 0 else 0.0
|
||||||
|
goodput_kbit_per_second = goodput_bytes_per_second * 8 / 1000.0
|
||||||
|
useful_data_bits = object_bytes * 8
|
||||||
|
total_symbols = result.total_bpsk_symbols
|
||||||
|
return {
|
||||||
|
"airtime_seconds": airtime,
|
||||||
|
"goodput_bytes_per_second": goodput_bytes_per_second,
|
||||||
|
"goodput_kbit_per_second": goodput_kbit_per_second,
|
||||||
|
"useful_data_bits": useful_data_bits,
|
||||||
|
"total_bpsk_symbols": total_symbols,
|
||||||
|
"useful_bits_per_symbol": useful_data_bits / total_symbols if total_symbols else 0.0,
|
||||||
|
"overhead": {
|
||||||
|
"fragment_metadata_bytes_per_packet": fragments.FRAGMENT_HEADER_SIZE,
|
||||||
|
"protocol_header_plus_crc_bytes_per_packet": lab045.HEADER_SIZE + lab045.CRC_SIZE,
|
||||||
|
"convolutional_rate": "1/2",
|
||||||
|
"phy_preamble_bits_per_frame": lab045.PREAMBLE_BIT_COUNT,
|
||||||
|
"protected_phy_header_bits_per_frame": lab045.PHY_HEADER_BIT_COUNT,
|
||||||
|
"guard_plus_rrc_symbol_times_per_frame": 2 * lab045.GUARD_SYMBOL_COUNT + lab045.RRC_SPAN_SYMBOLS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ Функциональные тесты
|
||||||
|
|
||||||
|
|
||||||
|
def run_functional_tests() -> None:
|
||||||
|
"""Быстрые самопроверки без данных эксперимента (вызывается quick_gate)."""
|
||||||
|
|
||||||
|
obj = make_deterministic_object(2048, seed=1)
|
||||||
|
ideal = run_object_transport(obj, channel=ideal_channel)
|
||||||
|
assert ideal.object_complete and ideal.sha_match and ideal.object_byte_errors == 0
|
||||||
|
assert ideal.crc_fail_count == 0
|
||||||
|
|
||||||
|
frames = build_object_frames(obj)
|
||||||
|
assert frames[0].plan.frame_symbol_count == 5724
|
||||||
|
assert all(len(f.plan.packet) == 268 for f in frames)
|
||||||
|
|
||||||
|
# Fragment round trip через существующий слой без канала.
|
||||||
|
fs = fragments.split_image_bytes(obj, DEFAULT_OBJECT_ID, FRAGMENT_DATA_SIZE)
|
||||||
|
rebuilt = fragments.reassemble_image(fs)
|
||||||
|
assert rebuilt == obj
|
||||||
|
|
||||||
|
# Последний короткий фрагмент восстанавливается по длине из заголовка.
|
||||||
|
last = fs[-1]
|
||||||
|
padded = _pad_payload_to_packet(fragments.encode_image_fragment(last))
|
||||||
|
recovered = recover_fragment_from_payload(padded)
|
||||||
|
assert recovered is not None and recovered.data == last.data
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ main
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Lab046 multi-packet object transport (software)")
|
||||||
|
parser.add_argument("--object-size", type=int, default=DEFAULT_OBJECT_SIZE)
|
||||||
|
parser.add_argument("--seed", type=int, default=DEFAULT_OBJECT_SEED)
|
||||||
|
parser.add_argument("--sigma", type=float, default=0.40)
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
|
||||||
|
obj = make_deterministic_object(arguments.object_size, arguments.seed)
|
||||||
|
ideal = run_object_transport(obj, channel=ideal_channel)
|
||||||
|
noisy = run_object_transport(
|
||||||
|
obj,
|
||||||
|
channel=make_noisy_channel(arguments.sigma, base_seed=46000),
|
||||||
|
)
|
||||||
|
report = goodput_report(ideal)
|
||||||
|
|
||||||
|
print("Lab046 software object transport")
|
||||||
|
print(f"object size = {ideal.object_size} bytes")
|
||||||
|
print(f"object sha256 = {ideal.object_sha256}")
|
||||||
|
print(f"fragment data size = {FRAGMENT_DATA_SIZE} bytes")
|
||||||
|
print(f"fragment count = {ideal.fragment_count}")
|
||||||
|
print(f"protocol packet = {ideal.protocol_packet_bytes} bytes")
|
||||||
|
print(f"bpsk frames = {ideal.bpsk_frame_count}")
|
||||||
|
print(f"total bpsk symbols = {ideal.total_bpsk_symbols}")
|
||||||
|
print(f"total waveform samples = {ideal.total_waveform_samples}")
|
||||||
|
print(f"estimated airtime = {report['airtime_seconds']:.2f} s")
|
||||||
|
print(f"goodput = {report['goodput_bytes_per_second']:.1f} bytes/s = {report['goodput_kbit_per_second']:.2f} kbit/s")
|
||||||
|
print("--- ideal ---")
|
||||||
|
print(f"crc pass/fail = {ideal.crc_pass_count}/{ideal.crc_fail_count}")
|
||||||
|
print(f"object complete = {ideal.object_complete}, sha match = {ideal.sha_match}, byte errors = {ideal.object_byte_errors}")
|
||||||
|
print(f"--- noisy (gaussian sigma={arguments.sigma}) ---")
|
||||||
|
print(f"pre-FEC coded errors = {noisy.pre_fec_coded_bit_errors}")
|
||||||
|
print(f"post-FEC protocol errors = {noisy.post_fec_protocol_bit_errors}")
|
||||||
|
print(f"crc pass/fail = {noisy.crc_pass_count}/{noisy.crc_fail_count}")
|
||||||
|
print(f"object complete = {noisy.object_complete}, sha match = {noisy.sha_match}, byte errors = {noisy.object_byte_errors}")
|
||||||
|
print("RF TX = 0 (software-only)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
245
tests/test_lab046_bpsk_object_transport.py
Normal file
245
tests/test_lab046_bpsk_object_transport.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"""Быстрые проверки Lab046: фрагментация большого объекта и fail-closed сборка.
|
||||||
|
|
||||||
|
Тесты не трогают оборудование, не создают файлов и не запускают RF. Полный
|
||||||
|
тракт PHY (кадр Lab045 -> канал -> приём -> сборка) проверяется на маленьких
|
||||||
|
объектах, чтобы оставаться быстрым.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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()
|
||||||
Reference in New Issue
Block a user