diff --git a/docs/lab043_current_state.md b/docs/lab043_current_state.md new file mode 100644 index 0000000..67b1fa7 --- /dev/null +++ b/docs/lab043_current_state.md @@ -0,0 +1,401 @@ +# Lab043 — фактический checkpoint + +Технический снимок состояния для продолжения работы без истории чата. +Только факты и результаты измерений. Дата снимка: 27 августа 2026 года. + +Пометки источника: **[файл]** — подтверждено чтением raw/analysis/report; +**[handoff]** — со слов оператора/handoff-промпта, в файлах не найдено; +**[расчёт]** — вычислено из существующих artifacts при этом снимке. + +Сокращения при первом употреблении: TX (Transmitter) — передатчик; +RX (Receiver) — приёмник; LNA (Low-Noise Amplifier) — малошумящий входной +усилитель; VGA (Variable-Gain Amplifier) — усилитель с регулируемым +коэффициентом; ADC (Analog-to-Digital Converter) — аналого-цифровой +преобразователь; BPSK (Binary Phase Shift Keying) — двоичная фазовая +манипуляция, два состояния фазы, один бит на символ; QPSK (Quadrature +Phase Shift Keying) — четырёхпозиционная фазовая манипуляция, два бита на +символ; SNR (Signal-to-Noise Ratio) — отношение сигнал/шум; BER (Bit Error +Rate) — доля ошибочных битов; SER (Symbol Error Rate) — доля ошибочных +символов, для BPSK совпадает с BER; CFO (Carrier Frequency Offset) — +остаточное смещение несущей; TCXO — термокомпенсированный кварцевый +генератор; CRC (Cyclic Redundancy Check) — циклический контроль +избыточности. + +## 1. Цель Lab043 + +Довести цифровой канал HackRF → HackRF при общей опоре до устойчивого +приёма BPSK, затем последовательно перейти: BPSK → QPSK → бинарные данные +→ пакет → CRC → файл → сжатое изображение → редкие видеокадры. Не +превращать диагностику BPSK в бесконечное исследование, но перейти дальше +только после подтверждённого рабочего BPSK. + +## 2. Оборудование + +Две платы HackRF One r9. Host tools 2024.02.1, libhackrf 2024.02.1 (API +0.9), прошивка обеих 2024.02.1 (API 1.08). Обе — клоны, hackrf_info +сообщает, что железо не выглядит произведённым Great Scott Gadgets; это +известно и само по себе не STOP. Инструменты: `C:\Users\user\Tools\HackRF\Library\bin\`. +**Pluto+ в текущем тракте не используется.** **[файл: hackrf_info]** + +## 3. Serial + +- MASTER / TX: `000000000000000097c063dc323b97d3` +- SLAVE / RX: `000000000000000097c063dc2e5327d3` **[файл: hackrf_info]** + +## 4. Внешняя опора + +JYEC TCXO 10.000000 MHz, 0.1 ppm, установлен на MASTER. **[handoff]** + +## 5. Физическая схема опоры + +TCXO 10 MHz → MASTER → MASTER CLKOUT → SLAVE CLKIN → SLAVE. SLAVE CLKOUT +должен оставаться OFF, clock loop не создавать. + +## 6. Доказательство выбора внешней опоры через reg29 + +Si5351 register 29: до запуска трансивера 0x0e; после RX при внешней +10 MHz опоре 0x26. Это переключение PLLA с внутреннего 25 MHz кварца на +внешний 10 MHz CLKIN. Цепь подтверждена: MASTER после RX reg29=0x26, +MASTER CLKOUT ON, SLAVE до CLKOUT сигнала нет, после CLKOUT сигнал +обнаружен, SLAVE после RX reg29=0x26. **Считать доказанным, заново не +исследовать.** **[файл: оба report.txt подтверждают reg29=0x26 у MASTER и SLAVE]** + +## 7. Тоновый опыт до правильной синхронизации + +Наблюдался значительный уход несущей. **[handoff]** + +## 8. Старый CFO + +Примерно +1165.124 Hz до общей опоры. **[handoff; в файлах тонового опыта только сырые IQ, отчёт не сохранён]** + +## 9. Результат после общей опоры + +Residual CFO примерно +0.041 Hz. Улучшение примерно в 28417 раз. +Килогерцовый CFO устранён общей 10 MHz опорой. **К программной +компенсации большого CFO как к причине отказа BPSK не возвращаться.** +**[handoff; тоновый raw существует, файла анализа с этим числом нет]** + +## 10. Параметры RF-тракта + +- RF center: 1080 MHz +- Fs (частота дискретизации): 8 000 000 отсчётов/с +- symbol rate: 20 000 символов/с +- samples/symbol: 400 +- digital carrier offset: +50 000 Hz +- MASTER TX VGA: 10 dB +- TX RF amp: OFF; antenna bias/power: OFF +- SLAVE RX RF amp: OFF; bias: OFF; RX gain — по текущей точке лестницы **[файл: metadata.json, rx_stdout.txt]** + +## 11. Параметры BPSK-формы + +17664 символов = 17664 бита. Преамбула 1280 символов (источник +pilot_prbs7, initial_state 0x7f). Payload 16384 бита (NumPy PCG64, seed +0x043). Rolloff 0.35, RRC span 10 символов, guard 16 символов с каждой +стороны. bits SHA-256: `aeae114e831ac6b20a82d70fa5153072f574ef04ea2aec8d9f6d0103100107c2`. +Программный roundtrip формы: корреляция преамбулы 0.99995, bit errors 0, +BER 0. **[файл: source_waveform_metadata.json]** + +## 12. SHA-256 эталонной TX-формы + +`fed634fb2a4fbf41ea5a1fce547447b8e3e6dbe8110b8429c507637c6d2d1baa` + +Проверено при этом снимке: SHA файла `tx_bpsk_cs8.iq` во внешнем каталоге +лестницы совпал с эталоном. **До любого TX сверять SHA; при отличии STOP.** +**[файл + расчёт]** + +## 13. Результат первого BPSK при общей опоре + +Каталог `20260827_125733_common_clock_bpsk`, RX LNA 8 / VGA 16. **[файл: analysis.json]** + +- независимая преамбула: НЕ найдена (preamble_found=False) +- symbol SNR: −28.31 dB +- BER (forced diagnostic): 47.88 % +- constellation separation/spread: 0.0767 +- clipping: 0; peak ADC: I=2, Q=3 +- классификация: C + +Примечание: handoff-промпт для этого опыта приводил SNR −26.20 dB, +BER 47.70 %, separation 0.0978. Расхождение с файлом небольшое; при +конфликте источником считается файл analysis.json. + +## 14. Первая RX gain ladder + +Каталог `20260827_130958_common_clock_bpsk_gain_ladder`. TX постоянно +VGA 10 dB, RF amp OFF. |h| — комплексный коэффициент канала rx ≈ h·tx, +здесь абсолютный RMS полезного сигнала в кодах ADC (rms_centered_complex); +шкала ADC HackRF: int8, −128…127. Δ|h| — изменение относительно +предыдущей точки. **[файл: analysis.json по точкам; Δ|h| и dBFS — расчёт]** + +| LNA | VGA | peak, кодов | \|h\| (RMS), кодов | \|h\|, dBFS | Δ\|h\| | symbol SNR, dB | separation | BER | preamble | clipping | status | +|---:|---:|---:|---:|---:|---:|---:|---:|---:|:--:|:--:|---| +| 8 | 8 | 3 | 0.176 | −57.2 | — | −51.70 (diag) | 0.0053 (diag) | 49.98 % (diag) | NO | 0 | нет синхр. | +| 16 | 8 | 3 | 0.264 | −53.7 | +3.5 dB | −20.81 (diag) | 0.1823 | 44.87 % (diag) | NO | 0 | нет синхр. | +| 16 | 16 | 4 | 0.579 | −46.9 | +6.8 dB | −10.62 | 0.5892 | 33.84 % | YES | 0 | порог не достигнут | +| 24 | 16 | 3 | 0.346 | −51.4 | −4.5 dB | −2.62 | 1.4795 | 14.70 % | YES | 0 | порог не достигнут | + +Классификация ladder: B. + +## 15. Лучшая текущая точка + +LNA 24 / VGA 16: preamble YES; symbol SNR −2.62 dB; BER 14.70 %; +separation 1.4795; clipping 0. Это лучший проверенный результат, но не +рабочая рекомендация. **[файл]** + +## 16. Почему QPSK не запущен + +Рабочий порог не достигнут: BER 14.70 % при цели ≤ 1 %; SNR −2.62 dB при +цели ≥ 6 dB; separation 1.4795 при цели ≥ 2. QPSK, JPEG, пакеты, видео до +подтверждённого BPSK не запускать. + +## 17. Текущая гипотеза и открытый вопрос + +Формально задача — продолжить RX gain ladder выше 24/16 и одновременно +проверять физический рост полезного |h|. Но данные уже показывают +проблему: полезный сигнал занимает лишь единицы кодов ADC (peak ≤ 4 из +128, RMS ≤ 0.58), и рост номинального усиления на 24 dB (8/8 → 24/16) дал +прирост |h| лишь около +6 dB, немонотонно, с падением на 4.5 dB при +переходе 16/16 → 24/16. Это близко к STOP-условию: усиление не +превращается в амплитуду на ADC. Прежде чем крутить усиление выше, +проверить, растёт ли физически |h|, и почему полезный сигнал оцифровывается +всего 2 битами из 8. **[расчёт из analysis.json]** + +## 18. Рабочие критерии BPSK + +Все одновременно: independent preamble YES; BER ≤ 1 %; symbol SNR ≥ 6 dB; +constellation separation ≥ 2; без clipping и перегруза ADC. Цель — не +максимальный gain, а минимальная безопасная рабочая точка. + +## 19. Пути к raw / report / analysis + +Внешний корень (в Git не хранить): `C:\Users\user\Documents\SDR_Rover_Lab043_raw\` + +- `20260827_common_clock_tone\` — тоновый опыт, только сырые IQ. +- `20260827_125733_common_clock_bpsk\` — первый BPSK: `report.txt`, `analysis.json`, `rx_cs8.iq`. +- `20260827_130958_common_clock_bpsk_gain_ladder\` — лестница: `report.txt`, `summary.json`, `source_waveform_metadata.json`, `tx_bpsk_cs8.iq`, подкаталоги `lnaXX_vgaXX\` (`rx_cs8.iq`, `background_cs8.iq`, `analysis.json`, `metadata.json`, `constellation.png`, `correlation.png`, `rx_stdout.txt`, `rx_stderr.txt`). + +## 20. Следующий конкретный эксперимент + +Продолжить лестницу выше 24/16 (кандидаты 24/24, 32/24, 32/32, 40/32 +после проверки допустимых ступеней libhackrf 2024.02.1), TX не менять, +RF amp OFF, TX VGA 10 dB. Каждая точка — отдельный опыт: preflight общей +опоры, RX до TX, один короткий BPSK TX, сохранить raw, измерить |h| и +Δ|h| dB, проверить перегруз, и только затем решать о следующей точке. +STOP немедленно при достижении рабочей точки, при clipping/перегрузе, или +если рост gain не даёт роста |h|. + +**Критический разрыв передачи (обнаружен при этом снимке):** инструмент, +которым выполнялись BPSK-опыты HackRF → HackRF (генерация формы, вызовы +`hackrf_transfer` для TX/RX, анализ преамбулы/SNR/BER/созвездия), **в +репозитории отсутствует** — ни отслеженного, ни неотслеженного `.py`. В +репозитории есть только `experiments/lab043_hackrf_gain_ladder.py`, но это +Pluto+ → HackRF и один непрерывный тон, не BPSK, потолок 24/24. Внешние +artifacts (TX-форма, RX-захваты, analysis.json) существуют, но +порождающий код — нет. Для воспроизводимого продолжения «той же формой и +тем же анализом» этот инструмент нужно закоммитить в проект либо написать +заново отдельной согласованной задачей. **[расчёт: полный поиск по репозиторию]** + +## 21. Запреты + +- Не возвращаться без причины к Pluto+ в текущем тракте. +- Не пересматривать доказанный common clock и reg29. +- Не запускать QPSK, JPEG, пакеты и видео до BPSK PASS. +- Raw-файлы IQ в Git не добавлять; хранить во внешнем каталоге. +- Форму BPSK не менять; до TX сверять SHA-256, при отличии STOP. +- Усиление вслепую не крутить: каждая точка — отдельный контролируемый опыт. + +## 22. Common-clock recheck 28 августа 2026 года + +Повторная аппаратная проверка после восстановления кабеля MASTER CLKOUT → +SLAVE CLKIN завершена с результатом PASS: MASTER после короткого RX имел +reg29=0x26; SLAVE дословно вернул `CLKIN status: clock signal detected`; +SLAVE после короткого RX имел reg29=0x26. После проверки CLKOUT обеих плат +переведены в `Down`, процессов `hackrf_transfer` не осталось. RF TX не +выполнялся. + +Обнаруженный false-positive parser исправлен fail-closed: полные строки +`clock signal detected` и `no clock signal detected` распознаются раздельно; +пустой или неожиданный вывод даёт UNKNOWN. Продолжение разрешает только +DETECTED. + +## 23. Offline reconciliation старой BPSK gain ladder + +Фактический анализатор первой лестницы восстановлен из журнала запуска Codex +от 27 августа. Git, PowerShell history, временные `.py/.ps1` и каталог raw +исходного кода не содержали. Старый код сначала находил oracle-start полной +известной 17664-битной формой, а затем проверял близость глобального пика +преамбулы. Поэтому историческая реализация содержала data leakage в одной из +проверок, хотя сам preamble-only пик вычислялся отдельно. + +Причина расхождения нового runner: старый detector исключал вокруг главного +пика ±0.05 с, то есть ±1000 символов, а первая версия runner сравнивала с +PRBS7-повтором через 127 символов за пределами лишь ±4 символов. Дополнительно +runner применял `10*log10` к отношению амплитуд корреляции вместо исторического +`20*log10`. До этого этапа signed int8, I/Q, DC removal, перенос `exp(-j...)`, +50 кГц, 400 отсчётов/символ и timing совпадали. + +Исправленный официальный путь глобально ищет start и timing только по +преамбуле. Полная форма не влияет на preamble PASS и хранится только как явно +помеченная oracle diagnostic. Остаточный CFO оценивается non-data-aided по +фазе квадрата BPSK-потока; квадрат устраняет неизвестные знаки payload. +Полярность и начальная фаза снимаются по преамбуле. + +Regression четырёх неизменённых raw: + +| LNA | VGA | sync | \|h\| | Δ\|h\| | SNR, dB | separation | BER | +|---:|---:|---|---:|---:|---:|---:|---:| +| 8 | 8 | oracle diagnostic | 3.6166e-6 | — | -51.70 diag | 0.0053 diag | NOT COMPUTED; 49.98% diag | +| 16 | 8 | oracle diagnostic | 1.8902e-4 | +34.36 dB | -20.81 diag | 0.1823 diag | NOT COMPUTED; 44.87% diag | +| 16 | 16 | independent | 1.3641e-3 | +17.17 dB | -10.62 | 0.5892 | 33.78% | +| 24 | 16 | independent | 2.1859e-3 | +4.10 dB | -2.62 | 1.4795 | 14.78% | + +Первые два `|h|` являются oracle diagnostic при почти случайном BER, поэтому +их большие дельты нельзя считать точной физической калибровкой gain. Между +двумя независимо синхронизированными точками 16/16 → 24/16 полезная BPSK- +компонента выросла на 4.10 dB: RX gain физически помогал, но прирост оказался +меньше номинальных 8 dB. + +Для 24/16 научно корректный результат: preamble YES только по преамбуле; +detected sample 6263020, refined start 6263013, timing phase -1; correlation +29.43 dB над median и 12.41 dB над независимым вторым кандидатом; CFO +-0.01224 Hz; carrier phase 122.17°; preamble errors 200/1280; официальный +BER 14.78%; измерительный symbol SNR -2.62 dB; separation 1.4795. Старые +14.70% воспроизводятся только при full-frame-aided оценке carrier и остаются +сравнительной, а не официальной величиной. + +Offline analyzer, deterministic waveform, direct invocation и fail-closed +common-clock skeleton покрыты тестами. Реальный hardware RX/TX режим по-прежнему +не реализован: `run_one_gain_point()` намеренно заканчивается +`NotImplementedError`, RF TX calls=0. Следующую gain ladder не запускать до +отдельной реализации и проверки аппаратного режима. + +## 24. Hardware runner и остановка ladder 31 августа 2026 года + +`run_one_gain_point()` завершён как fail-closed аппаратная процедура: +детерминированная форма и SHA-gate выполняются до обращения к HackRF; затем +common-clock preflight, старт SLAVE RX до TX, ровно один разрешённый TX, +штатное завершение RX, двойная проверка SHA-256 и размера raw, проверка +overrun/underrun, независимый preamble-only анализ и безусловное выключение +обоих CLKOUT. Последовательный режим создаёт каталог только для фактически +начатой точки и после каждого анализа проверяет PASS/STOP. + +Mock-проверки воспроизвели preflight failure, SHA failure, RX startup failure, +успешный единичный TX, TX failure без повтора, analysis failure без повтора, +cleanup процесса и обоих CLKOUT, а также запрет второго TX. Перед аппаратной +попыткой: `python tools/quick_gate.py` PASS; `python -m pytest tests/ -q`: +343 passed; `git diff --check` PASS. Предупреждение pytest относилось только к +невозможности создать `.pytest_cache` и не затрагивало тесты. + +Разрешённая лестница 24/24 → 32/24 → 32/32 → 40/32 была запущена через +репозиторный `experiments/lab043_hackrf_bpsk.py`, но common-clock preflight +остановил её до первой точки: MASTER прошёл свою часть проверки, после +включения MASTER CLKOUT SLAVE дословно вернул +`CLKIN status: no clock signal detected`. Классификация запуска D, выполненных +gain points 0, RF TX calls 0, RECOMMENDED RX GAIN отсутствует. Итоговый отчёт: +`C:\Users\user\Documents\SDR_Rover_Lab043_raw\20260831_110014_common_clock_bpsk_gain_ladder_2\ladder_report.json`. + +После STOP обе платы явно получили команду CLKOUT OFF; процессов +`hackrf_transfer`, `hackrf_clock` и `hackrf_debug` не осталось. Следующий шаг: +физически проверить соединение MASTER CLKOUT → SLAVE CLKIN и отдельным +common-clock preflight восстановить тройной PASS до нового разрешения RF. + +После физического восстановления соединения отдельная ручная проверка дала +тройной PASS: MASTER reg29=0x26, точная строка SLAVE +`CLKIN status: clock signal detected`, SLAVE reg29=0x26; оба коротких RX имели +0 overruns. Однако немедленно запущенный репозиторный runner повторил свой +обязательный preflight и получил от SLAVE +`CLKIN status: no clock signal detected`. Поэтому лестница снова остановлена +до первой точки: gain points 0, RF TX calls 0, классификация D. Отчёт второй +попытки: +`C:\Users\user\Documents\SDR_Rover_Lab043_raw\20260831_111352_common_clock_bpsk_gain_ladder_2\ladder_report.json`. +Противоречие PASS → NOT_DETECTED означает нестабильное соединение или +детектирование общей опоры; повторять RF-запуск автоматически нельзя. + +После замены пользователем провода общей опоры выполнена ещё одна явно +разрешённая попытка runner. Его обязательный preflight снова получил точную +строку `CLKIN status: no clock signal detected`; gain points 0, RF TX calls 0, +классификация D. Отчёт: +`C:\Users\user\Documents\SDR_Rover_Lab043_raw\20260831_111801_common_clock_bpsk_gain_ladder_2\ladder_report.json`. +Поскольку отдельная ручная последовательность ранее дала PASS, а runner +проверяет CLKIN сразу после включения MASTER CLKOUT, следующая программная +гипотеза — недостаточная выдержка перед чтением CLKIN. До её согласованной +реализации и mock-проверки новые RF-попытки не выполнять. + +## 25. Common-clock settle-time 31 августа 2026 года + +Timing/race hypothesis подтверждена измерением без RF TX. В исходном +измерительном цикле запросы после MASTER CLKOUT ON дали NOT_DETECTED на +37.9 и 115.3 мс, затем DETECTED на 171.0 мс. MASTER и SLAVE после коротких +RX имели reg29=0x26, cleanup обеих плат завершился без ошибок. + +В runner реализован fail-closed bounded polling на монотонных часах: +`initial_settle_s=0.050`, `poll_interval_s=0.050`, `timeout_s=0.500`. +UNKNOWN, ненулевой exit code и subprocess exception немедленно закрывают +gate; постоянный NOT_DETECTED заканчивается TIMEOUT. Предел 500 мс выбран +почти втрое больше первого измеренного времени 171 мс и более чем вдвое +больше максимума подтверждающей серии 196.55 мс, но не допускает +неограниченного ожидания. + +Пять полностью раздельных common-clock-only циклов дали 5/5 PASS: + +| cycle | time to DETECTED, ms | queries | MASTER reg29 | SLAVE CLKIN | SLAVE reg29 | +|---:|---:|---:|---:|---|---:| +| 1 | 132.27 | 2 | 0x26 | DETECTED | 0x26 | +| 2 | 134.45 | 2 | 0x26 | DETECTED | 0x26 | +| 3 | 130.76 | 2 | 0x26 | DETECTED | 0x26 | +| 4 | 130.17 | 2 | 0x26 | DETECTED | 0x26 | +| 5 | 196.55 | 3 | 0x26 | DETECTED | 0x26 | + +Во всех циклах первый запрос после initial settle ещё возвращал +NOT_DETECTED. Поэтому одиночная мгновенная проверка была реальной гонкой, а +не доказательством отсутствия общей опоры. Common-clock gate: STABLE. RF TX +count 0; после серии оба CLKOUT выключены, процессов HackRF не осталось. + +Артефакты: +`C:\Users\user\Documents\SDR_Rover_Lab043_raw\20260831_112534_common_clock_settle_test\settle_report.json` +и `settle_report.txt`. Mock tests покрывают первый DETECTED, delayed DETECTED, +TIMEOUT, UNKNOWN, subprocess exception, PASS на границе timeout, конечность +polling и cleanup. После изменения: quick_gate PASS, pytest 350 passed, +`git diff --check` PASS. BPSK gain ladder на этом этапе не запускалась. + +## 26. Первая рабочая BPSK-точка 31 августа 2026 года + +После закрытия common-clock timing issue репозиторный runner выполнил первую +разрешённую точку LNA 24 / VGA 24 и немедленно остановил ladder по BPSK PASS. +Точки 32/24, 32/32 и 40/32 не запускались. Выполнен ровно один RF TX; +waveform SHA-256 совпал с эталоном +`fed634fb2a4fbf41ea5a1fce547447b8e3e6dbe8110b8429c507637c6d2d1baa`. + +Common-clock preflight перед TX: MASTER reg29=0x26; SLAVE DETECTED через +132.74 мс и два запроса; SLAVE reg29=0x26. Официальная синхронизация найдена +глобально только по преамбуле: peak/median 38.02 dB, peak/second independent +candidate 10.98 dB, detected start 6231460, refined start 6231458, timing +1 +sample. Payload не использовался для start/timing/carrier phase/polarity. + +Результат LNA 24 / VGA 24: preamble YES, errors 9/1280; CFO +0.00475 Hz; +carrier phase -132.45°; |h|=4.5363e-2; symbol SNR 7.94 dB; centers примерно +(-0.9936-0.0056j) и (+0.9912+0.0089j); within-cluster RMS 0.3980; +separation 4.9867; официальный BER=SER=0.4586% (81 ошибок из 17664 бит). +Все рабочие критерии выполнены: BER≤1%, SNR≥6 dB, separation≥2, +independent preamble, clipping 0 и отсутствие ADC overload. + +Raw: mean I/Q 0.948/1.837 кода; RMS после DC removal 0.791; p95 3.162; +p99 3.606; p99.9 3.606; peak component 5; ADC headroom 122 кода; +clipping 0/32000000. Файл имеет 64000000 байт, 32000000 complex samples, +длительность 4.0 с. Две проверки SHA-256 совпали: +`a8d857a7fdd895475d8c58b56d8f1778ffce96966709ce98d691e078b4a832c8`. +RX overruns 0, TX underruns 0. + +Относительно старой точки 24/16 runner получил Δ|h|=+26.34 dB. Это намного +больше номинального изменения VGA +8 dB; после замены кабеля и физических +переподключений дельту нельзя интерпретировать как чистую калибровку RX gain. +Она не отменяет достоверность текущего внутрикадрового результата и отсутствие +перегруза, но для калибровки тракта требует отдельного опыта. + +Классификация A. RECOMMENDED RX GAIN: LNA 24 / VGA 24. Практически BER +0.4586% означает около 4.6 ошибочных битов на 1000 до коррекции ошибок. +Артефакты: +`C:\Users\user\Documents\SDR_Rover_Lab043_raw\20260831_114210_common_clock_bpsk_gain_ladder_2\`. +После опыта MASTER и SLAVE CLKOUT явно выключены с exit code 0, процессов +HackRF нет. Послеруновые проверки: quick_gate PASS, pytest 351 passed, +`git diff --check` PASS. QPSK/JPEG/video не запускались. Следующий этап — +переход к полезным данным, но только отдельной согласованной задачей. diff --git a/experiments/lab043_hackrf_bpsk.py b/experiments/lab043_hackrf_bpsk.py new file mode 100644 index 0000000..010ec03 --- /dev/null +++ b/experiments/lab043_hackrf_bpsk.py @@ -0,0 +1,1489 @@ +"""Lab043: воспроизводимый BPSK-опыт HackRF → HackRF при общей опоре. + +Инструмент превращает уже выполненный вручную аппаратный опыт в +повторяемую процедуру внутри проекта. Он НЕ вводит новую архитектуру: +форма сигнала и примитивы взяты из уже проверенного кода проекта. + +BPSK (Binary Phase Shift Keying) — двоичная фазовая манипуляция, два +состояния фазы, один бит на символ. + +Два режима: + + --analyze-existing offline-анализ сохранённого захвата, + оборудование не требуется; + + --hardware --lna L --vga V один аппаратный опыт (передача + приём); + --hardware-ladder последовательная разрешённая gain ladder. + +Оба аппаратных режима по умолчанию заблокированы и требуют явного +--i-understand-hardware. + +Восстановление формы (см. build_tx_cs8) проверяется по SHA-256; при +несовпадении аппаратный режим останавливается до передачи. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import re +import subprocess +import sys +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable, TextIO + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import numpy as np +from scipy.signal import fftconvolve, resample_poly + +from experiments.lab043_hackrf_hardware import ( + signed_iq_bytes_to_complex64, + validate_hackrf_rx_gains, +) +from experiments.lab043_pluto_to_rtlsdr import pilot_prbs7, shape_frame_like_lab042 +from protocol import bpsk_radio as radio + + +# ------------------------------------------------------------------ оборудование +MASTER_SERIAL = "000000000000000097c063dc323b97d3" # TX +SLAVE_SERIAL = "000000000000000097c063dc2e5327d3" # RX +HACKRF_TOOLS_DIR = Path(r"C:\Users\user\Tools\HackRF\Library\bin") + +# ------------------------------------------------------------------ радиотракт +RF_HZ = 1_080_000_000 +SAMPLE_RATE_HZ = 8_000_000 +SYMBOL_RATE = 20_000 +SAMPLES_PER_SYMBOL = SAMPLE_RATE_HZ // SYMBOL_RATE # 400 +CARRIER_OFFSET_HZ = 50_000.0 +TX_VGA_DB = 10 +RF_AMP_ENABLED = False +ANTENNA_POWER_ENABLED = False + +# ------------------------------------------------------------------ форма BPSK +PILOT_SYMBOL_COUNT = 1_280 +PILOT_INITIAL_STATE = 0x7F +PAYLOAD_SEED = 0x043 +PAYLOAD_SYMBOL_COUNT = 16_384 +TOTAL_SYMBOL_COUNT = PILOT_SYMBOL_COUNT + PAYLOAD_SYMBOL_COUNT # 17664 +TARGET_RMS_CODE = 44.0 +INT8_LIMIT = 127 + +# SHA-256 эталонной cs8-формы. Восстанавливается из примитивов, не +# подгоняется под это значение; при несовпадении аппаратный режим STOP. +EXPECTED_TX_SHA256 = "fed634fb2a4fbf41ea5a1fce547447b8e3e6dbe8110b8429c507637c6d2d1baa" + +# ------------------------------------------------------------------ пороги анализа +PREAMBLE_OVER_MEDIAN_DB_MIN = 12.0 +PREAMBLE_OVER_SECOND_DB_MIN = 3.0 +DETECTION_DECIMATION = 20 +# Восстановлено из фактического анализатора первой gain ladder: 0.05 с +# при 20 ксимв/с. Это исключает автокорреляционные повторы одной и той же +# 1280-символьной PRBS7-преамбулы, но не соседний независимый пакет. +SECOND_PEAK_GUARD_SYMBOLS = 1_000 + +# Si5351 register 29 после запуска RX при внешней 10 МГц опоре. +REG29_EXTERNAL_CLOCK = 0x26 +SHORT_RX_SAMPLE_COUNT = 65_536 +CAPTURE_SAMPLE_COUNT = 32_000_000 +RX_LEAD_SECONDS = 0.75 +RX_WAIT_TIMEOUT_SECONDS = 15 + +PASS_BER_MAX = 0.01 +PASS_SNR_DB_MIN = 6.0 +PASS_SEPARATION_MIN = 2.0 +NEAR_INT8_CODE = 120 +MIN_H_CHANGE_DB = -1.0 +BASELINE_24_16_ABS_H = 2.1859e-3 + +GAIN_LADDER = ((24, 24), (32, 24), (32, 32), (40, 32)) + +CLKIN_STATUS_DETECTED = "DETECTED" +CLKIN_STATUS_NOT_DETECTED = "NOT_DETECTED" +CLKIN_STATUS_UNKNOWN = "UNKNOWN" +CLOCK_WAIT_TIMEOUT = "TIMEOUT" +CLOCK_WAIT_ERROR = "ERROR" + +# Первый измерительный цикл 2026-08-31: NOT_DETECTED до 115.3 мс, +# DETECTED на 171.0 мс после T0. Предел 500 мс почти втрое больше +# измеренного времени, но остаётся коротким и жёстко ограниченным. +CLOCK_INITIAL_SETTLE_SECONDS = 0.050 +CLOCK_POLL_INTERVAL_SECONDS = 0.050 +CLOCK_TIMEOUT_SECONDS = 0.500 +CLOCK_INTER_CYCLE_SECONDS = 0.200 +CLOCK_SETTLE_CYCLE_COUNT = 5 + + +# ================================================================== форма сигнала + + +def reference_bits() -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Вернуть биты пилота, payload и их объединение. + + Пилот — детерминированный PRBS7 проекта. Payload — фиксированная + последовательность NumPy PCG64 с зафиксированным семенем. Обе части + восстанавливаются из примитивов, а не из сохранённого IQ. + """ + + pilot = pilot_prbs7(PILOT_SYMBOL_COUNT, PILOT_INITIAL_STATE) + payload = np.random.Generator(np.random.PCG64(PAYLOAD_SEED)).integers( + 0, 2, PAYLOAD_SYMBOL_COUNT, dtype=np.uint8 + ) + return pilot, payload, np.concatenate((pilot, payload)) + + +def build_tx_complex(all_bits: np.ndarray | None = None) -> np.ndarray: + """Собрать комплексную форму BPSK до перевода в int8. + + Символы формируются существующим формирователем Lab042 (RRC и + защитные интервалы), затем переносятся на цифровую несущую +50 кГц. + Возвращаются отсчёты с целевым среднеквадратичным уровнем TARGET_RMS_CODE. + """ + + if all_bits is None: + _, _, all_bits = reference_bits() + symbols = radio.bpsk_modulate(all_bits) + shaped = shape_frame_like_lab042(symbols, SAMPLES_PER_SYMBOL) + indexes = np.arange(len(shaped)) + carriered = shaped * np.exp(1j * 2.0 * np.pi * CARRIER_OFFSET_HZ * indexes / SAMPLE_RATE_HZ) + rms = float(np.sqrt(np.mean(np.abs(carriered) ** 2))) + if rms <= 0.0: + raise ValueError("Нулевая мощность формы") + return carriered * (TARGET_RMS_CODE / rms) + + +def to_cs8_bytes(waveform: np.ndarray) -> bytes: + """Перевести комплексную форму в interleaved signed int8 I/Q.""" + + real = np.clip(np.round(waveform.real), -128, INT8_LIMIT).astype(np.int8) + imag = np.clip(np.round(waveform.imag), -128, INT8_LIMIT).astype(np.int8) + interleaved = np.empty(2 * len(real), dtype=np.int8) + interleaved[0::2] = real + interleaved[1::2] = imag + return interleaved.tobytes() + + +def build_tx_cs8() -> bytes: + """Собрать cs8-форму и проверить её SHA-256 против эталона.""" + + payload = to_cs8_bytes(build_tx_complex()) + digest = hashlib.sha256(payload).hexdigest() + if digest != EXPECTED_TX_SHA256: + raise ValueError( + "SHA-256 восстановленной формы не совпал с эталоном. " + f"Получено {digest}, ожидалось {EXPECTED_TX_SHA256}. Аппаратный режим запрещён." + ) + return payload + + +# ================================================================== чтение raw + + +def load_cs8(path: Path) -> np.ndarray: + """Прочитать сохранённый interleaved signed int8 I/Q в complex64. + + Файл читается один раз; повторных загрузок 64 МБ не выполняется. + """ + + raw = Path(path).read_bytes() + return signed_iq_bytes_to_complex64(raw) + + +def raw_statistics(samples: np.ndarray) -> dict: + """Базовая статистика сырого потока: уровень, перегрузка, проценты.""" + + codes = samples * 128.0 # обратно в шкалу кодов int8 + magnitude = np.abs(codes) + centered = codes - np.mean(codes) + component_peak = np.maximum(np.abs(codes.real), np.abs(codes.imag)) + return { + "sample_count": int(len(samples)), + "mean_i_code": float(np.mean(codes.real)), + "mean_q_code": float(np.mean(codes.imag)), + "rms_code": float(np.sqrt(np.mean(magnitude**2))), + "rms_after_dc_removal_code": float(np.sqrt(np.mean(np.abs(centered) ** 2))), + "i_min_code": float(np.min(codes.real)), + "i_max_code": float(np.max(codes.real)), + "q_min_code": float(np.min(codes.imag)), + "q_max_code": float(np.max(codes.imag)), + "amplitude_p95_code": float(np.percentile(magnitude, 95.0)), + "amplitude_p99_code": float(np.percentile(magnitude, 99.0)), + "amplitude_p999_code": float(np.percentile(magnitude, 99.9)), + "peak_component_code": float(np.max(component_peak)), + "peak_complex_code": float(np.max(magnitude)), + "clipping_count": int(np.sum(component_peak >= INT8_LIMIT)), + "clipping_fraction": float(np.mean(component_peak >= INT8_LIMIT)), + "near_int8_count": int(np.sum(component_peak >= NEAR_INT8_CODE)), + "near_int8_fraction": float(np.mean(component_peak >= NEAR_INT8_CODE)), + "adc_headroom_code": float(INT8_LIMIT - np.max(component_peak)), + } + + +# ================================================================== анализ BPSK + + +def _db(ratio: float) -> float: + return 10.0 * math.log10(max(float(ratio), 1e-300)) + + +def _amplitude_db(ratio: float) -> float: + """Перевести отношение амплитуд корреляции в dB через 20 log10.""" + + return 20.0 * math.log10(max(float(ratio), 1e-300)) + + +def _tx_cs8_complex() -> np.ndarray: + """Вернуть точную проверенную TX-форму в той же шкале, что и RX.""" + + return signed_iq_bytes_to_complex64(build_tx_cs8()) + + +def _normalized_sliding_correlation(signal: np.ndarray, reference: np.ndarray) -> np.ndarray: + """Нормированная комплексная корреляция для всех допустимых стартов.""" + + correlation = fftconvolve(signal, np.conj(reference[::-1]), mode="valid") + cumulative_energy = np.concatenate(( + np.zeros(1, dtype=np.float64), + np.cumsum(np.abs(signal) ** 2, dtype=np.float64), + )) + window_energy = cumulative_energy[len(reference) :] - cumulative_energy[: -len(reference)] + reference_energy = float(np.sum(np.abs(reference) ** 2)) + denominator = np.sqrt(np.maximum(window_energy * reference_energy, 1e-30)) + return np.abs(correlation) / denominator + + +def _detection_streams(samples: np.ndarray, tx_samples: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """DC removal, перенос -50 кГц и decimation как в исходном анализаторе.""" + + rx_indexes = np.arange(len(samples)) + centered = samples - np.mean(samples) + rx_baseband = centered * np.exp( + -2j * np.pi * CARRIER_OFFSET_HZ * rx_indexes / SAMPLE_RATE_HZ + ) + rx_decimated = resample_poly( + rx_baseband, 1, DETECTION_DECIMATION, window=("kaiser", 8) + ).astype(np.complex64) + + tx_indexes = np.arange(len(tx_samples)) + tx_baseband = tx_samples * np.exp( + -2j * np.pi * CARRIER_OFFSET_HZ * tx_indexes / SAMPLE_RATE_HZ + ) + tx_decimated = resample_poly( + tx_baseband, 1, DETECTION_DECIMATION, window=("kaiser", 8) + ).astype(np.complex64) + tx_decimated -= np.mean(tx_decimated) + return rx_decimated, tx_decimated + + +def _peak_metrics(scores: np.ndarray, guard_samples: int) -> dict: + peak_index = int(np.argmax(scores)) + peak = float(scores[peak_index]) + median = float(np.median(scores)) + mask = np.ones(len(scores), dtype=bool) + low = max(0, peak_index - guard_samples) + high = min(len(mask), peak_index + guard_samples + 1) + mask[low:high] = False + remaining_indexes = np.flatnonzero(mask) + if len(remaining_indexes): + second_relative = int(np.argmax(scores[mask])) + second_index = int(remaining_indexes[second_relative]) + second = float(scores[second_index]) + else: + second_index = -1 + second = 0.0 + return { + "peak_index": peak_index, + "peak": peak, + "median": median, + "second_index": second_index, + "second": second, + "over_median_db": _amplitude_db(peak / median) if median > 0 else float("inf"), + "over_second_db": _amplitude_db(peak / second) if second > 0 else float("inf"), + } + + +def detect_preamble(rx_decimated: np.ndarray, tx_decimated: np.ndarray) -> dict: + """Глобально найти кадр только по известной преамбуле, без payload. + + Reference повторяет фактический анализ 2026-08-27: ведущий guard, + 1280 символов PRBS7 и десять символов хвоста RRC. Полная 17664-битная + форма здесь принципиально не используется. + """ + + samples_per_symbol = SAMPLES_PER_SYMBOL // DETECTION_DECIMATION + reference_symbol_count = 16 + PILOT_SYMBOL_COUNT + 10 + reference_length = reference_symbol_count * samples_per_symbol + scores = _normalized_sliding_correlation(rx_decimated, tx_decimated[:reference_length]) + guard = SECOND_PEAK_GUARD_SYMBOLS * samples_per_symbol + metrics = _peak_metrics(scores, guard) + detected = bool( + metrics["over_median_db"] >= PREAMBLE_OVER_MEDIAN_DB_MIN + and metrics["over_second_db"] >= PREAMBLE_OVER_SECOND_DB_MIN + ) + sample_rate_decimated = SAMPLE_RATE_HZ / DETECTION_DECIMATION + return { + "preamble_detected": detected, + "method": "global preamble-only normalized correlation", + "payload_used_for_start": False, + "peak_index_decimated": metrics["peak_index"], + "start_sample": metrics["peak_index"] * DETECTION_DECIMATION, + "start_seconds": metrics["peak_index"] / sample_rate_decimated, + "peak": metrics["peak"], + "median": metrics["median"], + "second_index_decimated": metrics["second_index"], + "second_start_seconds": ( + metrics["second_index"] / sample_rate_decimated + if metrics["second_index"] >= 0 + else None + ), + "over_median_db": metrics["over_median_db"], + "over_second_db": metrics["over_second_db"], + "threshold_over_median_db": PREAMBLE_OVER_MEDIAN_DB_MIN, + "threshold_over_second_db": PREAMBLE_OVER_SECOND_DB_MIN, + "second_peak_guard_symbols": SECOND_PEAK_GUARD_SYMBOLS, + } + + +def _refine_frame_start(samples: np.ndarray, tx_samples: np.ndarray, coarse_start: int) -> int: + """Уточнить raw-start локальной корреляцией только с prefix-преамбулой.""" + + reference_length = (16 + PILOT_SYMBOL_COUNT + 10) * SAMPLES_PER_SYMBOL + low = max(0, coarse_start - 2_000) + high = min(len(samples), coarse_start + reference_length + 2_000) + local = samples[low:high] - np.mean(samples) + reference = tx_samples[:reference_length] - np.mean(tx_samples[:reference_length]) + correlation = fftconvolve(local, np.conj(reference[::-1]), mode="valid") + return low + int(np.argmax(np.abs(correlation))) + + +def _matched_waveform(samples: np.ndarray, start: int, tx_length: int) -> tuple[np.ndarray, int]: + segment = samples[start : start + tx_length] - np.mean(samples) + indexes = start + np.arange(len(segment)) + baseband = segment * np.exp( + -2j * np.pi * CARRIER_OFFSET_HZ * indexes / SAMPLE_RATE_HZ + ) + taps = radio.root_raised_cosine_taps(0.35, SAMPLES_PER_SYMBOL, 10) + matched = fftconvolve(baseband, taps, mode="full") + nominal = 16 * SAMPLES_PER_SYMBOL + 2 * ((len(taps) - 1) // 2) + return matched, nominal + + +def _timing_offsets(matched: np.ndarray, nominal: int, expected_symbols: np.ndarray) -> tuple[int, int]: + offsets = np.arange(-800, 801) + preamble_scores: list[float] = [] + full_scores: list[float] = [] + for offset in offsets: + symbols = matched[ + nominal + offset : nominal + offset + len(expected_symbols) * SAMPLES_PER_SYMBOL : SAMPLES_PER_SYMBOL + ] + if len(symbols) != len(expected_symbols): + preamble_scores.append(0.0) + full_scores.append(0.0) + continue + preamble = symbols[:PILOT_SYMBOL_COUNT] + preamble_scores.append( + abs(np.vdot(expected_symbols[:PILOT_SYMBOL_COUNT], preamble)) + / math.sqrt(PILOT_SYMBOL_COUNT * np.sum(np.abs(preamble) ** 2) + 1e-30) + ) + full_scores.append( + abs(np.vdot(expected_symbols, symbols)) + / math.sqrt(len(expected_symbols) * np.sum(np.abs(symbols) ** 2) + 1e-30) + ) + preamble_offset = int(offsets[int(np.argmax(preamble_scores))]) + oracle_offset = int(offsets[int(np.argmax(full_scores))]) + return preamble_offset, oracle_offset + + +def _estimate_cfo_gain( + symbols: np.ndarray, + expected_symbols: np.ndarray, + estimation_count: int, +) -> tuple[float, complex]: + """Оценить CFO и комплексный gain по заданному prefix символов.""" + + count = min(len(symbols), len(expected_symbols), estimation_count) + if count <= 0: + return 0.0, 0j + if estimation_count == PILOT_SYMBOL_COUNT: + # Non-data-aided BPSK carrier recovery: squaring removes unknown + # payload signs because (+1)^2 == (-1)^2. The phase slope is doubled. + squared = symbols**2 + block_length = 512 + block_count = len(squared) // block_length + if block_count >= 2: + block_values = squared[: block_count * block_length].reshape( + block_count, block_length + ).mean(1) + block_time = ( + np.arange(block_count) * block_length + (block_length - 1) / 2 + 0.5 + ) / SYMBOL_RATE + phase = np.unwrap(np.angle(block_values)) + weights = np.abs(block_values) ** 2 + matrix = np.column_stack((np.ones(block_count), block_time - block_time[0])) + root_weights = np.sqrt(weights / max(float(np.max(weights)), 1e-30)) + slope = np.linalg.lstsq( + matrix * root_weights[:, None], phase * root_weights, rcond=None + )[0][1] + cfo_hz = float(slope / (4 * np.pi)) + else: + cfo_hz = 0.0 + preamble_time = (np.arange(count) + 0.5) / SYMBOL_RATE + corrected_data = symbols[:count] * expected_symbols[:count] + gain = complex(np.mean( + corrected_data * np.exp(-2j * np.pi * cfo_hz * preamble_time) + )) + return cfo_hz, gain + + corrected_data = symbols[:count] * expected_symbols[:count] + time = (np.arange(count) + 0.5) / SYMBOL_RATE + frequencies = np.fft.fftfreq(count, 1 / SYMBOL_RATE) + spectrum = np.abs(np.fft.fft(corrected_data * np.hanning(count))) ** 2 + candidates = np.flatnonzero(np.abs(frequencies) <= 100.0) + peak_index = int(candidates[int(np.argmax(spectrum[candidates]))]) + neighbours = np.array([(peak_index - 1) % count, peak_index, (peak_index + 1) % count]) + logarithmic_power = np.log(np.maximum(spectrum[neighbours], 1e-300)) + denominator = logarithmic_power[0] - 2 * logarithmic_power[1] + logarithmic_power[2] + interpolation = ( + 0.0 + if abs(denominator) < 1e-18 + else 0.5 * (logarithmic_power[0] - logarithmic_power[2]) / denominator + ) + coarse_cfo = float(frequencies[peak_index] + interpolation * SYMBOL_RATE / count) + + block_length = 512 + block_count = count // block_length + derotated = corrected_data * np.exp(-2j * np.pi * coarse_cfo * time) + if block_count >= 2: + block_values = derotated[: block_count * block_length].reshape(block_count, block_length).mean(1) + block_time = time[: block_count * block_length].reshape(block_count, block_length).mean(1) + phase = np.unwrap(np.angle(block_values)) + weights = np.abs(block_values) ** 2 + matrix = np.column_stack((np.ones(block_count), block_time - block_time[0])) + root_weights = np.sqrt(weights / max(float(np.max(weights)), 1e-30)) + slope = np.linalg.lstsq( + matrix * root_weights[:, None], phase * root_weights, rcond=None + )[0][1] + cfo_hz = float(coarse_cfo + slope / (2 * np.pi)) + else: + cfo_hz = coarse_cfo + gain = complex(np.mean(corrected_data * np.exp(-2j * np.pi * cfo_hz * time))) + return cfo_hz, gain + + +def _evaluate_symbols( + matched: np.ndarray, + nominal: int, + timing_offset: int, + bits: np.ndarray, + estimation_count: int, +) -> dict: + expected = radio.bpsk_modulate(bits).astype(np.complex128) + symbols = matched[ + nominal + timing_offset : nominal + timing_offset + len(bits) * SAMPLES_PER_SYMBOL : SAMPLES_PER_SYMBOL + ].astype(np.complex128) + count = min(len(symbols), len(expected)) + symbols = symbols[:count] + expected = expected[:count] + bits = bits[:count] + cfo_hz, gain = _estimate_cfo_gain(symbols, expected, estimation_count) + time = (np.arange(count) + 0.5) / SYMBOL_RATE + derotation = np.exp(-2j * np.pi * cfo_hz * time) + if gain == 0 or count == 0: + return { + "timing_offset_samples": timing_offset, + "cfo_hz": cfo_hz, + "abs_h": 0.0, + "phase_rad": 0.0, + "phase_deg": 0.0, + "errors": count, + "ber": 1.0 if count else float("nan"), + "preamble_errors": min(count, PILOT_SYMBOL_COUNT), + "symbols_compared": count, + "snr_db": float("-inf"), + "centers": [], + "distance": 0.0, + "within_rms": 0.0, + "separation": 0.0, + } + normalized = symbols * derotation / gain + decisions = normalized.real >= 0.0 + errors = int(np.count_nonzero(decisions != bits)) + preamble_count = min(PILOT_SYMBOL_COUNT, count) + preamble_errors = int(np.count_nonzero(decisions[:preamble_count] != bits[:preamble_count])) + residual = symbols - gain * expected * np.exp(2j * np.pi * cfo_hz * time) + snr_db = float(_db(abs(gain) ** 2 / np.mean(np.abs(residual) ** 2))) + centers: list[complex] = [] + squared_residual = 0.0 + for bit in (0, 1): + group = normalized[bits == bit] + center = complex(np.mean(group)) + centers.append(center) + squared_residual += float(np.sum(np.abs(group - center) ** 2)) + within_rms = math.sqrt(squared_residual / count) + distance = float(abs(centers[1] - centers[0])) + return { + "timing_offset_samples": int(timing_offset), + "cfo_hz": cfo_hz, + "abs_h": float(abs(gain)), + "phase_rad": float(np.angle(gain)), + "phase_deg": float(np.degrees(np.angle(gain))), + "polarity": "resolved from preamble" if estimation_count == PILOT_SYMBOL_COUNT else "oracle full frame", + "errors": errors, + "ber": errors / count, + "preamble_errors": preamble_errors, + "symbols_compared": count, + "snr_db": snr_db, + "centers": [{"i": center.real, "q": center.imag} for center in centers], + "distance": distance, + "within_rms": within_rms, + "separation": distance / within_rms, + } + + +def analyze_capture(samples: np.ndarray, include_oracle: bool = True) -> dict: + """Offline-анализ с независимой preamble-only синхронизацией. + + Полная известная форма используется только в явно помеченной oracle + диагностике и никогда не влияет на официальный preamble PASS. + """ + + _, _, all_bits = reference_bits() + expected_symbols = radio.bpsk_modulate(all_bits) + tx_samples = _tx_cs8_complex() + raw = raw_statistics(samples) + rx_decimated, tx_decimated = _detection_streams(samples, tx_samples) + detection = detect_preamble(rx_decimated, tx_decimated) + + independent_start = _refine_frame_start(samples, tx_samples, detection["start_sample"]) + independent_matched, nominal = _matched_waveform(samples, independent_start, len(tx_samples)) + preamble_timing, _ = _timing_offsets(independent_matched, nominal, expected_symbols) + formal = _evaluate_symbols( + independent_matched, + nominal, + preamble_timing, + all_bits, + PILOT_SYMBOL_COUNT, + ) + # После независимого start можно оценить |h| по всей известной форме как + # измерительную характеристику. Эта оценка не участвует в синхронизации. + full_measurement = _evaluate_symbols( + independent_matched, + nominal, + preamble_timing, + all_bits, + TOTAL_SYMBOL_COUNT, + ) + + oracle: dict | None = None + oracle_detection: dict | None = None + if include_oracle: + full_scores = _normalized_sliding_correlation(rx_decimated, tx_decimated) + full_guard = int(0.15 * SAMPLE_RATE_HZ / DETECTION_DECIMATION) + oracle_detection = _peak_metrics(full_scores, full_guard) + oracle_coarse = oracle_detection["peak_index"] * DETECTION_DECIMATION + oracle_start = _refine_frame_start(samples, tx_samples, oracle_coarse) + oracle_matched, oracle_nominal = _matched_waveform(samples, oracle_start, len(tx_samples)) + _, oracle_timing = _timing_offsets(oracle_matched, oracle_nominal, expected_symbols) + oracle = _evaluate_symbols( + oracle_matched, + oracle_nominal, + oracle_timing, + all_bits, + TOTAL_SYMBOL_COUNT, + ) + oracle["start_sample"] = oracle_start + oracle["data_leakage"] = "full 17664-bit waveform used for start/timing" + + preamble_detected = bool(detection["preamble_detected"]) + selected_h = full_measurement["abs_h"] if preamble_detected else (oracle or formal)["abs_h"] + selected_h_status = "INDEPENDENT_SYNC" if preamble_detected else "ORACLE_DIAGNOSTIC" + return { + "raw": raw, + "timing": { + "detected_start_sample": int(detection["start_sample"]), + "refined_start_sample": int(independent_start), + "start_seconds": independent_start / SAMPLE_RATE_HZ, + "nominal_symbol_sample": int(nominal), + "symbol_timing_phase_samples": int(preamble_timing), + }, + "detection": detection, + "synchronization": { + "independent": preamble_detected, + "payload_used_for_start_or_timing": False, + "estimated_carrier_phase_rad": formal["phase_rad"], + "estimated_carrier_phase_deg": formal["phase_deg"], + "cfo_hz": formal["cfo_hz"], + "polarity": formal["polarity"], + "preamble_errors": formal["preamble_errors"], + "preamble_symbols_compared": min(PILOT_SYMBOL_COUNT, formal["symbols_compared"]), + }, + "formal": formal if preamble_detected else None, + "formal_candidate": formal, + "full_frame_measurement_after_independent_sync": ( + full_measurement if preamble_detected else None + ), + "oracle_diagnostic": oracle, + "oracle_detection": oracle_detection, + "channel": { + "abs_h": selected_h, + "status": selected_h_status, + "delta_definition": "20*log10(abs_h_new/abs_h_previous)", + }, + "official_ber": formal["ber"] if preamble_detected else None, + "official_ser": formal["ber"] if preamble_detected else None, + "official_ber_note": ( + "по независимой preamble-only синхронизации" + if preamble_detected + else "НЕ ВЫЧИСЛЕНО: только oracle diagnostic" + ), + "diagnostic_ber": (oracle or formal)["ber"], + "symbol_snr_db": ( + full_measurement["snr_db"] if preamble_detected else (oracle or formal)["snr_db"] + ), + "separation": ( + full_measurement["separation"] if preamble_detected else (oracle or formal)["separation"] + ), + "clipping_count": raw["clipping_count"], + } + + +def h_ladder_table(points: list[tuple[int, int, Path]]) -> list[dict]: + """Свести |h| и производные показатели по нескольким сохранённым точкам.""" + + rows: list[dict] = [] + previous_abs_h: float | None = None + for lna, vga, path in points: + analysis = analyze_capture(load_cs8(path)) + abs_h = analysis["channel"]["abs_h"] + delta_db = None if previous_abs_h in (None, 0.0) else 20.0 * math.log10(abs_h / previous_abs_h) + rows.append({ + "lna_db": lna, + "vga_db": vga, + "abs_h": abs_h, + "delta_abs_h_db": delta_db, + "sync_status": analysis["channel"]["status"], + "preamble_detected": analysis["detection"]["preamble_detected"], + "symbol_snr_db": analysis["symbol_snr_db"], + "separation": analysis["separation"], + "official_ber": analysis["official_ber"], + "diagnostic_ber": analysis["diagnostic_ber"], + "clipping_count": analysis["clipping_count"], + }) + previous_abs_h = abs_h + return rows + + +# ================================================================== аппаратный режим + + +@dataclass +class TransmitGuard: + """Гарантия ровно одной передачи за аппаратный опыт.""" + + transmit_calls: int = 0 + tx_allowed: bool = False + + def allow(self) -> None: + self.tx_allowed = True + + def register_transmit(self) -> None: + if not self.tx_allowed: + raise RuntimeError("Передача запрещена: общий clock-gate не пройден") + if self.transmit_calls >= 1: + raise RuntimeError("Повторная передача запрещена: разрешён ровно один TX") + self.transmit_calls += 1 + + +CommandRunner = Callable[[list[str]], subprocess.CompletedProcess] + + +def _default_command_runner(command: list[str]) -> subprocess.CompletedProcess: # pragma: no cover - железо + return subprocess.run(command, capture_output=True, text=True, timeout=30) + + +@dataclass +class RunningCommand: + """Фоновый процесс с принадлежащими ему файлами stdout/stderr.""" + + process: subprocess.Popen + stdout_handle: TextIO + stderr_handle: TextIO + + def poll(self) -> int | None: + return self.process.poll() + + def wait(self, timeout: float) -> int: + try: + return self.process.wait(timeout=timeout) + finally: + self.close_logs() + + def terminate(self) -> None: + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=5) + self.close_logs() + + def close_logs(self) -> None: + if not self.stdout_handle.closed: + self.stdout_handle.close() + if not self.stderr_handle.closed: + self.stderr_handle.close() + + +ProcessStarter = Callable[[list[str], Path, Path], RunningCommand] +SleepFunction = Callable[[float], None] +MonotonicFunction = Callable[[], float] + + +def _default_process_starter( + command: list[str], stdout_path: Path, stderr_path: Path +) -> RunningCommand: # pragma: no cover - железо + stdout_handle = stdout_path.open("w", encoding="utf-8") + stderr_handle = stderr_path.open("w", encoding="utf-8") + try: + process = subprocess.Popen( + command, + stdout=stdout_handle, + stderr=stderr_handle, + text=True, + ) + except Exception: + stdout_handle.close() + stderr_handle.close() + raise + return RunningCommand(process, stdout_handle, stderr_handle) + + +def parse_clkin_status(output: str) -> str: + """Строго разобрать единственную допустимую строку состояния CLKIN. + + Проверка намеренно fail-closed: положительное и отрицательное состояния + распознаются только по полному совпадению. Пустой, многострочный или любой + неожиданный вывод возвращает UNKNOWN и не может открыть clock-gate. + """ + + normalized_output = output.strip() + if normalized_output == "CLKIN status: clock signal detected": + return CLKIN_STATUS_DETECTED + if normalized_output == "CLKIN status: no clock signal detected": + return CLKIN_STATUS_NOT_DETECTED + return CLKIN_STATUS_UNKNOWN + + +def wait_for_slave_clock( + runner: CommandRunner, + *, + initial_settle_s: float = CLOCK_INITIAL_SETTLE_SECONDS, + poll_interval_s: float = CLOCK_POLL_INTERVAL_SECONDS, + timeout_s: float = CLOCK_TIMEOUT_SECONDS, + sleep_function: SleepFunction = time.sleep, + monotonic_function: MonotonicFunction = time.monotonic, + started_at: float | None = None, +) -> dict: + """Ограниченно ждать точного DETECTED от SLAVE, сохраняя каждый poll. + + UNKNOWN, ненулевой exit code и исключение subprocess немедленно закрывают + gate. NOT_DETECTED разрешает следующий запрос только пока не исчерпан + монотонный timeout. + """ + + if initial_settle_s < 0 or poll_interval_s <= 0 or timeout_s <= 0: + raise ValueError("Параметры ожидания clock должны быть положительными") + if initial_settle_s > timeout_s: + raise ValueError("initial_settle_s не может превышать timeout_s") + + start = monotonic_function() if started_at is None else started_at + settle_remaining = initial_settle_s - (monotonic_function() - start) + if settle_remaining > 0: + sleep_function(settle_remaining) + + clock_tool = str(HACKRF_TOOLS_DIR / "hackrf_clock.exe") + polls: list[dict] = [] + while True: + query_started_s = monotonic_function() - start + if query_started_s > timeout_s: + return { + "state": CLOCK_WAIT_TIMEOUT, + "queries": len(polls), + "time_to_detect_ms": None, + "elapsed_ms": 1000.0 * query_started_s, + "polls": polls, + } + command = [clock_tool, "-d", SLAVE_SERIAL, "-i"] + try: + result = runner(command) + except Exception as error: + elapsed_s = monotonic_function() - start + polls.append({ + "query": len(polls) + 1, + "start_ms": 1000.0 * query_started_s, + "end_ms": 1000.0 * elapsed_s, + "raw": f"{type(error).__name__}: {error}", + "state": CLOCK_WAIT_ERROR, + "exit_code": None, + }) + return { + "state": CLOCK_WAIT_ERROR, + "queries": len(polls), + "time_to_detect_ms": None, + "elapsed_ms": 1000.0 * elapsed_s, + "polls": polls, + } + + raw_output = "\n".join( + part.strip() + for part in ((result.stdout or ""), (result.stderr or "")) + if part.strip() + ) + parsed_state = ( + parse_clkin_status(raw_output) + if result.returncode == 0 + else CLOCK_WAIT_ERROR + ) + elapsed_s = monotonic_function() - start + polls.append({ + "query": len(polls) + 1, + "start_ms": 1000.0 * query_started_s, + "end_ms": 1000.0 * elapsed_s, + "raw": raw_output, + "state": parsed_state, + "exit_code": result.returncode, + }) + if parsed_state == CLKIN_STATUS_DETECTED: + return { + "state": CLKIN_STATUS_DETECTED, + "queries": len(polls), + "time_to_detect_ms": 1000.0 * elapsed_s, + "elapsed_ms": 1000.0 * elapsed_s, + "polls": polls, + } + if parsed_state in {CLKIN_STATUS_UNKNOWN, CLOCK_WAIT_ERROR}: + return { + "state": parsed_state, + "queries": len(polls), + "time_to_detect_ms": None, + "elapsed_ms": 1000.0 * elapsed_s, + "polls": polls, + } + + remaining_s = timeout_s - (monotonic_function() - start) + if remaining_s <= 1e-9: + return { + "state": CLOCK_WAIT_TIMEOUT, + "queries": len(polls), + "time_to_detect_ms": None, + "elapsed_ms": 1000.0 * (monotonic_function() - start), + "polls": polls, + } + sleep_function(min(poll_interval_s, remaining_s)) + + +def _read_si5351_reg29(serial: str, runner: CommandRunner) -> int: + """Прочитать Si5351 register 29 указанной платы (read-only).""" + + tool = str(HACKRF_TOOLS_DIR / "hackrf_debug.exe") + result = runner([tool, "-d", serial, "--si5351c", "-n", "29", "-r"]) + if result.returncode != 0: + raise RuntimeError(f"hackrf_debug reg29 не выполнен для {serial}: {result.stderr.strip()}") + text = (result.stdout or "") + (result.stderr or "") + for token in text.replace(":", " ").replace("=", " ").split(): + try: + return int(token, 0) + except ValueError: + continue + raise RuntimeError(f"reg29 не разобран для {serial}: {text.strip()}") + + +def _set_clkout(serial: str, enabled: bool, runner: CommandRunner) -> None: + tool = str(HACKRF_TOOLS_DIR / "hackrf_clock.exe") + result = runner([tool, "-d", serial, "-o", "1" if enabled else "0"]) + if result.returncode != 0: + output = ((result.stdout or "") + (result.stderr or "")).strip() + raise RuntimeError(f"CLKOUT {serial} не установлен в {enabled}: {output}") + + +def _short_rx(serial: str, runner: CommandRunner) -> None: + """Один конечный RX-start для штатного transceiver_startup, без TX.""" + + tool = str(HACKRF_TOOLS_DIR / "hackrf_transfer.exe") + command = [ + tool, + "-d", serial, + "-r", "NUL", + "-f", str(RF_HZ), + "-s", str(SAMPLE_RATE_HZ), + "-n", str(SHORT_RX_SAMPLE_COUNT), + "-l", "0", + "-g", "0", + "-a", "0", + "-p", "0", + "-B", + ] + result = runner(command) + if result.returncode != 0: + output = ((result.stdout or "") + (result.stderr or "")).strip() + raise RuntimeError(f"Короткий RX {serial} не выполнен: {output}") + + +def _disable_clock_outputs(runner: CommandRunner) -> list[str]: + """Best-effort cleanup обоих CLKOUT; вернуть описания ошибок.""" + + errors: list[str] = [] + for serial in (MASTER_SERIAL, SLAVE_SERIAL): + try: + _set_clkout(serial, False, runner) + except RuntimeError as error: + errors.append(str(error)) + return errors + + +def common_clock_preflight( + runner: CommandRunner, + guard: TransmitGuard, + *, + sleep_function: SleepFunction = time.sleep, + monotonic_function: MonotonicFunction = time.monotonic, +) -> dict: + """Fail-closed проверка общей опоры до разрешения передачи. + + Любая ошибка оставляет guard.tx_allowed = False, поэтому передача + остаётся запрещённой. TX разрешается только при полном PASS. + """ + + guard.tx_allowed = False + try: + _set_clkout(MASTER_SERIAL, False, runner) + _set_clkout(SLAVE_SERIAL, False, runner) + _short_rx(MASTER_SERIAL, runner) + master_reg29 = _read_si5351_reg29(MASTER_SERIAL, runner) + if master_reg29 != REG29_EXTERNAL_CLOCK: + raise RuntimeError(f"MASTER reg29={master_reg29:#04x} != {REG29_EXTERNAL_CLOCK:#04x}") + + clock_wait_started = monotonic_function() + _set_clkout(MASTER_SERIAL, True, runner) + clock_wait = wait_for_slave_clock( + runner, + sleep_function=sleep_function, + monotonic_function=monotonic_function, + started_at=clock_wait_started, + ) + clkin_status = clock_wait["state"] + if clkin_status != CLKIN_STATUS_DETECTED: + last_output = clock_wait["polls"][-1]["raw"] if clock_wait["polls"] else "<нет запросов>" + raise RuntimeError(f"SLAVE CLKIN={clkin_status}: {last_output}") + + _short_rx(SLAVE_SERIAL, runner) + slave_reg29 = _read_si5351_reg29(SLAVE_SERIAL, runner) + if slave_reg29 != REG29_EXTERNAL_CLOCK: + raise RuntimeError(f"SLAVE reg29={slave_reg29:#04x} != {REG29_EXTERNAL_CLOCK:#04x}") + except Exception: + _disable_clock_outputs(runner) + raise + + guard.allow() + return { + "passed": True, + "master_reg29": master_reg29, + "slave_reg29": slave_reg29, + "slave_clkin_status": clkin_status, + "slave_clock_wait": clock_wait, + "master_serial": MASTER_SERIAL, + "slave_serial": SLAVE_SERIAL, + } + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while block := stream.read(1024 * 1024): + digest.update(block) + return digest.hexdigest() + + +def _transfer_log_is_clean(text: str) -> bool: + """Отклонить явный ненулевой overrun/underrun и неясную ошибку USB.""" + + lowered = text.lower() + for match in re.finditer(r"(\d+)\s+(?:rx\s+)?overruns?|(\d+)\s+(?:tx\s+)?underruns?", lowered): + values = [value for value in match.groups() if value is not None] + if any(int(value) != 0 for value in values): + return False + suspicious = ("overrun", "underrun") + for word in suspicious: + if word in lowered and not re.search(rf"0\s+(?:rx\s+|tx\s+)?{word}s?", lowered): + return False + return True + + +def _capture_intervals(samples: np.ndarray, frame_start: int, frame_length: int) -> dict: + """Статистика независимых областей до, во время и после кадра.""" + + frame_start = max(0, min(int(frame_start), len(samples))) + frame_end = max(frame_start, min(frame_start + frame_length, len(samples))) + + def statistics_or_none(region: np.ndarray) -> dict | None: + return raw_statistics(region) if len(region) else None + + return { + "before": statistics_or_none(samples[:frame_start]), + "during": statistics_or_none(samples[frame_start:frame_end]), + "after": statistics_or_none(samples[frame_end:]), + "frame_start_sample": frame_start, + "frame_end_sample": frame_end, + } + + +def _point_passes(analysis: dict) -> bool: + return bool( + analysis["detection"]["preamble_detected"] + and analysis["official_ber"] is not None + and analysis["official_ber"] <= PASS_BER_MAX + and analysis["symbol_snr_db"] >= PASS_SNR_DB_MIN + and analysis["separation"] >= PASS_SEPARATION_MIN + and analysis["raw"]["clipping_count"] == 0 + and analysis["raw"]["near_int8_count"] == 0 + ) + + +def _point_stop_reason(analysis: dict, delta_abs_h_db: float | None) -> str | None: + raw = analysis["raw"] + if raw["clipping_count"]: + return "ADC_CLIPPING" + if raw["near_int8_count"]: + return "NEAR_SIGNED_INT8_LIMIT" + if delta_abs_h_db is not None and delta_abs_h_db < MIN_H_CHANGE_DB: + return "ABS_H_ANOMALOUS_DECLINE" + return None + + +def run_one_gain_point( + lna_gain_db: int, + vga_gain_db: int, + output_directory: Path, + runner: CommandRunner | None = None, + process_starter: ProcessStarter | None = None, + sleep_function: SleepFunction = time.sleep, + previous_abs_h: float | None = None, +) -> dict: + """Один аппаратный опыт: preflight, RX до TX, ровно один TX, анализ. + + Порядок fail-closed: сначала общий clock-gate, только затем передача. + При любой ошибке после передачи второй TX не выполняется. + """ + + validate_hackrf_rx_gains(lna_gain_db, vga_gain_db) + runner = runner or _default_command_runner + process_starter = process_starter or _default_process_starter + guard = TransmitGuard() + tx_payload = build_tx_cs8() # SHA-проверка формы до любого обращения к железу + waveform_sha256 = hashlib.sha256(tx_payload).hexdigest() + rx_process: RunningCommand | None = None + + try: + preflight = common_clock_preflight(runner, guard) + output_directory.mkdir(parents=True, exist_ok=True) + tx_path = output_directory / "tx_bpsk_cs8.iq" + tx_path.write_bytes(tx_payload) + rx_path = output_directory / "rx_cs8.iq" + 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(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(CAPTURE_SAMPLE_COUNT), + "-l", str(lna_gain_db), + "-g", str(vga_gain_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() + details = rx_stderr_path.read_text(encoding="utf-8", errors="replace") + raise RuntimeError(f"RX завершился до разрешения TX: {details.strip()}") + + tx_command = [ + transfer_tool, + "-d", MASTER_SERIAL, + "-t", str(tx_path), + "-f", str(RF_HZ), + "-s", str(SAMPLE_RATE_HZ), + "-n", str(len(tx_payload) // 2), + "-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}") + + rx_returncode = rx_process.wait(RX_WAIT_TIMEOUT_SECONDS) + if rx_returncode != 0: + raise RuntimeError(f"RX завершился с кодом {rx_returncode}") + + expected_size = CAPTURE_SAMPLE_COUNT * 2 + size_bytes = rx_path.stat().st_size + if size_bytes != expected_size: + raise RuntimeError(f"Размер raw {size_bytes}, ожидалось {expected_size}") + raw_sha256_first = _sha256_file(rx_path) + raw_sha256_second = _sha256_file(rx_path) + if raw_sha256_first != raw_sha256_second: + raise RuntimeError("SHA-256 raw изменился после закрытия файла") + + 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 _transfer_log_is_clean(rx_log): + raise RuntimeError("RX log содержит overrun/underrun") + if not _transfer_log_is_clean(tx_log): + raise RuntimeError("TX log содержит overrun/underrun") + + samples = load_cs8(rx_path) + analysis = analyze_capture(samples) + frame_start = analysis["timing"]["refined_start_sample"] + analysis["capture_intervals"] = _capture_intervals( + samples, frame_start, len(tx_payload) // 2 + ) + abs_h = analysis["channel"]["abs_h"] + delta_abs_h_db = ( + None + if previous_abs_h in (None, 0.0) + else 20.0 * math.log10(abs_h / previous_abs_h) + ) + passed = _point_passes(analysis) + stop_reason = "BPSK_PASS" if passed else _point_stop_reason(analysis, delta_abs_h_db) + point_result = { + "lna_db": lna_gain_db, + "vga_db": vga_gain_db, + "preflight": preflight, + "waveform_sha256": waveform_sha256, + "raw": { + "path": str(rx_path), + "size_bytes": size_bytes, + "sample_count": CAPTURE_SAMPLE_COUNT, + "duration_seconds": CAPTURE_SAMPLE_COUNT / SAMPLE_RATE_HZ, + "sha256_first": raw_sha256_first, + "sha256_second": raw_sha256_second, + }, + "tx_calls": guard.transmit_calls, + "delta_abs_h_db": delta_abs_h_db, + "passed": passed, + "stop_reason": stop_reason, + "analysis": analysis, + } + metadata = { + key: value for key, value in point_result.items() if key != "analysis" + } + (output_directory / "metadata.json").write_text( + json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8" + ) + (output_directory / "analysis.json").write_text( + json.dumps(analysis, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return point_result + finally: + if rx_process is not None: + try: + rx_process.terminate() + except Exception: + pass + _disable_clock_outputs(runner) + + +def default_ladder_output_directory() -> Path: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + return ( + Path.home() + / "Documents" + / "SDR_Rover_Lab043_raw" + / f"{timestamp}_common_clock_bpsk_gain_ladder_2" + ) + + +def default_settle_output_directory() -> Path: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + return ( + Path.home() + / "Documents" + / "SDR_Rover_Lab043_raw" + / f"{timestamp}_common_clock_settle_test" + ) + + +def run_common_clock_settle_test( + output_directory: Path, + cycle_count: int = CLOCK_SETTLE_CYCLE_COUNT, + runner: CommandRunner | None = None, + sleep_function: SleepFunction = time.sleep, + monotonic_function: MonotonicFunction = time.monotonic, +) -> dict: + """Выполнить bounded common-clock-only циклы и сохранить доказательства.""" + + if cycle_count <= 0: + raise ValueError("cycle_count должен быть положительным") + runner = runner or _default_command_runner + cycles: list[dict] = [] + for cycle_number in range(1, cycle_count + 1): + cycle = { + "cycle": cycle_number, + "t0_utc": None, + "master_reg29": None, + "slave_reg29": None, + "slave_clkin": None, + "time_to_detect_ms": None, + "number_of_queries": 0, + "polls": [], + "passed": False, + "error": None, + "cleanup_errors": [], + } + try: + _set_clkout(MASTER_SERIAL, False, runner) + _set_clkout(SLAVE_SERIAL, False, runner) + _short_rx(MASTER_SERIAL, runner) + cycle["master_reg29"] = _read_si5351_reg29(MASTER_SERIAL, runner) + if cycle["master_reg29"] != REG29_EXTERNAL_CLOCK: + raise RuntimeError( + f"MASTER reg29={cycle['master_reg29']:#04x} != {REG29_EXTERNAL_CLOCK:#04x}" + ) + + cycle["t0_utc"] = datetime.now(timezone.utc).isoformat() + clock_wait_started = monotonic_function() + _set_clkout(MASTER_SERIAL, True, runner) + clock_wait = wait_for_slave_clock( + runner, + sleep_function=sleep_function, + monotonic_function=monotonic_function, + started_at=clock_wait_started, + ) + cycle["slave_clkin"] = clock_wait["state"] + cycle["time_to_detect_ms"] = clock_wait["time_to_detect_ms"] + cycle["number_of_queries"] = clock_wait["queries"] + cycle["polls"] = clock_wait["polls"] + if clock_wait["state"] != CLKIN_STATUS_DETECTED: + raise RuntimeError(f"SLAVE CLKIN={clock_wait['state']}") + + _short_rx(SLAVE_SERIAL, runner) + cycle["slave_reg29"] = _read_si5351_reg29(SLAVE_SERIAL, runner) + if cycle["slave_reg29"] != REG29_EXTERNAL_CLOCK: + raise RuntimeError( + f"SLAVE reg29={cycle['slave_reg29']:#04x} != {REG29_EXTERNAL_CLOCK:#04x}" + ) + cycle["passed"] = True + except Exception as error: + cycle["error"] = f"{type(error).__name__}: {error}" + finally: + cycle["cleanup_errors"] = _disable_clock_outputs(runner) + cycles.append(cycle) + + if not cycle["passed"]: + break + if cycle_number < cycle_count: + sleep_function(CLOCK_INTER_CYCLE_SECONDS) + + report = { + "test": "Lab043 common clock bounded settle", + "created_utc": datetime.now(timezone.utc).isoformat(), + "parameters": { + "initial_settle_s": CLOCK_INITIAL_SETTLE_SECONDS, + "poll_interval_s": CLOCK_POLL_INTERVAL_SECONDS, + "timeout_s": CLOCK_TIMEOUT_SECONDS, + "inter_cycle_s": CLOCK_INTER_CYCLE_SECONDS, + "requested_cycles": cycle_count, + }, + "completed_cycles": len(cycles), + "passed_cycles": sum(int(cycle["passed"]) for cycle in cycles), + "stable": len(cycles) == cycle_count and all(cycle["passed"] for cycle in cycles), + "rf_tx_count": 0, + "cycles": cycles, + } + output_directory.mkdir(parents=True, exist_ok=True) + (output_directory / "settle_report.json").write_text( + json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" + ) + lines = [ + "Lab043 common-clock settle test", + f"parameters: initial={CLOCK_INITIAL_SETTLE_SECONDS:.3f}s, " + f"interval={CLOCK_POLL_INTERVAL_SECONDS:.3f}s, timeout={CLOCK_TIMEOUT_SECONDS:.3f}s", + f"cycles: {report['passed_cycles']}/{cycle_count} PASS", + f"common-clock gate: {'STABLE' if report['stable'] else 'UNSTABLE'}", + "RF TX count: 0", + "", + ] + for cycle in cycles: + lines.append( + f"cycle {cycle['cycle']}: {'PASS' if cycle['passed'] else 'FAIL'}, " + f"detect_ms={cycle['time_to_detect_ms']}, queries={cycle['number_of_queries']}, " + f"MASTER reg29={cycle['master_reg29']}, SLAVE CLKIN={cycle['slave_clkin']}, " + f"SLAVE reg29={cycle['slave_reg29']}, error={cycle['error']}" + ) + (output_directory / "settle_report.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") + return report + + +def run_gain_ladder(output_directory: Path) -> dict: # pragma: no cover - железо + """Последовательно выполнить только нужные точки и остановиться fail-closed.""" + + rows: list[dict] = [] + previous_abs_h: float | None = BASELINE_24_16_ABS_H + classification = "B" + stop_reason = "LADDER_EXHAUSTED_WITHOUT_PASS" + for lna_gain_db, vga_gain_db in GAIN_LADDER: + point_directory = output_directory / f"lna{lna_gain_db:02d}_vga{vga_gain_db:02d}" + try: + result = run_one_gain_point( + lna_gain_db, + vga_gain_db, + point_directory, + previous_abs_h=previous_abs_h, + ) + except Exception as error: + classification = "D" + stop_reason = f"{type(error).__name__}: {error}" + break + rows.append(result) + previous_abs_h = result["analysis"]["channel"]["abs_h"] + if result["passed"]: + classification = "A" + stop_reason = "BPSK_PASS" + break + if result["stop_reason"] is not None: + classification = "C" + stop_reason = result["stop_reason"] + break + + report = { + "classification": classification, + "baseline": { + "lna_db": 24, + "vga_db": 16, + "abs_h": BASELINE_24_16_ABS_H, + }, + "stop_reason": stop_reason, + "recommended_rx_gain": ( + {"lna_db": rows[-1]["lna_db"], "vga_db": rows[-1]["vga_db"]} + if rows and rows[-1]["passed"] + else None + ), + "tx_count": sum(row["tx_calls"] for row in rows), + "points": rows, + "output_directory": str(output_directory), + } + output_directory.mkdir(parents=True, exist_ok=True) + (output_directory / "ladder_report.json").write_text( + json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return report + + +# ================================================================== CLI + + +def _analyze_existing(path: Path) -> int: + analysis = analyze_capture(load_cs8(path)) + print(json.dumps(analysis, ensure_ascii=False, indent=2, default=str)) + return 0 + + +def _validate_old_ladder(root: Path) -> int: + """Отдельная offline-regression внешних raw, не входящих в Git.""" + + points = [ + (8, 8, root / "lna08_vga08" / "rx_cs8.iq"), + (16, 8, root / "lna16_vga08" / "rx_cs8.iq"), + (16, 16, root / "lna16_vga16" / "rx_cs8.iq"), + (24, 16, root / "lna24_vga16" / "rx_cs8.iq"), + ] + missing = [str(path) for _, _, path in points if not path.is_file()] + if missing: + raise FileNotFoundError("Отсутствуют regression raw: " + ", ".join(missing)) + report = { + "root": str(root), + "waveform_sha256": EXPECTED_TX_SHA256, + "rows": h_ladder_table(points), + } + print(json.dumps(report, ensure_ascii=False, indent=2, default=str)) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Lab043 BPSK HackRF -> HackRF") + parser.add_argument("--analyze-existing", type=Path, help="offline-анализ сохранённого cs8-захвата") + parser.add_argument( + "--validate-old-ladder", + type=Path, + help="offline-regression четырёх внешних raw первой gain ladder", + ) + parser.add_argument("--hardware", action="store_true", help="аппаратный опыт (по умолчанию заблокирован)") + parser.add_argument( + "--hardware-ladder", + action="store_true", + help="последовательная gain ladder с анализом и STOP после каждой точки", + ) + parser.add_argument( + "--common-clock-settle-test", + action="store_true", + help="пять bounded common-clock-only циклов без RF TX", + ) + parser.add_argument("--lna", type=int, default=24) + parser.add_argument("--vga", type=int, default=16) + parser.add_argument("--i-understand-hardware", action="store_true", help="явное подтверждение передачи") + parser.add_argument("--output-directory", type=Path) + arguments = parser.parse_args() + + if arguments.analyze_existing is not None: + return _analyze_existing(arguments.analyze_existing) + + if arguments.validate_old_ladder is not None: + return _validate_old_ladder(arguments.validate_old_ladder) + + if arguments.common_clock_settle_test: + output_directory = arguments.output_directory or default_settle_output_directory() + report = run_common_clock_settle_test(output_directory) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 if report["stable"] else 1 + + if arguments.hardware or arguments.hardware_ladder: + if not arguments.i_understand_hardware: + print("Аппаратный режим заблокирован. Нужен явный --i-understand-hardware.") + return 2 + output_directory = arguments.output_directory or default_ladder_output_directory() + if arguments.hardware_ladder: + report = run_gain_ladder(output_directory) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 if report["classification"] in {"A", "B", "C"} else 1 + result = run_one_gain_point(arguments.lna, arguments.vga, output_directory) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + parser.print_help() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_lab043_hackrf_bpsk.py b/tests/test_lab043_hackrf_bpsk.py new file mode 100644 index 0000000..7f12780 --- /dev/null +++ b/tests/test_lab043_hackrf_bpsk.py @@ -0,0 +1,511 @@ +"""Проверки fail-closed разбора CLKIN для Lab043.""" + +from __future__ import annotations + +import hashlib +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +from experiments import lab043_hackrf_bpsk as bpsk + + +class FakeCommandRunner: + """Предсказуемый HackRF host без обращения к оборудованию.""" + + def __init__( + self, + clkin_output: str | list[str] = "CLKIN status: clock signal detected\n", + tx_returncode: int = 0, + clkin_exception_at: int | None = None, + ) -> None: + self.clkin_outputs = [clkin_output] if isinstance(clkin_output, str) else list(clkin_output) + self.tx_returncode = tx_returncode + self.clkin_exception_at = clkin_exception_at + self.clkin_queries = 0 + self.commands: list[list[str]] = [] + + def __call__(self, command: list[str]) -> subprocess.CompletedProcess: + self.commands.append(command) + executable = Path(command[0]).name + if executable == "hackrf_debug.exe": + return subprocess.CompletedProcess(command, 0, "[ 29] -> 0x26\n", "") + if executable == "hackrf_clock.exe" and "-i" in command: + self.clkin_queries += 1 + if self.clkin_exception_at == self.clkin_queries: + raise subprocess.SubprocessError("mock clock query failure") + index = min(self.clkin_queries - 1, len(self.clkin_outputs) - 1) + return subprocess.CompletedProcess(command, 0, self.clkin_outputs[index], "") + if executable == "hackrf_transfer.exe" and "-t" in command: + return subprocess.CompletedProcess(command, self.tx_returncode, "", "") + return subprocess.CompletedProcess(command, 0, "", "") + + +class FakeClock: + """Монотонные часы, которые продвигаются только через sleep.""" + + def __init__(self) -> None: + self.seconds = 0.0 + self.sleeps: list[float] = [] + + def monotonic(self) -> float: + return self.seconds + + def sleep(self, seconds: float) -> None: + self.sleeps.append(seconds) + self.seconds += seconds + + +class FakeRunningCommand: + def __init__(self, startup_failed: bool = False, returncode: int = 0) -> None: + self.startup_failed = startup_failed + self.returncode = returncode + self.terminated = False + self.logs_closed = False + + def poll(self) -> int | None: + if self.startup_failed: + return self.returncode or 1 + return self.returncode if self.terminated else None + + def wait(self, timeout: float) -> int: + del timeout + self.logs_closed = True + return self.returncode + + def terminate(self) -> None: + self.terminated = True + self.logs_closed = True + + def close_logs(self) -> None: + self.logs_closed = True + + +class FakeProcessStarter: + def __init__(self, startup_failed: bool = False, returncode: int = 0) -> None: + self.startup_failed = startup_failed + self.returncode = returncode + self.processes: list[FakeRunningCommand] = [] + + def __call__(self, command: list[str], stdout_path: Path, stderr_path: Path) -> FakeRunningCommand: + stdout_path.write_text("0 overruns\n", encoding="utf-8") + stderr_path.write_text("", encoding="utf-8") + raw_path = Path(command[command.index("-r") + 1]) + raw_path.write_bytes(bytes(2 * bpsk.CAPTURE_SAMPLE_COUNT)) + process = FakeRunningCommand(self.startup_failed, self.returncode) + self.processes.append(process) + return process + + +def fake_analysis() -> dict: + return { + "timing": {"refined_start_sample": 2}, + "detection": {"preamble_detected": True}, + "official_ber": 0.0, + "symbol_snr_db": 10.0, + "separation": 3.0, + "channel": {"abs_h": 0.01}, + "raw": { + "clipping_count": 0, + "near_int8_count": 0, + "adc_headroom_code": 100.0, + }, + } + + +def test_parse_clkin_status_detected() -> None: + assert ( + bpsk.parse_clkin_status("CLKIN status: clock signal detected\n") + == bpsk.CLKIN_STATUS_DETECTED + ) + + +def test_parse_clkin_status_not_detected() -> None: + assert ( + bpsk.parse_clkin_status("CLKIN status: no clock signal detected\n") + == bpsk.CLKIN_STATUS_NOT_DETECTED + ) + + +def test_parse_clkin_status_empty_output_is_unknown() -> None: + assert bpsk.parse_clkin_status("") == bpsk.CLKIN_STATUS_UNKNOWN + + +def test_parse_clkin_status_unexpected_output_is_unknown() -> None: + assert bpsk.parse_clkin_status("CLKIN status unavailable") == bpsk.CLKIN_STATUS_UNKNOWN + + +def test_wait_for_slave_clock_detected_on_first_query() -> None: + runner = FakeCommandRunner() + clock = FakeClock() + + result = bpsk.wait_for_slave_clock( + runner, sleep_function=clock.sleep, monotonic_function=clock.monotonic + ) + + assert result["state"] == bpsk.CLKIN_STATUS_DETECTED + assert result["queries"] == 1 + assert result["time_to_detect_ms"] == pytest.approx(50.0) + + +def test_wait_for_slave_clock_delayed_detected() -> None: + runner = FakeCommandRunner([ + "CLKIN status: no clock signal detected\n", + "CLKIN status: no clock signal detected\n", + "CLKIN status: clock signal detected\n", + ]) + clock = FakeClock() + + result = bpsk.wait_for_slave_clock( + runner, sleep_function=clock.sleep, monotonic_function=clock.monotonic + ) + + assert result["state"] == bpsk.CLKIN_STATUS_DETECTED + assert [poll["state"] for poll in result["polls"]] == [ + bpsk.CLKIN_STATUS_NOT_DETECTED, + bpsk.CLKIN_STATUS_NOT_DETECTED, + bpsk.CLKIN_STATUS_DETECTED, + ] + + +def test_wait_for_slave_clock_timeout_is_bounded_and_fail_closed() -> None: + runner = FakeCommandRunner("CLKIN status: no clock signal detected\n") + clock = FakeClock() + guard = bpsk.TransmitGuard() + + with pytest.raises(RuntimeError, match="TIMEOUT"): + bpsk.common_clock_preflight( + runner, + guard, + sleep_function=clock.sleep, + monotonic_function=clock.monotonic, + ) + + assert runner.clkin_queries == 10 + assert clock.seconds == pytest.approx(bpsk.CLOCK_TIMEOUT_SECONDS) + assert guard.transmit_calls == 0 + assert guard.tx_allowed is False + + +def test_wait_for_slave_clock_unknown_is_immediate_fail_closed() -> None: + runner = FakeCommandRunner("CLKIN status unavailable\n") + clock = FakeClock() + guard = bpsk.TransmitGuard() + + with pytest.raises(RuntimeError, match="UNKNOWN"): + bpsk.common_clock_preflight( + runner, + guard, + sleep_function=clock.sleep, + monotonic_function=clock.monotonic, + ) + + assert runner.clkin_queries == 1 + assert guard.transmit_calls == 0 + + +def test_wait_for_slave_clock_subprocess_exception_is_fail_closed() -> None: + runner = FakeCommandRunner(clkin_exception_at=1) + clock = FakeClock() + guard = bpsk.TransmitGuard() + + with pytest.raises(RuntimeError, match="ERROR"): + bpsk.common_clock_preflight( + runner, + guard, + sleep_function=clock.sleep, + monotonic_function=clock.monotonic, + ) + + assert guard.transmit_calls == 0 + assert guard.tx_allowed is False + + +def test_wait_for_slave_clock_detected_at_timeout_boundary() -> None: + runner = FakeCommandRunner([ + "CLKIN status: no clock signal detected\n", + "CLKIN status: no clock signal detected\n", + "CLKIN status: no clock signal detected\n", + "CLKIN status: clock signal detected\n", + ]) + clock = FakeClock() + + result = bpsk.wait_for_slave_clock( + runner, + initial_settle_s=0.05, + poll_interval_s=0.05, + timeout_s=0.20, + sleep_function=clock.sleep, + monotonic_function=clock.monotonic, + ) + + assert result["state"] == bpsk.CLKIN_STATUS_DETECTED + assert result["queries"] == 4 + assert result["time_to_detect_ms"] == pytest.approx(200.0) + + +def test_clock_failure_cleanup_disables_both_outputs() -> None: + runner = FakeCommandRunner("unexpected") + clock = FakeClock() + guard = bpsk.TransmitGuard() + + with pytest.raises(RuntimeError): + bpsk.common_clock_preflight( + runner, + guard, + sleep_function=clock.sleep, + monotonic_function=clock.monotonic, + ) + + clock_commands = [ + command for command in runner.commands if Path(command[0]).name == "hackrf_clock.exe" + ] + assert [command[command.index("-d") + 1] for command in clock_commands[-2:]] == [ + bpsk.MASTER_SERIAL, + bpsk.SLAVE_SERIAL, + ] + assert all(command[-2:] == ["-o", "0"] for command in clock_commands[-2:]) + + +def test_common_clock_preflight_is_fail_closed_and_skips_slave_rx() -> None: + runner = FakeCommandRunner("CLKIN status: no clock signal detected\n") + guard = bpsk.TransmitGuard() + + with pytest.raises(RuntimeError, match="TIMEOUT"): + bpsk.common_clock_preflight(runner, guard) + + transfer_commands = [ + command for command in runner.commands if Path(command[0]).name == "hackrf_transfer.exe" + ] + assert len(transfer_commands) == 1 + assert bpsk.MASTER_SERIAL in transfer_commands[0] + assert guard.tx_allowed is False + assert guard.transmit_calls == 0 + + +def test_common_clock_preflight_runs_both_short_rx_before_allowing_tx() -> None: + runner = FakeCommandRunner() + guard = bpsk.TransmitGuard() + + result = bpsk.common_clock_preflight(runner, guard) + + transfer_commands = [ + command for command in runner.commands if Path(command[0]).name == "hackrf_transfer.exe" + ] + assert [command[command.index("-d") + 1] for command in transfer_commands] == [ + bpsk.MASTER_SERIAL, + bpsk.SLAVE_SERIAL, + ] + assert result["master_reg29"] == 0x26 + assert result["slave_reg29"] == 0x26 + assert result["slave_clkin_status"] == bpsk.CLKIN_STATUS_DETECTED + assert guard.tx_allowed is True + + +def test_failure_before_tx_produces_zero_tx_calls(tmp_path: Path) -> None: + runner = FakeCommandRunner("CLKIN status: no clock signal detected\n") + + with pytest.raises(RuntimeError, match="TIMEOUT"): + bpsk.run_one_gain_point(24, 16, tmp_path, runner) + + assert not any("-t" in command for command in runner.commands) + + +def prepare_mock_point(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(bpsk, "CAPTURE_SAMPLE_COUNT", 16) + monkeypatch.setattr(bpsk, "analyze_capture", lambda samples: fake_analysis()) + + +def test_waveform_sha_failure_produces_zero_tx_calls( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runner = FakeCommandRunner() + monkeypatch.setattr(bpsk, "build_tx_cs8", lambda: (_ for _ in ()).throw(ValueError("SHA"))) + + with pytest.raises(ValueError, match="SHA"): + bpsk.run_one_gain_point(24, 24, tmp_path, runner) + + assert runner.commands == [] + + +def test_rx_startup_failure_produces_zero_tx_calls( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + prepare_mock_point(monkeypatch) + runner = FakeCommandRunner() + starter = FakeProcessStarter(startup_failed=True, returncode=1) + + with pytest.raises(RuntimeError, match="RX завершился до разрешения TX"): + bpsk.run_one_gain_point(24, 24, tmp_path, runner, starter, lambda _: None) + + assert not any("-t" in command for command in runner.commands) + assert starter.processes[0].terminated is True + + +def test_successful_experiment_has_one_tx_and_expected_order( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + prepare_mock_point(monkeypatch) + events: list[str] = [] + runner = FakeCommandRunner() + original_runner = runner + + def recording_runner(command: list[str]) -> subprocess.CompletedProcess: + if "-t" in command: + events.append("tx_start_finish") + return original_runner(command) + + def preflight(command_runner: bpsk.CommandRunner, guard: bpsk.TransmitGuard) -> dict: + del command_runner + events.append("preflight") + guard.allow() + return {"passed": True} + + class RecordingProcess(FakeRunningCommand): + def wait(self, timeout: float) -> int: + events.append("rx_finish") + return super().wait(timeout) + + def starter(command: list[str], stdout_path: Path, stderr_path: Path) -> RecordingProcess: + events.append("rx_start") + stdout_path.write_text("0 overruns\n", encoding="utf-8") + stderr_path.write_text("", encoding="utf-8") + Path(command[command.index("-r") + 1]).write_bytes(bytes(2 * bpsk.CAPTURE_SAMPLE_COUNT)) + return RecordingProcess() + + monkeypatch.setattr(bpsk, "common_clock_preflight", preflight) + monkeypatch.setattr( + bpsk, + "analyze_capture", + lambda samples: events.append("analysis") or fake_analysis(), + ) + + result = bpsk.run_one_gain_point( + 24, 24, tmp_path, recording_runner, starter, lambda _: None + ) + + assert events == ["preflight", "rx_start", "tx_start_finish", "rx_finish", "analysis"] + assert result["tx_calls"] == 1 + assert result["passed"] is True + assert (tmp_path / "metadata.json").is_file() + assert (tmp_path / "analysis.json").is_file() + + +def test_tx_failure_has_no_retry_and_cleans_process_and_clocks( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + prepare_mock_point(monkeypatch) + runner = FakeCommandRunner(tx_returncode=1) + starter = FakeProcessStarter() + + with pytest.raises(RuntimeError, match="Единственный TX"): + bpsk.run_one_gain_point(24, 24, tmp_path, runner, starter, lambda _: None) + + assert sum("-t" in command for command in runner.commands) == 1 + assert starter.processes[0].terminated is True + clock_commands = [ + command for command in runner.commands if Path(command[0]).name == "hackrf_clock.exe" + ] + assert [command[command.index("-d") + 1] for command in clock_commands[-2:]] == [ + bpsk.MASTER_SERIAL, + bpsk.SLAVE_SERIAL, + ] + assert all(command[-2:] == ["-o", "0"] for command in clock_commands[-2:]) + + +def test_analysis_failure_has_no_retry_and_cleans_process( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(bpsk, "CAPTURE_SAMPLE_COUNT", 16) + monkeypatch.setattr( + bpsk, "analyze_capture", lambda samples: (_ for _ in ()).throw(RuntimeError("analysis")) + ) + runner = FakeCommandRunner() + starter = FakeProcessStarter() + + with pytest.raises(RuntimeError, match="analysis"): + bpsk.run_one_gain_point(24, 24, tmp_path, runner, starter, lambda _: None) + + assert sum("-t" in command for command in runner.commands) == 1 + assert starter.processes[0].terminated is True + + +def test_transmit_guard_allows_exactly_one_tx() -> None: + guard = bpsk.TransmitGuard() + with pytest.raises(RuntimeError, match="общий clock-gate"): + guard.register_transmit() + guard.allow() + guard.register_transmit() + with pytest.raises(RuntimeError, match="Повторная передача"): + guard.register_transmit() + assert guard.transmit_calls == 1 + + +def test_gain_ladder_first_delta_uses_scientific_24_16_baseline( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + previous_values: list[float | None] = [] + + def fake_point( + lna_gain_db: int, + vga_gain_db: int, + output_directory: Path, + runner: bpsk.CommandRunner | None = None, + process_starter: bpsk.ProcessStarter | None = None, + sleep_function: bpsk.SleepFunction = bpsk.time.sleep, + previous_abs_h: float | None = None, + ) -> dict: + del output_directory, runner, process_starter, sleep_function + previous_values.append(previous_abs_h) + return { + "lna_db": lna_gain_db, + "vga_db": vga_gain_db, + "tx_calls": 1, + "passed": True, + "stop_reason": "BPSK_PASS", + "analysis": {"channel": {"abs_h": 0.004}}, + } + + monkeypatch.setattr(bpsk, "run_one_gain_point", fake_point) + + report = bpsk.run_gain_ladder(tmp_path) + + assert previous_values == [bpsk.BASELINE_24_16_ABS_H] + assert report["classification"] == "A" + assert report["baseline"]["abs_h"] == bpsk.BASELINE_24_16_ABS_H + + +def test_deterministic_waveform_sha256() -> None: + assert hashlib.sha256(bpsk.build_tx_cs8()).hexdigest() == bpsk.EXPECTED_TX_SHA256 + + +def test_direct_invocation_smoke() -> None: + project_root = Path(__file__).resolve().parents[1] + result = subprocess.run( + [sys.executable, str(project_root / "experiments" / "lab043_hackrf_bpsk.py"), "--help"], + cwd=project_root, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + assert "--analyze-existing" in result.stdout + + +def test_offline_synthetic_bpsk() -> None: + tx = bpsk.signed_iq_bytes_to_complex64(bpsk.build_tx_cs8()) + capture = np.concatenate(( + np.zeros(80_000, dtype=np.complex64), + tx, + np.zeros(80_000, dtype=np.complex64), + )) + + analysis = bpsk.analyze_capture(capture, include_oracle=False) + + assert analysis["detection"]["preamble_detected"] is True + assert analysis["detection"]["payload_used_for_start"] is False + assert analysis["official_ber"] == 0.0 + assert analysis["synchronization"]["preamble_errors"] == 0