6 Commits

Author SHA1 Message Date
LittleSam129
b0111d2232 Docs: add laboratory index and result pointers 2026-09-02 16:19:27 +03:00
LittleSam129
52197a4d85 Lab046C: validate full 1400 MHz RF object transport 2026-09-02 16:18:40 +03:00
LittleSam129
d985030f04 Lab046C: recover short 1400 MHz capture offline 2026-09-02 12:23:14 +03:00
LittleSam129
9b906c622d Lab046C: analyze weak RF capture offline 2026-09-01 17:09:27 +03:00
LittleSam129
b6654671b4 Lab046C: record first continuous RF object attempt 2026-09-01 15:42:00 +03:00
LittleSam129
39fa4b139d Lab046B: validate continuous raw IQ frame reception 2026-09-01 14:36:51 +03:00
13 changed files with 5412 additions and 12 deletions

View File

@@ -749,3 +749,351 @@ Verifier read-only нашёл реальный дефект первого ва
## Следующий этап
Сначала ChatGPT смотрит Lab046A software result. После отдельного явного разрешения можно выбирать следующий шаг: аппаратная проверка многокадровой передачи, оптимизация накладных расходов серии кадров или erasure-FEC для потери целых фрагментов. RF TX, QPSK, JPEG и video автоматически не запускать.
# Запись 027
## Дата
1 сентября 2026 года
## Тема
Lab046B: программная проверка нескольких полных Lab045-кадров в одном непрерывном сыром IQ-потоке, RF TX = 0, аппаратная проверка не выполнялась.
## Выполнено
Статус: **ПРОГРАММНАЯ ПРОВЕРКА НЕПРЕРЫВНОГО СЫРОГО IQ-ПОТОКА = PASS. RF TX = 0. АППАРАТНАЯ ПРОВЕРКА = НЕ ВЫПОЛНЯЛАСЬ.** HackRF, hackrf_transfer, common-clock операции, RF RX/TX, JPEG, video, QPSK, erasure FEC и ARQ не запускались.
Добавлен software-only эксперимент `experiments/lab046b_continuous_iq.py`. Пять кадров формируются существующим Lab045: protocol packet, convolutional FEC R=1/2 K=7, interleaver, защищённый PHY header, PRBS7, BPSK, RRC и guards. Они размещаются в одном `complex64` IQ stream. Приёмный `scan_continuous_iq` получает только IQ и известные PHY/FEC параметры; TX offsets, длины waveform, payload, packets и coded bits в его API отсутствуют. TX reference используется лишь в отдельной диагностике после независимого CRC PASS.
Первичный поиск переиспользует PRBS7 reference и normalized correlation Lab043/044, но не применяет глобальный mandatory peak/second gate. Все локальные candidates рассматриваются по положению. Для каждого кандидата локально переиспользуются refine start, matched filter, preamble-only timing, CFO, phase/polarity и `normalized.real`; затем Lab045 выполняет header-driven deinterleave, soft Viterbi и CRC. Cursor монотонен, accepted frame повторно не декодируется, rejected candidate не останавливает поиск, `MAX_FRAMES` и `MAX_SEARCH_ITERATIONS` ограничивают работу.
Фактический идеальный stream: 5 waveform, leading 137 samples, gaps 211/353/587/809, trailing 233; всего 11 534 330 `complex64` samples = 92 274 640 bytes (88.0 MiB). PRBS7 candidates 32, processed 5, accepted 5, rejected 0, PHY header PASS 5, FEC PASS 5, CRC 5/0. Все обнаруженные sample offsets совпали с TX при постфактум-сравнении; sampling phase modulo 400 = 137/348/301/88/97. Peak/second настоящих кадров около 0 dB, что воспроизводимо показывает непригодность старого глобального критерия для многокадрового решения.
Сценарий CFO/phase/amplitude: внесено 0.2 Hz, 0.7 rad и scale 0.65 без передачи этих истинных значений scanner-у. Медианная независимая оценка 0.200003768 Hz и 0.699634288 rad, полярность восстановлена по PRBS7, CRC 5/0. Комплексный AWGN: seed 46002, финальная sigma=0.17, accepted 5, pre-FEC 0, post-FEC 0, CRC 5/0. Начальная разумная sigma=0.12 также дала 0 pre-FEC ошибок; после одной разрешённой корректировки дальнейшего подбора не было. Результат шума фиксируется честно как успешный IQ receive без нового события FEC correction.
Ложный IQ-кандидат содержит настоящую PRBS7 waveform и неверный protected PHY magic. Получено candidates 11, processed 8, rejected 7; поиск продолжился и следующий настоящий кадр прошёл PHY/FEC/CRC. Добавлены 14 тестов: два и пять реальных IQ-кадров, arbitrary/non-grid start, разные gaps, ровно один приём кадра, tail background/noise, malformed candidate, продолжение поиска, отсутствие TX-oracle API, CFO/phase, deterministic complex noise, monotonic cursor, оба safety limits и регрессия нескольких сильных настоящих пиков.
Независимый read-only verifier подтвердил тракт, отсутствие TX oracle, multi-peak решение, ложный кандидат, численные CFO/phase и размер основного массива; 88.0 MiB относится именно к main IQ array, не к полному peak RSS с FFT/RRC workspace. Software gate: `python tools/quick_gate.py` PASS (468 тестов, 7 функциональных лаб, 13 штатно пропущено); `python -m pytest tests/ -q` = 468 passed; `git diff --check` чист, кроме информационных LF/CRLF notices. Документация обновлена в `docs/lab046_current_state.md`. Git add/commit/push не выполнялись.
## Следующий этап
Сначала ChatGPT анализирует Lab046B. Аппаратную Lab046, JPEG, video, QPSK, 12+3 erasure FEC, ARQ и адаптивную модуляцию автоматически не начинать.
# Запись 028
## Дата
1 сентября 2026 года
## Тема
Подготовка одного непрерывного аппаратного BPSK-сеанса Lab046C без RF-передачи.
## Выполнено
Проведён аудит фактических Lab043--Lab046B. Новый радиопротокол не создавался:
Lab046C переиспользует общий clock-gate и однократный TX guard Lab043--Lab045,
физический FEC/CRC-кадр Lab045, фрагментацию и `ObjectAssembler` Lab046,
локальный многокадровый IQ-поиск Lab046B.
Подготовлен `experiments/lab046c_hackrf_bpsk_object.py`. Он создаёт новый
криптографически случайный объект 16384 байта, последовательно формирует один
cs8-файл из 68 waveform без искусственных gaps и фиксирует SHA-256. Расчёт по
фактическому коду: 2 306 400 samples на кадр, 156 835 200 samples и
313 670 400 bytes всего, 19,6044 с при 8 MS/s.
Будущий приём запланирован на 24,0 с: 192 000 000 cs8 samples и
384 000 000 bytes. Файл читается через memmap окнами в `complex64`; RX API не
принимает TX offsets, длины waveform, ожидаемые 68 фрагментов, TX packets,
coded bits или объект. Завершённость определяется только принятыми метаданными
и `ObjectAssembler` после CRC.
Аппаратная последовательность fail-closed: фиксированные MASTER/SLAVE serial,
common-clock gate, RX раньше TX, ровно один вызов guard перед одним TX process,
cleanup CLKOUT/processes в `finally`, без retry. Отдельный `--preflight-only`
не может выполнить TX. Реальный аппаратный Lab046C не запускался, `RF TX = 0`.
Фактически создан внешний каталог
`C:\Users\user\Documents\SDR_Rover_Lab046_raw\20260901_145339_bpsk_object`.
SHA-256 нового `tx_object.bin`:
`f7568a5d1a6dc5dd907fa1e971a171578315de740bd6788136f447e409fc6bf8`.
SHA-256 единого `tx_stream.cs8`:
`8941eb231f1f2f99cde51f3cc5426472dbedf1a61675543e7774faec4b935f5e`.
Quick gate прошёл 482 теста и 8 функциональных лаб, полный pytest дал
482 passed. Безопасный common-clock preflight без RF TX подтвердил MASTER и
SLAVE reg29 `0x26`, точный SLAVE CLKIN `DETECTED` за 141,51 мс; cleanup
выключил CLKOUT обеих плат, оставшихся `hackrf_transfer` нет. RF TX count = 0.
Следующий этап: после успешных software gates и безопасного common-clock
preflight остановиться и запросить точную отдельную авторизацию на один
непрерывный RF TX Lab046C; без неё передачу не выполнять.
# Запись 029
## Дата
1 сентября 2026 года
## Тема
Первый непрерывный аппаратный опыт Lab046C: официальный RESULT C.
## Выполнено
После отдельной точной авторизации выполнен ровно один непрерывный RF TX
Lab046C без повтора. Передан единый `tx_stream.cs8`: 68 полных Lab045 waveform,
156 835 200 complex samples, 313 670 400 bytes, 19,6044 с при 8 MS/s. MASTER/TX
`000000000000000097c063dc323b97d3`, SLAVE/RX
`000000000000000097c063dc2e5327d3`, RF 1080 МГц, TX VGA 10 дБ, RX LNA/VGA
24/24 дБ, RF amp и bias OFF.
Common-clock gate прошёл: MASTER reg29 `0x26`, SLAVE CLKIN `DETECTED`, SLAVE
reg29 `0x26`. Фактический TX длился 19,60632 с и полностью находился внутри RX
capture 24,00386 с. TX underruns 0, RX overruns 0; cleanup выключил CLKOUT обеих
плат и не оставил процессов `hackrf_transfer`. `RF TX COUNT = 1`.
Независимый многокадровый приёмник не использовал TX offsets, packets, coded
bits, ожидаемые 68 фрагментов или исходный объект. Его официальный результат:
**LAB046C HARDWARE RESULT = C, PREAMBLE CANDIDATES = 0, VALID FRAMES = 0,
PHY HEADER PASS = 0, CRC PASS = 0, OBJECT COMPLETE = NO.** Восстановленный
объект не создавался. Рабочий порог PRBS7 оставался `0.65`, независимый максимум
равен `0.126562`; detector, gain, PHY, FEC и Витерби после опыта не менялись.
TX-файл независимо проверен: SHA-256
`8941eb231f1f2f99cde51f3cc5426472dbedf1a61675543e7774faec4b935f5e`, RMS
44,002775 code (`-9,274598 dBFS`), peak 70,405966 code, BPSK около +50 кГц.
Первый, средний и последний кадры проходят PRBS7, PHY header, FEC и CRC прямо
из файла; амплитуда практически идентична успешному Lab045. Дефект TX-файла
или runner, объясняющий RESULT C, не найден.
Только после фиксации независимого RESULT C выполнена read-only диагностика с
TX reference. Обнаружены следы 68/68 waveform с точным периодом 2 306 400
samples: aligned min/median/max `0.065784/0.089991/0.122379`, смещённый контроль
median/max `0.004275/0.010075`, медианное преимущество 26,46 дБ. Все 68 aligned
значений выше максимума контроля. Это доказывает слабое присутствие TX в RX,
но не меняет официальный исход: **POST-RUN TX-REFERENCE DIAGNOSTIC = 68/68
WAVEFORM TRACES DETECTED; OFFICIAL RESULT CHANGED = NO.** OBJECT TRANSPORT PASS
и LAB046C COMPLETE SUCCESS не заявляются.
Перед фиксацией результата повторно пройдены software gates: `python
tools/quick_gate.py` PASS, `python -m pytest tests/ -q` = 482 passed,
`git diff --check` чист, кроме информационных LF/CRLF notices.
Следующий этап определяется отдельной постановкой после анализа RESULT C;
повторный RF TX, изменение PRBS7 threshold, gain или detector автоматически не
разрешены.
# Запись 030
## Дата
1 сентября 2026 года
## Тема
Lab046C: автономная слабосигнальная обработка исходного RX capture, offline
результат FAIL без нового RF.
## Выполнено
Исходный аппаратный результат сохранён без изменения: **ORIGINAL LAB046C
HARDWARE RESULT = C, RF TX COUNT = 1, ORIGINAL HARDWARE RESULT CHANGED = NO.**
HackRF не запускался, RF TX в этом этапе равен 0, второй аппаратный или offline
независимый прогон не выполнялся.
Добавлен отдельный `experiments/lab046c_offline_recovery.py`. Исторический
Lab046B threshold `0.65` и поведение Lab045 не менялись. Новый первый каскад
зафиксирован до полного прогона только по фону первых 0,5 с: 99,9-й процентиль
локальных PRBS7-максимумов, threshold `0.025011632591485977`, median
`0.00838765874505043`, MAD `0.003039387986063957`, 5193 peaks, 6 candidates,
12,0 candidates/s. На независимом контрольном фоне 21--24 с уже фиксированное
правило дало 35501 peak, 301 candidate, 100,333333 candidates/s; после этого
порог не изменялся.
Выполнен ровно один полный independent pass сохранённых 192 000 000 cs8
samples. API не получал TX stream/object, offsets, ожидаемые 68, fragment order,
packets или coded bits. Coarse stage: 291446 local peaks и 3025 candidates.
Strict stage обработал все 3025: refinement/timing/carrier PASS 3025,
PHY HEADER PASS 0, PHY HEADER FAIL 3025 (`MAGIC_MISMATCH`), FEC attempts 0,
CRC 0/0, valid frames 0. `VALID FRAGMENTS = 0`, `OBJECT COMPLETE = NO`,
`recovered_object.bin` не создан. **OFFLINE REPROCESSING OF ORIGINAL RX CAPTURE
= FAIL; OBJECT RECOVERED = NO.**
Только после независимого verdict выполнена TX-reference диагностика. Все 68
реальных стартов имели candidate в пределах +-40 samples; корреляция
min/median/max `0.059379209/0.088661019/0.126557365`, все выше фиксированного
threshold. Однако preamble errors min/median/max `341/430/554` из 1280,
PHY-header errors `30/54/81` из 144, SNR
`-19.282130/-12.829480/-9.195846 dB`. Все 68 диагностических заголовков дали
MAGIC_MISMATCH. `FALSE CRC PASS = 0`, independently valid frames matching TX =
0, missed TX frames = 68. Ни один пропущенный кадр не добавлялся обратно в
`ObjectAssembler`.
Производительность: background calibration 0,269617 с, control 1,031204 с,
coarse search 7,721316 с, strict processing 836,340048 с, среднее 0,276476 с на
кандидат, полный прогон 845,427589 с, то есть 35,226150 длительности 24-секундного
capture. RX использован как memmap, coarse block 16 000 000 complex64 samples;
консервативная оценка временной памяти 1 545 705 764 байта, strict local window
не более 594400 samples / 4 755 200 байт. Полный complex64 capture в память не
загружался.
Добавлены 24 теста statistical calibration/control, deterministic weak PRBS7,
CRC-only acceptance, ошибок magic/version/checksum/length, FEC/CRC fail,
block-boundary ownership, monotonic cursor, safety limits, отсутствия TX oracle
и регрессии Lab045/Lab046B. До полного прогона verifier нашёл и помог закрыть
fail-open status safety-limit и заниженную оценку памяти; повторная проверка
дала чистый pre-run verdict.
Финальный software gate после фиксации offline FAIL: `python
tools/quick_gate.py` PASS, 506 проверок и 9 функциональных лабораторий;
`python -m pytest tests/ -q` = 506 passed. Предупреждение `.pytest_cache` не
повлияло на результат. `git diff --check` чист, кроме информационных LF/CRLF
notices. Финальный read-only verifier независимо сверил JSON, документацию,
68/68 post-verdict mapping, счётчики, времена и память; научных расхождений не
найдено.
## Следующий этап
Сначала анализируется честный offline FAIL. Порог этого independent pass не
менять задним числом и второй улучшенный pass автоматически не запускать.
Повторный RF TX, gain ladder, JPEG, video, QPSK, erasure FEC и ARQ без новой
отдельной постановки запрещены.
# Запись 031
## Дата
2 сентября 2026 года
## Тема
Lab046C: независимая повторная обработка короткой записи 1400 МГц с отдельным
статистическим допуском кандидатов, без нового RF.
## Выполнено
Использована сохранённая запись
`20260901_172749_1400MHz/rx_1400mhz.cs8`: 19 200 000 комплексных отсчётов cs8,
2,4 с при 8 МГц, SHA-256
`06ab0867865bca462a21c42c9f3bc7e519e336108f7b5e98e7b283f842387a99`.
HackRF не запускался, `RF TX = 0`. Исторический порог
`IQ_PREAMBLE_THRESHOLD = 0.65`, Lab045, FEC и PHY не изменялись. Исходный
аппаратный `CONTROL RESULT = C` сохранён без переписывания.
До полного независимого декодирования зафиксировано отдельное правило первого
каскада только по фону до передачи `0,00 ... 0,50 с`:
`pre-TX local-peak Q99.9 + 4 * local-peak MAD`. Q99.9 равен
`0.022788675501942635`, MAD равен `0.0027075146790593863`, итоговый порог равен
`0.03361873421818018`. На независимом контрольном фоне после передачи
`1,60 ... 2,40 с` максимум равен `0.02711654081940651`, кандидатов 0. Порог
после просмотра результата не менялся.
Перед полным проходом независимая проверка выявила небезопасную при отказе ошибку общей точки
приёма: противоречивый результат `accepted=True, CRC_FAIL` мог увеличить
`VALID_FRAMES`. Добавлена обязательная повторная проверка `CRC PASS`; сценарий
после исправления даёт `VALID_FRAMES = 0`. Независимая повторная проверка
признала исправление чистым.
Выполнен ровно один полный проход всей записи без смещений TX, ожидаемого числа
кадров, сигнала TX, пакетов, полезной нагрузки и кодированных битов. Получено
29 205 локальных максимумов и 1 279 кандидатов первого каскада. Строгий каскад
обработал 28 кандидатов: PHY-заголовок PASS/FAIL `3/25`, FEC attempts/PASS/FAIL
`3/3/0`, CRC PASS/FAIL `3/0`, `VALID FRAMES = 3`. Поиск завершился по концу
записи и списка кандидатов; ограничения безопасности не сработали.
Независимый результат записан до открытия опорных данных передатчика. Только
после этого три действительных кадра сопоставлены с тремя переданными:
пропущено 0, `FALSE CRC PASS = 0`, ошибки положения `0/-1/+2` отсчёта. Ошибки
до FEC составили `17 + 19 + 29 = 65` бит, после FEC 0 бит, в полезной нагрузке
0 байт и 0 бит. Независимая проверка готовых JSON и трёх пар полезной
нагрузки/пакета
расхождений не выявила. `1400 MHz OFFLINE REPROCESSING = PASS`,
`RF PATH ASSESSMENT = HEALTHY`.
Итоговый программный шлюз перед полным проходом: `python tools/quick_gate.py`
код 0 и 512 проверок; `python -m pytest tests/ -q` = 512 passed;
`git diff --check` код 0 с информационными уведомлениями LF/CRLF. Git add,
commit и push не выполнялись.
Подробный отчёт и машинно-читаемые результаты сохранены в
`docs/lab046c_1400mhz_offline_reprocessing.md` и
`data/processed/lab046c_1400mhz_offline/`.
## Следующий этап
STOP. Новый RF, повтор Lab046C, изменение порога, JPEG, QPSK и изменение FEC
без отдельной постановки запрещены.
# Запись 032
## Дата
2 сентября 2026 года
## Тема
Lab046C: успешная полная передача объекта 16384 байта через аппаратный
радиоканал 1400 МГц, RESULT A.
## Выполнено
После отдельной точной авторизации выполнен один непрерывный процесс передачи
без повтора. Новый криптографически случайный объект 16384 байта разбит
существующей архитектурой Lab046 на 68 фрагментов и передан 68
последовательными защищёнными BPSK-кадрами. RF равна 1400 МГц, частота
дискретизации 8 МГц, символьная скорость 20 ксимв/с, цифровое смещение несущей
+50 кГц. Усиления сохранены: TX VGA 10 дБ, RX LNA/VGA 24/24 дБ, RF amplifier
и Bias-Tee выключены. MASTER reg29 `0x26`, SLAVE CLKIN `DETECTED`, SLAVE reg29
`0x26`; общий опорный генератор прошёл проверку.
Поток передачи содержит 156 835 200 комплексных отсчётов, 313 670 400 байт и
19,6044 с сигнала. Приём содержит 192 000 000 комплексных отсчётов,
384 000 000 байт и 24,0 с сигнала. Приём начался на 0,7525116 с раньше
передачи и закончился на 3,6341200 с позже. RX overruns 0, TX underruns 0,
clipping 0, near limit 0. После опыта оба CLKOUT выключены, оставшихся
процессов `hackrf_transfer` нет.
До полного декодирования по фону до передачи зафиксирован порог первого
каскада `Q99.9 + 4 * MAD`: Q99.9 `0.023181289434432983`, MAD
`0.0025865877978503704`, threshold `0.033527640625834465`. На независимом фоне
после передачи maximum `0.030997175723314285`, кандидатов 0. Порог после
начала декодирования не менялся. Исторический строгий
`IQ_PREAMBLE_THRESHOLD = 0.65`, Lab045, PHY и FEC не менялись.
Независимый приёмник обработал весь 24-секундный файл без смещений и периода
кадров передатчика, ожидаемого числа 68, пакетов, кодированных битов и
исходного объекта. Получено 295469 локальных максимумов, 35009 кандидатов
первого каскада и 95 обработанных неперекрывающихся гипотез. PHY-заголовок
PASS/FAIL `68/27`, FEC attempts/PASS/FAIL `68/68/0`, CRC PASS/FAIL `68/0`,
действительных кадров 68. Поиск завершился по концу записи и списка
кандидатов, а не по известному количеству кадров.
До открытия опорных данных передатчика `ObjectAssembler` независимо принял 68
уникальных фрагментов без дубликатов, конфликтов и пропусков: **OBJECT
COMPLETE = YES**. Только после фиксации этого вердикта выполнено сравнение с
передатчиком. Совпали 68/68 кадров, пропущено 0, ложных CRC PASS 0. До FEC
обнаружено 787 ошибок кодированных бит, после FEC 0 ошибок протокольных бит.
Восстановленный объект имеет 16384 байта, 0 ошибочных байт и 0 ошибочных бит.
SHA-256 исходного и восстановленного объектов совпадает:
`b5a27caaa3f03dfc468e71a857a06f18cf0259ca6ea21d3c42ccfde7168d1dfe`.
SNR min/median/max для 68 кадров равен `8.302870/8.909278/9.837726 дБ`, ошибки
преамбулы `0/3/9` из 1280, CFO
`-0.214661/0.016493/0.281551 Гц`. Медианный SNR по третям сеанса снижался
`9.29 -> 8.86 -> 8.57 дБ`, но потерь кадров и CRC FAIL не было. Итог:
**LAB046C HARDWARE RESULT = A; FULL RF OBJECT TRANSPORT = PASS; 68/68 FRAMES;
OBJECT COMPLETE = YES; SHA MATCH = YES; OBJECT BYTE ERRORS = 0.**
Большие сформированные артефакты оставлены вне Git в
`C:\Users\user\Documents\SDR_Rover_Lab046_raw\20260902_151819_1400MHz_full`.
Независимый проверяющий только чтением сверил аппаратные и независимые JSON,
арифметику порога, счётчики, размеры, SHA-256, побайтовое совпадение объектов,
временное покрытие RX над TX и очистку; расхождений не найдено. После опыта
`python tools/quick_gate.py` прошёл, `python -m pytest tests/ -q` дал 512
passed и 0 failed, `git diff --check` прошёл.
## Следующий этап
STOP. Новый RF, JPEG, видео, QPSK, 180 МГц и FEC 12+3 без отдельной постановки
не начинать.

