Compare commits
6 Commits
e8d85ae4b7
...
b0111d2232
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0111d2232 | ||
|
|
52197a4d85 | ||
|
|
d985030f04 | ||
|
|
9b906c622d | ||
|
|
b6654671b4 | ||
|
|
39fa4b139d |
348
PROJECT_LOG.md
348
PROJECT_LOG.md
@@ -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 без отдельной постановки
|
||||
не начинать.
|
||||
|
||||
@@ -128,6 +128,9 @@ SDR_Rover
|
||||
└── gnuradio/ пока пусто
|
||||
```
|
||||
|
||||
Лабораторные работы и расположение их кода, проверок, документов и данных:
|
||||
[`docs/LABS_INDEX.md`](docs/LABS_INDEX.md).
|
||||
|
||||
### `protocol`
|
||||
|
||||
Ядро системы. Пакет с CRC32, фрагментация изображений, кадр канала, исправление стираний, приоритеты, аварийная логика, сеансы связи, примитивы BPSK-радиотракта.
|
||||
|
||||
17
data/processed/lab044/README.md
Normal file
17
data/processed/lab044/README.md
Normal 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-записи и бинарные файлы сюда не копировать.
|
||||
18
data/processed/lab045/README.md
Normal file
18
data/processed/lab045/README.md
Normal 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
156
docs/LABS_INDEX.md
Normal 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. Пустые и
|
||||
фиктивные файлы результатов не создаются.
|
||||
@@ -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 автоматически не разрешены.
|
||||
|
||||
101
docs/lab046c_1400mhz_offline_reprocessing.md
Normal file
101
docs/lab046c_1400mhz_offline_reprocessing.md
Normal 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`.
|
||||
926
experiments/lab046b_continuous_iq.py
Normal file
926
experiments/lab046b_continuous_iq.py
Normal 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())
|
||||
886
experiments/lab046c_hackrf_bpsk_object.py
Normal file
886
experiments/lab046c_hackrf_bpsk_object.py
Normal 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())
|
||||
1415
experiments/lab046c_offline_recovery.py
Normal file
1415
experiments/lab046c_offline_recovery.py
Normal file
File diff suppressed because it is too large
Load Diff
291
tests/test_lab046b_continuous_iq.py
Normal file
291
tests/test_lab046b_continuous_iq.py
Normal 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()
|
||||
350
tests/test_lab046c_hackrf_bpsk_object.py
Normal file
350
tests/test_lab046c_hackrf_bpsk_object.py
Normal 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
|
||||
604
tests/test_lab046c_offline_recovery.py
Normal file
604
tests/test_lab046c_offline_recovery.py
Normal 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()
|
||||
Reference in New Issue
Block a user