CLOCK-001: validate independent-clock QPSK receiver
This commit is contained in:
1662
data/processed/clock001/results.json
Normal file
1662
data/processed/clock001/results.json
Normal file
File diff suppressed because it is too large
Load Diff
1348
data/processed/rf001/offline_roundtrip.json
Normal file
1348
data/processed/rf001/offline_roundtrip.json
Normal file
File diff suppressed because it is too large
Load Diff
166
docs/rf/CLOCK_001.md
Normal file
166
docs/rf/CLOCK_001.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# CLOCK-001: независимые такты перед RF-001
|
||||||
|
|
||||||
|
17 сентября 2026 года. Статус **PARTIAL**: программный приёмник прошёл основную
|
||||||
|
сетку до ±50 ppm, но потерял длинные пакеты при ±100 ppm. Последняя точка
|
||||||
|
служит только стресс-тестом. Частоты реальных генераторов Auron HackRF Plus
|
||||||
|
не измерялись. SDR не открывался, `RF TX = 0`. Данные каждого запуска
|
||||||
|
сохранены в `data/processed/clock001/results.json`.
|
||||||
|
|
||||||
|
## Модель и вход приёмника
|
||||||
|
|
||||||
|
TX строит QPSK/FEC 2/3/RRC при номинальных 8 Мвыб/с и 500 ксимв/с. Для
|
||||||
|
приёмника с независимой частотой `Fs_RX = Fs_TX · (1 + ε)`, где
|
||||||
|
`ε = ppm · 10⁻⁶`, программа берёт комплексную TX-волну в моменты
|
||||||
|
`n / (1 + ε)` по её исходной сетке. Дробные отсчёты получаются линейной
|
||||||
|
интерполяцией. Поэтому ошибка момента **накапливается по времени**, а не
|
||||||
|
подменяется одним постоянным сдвигом. Интерполяция не описывает аналоговый
|
||||||
|
тракт АЦП, шум генераторов или аппаратное квантование.
|
||||||
|
|
||||||
|
CFO добавляется отдельно как поворот с частотой в герцах физического времени
|
||||||
|
RX; начальная фаза и AWGN задаются отдельными параметрами. Даже при нулевом
|
||||||
|
внешнем CFO цифровая несущая 125 кГц из-за изменения частоты выборки даёт
|
||||||
|
остаток `(125000 + CFO) / (1 + ε) − 125000` Гц относительно номинального
|
||||||
|
цифрового смесителя. Отчёт хранит и заданный CFO, и этот ожидаемый остаток;
|
||||||
|
один не выдаётся за другой. Частотный тест проверяет оба эффекта раздельно.
|
||||||
|
|
||||||
|
Программа `tools/clock001.py` строит по три пакета каждого размера с паузой
|
||||||
|
5 мс и seed `20260917`. Нагрузка 256 байт совпадает с RF-001; для 512 и
|
||||||
|
1012 байт та же SHA-последовательность продлена. **1012 байт** заполняют
|
||||||
|
допустимый PHY-пакет 1024 байта с 12 байтами заголовка и CRC. Во всех
|
||||||
|
39 точках ppm-сетки приёмник получает только полученный IQ и номинальный
|
||||||
|
радиорежим. TX-границы, исходные пакеты, заданный ppm/CFO и число пакетов
|
||||||
|
используются только после декодирования для оценки.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -B -X utf8 tools/clock001.py `
|
||||||
|
--numba-packages C:\Users\user\Desktop\projects\SDR_Rover\.lab053_cpu_runtime\packages
|
||||||
|
```
|
||||||
|
|
||||||
|
Параметр с локальным путём к Numba нужен на этом ПК; в обычном окружении с
|
||||||
|
зависимостями проекта он не требуется. Команда отказывается перезаписывать
|
||||||
|
готовый отчёт.
|
||||||
|
|
||||||
|
## Строгие результаты
|
||||||
|
|
||||||
|
В таблице каждая ячейка означает число **точных пакетов из трёх** после
|
||||||
|
PHY/FEC/CRC. CFO, фаза и AWGN в этой сетке равны нулю/отсутствуют.
|
||||||
|
|
||||||
|
| Payload | 0 | ±1 ppm | ±5 ppm | ±10 ppm | ±20 ppm | ±50 ppm | ±100 ppm, стресс |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|---:|
|
||||||
|
| 256 байт | 3/3 | 3/3 в обе стороны | 3/3 | 3/3 | 3/3 | 3/3 | 3/3 |
|
||||||
|
| 512 байт | 3/3 | 3/3 | 3/3 | 3/3 | 3/3 | 3/3 | 3/3 |
|
||||||
|
| 1012 байт | 3/3 | 3/3 | 3/3 | 3/3 | 3/3 | 3/3 | **0/3** |
|
||||||
|
|
||||||
|
Основная сетка 0, ±1, ±5, ±10, ±20 и ±50 ppm: **99/99**. Наибольшая
|
||||||
|
симметричная проверенная точка со 100% точных пакетов для всех размеров:
|
||||||
|
**±50 ppm**. Для 256 и 512 байт стресс ±100 ppm тоже дал 3/3 в обе стороны.
|
||||||
|
Одиночный пакет 1012 байт при −100 и +100 ppm дал 0/1; в обоих случаях
|
||||||
|
найден один корреляционный кандидат, но CRC-пакета нет. Значит, новый поиск
|
||||||
|
преамбулы перед следующим пакетом не устраняет эту потерю внутри текущего
|
||||||
|
длинного пакета. При ±100 ppm модель накапливает по нему ±0,7572 символа.
|
||||||
|
Контроль с отдельной кубической интерполяцией повторил для одиночного
|
||||||
|
1012-байтного пакета 1/1 при ±50 и 0/1 при ±100 ppm. Вывод не зависит
|
||||||
|
только от выбора линейного интерполятора в основном опыте.
|
||||||
|
|
||||||
|
Текущий приёмник оценивает начало **каждого** пакета заново, затем выбирает
|
||||||
|
его символы с постоянным шагом 16 отсчётов. Для 1012 байт при −50 ppm
|
||||||
|
медианная ошибка в начале/расчётная ошибка в конце составили
|
||||||
|
−0,0325 / +0,3461 символа; при +50 ppm +0,0322 / −0,3464. Разность по
|
||||||
|
модели равна ±0,3786 символа. Начальную ошибку считаем по началу принятого
|
||||||
|
CRC-пакета и известной **после приёма** TX-шкале. Конечная ошибка
|
||||||
|
экстраполирована из заданного ppm при фиксированном шаге приёмника; это не
|
||||||
|
независимое измерение последнего символа. Для потерянного пакета эти оценки
|
||||||
|
равны `null`, а введённый моделью дрейф указан отдельно.
|
||||||
|
|
||||||
|
В JSON `correlation_candidates` считает кандидатов поиска. Поле
|
||||||
|
`detected_packets` равно числу CRC-valid пакетов: нынешний API не выдаёт
|
||||||
|
отдельное число пакетов, распознанных до CRC. Поля точности и потерь
|
||||||
|
считаются отдельно по TX-ведомости после приёма.
|
||||||
|
|
||||||
|
Отдельно проверены шесть сочетаний для **256 байт**, каждое с тремя пакетами:
|
||||||
|
|
||||||
|
| ppm | CFO | Фаза | Eb/N0 | Точные пакеты |
|
||||||
|
|---:|---:|---:|---:|---:|
|
||||||
|
| +5 | +20 Гц | +15° | 9 и 12 дБ | 3/3 при каждом уровне |
|
||||||
|
| +10 | +100 Гц | +90° | 9 и 12 дБ | 3/3 при каждом уровне |
|
||||||
|
| +20 | +100 Гц | −45° | 9 и 12 дБ | 3/3 при каждом уровне |
|
||||||
|
|
||||||
|
Итого **18/18**. Шум задан прежней программной AWGN-моделью VIDEO-005;
|
||||||
|
это не оценка реального RF-канала.
|
||||||
|
|
||||||
|
## Компенсация и скорость
|
||||||
|
|
||||||
|
Для основной сетки повторной оценки момента по преамбуле каждого пакета
|
||||||
|
хватило. **Непрерывное слежение в рабочий приёмник не добавлялось.** Для
|
||||||
|
расширения диапазона до ±100 ppm на пакете 1012 байт потребовалась бы
|
||||||
|
коррекция внутри пакета, например отдельное исследование детектора ошибки
|
||||||
|
момента [Gardner для BPSK/QPSK](https://doi.org/10.1109/TCOM.1986.1096561)
|
||||||
|
с интерполяцией и петлёй. [Документация GNU Radio](https://www.gnuradio.org/doc/doxygen/classgr_1_1digital_1_1symbol__sync__cc.html)
|
||||||
|
показывает такой класс синхронизатора и его параметры. Перед включением
|
||||||
|
понадобятся строгие CRC-тесты на всей сетке, проверка без истинного ppm и
|
||||||
|
новый замер скорости. Старый BPSK-детектор сюда не переносился.
|
||||||
|
|
||||||
|
На каноническом потоке VIDEO-005 из 32 длинных пакетов с добавленными
|
||||||
|
+20 ppm, +20 Гц CFO, фазой +15° и Eb/N0 9 дБ получено **32/32** при каждом
|
||||||
|
из трёх прогретых запусков. Времена приёмника: 0,272 / 0,262 / 0,262 с;
|
||||||
|
длительность IQ 0,485764 с; медианный **RADIO RECEIVER REALTIME FACTOR =
|
||||||
|
0,540**. Построение канала и ресемплирование в это время не входят. Для
|
||||||
|
сравнения, сохранённый ориентир VIDEO-005 равен 0,578 на другом входе;
|
||||||
|
разница не доказывает ускорения. Первый запуск ppm-сетки включал JIT и не
|
||||||
|
использован для оценки скорости. Порог 0,67 на этом ПК выполнен.
|
||||||
|
|
||||||
|
## Code quality audit
|
||||||
|
|
||||||
|
Рабочий `receive_iq_stream()` и его защитные проверки не менялись. Новый
|
||||||
|
пересчёт временной шкалы, сравнение с эталоном и запись JSON остаются вне
|
||||||
|
пути приёма. Отдельный `cProfile` того же 32-пакетного IQ занял 0,293 с;
|
||||||
|
его время включает накладные расходы профилировщика и не подставляется в
|
||||||
|
фактор 0,540. Основные этапы:
|
||||||
|
|
||||||
|
| Этап | Время, мс | Доля профиля | Вызовы | Основные массивы и копии |
|
||||||
|
|---|---:|---:|---:|---|
|
||||||
|
| Цифровое смещение несущей | 16,4 | 5,6% | 1 | Осциллятор и baseband |
|
||||||
|
| Согласованный RRC | 62,1 | 21,2% | 1 | Выход и внутренние FFT-буферы |
|
||||||
|
| Поиск преамбул | 47,6 | 16,2% | 1 | Две корреляции и массивы энергии |
|
||||||
|
| Восстановление момента | 9,9 | 3,4% | 32 | Индексы и символы каждого кандидата |
|
||||||
|
| Оценка CFO и фазы | 12,2 | 4,2% | 32 | Исправленные символы |
|
||||||
|
| PHY-заголовок | 0,8 | 0,3% | 32 | Короткие массивы заголовка |
|
||||||
|
| Слежение за фазой тела | 78,4 | 26,8% | 32 | Копия символов пакета и блоки решений |
|
||||||
|
| Строгий PHY/FEC/CRC | 54,8 | 18,7% | 32 | Мягкие биты и рабочие массивы декодера |
|
||||||
|
|
||||||
|
Число аллокаций NumPy не измерялось; в таблице перечислены видимые по коду
|
||||||
|
массивы. Resampling создаёт один выходной complex64 IQ и временные массивы
|
||||||
|
до миллиона отсчётов за блок; в измеренное время приёмника он не входит. Проверки
|
||||||
|
размеров и конечности IQ оставлены как защита входа; диапазон ppm и длина
|
||||||
|
пакета проверяются при создании опыта. Проверка эталона находится после
|
||||||
|
приёмника. Ruff не нашёл неиспользуемых импортов, временных compatibility
|
||||||
|
wrappers и лабораторных ветвей в новом коде нет. Канонический генератор
|
||||||
|
32-пакетного VIDEO-005 входа также строит сигнал с нарушениями канала,
|
||||||
|
который CLOCK-001 не использует; это один офлайн-вызов, не приёмный hot path.
|
||||||
|
Удалять или переписывать его здесь нет оснований. Поведение VIDEO-005
|
||||||
|
проверено его неизменённым шлюзом: 178 тестов прошли.
|
||||||
|
|
||||||
|
| Предмет | Место | Почему проверен | Решение |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Дополнительный IQ от канонического генератора | `tools/clock001.py`, `run()` | Создаётся, но не участвует в CLOCK-входе | **KEEP**: однократная офлайн-цена за неизменный эталон VIDEO-005 |
|
||||||
|
| Длинная оркестрация опыта | `tools/clock001.py`, `run()` | Собирает сетку, сочетания и замер | **KEEP**: не исполняется в рабочем приёмнике; шаги явно разделены |
|
||||||
|
| Проверка конечности всего TX IQ | `tools/clock001.py`, `sample_independent_clock()` | Один проход по большому массиву | **KEEP**: защита от повреждённого входа до интерполяции |
|
||||||
|
|
||||||
|
В CLOCK-001 нет внешнего erasure FEC и видеотранспорта: это опыт с PHY-пакетами,
|
||||||
|
поэтому их время нельзя включать в фактор радиоприёмника.
|
||||||
|
|
||||||
|
## Следующий аппаратный план
|
||||||
|
|
||||||
|
План RF-001 разделён на **RF-001A** с проводной общей опорой 10 МГц как
|
||||||
|
лабораторным baseline и **RF-001B** с независимыми тактами. В B провод
|
||||||
|
CLKOUT → CLKIN отключён. Остальные параметры и TX-волна должны совпасть;
|
||||||
|
оба режима сохраняют исходный RX IQ и полные наблюдения счётчиков. В B
|
||||||
|
нужны последовательности оценок CFO и моментов пакетов, чтобы после
|
||||||
|
приёма оценить их изменение во времени и фактическую разницу частот
|
||||||
|
дискретизации. Моделируемые ppm не считаются характеристикой плат.
|
||||||
|
|
||||||
|
**Готовность A и B: да, только к подготовке контролируемого опыта.**
|
||||||
|
Фактические частоты, соединения, gain и RF-условия ещё не проверены. Для
|
||||||
|
каждого аппаратного сеанса потребуется отдельный preflight и прямое
|
||||||
|
разрешение. CLOCK-001 не открывала SDR, не меняла аппаратную конфигурацию
|
||||||
|
и не выполняла RF TX/RX. Коммита и push для этого этапа нет.
|
||||||
185
docs/rf/RF_001_PLAN.md
Normal file
185
docs/rf/RF_001_PLAN.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# RF-001 — подготовка первого QPSK-пакетного опыта
|
||||||
|
|
||||||
|
**Статус: PREPARED / NOT_RUN.** Это программная подготовка после коммита
|
||||||
|
VIDEO-005 `6df5f8a`. SDR не открывался; приёма и передачи через него не было.
|
||||||
|
`RF TX = 0`. Первый аппаратный сеанс требует отдельной постановки и прямого
|
||||||
|
разрешения.
|
||||||
|
|
||||||
|
## Цель и граница
|
||||||
|
|
||||||
|
Будущий опыт проверит известные PHY-пакеты через два Auron HackRF Plus.
|
||||||
|
Утверждён один начальный режим: QPSK, **500 000 символов/с**, внутренний FEC
|
||||||
|
**2/3**, прикладная нагрузка **256 байт**, пауза **5 мс**, **100 пакетов** с
|
||||||
|
номерами 0–99 и seed `20260917`. H.264, камера, видеотранспорт, внешний
|
||||||
|
erasure FEC и адаптивное переключение не участвуют. До первого аппаратного
|
||||||
|
результата нагрузку не увеличивать, а паузу не уменьшать.
|
||||||
|
|
||||||
|
```text
|
||||||
|
TX: payload(seed, sequence) → build_frame(QPSK, 2/3) → shape_packet(RRC)
|
||||||
|
→ IQ-файл → [будущий аппаратный IQSink]
|
||||||
|
RX: [будущий аппаратный IQSource] → исходный raw IQ-файл
|
||||||
|
→ receive_iq_stream() VIDEO-005 → CRC-valid пакеты
|
||||||
|
→ отдельное сравнение с TX-ведомостью и отчёт
|
||||||
|
```
|
||||||
|
|
||||||
|
PHY-приёмник не получает TX-границы, payload, истинную фазу, CFO, момент
|
||||||
|
выборки или ожидаемое число пакетов. SHA и ошибки сравниваются после строгого
|
||||||
|
декодирования. `receive_iq_stream()` для RF-001 не менялся. Исторический
|
||||||
|
`build_frame()` ставит в пакет тип 3; здесь содержимое — тестовые байты,
|
||||||
|
видеосборщик не вызывается.
|
||||||
|
|
||||||
|
## Файловые инструменты
|
||||||
|
|
||||||
|
`tools/qpsk_rf_tx.py` создаёт весь IQ без аппаратных импортов в новом
|
||||||
|
каталоге `data/raw/rf001/offline_qpsk_100_packets/`. Существующие файлы он
|
||||||
|
не перезаписывает. Нагрузка каждого номера получается из SHA-256 по seed,
|
||||||
|
номеру и счётчику блока, без зависимости от версии генератора NumPy.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -B -X utf8 tools/qpsk_rf_tx.py
|
||||||
|
```
|
||||||
|
|
||||||
|
`tx_waveform.c64le` — сырые little-endian complex64, 8 байт на комплексный
|
||||||
|
отсчёт, без заголовка. `tx_metadata.json` содержит `test_id`, UTC-время,
|
||||||
|
режим, номинальный цифровой сдвиг несущей 125 кГц, seed, номера и SHA-256
|
||||||
|
payload/пакетов, паузу, dtype, число/длительность отсчётов, SHA IQ и
|
||||||
|
TX-границы. Последние остаются только в ведомости. Форма каждого пакета
|
||||||
|
включает по 64 защитных отсчёта на концах; между массивами вставлены
|
||||||
|
39 872 нулевых отсчёта. Итого пауза вместе с защитными участками — ровно
|
||||||
|
**40 000 отсчётов = 5 мс**. Это цифровая форма без аппаратной калибровки
|
||||||
|
амплитуды и преобразования к формату TX устройства.
|
||||||
|
|
||||||
|
`tools/qpsk_rf_rx.py` читает `c64le` и вызывает существующий приёмник
|
||||||
|
VIDEO-005. Необязательная TX-ведомость читается **после** вызова приёмника.
|
||||||
|
Для будущего сохранённого аппаратного файла предусмотрен вход
|
||||||
|
`--iq-format cs8`: чередующиеся знаковые байты I/Q преобразуются в
|
||||||
|
complex64 для анализа, исходный файл не меняется; отдельно считаются
|
||||||
|
коды насыщения −128/127. Это файловый конвертер, не аппаратный адаптер.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -B -X utf8 tools/qpsk_rf_rx.py `
|
||||||
|
--iq-file data/raw/rf001/offline_qpsk_100_packets/tx_waveform.c64le `
|
||||||
|
--truth-metadata data/raw/rf001/offline_qpsk_100_packets/tx_metadata.json `
|
||||||
|
--report data/processed/rf001/offline_roundtrip.json `
|
||||||
|
--numba-packages C:\Users\user\Desktop\projects\SDR_Rover\.lab053_cpu_runtime\packages
|
||||||
|
```
|
||||||
|
|
||||||
|
Путь `--numba-packages` относится к текущему ПК; на втором ноутбуке Numba
|
||||||
|
берётся из его окружения. Без TX-ведомости инструмент сообщает кандидатов
|
||||||
|
преамбулы и CRC-пакеты, номера, повторы и оценки CFO/фазы/такта, но не
|
||||||
|
объявляет точность или потери относительно неизвестного ожидаемого списка.
|
||||||
|
|
||||||
|
## Программный круг на 100 пакетах
|
||||||
|
|
||||||
|
| Показатель | Результат |
|
||||||
|
|---|---:|
|
||||||
|
| IQ | 8 833 828 отсчётов; 1,1042285 с; 70 670 624 байта |
|
||||||
|
| SHA-256 IQ | `7cd81395efaa88b623b7e4e5a02e3a80b52185007397ea56ceaacc13353974b2` |
|
||||||
|
| Кандидаты корреляционного поиска / отвергнутые | 557 / 457 |
|
||||||
|
| CRC-valid / точные пакеты | 100 / 100 |
|
||||||
|
| Пропущенные / дубли | 0 / 0 |
|
||||||
|
| Raw BER по всем 100 принятым пакетам | 0 / 322 500 кодированных битов |
|
||||||
|
| Ошибки после FEC | 0 в сопоставленных пакетах |
|
||||||
|
|
||||||
|
**557 — кандидаты корреляции, а не 557 принятых преамбул.** Только 100
|
||||||
|
прошли PHY/FEC/CRC. Raw BER получен отдельным повтором штатных PHY-функций
|
||||||
|
после первого приёма. При потере пакетов полный raw BER неизвестен: API
|
||||||
|
VIDEO-005 не отдаёт мягкие решения отвергнутых кандидатов. Тогда отчёт
|
||||||
|
покажет BER на CRC-valid пакетах отдельно, а `raw_ber` оставит `null`.
|
||||||
|
Условный BER нельзя выдавать за BER всего канала.
|
||||||
|
Полный машинный отчёт сохранён в `data/processed/rf001/offline_roundtrip.json`;
|
||||||
|
исходный IQ и TX-ведомость лежат в игнорируемом Git `data/raw/rf001/`.
|
||||||
|
|
||||||
|
## Будущий захват и аппаратная граница
|
||||||
|
|
||||||
|
Следующий этап сначала сохраняет **исходные** байты аппаратного RX, затем
|
||||||
|
анализирует их офлайн. Небольшая граница `IQSource` создаёт raw IQ-файл и
|
||||||
|
`capture_metadata.json`; файловый анализатор читает сохранённый файл.
|
||||||
|
`IQSink` сможет принять заранее проверенный TX-файл только после отдельного
|
||||||
|
допуска. PHY-модули не знают о HackRF API; собственный libusb-слой не нужен.
|
||||||
|
|
||||||
|
Предлагаемый файловый контракт capture: `capture.cs8` без заголовка, пары
|
||||||
|
знаковых байтов `I0,Q0,I1,Q1,…`; исходный файл не нормализовать и не
|
||||||
|
перезаписывать. `capture_metadata.json` создаётся рядом. Неизвестные поля
|
||||||
|
остаются `null` или `NOT_RUN`, а не заполняются нулями:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"status": "NOT_RUN",
|
||||||
|
"raw_iq_file": "capture.cs8",
|
||||||
|
"iq_format": "cs8_interleaved_signed_iq",
|
||||||
|
"sample_rate_hz": 8000000,
|
||||||
|
"sample_count": null,
|
||||||
|
"raw_iq_sha256": null,
|
||||||
|
"capture_started_utc": null,
|
||||||
|
"center_frequency_hz": null,
|
||||||
|
"nominal_digital_carrier_offset_hz": 125000,
|
||||||
|
"clock_mode": "NOT_RUN",
|
||||||
|
"clock_link": "NOT_RUN",
|
||||||
|
"estimated_sample_clock_offset_ppm": null,
|
||||||
|
"cfo_series_hz": null,
|
||||||
|
"timing_drift_series_symbols": null,
|
||||||
|
"receiver_serial": null,
|
||||||
|
"gain_settings": null,
|
||||||
|
"common_clock": "NOT_RUN",
|
||||||
|
"tx_underrun_observations": [],
|
||||||
|
"rx_overrun_observations": [],
|
||||||
|
"rx_clipping_rail_codes": null,
|
||||||
|
"counter_observation_complete": false,
|
||||||
|
"capture_validity": "NOT_RUN",
|
||||||
|
"command_log": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
В metadata захвата потребуются время, путь/формат/SHA raw IQ, частота
|
||||||
|
дискретизации, центр RF, номинальный цифровой сдвиг, число отсчётов,
|
||||||
|
серийные номера, gain, состояние общей опоры, журнал команд и **все**
|
||||||
|
наблюдения underrun/overrun/clipping по ходу сеанса. Неполный или нечистый
|
||||||
|
счётчик делает результат непригодным для PASS; конечный ноль не отменяет
|
||||||
|
промежуточную потерю. Сейчас аппаратного источника/стока и фактического
|
||||||
|
capture metadata нет — это будущая отдельно разрешённая работа.
|
||||||
|
Дрейф частоты дискретизации не моделировался в VIDEO-005 и остаётся отдельным
|
||||||
|
риском для реального захвата.
|
||||||
|
|
||||||
|
RF-001 делится на два будущих режима с отдельным разрешением на каждый выход
|
||||||
|
в эфир. **RF-001A** использует проводную общую опору 10 МГц только как
|
||||||
|
лабораторный baseline: TCXO → MASTER → MASTER CLKOUT → SLAVE CLKIN. Предыдущие
|
||||||
|
лабораторные проверки фиксировали MASTER и SLAVE `reg29 = 0x26`, `SLAVE CLKIN =
|
||||||
|
DETECTED`. Это **исторические данные**, а не свежая проверка подключения.
|
||||||
|
**RF-001B** использует независимые такты: провод CLKOUT → CLKIN снят. Именно
|
||||||
|
этот вариант соответствует архитектуре ровера и НСУ без проводной опоры.
|
||||||
|
|
||||||
|
Для сравнения A/B одинаковыми остаются RF-частота, 500 ксимв/с, FEC 2/3,
|
||||||
|
payload 256 байт, пауза 5 мс, 100 пакетов, TX-уровень, RX-усиления,
|
||||||
|
геометрия, антенны и TX-волна. Оба сеанса сначала сохраняют исходный RX IQ и
|
||||||
|
полный журнал счётчиков. В RF-001B по принятой преамбуле каждого пакета и
|
||||||
|
известным TX-номерам нужно оценить CFO по времени и наклон ошибки момента
|
||||||
|
выборки, затем разницу частот дискретизации. Эти оценки делаются **после**
|
||||||
|
строгого PHY/FEC/CRC; потери и пропуски не подменяются известными границами
|
||||||
|
передатчика. Программная сетка CLOCK-001 проверила до ±50 ppm, но фактический
|
||||||
|
ppm плат ещё не измерен. Частота RF, gain, кабели, антенны и уровень сигнала
|
||||||
|
для нового опыта не утверждены здесь; они войдут в отдельный аппаратный
|
||||||
|
preflight.
|
||||||
|
|
||||||
|
## Форма отчёта и следующий критерий
|
||||||
|
|
||||||
|
Будущий отчёт содержит `RF-001 STATUS`, режим, число кандидатов преамбулы,
|
||||||
|
CRC-valid и точных пакетов, долю точных, raw BER с областью расчёта,
|
||||||
|
ошибки после FEC, дубли/пропуски, медиану CFO и P95 |CFO|, оценки фазы/такта,
|
||||||
|
TX underruns, RX overruns, RX clipping, общую опору
|
||||||
|
`DETECTED / NOT_DETECTED`, SHA capture и причины отказа. Сейчас аппаратные
|
||||||
|
поля — `NOT_RUN`, а не нули.
|
||||||
|
|
||||||
|
Для будущего короткого OTA smoke test, если захват и счётчики чисты:
|
||||||
|
не менее 99 точных из 100 — GOOD; 95–98 — DIAGNOSTIC/PARTIAL; менее 95 —
|
||||||
|
не переходить к видео. Это предварительная классификация, не
|
||||||
|
эксплуатационная норма. Программные 100/100 не доказывают RF PASS.
|
||||||
|
|
||||||
|
Только после отдельного аппаратного PASS: сравнение пауз 5 → 2 → 1 → 0,5
|
||||||
|
→ 0 мс при 256 байтах. После выбора паузы — отдельно 256 → 512 →
|
||||||
|
максимально допустимый рабочий payload. Здесь матрица не выполняется.
|
||||||
|
|
||||||
|
**RF-001 HARDWARE STATUS = NOT_RUN. SDR HARDWARE = NOT_ACCESSED. RF TX = 0.**
|
||||||
|
Следующий шаг — отдельное согласование preflight и одного контролируемого
|
||||||
|
сеанса; автоматического перехода к эфиру нет.
|
||||||
48
docs/rf/SECOND_LAPTOP_RF_SETUP.md
Normal file
48
docs/rf/SECOND_LAPTOP_RF_SETUP.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Второй ноутбук для будущего RF-001
|
||||||
|
|
||||||
|
Это список подготовки Windows 10/11. В текущей задаче второй ноутбук,
|
||||||
|
USB-драйвер и HackRF не проверялись. Никакой пункт ниже не запускается
|
||||||
|
автоматически.
|
||||||
|
|
||||||
|
## Программы и проект
|
||||||
|
|
||||||
|
1. Зафиксировать выпуск Windows и имеющийся USB-драйвер для каждого Auron
|
||||||
|
HackRF Plus. Сначала инвентаризация; Zadig не запускать без отдельного
|
||||||
|
решения. Прошивку устройства не обновлять автоматически.
|
||||||
|
2. Установить Git и VS Code. Установить **Python 3.13.1**, как на основном
|
||||||
|
ПК при подготовке RF-001; проверить `python --version` и `git --version`.
|
||||||
|
3. Получить согласованный коммит и ветку `video-development`, сверить HEAD.
|
||||||
|
Создать отдельное виртуальное окружение Python и установить зависимости
|
||||||
|
из `requirements-video.txt`; для разработки также
|
||||||
|
`requirements-video-dev.txt`. Проверить импорты NumPy, SciPy и Numba,
|
||||||
|
затем запустить целевые программные тесты.
|
||||||
|
4. Установить совместимые HackRF host tools/libhackrf, но до отдельного
|
||||||
|
аппаратного разрешения не открывать устройство. Позже, во время
|
||||||
|
согласованной инвентаризации, проверить `hackrf_info` и сведения
|
||||||
|
о каждом устройстве, не выполняя TX/RX. Не заменять драйвер только ради
|
||||||
|
ошибки команды без проверки текущего состояния USB.
|
||||||
|
5. Сверить SHA-256 программного `tx_waveform.c64le` с TX-ведомостью RF-001.
|
||||||
|
Локально повторить файловый RX: 100/100 точных пакетов до аппаратного
|
||||||
|
этапа.
|
||||||
|
|
||||||
|
## Перед будущим аппаратным сеансом
|
||||||
|
|
||||||
|
- Зафиксировать серийные номера MASTER/SLAVE, версии firmware и host tools,
|
||||||
|
идентификаторы USB и состояние драйверов. Само наличие `hackrf_info` не
|
||||||
|
означает исправную общую опору или готовность к передаче.
|
||||||
|
- Сверить физическую схему TCXO 10 МГц → MASTER → MASTER CLKOUT → SLAVE
|
||||||
|
CLKIN для лабораторного RF-001A; не создавать петлю через SLAVE CLKOUT.
|
||||||
|
Для отдельного RF-001B провод CLKOUT → CLKIN отключается при неизменных
|
||||||
|
прочих параметрах. Исторические проверки
|
||||||
|
`reg29 = 0x26` на обеих платах и `SLAVE CLKIN = DETECTED` нужно подтвердить
|
||||||
|
в новом preflight, если он будет отдельно разрешён.
|
||||||
|
- Записать согласованные RF-частоту, усиления, антенны/кабели, геометрию,
|
||||||
|
формат TX и лимиты уровня. Цифровой файл complex64 не является готовым
|
||||||
|
аппаратным потоком до отдельного преобразования и проверки уровня.
|
||||||
|
- Настроить сохранение исходного RX IQ и metadata **до** офлайн-декодирования.
|
||||||
|
Сохранить полный журнал команд и значения underrun/overrun/clipping,
|
||||||
|
включая промежуточные наблюдения. Неполный захват не считать PASS.
|
||||||
|
|
||||||
|
Команды `python --version`, `git --version` и `hackrf_info` приведены как
|
||||||
|
будущий чеклист. В рамках программной подготовки RF-001 аппаратные команды
|
||||||
|
не выполнялись, RF TX = 0.
|
||||||
@@ -19,8 +19,14 @@
|
|||||||
"tools/video005_video.py",
|
"tools/video005_video.py",
|
||||||
"tools/video005_benchmark.py",
|
"tools/video005_benchmark.py",
|
||||||
"tools/video_gate.py",
|
"tools/video_gate.py",
|
||||||
|
"tools/qpsk_rf_common.py",
|
||||||
|
"tools/qpsk_rf_tx.py",
|
||||||
|
"tools/qpsk_rf_rx.py",
|
||||||
|
"tools/clock001.py",
|
||||||
"tests/test_video005_iq.py",
|
"tests/test_video005_iq.py",
|
||||||
"tests/test_video005_video.py"
|
"tests/test_video005_video.py",
|
||||||
|
"tests/test_rf001_preparation.py",
|
||||||
|
"tests/test_clock001.py"
|
||||||
],
|
],
|
||||||
"typeCheckingMode": "basic",
|
"typeCheckingMode": "basic",
|
||||||
"pythonVersion": "3.13",
|
"pythonVersion": "3.13",
|
||||||
|
|||||||
97
tests/test_clock001.py
Normal file
97
tests/test_clock001.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""CLOCK-001: отдельная частота АЦП, строгий приём и область метрик."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from scipy.ndimage import map_coordinates
|
||||||
|
|
||||||
|
from protocol.video003_modem import SAMPLE_RATE_HZ
|
||||||
|
from protocol.video005_receiver import IqStreamResult, receive_iq_stream
|
||||||
|
from tools import clock001
|
||||||
|
from tools.qpsk_rf_common import SEED, payload_for_sequence
|
||||||
|
|
||||||
|
|
||||||
|
def test_sampling_rate_changes_time_base_and_keeps_cfo_independent() -> None:
|
||||||
|
tone_hz = 125_000
|
||||||
|
cfo_hz = 100
|
||||||
|
indexes = np.arange(200_000)
|
||||||
|
transmitted = np.exp(2j * np.pi * tone_hz * indexes / SAMPLE_RATE_HZ).astype(np.complex64)
|
||||||
|
for ppm in (-50, 0, 50):
|
||||||
|
received = clock001.sample_independent_clock(
|
||||||
|
transmitted, ppm, cfo_hz=cfo_hz, initial_phase_degrees=45,
|
||||||
|
)
|
||||||
|
scale = 1 + ppm * 1e-6
|
||||||
|
assert len(received) == math.floor((len(transmitted) - 1) * scale) + 1
|
||||||
|
measured_hz = (np.angle(np.vdot(received[:-1].astype(np.complex128),
|
||||||
|
received[1:].astype(np.complex128)))
|
||||||
|
* SAMPLE_RATE_HZ / (2 * np.pi))
|
||||||
|
assert measured_hz == pytest.approx((tone_hz + cfo_hz) / scale, abs=0.2)
|
||||||
|
assert np.angle(received[0]) == pytest.approx(np.pi / 4, abs=1e-6)
|
||||||
|
assert clock001.payload_for_case(256, 7) == payload_for_sequence(SEED, 7)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ppm", (-50, 50))
|
||||||
|
def test_long_packets_reacquire_and_decode_at_primary_limit(ppm: int) -> None:
|
||||||
|
fixture = clock001.build_fixture(1012)
|
||||||
|
received_iq = clock001.sample_independent_clock(fixture.iq, ppm)
|
||||||
|
result = receive_iq_stream(received_iq, decoder_backend="legacy")
|
||||||
|
summary = clock001.summarize_case(
|
||||||
|
result, fixture, sample_clock_offset_ppm=ppm, cfo_hz=0,
|
||||||
|
initial_phase_degrees=0, ebn0_db=None, receiver_time_s=1.0,
|
||||||
|
received_samples=len(received_iq),
|
||||||
|
)
|
||||||
|
assert summary["exact_packets"] == 3
|
||||||
|
assert summary["exact_sequences"] == [0, 1, 2]
|
||||||
|
assert summary["reacquisition_per_packet"] is True
|
||||||
|
assert summary["injected_timing_drift_across_packet_symbols"] == pytest.approx(-ppm * 1e-6 * 7572)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ppm", (-100, 100))
|
||||||
|
def test_long_packet_stress_failure_is_not_declared_exact(ppm: int) -> None:
|
||||||
|
fixture = clock001.build_fixture(1012, packet_count=1)
|
||||||
|
received_iq = clock001.sample_independent_clock(fixture.iq, ppm)
|
||||||
|
result = receive_iq_stream(received_iq, decoder_backend="legacy")
|
||||||
|
summary = clock001.summarize_case(
|
||||||
|
result, fixture, sample_clock_offset_ppm=ppm, cfo_hz=0,
|
||||||
|
initial_phase_degrees=0, ebn0_db=None, receiver_time_s=1.0,
|
||||||
|
received_samples=len(received_iq),
|
||||||
|
)
|
||||||
|
assert summary["exact_packets"] == 0
|
||||||
|
assert summary["missing_sequences"] == [0]
|
||||||
|
assert summary["initial_timing_error_symbols_median"] is None
|
||||||
|
assert summary["final_timing_error_symbols_median"] is None
|
||||||
|
assert abs(summary["injected_timing_drift_across_packet_symbols"]) > 0.75
|
||||||
|
|
||||||
|
|
||||||
|
def test_cubic_resampling_confirms_long_packet_stress_boundary() -> None:
|
||||||
|
"""Проверяет вывод вторым интерполятором, без TX-подсказок приёмнику."""
|
||||||
|
fixture = clock001.build_fixture(1012, packet_count=1)
|
||||||
|
for ppm, expected_packets in ((-100, 0), (-50, 1), (50, 1), (100, 0)):
|
||||||
|
scale = 1 + ppm * 1e-6
|
||||||
|
count = math.floor((len(fixture.iq) - 1) * scale) + 1
|
||||||
|
positions = np.arange(count, dtype=np.float64) / scale
|
||||||
|
received_iq = (
|
||||||
|
map_coordinates(fixture.iq.real, [positions], order=3, mode="nearest")
|
||||||
|
+ 1j * map_coordinates(fixture.iq.imag, [positions], order=3, mode="nearest")
|
||||||
|
).astype(np.complex64)
|
||||||
|
result = receive_iq_stream(received_iq, decoder_backend="legacy")
|
||||||
|
assert len(result.packets) == expected_packets
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_is_used_only_after_receiver(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
fixture = clock001.build_fixture(256, packet_count=1)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_receiver(iq: np.ndarray) -> IqStreamResult:
|
||||||
|
calls.append(iq)
|
||||||
|
return IqStreamResult((), 0, 0, {})
|
||||||
|
|
||||||
|
monkeypatch.setattr(clock001, "receive_iq_stream", fake_receiver)
|
||||||
|
summary = clock001.evaluate_case(fixture, ppm=20)
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert isinstance(calls[0], np.ndarray)
|
||||||
|
assert summary["exact_packets"] == 0
|
||||||
|
assert summary["missing_sequences"] == [0]
|
||||||
113
tests/test_rf001_preparation.py
Normal file
113
tests/test_rf001_preparation.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""RF-001: воспроизводимость файлового IQ без аппаратуры и передачи."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from itertools import pairwise
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from protocol.video003_modem import build_frame
|
||||||
|
from protocol.video005_receiver import IqStreamResult, ReceivedIqPacket
|
||||||
|
from tools import qpsk_rf_rx
|
||||||
|
from tools.qpsk_rf_common import (
|
||||||
|
GAP_SAMPLES, PACKET_COUNT, PAYLOAD_BYTES, SEED, payload_for_sequence,
|
||||||
|
read_iq_file, sha256_file,
|
||||||
|
)
|
||||||
|
from tools.qpsk_rf_tx import build_dry_run, write_dry_run
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_and_gap_are_fixed_and_reproducible() -> None:
|
||||||
|
first = payload_for_sequence(SEED, 0)
|
||||||
|
assert len(first) == PAYLOAD_BYTES
|
||||||
|
assert first == payload_for_sequence(SEED, 0)
|
||||||
|
assert first != payload_for_sequence(SEED, 1)
|
||||||
|
iq, metadata = build_dry_run()
|
||||||
|
assert iq.dtype == np.dtype("<c8")
|
||||||
|
assert metadata["packet_count"] == PACKET_COUNT
|
||||||
|
assert metadata["seed"] == SEED
|
||||||
|
assert metadata["inter_packet_gap_samples"] == GAP_SAMPLES
|
||||||
|
assert metadata["rf_tx"] == 0
|
||||||
|
starts = metadata["packet_waveform_start_samples"]
|
||||||
|
assert len(starts) == PACKET_COUNT
|
||||||
|
assert starts[0] == 0
|
||||||
|
assert all(right > left for left, right in pairwise(starts))
|
||||||
|
assert iq.size == metadata["waveform_sample_count"]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
payload_for_sequence(-1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_100_packet_file_roundtrip(tmp_path: Path) -> None:
|
||||||
|
output_directory = tmp_path / "rf001"
|
||||||
|
iq_path, metadata_path = write_dry_run(output_directory)
|
||||||
|
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||||
|
assert iq_path.stat().st_size == metadata["waveform_sample_count"] * 8
|
||||||
|
assert sha256_file(iq_path) == metadata["iq_sha256"]
|
||||||
|
with pytest.raises(FileExistsError):
|
||||||
|
write_dry_run(output_directory)
|
||||||
|
report = qpsk_rf_rx.decode_file(iq_path, truth_metadata_path=metadata_path)
|
||||||
|
assert report["decoded_packets"] == PACKET_COUNT
|
||||||
|
assert report["crc_valid_packets"] == PACKET_COUNT
|
||||||
|
assert report["exact_packets"] == PACKET_COUNT
|
||||||
|
assert report["packet_sequences"] == list(range(PACKET_COUNT))
|
||||||
|
assert report["duplicates"] == 0
|
||||||
|
assert report["missing_packets"] == []
|
||||||
|
assert report["raw_ber"] == 0
|
||||||
|
assert report["raw_bits_compared_on_crc_valid_packets"] > 0
|
||||||
|
assert report["raw_ber_replay_failures"] == 0
|
||||||
|
assert report["post_fec_bit_errors"] == 0
|
||||||
|
assert report["same_bytes_as_tx_waveform"]
|
||||||
|
assert report["rf_tx"] == 0
|
||||||
|
assert report["sdr_hardware"] == "NOT_ACCESSED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_packets_do_not_get_invented_raw_ber() -> None:
|
||||||
|
frame = build_frame(payload_for_sequence(SEED, 0), "QPSK", "2/3", sequence_number=0)
|
||||||
|
packet = ReceivedIqPacket(frame.packet, payload_for_sequence(SEED, 0),
|
||||||
|
0.0, 0.0, 0.0, 0.0, 0, 1.0, 0.0)
|
||||||
|
reception = IqStreamResult((packet,), 1, 0, {})
|
||||||
|
_, metadata = build_dry_run()
|
||||||
|
report = qpsk_rf_rx.summarize_result(
|
||||||
|
reception, iq_sample_count=100, iq_sha256="different", iq_format="c64le",
|
||||||
|
clipping_codes=None, truth=metadata, raw_bits=(0, 3210, 0),
|
||||||
|
)
|
||||||
|
assert report["exact_packets"] == 1
|
||||||
|
assert len(report["missing_packets"]) == 99
|
||||||
|
assert report["raw_ber"] is None
|
||||||
|
assert report["raw_ber_on_crc_valid_packets"] == 0
|
||||||
|
assert report["raw_ber_scope"].startswith("CRC_VALID_PACKETS_ONLY")
|
||||||
|
assert report["post_fec_error_rate"] is None
|
||||||
|
assert report["post_fec_error_rate_on_compared_packets"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_truth_file_is_read_only_after_receiver(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
iq_path = tmp_path / "input.c64le"
|
||||||
|
np.zeros(32, dtype="<c8").tofile(iq_path)
|
||||||
|
truth_path = tmp_path / "truth.json"
|
||||||
|
truth_path.write_text("invalid json", encoding="utf-8")
|
||||||
|
called = []
|
||||||
|
|
||||||
|
def fake_receiver(iq: np.ndarray, *, decoder_backend: str) -> IqStreamResult:
|
||||||
|
assert iq.size == 32
|
||||||
|
called.append(True)
|
||||||
|
return IqStreamResult((), 0, 0, {})
|
||||||
|
|
||||||
|
monkeypatch.setattr(qpsk_rf_rx, "receive_iq_stream", fake_receiver)
|
||||||
|
with pytest.raises(json.JSONDecodeError):
|
||||||
|
qpsk_rf_rx.decode_file(iq_path, truth_metadata_path=truth_path)
|
||||||
|
assert called == [True]
|
||||||
|
|
||||||
|
|
||||||
|
def test_iq_file_format_and_clipping_validation(tmp_path: Path) -> None:
|
||||||
|
invalid = tmp_path / "bad.c64le"
|
||||||
|
invalid.write_bytes(b"123")
|
||||||
|
with pytest.raises(ValueError, match="c64le"):
|
||||||
|
read_iq_file(invalid)
|
||||||
|
raw = tmp_path / "capture.cs8"
|
||||||
|
raw.write_bytes(bytes([127, 0, 128, 10]))
|
||||||
|
iq, clipping = read_iq_file(raw, "cs8")
|
||||||
|
assert iq.dtype == np.complex64
|
||||||
|
assert iq.size == 2
|
||||||
|
assert clipping == 2
|
||||||
354
tools/clock001.py
Normal file
354
tools/clock001.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
"""CLOCK-001: программная проверка QPSK при независимых тактах TX и RX."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
if __package__ in (None, ""):
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from protocol.packet import parse_packet
|
||||||
|
from protocol.video003_modem import SAMPLE_RATE_HZ, build_frame
|
||||||
|
from protocol.video005_iq_waveform import (
|
||||||
|
GUARD_SAMPLES,
|
||||||
|
NOMINAL_CARRIER_HZ,
|
||||||
|
SYMBOL_RATE,
|
||||||
|
rrc_taps,
|
||||||
|
shape_packet,
|
||||||
|
)
|
||||||
|
from protocol.video005_receiver import IqStreamResult, receive_iq_stream
|
||||||
|
from tools.qpsk_rf_common import GAP_SAMPLES, SEED
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
OUTPUT = ROOT / "data/processed/clock001/results.json"
|
||||||
|
PAYLOAD_SIZES = (256, 512, 1012)
|
||||||
|
OFFSETS_PPM = (0, -1, 1, -5, 5, -10, 10, -20, 20, -50, 50, -100, 100)
|
||||||
|
PRIMARY_LIMIT_PPM = 50
|
||||||
|
PACKETS_PER_CASE = 3
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ClockFixture:
|
||||||
|
iq: np.ndarray
|
||||||
|
expected_packets: dict[int, bytes]
|
||||||
|
starts: dict[int, int]
|
||||||
|
symbol_counts: dict[int, int]
|
||||||
|
payload_bytes: int
|
||||||
|
|
||||||
|
|
||||||
|
def payload_for_case(payload_bytes: int, sequence_number: int) -> bytes:
|
||||||
|
"""Расширяет фиксированную RF-001 SHA-последовательность до длины пакета."""
|
||||||
|
if not 0 <= sequence_number <= 0xFFFF or not 1 <= payload_bytes <= 1012:
|
||||||
|
raise ValueError("неверный номер или размер пакета")
|
||||||
|
prefix = SEED.to_bytes(8, "big") + sequence_number.to_bytes(2, "big")
|
||||||
|
blocks = math.ceil(payload_bytes / hashlib.sha256().digest_size)
|
||||||
|
return b"".join(
|
||||||
|
hashlib.sha256(prefix + block.to_bytes(2, "big")).digest()
|
||||||
|
for block in range(blocks)
|
||||||
|
)[:payload_bytes]
|
||||||
|
|
||||||
|
|
||||||
|
def build_fixture(payload_bytes: int, packet_count: int = PACKETS_PER_CASE) -> ClockFixture:
|
||||||
|
"""Собирает поток с RF-001 паузой; границы сохраняет только для оценки."""
|
||||||
|
if packet_count < 1 or packet_count > 100:
|
||||||
|
raise ValueError("число пакетов должно быть от 1 до 100")
|
||||||
|
separator = GAP_SAMPLES - 2 * GUARD_SAMPLES
|
||||||
|
if separator < 0:
|
||||||
|
raise AssertionError("пауза короче защитных интервалов")
|
||||||
|
waveforms = []
|
||||||
|
expected_packets = {}
|
||||||
|
starts = {}
|
||||||
|
symbol_counts = {}
|
||||||
|
position = 0
|
||||||
|
for sequence_number in range(packet_count):
|
||||||
|
frame = build_frame(payload_for_case(payload_bytes, sequence_number),
|
||||||
|
"QPSK", "2/3", sequence_number=sequence_number)
|
||||||
|
waveform = shape_packet(frame)
|
||||||
|
starts[sequence_number] = position
|
||||||
|
expected_packets[sequence_number] = frame.packet
|
||||||
|
symbol_counts[sequence_number] = len(frame.symbols)
|
||||||
|
waveforms.append(waveform)
|
||||||
|
position += len(waveform) + (separator if sequence_number < packet_count - 1 else 0)
|
||||||
|
iq = np.zeros(position, dtype=np.complex64)
|
||||||
|
for sequence_number, waveform in enumerate(waveforms):
|
||||||
|
start = starts[sequence_number]
|
||||||
|
iq[start:start + len(waveform)] = waveform
|
||||||
|
return ClockFixture(iq, expected_packets, starts, symbol_counts, payload_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def sample_independent_clock(
|
||||||
|
tx_iq: np.ndarray,
|
||||||
|
sample_clock_offset_ppm: float,
|
||||||
|
*,
|
||||||
|
cfo_hz: float = 0.0,
|
||||||
|
initial_phase_degrees: float = 0.0,
|
||||||
|
ebn0_db: float | None = None,
|
||||||
|
seed: int = SEED,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Выбирает TX-волну в моменты АЦП Fs_RX=Fs_TX·(1+ppm·1e-6).
|
||||||
|
|
||||||
|
Дробная интерполяция меняет временную шкалу по всему потоку. CFO задаётся
|
||||||
|
отдельно в герцах физического времени RX; затем добавляется AWGN.
|
||||||
|
"""
|
||||||
|
if (tx_iq.ndim != 1 or not np.iscomplexobj(tx_iq) or tx_iq.size < 2
|
||||||
|
or not np.all(np.isfinite(tx_iq))):
|
||||||
|
raise ValueError("нужен конечный одномерный комплексный TX IQ")
|
||||||
|
if not all(math.isfinite(value) for value in (
|
||||||
|
sample_clock_offset_ppm, cfo_hz, initial_phase_degrees)):
|
||||||
|
raise ValueError("параметры такта и несущей должны быть конечными")
|
||||||
|
if abs(sample_clock_offset_ppm) > 1000:
|
||||||
|
raise ValueError("модель ограничена диапазоном ±1000 ppm")
|
||||||
|
if ebn0_db is not None and not math.isfinite(ebn0_db):
|
||||||
|
raise ValueError("Eb/N0 должно быть конечным")
|
||||||
|
scale = 1 + sample_clock_offset_ppm * 1e-6
|
||||||
|
output_count = math.floor((tx_iq.size - 1) * scale) + 1
|
||||||
|
received = np.empty(output_count, dtype=np.complex64)
|
||||||
|
# Обрабатываем длинные потоки частями, сохраняя единую временную шкалу.
|
||||||
|
for start in range(0, output_count, 1_000_000):
|
||||||
|
stop = min(start + 1_000_000, output_count)
|
||||||
|
positions = np.arange(start, stop, dtype=np.float64) / scale
|
||||||
|
lower = np.floor(positions).astype(np.int64)
|
||||||
|
fraction = positions - lower
|
||||||
|
upper = np.minimum(lower + 1, tx_iq.size - 1)
|
||||||
|
part = (tx_iq[lower] * (1 - fraction)
|
||||||
|
+ tx_iq[upper] * fraction).astype(np.complex64)
|
||||||
|
if cfo_hz or initial_phase_degrees:
|
||||||
|
physical_time = np.arange(start, stop, dtype=np.float64) / (SAMPLE_RATE_HZ * scale)
|
||||||
|
phase = math.radians(initial_phase_degrees) + 2 * math.pi * cfo_hz * physical_time
|
||||||
|
part *= np.exp(1j * phase).astype(np.complex64)
|
||||||
|
received[start:stop] = part
|
||||||
|
if ebn0_db is not None:
|
||||||
|
sigma = math.sqrt(1 / (2 * (2 / 3) * 10 ** (ebn0_db / 10)) / 2)
|
||||||
|
random_generator = np.random.default_rng(seed)
|
||||||
|
for start in range(0, output_count, 1_000_000):
|
||||||
|
stop = min(start + 1_000_000, output_count)
|
||||||
|
received[start:stop] += (sigma * (
|
||||||
|
random_generator.standard_normal(stop - start)
|
||||||
|
+ 1j * random_generator.standard_normal(stop - start)
|
||||||
|
)).astype(np.complex64)
|
||||||
|
return received
|
||||||
|
|
||||||
|
|
||||||
|
def _median(values: list[float]) -> float | None:
|
||||||
|
return float(statistics.median(values)) if values else None
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_case(
|
||||||
|
result: IqStreamResult,
|
||||||
|
fixture: ClockFixture,
|
||||||
|
*,
|
||||||
|
sample_clock_offset_ppm: float,
|
||||||
|
cfo_hz: float,
|
||||||
|
initial_phase_degrees: float,
|
||||||
|
ebn0_db: float | None,
|
||||||
|
receiver_time_s: float,
|
||||||
|
received_samples: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Сравнивает строгий выход приёмника с эталоном после декодирования."""
|
||||||
|
scale = 1 + sample_clock_offset_ppm * 1e-6
|
||||||
|
tx_filter_delay = (len(rrc_taps()) - 1) / 2
|
||||||
|
rx_filter_delay = tx_filter_delay
|
||||||
|
samples_per_symbol = SAMPLE_RATE_HZ // SYMBOL_RATE
|
||||||
|
effective_cfo_hz = (NOMINAL_CARRIER_HZ + cfo_hz) / scale - NOMINAL_CARRIER_HZ
|
||||||
|
exact_sequences: set[int] = set()
|
||||||
|
seen: set[int] = set()
|
||||||
|
duplicates: list[int] = []
|
||||||
|
unexpected: list[int] = []
|
||||||
|
initial_errors = []
|
||||||
|
final_errors = []
|
||||||
|
drift_values = []
|
||||||
|
cfo_errors = []
|
||||||
|
for packet in result.packets:
|
||||||
|
sequence_number = parse_packet(packet.packet).sequence_number
|
||||||
|
if sequence_number in seen:
|
||||||
|
duplicates.append(sequence_number)
|
||||||
|
seen.add(sequence_number)
|
||||||
|
expected = fixture.expected_packets.get(sequence_number)
|
||||||
|
if expected is None or packet.packet != expected:
|
||||||
|
unexpected.append(sequence_number)
|
||||||
|
continue
|
||||||
|
exact_sequences.add(sequence_number)
|
||||||
|
tx_preamble = fixture.starts[sequence_number] + GUARD_SAMPLES + tx_filter_delay
|
||||||
|
ideal_rx_start = tx_preamble * scale + rx_filter_delay
|
||||||
|
initial_error = (packet.start_sample - ideal_rx_start) / samples_per_symbol
|
||||||
|
symbol_intervals = fixture.symbol_counts[sequence_number] - 1
|
||||||
|
final_error = initial_error - symbol_intervals * sample_clock_offset_ppm * 1e-6
|
||||||
|
initial_errors.append(initial_error)
|
||||||
|
final_errors.append(final_error)
|
||||||
|
drift_values.append(final_error - initial_error)
|
||||||
|
cfo_errors.append(packet.carrier_offset_hz - effective_cfo_hz)
|
||||||
|
packet_count = len(fixture.expected_packets)
|
||||||
|
modeled_drift = -(next(iter(fixture.symbol_counts.values())) - 1) * sample_clock_offset_ppm * 1e-6
|
||||||
|
# Приёмник выдаёт только CRC-valid пакеты. Отдельной стадии "пакет до CRC"
|
||||||
|
# в его API нет; число корреляционных кандидатов указано отдельно.
|
||||||
|
return {
|
||||||
|
"sample_clock_offset_ppm": sample_clock_offset_ppm,
|
||||||
|
"cfo_hz": cfo_hz,
|
||||||
|
"effective_cfo_hz_at_nominal_rx_rate": effective_cfo_hz,
|
||||||
|
"initial_phase_degrees": initial_phase_degrees,
|
||||||
|
"ebn0_db": ebn0_db,
|
||||||
|
"payload_bytes": fixture.payload_bytes,
|
||||||
|
"tx_packets": packet_count,
|
||||||
|
"correlation_candidates": result.candidate_count,
|
||||||
|
"detected_packets": len(result.packets),
|
||||||
|
"crc_valid_packets": len(result.packets),
|
||||||
|
"exact_packets": len(exact_sequences),
|
||||||
|
"packet_success_rate": len(exact_sequences) / packet_count,
|
||||||
|
"exact_sequences": sorted(exact_sequences),
|
||||||
|
"missing_sequences": sorted(fixture.expected_packets.keys() - exact_sequences),
|
||||||
|
"duplicate_sequences": duplicates,
|
||||||
|
"unexpected_or_wrong_sequences": unexpected,
|
||||||
|
"initial_timing_error_symbols_median": _median(initial_errors),
|
||||||
|
"final_timing_error_symbols_median": _median(final_errors),
|
||||||
|
"timing_drift_across_packet_symbols_median": _median(drift_values),
|
||||||
|
"injected_timing_drift_across_packet_symbols": modeled_drift,
|
||||||
|
"maximum_absolute_final_timing_error_symbols": (
|
||||||
|
max(map(abs, final_errors)) if final_errors else None
|
||||||
|
),
|
||||||
|
"cfo_estimate_error_hz_median": _median(cfo_errors),
|
||||||
|
"timing_error_method": "accepted-packet start versus injected time base; final error extrapolated with fixed 16-sample step",
|
||||||
|
"reacquisition_per_packet": True,
|
||||||
|
"continuous_timing_tracking_used": False,
|
||||||
|
"receiver_time_s": receiver_time_s,
|
||||||
|
"received_duration_s": received_samples / (SAMPLE_RATE_HZ * scale),
|
||||||
|
"receiver_realtime_factor": receiver_time_s / (received_samples / (SAMPLE_RATE_HZ * scale)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_case(fixture: ClockFixture, *, ppm: float, cfo_hz: float = 0.0,
|
||||||
|
phase_degrees: float = 0.0, ebn0_db: float | None = None,
|
||||||
|
seed: int = SEED) -> dict:
|
||||||
|
rx_iq = sample_independent_clock(
|
||||||
|
fixture.iq, ppm, cfo_hz=cfo_hz, initial_phase_degrees=phase_degrees,
|
||||||
|
ebn0_db=ebn0_db, seed=seed,
|
||||||
|
)
|
||||||
|
started = time.perf_counter()
|
||||||
|
result = receive_iq_stream(rx_iq)
|
||||||
|
elapsed = time.perf_counter() - started
|
||||||
|
return summarize_case(result, fixture, sample_clock_offset_ppm=ppm,
|
||||||
|
cfo_hz=cfo_hz, initial_phase_degrees=phase_degrees,
|
||||||
|
ebn0_db=ebn0_db, receiver_time_s=elapsed,
|
||||||
|
received_samples=len(rx_iq))
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> dict:
|
||||||
|
"""Снимает отдельную ppm-сетку, короткие комбинации и прогретую скорость."""
|
||||||
|
fixtures = {size: build_fixture(size) for size in PAYLOAD_SIZES}
|
||||||
|
grid = [evaluate_case(fixtures[size], ppm=ppm)
|
||||||
|
for size in PAYLOAD_SIZES for ppm in OFFSETS_PPM]
|
||||||
|
combinations = [
|
||||||
|
(5, 20, 15, 9), (5, 20, 15, 12),
|
||||||
|
(10, 100, 90, 9), (10, 100, 90, 12),
|
||||||
|
(20, 100, -45, 9), (20, 100, -45, 12),
|
||||||
|
]
|
||||||
|
combined = [evaluate_case(fixtures[256], ppm=ppm, cfo_hz=cfo,
|
||||||
|
phase_degrees=phase, ebn0_db=ebn0,
|
||||||
|
seed=SEED + index)
|
||||||
|
for index, (ppm, cfo, phase, ebn0) in enumerate(combinations)]
|
||||||
|
single_long_fixture = build_fixture(1012, packet_count=1)
|
||||||
|
single_long_stress = [evaluate_case(single_long_fixture, ppm=ppm)
|
||||||
|
for ppm in (-100, 100)]
|
||||||
|
from tools.video005_benchmark import build_radio_fixture
|
||||||
|
|
||||||
|
benchmark_packets, clean_benchmark_iq, _ = build_radio_fixture()
|
||||||
|
benchmark_iq = sample_independent_clock(clean_benchmark_iq, 20, cfo_hz=20,
|
||||||
|
initial_phase_degrees=15, ebn0_db=9)
|
||||||
|
benchmark_times = []
|
||||||
|
benchmark_exact = []
|
||||||
|
for _ in range(3):
|
||||||
|
started = time.perf_counter()
|
||||||
|
received = receive_iq_stream(benchmark_iq)
|
||||||
|
benchmark_times.append(time.perf_counter() - started)
|
||||||
|
observed = {parse_packet(packet.packet).sequence_number: packet.packet
|
||||||
|
for packet in received.packets}
|
||||||
|
benchmark_exact.append(sum(observed.get(index) == packet
|
||||||
|
for index, packet in enumerate(benchmark_packets)))
|
||||||
|
duration = len(benchmark_iq) / (SAMPLE_RATE_HZ * (1 + 20e-6))
|
||||||
|
benchmark_all_exact = all(count == len(benchmark_packets) for count in benchmark_exact)
|
||||||
|
primary = [case for case in grid if abs(case["sample_clock_offset_ppm"]) <= PRIMARY_LIMIT_PPM]
|
||||||
|
primary_all_exact = all(case["exact_packets"] == case["tx_packets"] for case in primary)
|
||||||
|
stress = [case for case in grid if abs(case["sample_clock_offset_ppm"]) > PRIMARY_LIMIT_PPM]
|
||||||
|
stress_all_exact = all(case["exact_packets"] == case["tx_packets"] for case in stress)
|
||||||
|
all_combined_exact = all(case["exact_packets"] == case["tx_packets"] for case in combined)
|
||||||
|
symmetric_limits = {}
|
||||||
|
for size in PAYLOAD_SIZES:
|
||||||
|
maximum = 0
|
||||||
|
for offset in (1, 5, 10, 20, 50, 100):
|
||||||
|
pair = [case for case in grid if case["payload_bytes"] == size
|
||||||
|
and abs(case["sample_clock_offset_ppm"]) == offset]
|
||||||
|
if len(pair) != 2 or any(case["exact_packets"] != case["tx_packets"] for case in pair):
|
||||||
|
break
|
||||||
|
maximum = offset
|
||||||
|
symmetric_limits[str(size)] = maximum
|
||||||
|
return {
|
||||||
|
"status": ("PASS" if primary_all_exact and stress_all_exact
|
||||||
|
and all_combined_exact and benchmark_all_exact else "PARTIAL"),
|
||||||
|
"experiment": "CLOCK-001 INDEPENDENT TX/RX CLOCK READINESS",
|
||||||
|
"seed": SEED,
|
||||||
|
"sample_rate_nominal_hz": SAMPLE_RATE_HZ,
|
||||||
|
"symbol_rate": SYMBOL_RATE,
|
||||||
|
"fec_rate": "2/3",
|
||||||
|
"packets_per_case": PACKETS_PER_CASE,
|
||||||
|
"payload_sizes": PAYLOAD_SIZES,
|
||||||
|
"offsets_ppm": OFFSETS_PPM,
|
||||||
|
"primary_limit_ppm": PRIMARY_LIMIT_PPM,
|
||||||
|
"grid": grid,
|
||||||
|
"combined": combined,
|
||||||
|
"single_long_stress": single_long_stress,
|
||||||
|
"primary_grid_all_exact": primary_all_exact,
|
||||||
|
"stress_grid_all_exact": stress_all_exact,
|
||||||
|
"combined_all_exact": all_combined_exact,
|
||||||
|
"benchmark_all_exact": benchmark_all_exact,
|
||||||
|
"maximum_symmetric_ppm_with_all_exact_by_payload": symmetric_limits,
|
||||||
|
"maximum_symmetric_ppm_with_all_payloads_exact": min(symmetric_limits.values()),
|
||||||
|
"per_packet_reacquisition_sufficient_for_primary_grid": primary_all_exact,
|
||||||
|
"continuous_tracking_required_for_primary_grid": not primary_all_exact,
|
||||||
|
"intra_packet_timing_correction_needed_to_cover_long_packet_stress": not stress_all_exact,
|
||||||
|
"benchmark": {
|
||||||
|
"description": "VIDEO-005 32 long packets; independent clocks +20 ppm, CFO 20 Hz, phase 15 deg, Eb/N0 9 dB",
|
||||||
|
"iq_duration_s": duration,
|
||||||
|
"warm_receiver_times_s": benchmark_times,
|
||||||
|
"warm_realtime_factor": statistics.median(benchmark_times) / duration,
|
||||||
|
"exact_packets_each_run": benchmark_exact,
|
||||||
|
"tx_packets": len(benchmark_packets),
|
||||||
|
},
|
||||||
|
"rf_tx": 0,
|
||||||
|
"sdr_hardware": "NOT_ACCESSED",
|
||||||
|
"hardware_status": "NOT_RUN",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--numba-packages", type=Path)
|
||||||
|
parser.add_argument("--report", type=Path, default=OUTPUT)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.numba_packages is not None:
|
||||||
|
if not args.numba_packages.is_dir():
|
||||||
|
parser.error("каталог Numba недоступен")
|
||||||
|
sys.path.insert(0, str(args.numba_packages.resolve()))
|
||||||
|
if args.report.exists():
|
||||||
|
parser.error("отчёт уже существует; выберите новый путь")
|
||||||
|
report = run()
|
||||||
|
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
print(f"CLOCK-001: {report['status']}; primary_all_exact={report['primary_grid_all_exact']}; "
|
||||||
|
f"combined_all_exact={report['combined_all_exact']}; "
|
||||||
|
f"realtime_factor={report['benchmark']['warm_realtime_factor']:.3f}; "
|
||||||
|
"RF TX=0")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
69
tools/qpsk_rf_common.py
Normal file
69
tools/qpsk_rf_common.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Формат файлов и воспроизводимые пакеты для подготовки RF-001 без SDR."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from protocol.video003_modem import SAMPLE_RATE_HZ
|
||||||
|
from protocol.video005_iq_waveform import NOMINAL_CARRIER_HZ, SYMBOL_RATE
|
||||||
|
|
||||||
|
|
||||||
|
TEST_ID = "RF001_QPSK_500K_R23_256B_100P_GAP5MS"
|
||||||
|
SEED = 20260917
|
||||||
|
PAYLOAD_BYTES = 256
|
||||||
|
PACKET_COUNT = 100
|
||||||
|
GAP_MS = 5
|
||||||
|
GAP_SAMPLES = SAMPLE_RATE_HZ * GAP_MS // 1000
|
||||||
|
IQ_FORMAT = "c64le"
|
||||||
|
|
||||||
|
|
||||||
|
def payload_for_sequence(seed: int, sequence_number: int) -> bytes:
|
||||||
|
"""Создаёт 256 байт через SHA-256, не завися от версии генератора NumPy."""
|
||||||
|
if not 0 <= seed < 2**64 or not 0 <= sequence_number <= 0xFFFF:
|
||||||
|
raise ValueError("seed или номер пакета вне допустимого диапазона")
|
||||||
|
prefix = seed.to_bytes(8, "big") + sequence_number.to_bytes(2, "big")
|
||||||
|
return b"".join(
|
||||||
|
hashlib.sha256(prefix + block.to_bytes(2, "big")).digest()
|
||||||
|
for block in range(PAYLOAD_BYTES // hashlib.sha256().digest_size)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
"""Хеширует файл потоково, не копируя весь IQ в память."""
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
while chunk := stream.read(1 << 20):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def read_iq_file(path: Path, iq_format: str = IQ_FORMAT) -> tuple[np.ndarray, int | None]:
|
||||||
|
"""Читает файловый IQ; `cs8` сохраняет исходный файл без изменений."""
|
||||||
|
size = path.stat().st_size
|
||||||
|
if iq_format == "c64le":
|
||||||
|
if size == 0 or size % 8:
|
||||||
|
raise ValueError("c64le должен содержать целое ненулевое число отсчётов")
|
||||||
|
return np.memmap(path, dtype="<c8", mode="r"), None
|
||||||
|
if iq_format == "cs8":
|
||||||
|
if size == 0 or size % 2:
|
||||||
|
raise ValueError("cs8 должен содержать целое ненулевое число пар I/Q")
|
||||||
|
raw = np.memmap(path, dtype=np.int8, mode="r")
|
||||||
|
clipping = int(np.count_nonzero((raw == -128) | (raw == 127)))
|
||||||
|
iq = ((raw[0::2].astype(np.float32) + 1j * raw[1::2].astype(np.float32))
|
||||||
|
/ 128).astype(np.complex64)
|
||||||
|
return iq, clipping
|
||||||
|
raise ValueError("поддерживаются только c64le и cs8")
|
||||||
|
|
||||||
|
|
||||||
|
def radio_mode() -> dict[str, int | float | str]:
|
||||||
|
"""Параметры номинального режима, которые разрешено знать приёмнику."""
|
||||||
|
return {
|
||||||
|
"modulation": "QPSK",
|
||||||
|
"symbol_rate": SYMBOL_RATE,
|
||||||
|
"sample_rate": SAMPLE_RATE_HZ,
|
||||||
|
"fec_rate": "2/3",
|
||||||
|
"digital_carrier_offset_hz": NOMINAL_CARRIER_HZ,
|
||||||
|
}
|
||||||
302
tools/qpsk_rf_rx.py
Normal file
302
tools/qpsk_rf_rx.py
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
"""RF-001: файловый IQ → существующий VIDEO-005 receiver → отчёт после CRC."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.signal import oaconvolve
|
||||||
|
|
||||||
|
if __package__ in (None, ""):
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from protocol.packet import build_packet, parse_packet
|
||||||
|
from protocol import punctured_fec
|
||||||
|
from protocol.video003_modem import (
|
||||||
|
PHY_HEADER_SYMBOLS, PREAMBLE_SYMBOLS, SAMPLE_RATE_HZ, receive_frame,
|
||||||
|
)
|
||||||
|
from protocol.video005_carrier import (
|
||||||
|
correct_pilot_estimate, estimate_from_preamble, track_qpsk_body,
|
||||||
|
)
|
||||||
|
from protocol.video005_iq_waveform import NOMINAL_CARRIER_HZ, SYMBOL_RATE, rrc_taps
|
||||||
|
from protocol.video005_receiver import IqStreamResult, receive_iq_stream
|
||||||
|
from protocol.video005_timing import sample_symbols
|
||||||
|
from tools.qpsk_rf_common import (
|
||||||
|
GAP_MS, PACKET_COUNT, PAYLOAD_BYTES, SEED, TEST_ID,
|
||||||
|
payload_for_sequence, read_iq_file, sha256_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bit_errors(observed: bytes, expected: bytes) -> tuple[int, int]:
|
||||||
|
"""Считает ошибки только для сопоставленных пакетов равной длины."""
|
||||||
|
if len(observed) != len(expected):
|
||||||
|
return 0, 0
|
||||||
|
return sum((left ^ right).bit_count() for left, right in zip(
|
||||||
|
observed, expected, strict=True
|
||||||
|
)), len(expected) * 8
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_ber_after_decode(
|
||||||
|
iq: np.ndarray,
|
||||||
|
reception: IqStreamResult,
|
||||||
|
seed: int,
|
||||||
|
decoder_backend: str,
|
||||||
|
) -> tuple[int, int, int]:
|
||||||
|
"""Повторяет штатные PHY-функции для BER только после строгого первого приёма.
|
||||||
|
|
||||||
|
Метрика относится к CRC-valid кандидатам. Границы отвергнутых кандидатов
|
||||||
|
VIDEO-005 не возвращает; их биты здесь не выдумываются.
|
||||||
|
"""
|
||||||
|
period = round(SAMPLE_RATE_HZ / NOMINAL_CARRIER_HZ)
|
||||||
|
cycle = np.exp(-2j * np.pi * np.arange(period) / period).astype(np.complex64)
|
||||||
|
baseband = np.asarray(iq, dtype=np.complex64) * np.resize(cycle, iq.size)
|
||||||
|
matched = oaconvolve(baseband, rrc_taps().astype(np.float32), mode="full")
|
||||||
|
if decoder_backend == "numba":
|
||||||
|
from protocol.video004_viterbi import decode_soft as fec_decoder
|
||||||
|
else:
|
||||||
|
fec_decoder = punctured_fec.decode_soft
|
||||||
|
bit_errors = 0
|
||||||
|
bits_compared = 0
|
||||||
|
replay_failures = 0
|
||||||
|
for packet in reception.packets:
|
||||||
|
sequence_number = parse_packet(packet.packet).sequence_number
|
||||||
|
coded_count = punctured_fec.coded_bit_count(len(packet.packet) * 8, "2/3")
|
||||||
|
symbol_count = PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS + (coded_count + 1) // 2
|
||||||
|
symbols = sample_symbols(matched, packet.start_sample, 16, symbol_count)
|
||||||
|
if len(symbols) != symbol_count:
|
||||||
|
replay_failures += 1
|
||||||
|
continue
|
||||||
|
estimate = estimate_from_preamble(symbols, SYMBOL_RATE)
|
||||||
|
corrected = correct_pilot_estimate(symbols, estimate)
|
||||||
|
tracked, _ = track_qpsk_body(corrected)
|
||||||
|
replay = receive_frame(tracked, "QPSK", "2/3", SYMBOL_RATE,
|
||||||
|
maximum_start=0, fec_decoder=fec_decoder)
|
||||||
|
if replay.packet != packet.packet or replay.coded_soft is None:
|
||||||
|
replay_failures += 1
|
||||||
|
continue
|
||||||
|
from protocol.video003_modem import build_frame
|
||||||
|
expected_bits = build_frame(payload_for_sequence(seed, sequence_number),
|
||||||
|
"QPSK", "2/3", sequence_number).coded_bits
|
||||||
|
observed_bits = (replay.coded_soft >= 0).astype(np.uint8)
|
||||||
|
if len(observed_bits) != len(expected_bits):
|
||||||
|
replay_failures += 1
|
||||||
|
continue
|
||||||
|
bit_errors += int(np.count_nonzero(observed_bits != expected_bits))
|
||||||
|
bits_compared += len(expected_bits)
|
||||||
|
return bit_errors, bits_compared, replay_failures
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_result(
|
||||||
|
reception: IqStreamResult,
|
||||||
|
*,
|
||||||
|
iq_sample_count: int,
|
||||||
|
iq_sha256: str,
|
||||||
|
iq_format: str,
|
||||||
|
clipping_codes: int | None,
|
||||||
|
truth: dict[str, Any] | None,
|
||||||
|
raw_bits: tuple[int, int, int] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Выполняет сравнение с эталоном только после завершения IQ-приёма."""
|
||||||
|
rows = []
|
||||||
|
observed_by_sequence: dict[int, bytes] = {}
|
||||||
|
for packet in reception.packets:
|
||||||
|
parsed = parse_packet(packet.packet)
|
||||||
|
observed_by_sequence.setdefault(parsed.sequence_number, packet.packet)
|
||||||
|
rows.append({
|
||||||
|
"sequence_number": parsed.sequence_number,
|
||||||
|
"packet_sha256": hashlib.sha256(packet.packet).hexdigest(),
|
||||||
|
"payload_sha256": hashlib.sha256(parsed.payload).hexdigest(),
|
||||||
|
"start_sample": packet.start_sample,
|
||||||
|
"preamble_score": packet.preamble_score,
|
||||||
|
"cfo_hz": packet.carrier_offset_hz,
|
||||||
|
"phase_rad": packet.initial_phase_rad,
|
||||||
|
"timing_phase_symbols": packet.timing_phase_symbols,
|
||||||
|
"qpsk_quadrant": packet.quadrant,
|
||||||
|
"tracking_error_rms_rad": packet.tracking_error_rms_rad,
|
||||||
|
})
|
||||||
|
sequences = [row["sequence_number"] for row in rows]
|
||||||
|
unique_sequences = set(sequences)
|
||||||
|
cfo = [row["cfo_hz"] for row in rows]
|
||||||
|
report: dict[str, Any] = {
|
||||||
|
"rf001_status": "PREPARED_NOT_RUN",
|
||||||
|
"test_id": truth.get("test_id") if truth is not None else None,
|
||||||
|
"modulation": "QPSK",
|
||||||
|
"symbol_rate": 500_000,
|
||||||
|
"inner_fec": "2/3",
|
||||||
|
"payload_bytes": 256,
|
||||||
|
"inter_packet_gap_ms": 5,
|
||||||
|
"tx_packets": truth.get("packet_count") if truth is not None else None,
|
||||||
|
"iq_format": iq_format,
|
||||||
|
"iq_sample_count": iq_sample_count,
|
||||||
|
"iq_sha256": iq_sha256,
|
||||||
|
"detected_preambles": reception.candidate_count,
|
||||||
|
"rejected_candidates": reception.rejected_candidates,
|
||||||
|
"decoded_packets": len(rows),
|
||||||
|
"crc_valid_packets": len(rows),
|
||||||
|
"packet_sequences": sequences,
|
||||||
|
"duplicates": len(sequences) - len(unique_sequences),
|
||||||
|
"missing_packets": None,
|
||||||
|
"exact_packets": None,
|
||||||
|
"packet_success_rate": None,
|
||||||
|
"raw_ber": None,
|
||||||
|
"raw_ber_scope": "UNAVAILABLE_FOR_REJECTED_CANDIDATES_FROM_VIDEO005_API",
|
||||||
|
"raw_bit_errors_on_crc_valid_packets": None,
|
||||||
|
"raw_bits_compared_on_crc_valid_packets": None,
|
||||||
|
"raw_ber_on_crc_valid_packets": None,
|
||||||
|
"raw_ber_replay_failures": None,
|
||||||
|
"post_fec_bit_errors": None,
|
||||||
|
"post_fec_bits_compared": None,
|
||||||
|
"post_fec_error_rate": None,
|
||||||
|
"post_fec_error_rate_on_compared_packets": None,
|
||||||
|
"post_fec_uncomparable_packets": None,
|
||||||
|
"cfo_median_hz": statistics.median(cfo) if cfo else None,
|
||||||
|
"cfo_p95_absolute_hz": float(np.percentile(np.abs(cfo), 95)) if cfo else None,
|
||||||
|
"phase_error_rad": None,
|
||||||
|
"timing_offset_symbols": None,
|
||||||
|
"phase_and_timing_estimates": rows,
|
||||||
|
"tx_underruns": "NOT_RUN",
|
||||||
|
"rx_overruns": "NOT_RUN",
|
||||||
|
"rx_clipping_rail_codes": clipping_codes if clipping_codes is not None else "NOT_APPLICABLE",
|
||||||
|
"common_10mhz_clock": "NOT_RUN",
|
||||||
|
"sdr_hardware": "NOT_ACCESSED",
|
||||||
|
"rf_tx": 0,
|
||||||
|
}
|
||||||
|
if truth is None:
|
||||||
|
return report
|
||||||
|
expected_rows = truth["expected_packet_hashes"]
|
||||||
|
expected = {int(item["sequence_number"]): item["packet_sha256"]
|
||||||
|
for item in expected_rows}
|
||||||
|
if (len(expected) != len(expected_rows) or len(expected) != truth["packet_count"]
|
||||||
|
or set(expected) != set(range(PACKET_COUNT))):
|
||||||
|
raise ValueError("ведомость содержит повторные или недостающие номера")
|
||||||
|
exact = {
|
||||||
|
row["sequence_number"] for row in rows
|
||||||
|
if expected.get(row["sequence_number"]) == row["packet_sha256"]
|
||||||
|
}
|
||||||
|
missing = sorted(expected.keys() - unique_sequences)
|
||||||
|
seed = int(truth["seed"])
|
||||||
|
if (truth["packet_count"] != PACKET_COUNT
|
||||||
|
or truth["application_payload_bytes"] != PAYLOAD_BYTES
|
||||||
|
or truth["seed"] != SEED):
|
||||||
|
raise ValueError("ведомость не соответствует каноническому RF-001")
|
||||||
|
for sequence_number, digest in expected.items():
|
||||||
|
reference = build_packet(payload_for_sequence(seed, sequence_number),
|
||||||
|
message_type=3, sequence_number=sequence_number)
|
||||||
|
if hashlib.sha256(reference).hexdigest() != digest:
|
||||||
|
raise ValueError("эталонный SHA пакета не совпадает с seed")
|
||||||
|
errors = 0
|
||||||
|
compared = 0
|
||||||
|
uncomparable = 0
|
||||||
|
for sequence_number, observed in observed_by_sequence.items():
|
||||||
|
if sequence_number not in expected:
|
||||||
|
continue
|
||||||
|
reference = build_packet(payload_for_sequence(seed, sequence_number),
|
||||||
|
message_type=3, sequence_number=sequence_number)
|
||||||
|
packet_errors, packet_bits = _bit_errors(observed, reference)
|
||||||
|
if packet_bits == 0:
|
||||||
|
uncomparable += 1
|
||||||
|
errors += packet_errors
|
||||||
|
compared += packet_bits
|
||||||
|
complete_post_fec = (unique_sequences == expected.keys()
|
||||||
|
and len(sequences) == truth["packet_count"]
|
||||||
|
and uncomparable == 0)
|
||||||
|
report.update({
|
||||||
|
"missing_packets": missing,
|
||||||
|
"unexpected_sequences": sorted(unique_sequences - expected.keys()),
|
||||||
|
"exact_packets": len(exact),
|
||||||
|
"packet_success_rate": len(exact) / truth["packet_count"],
|
||||||
|
"post_fec_bit_errors": errors,
|
||||||
|
"post_fec_bits_compared": compared,
|
||||||
|
"post_fec_error_rate": errors / compared if compared and complete_post_fec else None,
|
||||||
|
"post_fec_error_rate_on_compared_packets": errors / compared if compared else None,
|
||||||
|
"post_fec_uncomparable_packets": uncomparable,
|
||||||
|
"same_bytes_as_tx_waveform": iq_sha256 == truth.get("iq_sha256"),
|
||||||
|
})
|
||||||
|
if raw_bits is not None:
|
||||||
|
raw_errors, raw_count, replay_failures = raw_bits
|
||||||
|
conditional_ber = raw_errors / raw_count if raw_count else None
|
||||||
|
complete = (unique_sequences == expected.keys()
|
||||||
|
and len(sequences) == truth["packet_count"]
|
||||||
|
and replay_failures == 0)
|
||||||
|
report.update({
|
||||||
|
"raw_bit_errors_on_crc_valid_packets": raw_errors,
|
||||||
|
"raw_bits_compared_on_crc_valid_packets": raw_count,
|
||||||
|
"raw_ber_on_crc_valid_packets": conditional_ber,
|
||||||
|
"raw_ber_replay_failures": replay_failures,
|
||||||
|
"raw_ber": conditional_ber if complete else None,
|
||||||
|
"raw_ber_scope": ("ALL_EXPECTED_PACKETS" if complete else
|
||||||
|
"CRC_VALID_PACKETS_ONLY; FULL_RAW_BER_UNAVAILABLE"),
|
||||||
|
})
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def decode_file(
|
||||||
|
iq_path: Path,
|
||||||
|
*,
|
||||||
|
iq_format: str = "c64le",
|
||||||
|
truth_metadata_path: Path | None = None,
|
||||||
|
decoder_backend: str = "numba",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Открывает только файл; truth читается после вызова рабочего приёмника."""
|
||||||
|
iq, clipping_codes = read_iq_file(iq_path, iq_format)
|
||||||
|
reception = receive_iq_stream(iq, decoder_backend=decoder_backend)
|
||||||
|
truth = (json.loads(truth_metadata_path.read_text(encoding="utf-8"))
|
||||||
|
if truth_metadata_path is not None else None)
|
||||||
|
if truth is not None and (
|
||||||
|
truth.get("schema_version") != 1
|
||||||
|
or truth.get("test_id") != TEST_ID
|
||||||
|
or truth.get("modulation") != "QPSK"
|
||||||
|
or truth.get("symbol_rate") != SYMBOL_RATE
|
||||||
|
or truth.get("sample_rate") != SAMPLE_RATE_HZ
|
||||||
|
or truth.get("fec_rate") != "2/3"
|
||||||
|
or truth.get("digital_carrier_offset_hz") != NOMINAL_CARRIER_HZ
|
||||||
|
or truth.get("packet_count") != PACKET_COUNT
|
||||||
|
or truth.get("application_payload_bytes") != PAYLOAD_BYTES
|
||||||
|
or truth.get("inter_packet_gap_ms") != GAP_MS
|
||||||
|
or truth.get("seed") != SEED
|
||||||
|
):
|
||||||
|
raise ValueError("TX-ведомость не соответствует режиму RF-001")
|
||||||
|
raw_bits = (_raw_ber_after_decode(iq, reception, int(truth["seed"]), decoder_backend)
|
||||||
|
if truth is not None else None)
|
||||||
|
return summarize_result(
|
||||||
|
reception,
|
||||||
|
iq_sample_count=int(iq.size),
|
||||||
|
iq_sha256=sha256_file(iq_path),
|
||||||
|
iq_format=iq_format,
|
||||||
|
clipping_codes=clipping_codes,
|
||||||
|
truth=truth,
|
||||||
|
raw_bits=raw_bits,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--iq-file", required=True, type=Path)
|
||||||
|
parser.add_argument("--iq-format", choices=("c64le", "cs8"), default="c64le")
|
||||||
|
parser.add_argument("--truth-metadata", type=Path)
|
||||||
|
parser.add_argument("--report", required=True, type=Path)
|
||||||
|
parser.add_argument("--numba-packages", type=Path)
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
if arguments.report.exists():
|
||||||
|
parser.error("отчёт уже существует; перезапись запрещена")
|
||||||
|
if arguments.numba_packages is not None:
|
||||||
|
if not arguments.numba_packages.is_dir():
|
||||||
|
parser.error("каталог пакетов Numba отсутствует")
|
||||||
|
sys.path.insert(0, str(arguments.numba_packages.resolve()))
|
||||||
|
result = decode_file(arguments.iq_file, iq_format=arguments.iq_format,
|
||||||
|
truth_metadata_path=arguments.truth_metadata)
|
||||||
|
arguments.report.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
arguments.report.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
print(f"RF-001 FILE RX: CRC={result['crc_valid_packets']}; "
|
||||||
|
f"exact={result['exact_packets']}; RF TX=0")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
107
tools/qpsk_rf_tx.py
Normal file
107
tools/qpsk_rf_tx.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""RF-001: построение 100 QPSK-пакетов и IQ только в режиме dry-run."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
if __package__ in (None, ""):
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from protocol.video003_modem import SAMPLE_RATE_HZ, build_frame
|
||||||
|
from protocol.video005_iq_waveform import GUARD_SAMPLES, shape_packet
|
||||||
|
from tools.qpsk_rf_common import (
|
||||||
|
GAP_MS,
|
||||||
|
GAP_SAMPLES,
|
||||||
|
IQ_FORMAT,
|
||||||
|
PACKET_COUNT,
|
||||||
|
PAYLOAD_BYTES,
|
||||||
|
SEED,
|
||||||
|
TEST_ID,
|
||||||
|
payload_for_sequence,
|
||||||
|
radio_mode,
|
||||||
|
sha256_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DEFAULT_OUTPUT = ROOT / "data/raw/rf001/offline_qpsk_100_packets"
|
||||||
|
|
||||||
|
|
||||||
|
def build_dry_run() -> tuple[np.ndarray, dict]:
|
||||||
|
"""Собирает ровно утверждённый режим; границы остаются только в TX-ведомости."""
|
||||||
|
separator = GAP_SAMPLES - 2 * GUARD_SAMPLES
|
||||||
|
if separator < 0:
|
||||||
|
raise AssertionError("пауза короче двух защитных интервалов RRC")
|
||||||
|
waveforms = []
|
||||||
|
expected = []
|
||||||
|
starts = []
|
||||||
|
position = 0
|
||||||
|
for sequence_number in range(PACKET_COUNT):
|
||||||
|
payload = payload_for_sequence(SEED, sequence_number)
|
||||||
|
frame = build_frame(payload, "QPSK", "2/3", sequence_number=sequence_number)
|
||||||
|
waveform = shape_packet(frame)
|
||||||
|
starts.append(position)
|
||||||
|
waveforms.append(waveform)
|
||||||
|
expected.append({
|
||||||
|
"sequence_number": sequence_number,
|
||||||
|
"payload_sha256": hashlib.sha256(payload).hexdigest(),
|
||||||
|
"packet_sha256": hashlib.sha256(frame.packet).hexdigest(),
|
||||||
|
})
|
||||||
|
position += waveform.size + (separator if sequence_number < PACKET_COUNT - 1 else 0)
|
||||||
|
iq = np.zeros(position, dtype="<c8")
|
||||||
|
for index, waveform in enumerate(waveforms):
|
||||||
|
iq[starts[index]:starts[index] + waveform.size] = waveform
|
||||||
|
metadata = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"test_id": TEST_ID,
|
||||||
|
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
|
||||||
|
**radio_mode(),
|
||||||
|
"application_payload_bytes": PAYLOAD_BYTES,
|
||||||
|
"packet_count": PACKET_COUNT,
|
||||||
|
"inter_packet_gap_ms": GAP_MS,
|
||||||
|
"inter_packet_gap_samples": GAP_SAMPLES,
|
||||||
|
"inter_array_zero_samples": separator,
|
||||||
|
"seed": SEED,
|
||||||
|
"iq_format": IQ_FORMAT,
|
||||||
|
"dtype": "<c8",
|
||||||
|
"waveform_sample_count": int(iq.size),
|
||||||
|
"waveform_duration_s": iq.size / SAMPLE_RATE_HZ,
|
||||||
|
"packet_waveform_start_samples": starts,
|
||||||
|
"expected_packet_hashes": expected,
|
||||||
|
"rf_tx": 0,
|
||||||
|
"sdr_hardware": "NOT_ACCESSED",
|
||||||
|
"status": "PREPARED_NOT_RUN",
|
||||||
|
}
|
||||||
|
return iq, metadata
|
||||||
|
|
||||||
|
|
||||||
|
def write_dry_run(output_directory: Path = DEFAULT_OUTPUT) -> tuple[Path, Path]:
|
||||||
|
"""Создаёт новый каталог; существующие IQ и метаданные не перезаписывает."""
|
||||||
|
output_directory.mkdir(parents=True, exist_ok=False)
|
||||||
|
iq, metadata = build_dry_run()
|
||||||
|
iq_path = output_directory / "tx_waveform.c64le"
|
||||||
|
metadata_path = output_directory / "tx_metadata.json"
|
||||||
|
iq.tofile(iq_path)
|
||||||
|
metadata["iq_sha256"] = sha256_file(iq_path)
|
||||||
|
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
return iq_path, metadata_path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT)
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
iq_path, metadata_path = write_dry_run(arguments.output_dir)
|
||||||
|
print(f"RF-001 DRY-RUN: {iq_path}; metadata={metadata_path}; RF TX=0")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user