View File

@@ -128,6 +128,9 @@ SDR_Rover
└── gnuradio/ пока пусто
```
Лабораторные работы и расположение их кода, проверок, документов и данных:
[`docs/LABS_INDEX.md`](docs/LABS_INDEX.md).
### `protocol`
Ядро системы. Пакет с CRC32, фрагментация изображений, кадр канала, исправление стираний, приоритеты, аварийная логика, сеансы связи, примитивы BPSK-радиотракта.

View File

@@ -0,0 +1,17 @@
# Lab044
В этом каталоге отдельные обработанные данные Lab044 не сохранялись. Файл
служит только указателем и не содержит копий аппаратных данных.
- Основной код:
[`experiments/lab044_hackrf_bpsk_packet.py`](../../../experiments/lab044_hackrf_bpsk_packet.py).
- Проверки:
[`tests/test_lab044_hackrf_bpsk_packet.py`](../../../tests/test_lab044_hackrf_bpsk_packet.py).
- Текущее состояние и аппаратный отчёт:
[`docs/lab044_current_state.md`](../../../docs/lab044_current_state.md).
- Внешние аппаратные данные:
`C:\Users\user\Documents\SDR_Rover_Lab044_raw\20260831_140142_bpsk_packet\`.
- Общий указатель:
[`docs/LABS_INDEX.md`](../../../docs/LABS_INDEX.md).
Большие IQ-записи и бинарные файлы сюда не копировать.

View File

@@ -0,0 +1,18 @@
# Lab045
В этом каталоге отдельные обработанные данные Lab045 не сохранялись. Файл
служит только указателем и не содержит копий аппаратных данных.
- Основной код:
[`experiments/lab045_hackrf_bpsk_fec.py`](../../../experiments/lab045_hackrf_bpsk_fec.py).
- Проверки:
[`tests/test_lab045_hackrf_bpsk_fec.py`](../../../tests/test_lab045_hackrf_bpsk_fec.py).
- Текущее состояние и аппаратный отчёт:
[`docs/lab045_current_state.md`](../../../docs/lab045_current_state.md).
- Внешние аппаратные данные:
`C:\Users\user\Documents\SDR_Rover_Lab045_raw\20260901_103434_bpsk_fec\`.
- Общий указатель:
[`docs/LABS_INDEX.md`](../../../docs/LABS_INDEX.md).
Большие аппаратные артефакты специально не хранятся в Git. IQ-записи и
бинарные файлы сюда не копировать.

156
docs/LABS_INDEX.md Normal file
View File

@@ -0,0 +1,156 @@
# Указатель лабораторных работ
Этот файл показывает, где находятся код, проверки, документы и данные
лабораторных работ. Он не заменяет отчёты и не дублирует большие IQ-записи.
Пути к внешним аппаратным данным приведены только там, где они уже зафиксированы
в документации проекта и существуют на текущем компьютере.
## Lab043
- **Цель:** воспроизводимый BPSK-канал HackRF -> HackRF с общей опорой,
независимой кадровой синхронизацией и измерением параметров радиотракта.
- **Статус:** рабочая точка BPSK и общий опорный генератор подтверждены;
дополнительные аппаратные ветви использовались для диагностики усиления,
непрерывности и асимметрии сигнала.
- **Основной эксперимент:**
[`experiments/lab043_hackrf_bpsk.py`](../experiments/lab043_hackrf_bpsk.py).
- **Основные проверки:**
[`tests/test_lab043_hackrf_bpsk.py`](../tests/test_lab043_hackrf_bpsk.py).
Дополнительные проверки находятся в файлах `tests/test_lab043_*.py`.
- **Документ состояния:**
[`docs/lab043_current_state.md`](lab043_current_state.md).
- **Обработанные данные:** каталоги
[`data/processed/lab043_diagnostics`](../data/processed/lab043_diagnostics),
[`lab043_hackrf_compare`](../data/processed/lab043_hackrf_compare),
[`lab043_hackrf_continuity`](../data/processed/lab043_hackrf_continuity),
[`lab043_hackrf_counter`](../data/processed/lab043_hackrf_counter) и
[`lab043_hackrf_gain_ladder`](../data/processed/lab043_hackrf_gain_ladder).
- **Внешние аппаратные данные:**
`C:\Users\user\Documents\SDR_Rover_Lab043_raw\`.
- **Журнал:** отдельной записи с темой Lab043 нет. Переход к Lab043 указан в
записи 020, а подтверждённая рабочая точка Lab043 используется и упоминается
в записях 021-024
([`PROJECT_LOG.md`](../PROJECT_LOG.md)).
## Lab044
- **Цель:** передача произвольного протокольного пакета через реальный
BPSK-канал HackRF с независимым решением CRC.
- **Статус:** один аппаратный запуск, `RESULT C`; преамбула и PHY sync
восстановлены, пакет не дошёл до проверки CRC.
- **Основной эксперимент:**
[`experiments/lab044_hackrf_bpsk_packet.py`](../experiments/lab044_hackrf_bpsk_packet.py).
- **Основные проверки:**
[`tests/test_lab044_hackrf_bpsk_packet.py`](../tests/test_lab044_hackrf_bpsk_packet.py).
- **Документ состояния:**
[`docs/lab044_current_state.md`](lab044_current_state.md).
- **Обработанные данные:** отдельные результаты не сохранялись; каталог
[`data/processed/lab044`](../data/processed/lab044) содержит только
навигационный README.
- **Внешние аппаратные данные:**
`C:\Users\user\Documents\SDR_Rover_Lab044_raw\20260831_140142_bpsk_packet\`.
- **Журнал:** подготовка в записи 021, аппаратный результат в записи 022
([`PROJECT_LOG.md`](../PROJECT_LOG.md)).
## Lab045
- **Цель:** защищённый BPSK-кадр со свёрточным FEC R=1/2, K=7, перемежением,
мягким декодером Витерби, защищённым PHY-заголовком и CRC.
- **Статус:** аппаратный `RESULT A`; исправление реальных ошибок FEC доказано,
CRC пройден, полезная нагрузка восстановлена без ошибок.
- **Основной эксперимент:**
[`experiments/lab045_hackrf_bpsk_fec.py`](../experiments/lab045_hackrf_bpsk_fec.py).
- **Основные проверки:**
[`tests/test_lab045_hackrf_bpsk_fec.py`](../tests/test_lab045_hackrf_bpsk_fec.py).
- **Документ состояния:**
[`docs/lab045_current_state.md`](lab045_current_state.md).
- **Обработанные данные:** отдельные результаты не сохранялись; каталог
[`data/processed/lab045`](../data/processed/lab045) содержит только
навигационный README.
- **Внешние аппаратные данные:**
`C:\Users\user\Documents\SDR_Rover_Lab045_raw\20260901_103434_bpsk_fec\`.
- **Журнал:** программная подготовка в записи 023, аппаратный результат в
записи 024 ([`PROJECT_LOG.md`](../PROJECT_LOG.md)).
## Lab046
- **Цель:** фрагментация произвольного бинарного объекта, передача его
несколькими независимо защищёнными кадрами Lab045 и аварийно-безопасная
сборка объекта.
- **Статус:** программная проверка завершена успешно; RF не запускался.
- **Основной эксперимент:**
[`experiments/lab046_bpsk_object_transport.py`](../experiments/lab046_bpsk_object_transport.py).
- **Основные проверки:**
[`tests/test_lab046_bpsk_object_transport.py`](../tests/test_lab046_bpsk_object_transport.py).
- **Документ состояния:**
[`docs/lab046_current_state.md`](lab046_current_state.md).
- **Обработанные данные:** отдельного каталога в `data/processed` нет.
- **Внешние аппаратные данные:** отсутствуют, поскольку этап программный.
- **Журнал:** запись 025 ([`PROJECT_LOG.md`](../PROJECT_LOG.md)).
## Lab046A
- **Цель:** независимый поиск и приём нескольких кадров объекта в одном
непрерывном нормированном потоке мягких BPSK-значений.
- **Статус:** программная проверка завершена успешно; работа с IQ и
аппаратная проверка на этом этапе не выполнялись.
- **Основной эксперимент:** отдельного `lab046a_*.py` нет; реализация находится
в [`experiments/lab046_bpsk_object_transport.py`](../experiments/lab046_bpsk_object_transport.py).
- **Основные проверки:** отдельного `test_lab046a_*.py` нет; сценарии находятся
в [`tests/test_lab046_bpsk_object_transport.py`](../tests/test_lab046_bpsk_object_transport.py).
- **Документ состояния:**
[`docs/lab046_current_state.md`](lab046_current_state.md).
- **Обработанные данные:** отдельного каталога в `data/processed` нет.
- **Внешние аппаратные данные:** отсутствуют, поскольку этап программный.
- **Журнал:** запись 026 ([`PROJECT_LOG.md`](../PROJECT_LOG.md)).
## Lab046B
- **Цель:** поиск и приём нескольких полных кадров Lab045 в одном непрерывном
сыром IQ-потоке без использования границ кадров передатчика.
- **Статус:** программная проверка завершена успешно; RF не запускался.
- **Основной эксперимент:**
[`experiments/lab046b_continuous_iq.py`](../experiments/lab046b_continuous_iq.py).
- **Основные проверки:**
[`tests/test_lab046b_continuous_iq.py`](../tests/test_lab046b_continuous_iq.py).
- **Документ состояния:**
[`docs/lab046_current_state.md`](lab046_current_state.md).
- **Обработанные данные:** отдельного каталога в `data/processed` нет.
- **Внешние аппаратные данные:** отсутствуют, поскольку этап программный.
- **Журнал:** запись 027 ([`PROJECT_LOG.md`](../PROJECT_LOG.md)).
## Lab046C
- **Цель:** непрерывная аппаратная передача объекта 16 КиБ последовательностью
защищённых BPSK-кадров и независимое восстановление объекта из IQ-записи.
- **Статус:** полный аппаратный результат на 1400 МГц равен `RESULT A`:
принято 68/68 кадров, объект восстановлен без ошибок. Первый полный опыт на
1080 МГц сохраняется отдельно как исторический `RESULT C`.
- **Основной аппаратный эксперимент:**
[`experiments/lab046c_hackrf_bpsk_object.py`](../experiments/lab046c_hackrf_bpsk_object.py).
- **Автономная обработка сохранённых записей:**
[`experiments/lab046c_offline_recovery.py`](../experiments/lab046c_offline_recovery.py).
- **Основные проверки:**
[`tests/test_lab046c_hackrf_bpsk_object.py`](../tests/test_lab046c_hackrf_bpsk_object.py)
и
[`tests/test_lab046c_offline_recovery.py`](../tests/test_lab046c_offline_recovery.py).
- **Документы состояния:**
[`docs/lab046_current_state.md`](lab046_current_state.md) и отдельный отчёт
о короткой записи 1400 МГц
[`docs/lab046c_1400mhz_offline_reprocessing.md`](lab046c_1400mhz_offline_reprocessing.md).
- **Обработанные данные:** локально присутствуют каталоги
`data/processed/lab046c_offline_recovery` и
`data/processed/lab046c_1400mhz_offline`; в текущем состоянии Git их файлы
не отслеживаются.
- **Внешние аппаратные данные:**
`C:\Users\user\Documents\SDR_Rover_Lab046_raw\20260901_145339_bpsk_object\`,
`C:\Users\user\Documents\SDR_Rover_RF_Check\20260901_172749_1400MHz\` и
`C:\Users\user\Documents\SDR_Rover_Lab046_raw\20260902_151819_1400MHz_full\`.
- **Журнал:** записи 028-032 ([`PROJECT_LOG.md`](../PROJECT_LOG.md)).
## Правило хранения данных
Большие записи IQ, потоки `cs8`, бинарные объекты и другие аппаратные исходные
данные остаются во внешних каталогах. В `data/processed` хранятся только
небольшие обработанные результаты или навигационные README. Пустые и
фиктивные файлы результатов не создаются.

View File

@@ -4,12 +4,238 @@
**LAB046 SOFTWARE OBJECT TRANSPORT = COMPLETE.**
**CONTINUOUS STREAM VALIDATION = PASS (NORMALIZED SOFT-SYMBOL LEVEL). RF TX = 0.
CONTINUOUS SAMPLE/IQ VALIDATION = NOT PERFORMED.
HARDWARE VALIDATION = NOT PERFORMED.**
**CONTINUOUS STREAM VALIDATION = PASS (NORMALIZED SOFT-SYMBOL LEVEL).**
Lab046 — только программная лаба. HackRF и `hackrf_transfer` не
запускались, эфирной передачи нет. Ни JPEG, ни видео, ни QPSK.
**ПРОГРАММНАЯ ПРОВЕРКА НЕПРЕРЫВНОГО СЫРОГО IQ-ПОТОКА = PASS.**
**LAB046C HARDWARE RESULT = A (FULL 1400 MHZ RF OBJECT TRANSPORT).**
**FULL RF OBJECT TRANSPORT = PASS.**
**RF TX PROCESS COUNT FOR THIS RESULT = 1.**
**VALID FRAMES = 68/68. CRC PASS = 68. CRC FAIL = 0.**
**PRE-FEC CODED BIT ERRORS = 787. POST-FEC PROTOCOL BIT ERRORS = 0.**
**OBJECT COMPLETE = YES. OBJECT SIZE = 16384 BYTES.**
**SHA MATCH = YES. OBJECT BYTE ERRORS = 0. OBJECT BIT ERRORS = 0.**
**HISTORICAL FIRST LAB046C AT 1080 MHZ = RESULT C.**
**THE HISTORICAL 1080 MHZ RESULT WAS NOT RECLASSIFIED.**
**INDEPENDENT 1400 MHZ THRESHOLD = 0.033527640625834465.**
**CURRENT SOFTWARE GATE = PASS: quick_gate 512, pytest 512 passed,
git diff --check clean.**
Lab046A/B остаются только программными лабами. В истории Lab046C отдельно
сохранены первый полный аппаратный результат C на 1080 МГц и новый полный
аппаратный результат A на 1400 МГц. В новом опыте выполнен ровно один заранее
разрешённый непрерывный процесс передачи без повтора. Ни JPEG, ни видео, ни
QPSK не запускались.
## Lab046C: полный аппаратный результат 1400 МГц, RESULT A
2 сентября 2026 года через два HackRF передан новый криптографически случайный
объект размером 16384 байта. Он был разбит существующей архитектурой Lab046 на
68 фрагментов и передан 68 последовательными защищёнными BPSK-кадрами одним
непрерывным процессом. Частота равна 1400 МГц, частота дискретизации 8 МГц,
символьная скорость 20 ксимв/с, цифровое смещение несущей +50 кГц. Усиления не
менялись: TX VGA 10 дБ, RX LNA/VGA 24/24 дБ, RF amplifier и Bias-Tee выключены.
Общий опорный генератор прошёл аварийно-безопасную проверку: MASTER reg29
`0x26`, SLAVE CLKIN `DETECTED`, SLAVE reg29 `0x26`. Приём начался на
`0.7525116 с` раньше передачи и закончился на `3.6341200 с` позже неё. Файл
передачи содержит 156 835 200 комплексных отсчётов, 313 670 400 байт и
19,6044 с сигнала; процесс передачи занял 19,6408497 с. Приём содержит
192 000 000 комплексных отсчётов, 384 000 000 байт и 24,0 с сигнала; процесс
приёма занял 24,0274813 с. Переполнения приёмника, опустошения передатчика,
ограничение АЦП и приближение к пределу отсутствуют.
До полного декодирования по фону до передачи зафиксировано правило первого
каскада `Q99.9 локальных максимумов + 4 * MAD`: Q99.9
`0.023181289434432983`, MAD `0.0025865877978503704`, порог
`0.033527640625834465`. После начала декодирования порог не менялся. На
независимом фоне после передачи максимум равен `0.030997175723314285`,
кандидатов выше порога нет. Исторический строгий порог
`IQ_PREAMBLE_THRESHOLD = 0.65`, Lab045, PHY и FEC не менялись.
Независимый приёмник обработал весь 24-секундный файл. Он не использовал
смещения и период кадров передатчика, ожидаемое число 68, пакеты, кодированные
биты или исходный объект. Первый каскад получил 295469 локальных максимумов и
35009 кандидатов. Строгий каскад обработал 95 неперекрывающихся гипотез:
PHY-заголовок PASS/FAIL `68/27`, FEC attempts/PASS/FAIL `68/68/0`, CRC
PASS/FAIL `68/0`, действительных кадров `68`. Поиск завершился по концу записи
и списка кандидатов, а не по достижении известного количества кадров.
`ObjectAssembler` принял 68 уникальных фрагментов. Дубликатов, конфликтующих
дубликатов и пропусков нет. Независимый вердикт: `OBJECT COMPLETE = YES`.
Восстановленный объект размером 16384 байта был записан до открытия опорных
данных передатчика. Только после этого выполнено сравнение: все 68 кадров
совпали с 68 переданными, пропущенных кадров и ложных CRC PASS нет. Ошибки до
FEC составили 787 кодированных бит, после FEC 0 протокольных бит. Ошибки
объекта равны 0 байт и 0 бит. SHA-256 исходного и восстановленного объектов:
`b5a27caaa3f03dfc468e71a857a06f18cf0259ca6ea21d3c42ccfde7168d1dfe`.
Для 68 принятых кадров SNR min/median/max равен
`8.302870/8.909278/9.837726 дБ`, ошибки преамбулы `0/3/9` из 1280, CFO
`-0.214661/0.016493/0.281551 Гц`. Медианный SNR по третям сеанса плавно
снижался `9.29 -> 8.86 -> 8.57 дБ`, но все кадры прошли FEC и CRC. Ошибки
начала кадра находились в пределах от -4 до +3 отсчётов.
Фактические внешние артефакты сохранены в
`C:\Users\user\Documents\SDR_Rover_Lab046_raw\20260902_151819_1400MHz_full`.
Большие `tx_stream.cs8`, `rx_stream.cs8`, `tx_object.bin` и
`recovered_object.bin` в Git не добавляются. После результата оба CLKOUT
выключены, процессов `hackrf_transfer` нет. Независимая проверка сохранённых
JSON, размеров, SHA-256, временного покрытия и счётчиков расхождений не нашла.
## Lab046C: подготовка аппаратного непрерывного объекта
Добавлен `experiments/lab046c_hackrf_bpsk_object.py`, который не меняет
протокол и только связывает готовые Lab043--Lab046B. Подготовительный режим
создаёт новый криптографически случайный объект 16384 байта вне Git, разбивает
его существующим Lab046 API на 68 фрагментов и последовательно записывает
68 полных Lab045 waveform в один `tx_stream.cs8`. Полные 313 670 400 байт не
удерживаются в ОЗУ одновременно; SHA-256 объекта и потока сохраняются в
`tx_metadata.json`.
24-секундный RX был запланирован как 192 000 000 complex cs8 samples,
384 000 000 байт. Такой capture даёт 0,75 с до передачи и около 3,65 с после
потока длительностью 19,6044 с. Приёмный API получает только путь к сырому
cs8 и PHY safety limits. Он читает файл через memmap окнами `complex64`, ищет
локальные PRBS7-кандидаты без обязательного global peak/second gate, получает
длину кадра из принятого PHY header и передаёт прошедшие CRC payload в
`ObjectAssembler`. TX start, границы 68 waveform, ожидаемое число фрагментов,
пакеты, coded bits и исходный объект приёмнику не передаются.
Аппаратный runner фиксирует доказанные serial и PHY-параметры, запускает RX
раньше TX, содержит один `TransmitGuard.register_transmit()` и cleanup в
`finally`. `--preflight-only` не регистрирует передачу и выполняет только
inventory/common-clock gate с последующим CLKOUT OFF. RF-режим требует два
отдельных CLI-ключа. После отдельной точной авторизации он был выполнен один
раз; повторный запуск не выполнялся и не разрешён.
Фактическая подготовка 1 сентября 2026 года создала внешний каталог
`C:\Users\user\Documents\SDR_Rover_Lab046_raw\20260901_145339_bpsk_object`:
- `tx_object.bin`: 16384 байта, SHA-256
`f7568a5d1a6dc5dd907fa1e971a171578315de740bd6788136f447e409fc6bf8`;
- `tx_stream.cs8`: 313670400 байт, 156835200 complex samples, 19,6044 с,
SHA-256 `8941eb231f1f2f99cde51f3cc5426472dbedf1a61675543e7774faec4b935f5e`;
- `tx_metadata.json`: `LAB046C_PREPARATION_ONLY`, `rf_tx_count = 0`.
Software gate подготовки: quick gate PASS с 482 тестами и 8 функциональными
лабами; полный `pytest` = 482 passed. Безопасный common-clock preflight также PASS:
MASTER reg29 `0x26`, SLAVE CLKIN `DETECTED` после 141,51 мс, SLAVE reg29
`0x26`; после проверки оба CLKOUT выключены, процессов `hackrf_transfer` нет,
`RF TX = 0` на этапе подготовки.
## Lab046C: первый аппаратный результат 1080 МГц, исторический RESULT C
Выполнен ровно один непрерывный RF TX всего `tx_stream.cs8`; автоматического
или ручного повтора не было. MASTER/TX
`000000000000000097c063dc323b97d3`, SLAVE/RX
`000000000000000097c063dc2e5327d3`, RF 1080 МГц, Fs 8 MS/s, BPSK 20 ксимв/с,
TX VGA 10 дБ, RX LNA/VGA 24/24 дБ, RF amp и bias OFF. Common clock прошёл
fail-closed gate: MASTER reg29 `0x26`, SLAVE CLKIN `DETECTED`, SLAVE reg29
`0x26`. Передача длилась 19,60632 с и полностью находилась внутри RX capture
длительностью 24,00386 с. USB-журналы чистые: TX underruns 0, RX overruns 0.
Независимый приёмник без TX oracle вернул официальный `RESULT C`:
- `PREAMBLE CANDIDATES = 0`;
- `VALID FRAMES = 0`;
- `PHY HEADER PASS = 0`;
- `CRC PASS = 0`;
- `OBJECT COMPLETE = NO`;
- восстановленный объект не создавался.
Рабочий порог PRBS7 оставался `0.65`; максимальная независимая корреляция
равна `0.126562`. Фоновая медиана мощности `-34.467535 dBFS`, кратковременный
максимум `-34.307678 dBFS`; максимальный средний подъём на интервале 19,6 с
всего `0.012049 dB`. Обычный power/PSD-анализ не показывает заметной передачи.
После завершения и фиксации независимого `RESULT C` выполнена разрешённая
read-only диагностика с TX reference. Она обнаружила следы всех 68 waveform с
точным периодом 2 306 400 samples. Нормированная корреляция в выровненных
позициях: min `0.065784`, median `0.089991`, max `0.122379`; контроль со
смещением: median `0.004275`, max `0.010075`. Медианное преимущество 26,46 дБ,
все 68 aligned значений выше максимума контроля.
Эта post-run диагностика использует знание TX только после официального
решения и **не** превращает опыт в PASS: `OBJECT TRANSPORT PASS` и
`LAB046C COMPLETE SUCCESS` не заявляются. TX-файл, его спектр около +50 кГц,
первый/средний/последний self-decode и runner проверены; дефекта формирования
или управления, объясняющего результат, не найдено. Сохранённый официальный
исход остаётся `LAB046C HARDWARE RESULT = C`, `RF TX COUNT = 1`,
`OBJECT COMPLETE = NO`.
## Lab046C: автономная слабосигнальная обработка исходного RX capture
После аппаратного RESULT C реализован отдельный offline-модуль
`experiments/lab046c_offline_recovery.py`. Lab045 и строгий многокадровый
порог Lab046B `IQ_PREAMBLE_THRESHOLD = 0.65` не изменялись. Новый режим не
содержит HackRF-команд и читает только ранее сохранённый `rx_stream.cs8` через
`np.memmap`.
Первый каскад был зафиксирован до полного прогона по калибровочному фону
`0.0--0.5 s`: порог равен 99,9-му процентилю локальных PRBS7-максимумов.
Получено `threshold = 0.025011632591485977`, correlation median
`0.00838765874505043`, MAD `0.003039387986063957`, 5193 локальных максимума,
6 кандидатов, `12.0 candidates/s`. После фиксации правило проверено на
отдельном контрольном фоне `21.0--24.0 s` и больше не менялось: 35501 локальный
максимум, 301 кандидат, `100.333333 candidates/s`. Временные границы TX
использовались только для выбора участков, где передачи заведомо не было, и не
передавались поиску кадров.
Выполнен ровно один независимый полный offline-прогон 192 000 000 samples без
`tx_stream.cs8`, `tx_object.bin`, TX offsets, ожидаемого числа 68, fragment
order, packets или coded bits. Первый каскад насчитал 291446 локальных
максимумов и 3025 кандидатов выше порога. Все 3025 прошли refinement, timing и
оценку carrier/CFO/phase, но все 3025 были отвергнуты как
`PHY_HEADER_MAGIC_MISMATCH`. `PHY HEADER PASS = 0`, `FEC ATTEMPTS = 0`,
`CRC PASS = 0`, `VALID FRAMES = 0`, `VALID FRAGMENTS = 0`,
`OBJECT COMPLETE = NO`. `recovered_object.bin` не создавался.
Только после завершённого independent verdict была открыта TX reference.
Диагностика показала, что каждый из 68 настоящих стартов имел first-stage
кандидат в пределах `+-40 samples`; корреляция на этих кандидатах
min/median/max `0.059379209/0.088661019/0.126557365`, то есть все они были выше
зафиксированного порога. `FALSE CRC PASS = 0`, но совпавших independently valid
frames также 0, пропущены все 68 TX frames. На известных только post-verdict
позициях локальная физическая экстракция прошла для 68/68, однако ошибки
min/median/max составили `341/430/554` из 1280 бит преамбулы и `30/54/81` из
144 бит PHY header. Оценочный SNR min/median/max
`-19.282130/-12.829480/-9.195846 dB`; все заголовки дали `MAGIC_MISMATCH`.
Следовательно, новый first-stage detector обнаружил слабые следы, но качество
демодуляции оказалось недостаточным даже для защищённого PHY header.
Производительность: calibration `0.269617 s`, control `1.031204 s`, coarse
search `7.721316 s`, strict candidate processing `836.340048 s`, в среднем
`0.276476 s/candidate`, полный прогон `845.427589 s`. Capture длительностью
24 s обработан за `35.226150` его длительности. RX остаётся `memmap`; самый
большой coarse `complex64` block содержит 16 000 000 samples (128 000 000
байт), консервативная оценка временных массивов SciPy `1 545 705 764` байта.
Максимальное strict local window: 594400 samples, 4 755 200 байт. Полный
192M-sample `complex64` массив не создавался.
Артефакты результата находятся в
`data/processed/lab046c_offline_recovery/`: `independent_result.json`,
`post_verdict_diagnostics.json`, `tx_reference_stage_diagnostics.json`.
Правильная итоговая формулировка: **ORIGINAL LAB046C HARDWARE RESULT = C;
OFFLINE REPROCESSING OF ORIGINAL RX CAPTURE = FAIL; OBJECT RECOVERED = NO;
ORIGINAL HARDWARE RESULT CHANGED = NO.** Второй независимый прогон с новым
порогом не выполнялся.
Финальный software gate после результата: `python tools/quick_gate.py` PASS,
506 проверок и 9 функциональных лабораторий; `python -m pytest tests/ -q` =
506 passed. Единственное предупреждение связано с недоступным `.pytest_cache`
и не является отказом. `git diff --check` чист, кроме информационных LF/CRLF
notices.
## Назначение
@@ -110,6 +336,61 @@ Verifier нашёл опасный вариант прежнего глобал
кадра. Поэтому suppression убран из списка кандидатов, а добавленный
регрессионный тест проверяет, что ложный peak не скрывает настоящий кадр.
## Lab046B: непрерывный сырой IQ-поток
Lab046B закрывает только программный sample-level пробел Lab046A. Новый
`experiments/lab046b_continuous_iq.py` формирует пять полных Lab045 waveform с
PRBS7, защищённым PHY header, interleaved convolutional body, BPSK, RRC и
guards, размещает их в одном `complex64` массиве и передаёт scanner-у только
этот массив. TX start offsets, длины TX waveform, номера кадров, payload,
packet и coded bits в `scan_continuous_iq` не передаются.
Переиспользованы существующие функции Lab043-Lab045: BPSK/RRC waveform,
PRBS7 reference, normalized sliding correlation, локальное уточнение sample
start, matched filter, preamble-only timing, non-data-aided CFO и комплексный
gain, `normalized.real`, защищённый PHY header, deinterleave, soft Viterbi и
protocol CRC. Новый слой только перечисляет локальные IQ-кандидаты, вызывает
этот тракт на окне кандидата и монотонно продолжает поиск.
Глобальное требование «главный пик значительно выше второго» не используется.
В идеальном пятикадровом опыте peak/second для настоящих кадров лежит около
0 dB, поскольку вторым пиком является другой настоящий кадр; тем не менее все
пять кадров приняты. Все локальные максимумы выше порога сохраняются, а после
CRC PASS cursor переносится за вычисленный по принятому header конец кадра.
После плохого кандидата cursor также строго растёт, но следующий кандидат не
скрывается. Выход ограничен `MAX_FRAMES` и `MAX_SEARCH_ITERATIONS`.
### Фактические сценарии Lab046B
| Сценарий | Получено |
|---|---|
| Идеальный continuous IQ | PRBS7 candidates 32, processed 5, accepted 5, rejected 0, PHY/FEC/CRC 5/5/5, CRC FAIL 0, sample offset error 0 для всех |
| CFO + phase + amplitude | внесено 0.2 Hz / 0.7 rad / 0.65; медианная оценка 0.200003768 Hz / 0.699634288 rad; CRC 5/0 |
| Комплексный AWGN | sigma=0.17, seed=46002; accepted 5, pre-FEC 0, post-FEC 0, CRC 5/0 |
| PRBS7 + неверный PHY header перед настоящим кадром | candidates 11, processed 8, rejected 7, следующий настоящий кадр найден, CRC 1/0 |
Начало первого waveform: sample 137, то есть не sample 0 и не граница сетки
400 samples/symbol. Дополнительные gaps: 211/353/587/809 samples. Найденные
фазы символьной выборки: 137/348/301/88/97 samples modulo 400. Точные TX
offsets используются только постфактум после CRC PASS; ошибки обнаруженного
start во всех пяти случаях равны нулю.
Шумовая точка зафиксирована честно. Первый разумный однокадровый запуск при
sigma=0.12 дал CRC PASS, но 0 pre-FEC ошибок. Выполнена одна разрешённая
обоснованная корректировка до sigma=0.17; она также дала 0 pre-FEC ошибок и
CRC PASS. Дальнейший подбор не выполнялся. Поэтому этот сценарий доказывает
устойчивый многокадровый IQ receive с комплексным детерминированным шумом, но
не является новым доказательством реального исправления ошибок FEC; такое
доказательство уже относится к аппаратной Lab045.
Основной массив содержит 11 534 330 `complex64` отсчётов и занимает
92 274 640 байт (88.0 MiB). Пять waveform занимают 11 532 000 samples, остальное
— leading/gaps/trailing. Полные CFO/phase-corrected копии не создаются:
первичный поиск работает с decimated stream, а matched filtering и soft
extraction выполняются на локальном окне одного кандидата. Это размер именно
основного IQ-массива, а не peak RSS процесса: RRC/FFT, локальный matched stream
и временные массивы SciPy требуют дополнительную рабочую память.
## Память continuous stream
Основной 68-кадровый soft stream содержит 389 232 значения `float32` и занимает
@@ -233,18 +514,22 @@ Goodput 6,69 кбит/с — это честная baseline при 20 ксимв
- Полный тракт `объект → фрагменты → кадр Lab045 → канал → приём → сборка`
проверен для всех шести сценариев значениями выше.
## Software gate
## Software gate Lab046B на момент его фиксации
- `python tools/quick_gate.py`: PASS; 454 теста ядра, 6 функциональных
- `python tools/quick_gate.py`: PASS; 468 тестов ядра, 7 функциональных
проверок лабораторий выполнено, 13 штатно пропущено.
- `python -m pytest tests/ -q`: 454 passed, 0 failed, 0 errors, 0 skipped.
- `python -m pytest tests/ -q`: 468 passed, 0 failed, 0 errors, 0 skipped.
Единственное предупреждение связано с отсутствием прав на создание
`.pytest_cache`; на выполнение и результат тестов оно не повлияло.
- `git diff --check`: чист.
Актуальный общий gate после добавления Lab046C указан в начале документа.
## Ограничения и следующий шаг
RF TX = 0, аппаратная проверка не проводилась. Erasure-код 12+3 GF(256) не
задействован (будущее восстановление потери целых фрагментов). Оптимизация
преамбулы/guard на серию кадров, QPSK, JPEG и видео в этой лабе не делались.
Следующий этап определяется после анализа (Lab046 первым смотрит ChatGPT).
Для Lab046A/B RF TX = 0. Первый полный аппаратный Lab046C на 1080 МГц сохранён
как исторический RESULT C. Новый полный аппаратный Lab046C на 1400 МГц
завершён как RESULT A: 68/68 кадров, объект 16384 байта восстановлен без
ошибок. Erasure-код 12+3 GF(256) не задействован, Lab045, PHY и FEC не
менялись. Следующий этап определяется отдельной постановкой. Новый RF-сеанс,
JPEG, видео, QPSK и переход к другой FEC автоматически не разрешены.

View File

@@ -0,0 +1,101 @@
# Повторная обработка записи Lab046C на 1400 МГц
## Границы работы
Использована только сохранённая запись:
`C:\Users\user\Documents\SDR_Rover_RF_Check\20260901_172749_1400MHz\rx_1400mhz.cs8`
- размер: 38 400 000 байт;
- число комплексных отсчётов cs8: 19 200 000;
- частота дискретизации: 8 МГц;
- длительность: 2,4 с;
- SHA-256: `06ab0867865bca462a21c42c9f3bc7e519e336108f7b5e98e7b283f842387a99`;
- число запусков передатчика в этой работе: 0.
HackRF не запускался. Порог `IQ_PREAMBLE_THRESHOLD = 0.65`, Lab045, FEC и PHY
не изменялись. Исходный аппаратный `CONTROL RESULT = C` не переписывается.
## Правило первого каскада
Правило зафиксировано до полного независимого декодирования. Использован только
фон до передачи, интервал `0,00 ... 0,50 с`:
```text
candidate_threshold =
pre-TX local-peak Q99.9 + 4 * pre-TX local-peak MAD
```
Получено:
- локальных максимумов: 5 538;
- медиана локальных максимумов: `0.0094285998493433`;
- MAD локальных максимумов: `0.0027075146790593863`;
- Q99: `0.019724003970623016`;
- Q99.9: `0.022788675501942635`;
- Q99.99: `0.023935016244649887`;
- максимум: `0.024017350748181343`;
- зафиксированный порог: `0.03361873421818018`.
Фон после передачи, интервал `1,60 ... 2,40 с`, использован только как
независимая контрольная выборка:
- локальных максимумов: 9 285;
- медиана локальных максимумов: `0.009831136092543602`;
- MAD локальных максимумов: `0.002742284908890724`;
- Q99.9: `0.023004598915576935`;
- Q99.99: `0.026167524978518486`;
- максимум: `0.02711654081940651`;
- кандидатов при зафиксированном пороге: 0.
После просмотра результата порог не менялся.
## Независимый результат
Выполнен ровно один полный проход по всем 19 200 000 отсчётам. Приёмник не
получал смещения переданных кадров, их ожидаемое число, сигнал передатчика,
пакеты, полезную нагрузку или кодированные биты.
- локальных максимумов во всей записи: 29 205;
- кандидатов первого каскада: 1 279;
- обработано строгим каскадом: 28;
- PHY-заголовок прошли: 3;
- PHY-заголовок не прошли: 25;
- попыток FEC: 3;
- FEC прошли: 3;
- FEC не прошли: 0;
- CRC прошли: 3;
- CRC не прошли: 0;
- действительных кадров: 3.
Поиск дошёл до конца записи и до конца списка кандидатов. Ограничения числа
кандидатов, итераций и действительных кадров не сработали. Все 25 отклонённых
кандидатов получили `PHY_HEADER_MAGIC_MISMATCH`. Только `CRC PASS` добавлял кадр
в независимый результат.
## Диагностика после независимого решения
Независимый JSON был записан до открытия опорных файлов передатчика. После
этого выполнено сопоставление:
- ожидаемых переданных кадров: 3;
- совпавших действительных кадров: 3;
- пропущенных кадров: 0;
- ложных `CRC PASS`: 0;
- ошибки положения начала: `0`, `-1`, `+2` отсчёта;
- ошибки кодированных битов до FEC: `17 + 19 + 29 = 65`;
- ошибки пакета после FEC: 0 бит;
- ошибки полезной нагрузки: 0 байт, 0 бит.
Три полезные нагрузки по 256 байт формируют соответствующие переданные пакеты
по 268 байт. Добавления пропущенных кадров в независимый результат не было.
## Вывод
Повторная обработка записи на 1400 МГц получила `VALID FRAMES = 3` и
`CRC PASS = 3` без ложных срабатываний CRC. Это подтверждает исправность
физического радиочастотного тракта для данного короткого опыта. Оценка:
`RF PATH ASSESSMENT = HEALTHY`.
Результат не является разрешением на новый RF, повтор Lab046C, JPEG, QPSK или
изменение FEC. `RF TX = 0`.

View File

@@ -0,0 +1,926 @@
"""Lab046B: несколько Lab045-кадров в одном непрерывном IQ-потоке.
Эксперимент программный. Передатчик формирует настоящие Lab045 waveform,
а приёмник получает только один ``complex64`` массив. Известные TX-границы,
payload, packet и coded bits не входят в API приёмника. Сравнение с TX
выполняется отдельно и только после независимого CRC PASS.
RF TX = 0. Аппаратура не используется.
"""
from __future__ import annotations
import argparse
import math
import sys
from dataclasses import dataclass
from pathlib import Path
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
from experiments import lab045_hackrf_bpsk_fec as lab045
from protocol import bpsk_radio as radio
FRAME_COUNT = 5
LEADING_BACKGROUND_SAMPLES = 137
INTER_FRAME_GAPS = (211, 353, 587, 809)
TRAILING_BACKGROUND_SAMPLES = 233
IMPAIRED_CFO_HZ = 0.2
IMPAIRED_PHASE_RAD = 0.7
IMPAIRED_AMPLITUDE = 0.65
NOISE_SEED = 46002
NOISE_SIGMA = 0.17
IQ_PREAMBLE_THRESHOLD = 0.65
MAX_FRAMES = 32
MAX_SEARCH_ITERATIONS = 256
IMPAIRMENT_BLOCK_SAMPLES = 262_144
def _amplitude_db(ratio: float) -> float:
return 20.0 * math.log10(max(float(ratio), 1e-300))
def _wrap_phase(value: float) -> float:
return float((value + math.pi) % (2.0 * math.pi) - math.pi)
def deterministic_payload(frame_index: int) -> bytes:
"""Создать различимый детерминированный payload одного Lab045-кадра."""
if frame_index < 0:
raise ValueError("frame_index не может быть отрицательным")
random_generator = np.random.Generator(np.random.PCG64(46_000 + frame_index))
payload = random_generator.integers(
0,
256,
lab045.PAYLOAD_SIZE_BYTES,
dtype=np.uint8,
)
payload[:4] = np.frombuffer(frame_index.to_bytes(4, "big"), dtype=np.uint8)
return payload.tobytes()
def build_frame_plans(frame_count: int = FRAME_COUNT) -> tuple[lab045.TransmissionPlan, ...]:
if frame_count <= 0:
raise ValueError("нужен хотя бы один кадр")
return tuple(
lab045.build_transmission_plan(deterministic_payload(frame_index))
for frame_index in range(frame_count)
)
def malformed_candidate_waveform() -> np.ndarray:
"""Сформировать настоящий IQ-prefix PRBS7 с заведомо неверным PHY magic."""
header = lab045.encode_phy_header(lab045.MIN_PACKET_BYTES).copy()
header[: lab045.HEADER_REPETITION] ^= 1
bits = np.concatenate((lab045.lab044.preamble_bits(), header))
shaped = lab045.lab044._scale_and_carrier(radio.bpsk_modulate(bits))
raw = lab045.lab044.lab043.to_cs8_bytes(shaped)
return lab045.lab044.signed_iq_bytes_to_complex64(raw)
@dataclass(frozen=True)
class ContinuousIqPlan:
"""TX-side описание; ``frame_starts`` запрещено передавать scanner-у."""
samples: np.ndarray
frame_plans: tuple[lab045.TransmissionPlan, ...]
frame_starts: tuple[int, ...]
leading_background_samples: int
inter_frame_gaps: tuple[int, ...]
trailing_background_samples: int
cfo_hz: float
phase_rad: float
amplitude: float
noise_sigma: float
noise_seed: int
malformed_candidate_start: int | None
malformed_candidate_sample_count: int
@property
def sample_count(self) -> int:
return int(self.samples.size)
@property
def memory_bytes(self) -> int:
return int(self.samples.nbytes)
def _apply_impairments_in_place(
samples: np.ndarray,
*,
cfo_hz: float,
phase_rad: float,
amplitude: float,
noise_sigma: float,
noise_seed: int,
) -> None:
"""Применить канал блоками без постоянной full-size complex128 копии."""
if amplitude <= 0.0 or not np.isfinite(amplitude):
raise ValueError("amplitude должна быть положительной и конечной")
if noise_sigma < 0.0 or not np.isfinite(noise_sigma):
raise ValueError("noise_sigma должна быть неотрицательной и конечной")
if not np.isfinite(cfo_hz) or not np.isfinite(phase_rad):
raise ValueError("CFO и phase должны быть конечными")
phase_step = 2.0 * math.pi * cfo_hz / lab045.SAMPLE_RATE_HZ
random_generator = np.random.Generator(np.random.PCG64(noise_seed))
for start in range(0, samples.size, IMPAIRMENT_BLOCK_SAMPLES):
end = min(samples.size, start + IMPAIRMENT_BLOCK_SAMPLES)
indexes = np.arange(start, end, dtype=np.float64)
rotation = np.exp(1j * (phase_rad + phase_step * indexes)).astype(np.complex64)
samples[start:end] *= np.complex64(amplitude) * rotation
if noise_sigma > 0.0:
real_noise = random_generator.standard_normal(end - start, dtype=np.float32)
imaginary_noise = random_generator.standard_normal(end - start, dtype=np.float32)
samples.real[start:end] += np.float32(noise_sigma) * real_noise
samples.imag[start:end] += np.float32(noise_sigma) * imaginary_noise
def build_continuous_iq_stream(
frame_plans: tuple[lab045.TransmissionPlan, ...] | list[lab045.TransmissionPlan],
*,
leading_background_samples: int = LEADING_BACKGROUND_SAMPLES,
inter_frame_gaps: tuple[int, ...] | list[int] | None = None,
trailing_background_samples: int = TRAILING_BACKGROUND_SAMPLES,
cfo_hz: float = 0.0,
phase_rad: float = 0.0,
amplitude: float = 1.0,
noise_sigma: float = 0.0,
noise_seed: int = NOISE_SEED,
prepend_malformed_candidate: bool = False,
malformed_to_frame_gap_samples: int = 317,
) -> ContinuousIqPlan:
"""Собрать один непрерывный ``complex64`` stream из полных Lab045 waveform."""
plans = tuple(frame_plans)
if not plans:
raise ValueError("нужен хотя бы один кадр")
if leading_background_samples < 0 or trailing_background_samples < 0:
raise ValueError("фоновые интервалы не могут быть отрицательными")
if malformed_to_frame_gap_samples < 0:
raise ValueError("интервал после ложного кандидата не может быть отрицательным")
if inter_frame_gaps is None:
gaps = tuple(0 for _ in range(len(plans) - 1))
else:
gaps = tuple(int(value) for value in inter_frame_gaps)
if len(gaps) != len(plans) - 1 or any(value < 0 for value in gaps):
raise ValueError("нужен один неотрицательный gap между каждой парой кадров")
malformed = malformed_candidate_waveform() if prepend_malformed_candidate else None
malformed_count = 0 if malformed is None else int(malformed.size)
total_samples = (
leading_background_samples
+ malformed_count
+ (malformed_to_frame_gap_samples if malformed is not None else 0)
+ sum(plan.waveform_sample_count for plan in plans)
+ sum(gaps)
+ trailing_background_samples
)
samples = np.zeros(total_samples, dtype=np.complex64)
cursor = leading_background_samples
malformed_start = None
if malformed is not None:
malformed_start = cursor
samples[cursor : cursor + malformed_count] = malformed
cursor += malformed_count + malformed_to_frame_gap_samples
frame_starts: list[int] = []
for frame_index, plan in enumerate(plans):
frame_starts.append(cursor)
waveform = lab045.lab044.signed_iq_bytes_to_complex64(lab045.build_tx_cs8(plan))
if waveform.dtype != np.complex64 or waveform.size != plan.waveform_sample_count:
raise ValueError("Lab045 waveform имеет неожиданную форму")
samples[cursor : cursor + waveform.size] = waveform
cursor += int(waveform.size)
if frame_index < len(gaps):
cursor += gaps[frame_index]
if cursor + trailing_background_samples != total_samples:
raise RuntimeError("внутренняя ошибка учёта длины IQ stream")
_apply_impairments_in_place(
samples,
cfo_hz=cfo_hz,
phase_rad=phase_rad,
amplitude=amplitude,
noise_sigma=noise_sigma,
noise_seed=noise_seed,
)
return ContinuousIqPlan(
samples=samples,
frame_plans=plans,
frame_starts=tuple(frame_starts),
leading_background_samples=leading_background_samples,
inter_frame_gaps=gaps,
trailing_background_samples=trailing_background_samples,
cfo_hz=cfo_hz,
phase_rad=phase_rad,
amplitude=amplitude,
noise_sigma=noise_sigma,
noise_seed=noise_seed,
malformed_candidate_start=malformed_start,
malformed_candidate_sample_count=malformed_count,
)
@dataclass(frozen=True)
class IqPreambleCandidate:
coarse_start_sample: int
score: float
over_median_db: float
over_second_db: float
def _decimate_then_downconvert(samples: np.ndarray) -> np.ndarray:
"""Получить компактный detection stream без full-size baseband копии."""
decimation = lab045.lab044.lab043.DETECTION_DECIMATION
decimated = lab045.lab044.lab043.resample_poly(
samples,
1,
decimation,
window=("kaiser", 8),
).astype(np.complex64, copy=False)
decimated -= np.mean(decimated, dtype=np.complex128).astype(np.complex64)
sample_rate = lab045.SAMPLE_RATE_HZ / decimation
indexes = np.arange(decimated.size, dtype=np.float64)
rotation = np.exp(
-2j * math.pi * lab045.CARRIER_OFFSET_HZ * indexes / sample_rate
).astype(np.complex64)
decimated *= rotation
return decimated
def _local_candidate_indexes(scores: np.ndarray, threshold: float) -> np.ndarray:
"""Вернуть все локальные максимумы выше порога без global peak/second gate."""
values = np.asarray(scores, dtype=np.float32)
if values.ndim != 1 or values.size == 0:
return np.empty(0, dtype=np.int64)
if not 0.0 < threshold <= 1.0:
raise ValueError("threshold должен лежать в (0, 1]")
left = np.empty_like(values)
right = np.empty_like(values)
left[0] = -np.inf
left[1:] = values[:-1]
right[-1] = -np.inf
right[:-1] = values[1:]
return np.flatnonzero(
(values >= np.float32(threshold))
& (values >= left)
& (values > right)
)
def _detect_iq_preamble_candidates(
samples: np.ndarray,
threshold: float,
) -> tuple[tuple[IqPreambleCandidate, ...], int]:
reference = lab045.lab044._preamble_reference_samples()
rx_decimated = _decimate_then_downconvert(samples)
reference_decimated = _decimate_then_downconvert(reference)
if rx_decimated.size < reference_decimated.size:
return (), int(rx_decimated.nbytes + reference_decimated.nbytes)
scores = np.asarray(
lab045.lab044.lab043._normalized_sliding_correlation(
rx_decimated,
reference_decimated,
),
dtype=np.float32,
)
scores[~np.isfinite(scores)] = 0.0
scores = np.clip(scores, 0.0, 1.0)
indexes = _local_candidate_indexes(scores, threshold)
median = float(np.median(scores))
candidate_scores = scores[indexes]
decimation = lab045.lab044.lab043.DETECTION_DECIMATION
candidates: list[IqPreambleCandidate] = []
for position, index in enumerate(indexes):
if candidate_scores.size > 1:
others = np.delete(candidate_scores, position)
second = float(np.max(others))
else:
second = 0.0
score = float(scores[index])
candidates.append(IqPreambleCandidate(
coarse_start_sample=int(index) * decimation,
score=score,
over_median_db=(
_amplitude_db(score / median) if median > 0.0 else float("inf")
),
over_second_db=(
_amplitude_db(score / second) if second > 0.0 else float("inf")
),
))
storage_bytes = int(
rx_decimated.nbytes + reference_decimated.nbytes + scores.nbytes
)
return tuple(candidates), storage_bytes
@dataclass(frozen=True)
class SoftWindow:
passed: bool
normalized_real: np.ndarray
timing_offset_samples: int
symbol_sample_phase: int
nominal_sample: int
carrier: dict
reason: str | None
def _extract_soft_window(
samples: np.ndarray,
frame_start: int,
symbol_count: int,
) -> SoftWindow:
waveform_samples = (
(symbol_count + 2 * lab045.GUARD_SYMBOL_COUNT) * lab045.SAMPLES_PER_SYMBOL
+ lab045.RRC_SPAN_SYMBOLS * lab045.SAMPLES_PER_SYMBOL
)
if frame_start < 0 or frame_start + waveform_samples > samples.size:
return SoftWindow(False, np.empty(0), 0, 0, 0, {}, "TRUNCATED_IQ_WINDOW")
segment = samples[frame_start : frame_start + waveform_samples]
matched, nominal = lab045.lab044.lab043._matched_waveform(
segment,
0,
waveform_samples,
)
preamble_bits = lab045.lab044.preamble_bits()
preamble_symbols = radio.bpsk_modulate(preamble_bits)
timing, _ = lab045.lab044.lab043._timing_offsets(
matched,
nominal,
preamble_symbols,
)
carrier = lab045.lab044.lab043._evaluate_symbols(
matched,
nominal,
timing,
preamble_bits,
lab045.PREAMBLE_BIT_COUNT,
)
if carrier["abs_h"] == 0.0:
return SoftWindow(False, np.empty(0), timing, 0, nominal, carrier, "ZERO_GAIN")
symbols = matched[
nominal + timing : nominal + timing + symbol_count * lab045.SAMPLES_PER_SYMBOL : lab045.SAMPLES_PER_SYMBOL
].astype(np.complex128)
if symbols.size != symbol_count:
return SoftWindow(False, np.empty(0), timing, 0, nominal, carrier, "TRUNCATED_SYMBOLS")
symbol_time = (np.arange(symbol_count, dtype=np.float64) + 0.5) / lab045.SYMBOL_RATE
normalized = (
symbols
* np.exp(-2j * math.pi * carrier["cfo_hz"] * symbol_time)
/ (carrier["abs_h"] * np.exp(1j * carrier["phase_rad"]))
)
sample_phase = int((frame_start + nominal + timing) % lab045.SAMPLES_PER_SYMBOL)
return SoftWindow(
True,
normalized.real.astype(np.float64, copy=False),
int(timing),
sample_phase,
int(nominal),
carrier,
None,
)
@dataclass(frozen=True)
class IqFrameReception:
detection_number: int
coarse_start_sample: int
refined_start_sample: int
frame_end_sample: int
preamble_score: float
over_median_db: float
over_second_db: float
timing_offset_samples: int
symbol_sample_phase: int
nominal_sample: int
estimated_cfo_hz: float
estimated_phase_rad: float
bpsk_polarity: str
preamble_errors: int
snr_db: float
post_preamble_soft: np.ndarray
reception: lab045.IndependentReception
@dataclass(frozen=True)
class CandidateDecode:
refined_start_sample: int
next_sample: int
phy_header_pass: bool
fec_pass: bool
crc: str
accepted: bool
reason: str
frame: IqFrameReception | None
def _decode_iq_candidate(
samples: np.ndarray,
candidate: IqPreambleCandidate,
detection_number: int,
) -> CandidateDecode:
reference = lab045.lab044._preamble_reference_samples()
try:
refined = lab045.lab044.lab043._refine_frame_start(
samples,
reference,
candidate.coarse_start_sample,
)
except (ValueError, IndexError):
return CandidateDecode(
candidate.coarse_start_sample,
candidate.coarse_start_sample + 1,
False,
False,
lab045.CRC_NOT_REACHED,
False,
"REFINEMENT_FAILED",
None,
)
probe_symbols = lab045.PREAMBLE_BIT_COUNT + lab045.PHY_HEADER_BIT_COUNT
probe = _extract_soft_window(samples, refined, probe_symbols)
if not probe.passed:
return CandidateDecode(
refined,
candidate.coarse_start_sample + 1,
False,
False,
lab045.CRC_NOT_REACHED,
False,
probe.reason or "PROBE_FAILED",
None,
)
header = lab045.decode_phy_header_soft(
probe.normalized_real[
lab045.PREAMBLE_BIT_COUNT : lab045.PREAMBLE_BIT_COUNT + lab045.PHY_HEADER_BIT_COUNT
]
)
if not header["passed"]:
return CandidateDecode(
refined,
candidate.coarse_start_sample + 1,
False,
False,
lab045.CRC_NOT_REACHED,
False,
f"PHY_HEADER_{header['reason']}",
None,
)
packet_bytes = int(header["packet_bytes"])
coded_length = lab045.conv.encoded_bit_count(packet_bytes * 8)
frame_symbols = lab045.PREAMBLE_BIT_COUNT + lab045.PHY_HEADER_BIT_COUNT + coded_length
full = _extract_soft_window(samples, refined, frame_symbols)
if not full.passed:
return CandidateDecode(
refined,
candidate.coarse_start_sample + 1,
True,
False,
lab045.CRC_NOT_REACHED,
False,
full.reason or "FULL_EXTRACTION_FAILED",
None,
)
post_preamble = full.normalized_real[lab045.PREAMBLE_BIT_COUNT:]
reception = lab045.analyze_post_preamble_soft(post_preamble)
crc = str(reception.analysis["crc"])
accepted = crc == lab045.CRC_PASS
waveform_samples = (
(frame_symbols + 2 * lab045.GUARD_SYMBOL_COUNT) * lab045.SAMPLES_PER_SYMBOL
+ lab045.RRC_SPAN_SYMBOLS * lab045.SAMPLES_PER_SYMBOL
)
frame_end = refined + waveform_samples
preamble_hard = (
full.normalized_real[: lab045.PREAMBLE_BIT_COUNT] >= 0.0
).astype(np.uint8)
preamble_errors = int(np.count_nonzero(
preamble_hard != lab045.lab044.preamble_bits()
))
carrier = full.carrier
frame = IqFrameReception(
detection_number=detection_number,
coarse_start_sample=candidate.coarse_start_sample,
refined_start_sample=refined,
frame_end_sample=frame_end,
preamble_score=candidate.score,
over_median_db=candidate.over_median_db,
over_second_db=candidate.over_second_db,
timing_offset_samples=full.timing_offset_samples,
symbol_sample_phase=full.symbol_sample_phase,
nominal_sample=full.nominal_sample,
estimated_cfo_hz=float(carrier["cfo_hz"]),
estimated_phase_rad=float(carrier["phase_rad"]),
bpsk_polarity=str(carrier["polarity"]),
preamble_errors=preamble_errors,
snr_db=float(carrier["snr_db"]),
post_preamble_soft=post_preamble,
reception=reception,
)
reason = "CRC_PASS" if accepted else f"CRC_{crc}"
return CandidateDecode(
refined,
frame_end if accepted else candidate.coarse_start_sample + 1,
True,
bool(reception.analysis["fec_decode_pass"]),
crc,
accepted,
reason,
frame,
)
@dataclass(frozen=True)
class ContinuousIqReceiveResult:
iq_sample_count: int
iq_dtype: str
preamble_candidates: int
processed_candidates: int
rejected_candidates: int
phy_header_pass_count: int
fec_pass_count: int
crc_pass_count: int
crc_fail_count: int
accepted_frames: tuple[IqFrameReception, ...]
rejected_reasons: tuple[str, ...]
candidate_starts: tuple[int, ...]
cursor_history: tuple[int, ...]
search_iterations: int
termination_reason: str
max_frames_reached: bool
max_search_iterations_reached: bool
detection_storage_bytes: int
def scan_continuous_iq(
iq_samples: np.ndarray,
*,
preamble_threshold: float = IQ_PREAMBLE_THRESHOLD,
max_frames: int = MAX_FRAMES,
max_search_iterations: int = MAX_SEARCH_ITERATIONS,
) -> ContinuousIqReceiveResult:
"""Найти несколько кадров только по одному IQ stream, без TX offsets."""
try:
samples = np.asarray(iq_samples, dtype=np.complex64)
except (TypeError, ValueError):
samples = np.empty((0, 0), dtype=np.complex64)
if not 0.0 < preamble_threshold <= 1.0:
raise ValueError("preamble_threshold должен лежать в (0, 1]")
if max_frames < 0 or max_search_iterations < 0:
raise ValueError("safety limits не могут быть отрицательными")
if samples.ndim != 1 or samples.size == 0 or not np.all(np.isfinite(samples)):
return ContinuousIqReceiveResult(
int(samples.size),
str(samples.dtype),
0,
0,
0,
0,
0,
0,
0,
(),
("INVALID_IQ_STREAM",),
(),
(0,),
0,
"INVALID_IQ_STREAM",
False,
False,
0,
)
candidates, storage_bytes = _detect_iq_preamble_candidates(
samples,
preamble_threshold,
)
cursor = 0
cursor_history = [cursor]
accepted_frames: list[IqFrameReception] = []
rejected_reasons: list[str] = []
candidate_starts: list[int] = []
phy_header_pass = 0
fec_pass = 0
crc_pass = 0
crc_fail = 0
iterations = 0
termination = "END_OF_STREAM"
max_frames_reached = False
max_iterations_reached = False
for candidate in candidates:
if candidate.coarse_start_sample < cursor:
continue
if len(accepted_frames) >= max_frames:
termination = "MAX_FRAMES_REACHED"
max_frames_reached = True
break
if iterations >= max_search_iterations:
termination = "MAX_SEARCH_ITERATIONS_REACHED"
max_iterations_reached = True
break
iterations += 1
candidate_starts.append(candidate.coarse_start_sample)
decoded = _decode_iq_candidate(samples, candidate, iterations)
phy_header_pass += int(decoded.phy_header_pass)
fec_pass += int(decoded.fec_pass)
crc_pass += int(decoded.crc == lab045.CRC_PASS)
crc_fail += int(decoded.crc == lab045.CRC_FAIL)
if decoded.accepted and decoded.frame is not None:
accepted_frames.append(decoded.frame)
else:
rejected_reasons.append(decoded.reason)
next_cursor = max(cursor + 1, int(decoded.next_sample))
cursor = next_cursor
cursor_history.append(cursor)
return ContinuousIqReceiveResult(
iq_sample_count=int(samples.size),
iq_dtype=str(samples.dtype),
preamble_candidates=len(candidates),
processed_candidates=iterations,
rejected_candidates=len(rejected_reasons),
phy_header_pass_count=phy_header_pass,
fec_pass_count=fec_pass,
crc_pass_count=crc_pass,
crc_fail_count=crc_fail,
accepted_frames=tuple(accepted_frames),
rejected_reasons=tuple(rejected_reasons),
candidate_starts=tuple(candidate_starts),
cursor_history=tuple(cursor_history),
search_iterations=iterations,
termination_reason=termination,
max_frames_reached=max_frames_reached,
max_search_iterations_reached=max_iterations_reached,
detection_storage_bytes=storage_bytes,
)
@dataclass(frozen=True)
class FrameDiagnostic:
detection_number: int
tx_frame_index: int
sample_offset: int
sample_offset_error: int
symbol_sample_phase: int
estimated_cfo_hz: float
estimated_channel_phase_rad: float
preamble_errors: int
over_median_db: float
over_second_db: float
snr_db: float
pre_fec_coded_bit_errors: int
post_fec_protocol_bit_errors: int
crc: str
def diagnostics_after_crc(
receive: ContinuousIqReceiveResult,
stream_plan: ContinuousIqPlan,
) -> tuple[FrameDiagnostic, ...]:
"""Сопоставить RX с TX только после независимого CRC PASS."""
plan_by_packet = {
plan.packet: (frame_index, plan)
for frame_index, plan in enumerate(stream_plan.frame_plans)
}
rows: list[FrameDiagnostic] = []
for frame in receive.accepted_frames:
packet = frame.reception.packet_bytes
if packet not in plan_by_packet:
continue
frame_index, plan = plan_by_packet[packet]
comparison = lab045.diagnostic_pre_and_post_fec(
frame.reception,
frame.post_preamble_soft,
plan,
)
pre_fec_errors = comparison["pre_fec_coded"]["bit_errors"]
post_fec_errors = comparison["post_fec_packet"]["bit_errors"]
if pre_fec_errors is None or post_fec_errors is None:
raise RuntimeError("CRC PASS frame оказался несопоставим с TX diagnostic")
phase_reference_sample = (
frame.refined_start_sample
+ frame.nominal_sample
+ frame.timing_offset_samples
- 0.5 * lab045.SAMPLES_PER_SYMBOL
)
estimated_channel_phase = _wrap_phase(
frame.estimated_phase_rad
- 2.0
* math.pi
* frame.estimated_cfo_hz
* phase_reference_sample
/ lab045.SAMPLE_RATE_HZ
)
rows.append(FrameDiagnostic(
detection_number=frame.detection_number,
tx_frame_index=frame_index,
sample_offset=frame.refined_start_sample,
sample_offset_error=(
frame.refined_start_sample - stream_plan.frame_starts[frame_index]
),
symbol_sample_phase=frame.symbol_sample_phase,
estimated_cfo_hz=frame.estimated_cfo_hz,
estimated_channel_phase_rad=estimated_channel_phase,
preamble_errors=frame.preamble_errors,
over_median_db=frame.over_median_db,
over_second_db=frame.over_second_db,
snr_db=frame.snr_db,
pre_fec_coded_bit_errors=int(pre_fec_errors),
post_fec_protocol_bit_errors=int(post_fec_errors),
crc=str(frame.reception.analysis["crc"]),
))
return tuple(rows)
@dataclass(frozen=True)
class IqScenarioResult:
name: str
frame_count: int
sample_count: int
iq_dtype: str
iq_memory_bytes: int
frame_starts: tuple[int, ...]
inter_frame_gaps: tuple[int, ...]
leading_background_samples: int
trailing_background_samples: int
cfo_hz: float
phase_rad: float
amplitude: float
noise_sigma: float
noise_seed: int
malformed_candidate_start: int | None
receive: ContinuousIqReceiveResult
diagnostics: tuple[FrameDiagnostic, ...]
@property
def pre_fec_coded_bit_errors(self) -> int:
return sum(row.pre_fec_coded_bit_errors for row in self.diagnostics)
@property
def post_fec_protocol_bit_errors(self) -> int:
return sum(row.post_fec_protocol_bit_errors for row in self.diagnostics)
@property
def estimated_cfo_hz(self) -> float | None:
if not self.diagnostics:
return None
return float(np.median([row.estimated_cfo_hz for row in self.diagnostics]))
@property
def estimated_phase_rad(self) -> float | None:
if not self.diagnostics:
return None
values = np.asarray(
[row.estimated_channel_phase_rad for row in self.diagnostics],
dtype=np.float64,
)
return float(np.angle(np.mean(np.exp(1j * values))))
def run_iq_scenario(
name: str,
frame_plans: tuple[lab045.TransmissionPlan, ...],
**stream_arguments,
) -> IqScenarioResult:
stream_plan = build_continuous_iq_stream(frame_plans, **stream_arguments)
receive = scan_continuous_iq(stream_plan.samples)
diagnostics = diagnostics_after_crc(receive, stream_plan)
return IqScenarioResult(
name=name,
frame_count=len(frame_plans),
sample_count=stream_plan.sample_count,
iq_dtype=str(stream_plan.samples.dtype),
iq_memory_bytes=stream_plan.memory_bytes,
frame_starts=stream_plan.frame_starts,
inter_frame_gaps=stream_plan.inter_frame_gaps,
leading_background_samples=stream_plan.leading_background_samples,
trailing_background_samples=stream_plan.trailing_background_samples,
cfo_hz=stream_plan.cfo_hz,
phase_rad=stream_plan.phase_rad,
amplitude=stream_plan.amplitude,
noise_sigma=stream_plan.noise_sigma,
noise_seed=stream_plan.noise_seed,
malformed_candidate_start=stream_plan.malformed_candidate_start,
receive=receive,
diagnostics=diagnostics,
)
@dataclass(frozen=True)
class Lab046bValidation:
ideal: IqScenarioResult
impaired: IqScenarioResult
noisy: IqScenarioResult
malformed: IqScenarioResult
def run_lab046b_validation(frame_count: int = FRAME_COUNT) -> Lab046bValidation:
plans = build_frame_plans(frame_count)
gaps = INTER_FRAME_GAPS[: frame_count - 1]
if len(gaps) != frame_count - 1:
gaps = tuple(137 + 2 * index for index in range(frame_count - 1))
common = {
"leading_background_samples": LEADING_BACKGROUND_SAMPLES,
"inter_frame_gaps": gaps,
"trailing_background_samples": TRAILING_BACKGROUND_SAMPLES,
}
ideal = run_iq_scenario("IDEAL", plans, **common)
impaired = run_iq_scenario(
"CFO_PHASE_AMPLITUDE",
plans,
**common,
cfo_hz=IMPAIRED_CFO_HZ,
phase_rad=IMPAIRED_PHASE_RAD,
amplitude=IMPAIRED_AMPLITUDE,
)
noisy = run_iq_scenario(
"COMPLEX_GAUSSIAN_NOISE",
plans,
**common,
noise_sigma=NOISE_SIGMA,
noise_seed=NOISE_SEED,
)
malformed = run_iq_scenario(
"MALFORMED_CANDIDATE",
plans[:1],
leading_background_samples=LEADING_BACKGROUND_SAMPLES,
inter_frame_gaps=(),
trailing_background_samples=TRAILING_BACKGROUND_SAMPLES,
prepend_malformed_candidate=True,
)
return Lab046bValidation(ideal, impaired, noisy, malformed)
def run_functional_tests() -> None:
scores = np.asarray([0.0, 0.8, 0.1, 0.9, 0.0], dtype=np.float32)
assert _local_candidate_indexes(scores, 0.65).tolist() == [1, 3]
assert scan_continuous_iq(np.empty(0, dtype=np.complex64)).termination_reason == "INVALID_IQ_STREAM"
def _print_scenario(result: IqScenarioResult) -> None:
print(f"{result.name}:")
print(f" samples = {result.sample_count}")
print(f" dtype = {result.iq_dtype}")
print(f" preamble candidates = {result.receive.preamble_candidates}")
print(f" processed candidates = {result.receive.processed_candidates}")
print(f" accepted frames = {len(result.receive.accepted_frames)}")
print(f" rejected candidates = {result.receive.rejected_candidates}")
print(f" PHY header PASS = {result.receive.phy_header_pass_count}")
print(f" FEC PASS = {result.receive.fec_pass_count}")
print(f" CRC PASS/FAIL = {result.receive.crc_pass_count}/{result.receive.crc_fail_count}")
print(f" pre/post-FEC bit errors = {result.pre_fec_coded_bit_errors}/{result.post_fec_protocol_bit_errors}")
print(f" estimated CFO = {result.estimated_cfo_hz}")
print(f" estimated phase = {result.estimated_phase_rad}")
for row in result.diagnostics:
print(
" frame "
f"{row.tx_frame_index}: offset={row.sample_offset}, "
f"offset_error={row.sample_offset_error}, "
f"sample_phase={row.symbol_sample_phase}, "
f"CFO={row.estimated_cfo_hz:.9f} Hz, "
f"phase={row.estimated_channel_phase_rad:.9f} rad, "
f"preamble_errors={row.preamble_errors}, "
f"peak/median={row.over_median_db:.3f} dB, "
f"peak/second={row.over_second_db:.3f} dB, "
f"SNR={row.snr_db:.3f} dB, "
f"pre/post-FEC={row.pre_fec_coded_bit_errors}/"
f"{row.post_fec_protocol_bit_errors}, CRC={row.crc}"
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--frame-count", type=int, default=FRAME_COUNT)
arguments = parser.parse_args()
result = run_lab046b_validation(arguments.frame_count)
print("LAB046B SOFTWARE CONTINUOUS IQ VALIDATION")
print("RF TX = 0")
print("known TX frame boundaries at RX = NO")
print(
f"main IQ = {result.ideal.sample_count} samples, "
f"{result.ideal.iq_dtype}, {result.ideal.iq_memory_bytes} bytes"
)
for scenario in (result.ideal, result.impaired, result.noisy, result.malformed):
_print_scenario(scenario)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,886 @@
"""Lab046C: подготовка одного непрерывного аппаратного BPSK-сеанса.
Модуль связывает уже проверенные части Lab043--Lab046B. Подготовка создаёт
новый случайный объект 16 KiB и последовательно записывает 68 Lab045 waveform
в один cs8-файл. Приёмник читает будущий cs8 capture окнами через memmap и не
получает TX offsets, ожидаемый порядок фрагментов или переданный объект.
Аппаратный режим закрыт двумя явными ключами. ``--preflight-only`` выполняет
только read-only inventory и разрешённые короткие RX общего clock-gate. Сам
RF-сеанс этим модулем в Lab046C preparation не запускается: RF TX = 0.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import secrets
import subprocess
import sys
import time
from dataclasses import dataclass, replace
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
from experiments import lab044_hackrf_bpsk_packet as lab044
from experiments import lab045_hackrf_bpsk_fec as lab045
from experiments import lab046_bpsk_object_transport as lab046
from experiments import lab046b_continuous_iq as lab046b
OBJECT_SIZE_BYTES = 16_384
OBJECT_ID = lab046.DEFAULT_OBJECT_ID
MASTER_SERIAL = lab044.lab043.MASTER_SERIAL
SLAVE_SERIAL = lab044.lab043.SLAVE_SERIAL
RF_HZ = 1_080_000_000
SAMPLE_RATE_HZ = 8_000_000
SYMBOL_RATE = 20_000
SAMPLES_PER_SYMBOL = 400
CARRIER_OFFSET_HZ = 50_000
TX_VGA_DB = 10
RX_LNA_DB = 24
RX_VGA_DB = 24
RX_CAPTURE_SECONDS = 24.0
RX_CAPTURE_SAMPLE_COUNT = 192_000_000
RX_CAPTURE_FILE_BYTES = 384_000_000
RX_LEAD_SECONDS = 0.75
RX_WAIT_TIMEOUT_SECONDS = 35.0
DETECTION_CHUNK_SAMPLES = 16_000_000
REFINEMENT_MARGIN_SAMPLES = 4_000
MAX_FRAMES = 256
MAX_SEARCH_ITERATIONS = 2_048
RECEIVER_ORACLE_KEYS = {
"tx_start_offset",
"frame_start_offsets",
"tx_waveform_lengths",
"expected_fragment_count",
"tx_packets_or_coded_bits",
"tx_object_before_completion_verdict",
}
def _receiver_oracle_declaration_is_safe(value: object) -> bool:
if not isinstance(value, dict):
return False
return (
set(value) == RECEIVER_ORACLE_KEYS
and all(flag is False for flag in value.values())
)
def _validate_fixed_phy() -> None:
actual = (
lab045.RF_HZ,
lab045.SAMPLE_RATE_HZ,
lab045.SYMBOL_RATE,
lab045.SAMPLES_PER_SYMBOL,
lab045.CARRIER_OFFSET_HZ,
lab045.TX_VGA_DB,
lab045.RX_LNA_DB,
lab045.RX_VGA_DB,
)
expected = (
RF_HZ,
SAMPLE_RATE_HZ,
SYMBOL_RATE,
SAMPLES_PER_SYMBOL,
CARRIER_OFFSET_HZ,
TX_VGA_DB,
RX_LNA_DB,
RX_VGA_DB,
)
if actual != expected:
raise RuntimeError(f"Фактические PHY-параметры {actual} не совпали с {expected}")
if RX_CAPTURE_SAMPLE_COUNT != int(RX_CAPTURE_SECONDS * SAMPLE_RATE_HZ):
raise RuntimeError("RX sample accounting не совпал")
if RX_CAPTURE_FILE_BYTES != 2 * RX_CAPTURE_SAMPLE_COUNT:
raise RuntimeError("RX cs8 byte accounting не совпал")
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with Path(path).open("rb") as stream:
while block := stream.read(1024 * 1024):
digest.update(block)
return digest.hexdigest()
def default_output_directory() -> Path:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return (
Path.home()
/ "Documents"
/ "SDR_Rover_Lab046_raw"
/ f"{timestamp}_bpsk_object"
)
@dataclass(frozen=True)
class PreparedTransmission:
output_directory: Path
object_path: Path
stream_path: Path
metadata_path: Path
object_sha256: str
stream_sha256: str
fragment_count: int
frame_count: int
frame_samples: int
stream_sample_count: int
stream_file_bytes: int
stream_duration_seconds: float
ObjectFactory = Callable[[int], bytes]
def prepare_transmission(
output_directory: Path,
*,
object_factory: ObjectFactory = secrets.token_bytes,
) -> PreparedTransmission:
"""Создать новый объект и один cs8 TX stream без RF и полных RAM-копий."""
_validate_fixed_phy()
output_directory = Path(output_directory)
output_directory.mkdir(parents=True, exist_ok=False)
object_path = output_directory / "tx_object.bin"
stream_path = output_directory / "tx_stream.cs8"
metadata_path = output_directory / "tx_metadata.json"
data = bytes(object_factory(OBJECT_SIZE_BYTES))
if len(data) != OBJECT_SIZE_BYTES:
raise ValueError("object_factory вернул неверное число байт")
object_path.write_bytes(data)
object_digest = _sha256_file(object_path)
frames = lab046.build_object_frames(data, OBJECT_ID)
if not frames:
raise RuntimeError("Фрагментация не создала кадров")
fragment_count = frames[0].fragment_count
frame_samples = frames[0].plan.waveform_sample_count
if len(frames) != fragment_count:
raise RuntimeError("Число FramePlan не совпало с fragment_count")
if any(frame.fragment_count != fragment_count for frame in frames):
raise RuntimeError("Фрагменты содержат разные fragment_count")
if any(frame.plan.waveform_sample_count != frame_samples for frame in frames):
raise RuntimeError("Lab046C ожидает одинаковую длину полных Lab045 waveform")
stream_digest_builder = hashlib.sha256()
stream_file_bytes = 0
with stream_path.open("xb") as stream:
for frame in frames:
waveform = lab045.build_tx_cs8(frame.plan)
expected_bytes = 2 * frame.plan.waveform_sample_count
if len(waveform) != expected_bytes:
raise RuntimeError("Lab045 waveform не прошёл cs8 accounting")
stream.write(waveform)
stream_digest_builder.update(waveform)
stream_file_bytes += len(waveform)
stream_sample_count = sum(frame.plan.waveform_sample_count for frame in frames)
expected_stream_bytes = 2 * stream_sample_count
if stream_file_bytes != expected_stream_bytes or stream_path.stat().st_size != expected_stream_bytes:
raise RuntimeError("TX stream file size не совпал с frame accounting")
stream_digest = stream_digest_builder.hexdigest()
if _sha256_file(stream_path) != stream_digest:
raise RuntimeError("TX stream SHA-256 не совпал при повторном чтении")
metadata = {
"created_utc": datetime.now(timezone.utc).isoformat(),
"stage": "LAB046C_PREPARATION_ONLY",
"rf_tx_count": 0,
"object": {
"path": str(object_path),
"bytes": len(data),
"sha256": object_digest,
"source": "cryptographic_random_bytes",
"deterministic_seed": None,
"object_id": OBJECT_ID,
},
"fragmentation": {
"fragment_data_bytes": lab046.FRAGMENT_DATA_SIZE,
"fragment_count": fragment_count,
"last_fragment_data_bytes": frames[-1].encoded_payload_length
- lab046.fragments.FRAGMENT_HEADER_SIZE,
},
"tx_stream": {
"path": str(stream_path),
"format": "cs8 interleaved signed I/Q",
"frame_count": len(frames),
"samples_per_frame": frame_samples,
"sample_count": stream_sample_count,
"file_bytes": stream_file_bytes,
"duration_seconds": stream_sample_count / SAMPLE_RATE_HZ,
"sha256": stream_digest,
"inter_frame_samples_added": 0,
},
"planned_rx": {
"duration_seconds": RX_CAPTURE_SECONDS,
"sample_count": RX_CAPTURE_SAMPLE_COUNT,
"file_bytes": RX_CAPTURE_FILE_BYTES,
"format": "cs8 interleaved signed I/Q",
"justification": (
"0.75 s before TX plus about 3.65 s after the 19.6044 s TX stream"
),
},
"hardware": hardware_parameters(),
"receiver_oracles": {
key: False for key in sorted(RECEIVER_ORACLE_KEYS)
},
"recovered_object": None,
}
metadata_path.write_text(
json.dumps(metadata, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return PreparedTransmission(
output_directory,
object_path,
stream_path,
metadata_path,
object_digest,
stream_digest,
fragment_count,
len(frames),
frame_samples,
stream_sample_count,
stream_file_bytes,
stream_sample_count / SAMPLE_RATE_HZ,
)
def hardware_parameters() -> dict:
return {
"master_tx_serial": MASTER_SERIAL,
"slave_rx_serial": SLAVE_SERIAL,
"frequency_hz": RF_HZ,
"sample_rate_hz": SAMPLE_RATE_HZ,
"symbol_rate": SYMBOL_RATE,
"samples_per_symbol": SAMPLES_PER_SYMBOL,
"digital_carrier_hz": CARRIER_OFFSET_HZ,
"tx_vga_db": TX_VGA_DB,
"tx_rf_amp": False,
"tx_bias_tee": False,
"rx_lna_db": RX_LNA_DB,
"rx_vga_db": RX_VGA_DB,
"rx_rf_amp": False,
"rx_bias_tee": False,
"master_clkout_to_slave_clkin": True,
"slave_clkout": False,
"clock_initial_settle_seconds": lab044.lab043.CLOCK_INITIAL_SETTLE_SECONDS,
"clock_poll_interval_seconds": lab044.lab043.CLOCK_POLL_INTERVAL_SECONDS,
"clock_timeout_seconds": lab044.lab043.CLOCK_TIMEOUT_SECONDS,
"expected_reg29": lab044.lab043.REG29_EXTERNAL_CLOCK,
}
def maximum_phy_waveform_samples() -> int:
coded_bits = lab045.conv.encoded_bit_count(lab045.MAX_PACKET_BYTES * 8)
symbols = lab045.PREAMBLE_BIT_COUNT + lab045.PHY_HEADER_BIT_COUNT + coded_bits
return (
(symbols + 2 * lab045.GUARD_SYMBOL_COUNT) * lab045.SAMPLES_PER_SYMBOL
+ lab045.RRC_SPAN_SYMBOLS * lab045.SAMPLES_PER_SYMBOL
)
def _cs8_window(raw: np.memmap, start_sample: int, end_sample: int) -> np.ndarray:
"""Преобразовать только одно окно interleaved int8 в complex64."""
if start_sample < 0 or end_sample < start_sample:
raise ValueError("Некорректные границы cs8 window")
byte_start = 2 * start_sample
byte_end = 2 * end_sample
codes = raw[byte_start:byte_end]
if codes.size != 2 * (end_sample - start_sample):
raise ValueError("Усечённое cs8 window")
samples = np.empty(end_sample - start_sample, dtype=np.complex64)
samples.real = np.asarray(codes[0::2], dtype=np.float32) / np.float32(128.0)
samples.imag = np.asarray(codes[1::2], dtype=np.float32) / np.float32(128.0)
return samples
@dataclass(frozen=True)
class WindowedIqReceiveResult:
iq_sample_count: int
iq_file_bytes: int
iq_storage_dtype: str
processing_dtype: str
maximum_window_samples: int
maximum_window_bytes: int
preamble_candidates: int
processed_candidates: int
rejected_candidates: int
phy_header_pass_count: int
fec_pass_count: int
crc_pass_count: int
crc_fail_count: int
accepted_frames: tuple[lab046b.IqFrameReception, ...]
rejected_reasons: tuple[str, ...]
cursor_history: tuple[int, ...]
search_iterations: int
termination_reason: str
def scan_cs8_capture(
capture_path: Path,
*,
preamble_threshold: float = lab046b.IQ_PREAMBLE_THRESHOLD,
max_frames: int = MAX_FRAMES,
max_search_iterations: int = MAX_SEARCH_ITERATIONS,
chunk_samples: int = DETECTION_CHUNK_SAMPLES,
) -> WindowedIqReceiveResult:
"""Оконно найти кадры в одном cs8 capture без каких-либо TX-границ."""
capture_path = Path(capture_path)
file_bytes = capture_path.stat().st_size
if file_bytes <= 0 or file_bytes % 2:
raise ValueError("cs8 capture должен иметь положительный чётный размер")
if max_frames < 0 or max_search_iterations < 0:
raise ValueError("safety limits не могут быть отрицательными")
overlap = maximum_phy_waveform_samples() + REFINEMENT_MARGIN_SAMPLES
decimation = lab044.lab043.DETECTION_DECIMATION
overlap = ((overlap + decimation - 1) // decimation) * decimation
chunk_samples = (chunk_samples // decimation) * decimation
if chunk_samples <= overlap:
raise ValueError("chunk_samples должен быть больше полного PHY window")
sample_count = file_bytes // 2
raw = np.memmap(capture_path, dtype=np.int8, mode="r")
step = chunk_samples - overlap
accepted_frames: list[lab046b.IqFrameReception] = []
rejected_reasons: list[str] = []
cursor = 0
cursor_history = [0]
candidate_count = 0
iterations = 0
phy_header_pass = 0
fec_pass = 0
crc_pass = 0
crc_fail = 0
maximum_window = 0
termination = "END_OF_STREAM"
for window_start in range(0, sample_count, step):
if len(accepted_frames) >= max_frames:
termination = "MAX_FRAMES_REACHED"
break
if iterations >= max_search_iterations:
termination = "MAX_SEARCH_ITERATIONS_REACHED"
break
window_end = min(sample_count, window_start + chunk_samples)
ownership_end = sample_count if window_end == sample_count else window_start + step
samples = _cs8_window(raw, window_start, window_end)
maximum_window = max(maximum_window, samples.size)
candidates, _ = lab046b._detect_iq_preamble_candidates(samples, preamble_threshold)
for candidate in candidates:
global_coarse = window_start + candidate.coarse_start_sample
if global_coarse < window_start or global_coarse >= ownership_end:
continue
candidate_count += 1
if global_coarse < cursor:
continue
if len(accepted_frames) >= max_frames:
termination = "MAX_FRAMES_REACHED"
break
if iterations >= max_search_iterations:
termination = "MAX_SEARCH_ITERATIONS_REACHED"
break
iterations += 1
decoded = lab046b._decode_iq_candidate(samples, candidate, iterations)
phy_header_pass += int(decoded.phy_header_pass)
fec_pass += int(decoded.fec_pass)
crc_pass += int(decoded.crc == lab045.CRC_PASS)
crc_fail += int(decoded.crc == lab045.CRC_FAIL)
global_next = window_start + int(decoded.next_sample)
if decoded.accepted and decoded.frame is not None:
frame = replace(
decoded.frame,
detection_number=iterations,
coarse_start_sample=window_start + decoded.frame.coarse_start_sample,
refined_start_sample=window_start + decoded.frame.refined_start_sample,
frame_end_sample=window_start + decoded.frame.frame_end_sample,
)
accepted_frames.append(frame)
else:
rejected_reasons.append(decoded.reason)
cursor = max(cursor + 1, global_next)
cursor_history.append(cursor)
if termination != "END_OF_STREAM":
break
if window_end == sample_count:
break
return WindowedIqReceiveResult(
iq_sample_count=sample_count,
iq_file_bytes=file_bytes,
iq_storage_dtype="interleaved int8 I/Q",
processing_dtype="complex64",
maximum_window_samples=maximum_window,
maximum_window_bytes=maximum_window * np.dtype(np.complex64).itemsize,
preamble_candidates=candidate_count,
processed_candidates=iterations,
rejected_candidates=len(rejected_reasons),
phy_header_pass_count=phy_header_pass,
fec_pass_count=fec_pass,
crc_pass_count=crc_pass,
crc_fail_count=crc_fail,
accepted_frames=tuple(accepted_frames),
rejected_reasons=tuple(rejected_reasons),
cursor_history=tuple(cursor_history),
search_iterations=iterations,
termination_reason=termination,
)
@dataclass(frozen=True)
class ObjectReceiveResult:
iq: WindowedIqReceiveResult
valid_fragments: int
duplicates: int
conflicting: int
rejected_fragments: int
missing_fragments: tuple[int, ...]
object_complete: bool
recovered_object: bytes | None
def receive_object_from_cs8(
capture_path: Path,
*,
preamble_threshold: float = lab046b.IQ_PREAMBLE_THRESHOLD,
max_frames: int = MAX_FRAMES,
max_search_iterations: int = MAX_SEARCH_ITERATIONS,
chunk_samples: int = DETECTION_CHUNK_SAMPLES,
) -> ObjectReceiveResult:
"""Независимо принять объект; TX object/count/offsets в API отсутствуют."""
iq = scan_cs8_capture(
capture_path,
preamble_threshold=preamble_threshold,
max_frames=max_frames,
max_search_iterations=max_search_iterations,
chunk_samples=chunk_samples,
)
assembler = lab046.ObjectAssembler()
for frame in iq.accepted_frames:
payload = frame.reception.payload_bytes
fragment = lab046.recover_fragment_from_payload(payload) if payload is not None else None
assembler.offer(fragment)
recovered = assembler.recovered_object()
return ObjectReceiveResult(
iq=iq,
valid_fragments=assembler.counters.accepted,
duplicates=assembler.counters.duplicates,
conflicting=assembler.counters.conflicting,
rejected_fragments=assembler.counters.rejected,
missing_fragments=tuple(assembler.missing_indexes()),
object_complete=assembler.object_complete(),
recovered_object=recovered,
)
def run_common_clock_preflight_only(
runner: lab044.lab043.CommandRunner | None = None,
*,
process_checker: lab045.ProcessChecker = lab045._running_hackrf_transfer_processes,
) -> dict:
"""Проверить inventory/common clock без RF TX и гарантированно выключить CLKOUT."""
runner = runner or lab044.lab043._default_command_runner
guard = lab044.lab043.TransmitGuard()
cleanup: dict = {}
failure: Exception | None = None
inventory: dict | None = None
preflight: dict | None = None
try:
running_before = process_checker()
if running_before:
raise RuntimeError(f"До preflight уже запущен hackrf_transfer: {running_before}")
inventory = lab045._verify_device_inventory(runner)
preflight = lab044.lab043.common_clock_preflight(runner, guard)
except Exception as error:
failure = error
finally:
clock_errors = lab044.lab043._disable_clock_outputs(runner)
cleanup["clock_outputs_off"] = not clock_errors
cleanup["clock_output_errors"] = clock_errors
try:
lingering = process_checker()
except Exception as error:
cleanup["lingering_hackrf_transfer"] = None
cleanup["process_check_error"] = str(error)
else:
cleanup["lingering_hackrf_transfer"] = lingering
cleanup["no_lingering_hackrf_transfer"] = not lingering
cleanup_failure = None
if not cleanup.get("clock_outputs_off", False):
cleanup_failure = "CLKOUT cleanup не подтверждён"
if cleanup.get("no_lingering_hackrf_transfer") is not True:
cleanup_failure = "после preflight остался или не проверен hackrf_transfer"
if failure is not None:
if cleanup_failure is not None:
raise RuntimeError(f"{failure}; {cleanup_failure}") from failure
raise failure
if cleanup_failure is not None:
raise RuntimeError(cleanup_failure)
assert inventory is not None and preflight is not None
if guard.transmit_calls != 0:
raise RuntimeError("Preflight-only нарушил RF TX count = 0")
return {
"passed": True,
"inventory": inventory,
"common_clock": preflight,
"cleanup": cleanup,
"rf_tx_count": 0,
}
def _load_and_validate_prepared(output_directory: Path) -> PreparedTransmission:
metadata_path = output_directory / "tx_metadata.json"
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
object_path = output_directory / "tx_object.bin"
stream_path = output_directory / "tx_stream.cs8"
tx = metadata["tx_stream"]
obj = metadata["object"]
prepared = PreparedTransmission(
output_directory,
object_path,
stream_path,
metadata_path,
str(obj["sha256"]),
str(tx["sha256"]),
int(metadata["fragmentation"]["fragment_count"]),
int(tx["frame_count"]),
int(tx["samples_per_frame"]),
int(tx["sample_count"]),
int(tx["file_bytes"]),
float(tx["duration_seconds"]),
)
if metadata.get("stage") != "LAB046C_PREPARATION_ONLY":
raise RuntimeError("tx_metadata.json имеет неверный stage")
if metadata.get("rf_tx_count") != 0:
raise RuntimeError("Подготовленный metadata уже объявляет RF TX")
if int(obj.get("object_id", -1)) != OBJECT_ID:
raise RuntimeError("Object ID не совпал с фиксированным Lab046C")
if Path(obj.get("path", "")).resolve() != object_path.resolve():
raise RuntimeError("Object path в metadata не совпал с каталогом")
if Path(tx.get("path", "")).resolve() != stream_path.resolve():
raise RuntimeError("TX stream path в metadata не совпал с каталогом")
if object_path.stat().st_size != OBJECT_SIZE_BYTES:
raise RuntimeError("tx_object.bin имеет неверный размер")
tx_object = object_path.read_bytes()
expected_frames = lab046.build_object_frames(tx_object, OBJECT_ID)
expected_frame_count = len(expected_frames)
expected_frame_samples = expected_frames[0].plan.waveform_sample_count
expected_stream_samples = sum(
frame.plan.waveform_sample_count for frame in expected_frames
)
expected_stream_bytes = 2 * expected_stream_samples
expected_duration = expected_stream_samples / SAMPLE_RATE_HZ
fragmentation = metadata.get("fragmentation", {})
exact_accounting = (
prepared.fragment_count == expected_frame_count == 68
and prepared.frame_count == expected_frame_count
and prepared.frame_samples == expected_frame_samples == 2_306_400
and prepared.stream_sample_count == expected_stream_samples == 156_835_200
and prepared.stream_file_bytes == expected_stream_bytes == 313_670_400
and abs(prepared.stream_duration_seconds - expected_duration) <= 1e-12
and int(fragmentation.get("fragment_data_bytes", -1)) == lab046.FRAGMENT_DATA_SIZE
and int(fragmentation.get("fragment_count", -1)) == expected_frame_count
and int(fragmentation.get("last_fragment_data_bytes", -1)) == 36
and int(tx.get("inter_frame_samples_added", -1)) == 0
)
if not exact_accounting:
raise RuntimeError("Metadata не прошёл точный Lab046C frame/stream accounting")
if metadata.get("hardware") != hardware_parameters():
raise RuntimeError("Hardware parameters в metadata не совпали с фиксированными")
oracle_flags = metadata.get("receiver_oracles", {})
if not _receiver_oracle_declaration_is_safe(oracle_flags):
raise RuntimeError("Metadata допускает TX oracle в приёмнике")
if stream_path.stat().st_size != prepared.stream_file_bytes:
raise RuntimeError("tx_stream.cs8 имеет неверный размер")
if _sha256_file(object_path) != prepared.object_sha256:
raise RuntimeError("tx_object.bin SHA-256 не совпал с metadata")
if _sha256_file(stream_path) != prepared.stream_sha256:
raise RuntimeError("tx_stream.cs8 SHA-256 не совпал с metadata")
return prepared
def run_hardware_experiment(
output_directory: Path,
runner: lab044.lab043.CommandRunner | None = None,
process_starter: lab044.lab043.ProcessStarter | None = None,
sleep_function: lab044.lab043.SleepFunction = time.sleep,
process_checker: lab045.ProcessChecker = lab045._running_hackrf_transfer_processes,
receiver: Callable[[Path], ObjectReceiveResult] = receive_object_from_cs8,
) -> dict:
"""Один будущий RF-сеанс без retry; вызывать только после отдельного разрешения."""
_validate_fixed_phy()
runner = runner or lab044.lab043._default_command_runner
process_starter = process_starter or lab044.lab043._default_process_starter
output_directory = Path(output_directory)
prepared = _load_and_validate_prepared(output_directory)
guard = lab044.lab043.TransmitGuard()
rx_process: lab044.lab043.RunningCommand | None = None
cleanup: dict = {}
final_result: dict | None = None
try:
if process_checker():
raise RuntimeError("До Lab046C уже запущен hackrf_transfer")
inventory = lab045._verify_device_inventory(runner)
preflight = lab044.lab043.common_clock_preflight(runner, guard)
rx_path = output_directory / "rx_stream.cs8"
rx_stdout_path = output_directory / "rx_stdout.txt"
rx_stderr_path = output_directory / "rx_stderr.txt"
tx_stdout_path = output_directory / "tx_stdout.txt"
tx_stderr_path = output_directory / "tx_stderr.txt"
transfer_tool = str(lab044.lab043.HACKRF_TOOLS_DIR / "hackrf_transfer.exe")
rx_command = [
transfer_tool,
"-d", SLAVE_SERIAL,
"-r", str(rx_path),
"-f", str(RF_HZ),
"-s", str(SAMPLE_RATE_HZ),
"-n", str(RX_CAPTURE_SAMPLE_COUNT),
"-l", str(RX_LNA_DB),
"-g", str(RX_VGA_DB),
"-a", "0",
"-p", "0",
"-B",
]
rx_process = process_starter(rx_command, rx_stdout_path, rx_stderr_path)
sleep_function(RX_LEAD_SECONDS)
if rx_process.poll() is not None:
rx_process.close_logs()
detail = rx_stderr_path.read_text(encoding="utf-8", errors="replace")
raise RuntimeError(f"RX завершился до разрешения TX: {detail.strip()}")
tx_command = [
transfer_tool,
"-d", MASTER_SERIAL,
"-t", str(prepared.stream_path),
"-f", str(RF_HZ),
"-s", str(SAMPLE_RATE_HZ),
"-n", str(prepared.stream_sample_count),
"-x", str(TX_VGA_DB),
"-a", "0",
"-p", "0",
"-B",
]
guard.register_transmit()
tx_result = runner(tx_command)
tx_stdout_path.write_text(tx_result.stdout or "", encoding="utf-8")
tx_stderr_path.write_text(tx_result.stderr or "", encoding="utf-8")
if tx_result.returncode != 0:
raise RuntimeError(f"Единственный TX завершился с кодом {tx_result.returncode}")
if rx_process.wait(RX_WAIT_TIMEOUT_SECONDS) != 0:
raise RuntimeError("RX завершился с ошибкой")
if rx_path.stat().st_size != RX_CAPTURE_FILE_BYTES:
raise RuntimeError("rx_stream.cs8 имеет неверный размер")
rx_log = rx_stdout_path.read_text(encoding="utf-8", errors="replace") + rx_stderr_path.read_text(
encoding="utf-8", errors="replace"
)
tx_log = (tx_result.stdout or "") + (tx_result.stderr or "")
if not lab044.lab043._transfer_log_is_clean(rx_log):
raise RuntimeError("RX log содержит overrun/underrun")
if not lab044.lab043._transfer_log_is_clean(tx_log):
raise RuntimeError("TX log содержит overrun/underrun")
received = receiver(rx_path)
recovered = received.recovered_object
if recovered is not None:
(output_directory / "recovered_object.bin").write_bytes(recovered)
# Только после независимого прохода всего RX и verdict assembler-а
# читается TX object для диагностического сравнения.
tx_object = prepared.object_path.read_bytes()
tx_frames = {
frame.fragment_index: frame
for frame in lab046.build_object_frames(tx_object, OBJECT_ID)
}
frame_diagnostics: list[dict] = []
total_pre_fec_errors = 0
total_post_fec_errors = 0
for frame in received.iq.accepted_frames:
payload = frame.reception.payload_bytes
fragment = lab046.recover_fragment_from_payload(payload) if payload is not None else None
fragment_index = fragment.fragment_index if fragment is not None else None
pre_fec_errors = None
post_fec_errors = None
if fragment_index in tx_frames:
comparison = lab045.diagnostic_pre_and_post_fec(
frame.reception,
frame.post_preamble_soft,
tx_frames[fragment_index].plan,
)
pre_fec_errors = comparison["pre_fec_coded"]["bit_errors"]
post_fec_errors = comparison["post_fec_packet"]["bit_errors"]
if pre_fec_errors is not None:
total_pre_fec_errors += int(pre_fec_errors)
if post_fec_errors is not None:
total_post_fec_errors += int(post_fec_errors)
frame_diagnostics.append({
"detection_number": frame.detection_number,
"sample_offset": frame.refined_start_sample,
"preamble_errors": frame.preamble_errors,
"peak_over_median_db": frame.over_median_db,
"estimated_cfo_hz": frame.estimated_cfo_hz,
"estimated_phase_rad": frame.estimated_phase_rad,
"snr_db": frame.snr_db,
"phy_header_pass": True,
"fec_pass": bool(frame.reception.analysis["fec_decode_pass"]),
"crc": str(frame.reception.analysis["crc"]),
"fragment_index": fragment_index,
"pre_fec_coded_bit_errors_after_crc": pre_fec_errors,
"post_fec_protocol_bit_errors_after_crc": post_fec_errors,
})
byte_errors = None
bit_errors = None
sha_match = False
if recovered is not None and len(recovered) == len(tx_object):
tx_values = np.frombuffer(tx_object, dtype=np.uint8)
rx_values = np.frombuffer(recovered, dtype=np.uint8)
xor = np.bitwise_xor(tx_values, rx_values)
byte_errors = int(np.count_nonzero(xor))
bit_errors = int(np.unpackbits(xor).sum())
sha_match = hashlib.sha256(recovered).hexdigest() == prepared.object_sha256
if received.object_complete and recovered is not None and sha_match and byte_errors == 0:
classification = "A"
elif received.valid_fragments > 0:
classification = "B"
else:
classification = "C"
result = {
"classification": classification,
"rf_tx_count": guard.transmit_calls,
"inventory": inventory,
"preflight": preflight,
"rx_capture_duration_seconds": RX_CAPTURE_SECONDS,
"rx_iq_sample_count": received.iq.iq_sample_count,
"preamble_candidates": received.iq.preamble_candidates,
"rejected_candidates": received.iq.rejected_candidates,
"valid_frames": len(received.iq.accepted_frames),
"phy_header_pass": received.iq.phy_header_pass_count,
"fec_pass": received.iq.fec_pass_count,
"crc_pass": received.iq.crc_pass_count,
"crc_fail": received.iq.crc_fail_count,
"valid_fragments": received.valid_fragments,
"duplicates": received.duplicates,
"missing_fragments": list(received.missing_fragments),
"object_complete": received.object_complete,
"total_pre_fec_coded_bit_errors": total_pre_fec_errors,
"total_post_fec_protocol_bit_errors": total_post_fec_errors,
"object_byte_errors": byte_errors,
"object_bit_errors": bit_errors,
"sha_match": sha_match,
"frames": frame_diagnostics,
"rx_sha256": _sha256_file(rx_path),
"recovered_object": (
str(output_directory / "recovered_object.bin") if recovered is not None else None
),
"cleanup": cleanup,
}
final_result = result
finally:
if rx_process is not None:
try:
rx_process.terminate()
except Exception as error:
cleanup.setdefault("rx_process_errors", []).append(str(error))
cleanup["clock_output_errors"] = lab044.lab043._disable_clock_outputs(runner)
try:
cleanup["lingering_hackrf_transfer"] = process_checker()
except Exception as error:
cleanup["process_check_error"] = str(error)
assert final_result is not None
cleanup["clock_outputs_off"] = not cleanup.get("clock_output_errors")
cleanup["no_lingering_hackrf_transfer"] = (
cleanup.get("lingering_hackrf_transfer") == []
and "process_check_error" not in cleanup
)
cleanup_passed = bool(
cleanup["clock_outputs_off"]
and cleanup["no_lingering_hackrf_transfer"]
)
final_result["cleanup_passed"] = cleanup_passed
if not cleanup_passed:
final_result["classification"] = "HARDWARE_ERROR_AFTER_TX"
(output_directory / "result.json").write_text(
json.dumps(final_result, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return final_result
def run_functional_tests() -> None:
_validate_fixed_phy()
frames = lab046.build_object_frames(bytes(OBJECT_SIZE_BYTES), OBJECT_ID)
assert len(frames) == 68
assert frames[0].plan.waveform_sample_count == 2_306_400
assert sum(frame.plan.waveform_sample_count for frame in frames) == 156_835_200
assert maximum_phy_waveform_samples() > frames[0].plan.waveform_sample_count
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--prepare", action="store_true")
parser.add_argument("--preflight-only", action="store_true")
parser.add_argument("--hardware", action="store_true")
parser.add_argument("--i-understand-hardware", action="store_true")
parser.add_argument("--authorize-one-rf-tx", action="store_true")
parser.add_argument("--output-directory", type=Path)
arguments = parser.parse_args()
selected_modes = sum((arguments.prepare, arguments.preflight_only, arguments.hardware))
if selected_modes > 1:
parser.error("Выберите только один режим")
if arguments.hardware:
if not arguments.i_understand_hardware or not arguments.authorize_one_rf_tx:
print("Аппаратный RF-режим заблокирован без двух явных ключей; RF TX = 0")
return 2
if arguments.output_directory is None:
parser.error("--hardware требует --output-directory с готовыми artifacts")
result = run_hardware_experiment(arguments.output_directory)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if arguments.preflight_only:
if not arguments.i_understand_hardware:
print("Common-clock preflight заблокирован без --i-understand-hardware; RF TX = 0")
return 2
result = run_common_clock_preflight_only()
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if arguments.prepare:
prepared = prepare_transmission(
arguments.output_directory or default_output_directory()
)
print(json.dumps({
"output_directory": str(prepared.output_directory),
"object_sha256": prepared.object_sha256,
"stream_sha256": prepared.stream_sha256,
"frame_count": prepared.frame_count,
"stream_samples": prepared.stream_sample_count,
"stream_file_bytes": prepared.stream_file_bytes,
"stream_duration_seconds": prepared.stream_duration_seconds,
"rf_tx_count": 0,
}, ensure_ascii=False, indent=2))
return 0
run_functional_tests()
print("LAB046C SOFTWARE PREFLIGHT = PASS")
print("RF TX = 0")
return 0
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,291 @@
"""Проверки Lab046B: несколько полных BPSK/FEC кадров в одном IQ stream."""
from __future__ import annotations
import inspect
from functools import lru_cache
import numpy as np
import pytest
from experiments import lab046b_continuous_iq as lab046b
@lru_cache(maxsize=1)
def full_validation() -> lab046b.Lab046bValidation:
"""Один дорогой интеграционный прогон для всего test-модуля."""
return lab046b.run_lab046b_validation()
@lru_cache(maxsize=1)
def two_frame_ideal() -> lab046b.IqScenarioResult:
plans = lab046b.build_frame_plans(2)
return lab046b.run_iq_scenario(
"TWO_FRAME_IDEAL",
plans,
leading_background_samples=137,
inter_frame_gaps=(211,),
trailing_background_samples=233,
)
def test_two_sequential_iq_frames_found_without_known_boundaries() -> None:
result = two_frame_ideal()
assert result.frame_count == 2
assert len(result.receive.accepted_frames) == 2
assert result.receive.crc_pass_count == 2
assert all(row.sample_offset_error == 0 for row in result.diagnostics)
def test_five_full_iq_frames_pass_complete_receiver_chain() -> None:
result = full_validation().ideal
assert result.frame_count == 5
assert len(result.receive.accepted_frames) == 5
assert result.receive.phy_header_pass_count == 5
assert result.receive.fec_pass_count == 5
assert result.receive.crc_pass_count == 5
assert result.receive.crc_fail_count == 0
assert result.post_fec_protocol_bit_errors == 0
def test_first_frame_offset_and_inter_frame_gaps_are_not_symbol_aligned() -> None:
result = full_validation().ideal
assert result.leading_background_samples == 137
assert result.frame_starts[0] == 137
assert result.frame_starts[0] % lab046b.lab045.SAMPLES_PER_SYMBOL != 0
assert result.inter_frame_gaps == (211, 353, 587, 809)
assert any(
gap % lab046b.lab045.SAMPLES_PER_SYMBOL != 0
for gap in result.inter_frame_gaps
)
assert [row.symbol_sample_phase for row in result.diagnostics] == [137, 348, 301, 88, 97]
def test_each_real_frame_is_accepted_exactly_once_and_tail_adds_none() -> None:
result = full_validation().ideal
accepted = result.receive.accepted_frames
starts = [frame.refined_start_sample for frame in accepted]
assert len(starts) == len(set(starts)) == 5
assert starts == list(result.frame_starts)
assert result.trailing_background_samples == 233
assert result.receive.crc_pass_count == 5
def test_multiple_strong_real_peaks_do_not_fail_peak_over_second_gate() -> None:
result = full_validation().ideal
assert result.receive.preamble_candidates >= 5
assert len(result.receive.accepted_frames) == 5
# Другие настоящие кадры дают почти такой же пик: старый mandatory gate
# peak/second здесь не прошёл бы, но многокадровое решение его не использует.
assert max(abs(row.over_second_db) for row in result.diagnostics) < 0.05
def test_local_candidate_finder_keeps_two_equal_strong_peaks() -> None:
scores = np.asarray([0.0, 0.91, 0.0, 0.90, 0.0], dtype=np.float32)
assert lab046b._local_candidate_indexes(scores, 0.65).tolist() == [1, 3]
def test_cfo_phase_and_amplitude_recovered_without_truth_in_scanner_api() -> None:
result = full_validation().impaired
parameters = set(inspect.signature(lab046b.scan_continuous_iq).parameters)
assert parameters == {
"iq_samples",
"preamble_threshold",
"max_frames",
"max_search_iterations",
}
assert result.cfo_hz == pytest.approx(0.2)
assert result.phase_rad == pytest.approx(0.7)
assert result.amplitude == pytest.approx(0.65)
assert result.estimated_cfo_hz == pytest.approx(0.2, abs=0.01)
assert result.estimated_phase_rad == pytest.approx(0.7, abs=0.02)
assert result.receive.crc_pass_count == 5
assert all(
frame.bpsk_polarity == "resolved from preamble"
for frame in result.receive.accepted_frames
)
def test_complex_iq_noise_is_deterministic_and_crc_clean() -> None:
first = np.zeros(4096, dtype=np.complex64)
second = np.zeros(4096, dtype=np.complex64)
arguments = {
"cfo_hz": 0.0,
"phase_rad": 0.0,
"amplitude": 1.0,
"noise_sigma": lab046b.NOISE_SIGMA,
"noise_seed": lab046b.NOISE_SEED,
}
lab046b._apply_impairments_in_place(first, **arguments)
lab046b._apply_impairments_in_place(second, **arguments)
assert np.array_equal(first, second)
result = full_validation().noisy
assert result.noise_seed == 46002
assert result.noise_sigma == pytest.approx(0.17)
assert result.receive.crc_pass_count == 5
assert result.receive.crc_fail_count == 0
assert result.pre_fec_coded_bit_errors == 0
assert result.post_fec_protocol_bit_errors == 0
def test_malformed_iq_candidate_rejected_then_real_frame_found() -> None:
result = full_validation().malformed
assert result.malformed_candidate_start == 137
assert result.receive.preamble_candidates > 1
assert result.receive.rejected_candidates >= 1
assert any(reason.startswith("PHY_HEADER_") for reason in result.receive.rejected_reasons)
assert len(result.receive.accepted_frames) == 1
assert result.receive.crc_pass_count == 1
assert result.diagnostics[0].sample_offset_error == 0
def _fake_frame(start: int, end: int) -> lab046b.IqFrameReception:
reception = lab046b.lab045.IndependentReception(
{
"fec_decode_pass": True,
"crc": lab046b.lab045.CRC_PASS,
},
b"",
b"",
)
return lab046b.IqFrameReception(
detection_number=1,
coarse_start_sample=start,
refined_start_sample=start,
frame_end_sample=end,
preamble_score=0.9,
over_median_db=20.0,
over_second_db=0.0,
timing_offset_samples=0,
symbol_sample_phase=0,
nominal_sample=0,
estimated_cfo_hz=0.0,
estimated_phase_rad=0.0,
bpsk_polarity="resolved from preamble",
preamble_errors=0,
snr_db=20.0,
post_preamble_soft=np.empty(0),
reception=reception,
)
def _fake_candidates(*starts: int) -> tuple[lab046b.IqPreambleCandidate, ...]:
return tuple(
lab046b.IqPreambleCandidate(start, 0.9, 20.0, 0.0)
for start in starts
)
def test_rejected_candidate_continues_and_cursor_is_monotonic(monkeypatch) -> None:
monkeypatch.setattr(
lab046b,
"_detect_iq_preamble_candidates",
lambda samples, threshold: (_fake_candidates(10, 30), 0),
)
def decode(samples, candidate, detection_number):
if candidate.coarse_start_sample == 10:
return lab046b.CandidateDecode(
10,
11,
False,
False,
lab046b.lab045.CRC_NOT_REACHED,
False,
"PHY_HEADER_BAD_MAGIC",
None,
)
frame = _fake_frame(30, 60)
return lab046b.CandidateDecode(
30,
60,
True,
True,
lab046b.lab045.CRC_PASS,
True,
"CRC_PASS",
frame,
)
monkeypatch.setattr(lab046b, "_decode_iq_candidate", decode)
result = lab046b.scan_continuous_iq(np.ones(100, dtype=np.complex64))
assert result.rejected_candidates == 1
assert len(result.accepted_frames) == 1
assert all(
after > before
for before, after in zip(result.cursor_history, result.cursor_history[1:])
)
def test_max_search_iterations_is_fail_closed(monkeypatch) -> None:
monkeypatch.setattr(
lab046b,
"_detect_iq_preamble_candidates",
lambda samples, threshold: (_fake_candidates(10, 20, 30), 0),
)
monkeypatch.setattr(
lab046b,
"_decode_iq_candidate",
lambda samples, candidate, detection_number: lab046b.CandidateDecode(
candidate.coarse_start_sample,
candidate.coarse_start_sample + 1,
False,
False,
lab046b.lab045.CRC_NOT_REACHED,
False,
"BAD",
None,
),
)
result = lab046b.scan_continuous_iq(
np.ones(100, dtype=np.complex64),
max_search_iterations=1,
)
assert result.search_iterations == 1
assert result.max_search_iterations_reached is True
assert result.termination_reason == "MAX_SEARCH_ITERATIONS_REACHED"
def test_max_frames_is_fail_closed(monkeypatch) -> None:
monkeypatch.setattr(
lab046b,
"_detect_iq_preamble_candidates",
lambda samples, threshold: (_fake_candidates(10, 40), 0),
)
def decode(samples, candidate, detection_number):
start = candidate.coarse_start_sample
frame = _fake_frame(start, start + 20)
return lab046b.CandidateDecode(
start,
start + 20,
True,
True,
lab046b.lab045.CRC_PASS,
True,
"CRC_PASS",
frame,
)
monkeypatch.setattr(lab046b, "_decode_iq_candidate", decode)
result = lab046b.scan_continuous_iq(
np.ones(100, dtype=np.complex64),
max_frames=1,
)
assert len(result.accepted_frames) == 1
assert result.max_frames_reached is True
assert result.termination_reason == "MAX_FRAMES_REACHED"
def test_main_iq_memory_accounting_is_complex64() -> None:
result = full_validation().ideal
assert result.iq_dtype == "complex64"
assert result.sample_count == 11_534_330
assert result.iq_memory_bytes == result.sample_count * np.dtype(np.complex64).itemsize
assert result.iq_memory_bytes == 92_274_640
def test_small_functional_checks() -> None:
lab046b.run_functional_tests()

View File

@@ -0,0 +1,350 @@
"""Аппаратно-независимые проверки Lab046C preparation и fail-closed runner."""
from __future__ import annotations
import inspect
import json
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
from experiments import lab046c_hackrf_bpsk_object as lab046c
def small_waveform_setup(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(lab046c, "OBJECT_SIZE_BYTES", 300)
monkeypatch.setattr(lab046c, "_validate_fixed_phy", lambda: None)
monkeypatch.setattr(
lab046c.lab045,
"build_tx_cs8",
lambda plan: bytes([plan.payload[12] & 0x7F, 0]) * plan.waveform_sample_count,
)
def test_frame_and_stream_accounting_matches_actual_code() -> None:
frames = lab046c.lab046.build_object_frames(bytes(lab046c.OBJECT_SIZE_BYTES))
assert len(frames) == 68
assert frames[-1].encoded_payload_length - 12 == 36
assert {frame.plan.waveform_sample_count for frame in frames} == {2_306_400}
assert sum(frame.plan.waveform_sample_count for frame in frames) == 156_835_200
assert 2 * sum(frame.plan.waveform_sample_count for frame in frames) == 313_670_400
assert sum(frame.plan.airtime_seconds for frame in frames) == pytest.approx(19.6044)
def test_preparation_writes_one_stream_sequentially_and_hashes(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
import hashlib
small_waveform_setup(monkeypatch)
output = tmp_path / "prepared"
prepared = lab046c.prepare_transmission(
output,
object_factory=lambda count: bytes((index * 17 + 3) % 256 for index in range(count)),
)
metadata = json.loads(prepared.metadata_path.read_text(encoding="utf-8"))
assert prepared.object_path.stat().st_size == 300
assert prepared.frame_count == 2
assert prepared.fragment_count == 2
assert prepared.stream_path.stat().st_size == prepared.stream_file_bytes
assert hashlib.sha256(prepared.object_path.read_bytes()).hexdigest() == prepared.object_sha256
assert hashlib.sha256(prepared.stream_path.read_bytes()).hexdigest() == prepared.stream_sha256
assert metadata["rf_tx_count"] == 0
assert metadata["object"]["deterministic_seed"] is None
assert metadata["tx_stream"]["inter_frame_samples_added"] == 0
assert metadata["recovered_object"] is None
def test_receiver_api_has_no_tx_oracle() -> None:
parameters = set(inspect.signature(lab046c.receive_object_from_cs8).parameters)
assert parameters == {
"capture_path",
"preamble_threshold",
"max_frames",
"max_search_iterations",
"chunk_samples",
}
forbidden = {
"tx_starts",
"frame_starts",
"frame_lengths",
"expected_fragment_count",
"tx_object",
"tx_packets",
"tx_coded_bits",
}
assert parameters.isdisjoint(forbidden)
def test_cs8_window_is_complex64_and_exact(tmp_path: Path) -> None:
path = tmp_path / "capture.cs8"
path.write_bytes(np.asarray([64, -64, -128, 127, 0, 1], dtype=np.int8).tobytes())
raw = np.memmap(path, dtype=np.int8, mode="r")
samples = lab046c._cs8_window(raw, 0, 3)
assert samples.dtype == np.complex64
assert samples[0] == pytest.approx(0.5 - 0.5j)
assert samples[1].real == pytest.approx(-1.0)
assert samples[1].imag == pytest.approx(127 / 128)
def fake_frame(local_start: int, local_end: int) -> lab046c.lab046b.IqFrameReception:
reception = lab046c.lab045.IndependentReception(
{"fec_decode_pass": True, "crc": lab046c.lab045.CRC_PASS},
b"packet",
bytes(lab046c.lab045.PAYLOAD_SIZE_BYTES),
)
return lab046c.lab046b.IqFrameReception(
detection_number=1,
coarse_start_sample=local_start,
refined_start_sample=local_start,
frame_end_sample=local_end,
preamble_score=0.9,
over_median_db=20.0,
over_second_db=0.0,
timing_offset_samples=0,
symbol_sample_phase=0,
nominal_sample=0,
estimated_cfo_hz=0.0,
estimated_phase_rad=0.0,
bpsk_polarity="resolved from preamble",
preamble_errors=0,
snr_db=20.0,
post_preamble_soft=np.empty(0),
reception=reception,
)
def test_windowed_scanner_owns_each_candidate_once_and_cursor_moves_forward(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
path = tmp_path / "capture.cs8"
path.write_bytes(bytes(2 * 80))
monkeypatch.setattr(lab046c, "maximum_phy_waveform_samples", lambda: 16)
monkeypatch.setattr(lab046c, "REFINEMENT_MARGIN_SAMPLES", 0)
monkeypatch.setattr(lab046c.lab044.lab043, "DETECTION_DECIMATION", 8)
def detect(samples, threshold):
del threshold
starts = [8, 24, 40] if len(samples) == 48 else [8]
return tuple(
lab046c.lab046b.IqPreambleCandidate(start, 0.9, 20.0, 0.0)
for start in starts
), 0
def decode(samples, candidate, detection_number):
del samples
start = candidate.coarse_start_sample
frame = fake_frame(start, start + 4)
return lab046c.lab046b.CandidateDecode(
start,
start + 4,
True,
True,
lab046c.lab045.CRC_PASS,
True,
"CRC_PASS",
frame,
)
monkeypatch.setattr(lab046c.lab046b, "_detect_iq_preamble_candidates", detect)
monkeypatch.setattr(lab046c.lab046b, "_decode_iq_candidate", decode)
result = lab046c.scan_cs8_capture(path, chunk_samples=48)
starts = [frame.refined_start_sample for frame in result.accepted_frames]
assert starts == [8, 24, 40, 56, 72]
assert len(starts) == len(set(starts))
assert all(after > before for before, after in zip(result.cursor_history, result.cursor_history[1:]))
assert result.processing_dtype == "complex64"
assert result.maximum_window_samples <= 48
def test_windowed_scanner_safety_limits_fail_closed(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
path = tmp_path / "capture.cs8"
path.write_bytes(bytes(2 * 80))
monkeypatch.setattr(lab046c, "maximum_phy_waveform_samples", lambda: 16)
monkeypatch.setattr(lab046c, "REFINEMENT_MARGIN_SAMPLES", 0)
monkeypatch.setattr(lab046c.lab044.lab043, "DETECTION_DECIMATION", 8)
monkeypatch.setattr(
lab046c.lab046b,
"_detect_iq_preamble_candidates",
lambda samples, threshold: (
tuple(lab046c.lab046b.IqPreambleCandidate(start, 0.9, 20.0, 0.0) for start in (8, 16, 24)),
0,
),
)
monkeypatch.setattr(
lab046c.lab046b,
"_decode_iq_candidate",
lambda samples, candidate, number: lab046c.lab046b.CandidateDecode(
candidate.coarse_start_sample,
candidate.coarse_start_sample + 1,
False,
False,
lab046c.lab045.CRC_NOT_REACHED,
False,
"BAD",
None,
),
)
result = lab046c.scan_cs8_capture(path, chunk_samples=48, max_search_iterations=1)
assert result.search_iterations == 1
assert result.termination_reason == "MAX_SEARCH_ITERATIONS_REACHED"
class FakeRunner:
def __init__(self) -> None:
self.commands: list[list[str]] = []
def __call__(self, command: list[str]):
self.commands.append(command)
return subprocess.CompletedProcess(command, 0, "", "")
def test_preflight_only_never_registers_tx(monkeypatch: pytest.MonkeyPatch) -> None:
runner = FakeRunner()
monkeypatch.setattr(lab046c.lab045, "_verify_device_inventory", lambda runner: {"passed": True})
def preflight(runner, guard):
guard.allow()
return {"passed": True, "master_reg29": 0x26, "slave_reg29": 0x26}
monkeypatch.setattr(lab046c.lab044.lab043, "common_clock_preflight", preflight)
monkeypatch.setattr(lab046c.lab044.lab043, "_disable_clock_outputs", lambda runner: [])
result = lab046c.run_common_clock_preflight_only(runner, process_checker=lambda: [])
assert result["rf_tx_count"] == 0
assert not any("-t" in command for command in runner.commands)
@pytest.mark.parametrize(
("clock_errors", "processes", "message"),
[
(["CLKOUT OFF failed"], [], "CLKOUT cleanup"),
([], ["pid 123"], "hackrf_transfer"),
],
)
def test_preflight_cleanup_failure_is_fail_closed(
monkeypatch: pytest.MonkeyPatch,
clock_errors: list[str],
processes: list[str],
message: str,
) -> None:
monkeypatch.setattr(lab046c.lab045, "_verify_device_inventory", lambda runner: {"passed": True})
def preflight(runner, guard):
guard.allow()
return {"passed": True}
calls = 0
def process_checker():
nonlocal calls
calls += 1
return [] if calls == 1 else processes
monkeypatch.setattr(lab046c.lab044.lab043, "common_clock_preflight", preflight)
monkeypatch.setattr(
lab046c.lab044.lab043,
"_disable_clock_outputs",
lambda runner: clock_errors,
)
with pytest.raises(RuntimeError, match=message):
lab046c.run_common_clock_preflight_only(FakeRunner(), process_checker=process_checker)
def test_prepared_metadata_cannot_self_declare_short_stream(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
import hashlib
output = tmp_path / "forged"
output.mkdir()
object_path = output / "tx_object.bin"
stream_path = output / "tx_stream.cs8"
object_path.write_bytes(bytes(lab046c.OBJECT_SIZE_BYTES))
stream_path.write_bytes(b"\x00\x00")
metadata = {
"stage": "LAB046C_PREPARATION_ONLY",
"rf_tx_count": 0,
"object": {
"path": str(object_path),
"sha256": hashlib.sha256(object_path.read_bytes()).hexdigest(),
"object_id": lab046c.OBJECT_ID,
},
"fragmentation": {
"fragment_data_bytes": lab046c.lab046.FRAGMENT_DATA_SIZE,
"fragment_count": 1,
"last_fragment_data_bytes": 1,
},
"tx_stream": {
"path": str(stream_path),
"sha256": hashlib.sha256(stream_path.read_bytes()).hexdigest(),
"frame_count": 1,
"samples_per_frame": 1,
"sample_count": 1,
"file_bytes": 2,
"duration_seconds": 1 / lab046c.SAMPLE_RATE_HZ,
"inter_frame_samples_added": 0,
},
"hardware": lab046c.hardware_parameters(),
"receiver_oracles": {"tx_start_offset": False},
}
(output / "tx_metadata.json").write_text(json.dumps(metadata), encoding="utf-8")
monkeypatch.setattr(lab046c, "_sha256_file", lambda path: hashlib.sha256(path.read_bytes()).hexdigest())
with pytest.raises(RuntimeError, match="accounting"):
lab046c._load_and_validate_prepared(output)
def test_prepared_metadata_requires_exact_oracle_declaration() -> None:
exact = {key: False for key in lab046c.RECEIVER_ORACLE_KEYS}
assert lab046c._receiver_oracle_declaration_is_safe(exact) is True
assert lab046c._receiver_oracle_declaration_is_safe(
{"tx_start_offset": False}
) is False
with_enabled_oracle = exact | {"tx_start_offset": True}
assert lab046c._receiver_oracle_declaration_is_safe(with_enabled_oracle) is False
def test_hardware_entry_point_requires_two_explicit_keys() -> None:
project_root = Path(__file__).resolve().parents[1]
script = project_root / "experiments" / "lab046c_hackrf_bpsk_object.py"
result = subprocess.run(
[sys.executable, str(script), "--hardware"],
cwd=project_root,
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 2
assert "RF TX = 0" in result.stdout
def test_hardware_sequence_has_rx_before_one_tx_and_cleanup_in_finally() -> None:
source = inspect.getsource(lab046c.run_hardware_experiment)
assert source.index("process_starter(rx_command") < source.index("guard.register_transmit()")
assert source.count("guard.register_transmit()") == 1
assert "finally:" in source
assert "_disable_clock_outputs" in source
assert '"-d", MASTER_SERIAL' in source
assert '"-d", SLAVE_SERIAL' in source
def test_direct_invocation_is_software_only() -> None:
project_root = Path(__file__).resolve().parents[1]
result = subprocess.run(
[sys.executable, str(project_root / "experiments" / "lab046c_hackrf_bpsk_object.py")],
cwd=project_root,
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, result.stderr
assert "LAB046C SOFTWARE PREFLIGHT = PASS" in result.stdout
assert "RF TX = 0" in result.stdout

View File

@@ -0,0 +1,604 @@
from __future__ import annotations
import inspect
from pathlib import Path
import struct
from types import SimpleNamespace
import zlib
import numpy as np
import pytest
from experiments import lab046c_offline_recovery as offline
def _capture_file(path: Path, sample_count: int = 200) -> Path:
path.write_bytes(bytes(2 * sample_count))
return path
def _header_soft(
*,
magic: int | None = None,
version: int | None = None,
length: int = 268,
checksum_delta: int = 0,
) -> np.ndarray:
lab045 = offline.lab046c.lab045
magic = lab045.PHY_MAGIC if magic is None else magic
version = lab045.PHY_HEADER_VERSION if version is None else version
checksum = lab045._phy_header_checksum(magic, version, length) ^ checksum_delta
fields = struct.pack(lab045.PHY_HEADER_FIELDS_FORMAT, magic, version, length, checksum)
bits = offline.lab046c.lab044.bytes_to_bits(fields)
repeated = np.repeat(bits, lab045.HEADER_REPETITION)
return 2.0 * repeated.astype(np.float64) - 1.0
def _soft_window(normalized: np.ndarray) -> offline.lab046b.SoftWindow:
return offline.lab046b.SoftWindow(
True,
normalized,
0,
0,
0,
{
"abs_h": 1.0,
"cfo_hz": 0.0,
"phase_rad": 0.0,
"polarity": "resolved from preamble",
"snr_db": 20.0,
},
None,
)
def _candidate_outcome(
candidate: offline.CoarseCandidate,
*,
accepted: bool,
next_sample: int,
crc: str | None = None,
) -> offline.CandidateOutcome:
crc = crc or (
offline.lab046c.lab045.CRC_PASS
if accepted
else offline.lab046c.lab045.CRC_NOT_REACHED
)
frame = None
if accepted:
reception = offline.lab046c.lab045.IndependentReception(
{
"fec_decode_pass": True,
"protocol_header_pass": True,
"crc": crc,
},
b"packet",
b"payload",
)
frame = offline.lab046b.IqFrameReception(
detection_number=1,
coarse_start_sample=candidate.sample_offset,
refined_start_sample=candidate.sample_offset,
frame_end_sample=next_sample,
preamble_score=candidate.score,
over_median_db=0.0,
over_second_db=0.0,
timing_offset_samples=0,
symbol_sample_phase=0,
nominal_sample=0,
estimated_cfo_hz=0.0,
estimated_phase_rad=0.0,
bpsk_polarity="resolved from preamble",
preamble_errors=0,
snr_db=20.0,
post_preamble_soft=np.empty(0),
reception=reception,
)
return offline.CandidateOutcome(
candidate_offset=candidate.sample_offset,
refined_start_sample=candidate.sample_offset,
next_sample=next_sample,
refinement_pass=True,
timing_pass=True,
carrier_pass=True,
phy_header_attempted=True,
phy_header_pass=accepted,
fec_attempted=accepted,
fec_pass=accepted,
protocol_parse_pass=accepted,
crc=crc,
accepted=accepted,
reason="CRC_PASS" if accepted else "PHY_HEADER_BAD",
frame=frame,
maximum_local_window_samples=10,
elapsed_seconds=0.001,
)
def test_calibration_uses_only_fixed_local_peak_quantile(monkeypatch) -> None:
scores = np.linspace(0.001, 0.03, 1000, dtype=np.float32)
peaks = np.linspace(0.01, 0.04, 101, dtype=np.float32)
monkeypatch.setattr(
offline,
"_background_peak_scores",
lambda path, interval: (scores, peaks, 0.1),
)
rule, statistics = offline.calibrate_candidate_rule(Path("unused"))
assert rule.local_peak_quantile == 0.999
assert rule.threshold == pytest.approx(np.quantile(peaks, 0.999))
assert statistics.candidate_count == int(np.count_nonzero(peaks >= rule.threshold))
def test_1400mhz_threshold_formula_is_reproducible() -> None:
peaks = np.asarray([0.01, 0.02, 0.021, 0.022, 0.024], dtype=np.float32)
first = offline.candidate_threshold_from_local_peaks(
peaks,
quantile=offline.BACKGROUND_1400MHZ_LOCAL_PEAK_QUANTILE,
mad_multiplier=offline.BACKGROUND_1400MHZ_MAD_MULTIPLIER,
)
second = offline.candidate_threshold_from_local_peaks(
peaks.copy(),
quantile=offline.BACKGROUND_1400MHZ_LOCAL_PEAK_QUANTILE,
mad_multiplier=offline.BACKGROUND_1400MHZ_MAD_MULTIPLIER,
)
assert first == second
def test_1400mhz_control_background_creates_no_candidates(monkeypatch) -> None:
scores = np.asarray([0.001, 0.01, 0.02], dtype=np.float32)
peaks = np.asarray([0.02, 0.02711654081940651], dtype=np.float32)
monkeypatch.setattr(
offline,
"_background_peak_scores",
lambda path, interval: (scores, peaks, 0.1),
)
rule = offline.CandidateRule(
"fixed before independent decoding",
0.999,
offline.FIXED_1400MHZ_CANDIDATE_THRESHOLD,
)
statistics = offline.evaluate_1400mhz_control_background(Path("unused"), rule)
assert statistics.candidate_count == 0
assert max(peaks) < rule.threshold
def test_control_background_cannot_change_fixed_rule(monkeypatch) -> None:
peaks = np.asarray([0.01, 0.02, 0.20], dtype=np.float32)
monkeypatch.setattr(
offline,
"_background_peak_scores",
lambda path, interval: (peaks.copy(), peaks, 0.1),
)
rule = offline.CandidateRule("fixed", 0.999, 0.025)
statistics = offline.evaluate_control_background(Path("unused"), rule)
assert rule.threshold == 0.025
assert statistics.candidate_count == 1
def test_deterministic_weak_prbs7_candidate_is_detected() -> None:
reference = offline.lab046c.lab044._preamble_reference_samples()
reference_decimated = offline.lab046b._decimate_then_downconvert(reference)
random_generator = np.random.Generator(np.random.PCG64(46003))
noise = (
random_generator.normal(0.0, 0.03, reference.size + 8_000)
+ 1j * random_generator.normal(0.0, 0.03, reference.size + 8_000)
).astype(np.complex64)
first = noise.copy()
second = noise.copy()
first[4_000 : 4_000 + reference.size] += np.complex64(0.08) * reference
second[4_000 : 4_000 + reference.size] += np.complex64(0.08) * reference
first_scores = offline._correlation_scores(first, reference_decimated)
second_scores = offline._correlation_scores(second, reference_decimated)
assert np.array_equal(first_scores, second_scores)
indexes = offline._all_local_peak_indexes(first_scores)
assert np.max(first_scores[indexes]) > 0.025
def test_multiple_equal_weak_peaks_need_no_global_peak_second_gate() -> None:
scores = np.asarray([0.0, 0.08, 0.0, 0.08, 0.0], dtype=np.float32)
indexes = offline._all_local_peak_indexes(scores)
assert indexes[scores[indexes] >= 0.025].tolist() == [1, 3]
@pytest.mark.parametrize("peak_index", [3, 4])
def test_candidate_at_block_boundary_is_owned_once(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
peak_index: int,
) -> None:
capture = _capture_file(tmp_path / f"boundary_{peak_index}.cs8", 160)
raw = np.memmap(capture, dtype=np.int8, mode="r")
monkeypatch.setattr(offline, "_aligned_overlap", lambda reference_samples: 20)
calls = 0
def score_function(samples, reference):
nonlocal calls
del reference
calls += 1
scores = np.zeros(max(5, samples.size // 20), dtype=np.float32)
if calls == 1:
scores[peak_index] = 0.1
elif peak_index == 4:
scores[0] = 0.1
return scores
starts = []
for window_start, scores, indexes, _, _ in offline._score_blocks(
raw,
offline.SampleInterval("test", 0, 160),
chunk_samples=100,
score_function=score_function,
):
starts.extend(window_start + int(index) * 20 for index in indexes if scores[index] >= 0.025)
assert len(starts) == len(set(starts)) == 1
def test_noise_candidates_never_become_valid_without_crc(
tmp_path: Path,
) -> None:
capture = _capture_file(tmp_path / "noise.cs8")
candidates = tuple(offline.CoarseCandidate(index * 10, 0.03) for index in range(5))
def reject(raw, sample_count, candidate, number):
del raw, sample_count, number
return _candidate_outcome(candidate, accepted=False, next_sample=candidate.sample_offset + 1)
result = offline.strict_process_candidates(capture, candidates, decoder=reject)
assert result.counters.valid_frames == 0
assert result.counters.crc_pass == 0
def test_inconsistent_accepted_flag_cannot_bypass_crc(tmp_path: Path) -> None:
capture = _capture_file(tmp_path / "inconsistent_accept.cs8")
candidate = offline.CoarseCandidate(10, 0.04)
def inconsistent(raw, sample_count, row, number):
del raw, sample_count, number
return _candidate_outcome(
row,
accepted=True,
next_sample=20,
crc=offline.lab046c.lab045.CRC_FAIL,
)
result = offline.strict_process_candidates(
capture,
(candidate,),
decoder=inconsistent,
)
assert result.counters.crc_pass == 0
assert result.counters.crc_fail == 1
assert result.counters.valid_frames == 0
assert result.accepted_frames == ()
def test_weak_peak_only_enters_first_stage_without_crc(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capture = _capture_file(tmp_path / "weak_candidate.cs8")
def blocks(raw, interval, chunk_samples):
del raw, interval, chunk_samples
scores = np.asarray([0.0, 0.04, 0.0], dtype=np.float32)
yield 0, scores, np.asarray([1]), 3, 100
monkeypatch.setattr(offline, "_score_blocks", blocks)
rule = offline.CandidateRule(
"fixed",
0.999,
offline.FIXED_1400MHZ_CANDIDATE_THRESHOLD,
)
coarse = offline.coarse_search_capture(capture, rule)
def reject(raw, sample_count, candidate, number):
del raw, sample_count, number
return _candidate_outcome(
candidate,
accepted=False,
next_sample=candidate.sample_offset + 1,
)
strict = offline.strict_process_candidates(
capture,
coarse.candidates,
decoder=reject,
)
assert len(coarse.candidates) == 1
assert strict.counters.valid_frames == 0
assert strict.counters.crc_pass == 0
@pytest.mark.parametrize(
("header", "reason"),
[
(_header_soft(magic=0x0000), "PHY_HEADER_MAGIC_MISMATCH"),
(_header_soft(version=2), "PHY_HEADER_VERSION_MISMATCH"),
(_header_soft(checksum_delta=1), "PHY_HEADER_CHECKSUM_MISMATCH"),
(_header_soft(length=1), "PHY_HEADER_LENGTH_OUT_OF_RANGE"),
],
)
def test_invalid_phy_header_is_rejected(
monkeypatch: pytest.MonkeyPatch,
header: np.ndarray,
reason: str,
) -> None:
preamble = np.ones(offline.lab046c.lab045.PREAMBLE_BIT_COUNT)
monkeypatch.setattr(offline.lab046c, "_cs8_window", lambda raw, start, end: np.ones(100, np.complex64))
monkeypatch.setattr(
offline.lab046c.lab044.lab043,
"_refine_frame_start",
lambda samples, reference, coarse: coarse,
)
monkeypatch.setattr(
offline.lab046b,
"_extract_soft_window",
lambda samples, start, count: _soft_window(np.concatenate((preamble, header))),
)
outcome = offline.decode_candidate_from_memmap(
np.zeros(2, dtype=np.int8),
10_000_000,
offline.CoarseCandidate(10, 0.03),
1,
)
assert outcome.accepted is False
assert outcome.reason == reason
assert outcome.crc == offline.lab046c.lab045.CRC_NOT_REACHED
@pytest.mark.parametrize(
("fec_pass", "crc"),
[
(False, offline.lab046c.lab045.CRC_NOT_REACHED),
(True, offline.lab046c.lab045.CRC_FAIL),
],
)
def test_fec_or_crc_failure_never_becomes_valid(
monkeypatch: pytest.MonkeyPatch,
fec_pass: bool,
crc: str,
) -> None:
preamble = np.ones(offline.lab046c.lab045.PREAMBLE_BIT_COUNT)
header = _header_soft()
normalized = np.concatenate((preamble, header, np.ones(100)))
monkeypatch.setattr(offline.lab046c, "_cs8_window", lambda raw, start, end: np.ones(100, np.complex64))
monkeypatch.setattr(
offline.lab046c.lab044.lab043,
"_refine_frame_start",
lambda samples, reference, coarse: 0,
)
monkeypatch.setattr(
offline.lab046b,
"_extract_soft_window",
lambda samples, start, count: _soft_window(normalized),
)
reception = offline.lab046c.lab045.IndependentReception(
{
"fec_decode_pass": fec_pass,
"protocol_header_pass": fec_pass,
"crc": crc,
},
None,
None,
)
monkeypatch.setattr(
offline.lab046c.lab045,
"analyze_post_preamble_soft",
lambda soft: reception,
)
outcome = offline.decode_candidate_from_memmap(
np.zeros(2, dtype=np.int8),
10_000_000,
offline.CoarseCandidate(10, 0.03),
1,
)
assert outcome.accepted is False
assert outcome.crc == crc
def test_rejected_candidate_does_not_hide_later_true_frame(tmp_path: Path) -> None:
capture = _capture_file(tmp_path / "later.cs8")
candidates = (
offline.CoarseCandidate(10, 0.03),
offline.CoarseCandidate(30, 0.04),
)
def decoder(raw, sample_count, candidate, number):
del raw, sample_count, number
return _candidate_outcome(
candidate,
accepted=candidate.sample_offset == 30,
next_sample=60 if candidate.sample_offset == 30 else 11,
)
result = offline.strict_process_candidates(capture, candidates, decoder=decoder)
assert result.counters.valid_frames == 1
assert result.accepted_frames[0].refined_start_sample == 30
def test_cursor_is_strictly_monotonic(tmp_path: Path) -> None:
capture = _capture_file(tmp_path / "cursor.cs8")
candidates = tuple(offline.CoarseCandidate(index, 0.03) for index in (10, 20, 30))
def decoder(raw, sample_count, candidate, number):
del raw, sample_count, number
return _candidate_outcome(candidate, accepted=False, next_sample=candidate.sample_offset + 1)
result = offline.strict_process_candidates(capture, candidates, decoder=decoder)
assert all(after > before for before, after in zip(result.cursor_history, result.cursor_history[1:]))
def test_max_candidates_fails_closed(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capture = _capture_file(tmp_path / "candidate_limit.cs8")
def blocks(raw, interval, chunk_samples):
del raw, interval, chunk_samples
scores = np.asarray([0.1, 0.2, 0.3], dtype=np.float32)
yield 0, scores, np.arange(3), 3, 100
monkeypatch.setattr(offline, "_score_blocks", blocks)
result = offline.coarse_search_capture(
capture,
offline.CandidateRule("test", 0.999, 0.01),
max_candidates=2,
)
assert result.max_candidates_reached is True
assert result.termination_reason == "MAX_CANDIDATES_REACHED"
assert len(result.candidates) == 2
def test_coarse_memory_estimate_is_explicitly_conservative(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capture = _capture_file(tmp_path / "memory.cs8", 600_000)
raw = np.memmap(capture, dtype=np.int8, mode="r")
monkeypatch.setattr(offline, "_aligned_overlap", lambda reference_samples: 20)
blocks = list(offline._score_blocks(
raw,
offline.SampleInterval("memory", 0, 600_000),
chunk_samples=600_020,
score_function=lambda samples, reference: np.zeros(1, dtype=np.float32),
))
reported = blocks[0][4]
input_bytes = 600_000 * np.dtype(np.complex64).itemsize
assert reported >= offline.COARSE_TEMPORARY_MEMORY_FACTOR * input_bytes
def test_max_search_iterations_fails_closed(tmp_path: Path) -> None:
capture = _capture_file(tmp_path / "iteration_limit.cs8")
candidates = tuple(offline.CoarseCandidate(index, 0.03) for index in (10, 20, 30))
def decoder(raw, sample_count, candidate, number):
del raw, sample_count, number
return _candidate_outcome(candidate, accepted=False, next_sample=candidate.sample_offset + 1)
result = offline.strict_process_candidates(
capture,
candidates,
max_search_iterations=1,
decoder=decoder,
)
assert result.max_search_iterations_reached is True
assert result.counters.candidates_processed == 1
def test_receiver_api_has_no_tx_or_expected_count_oracle() -> None:
parameters = set(inspect.signature(offline.run_independent_recovery).parameters)
assert parameters == {"capture_path"}
forbidden = {
"tx_offsets",
"frame_starts",
"expected_frame_count",
"tx_object",
"tx_stream",
"fragment_order",
}
assert parameters.isdisjoint(forbidden)
short_parameters = set(
inspect.signature(offline.run_1400mhz_independent_recovery).parameters
)
assert short_parameters == {"capture_path"}
assert short_parameters.isdisjoint(forbidden)
def test_post_verdict_diagnostics_refuses_incomplete_verdict(tmp_path: Path) -> None:
result = object.__new__(offline.IndependentRecoveryResult)
object.__setattr__(result, "completed", False)
with pytest.raises(RuntimeError, match="verdict"):
offline.diagnose_after_independent_verdict(result, tmp_path)
def test_1400mhz_missing_frames_report_error_counts_as_unavailable(
tmp_path: Path,
) -> None:
payload = bytes(256)
plan = offline.lab046c.lab045.build_transmission_plan(payload)
(tmp_path / "tx_payload_0.bin").write_bytes(payload)
(tmp_path / "tx_packet_0.bin").write_bytes(plan.packet)
independent = object.__new__(offline.IndependentRecoveryResult)
object.__setattr__(independent, "completed", True)
object.__setattr__(
independent,
"strict",
SimpleNamespace(accepted_frames=()),
)
diagnostics = offline.diagnose_1400mhz_after_independent_verdict(
independent,
tmp_path,
)
assert diagnostics.matched_valid_frames == 0
assert diagnostics.missed_tx_frames == (0,)
assert diagnostics.total_pre_fec_coded_bit_errors is None
assert diagnostics.total_post_fec_protocol_bit_errors is None
assert diagnostics.total_payload_bit_errors is None
def test_1400mhz_main_persists_independent_result_before_tx_reference() -> None:
source = inspect.getsource(offline.main)
write_position = source.index("write_1400mhz_independent_result")
diagnostic_position = source.index("diagnose_1400mhz_after_independent_verdict")
assert write_position < diagnostic_position
def test_safety_limited_independent_run_does_not_unlock_tx_reference(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capture = _capture_file(tmp_path / "limited.cs8")
rule = offline.CandidateRule("fixed", 0.999, 0.025)
background = offline.BackgroundStatistics(
offline.SampleInterval("background", 0, 100),
10,
3,
0.01,
0.001,
{"0.99": 0.02},
1,
1.0,
0.01,
)
coarse = offline.CoarseSearchResult(
200,
10,
(),
100,
1000,
0.1,
"MAX_CANDIDATES_REACHED",
True,
)
monkeypatch.setattr(offline, "calibrate_candidate_rule", lambda path: (rule, background))
monkeypatch.setattr(offline, "evaluate_control_background", lambda path, fixed: background)
monkeypatch.setattr(offline, "coarse_search_capture", lambda path, fixed: coarse)
result = offline.run_independent_recovery(capture)
assert result.completed is False
with pytest.raises(RuntimeError, match="verdict"):
offline.diagnose_after_independent_verdict(result, tmp_path)
def test_lab045_and_lab046b_historical_behavior_is_unchanged() -> None:
assert offline.lab046b.IQ_PREAMBLE_THRESHOLD == 0.65
parameters = inspect.signature(offline.lab046b.scan_continuous_iq).parameters
assert parameters["preamble_threshold"].default == 0.65
plan = offline.lab046c.lab045.build_transmission_plan(bytes(256))
reception = offline.lab046c.lab045.analyze_post_preamble_soft(
offline.lab046c.lab045.simulate_soft_channel(plan)
)
assert reception.analysis["crc"] == offline.lab046c.lab045.CRC_PASS
def test_false_sequences_are_not_part_of_receiver_api() -> None:
parameters = set(inspect.signature(offline.run_independent_recovery).parameters)
assert "false_preamble" not in parameters
assert "reference_bits" not in parameters
def test_direct_invocation_is_rf_free() -> None:
offline.run_functional_tests()
source = inspect.getsource(offline.run_independent_recovery)
assert "hackrf" not in source.lower()