Compare commits
11 Commits
1e4adc0a98
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0fa7e8a46 | ||
|
|
fa2e473c6d | ||
|
|
fce14f038e | ||
|
|
13f183f7c7 | ||
|
|
a37ad1f7b3 | ||
|
|
46b10b1243 | ||
|
|
0ae04ae4a8 | ||
|
|
92289bdf7c | ||
|
|
2da80d0908 | ||
|
|
c486039053 | ||
|
|
c9569164e0 |
@@ -7,7 +7,7 @@ model: sonnet
|
||||
|
||||
Ты проверяешь чужую работу в проекте SDR Rover Link: учебно-исследовательский
|
||||
цифровой радиоканал для наземного ровера. Python, модули протокола в `protocol/`,
|
||||
лабораторные работы в `tests/labNNN_*.py`, журнал проекта в `PROJECT_LOG.md`.
|
||||
лабораторные работы в `experiments/labNNN_*.py`, журнал проекта в `PROJECT_LOG.md`.
|
||||
|
||||
Ты не автор кода. Твоя ценность именно в том, что ты не участвовал в его написании.
|
||||
|
||||
@@ -18,7 +18,7 @@ model: sonnet
|
||||
Если пересказ расходится с первоисточником — это находка, и часто самая важная.
|
||||
|
||||
2. **Запусти сам.** Быстрая проверка: `python tools/quick_gate.py`.
|
||||
Полный прогон лабы: `python tests/labNNN_имя.py`. Не верь чужому «тесты проходят».
|
||||
Полный прогон лабы: `python experiments/labNNN_имя.py`. Не верь чужому «тесты проходят».
|
||||
|
||||
3. **Пересчитай ключевые числа независимым способом.** Это главный инструмент.
|
||||
Симуляция BER проверяется аналитической формулой, счётчик отброшенных пакетов —
|
||||
|
||||
@@ -12,7 +12,7 @@ argument-hint: <номер лабы> [краткая постановка]
|
||||
|
||||
Запусти агента `Explore` с запросом: что в `protocol/` уже относится к этой задаче,
|
||||
какие модули придётся трогать, где есть пересечение или дублирование, как устроены
|
||||
две-три ближайшие по смыслу лабы в `tests/`.
|
||||
две-три ближайшие по смыслу лабы в `experiments/`.
|
||||
|
||||
Прочитай сам последние записи в `PROJECT_LOG.md` — там указано, чем должен
|
||||
заниматься следующий этап.
|
||||
|
||||
@@ -19,7 +19,7 @@ import subprocess
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
WATCHED = re.compile(r"[\\/](?:protocol|tests)[\\/][^\\/]+\.py$")
|
||||
WATCHED = re.compile(r"[\\/](?:protocol|experiments|tests|tools)[\\/][^\\/]+\.py$")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -29,9 +29,24 @@ __pycache__/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Необработанные данные и IQ-записи
|
||||
# Необработанные данные и IQ-записи.
|
||||
# Правило закрывает крупные записи отсчётов и видео: они воспроизводятся
|
||||
# заново и в историю не помещаются.
|
||||
data/raw/
|
||||
|
||||
# Исключения: исходные изображения — это входные данные лабораторных, а не
|
||||
# результат. Без них Lab007, Lab009, Lab011 и Lab012 не запускаются, поэтому
|
||||
# они хранятся в репозитории. Суммарно около 1 МБ.
|
||||
!data/raw/lab007_source.jpg
|
||||
!data/raw/lab009_source.jpg
|
||||
!data/raw/lab011_scene.jpg
|
||||
!data/raw/lab012_safety_scene.jpg
|
||||
|
||||
# Метаданные и спектр записи Lab024a: сама запись отсчётов не хранится,
|
||||
# поэтому они оставлены как след проведённого измерения.
|
||||
!data/raw/lab024a_pluto_rx_100mhz.json
|
||||
!data/raw/lab024a_pluto_rx_100mhz_spectrum.png
|
||||
|
||||
# Временные файлы редакторов
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
60
.gitlab-ci.yml
Normal file
60
.gitlab-ci.yml
Normal file
@@ -0,0 +1,60 @@
|
||||
# Непрерывная проверка SDR Rover Link.
|
||||
#
|
||||
# Здесь выполняются только быстрые проверки: они должны укладываться в
|
||||
# секунды. Лабораторные эксперименты сюда не помещаются — они длятся
|
||||
# минутами, требуют исходных данных из data/raw и в части случаев
|
||||
# подключённого PlutoSDR. Их запуск остаётся ручным.
|
||||
#
|
||||
# Оборудование конвейеру не нужно: устанавливается только расчётное
|
||||
# окружение, без pyadi-iio и libiio.
|
||||
|
||||
stages:
|
||||
- gate
|
||||
- test
|
||||
- lint
|
||||
|
||||
default:
|
||||
image: python:3.13
|
||||
before_script:
|
||||
- python --version
|
||||
- python -m pip install --quiet --upgrade pip
|
||||
- python -m pip install --quiet -r requirements.txt
|
||||
cache:
|
||||
key: "python-$CI_COMMIT_REF_SLUG"
|
||||
paths:
|
||||
- .cache/pip
|
||||
|
||||
variables:
|
||||
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
||||
PYTHONIOENCODING: "utf-8"
|
||||
|
||||
# Дешёвый шлюз: синтаксис всех файлов, импорт protocol и функциональные
|
||||
# проверки тех лабораторных, которым не нужны данные эксперимента.
|
||||
# Выполняется первым, потому что ловит самые грубые поломки за секунду.
|
||||
быстрый-шлюз:
|
||||
stage: gate
|
||||
script:
|
||||
- python tools/quick_gate.py
|
||||
|
||||
# Быстрые проверки ядра протокола.
|
||||
тесты-ядра:
|
||||
stage: test
|
||||
script:
|
||||
- python -m pytest tests/ -q --junitxml=report.xml
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
junit: report.xml
|
||||
expire_in: 1 week
|
||||
|
||||
# Проверка стиля. Пока не блокирует конвейер: в лабораторных Lab001–Lab041
|
||||
# принято своеобразное оформление с переносами аргументов, и приводить их
|
||||
# к единому стилю сейчас означало бы тронуть 50 файлов с зафиксированными
|
||||
# результатами. Ограничение снимается, когда договоримся о правилах.
|
||||
стиль:
|
||||
stage: lint
|
||||
allow_failure: true
|
||||
before_script:
|
||||
- python -m pip install --quiet ruff
|
||||
script:
|
||||
- ruff check protocol/ tests/ tools/
|
||||
157
PROJECT_LOG.md
157
PROJECT_LOG.md
@@ -434,3 +434,160 @@ git version 2.51.1.windows.1
|
||||
## Следующий этап
|
||||
|
||||
Криптографическая аутентификация отложена. Сначала выполняется этап стабилизации: быстрые тесты `protocol/` вместо единственной точки входа через лабораторную, разделение быстрых проверок и экспериментов, воспроизводимое описание окружения.
|
||||
|
||||
---
|
||||
|
||||
# Запись 018
|
||||
|
||||
## Дата
|
||||
|
||||
10 августа 2026 года
|
||||
|
||||
## Тема
|
||||
|
||||
Выбор радиочастотной архитектуры: рабочий диапазон, скачки по частоте и меры против подавления.
|
||||
|
||||
## Исходные данные
|
||||
|
||||
- Устройство опрошено напрямую: `Analog Devices PlutoSDR Rev.C (Z7010-AD9361)`. Приёмник перестраивается от 70 МГц до 6 ГГц, передатчик от 46,9 МГц. Минимальная частота дискретизации 2 083 333 Гц.
|
||||
- Ограничений по разрешённым частотам у проекта нет.
|
||||
- Усилителя мощности нет, покупка отложена до первых испытаний.
|
||||
|
||||
## Отвергнутые варианты и причины
|
||||
|
||||
**Выбор редкой фиксированной частоты.** Рассматривался как защита от подавления. Отклонён: одиночная несущая в пустом эфире обнаруживается сканирующим приёмником тривиально. Уязвимость создаёт не значение частоты, а её неизменность.
|
||||
|
||||
**Скачки по всему диапазону 70 МГц – 6 ГГц.** Теоретически требуют от заградительной помехи на 42 дБ больше мощности, но выигрыш недостижим. Отношение крайних частот 86:1, одна антенна такой полосы не перекрывает; разброс потерь на 10 км составляет 39 дБ, проектировать пришлось бы по худшему краю; выше примерно 1 ГГц дифракция исчезает и для наземного ровера без прямой видимости эти каналы мертвы. Кроме того, подавление направлено на диапазон, в котором связь действительно работает, а не на тот, который способно настроить оборудование.
|
||||
|
||||
**Диапазоны 330–433 МГц.** Отклонены как плотно занятые и общеизвестные.
|
||||
|
||||
## Принято
|
||||
|
||||
Рабочий диапазон **200–250 МГц**. Обоснование: четвертьволновая антенна 33 см пригодна для установки на ровер, дифракция ещё эффективна, участок заметно свободнее любительского 144 МГц и УКВ выше 300 МГц. Потери на 10 км составляют около 99,5 дБ.
|
||||
|
||||
Совокупность мер против подавления:
|
||||
|
||||
1. **Скачки по частоте в пределах 200–250 МГц.** Требуют от заградительной помехи на 22 дБ больше мощности при неизменной антенне и механике ровера.
|
||||
2. **Направленная антенна наземной станции.** Даёт 10–20 дБ усиления и пространственно подавляет помеху, приходящую с иного направления.
|
||||
3. **Расширение спектра для команд управления.** Поток команд составляет 10,88 кбит/с, поэтому расширение для него реализуемо в узкой полосе, в отличие от видеопотока.
|
||||
|
||||
Видео остаётся некритичным классом: при подавлении управление сохраняется в узкой полосе, а ровер переходит к безопасному поведению по уже реализованной логике.
|
||||
|
||||
## Запрещённые участки
|
||||
|
||||
- 108–137 МГц — авиационная связь.
|
||||
- 243 МГц — международная аварийная частота.
|
||||
|
||||
## Ограничения решения
|
||||
|
||||
- Нижняя граница 70 МГц определяется приёмником Pluto и без смены оборудования не преодолевается.
|
||||
- Скачки требуют согласованной последовательности у обеих сторон, то есть зависят от криптографической аутентификации, которая пока не реализована.
|
||||
- Расчёты выполнены для свободного пространства. Потери на препятствиях 20–40 дБ учтены оценочно и подлежат измерению.
|
||||
- Дальность 10 км без усилителя мощности не достигается.
|
||||
|
||||
## Влияние на ближайшие работы
|
||||
|
||||
На Lab042 не влияет: там кабельная петля, несущая 435 МГц выбрана произвольно. Диапазон 200–250 МГц вступает в силу начиная с эфирных испытаний.
|
||||
|
||||
---
|
||||
|
||||
# Запись 019
|
||||
|
||||
## Дата
|
||||
|
||||
10 августа 2026 года
|
||||
|
||||
## Тема
|
||||
|
||||
Lab042: первая передача изображения через реальный радиотракт. Эфирная проверка на паре антенн.
|
||||
|
||||
## Причина эфирного прогона
|
||||
|
||||
Кабельная петля требует переходника SMA папа-папа, которого пока нет: пигтейлы к антеннам оканчиваются папой, а разъёмы Pluto — мамы. Чтобы не простаивать, выполнена вспомогательная эфирная проверка на двух антеннах, разнесённых на 60 см. Она не заменяет кабельный опыт: уровни в помещении не контролируются строго, возможны переотражения. Её задача — снять последнюю неизвестность в коде, а именно обвязку вокруг устройства, которая в моделях не проверяется.
|
||||
|
||||
## Условия
|
||||
|
||||
- Устройство Analog Devices PlutoSDR Rev.C, чип AD9361, прошивка v0.32.
|
||||
- Несущая 225 МГц, внутри целевого диапазона Записи 018.
|
||||
- Антенны, рассчитанные на 108 МГц, работают на несущей как полуволновые. Частота передачи и частота, на которую рассчитана антенна, различны, поэтому в авиационный диапазон 108–137 МГц опыт не заходит.
|
||||
- Изображение 320x180 в градациях серого, JPEG 4756 байт при качестве 35, десять фрагментов.
|
||||
|
||||
## Результат
|
||||
|
||||
- Изображение передано и собрано побайтово идентично исходному. Контрольная сумма MD5 переданного и принятого файла совпала.
|
||||
- Десять фрагментов из десяти, ни одной несошедшейся контрольной суммы.
|
||||
- Средняя нормированная корреляция при поиске кадра 0,99951.
|
||||
- Средняя оценка ухода несущей минус 0,368 Гц, что отражает общий опорный генератор передатчика и приёмника.
|
||||
- Канальная скорость 18,404 кбит/с при длительности сигнала в эфире около 2,07 секунды.
|
||||
- Результат воспроизведён тремя запусками подряд с одинаковой контрольной суммой.
|
||||
|
||||
## Дефекты, вскрытые железом
|
||||
|
||||
Программная модель проходила чисто, но на устройстве обнаружились четыре дефекта, каждый воспроизведён и устранён.
|
||||
|
||||
- Кадр длиной 566 528 отсчётов не помещался в приёмный буфер 262 144. Между отдельными чтениями поток отсчётов не является непрерывным, и кадр на стыке разрушался. Приём переведён на один буфер, размер которого рассчитывается по длине кадра.
|
||||
- Частотная коррекция применялась всегда, тогда как при общем опорном генераторе истинный уход близок к нулю. Оценка по маркеру длиной 80 символов состояла из шума, и коррекция им поворачивала фазу вдоль всего кадра: измерено 65 процентов ошибочных битов. Перенесена защита should_apply_cfo_correction из Lab023, разрешающая коррекцию только вне мёртвой зоны.
|
||||
- Усиление приёмника 20 дБ давало сигнал около 0,0003 от шкалы АЦП. Рабочие уровни подобраны измерением: приёмник 50 дБ, передатчик минус 10 дБ дают ноль ошибочных битов с запасом.
|
||||
- Величина вектора ошибки при неудачном приёме возвращала ноль, то есть показывала идеальный сигнал там, где сигнала не было. Это тот же класс ошибки, что вписанные показатели Lab041: метрика при отказе рапортует лучшее значение. Заменена на явный признак невычислимости.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Эфирный прогон является проверкой тракта, а не измерением: уровни не контролируются как в кабельной сборке.
|
||||
- Потолок усиления передатчика в эфирном режиме рассчитывается из бюджета линии по расстоянию между антеннами, а не задан константой.
|
||||
|
||||
## Следующий этап
|
||||
|
||||
Кабельная петля через аттенюатор 30 дБ после получения переходника папа-папа. Это и есть заявленный опыт Lab042 с контролируемыми уровнями, его результат станет основным для лабораторной. Затем Lab043 — разведение на два устройства.
|
||||
|
||||
---
|
||||
|
||||
# Запись 020
|
||||
|
||||
## Дата
|
||||
|
||||
11 августа 2026 года
|
||||
|
||||
## Тема
|
||||
|
||||
Lab042 завершена: кабельная петля через аттенюатор, зависимость доставки от мощности, устранение трёх дефектов отчётности.
|
||||
|
||||
## Схема опыта
|
||||
|
||||
Выход передатчика PlutoSDR через кабель SMA, аттенюатор AT30S 30 дБ, переходник и второй кабель на вход приёмника того же устройства. Антенны сняты. Это заявленный основной опыт лабораторной с контролируемыми уровнями, в отличие от вспомогательной эфирной проверки Записи 019.
|
||||
|
||||
## Зависимость доставки от мощности передатчика
|
||||
|
||||
Кабельный тракт при неизменной схеме исследован по усилению передатчика. Получен отчётливый переход от отказа к устойчивой передаче:
|
||||
|
||||
- TX минус 30 дБ: 0 из 10 фрагментов, контрольная сумма не сходится ни разу, средняя корреляция 0,936. Сигнал ниже порога уверенного приёма.
|
||||
- TX минус 20 дБ: 8 из 10 фрагментов, средняя корреляция 0,992. Пограничный режим: часть кадров проходит.
|
||||
- TX минус 10 дБ: 10 из 10 фрагментов, изображение собрано побайтово. Устойчивая передача.
|
||||
|
||||
Значения TX минус 30 и минус 20 дБ — исследование порога, повторять их не требуется. Рабочая точка кабельной петли: TX минус 10 дБ, RX плюс 20 дБ, аттенюатор 30 дБ, несущая 435 МГц.
|
||||
|
||||
Начальные умолчания кабельного режима TX минус 30 и RX плюс 20 дБ на железе прежде не проверялись; отказ первого прогона объяснялся именно этим, а не дефектом обработки.
|
||||
|
||||
## Результат рабочей точки
|
||||
|
||||
- Найдено 10 кадров из 10, контрольная сумма сошлась у всех десяти, восстановлены все фрагменты.
|
||||
- Изображение собрано и совпадает с переданным. Совпадение подтверждено независимо: SHA-256 принятого файла сверен с SHA-256 переданного JPEG, пересобранного из исходника отдельно от прогона. Оба равны 751d7ea0d8711e2ef54fc5cf209a809cfee5bbb14916dd20aa201d5285b40286.
|
||||
- Размер JPEG 4756 байт, качество 30, десять фрагментов.
|
||||
- Длительность сигнала около 2,07 секунды, канальная скорость 18,404 кбит/с.
|
||||
- Кадр 8 прошёл контрольную сумму при пониженной корреляции поиска 0,866 против 0,998 у остальных: низкая корреляция маркера не означает испорченных данных, фаза дискретизации была подобрана и кадр демодулировался чисто.
|
||||
|
||||
## Устранённые дефекты отчётности
|
||||
|
||||
Разбор кабельного прогона вскрыл три дефекта, при которых провал мог быть принят за успех. Все три устранены, каждый закрыт быстрым тестом в tests/test_lab042_reporting.py.
|
||||
|
||||
- Величина вектора ошибки нормировалась на единичный эталон, из-за чего при слабом сигнале становилась искусственно малой: провалившийся прогон показывал EVM меньше, чем успешный. Нормировка исправлена на мощность подогнанного сигнала. При невозможности вычислить величину возвращается признак невычислимости, а не ноль.
|
||||
- Отчёт перечислял lab042_received.jpg среди созданных файлов безусловно, даже когда изображение не собрано и файла нет. Теперь файл указывается только при фактической сборке.
|
||||
- Программа завершалась кодом ноль при полном отказе передачи. Введён критерий приёмки: при несобранном изображении код завершения ненулевой, чтобы автоматика не приняла провал за успех.
|
||||
|
||||
## Проверки
|
||||
|
||||
- Быстрые тесты ядра расширены с 125 до 135. Добавленные проверяют неуспешный путь: провал даёт ненулевой код, отсутствующий файл не объявляется созданным, невычислимая метрика не превращается в ноль.
|
||||
- Окончательный кабельный прогон рабочей точки выполнен уже исправленной версией программы, поэтому итоговый отчёт сформирован ею.
|
||||
|
||||
## Следующий этап
|
||||
|
||||
Lab043: передаёт PlutoSDR, принимает RTL-SDR. Общий опорный генератор исчезает, впервые появляется реальное рассогласование несущей и частоты дискретизации двух независимых устройств. Здесь понадобится защищённая частотная коррекция, перенесённая в Записи 019.
|
||||
|
||||
364
README.md
364
README.md
@@ -4,330 +4,170 @@
|
||||
|
||||
**SDR Rover Link** — учебно-исследовательский проект по разработке цифрового радиоканала для наземного робототехнического комплекса с использованием программно-определяемого радио — SDR, Software Defined Radio.
|
||||
|
||||
Проект должен пройти полный путь от передачи простого текстового пакета до передачи телеметрии и отдельных JPEG-изображений через реальный радиоканал.
|
||||
Проект должен пройти полный путь от передачи простого текстового пакета до передачи телеметрии и изображений через реальный радиоканал.
|
||||
|
||||
Основная задача проекта — не только получить работающую систему, но и последовательно изучить все этапы цифровой радиосвязи:
|
||||
|
||||
- формирование данных;
|
||||
- разбиение данных на пакеты;
|
||||
- контроль целостности;
|
||||
- исправление ошибок;
|
||||
- цифровую модуляцию;
|
||||
- формирование IQ-сигнала;
|
||||
- передачу через SDR;
|
||||
- приём и демодуляцию;
|
||||
- восстановление исходных данных.
|
||||
Основная задача — не только получить работающую систему, но и последовательно изучить все этапы цифровой радиосвязи: формирование данных, разбиение на пакеты, контроль целостности, исправление ошибок, цифровую модуляцию, формирование IQ-сигнала, передачу через SDR, приём, демодуляцию и восстановление исходных данных.
|
||||
|
||||
---
|
||||
|
||||
## 2. Целевой сценарий
|
||||
|
||||
Разрабатываемая система предназначена для исследовательского наземного ровера.
|
||||
|
||||
Целевые требования:
|
||||
|
||||
| Параметр | Требование |
|
||||
|---|---|
|
||||
| Дальность связи | до 10 км |
|
||||
| Скорость ровера | до 25 км/ч |
|
||||
| Прямая радиовидимость | может отсутствовать |
|
||||
| Рабочие частоты | ниже 350 МГц |
|
||||
| Основной тип данных | телеметрия и отдельные изображения |
|
||||
| Рабочий диапазон | 200–250 МГц, см. Запись 018 журнала |
|
||||
| Основной тип данных | телеметрия, команды и изображения |
|
||||
| Формат изображения | JPEG |
|
||||
| Частота передачи изображений | отдельные кадры, не видеопоток |
|
||||
| Действие при потере связи | безопасная остановка ровера |
|
||||
|
||||
Дальность 10 км без прямой видимости является целевым исследовательским требованием, а не гарантированной характеристикой первой версии системы.
|
||||
Дальность 10 км без прямой видимости — целевое исследовательское требование, а не гарантированная характеристика первой версии.
|
||||
|
||||
---
|
||||
|
||||
## 3. Почему не используется обычное FPV-видео
|
||||
|
||||
Типовые FPV-системы работают преимущественно в диапазонах 2,4 и 5,8 ГГц.
|
||||
Типовые FPV-системы работают в диапазонах 2,4 и 5,8 ГГц. Для наземного робота это плохо: велики потери у поверхности земли, сильное затенение рельефом и растительностью, а главное — на таких частотах практически нет дифракции, поэтому за препятствием связь пропадает.
|
||||
|
||||
Для наземного робота такие диапазоны имеют ряд ограничений:
|
||||
В этом проекте вместо непрерывного видеопотока передаются отдельные сжатые изображения, что резко снижает требуемую скорость канала.
|
||||
|
||||
- высокие потери при работе у поверхности земли;
|
||||
- затенение рельефом, зданиями и растительностью;
|
||||
- быстрое ухудшение изображения при слабом сигнале;
|
||||
- высокая загруженность распространённых диапазонов;
|
||||
- зависимость от постоянного широкополосного видеопотока.
|
||||
---
|
||||
|
||||
В этом проекте вместо непрерывного видео предполагается передавать отдельные сжатые изображения.
|
||||
## 4. Текущее состояние
|
||||
|
||||
Пример:
|
||||
**Выполнено:** лабораторные Lab001–Lab042. Пройден путь от структуры пакета до первой передачи изображения через реальный радиотракт.
|
||||
|
||||
```text
|
||||
Камера
|
||||
↓
|
||||
JPEG-изображение
|
||||
↓
|
||||
Разбиение на пакеты
|
||||
↓
|
||||
Передача через SDR
|
||||
↓
|
||||
Сборка изображения на наземной станции
|
||||
Что реализовано и проверено:
|
||||
|
||||
- пакетный протокол с CRC32, фрагментация и сборка изображений;
|
||||
- модуляция и демодуляция BPSK, формирующий фильтр, кадровая синхронизация, оценка и коррекция ухода несущей;
|
||||
- блочное исправление стираний над GF(256), перемежение, подбор параметров;
|
||||
- приоритетное обслуживание команд, телеметрии и видео;
|
||||
- двухступенчатая безопасная остановка, сторожевой таймер, постоянное аварийное намерение, безопасный сброс;
|
||||
- идентификаторы сеанса, безопасный запуск, отклонение пакетов предыдущего сеанса;
|
||||
- передача JPEG через настоящий радиотракт на одном PlutoSDR: сначала по эфиру на паре антенн, затем по кабелю через аттенюатор 30 дБ. Принятое изображение совпало с переданным побайтово (Записи 019 и 020).
|
||||
|
||||
**Что ещё не сделано:** два независимых устройства пока не разведены. Все передачи выполнены в петле одного PlutoSDR, где передатчик и приёмник делят опорный генератор, поэтому рассогласования несущей и частоты дискретизации между сторонами почти нет.
|
||||
|
||||
**Ближайшая работа:** Lab043 — передача с PlutoSDR на приём RTL-SDR. Общий опорный генератор исчезает, впервые появляется реальное рассогласование двух устройств. Техническое задание на завершённую Lab042: [docs/lab042_hardware_loopback_spec.md](docs/lab042_hardware_loopback_spec.md).
|
||||
|
||||
Хронология решений и результатов: [PROJECT_LOG.md](PROJECT_LOG.md).
|
||||
|
||||
---
|
||||
|
||||
## 5. Оборудование
|
||||
|
||||
| Позиция | Состояние |
|
||||
|---|---|
|
||||
| **Pluto+** — приёмопередатчик, чип AD9361, 70 МГц – 6 ГГц | есть |
|
||||
| RTL-SDR — приёмный донгл | есть |
|
||||
| Аттенюатор AT30S, 30 дБ, SMA | есть |
|
||||
| Кабели SMA, штыревые антенны | есть |
|
||||
| Усилитель мощности около 1 Вт | планируется после первых испытаний |
|
||||
| Raspberry Pi как бортовой вычислитель | планируется |
|
||||
|
||||
Устройство подключается по USB и появляется в системе как сетевой адаптер с адресом `192.168.2.1`.
|
||||
|
||||
**Проверять связь с Pluto командой `ping` бесполезно.** При активном VPN трафик уходит в туннель, и «успешный» отклик приходит даже от несуществующего адреса. Достоверна только успешная установка контекста `iio.Context("ip:192.168.2.1")`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Установка окружения
|
||||
|
||||
Расчётная часть работает на компьютере без SDR:
|
||||
|
||||
```bash
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Такой подход позволяет существенно снизить требуемую скорость радиоканала.
|
||||
Для лабораторных, работающих с устройством, дополнительно:
|
||||
|
||||
```bash
|
||||
python -m pip install -r requirements-hardware.txt
|
||||
```
|
||||
|
||||
Версии зафиксированы по проверенному окружению, Python 3.13.
|
||||
|
||||
---
|
||||
|
||||
## 4. Текущее оборудование
|
||||
## 7. Как запускать
|
||||
|
||||
### SDR-оборудование
|
||||
**Быстрые проверки ядра** — секунды, оборудование не нужно:
|
||||
|
||||
- RTL-SDR — приёмник;
|
||||
- ADALM-PlutoSDR — приёмопередатчик, ожидается поступление;
|
||||
- HackRF One — может временно использоваться при необходимости.
|
||||
```bash
|
||||
python -m pytest tests/ -q
|
||||
```
|
||||
|
||||
### Вычислительное оборудование
|
||||
**Дешёвый шлюз** — синтаксис, импорт `protocol`, быстрые проверки ядра и функциональные проверки лабораторных. Отвечает на один вопрос: не сломана ли кодовая база прямо сейчас:
|
||||
|
||||
- компьютер под управлением Windows;
|
||||
- Raspberry Pi — планируется для установки на ровер;
|
||||
- бортовой вычислитель ровера — будет выбран позднее.
|
||||
```bash
|
||||
python tools/quick_gate.py
|
||||
```
|
||||
|
||||
### Программное обеспечение
|
||||
**Лабораторный эксперимент** — минуты, пишет результаты в `data/processed/`:
|
||||
|
||||
- Python 3.12;
|
||||
- Anaconda;
|
||||
- Spyder;
|
||||
- Git;
|
||||
- GNU Radio — будет установлен на следующем этапе;
|
||||
- libiio и драйверы PlutoSDR — будут установлены после получения устройства.
|
||||
```bash
|
||||
python -m experiments.lab041_session_restart_safety
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Предварительно рассматриваемые диапазоны
|
||||
|
||||
В рамках проекта рассматриваются:
|
||||
|
||||
- 27 МГц;
|
||||
- 50 МГц;
|
||||
- 144 МГц;
|
||||
- другие разрешённые для лабораторных испытаний частоты ниже 350 МГц.
|
||||
|
||||
Конкретный диапазон будет выбран после анализа:
|
||||
|
||||
- доступной полосы;
|
||||
- требований законодательства;
|
||||
- размеров антенны;
|
||||
- потерь распространения;
|
||||
- уровня внешних помех;
|
||||
- возможностей выходного радиочастотного тракта;
|
||||
- допустимой мощности передачи.
|
||||
|
||||
Передача по эфиру должна выполняться только на разрешённых частотах и с соблюдением допустимой мощности. Первые опыты следует проводить через аттенюатор, эквивалент нагрузки или в экранированной лабораторной конфигурации.
|
||||
|
||||
---
|
||||
|
||||
## 6. Основные этапы проекта
|
||||
|
||||
### Этап 0. Подготовка среды
|
||||
|
||||
- создание репозитория;
|
||||
- настройка Git;
|
||||
- настройка Python и Spyder;
|
||||
- подготовка структуры каталогов;
|
||||
- ведение документации.
|
||||
|
||||
### Этап 1. Пакетный протокол
|
||||
|
||||
- структура пакета;
|
||||
- типы сообщений;
|
||||
- порядковый номер пакета;
|
||||
- длина данных;
|
||||
- CRC;
|
||||
- кодирование и декодирование.
|
||||
|
||||
### Этап 2. Виртуальный канал
|
||||
|
||||
- передача пакетов между Python-программами;
|
||||
- имитация потерь;
|
||||
- имитация повреждения битов;
|
||||
- измерение числа ошибочных пакетов.
|
||||
|
||||
### Этап 3. Передача файлов
|
||||
|
||||
- разбиение файла на фрагменты;
|
||||
- нумерация фрагментов;
|
||||
- подтверждение приёма;
|
||||
- повторная передача;
|
||||
- сборка исходного файла.
|
||||
|
||||
### Этап 4. Передача изображений
|
||||
|
||||
- загрузка JPEG;
|
||||
- разбиение JPEG на пакеты;
|
||||
- передача через виртуальный канал;
|
||||
- восстановление изображения;
|
||||
- обработка потерянных фрагментов.
|
||||
|
||||
### Этап 5. Физический уровень
|
||||
|
||||
- BPSK;
|
||||
- QPSK;
|
||||
- формирование символов;
|
||||
- IQ-сэмплы;
|
||||
- синхронизация;
|
||||
- фильтрация;
|
||||
- оценка BER и PER.
|
||||
|
||||
### Этап 6. PlutoSDR
|
||||
|
||||
- подключение устройства;
|
||||
- передача несущей;
|
||||
- передача тестовой последовательности;
|
||||
- передача цифрового пакета;
|
||||
- приём сигнала RTL-SDR или вторым SDR.
|
||||
|
||||
### Этап 7. Радиоканал передачи изображений
|
||||
|
||||
- передача JPEG через SDR;
|
||||
- измерение времени доставки;
|
||||
- испытания при различных уровнях сигнала;
|
||||
- испытания при наличии помех;
|
||||
- подбор скорости и модуляции.
|
||||
|
||||
### Этап 8. Полевые испытания
|
||||
|
||||
- испытания на малой мощности;
|
||||
- испытания на малой дистанции;
|
||||
- постепенное увеличение расстояния;
|
||||
- испытания с ретранслятором;
|
||||
- испытания на наземном ровере.
|
||||
|
||||
---
|
||||
|
||||
## 7. Структура репозитория
|
||||
## 8. Структура репозитория
|
||||
|
||||
```text
|
||||
SDR_Rover
|
||||
│
|
||||
├── README.md
|
||||
├── docs
|
||||
├── protocol
|
||||
├── rover
|
||||
├── ground_station
|
||||
├── gnuradio
|
||||
├── tests
|
||||
└── tools
|
||||
├── protocol/ реализация протокола и радиотракта
|
||||
├── experiments/ лабораторные Lab001–Lab041
|
||||
├── tests/ быстрые автоматические проверки
|
||||
├── tools/ вспомогательные программы и шлюз
|
||||
├── docs/ технические задания и исследования
|
||||
├── data/ исходные данные и результаты
|
||||
├── rover/ пока пусто
|
||||
├── ground_station/ пока пусто
|
||||
└── gnuradio/ пока пусто
|
||||
```
|
||||
|
||||
### `docs`
|
||||
|
||||
Документация, лабораторные работы, схемы и отчёты.
|
||||
|
||||
### `protocol`
|
||||
|
||||
Реализация пакетного протокола:
|
||||
Ядро системы. Пакет с CRC32, фрагментация изображений, кадр канала, исправление стираний, приоритеты, аварийная логика, сеансы связи, примитивы BPSK-радиотракта.
|
||||
|
||||
- структура пакета;
|
||||
- CRC;
|
||||
- кодирование;
|
||||
- декодирование;
|
||||
- типы сообщений.
|
||||
### `experiments`
|
||||
|
||||
### `rover`
|
||||
|
||||
Программы, которые будут работать на борту ровера:
|
||||
|
||||
- получение изображения;
|
||||
- сжатие JPEG;
|
||||
- передача телеметрии;
|
||||
- формирование радиопакетов.
|
||||
|
||||
### `ground_station`
|
||||
|
||||
Программы наземной станции:
|
||||
|
||||
- приём пакетов;
|
||||
- сборка изображений;
|
||||
- отображение телеметрии;
|
||||
- управление передачей.
|
||||
|
||||
### `gnuradio`
|
||||
|
||||
Схемы GNU Radio Companion:
|
||||
|
||||
- передатчик;
|
||||
- приёмник;
|
||||
- BPSK-модем;
|
||||
- QPSK-модем;
|
||||
- тестовые генераторы.
|
||||
Лабораторные работы. Это исследовательские программы: они моделируют канал, выполняют сотни повторов, строят CSV, графики и отчёты. **Быстрыми тестами они не являются.** При импорте создают каталоги и пишут файлы, поэтому импортировать их не следует — для повторного использования кода служит `protocol/`.
|
||||
|
||||
### `tests`
|
||||
|
||||
Автоматические и лабораторные тесты.
|
||||
Настоящие быстрые проверки `protocol/`. Выполняются за доли секунды, оборудования не требуют, запускаются через `pytest`.
|
||||
|
||||
### `tools`
|
||||
### `rover`, `ground_station`, `gnuradio`
|
||||
|
||||
Вспомогательные программы:
|
||||
|
||||
- анализ пакетов;
|
||||
- генераторы тестовых данных;
|
||||
- построение графиков;
|
||||
- анализ журналов.
|
||||
Каталоги созданы под будущую прикладную часть и пока пусты. Бортовая программа ровера, программа наземной станции и схемы GNU Radio появятся после того, как радиоканал заработает в эфире.
|
||||
|
||||
---
|
||||
|
||||
## 8. Принципы разработки
|
||||
## 9. Данные
|
||||
|
||||
В проекте применяются следующие правила:
|
||||
- `data/raw` — исходные данные. Крупные записи отсчётов и видео в репозиторий не помещаются, исходные изображения лабораторных хранятся.
|
||||
- `data/processed` — результаты лабораторных: CSV, отчёты и графики.
|
||||
|
||||
**Ограничение воспроизводимости.** Исходное видео `data/raw/lab026_rover_source.mp4` (около 7,8 МБ) в репозитории не хранится. Без него Lab026 и Lab027 не запускаются, файл нужно получить отдельно.
|
||||
|
||||
---
|
||||
|
||||
## 10. Принципы разработки
|
||||
|
||||
1. Каждый новый термин сначала объясняется, затем используется.
|
||||
2. Каждый этап должен завершаться работающим результатом.
|
||||
2. Каждый этап завершается работающим результатом.
|
||||
3. Радиоканал и пакетный протокол разрабатываются отдельно.
|
||||
4. Код проверяется автоматическими тестами.
|
||||
5. Все существенные решения фиксируются в документации.
|
||||
5. Все существенные решения фиксируются в журнале.
|
||||
6. Сложность увеличивается постепенно.
|
||||
7. Испытания по эфиру начинаются только после проверки через виртуальный канал.
|
||||
7. Испытания по эфиру начинаются только после проверки через модель.
|
||||
8. Передатчик сначала проверяется через аттенюатор или эквивалент нагрузки.
|
||||
9. **Каждое число в отчёте должно вычисляться из измерений текущего запуска.** Перенесённые из других лабораторных величины сопровождаются указанием источника. Вписанные вручную результаты недопустимы.
|
||||
|
||||
---
|
||||
|
||||
## 9. Текущий статус
|
||||
|
||||
Выполнено:
|
||||
|
||||
- установлен Python;
|
||||
- установлена Anaconda;
|
||||
- выбран Spyder;
|
||||
- установлен Git;
|
||||
- создан локальный Git-репозиторий;
|
||||
- создана базовая структура каталогов.
|
||||
|
||||
Следующая задача:
|
||||
|
||||
> Создать первый Python-модуль пакетного протокола и передать сообщение через виртуальный канал без SDR-оборудования.
|
||||
|
||||
---
|
||||
|
||||
## 10. Ближайший контрольный результат
|
||||
|
||||
Первая рабочая версия должна выполнять следующий цикл:
|
||||
|
||||
```text
|
||||
Текстовое сообщение
|
||||
↓
|
||||
Преобразование в байты
|
||||
↓
|
||||
Формирование пакета
|
||||
↓
|
||||
Добавление CRC
|
||||
↓
|
||||
Имитация радиоканала
|
||||
↓
|
||||
Проверка CRC
|
||||
↓
|
||||
Восстановление сообщения
|
||||
```
|
||||
|
||||
Первое тестовое сообщение:
|
||||
|
||||
```text
|
||||
HELLO SDR
|
||||
```
|
||||
Девятое правило добавлено после аудита достоверности, см. Запись 017 журнала.
|
||||
|
||||
---
|
||||
|
||||
@@ -337,4 +177,4 @@ HELLO SDR
|
||||
|
||||
НИЯУ МИФИ
|
||||
|
||||
2026
|
||||
2026
|
||||
|
||||
@@ -324,7 +324,7 @@ Lab041 — сеансы связи, безопасный запуск и защ
|
||||
Созданные файлы
|
||||
- protocol/control_messages.py
|
||||
- protocol/session_state.py
|
||||
- tests/lab041_session_restart_safety.py
|
||||
- experiments/lab041_session_restart_safety.py
|
||||
- data/processed/lab041/lab041_summary.csv
|
||||
- data/processed/lab041/lab041_session_metrics.csv
|
||||
- data/processed/lab041/lab041_rejection_metrics.csv
|
||||
|
||||
BIN
data/processed/lab042/lab042_constellation.png
Normal file
BIN
data/processed/lab042/lab042_constellation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
data/processed/lab042/lab042_fragment_delivery.png
Normal file
BIN
data/processed/lab042/lab042_fragment_delivery.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
data/processed/lab042/lab042_frame_correlation.png
Normal file
BIN
data/processed/lab042/lab042_frame_correlation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
11
data/processed/lab042/lab042_frames.csv
Normal file
11
data/processed/lab042/lab042_frames.csv
Normal file
@@ -0,0 +1,11 @@
|
||||
index,packet_bytes,frame_bits,correlation_score,sample_phase,carrier_offset_hz,carrier_coherence,cfo_correction_applied,error_vector_magnitude,frame_found,header_parsed,crc_valid,fragment_recovered
|
||||
0,536,4384,0.9991557547061216,19,-0.13990840791652204,0.9997935741467082,False,0.3400949142192574,True,True,True,True
|
||||
1,536,4384,0.9986269709852709,51,-0.6704420071784511,0.999254107740255,False,0.1962803864132724,True,True,True,True
|
||||
2,536,4384,0.9988503105697645,75,0.16739056727394086,0.9997145042457541,False,0.16392107712020013,True,True,True,True
|
||||
3,536,4384,0.9984613689137739,69,0.12541328438695132,0.9993958830571172,False,0.18678168516977972,True,True,True,True
|
||||
4,536,4384,0.9986165925127464,37,0.522370456657967,0.9996364225258064,False,0.15291774582164716,True,True,True,True
|
||||
5,536,4384,0.9988383991200348,32,-0.1940305233482036,0.9995177607884775,False,0.1768680391093208,True,True,True,True
|
||||
6,536,4384,0.9993940824417119,51,0.35917365385569155,0.9997260503953309,False,0.1442403116319193,True,True,True,True
|
||||
7,536,4384,0.9986343538268945,75,0.2939902862233339,0.9996675003381007,False,0.17829502789805413,True,True,True,True
|
||||
8,536,4384,0.9988608071024292,69,-0.7492708746413825,0.9995548745704333,False,0.15854008568562075,True,True,True,True
|
||||
9,172,1472,0.9984590339381546,60,0.5753674284248689,0.9992453603986811,False,0.232235822035248,True,True,True,True
|
||||
|
BIN
data/processed/lab042/lab042_received.jpg
Normal file
BIN
data/processed/lab042/lab042_received.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
85
data/processed/lab042/lab042_report.txt
Normal file
85
data/processed/lab042/lab042_report.txt
Normal file
@@ -0,0 +1,85 @@
|
||||
Lab042 — передача JPEG через кабельную петлю на одном PlutoSDR
|
||||
|
||||
Режим прогона
|
||||
- Канал: cable.
|
||||
- Трасса: кабель через аттенюатор 30 дБ.
|
||||
- Несущая 435.000 МГц, одинаковая на передачу и приём. Сигнал в эфир не выходит, поэтому её значение произвольно.
|
||||
- Усиление передатчика -10.0 дБ, приёмника 20.0 дБ, режим ручной.
|
||||
- Антенны не подключались: они создали бы путь распространения параллельно кабельному.
|
||||
|
||||
Параметры тракта
|
||||
- Символьная скорость 20000 симв/с, отсчётов на символ 128.
|
||||
- Частота дискретизации 2560000 Гц. Минимум устройства 2083333 Гц.
|
||||
- Скругление 0.35, длина фильтра 10 символов, защитный интервал 16 символов.
|
||||
- Значения перенесены из Lab018 без изменений, кроме передискретизации: она поднята с 32 до 128,
|
||||
потому что частота 640 000 Гц из Lab018 ниже предела устройства втрое.
|
||||
|
||||
Передаваемое изображение
|
||||
- Источник data/raw/lab011_scene.jpg, 320x180, качество 30.
|
||||
- Размер JPEG 4756 байт.
|
||||
- Фрагментов 10 по 512 байт данных.
|
||||
- Пакет протокола от 172 до 536 байт, радиокадр от 1472 до 4384 бит.
|
||||
- Последний фрагмент изображения короче остальных, поэтому его кадр меньше. Размер каждого кадра приведён в lab042_frames.csv.
|
||||
- Всего передано 5292544 отсчётов, что соответствует 2.067 с эфира.
|
||||
|
||||
Результат приёма
|
||||
- Кадров найдено 10 из 10.
|
||||
- Кадров с несошедшейся CRC32: 0.
|
||||
- Фрагментов восстановлено 10, доля 100.000%.
|
||||
- Изображение собрано: да.
|
||||
- Совпадает с переданным побайтово: да.
|
||||
- Размер принятого изображения 4756 байт.
|
||||
|
||||
Качество сигнала
|
||||
- Средняя нормированная корреляция при поиске кадра 0.99879.
|
||||
- Средняя оценка ухода несущей 0.029 Гц.
|
||||
- Средняя величина вектора ошибки 19.302%.
|
||||
|
||||
Скорость
|
||||
- Длительность сигнала в эфире 2.067 с.
|
||||
- Канальная скорость 18.404 кбит/с — полезные байты, делённые на время эфира.
|
||||
- Полное время работы программы 16.648 с, что даёт 2.285 кбит/с.
|
||||
- В программном режиме вторая величина отражает быстродействие вычислений, а не пропускную способность канала.
|
||||
|
||||
Кадры
|
||||
номер | корреляция | фаза | уход несущей, Гц | достоверность оценки | вектор ошибки, % | найден | заголовок | CRC32 | фрагмент
|
||||
0 | 0.99916 | 19 | -0.140 | 0.99979 | 34.009 | да | да | да | да
|
||||
1 | 0.99863 | 51 | -0.670 | 0.99925 | 19.628 | да | да | да | да
|
||||
2 | 0.99885 | 75 | 0.167 | 0.99971 | 16.392 | да | да | да | да
|
||||
3 | 0.99846 | 69 | 0.125 | 0.99940 | 18.678 | да | да | да | да
|
||||
4 | 0.99862 | 37 | 0.522 | 0.99964 | 15.292 | да | да | да | да
|
||||
5 | 0.99884 | 32 | -0.194 | 0.99952 | 17.687 | да | да | да | да
|
||||
6 | 0.99939 | 51 | 0.359 | 0.99973 | 14.424 | да | да | да | да
|
||||
7 | 0.99863 | 75 | 0.294 | 0.99967 | 17.830 | да | да | да | да
|
||||
8 | 0.99886 | 69 | -0.749 | 0.99955 | 15.854 | да | да | да | да
|
||||
9 | 0.99846 | 60 | 0.575 | 0.99925 | 23.224 | да | да | да | да
|
||||
|
||||
Функциональные проверки
|
||||
- ПРОЙДЕНО — 01. Размер подготовленного JPEG: JPEG занимает 4756 байт при пределе 5000, качество 30
|
||||
- ПРОЙДЕНО — 02. Разбиение и сборка изображения: 10 фрагментов собираются в исходные байты
|
||||
- ПРОЙДЕНО — 03. Кадр несёт пакет с CRC32: все 10 кадров несут пакет с сошедшейся CRC32
|
||||
- ПРОЙДЕНО — 04. Длина сформированного кадра: кадр занимает 566528 отсчётов, что совпадает с расчётом
|
||||
- ПРОЙДЕНО — 05. Масштабирование в целые отсчёты: пиковая амплитуда 1601 не превышает предела 16384
|
||||
- ПРОЙДЕНО — 06. Программная петля восстанавливает изображение: программная петля вернула 4756 байт, идентичных исходным
|
||||
- ПРОЙДЕНО — 07. Обнаружение искажённого кадра: одиночная битовая ошибка в данных кадра обнаружена контрольной суммой
|
||||
- ПРОЙДЕНО — 08. Потеря фрагмента: потеря фрагмента не портит остальные, сборка требует всех
|
||||
- ПРОЙДЕНО — 09. Достижимость частоты дискретизации: 2560000 Гц не ниже предела устройства 2083333 Гц
|
||||
- ПРОЙДЕНО — 10. Настройки устройства в пределах: усиления, полоса 200000 Гц и несущая 435000000 Гц внутри допустимых пределов, сигнал занимает 27000 Гц
|
||||
- ПРОЙДЕНО — 11. Потолок усиления в эфирном режиме: предел зависит от расстояния: -19.5 дБ на 5 см против 6.0 дБ на 200 см
|
||||
|
||||
Ограничения
|
||||
- Передатчик и приёмник принадлежат одному устройству и работают от общего опорного
|
||||
генератора, поэтому уход несущей между ними мал и не характеризует реальную линию.
|
||||
- Повторная передача потерянных фрагментов не выполняется: потери фиксируются, но не восстанавливаются.
|
||||
- Исправление стираний не подключалось.
|
||||
- Разведение на два устройства и выход в эфир — предмет Lab043 и Lab044.
|
||||
|
||||
Созданные файлы
|
||||
- data/processed/lab042/lab042_received.jpg
|
||||
- data/processed/lab042/lab042_summary.csv
|
||||
- data/processed/lab042/lab042_frames.csv
|
||||
- data/processed/lab042/lab042_report.txt
|
||||
- data/processed/lab042/lab042_constellation.png
|
||||
- data/processed/lab042/lab042_spectrum.png
|
||||
- data/processed/lab042/lab042_frame_correlation.png
|
||||
- data/processed/lab042/lab042_fragment_delivery.png
|
||||
BIN
data/processed/lab042/lab042_spectrum.png
Normal file
BIN
data/processed/lab042/lab042_spectrum.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
2
data/processed/lab042/lab042_summary.csv
Normal file
2
data/processed/lab042/lab042_summary.csv
Normal file
@@ -0,0 +1,2 @@
|
||||
loopback_mode,path_description,antenna_distance_cm,source_image,image_width,image_height,jpeg_quality,jpeg_bytes,fragment_data_size,fragment_count,packet_bytes_max,packet_bytes_min,frame_bits_max,frame_bits_min,symbol_rate,samples_per_symbol,sample_rate_hz,carrier_hz,transmitted_samples,air_time_seconds,frames_received,frames_failed_crc,fragments_recovered,fragment_delivery_fraction,image_reassembled,image_identical,received_image_bytes,transfer_seconds,channel_bitrate_kbps,wall_clock_bitrate_kbps,mean_correlation_score,mean_carrier_offset_hz,mean_error_vector_magnitude,tx_gain_db,rx_gain_db,attenuator_db
|
||||
cable,кабель через аттенюатор 30 дБ,0.0,data/raw/lab011_scene.jpg,320,180,30,4756,512,10,536,172,4384,1472,20000,128,2560000,435000000,5292544,2.0674,10,0,10,1.0,True,True,4756,16.64845450001303,18.40379220276676,2.285377300335609,0.9987897674116901,0.02900538637381942,0.193017509510432,-10.0,20.0,30.0
|
||||
|
@@ -22,16 +22,16 @@
|
||||
|
||||
| Звено | Где взять |
|
||||
|---|---|
|
||||
| Разбиение JPEG на фрагменты и сборка обратно | `protocol/image_fragments.py`, примеры в `tests/lab007_image_fragmentation.py` |
|
||||
| Разбиение JPEG на фрагменты и сборка обратно | `protocol/image_fragments.py`, примеры в `experiments/lab007_image_fragmentation.py` |
|
||||
| Пакет с CRC32, кодирование и разбор | `protocol/packet.py` |
|
||||
| Подбор размера и качества JPEG | `tests/lab010_image_optimization.py`, готовые профили в `data/processed/lab010` |
|
||||
| Формирование радиокадра с преамбулой и маркером | `build_radio_frame` в `tests/lab018_bpsk_radio_frame.py` |
|
||||
| Подбор размера и качества JPEG | `experiments/lab010_image_optimization.py`, готовые профили в `data/processed/lab010` |
|
||||
| Формирование радиокадра с преамбулой и маркером | `build_radio_frame` в `experiments/lab018_bpsk_radio_frame.py` |
|
||||
| Фильтр приподнятого косинуса | `root_raised_cosine_taps` там же |
|
||||
| Модуляция BPSK | `bpsk_modulate` там же |
|
||||
| Поиск кадра в потоке отсчётов | `find_radio_frame` в `tests/lab019_bpsk_receiver.py` |
|
||||
| Поиск кадра в потоке отсчётов | `find_radio_frame` в `experiments/lab019_bpsk_receiver.py` |
|
||||
| Оценка и коррекция ухода частоты | `estimate_carrier_parameters`, `correct_phase_and_frequency` там же |
|
||||
| Демодуляция и разбор кадра | `bpsk_demodulate`, `decode_radio_frame` там же |
|
||||
| Работа с Pluto на приём | `tests/lab024a_pluto_rx_capture.py` |
|
||||
| Работа с Pluto на приём | `experiments/lab024a_pluto_rx_capture.py` |
|
||||
|
||||
**Единственное, чего нет в проекте — передача.** Это и есть основной объём работы.
|
||||
|
||||
@@ -250,7 +250,7 @@
|
||||
- Параметры тракта из раздела 5 не менять.
|
||||
- Все константы вынести в начало файла с указанием источника, как это сделано в Lab041.
|
||||
- Никаких вписанных руками результатов: каждое число в отчёте должно вычисляться из измерений текущего запуска. Это отдельно проверяется при приёмке.
|
||||
- Файл лабораторной: `tests/lab042_pluto_image_loopback.py`, запуск через `python -m tests.lab042_pluto_image_loopback` из корня проекта.
|
||||
- Файл лабораторной: `experiments/lab042_pluto_image_loopback.py`, запуск через `python -m experiments.lab042_pluto_image_loopback` из корня проекта.
|
||||
|
||||
---
|
||||
|
||||
|
||||
521
docs/lab043_pluto_to_rtlsdr_spec.md
Normal file
521
docs/lab043_pluto_to_rtlsdr_spec.md
Normal file
@@ -0,0 +1,521 @@
|
||||
# Lab043. Передача JPEG с Pluto+ на независимый RTL-SDR
|
||||
|
||||
## 1. Цель лабораторной
|
||||
|
||||
Передать тот же подготовленный JPEG, который использован в Lab042, с
|
||||
Pluto+ на отдельный RTL-SDR и восстановить его побайтово.
|
||||
|
||||
В Lab042 передатчик и приёмник находились в одном Pluto+ и использовали
|
||||
общий опорный генератор. Lab043 должна убрать это упрощение и раздельно
|
||||
измерить три эффекта:
|
||||
|
||||
1. рассогласование несущих частот передатчика и приёмника;
|
||||
2. относительное рассогласование частот дискретизации двух устройств;
|
||||
3. начальную фазу выбора отсчёта символа внутри символьного интервала.
|
||||
|
||||
Лабораторная считается успешной только при выполнении радиокритериев,
|
||||
прикладных критериев и программных проверок, определённых в разделе 11.
|
||||
Запуск программы сам по себе успехом не считается.
|
||||
|
||||
## 2. Границы работы
|
||||
|
||||
Lab043 остаётся кабельной. Эфир добавил бы многолучёвость, внешние сигналы
|
||||
и неконтролируемые потери, хотя предмет этой работы состоит в
|
||||
синхронизации независимых устройств.
|
||||
|
||||
В Lab043 не входят:
|
||||
|
||||
- передача через антенны;
|
||||
- выбор рабочего диапазона 200-250 МГц;
|
||||
- ARQ и повторная передача;
|
||||
- исправление стираний и новая FEC;
|
||||
- потоковое видео;
|
||||
- скачки по частоте;
|
||||
- изменение логики управления и безопасности ровера.
|
||||
|
||||
Эфирный тракт остаётся предметом следующей лабораторной.
|
||||
|
||||
## 3. Подтверждённое оборудование и окружение
|
||||
|
||||
Состояние оборудования проверено непосредственным опросом. Перед
|
||||
реализацией и перед каждым аппаратным прогоном его необходимо проверить
|
||||
повторно.
|
||||
|
||||
### 3.1. Передатчик
|
||||
|
||||
- Pluto+;
|
||||
- устройство должно открываться успешным `iio.Context`, а не проверяться
|
||||
только командой `ping`;
|
||||
- чип AD9361;
|
||||
- используется первый передающий канал.
|
||||
|
||||
Конкретный рабочий URI IIO определяется повторным опросом перед опытом и
|
||||
сохраняется в метаданных запуска. Исторически работали сетевой URI
|
||||
`ip:192.168.2.1` и прямой USB IIO, но ни один из них нельзя считать
|
||||
доступным без текущей проверки.
|
||||
|
||||
### 3.2. Приёмник
|
||||
|
||||
- USB ID `VID_0BDA&PID_2838`;
|
||||
- имя библиотеки: `Generic RTL2832U OEM`;
|
||||
- тюнер, сообщённый библиотекой: Fitipower FC0013;
|
||||
- основной интерфейс `MI_00` использует WinUSB от libwdi;
|
||||
- устройство открыто из Python через `pyrtlsdr` и `librtlsdr`;
|
||||
- установка 435 МГц подтверждена чтением значения обратно;
|
||||
- частота дискретизации 2 400 000 отсчётов/с работает;
|
||||
- получены комплексные отсчёты;
|
||||
- доступные аппаратные усиления находятся в диапазоне от -9,9 до
|
||||
19,7 дБ и задаются дискретными ступенями устройства.
|
||||
|
||||
Ошибка второго USB-интерфейса не считается отказом SDR. Основной
|
||||
интерфейс `MI_00`, через который работает `librtlsdr`, исправен.
|
||||
|
||||
### 3.3. Зафиксированное программное окружение
|
||||
|
||||
- Python 3.13.1 x64;
|
||||
- `pyrtlsdr` 0.5.0;
|
||||
- `pyrtlsdrlib` 0.0.5;
|
||||
- поставляемая пакетом нативная `librtlsdr` v0.9.0;
|
||||
- `pyadi-iio` и `pylibiio` для Pluto+.
|
||||
|
||||
Принятый способ доступа к RTL-SDR: API `pyrtlsdr` с поставляемой
|
||||
`pyrtlsdrlib` нативной библиотекой. Малый собственный DLL-адаптер не
|
||||
нужен. Реализация должна проверять версии и выдавать понятную ошибку, если
|
||||
библиотека или устройство недоступны. Пользовательский путь к DLL
|
||||
зашивать в код запрещено.
|
||||
|
||||
## 4. Физическая схема и безопасность
|
||||
|
||||
Схема:
|
||||
|
||||
`Pluto+ TX -> SMA-кабель -> AT30S 30 дБ -> переходник/кабель -> RTL-SDR RX`
|
||||
|
||||
Обязательные условия перед каждым включением TX:
|
||||
|
||||
1. Антенны Pluto+ и RTL-SDR сняты.
|
||||
2. AT30S включён последовательно в тракт, а не подключён к свободному
|
||||
порту.
|
||||
3. Все разъёмы затянуты, кабель не отсоединяется при включённом TX.
|
||||
4. Пользователь отдельно подтверждает эту физическую схему.
|
||||
5. Первый заранее определённый рабочий режим: TX -30 дБ, RTL-SDR
|
||||
19,7 дБ, несущая 435 МГц, номинально 2,4 Мвыб/с на обоих устройствах.
|
||||
6. Автоматический перебор усилений, частот, порогов и способов
|
||||
синхронизации запрещён.
|
||||
7. Если первый режим не проходит, программа сохраняет исходные данные и
|
||||
диагностику и останавливается. Следующий прогон разрешён только после
|
||||
разбора причины и с изменением ровно одного заранее названного
|
||||
параметра.
|
||||
|
||||
При TX -30 дБ расчётный уровень после AT30S составляет около -53 дБм.
|
||||
Это значение следует из бюджета Lab042: около +7 дБм при TX 0 дБ,
|
||||
минус 30 дБ настройки TX и минус 30 дБ аттенюатора. Расчёт не заменяет
|
||||
проверку наличия аттенюатора и правильности схемы.
|
||||
|
||||
## 5. Номинальные параметры сигнала и обязательное чтение обратно
|
||||
|
||||
| Параметр | Запрошенное значение | Происхождение |
|
||||
|---|---:|---|
|
||||
| Несущая TX и RX | 435 МГц | Кабельная рабочая точка Lab042 |
|
||||
| Символьная скорость | 20 000 симв/с | Lab018-Lab023 и Lab042 |
|
||||
| Частота дискретизации TX | 2 400 000 отсчётов/с | Поддерживается Pluto+ и проверена на RTL-SDR |
|
||||
| Частота дискретизации RX | 2 400 000 отсчётов/с | Проверена непосредственным опросом RTL-SDR |
|
||||
| Номинальное число отсчётов на символ | 120 | 2 400 000 / 20 000 |
|
||||
| Полоса TX | 200 кГц | Минимальная полоса AD9361, использованная в Lab042 |
|
||||
| Размер данных фрагмента | 512 байт | Lab042 |
|
||||
| Модуляция | BPSK | Lab018-Lab023 и Lab042 |
|
||||
|
||||
Одинаковое номинальное значение 2,4 Мвыб/с выбрано специально. Lab043
|
||||
измеряет физическую ошибку генераторов, а не искусственно созданную
|
||||
разницу номинальных частот дискретизации.
|
||||
|
||||
Перед аппаратным опытом программа обязана установить и прочитать обратно:
|
||||
|
||||
- частоту дискретизации TX Pluto+;
|
||||
- частоту дискретизации RX RTL-SDR;
|
||||
- центральную частоту TX Pluto+;
|
||||
- центральную частоту RX RTL-SDR;
|
||||
- усиление TX Pluto+;
|
||||
- фактически выбранную ступень усиления RTL-SDR.
|
||||
|
||||
Для каждого параметра в отчёте и метаданных хранятся отдельные поля
|
||||
`requested` и `actual`. Если API не предоставляет независимого чтения
|
||||
обратно, поле `actual` имеет значение `N/A`, а не копию запрошенного
|
||||
значения. Передача не начинается, если частота дискретизации или несущая
|
||||
не прочитаны обратно либо отличаются от запрошенных больше, чем допускает
|
||||
API устройства и заранее установленная проверка.
|
||||
|
||||
## 6. Двухтоновая калибровка
|
||||
|
||||
Перед BPSK Pluto+ передаёт два комплексных тона, симметричных относительно
|
||||
несущей:
|
||||
|
||||
`-Fcal` и `+Fcal`, где `Fcal = 50 кГц`.
|
||||
|
||||
50 кГц выбраны до измерения: оба тона находятся внутри полосы 200 кГц,
|
||||
не сливаются около нуля и дают разнос 100 кГц для оценки масштаба частот.
|
||||
RTL-SDR измеряет положения двух пиков относительно своей настроенной
|
||||
несущей: `f_low` и `f_high`.
|
||||
|
||||
### 6.1. Рассогласование несущей
|
||||
|
||||
`carrier_offset_hz = (f_low + f_high) / 2`
|
||||
|
||||
`carrier_offset_ppm = carrier_offset_hz / 435000000 * 1e6`
|
||||
|
||||
Положительный результат означает, что принятый спектр сдвинут вверх по
|
||||
частоте. Для компенсации сырые комплексные отсчёты умножаются на:
|
||||
|
||||
`exp(-j * 2*pi*carrier_offset_hz*n/Fs_rx_actual)`
|
||||
|
||||
Знак поправки проверяется независимо: после компенсации среднее положение
|
||||
двух тонов должно стать ближе к нулю. Рассогласование несущей не должно
|
||||
подменяться оценкой ошибки частоты дискретизации.
|
||||
|
||||
### 6.2. Относительная ошибка частот дискретизации
|
||||
|
||||
`clock_scale = (f_high - f_low) / (2*Fcal)`
|
||||
|
||||
`sample_clock_error_ppm = (clock_scale - 1) * 1e6`
|
||||
|
||||
Здесь положительная ошибка означает, что масштаб передающего такта больше
|
||||
масштаба приёмного. Для перехода принятых отсчётов на временную сетку
|
||||
передатчика ожидаемая длина после передискретизации равна:
|
||||
|
||||
`len_corrected = round(len_received * clock_scale)`
|
||||
|
||||
Направление этой операции не принимается на веру. Оно подтверждается
|
||||
машинными тестами из раздела 8. После передискретизации накопленная ошибка
|
||||
символьного времени должна уменьшаться.
|
||||
|
||||
### 6.3. Начальная фаза выбора отсчёта символа
|
||||
|
||||
После грубой CFO-коррекции и передискретизации приёмник отдельно ищет
|
||||
начальную фазу выбора отсчёта в диапазоне от 0 до 119 номинальных
|
||||
отсчётов на символ. Это не CFO и не ошибка такта. В результатах отдельно
|
||||
хранятся:
|
||||
|
||||
- `carrier_offset_hz` и `carrier_offset_ppm`;
|
||||
- `clock_scale` и `sample_clock_error_ppm`;
|
||||
- `symbol_sample_phase` и остаточная ошибка символьного времени.
|
||||
|
||||
### 6.4. Формальное определение шумового фона
|
||||
|
||||
Шумовой фон и превышение пиков вычисляются одинаковым детерминированным
|
||||
алгоритмом:
|
||||
|
||||
1. Из комплексных отсчётов вычитается среднее.
|
||||
2. Оценивается спектральная плотность мощности методом Welch с окном Hann,
|
||||
`nfft = nperseg = 65536` и перекрытием 50 процентов.
|
||||
3. Разрешение одного БПФ вычисляется по прочитанной обратно частоте как
|
||||
`delta_f = Fs_rx_actual / 65536`. При 2,4 Мвыб/с оно равно
|
||||
`36,62109375 Гц`.
|
||||
4. Анализируется полоса от -100 до +100 кГц относительно настроенной
|
||||
несущей.
|
||||
5. Нижний пик ищется в диапазоне от -80 до -20 кГц, верхний от +20 до
|
||||
+80 кГц. Эти непересекающиеся окна заданы до измерения.
|
||||
6. После нахождения кандидатов ожидаемых тонов из оценки шума исключаются
|
||||
защитные зоны шириной +/-2 кГц вокруг измеренного положения каждого
|
||||
кандидата. Каждая полузона занимает около 55 разрешающих элементов БПФ
|
||||
при 2,4 Мвыб/с, поэтому утечка основного лепестка и ближайших боковых
|
||||
лепестков тона не попадает в медиану шума.
|
||||
7. Шумовая мощность `P_noise` равна медиане линейных значений мощности
|
||||
всех оставшихся спектральных элементов анализируемой полосы.
|
||||
8. Для каждого тона вычисляется
|
||||
`peak_excess_db = 10*log10(P_peak/P_noise)`.
|
||||
|
||||
Оба пика должны превышать шумовой фон не менее чем на 10 дБ. Порог 10 дБ,
|
||||
полоса анализа, окна поиска и защитные зоны зафиксированы до аппаратного
|
||||
измерения. Если нет двух конечных пиков, недостаточно элементов шума или
|
||||
хотя бы один пик не проходит порог, оценка этого захвата недействительна.
|
||||
|
||||
### 6.5. Повторы и представление недействительных оценок
|
||||
|
||||
Без изменения схемы выполняются три отдельных калибровочных захвата. Для
|
||||
каждого захвата отдельно сохраняются:
|
||||
|
||||
- `f_low_hz` и `f_high_hz`;
|
||||
- `low_peak_excess_db` и `high_peak_excess_db`;
|
||||
- `carrier_offset_hz` и `carrier_offset_ppm`;
|
||||
- `clock_scale` и `sample_clock_error_ppm`;
|
||||
- остаточная CFO после коррекции;
|
||||
- остаточная ошибка разноса тонов в герцах и ppm;
|
||||
- признак достоверности и причина отказа.
|
||||
|
||||
По трём захватам вычисляются среднее, минимум, максимум и стандартное
|
||||
отклонение каждой конечной оценки, а также число недействительных
|
||||
захватов. Стандартное отклонение считается по совокупности всех конечных
|
||||
оценок с `ddof=0`. Недействительное значение записывается как `NaN` в CSV
|
||||
и как `N/A` в текстовом отчёте. Оно никогда не заменяется нулём. Если
|
||||
конечных значений нет, все агрегаты этой величины равны `N/A`.
|
||||
|
||||
## 7. Диагностическая последовательность передачи
|
||||
|
||||
Аппаратная часть выполняется по ступеням. Переход к следующей ступени
|
||||
разрешён только после сохранения результатов предыдущей:
|
||||
|
||||
1. двухтоновые калибровочные захваты;
|
||||
2. известная короткая BPSK-последовательность;
|
||||
3. символьная синхронизация и измерение BER известной последовательности;
|
||||
4. один пакет с CRC;
|
||||
5. десять пакетированных фрагментов JPEG;
|
||||
6. сборка JPEG;
|
||||
7. проверка размера и SHA-256.
|
||||
|
||||
Известная последовательность задаётся до измерения как PRBS11 длиной
|
||||
2047 бит с полиномом `x^11 + x^9 + 1` и фиксированным ненулевым начальным
|
||||
состоянием, записанным в метаданные. BER вычисляется прямым сравнением
|
||||
каждого принятого бита с ожидаемым до упаковки в пакет и до проверки CRC.
|
||||
CRC не может использоваться вместо измерения BER.
|
||||
|
||||
### 7.1. Контрольный опыт PRBS11 после проверки непрерывного async-приёма
|
||||
|
||||
Короткая запись S и длинная запись L являются двумя отдельными аппаратными
|
||||
захватами. Каждый захват выполняется одним непрерывным
|
||||
`rtlsdr_read_async()` и содержит в этой последовательности: входной запас,
|
||||
двухтоновую калибровку, фиксированный защитный участок 0,10 с, один
|
||||
начальный маркер, непрерывную PRBS11 и выходной запас. Полезная PRBS11
|
||||
длится 0,25 с в S и не менее 2,0 с в L. Внутри полезной PRBS11 нет
|
||||
периодической повторной синхронизации.
|
||||
|
||||
Оценки CFO и масштаба частоты дискретизации для обработки PRBS11 берутся
|
||||
только из двух тонов в той же самой IQ-записи. Оценку из предыдущего
|
||||
двухтонового опыта или из другой записи применять запрещено. Если тоны в
|
||||
текущем захвате недостоверны, PRBS11 этой записи не интерпретируется.
|
||||
|
||||
Для обоих захватов заранее фиксируются 435 МГц, номинальные 2 400 000
|
||||
отсчётов/с TX и RX, 20 000 символов/с, TX -30 дБ, запрос усиления RTL-SDR
|
||||
19,7 дБ и `Fcal = 50 кГц`. Асинхронный приём использует callback 262144
|
||||
байта, 15 буферов и отбрасывает первый callback как разогревочный без
|
||||
перезапуска сеанса. Автоматический подбор параметров запрещён.
|
||||
|
||||
Одна и та же IQ-запись обрабатывается режимами A, B и C. В B и C грубая
|
||||
CFO берётся из тонов этой записи; в C дополнительно применяется
|
||||
`clock_scale` этих же тонов. Разрешённая защищённая тонкая CFO-коррекция не
|
||||
заменяет двухтоновое измерение. Для длинной записи SRO дополнительно
|
||||
оценивается независимо по накопленному дрейфу символьной фазы.
|
||||
|
||||
## 8. Обязательные программные проверки до аппаратного опыта
|
||||
|
||||
На синтетических комплексных данных проверяются четыре ошибки частоты
|
||||
дискретизации: +20, -20, +100 и -100 ppm. Для каждого случая тест обязан
|
||||
проверить:
|
||||
|
||||
- знак оценки `sample_clock_error_ppm`;
|
||||
- величину оценки в заранее заданном допуске;
|
||||
- направление изменения длины при передискретизации;
|
||||
- уменьшение остаточной накопленной ошибки символьного времени после
|
||||
поправки.
|
||||
|
||||
Для положительной ошибки длина должна изменяться в направлении,
|
||||
предписанном формулой `round(N * clock_scale)`, для отрицательной в
|
||||
противоположном. Тест должен упасть, если вместо `clock_scale` применена
|
||||
обратная величина. Допуск оценки и критерий уменьшения остаточной ошибки
|
||||
задаются в тесте до аппаратного измерения и печатаются в его результате.
|
||||
|
||||
Дополнительно синтетические тесты проверяют:
|
||||
|
||||
- знак грубой CFO-коррекции для положительной и отрицательной CFO;
|
||||
- уменьшение остаточной CFO после поправки;
|
||||
- отдельное восстановление начальной фазы выбора отсчёта символа;
|
||||
- явный `N/A`, если два тона не найдены достоверно;
|
||||
- формулу шумового фона и порог 10 дБ;
|
||||
- BER известной последовательности до CRC;
|
||||
- отсутствие ложного успеха при неполном пакете или изображении;
|
||||
- формирование кода возврата только по полному набору критериев.
|
||||
|
||||
До прохождения этих проверок передатчик включать нельзя.
|
||||
|
||||
## 9. Порядок аппаратного эксперимента
|
||||
|
||||
1. Открыть Pluto+ успешным `iio.Context` и записать URI и идентификаторы.
|
||||
2. Открыть RTL-SDR через `pyrtlsdr` и записать имя устройства и тюнера.
|
||||
3. Установить и прочитать обратно параметры из раздела 5.
|
||||
4. Получить отдельное подтверждение пользователя, что кабельная схема из
|
||||
раздела 4 собрана и AT30S находится в разрыве.
|
||||
5. Запустить один непрерывный приём RTL-SDR за 0,5 с до передачи Pluto+.
|
||||
6. Выполнить три отдельных двухтоновых калибровочных захвата.
|
||||
7. Сохранить оценки каждого захвата, агрегаты и не менее одного
|
||||
эталонного сырого IQ-захвата.
|
||||
8. Передать известную BPSK-последовательность и измерить BER.
|
||||
9. Принять один пакет и проверить CRC.
|
||||
10. Передать десять фрагментов JPEG и проверить критерии раздела 11.
|
||||
|
||||
Если любая ступень не проходит, переход к следующей запрещён. Исходные
|
||||
данные и отрицательный результат сохраняются без подгонки. Изменение
|
||||
одного параметра для следующего запуска оформляется как отдельное заранее
|
||||
объяснённое действие.
|
||||
|
||||
Весь опыт принимается RTL-SDR как одна непрерывная временная
|
||||
последовательность. Продолжительность приёма вычисляется из фактически
|
||||
сформированной передаваемой последовательности:
|
||||
|
||||
`T_rx = 0,5 с + N_tx / Fs_tx_actual + 0,5 с`.
|
||||
|
||||
Здесь `N_tx` является фактическим числом комплексных отсчётов всей
|
||||
сформированной TX-последовательности. Общая длительность опыта не задаётся
|
||||
произвольной константой. Поток разрешено читать блоками, например около
|
||||
262144 отсчётов, но блоки объединяются без пропусков и перестановок в одну
|
||||
последовательность. Размер блока чтения является параметром реализации, а
|
||||
не физическим защитным интервалом. Десять отдельных запусков приёмника для
|
||||
десяти кадров запрещены, потому что они уничтожили бы наблюдаемое
|
||||
накопление ошибки тактов.
|
||||
|
||||
Между BPSK-кадрами не добавляются новые интервалы. Используется структура
|
||||
Lab042: `GUARD_SYMBOL_COUNT` импортируется из её текущей конфигурации без
|
||||
копии числовой константы в Lab043, а формирование кадра повторяет ту же
|
||||
последовательность переднего интервала, символов, заднего интервала и RRC.
|
||||
Готовая функция Lab042 жёстко связана с 128 отсчётами на символ, поэтому
|
||||
при 120 отсчётах Lab043 переиспользуются её параметры, а не скрытая копия
|
||||
её глобальной частоты дискретизации. Если в дальнейшем отдельный интервал
|
||||
исчезнет из Lab042, Lab043 не создаёт новый без отдельного диагностического
|
||||
решения.
|
||||
|
||||
## 10. Три режима обработки одной записи
|
||||
|
||||
Одна и та же сохранённая BPSK/IQ-запись обрабатывается тремя режимами:
|
||||
|
||||
- режим A: без грубой CFO-коррекции и без коррекции частоты
|
||||
дискретизации;
|
||||
- режим B: только грубая CFO-коррекция из двухтоновой калибровки;
|
||||
- режим C: грубая CFO-коррекция, коррекция частоты дискретизации и
|
||||
защищённая тонкая CFO-коррекция.
|
||||
|
||||
Во всех режимах начальная фаза выбора отсчёта символа оценивается и
|
||||
записывается отдельно. Для каждого режима сохраняются BER известной
|
||||
последовательности, остаточная CFO, остаточная временная ошибка,
|
||||
корреляция маркера, число найденных кадров, число разобранных заголовков и
|
||||
CRC пакетов.
|
||||
|
||||
В режиме C защищённая функция `should_apply_cfo_correction` из
|
||||
`protocol/bpsk_radio.py` остаётся тонкой коррекцией после грубого
|
||||
измерения. Она не используется как единственный способ поиска сдвига в
|
||||
десятки килогерц, поскольку символьная оценка неоднозначна за пределами
|
||||
половины символьной скорости.
|
||||
|
||||
Провал режимов A или B не считается провалом лабораторной. Они являются
|
||||
контрольными измерениями. Критерии передачи применяются к режиму C.
|
||||
|
||||
## 11. Критерии приёмки
|
||||
|
||||
### 11.1. Радиокритерии режима C
|
||||
|
||||
- найдены все 10 кадров;
|
||||
- разобраны все 10 пакетов;
|
||||
- CRC32 сошлась у всех 10 пакетов.
|
||||
|
||||
### 11.2. Прикладные критерии режима C
|
||||
|
||||
- восстановлены все 10 уникальных фрагментов;
|
||||
- JPEG собран без заполнения отсутствующих данных;
|
||||
- размер принятого JPEG совпадает с размером переданного;
|
||||
- SHA-256 принятого JPEG совпадает с SHA-256 переданного.
|
||||
|
||||
### 11.3. Общий результат
|
||||
|
||||
Код возврата 0 разрешён только при одновременном выполнении:
|
||||
|
||||
- всех радиокритериев;
|
||||
- всех прикладных критериев;
|
||||
- всех обязательных программных проверок раздела 8.
|
||||
|
||||
Для подтверждения воспроизводимости заранее выбранная рабочая точка
|
||||
запускается три раза подряд без изменения схемы и параметров. Каждый
|
||||
повтор сохраняется отдельно. Три повтора являются проверкой
|
||||
воспроизводимости, а не статистической оценкой вероятности отказа.
|
||||
|
||||
Отрицательный результат допустим и сохраняется полностью. Запрещено
|
||||
автоматически поднимать усиление, менять частоту, пороги или алгоритм
|
||||
синхронизации после неудачного прогона.
|
||||
|
||||
## 12. Измерения и артефакты
|
||||
|
||||
### 12.1. Измерения передачи
|
||||
|
||||
Для каждого запуска и каждого режима обработки сохраняются:
|
||||
|
||||
- запрошенные и фактические усиления, несущие и частоты дискретизации;
|
||||
- число принятых отсчётов;
|
||||
- отдельные оценки CFO, ошибки частоты дискретизации и начальной
|
||||
символьной фазы;
|
||||
- BER известной последовательности до CRC;
|
||||
- число найденных кадров и разобранных заголовков;
|
||||
- CRC каждого пакета;
|
||||
- решение защищённой тонкой CFO-коррекции и его диагностические величины;
|
||||
- корреляция маркера и корректно определённая EVM;
|
||||
- число и номера восстановленных фрагментов;
|
||||
- размер и SHA-256 переданного и принятого JPEG;
|
||||
- длительность сигнала и полезная скорость.
|
||||
|
||||
Каждое число вычисляется из текущего запуска. Перенесённые константы
|
||||
снабжаются ссылкой на лабораторную-источник. Невычислимая величина
|
||||
записывается как `NaN` или `N/A`, но не как правдоподобный ноль.
|
||||
|
||||
### 12.2. План программных артефактов
|
||||
|
||||
Предполагаемые файлы после отдельного согласования реализации:
|
||||
|
||||
- `experiments/lab043_pluto_to_rtlsdr.py`;
|
||||
- `tests/test_lab043_calibration.py`;
|
||||
- `data/processed/lab043/lab043_calibration.csv`;
|
||||
- `data/processed/lab043/lab043_frames.csv`;
|
||||
- `data/processed/lab043/lab043_summary.csv`;
|
||||
- `data/processed/lab043/lab043_report.txt`;
|
||||
- графики калибровочного спектра, остаточной ошибки и доставки
|
||||
фрагментов.
|
||||
|
||||
### 12.3. Эталонный сырой IQ-захват
|
||||
|
||||
Не менее одного полного эталонного захвата до CFO- и SRO-коррекций
|
||||
сохраняется в `data/raw/lab043/` и не добавляется в Git.
|
||||
|
||||
Формат отсчётов: NumPy `.npy`, одномерный массив `complex64` в порядке
|
||||
приёма. Нормирование исходных 8-битных I/Q отсчётов и порядок I/Q должны
|
||||
быть однозначно описаны в метаданных. Рядом сохраняется UTF-8 JSON с тем
|
||||
же базовым именем и полями:
|
||||
|
||||
- локальная метка времени и UTC;
|
||||
- идентификаторы устройств и модель тюнера;
|
||||
- версии Python, `pyrtlsdr`, `pyrtlsdrlib`, `librtlsdr`, `pyadi-iio` и
|
||||
`pylibiio`;
|
||||
- запрошенные и фактические частоты дискретизации, несущие и усиления;
|
||||
- число отсчётов, длительность, dtype и порядок байтов;
|
||||
- идентификатор и SHA-256 переданной формы сигнала;
|
||||
- результаты калибровки, применённые к этой записи;
|
||||
- SHA-256 файла `.npy`.
|
||||
|
||||
IQ-файл содержит всю единую последовательность приёма. Ожидаемое число
|
||||
отсчётов вычисляется как `ceil(T_rx * Fs_rx_actual)`. Чтение выполняется
|
||||
последовательными блоками до достижения этого числа, последний блок
|
||||
обрезается только после получения требуемого количества отсчётов.
|
||||
|
||||
### 12.4. Разделение общего и лабораторного кода
|
||||
|
||||
Общие математические примитивы обнаружения двух известных тонов, оценки
|
||||
грубой CFO и относительной ошибки частот дискретизации, грубой частотной
|
||||
коррекции и передискретизации размещаются в `protocol/bpsk_radio.py` либо
|
||||
в другом подходящем существующем модуле `protocol/`. Каждый такой перенос
|
||||
сопровождается быстрым синтетическим тестом.
|
||||
|
||||
В `experiments/lab043_pluto_to_rtlsdr.py` остаются сценарий лабораторной,
|
||||
конкретное `Fcal`, три калибровочных захвата, методика оценки шума Lab043,
|
||||
режимы A/B/C, PRBS11, контрольный пакет, JPEG, отчётные артефакты,
|
||||
критерии приёмки и аппаратная последовательность. Общий код не зависит от
|
||||
конкретного JPEG и номера лабораторной.
|
||||
|
||||
## 13. Точка остановки перед аппаратным опытом
|
||||
|
||||
Программная реализация и синтетические проверки разрешены. Перед
|
||||
аппаратным опытом необходимо отдельно:
|
||||
|
||||
1. пройти все обязательные программные проверки раздела 8;
|
||||
2. повторно проверить доступность устройств и чтение параметров обратно;
|
||||
3. получить подтверждение пользователя о схеме и отдельное разрешение на
|
||||
включение TX.
|
||||
|
||||
Способ Python-доступа к RTL-SDR уже выбран и не является вопросом точки
|
||||
остановки.
|
||||
|
||||
До отдельного разрешения передатчик не включается, усиление Pluto+ не
|
||||
изменяется, аппаратная калибровка и передача тонов, BPSK или JPEG не
|
||||
выполняются. Операции `git add`, `commit`, `push`, `pull` и `fetch` также
|
||||
не выполняются.
|
||||
0
experiments/__init__.py
Normal file
0
experiments/__init__.py
Normal file
@@ -27,7 +27,7 @@ from protocol.video_packet import (
|
||||
PacketCRCError,
|
||||
VideoPacketError,
|
||||
)
|
||||
from tests.lab028_video_packetization import (
|
||||
from experiments.lab028_video_packetization import (
|
||||
EncodedComposite,
|
||||
SOURCE_VIDEO_PATH,
|
||||
VideoMetadata,
|
||||
@@ -32,14 +32,14 @@ from protocol.video_packet import (
|
||||
ObjectType,
|
||||
decode_packet,
|
||||
)
|
||||
from tests.lab028_video_packetization import (
|
||||
from experiments.lab028_video_packetization import (
|
||||
COMPOSITE_FPS,
|
||||
EncodedComposite,
|
||||
SOURCE_VIDEO_PATH,
|
||||
VideoMetadata,
|
||||
load_video_profile,
|
||||
)
|
||||
from tests.lab029_packet_channel_simulation import (
|
||||
from experiments.lab029_packet_channel_simulation import (
|
||||
PAYLOAD_SIZES,
|
||||
PreparedProfile,
|
||||
prepare_profiles,
|
||||
@@ -44,18 +44,18 @@ from protocol.video_packet import (
|
||||
ObjectType,
|
||||
decode_packet as decode_inner_packet,
|
||||
)
|
||||
from tests.lab028_video_packetization import (
|
||||
from experiments.lab028_video_packetization import (
|
||||
COMPOSITE_FPS,
|
||||
SOURCE_VIDEO_PATH,
|
||||
EncodedComposite,
|
||||
VideoMetadata,
|
||||
load_video_profile,
|
||||
)
|
||||
from tests.lab029_packet_channel_simulation import (
|
||||
from experiments.lab029_packet_channel_simulation import (
|
||||
PreparedProfile,
|
||||
prepare_profiles,
|
||||
)
|
||||
from tests.lab029b_time_based_burst_simulation import (
|
||||
from experiments.lab029b_time_based_burst_simulation import (
|
||||
BAD_TIME_FRACTION,
|
||||
CONTROL_STREAM_BITRATE_BPS,
|
||||
CONTROL_STREAM_BITRATE_KBPS,
|
||||
@@ -36,18 +36,18 @@ from protocol.video_packet import (
|
||||
CompositeReassembler,
|
||||
decode_packet as decode_inner_packet,
|
||||
)
|
||||
from tests.lab028_video_packetization import (
|
||||
from experiments.lab028_video_packetization import (
|
||||
COMPOSITE_FPS,
|
||||
SOURCE_VIDEO_PATH,
|
||||
EncodedComposite,
|
||||
VideoMetadata,
|
||||
load_video_profile,
|
||||
)
|
||||
from tests.lab029_packet_channel_simulation import (
|
||||
from experiments.lab029_packet_channel_simulation import (
|
||||
PreparedProfile,
|
||||
prepare_profiles,
|
||||
)
|
||||
from tests.lab029b_time_based_burst_simulation import (
|
||||
from experiments.lab029b_time_based_burst_simulation import (
|
||||
BAD_TIME_FRACTION,
|
||||
CONTROL_STREAM_BITRATE_BPS,
|
||||
CONTROL_STREAM_BITRATE_KBPS,
|
||||
@@ -55,7 +55,7 @@ from tests.lab029b_time_based_burst_simulation import (
|
||||
generate_bad_intervals,
|
||||
percentile,
|
||||
)
|
||||
from tests.lab030_packet_erasure_fec import (
|
||||
from experiments.lab030_packet_erasure_fec import (
|
||||
FECBlockPlan,
|
||||
FECMode,
|
||||
FunctionalTestResult,
|
||||
@@ -34,18 +34,18 @@ from protocol.video_packet import (
|
||||
CompositeReassembler,
|
||||
decode_packet as decode_inner_packet,
|
||||
)
|
||||
from tests.lab028_video_packetization import (
|
||||
from experiments.lab028_video_packetization import (
|
||||
COMPOSITE_FPS,
|
||||
SOURCE_VIDEO_PATH,
|
||||
EncodedComposite,
|
||||
VideoMetadata,
|
||||
load_video_profile,
|
||||
)
|
||||
from tests.lab029_packet_channel_simulation import (
|
||||
from experiments.lab029_packet_channel_simulation import (
|
||||
PreparedProfile,
|
||||
prepare_profiles,
|
||||
)
|
||||
from tests.lab029b_time_based_burst_simulation import (
|
||||
from experiments.lab029b_time_based_burst_simulation import (
|
||||
BAD_TIME_FRACTION,
|
||||
CONTROL_STREAM_BITRATE_BPS,
|
||||
CONTROL_STREAM_BITRATE_KBPS,
|
||||
@@ -53,7 +53,7 @@ from tests.lab029b_time_based_burst_simulation import (
|
||||
generate_bad_intervals,
|
||||
percentile,
|
||||
)
|
||||
from tests.lab030_packet_erasure_fec import (
|
||||
from experiments.lab030_packet_erasure_fec import (
|
||||
FECBlockPlan,
|
||||
FECMode,
|
||||
FunctionalTestResult,
|
||||
@@ -47,21 +47,21 @@ from protocol.video_packet import (
|
||||
CompositeReassembler,
|
||||
decode_packet as decode_inner_packet,
|
||||
)
|
||||
from tests.lab028_video_packetization import (
|
||||
from experiments.lab028_video_packetization import (
|
||||
COMPOSITE_FPS,
|
||||
SOURCE_VIDEO_PATH,
|
||||
EncodedComposite,
|
||||
VideoMetadata,
|
||||
load_video_profile,
|
||||
)
|
||||
from tests.lab029_packet_channel_simulation import prepare_profiles
|
||||
from tests.lab030_packet_erasure_fec import (
|
||||
from experiments.lab029_packet_channel_simulation import prepare_profiles
|
||||
from experiments.lab030_packet_erasure_fec import (
|
||||
FECBlockPlan,
|
||||
SourcePacket,
|
||||
TransmissionUnit,
|
||||
prepare_source_packets,
|
||||
)
|
||||
from tests.lab032_fec_parameter_sweep import (
|
||||
from experiments.lab032_fec_parameter_sweep import (
|
||||
SweepMode,
|
||||
build_parameter_units,
|
||||
)
|
||||
@@ -994,7 +994,7 @@ def write_report(
|
||||
"8. Созданные файлы и итоговый Git status",
|
||||
"- protocol/link_packet.py",
|
||||
"- protocol/priority_scheduler.py",
|
||||
"- tests/lab033_priority_channel_scheduler.py",
|
||||
"- experiments/lab033_priority_channel_scheduler.py",
|
||||
"- data/processed/lab033/lab033_summary.csv",
|
||||
"- data/processed/lab033/lab033_class_metrics.csv",
|
||||
"- data/processed/lab033/lab033_report.txt",
|
||||
@@ -34,10 +34,10 @@ from protocol.video_age_policy import (
|
||||
schedule_with_video_age,
|
||||
)
|
||||
from protocol.video_packet import CompositeReassembler, decode_packet as decode_inner_packet
|
||||
from tests.lab028_video_packetization import COMPOSITE_FPS
|
||||
from tests.lab029_packet_channel_simulation import prepare_profiles
|
||||
from tests.lab030_packet_erasure_fec import SourcePacket, prepare_source_packets
|
||||
from tests.lab033_priority_channel_scheduler import (
|
||||
from experiments.lab028_video_packetization import COMPOSITE_FPS
|
||||
from experiments.lab029_packet_channel_simulation import prepare_profiles
|
||||
from experiments.lab030_packet_erasure_fec import SourcePacket, prepare_source_packets
|
||||
from experiments.lab033_priority_channel_scheduler import (
|
||||
CHANNEL_RATES_KBPS,
|
||||
STREAM_VIDEO,
|
||||
VIDEO_PAYLOAD_SIZE,
|
||||
@@ -982,7 +982,7 @@ def write_report(lab033, continuous, aligned, results, tests):
|
||||
])
|
||||
lines.extend(f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}" for item in tests)
|
||||
lines.extend(["", "8. Созданные файлы"])
|
||||
lines.extend(f"- {path.as_posix()}" for path in (Path("protocol/video_age_policy.py"), Path("tests/lab034_stale_video_drop.py"), SUMMARY_CSV_PATH, VIDEO_CSV_PATH, CONTROL_CSV_PATH, REPORT_PATH, *PLOT_PATHS))
|
||||
lines.extend(f"- {path.as_posix()}" for path in (Path("protocol/video_age_policy.py"), Path("experiments/lab034_stale_video_drop.py"), SUMMARY_CSV_PATH, VIDEO_CSV_PATH, CONTROL_CSV_PATH, REPORT_PATH, *PLOT_PATHS))
|
||||
lines.extend([
|
||||
"",
|
||||
"9. Итоговый Git status",
|
||||
@@ -25,8 +25,8 @@ from protocol.video_frame_scheduler import (
|
||||
schedule_video_frames,
|
||||
)
|
||||
from protocol.video_packet import CompositeReassembler, decode_packet as decode_inner_packet
|
||||
from tests.lab028_video_packetization import COMPOSITE_FPS
|
||||
from tests.lab034_stale_video_drop import (
|
||||
from experiments.lab028_video_packetization import COMPOSITE_FPS
|
||||
from experiments.lab034_stale_video_drop import (
|
||||
PolicyDefinition as Lab034Policy,
|
||||
build_aligned_workload,
|
||||
build_lab033_workload,
|
||||
@@ -721,7 +721,7 @@ def write_report(aligned,results,tests):
|
||||
lines.extend(["","5. Интерпретация","- Реактивная Lab034 начинает кадр без гарантии завершения, затем удаляет остаток: уже переданные байты становятся бесполезными, а обновление не публикуется.","- Удаление до первого пакета исключает бесполезную передачу; обслуживание целыми кадрами гарантирует, что начатый кадр будет опубликован.","- Политика самого свежего уменьшает задержку ожидающих данных, но удаляет больше промежуточных кадров; очередь из двух кадров сохраняет больше последовательных обновлений ценой возраста.","- Прогноз полного завершения учитывает весь размер кадра и будущую периодическую высокоприоритетную нагрузку, поэтому полезнее проверки только текущего возраста.","- В модели точно известны команды 20 Гц, телеметрия 10 Гц и аварийная команда 10,0 с; неизвестные будущие дискретные события не моделируются и в реальной системе потребовали бы запаса.","- При устойчивой перегрузке невозможно одновременно сохранить все кадры, исходное JPEG-качество и малую задержку; требуется уменьшить частоту, качество или заранее пропускать кадры.","- Частота обновления, возраст изображения и длительность отсутствия нового изображения оцениваются одновременно: оптимизация одного показателя может ухудшить остальные.","","6. Допущения","- Ошибки и помехи отсутствуют; один общий абстрактный ресурс, форматы Lab028-Lab034 неизменны, активный пакет не прерывается.","- Прогноз не изменяет настоящую очередь; для допущенных кадров сохраняются только агрегированные ошибки, без подробного журнала.","- Политика автоматически не выбирается.","","7. Функциональные проверки"])
|
||||
lines.extend(f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}" for item in tests)
|
||||
lines.extend(["","8. Созданные файлы"])
|
||||
lines.extend(f"- {path.as_posix()}" for path in (Path("protocol/video_frame_scheduler.py"),Path("tests/lab035_video_frame_admission.py"),SUMMARY_CSV_PATH,VIDEO_CSV_PATH,CONTROL_CSV_PATH,PREDICTION_CSV_PATH,REPORT_PATH,*PLOT_PATHS))
|
||||
lines.extend(f"- {path.as_posix()}" for path in (Path("protocol/video_frame_scheduler.py"),Path("experiments/lab035_video_frame_admission.py"),SUMMARY_CSV_PATH,VIDEO_CSV_PATH,CONTROL_CSV_PATH,PREDICTION_CSV_PATH,REPORT_PATH,*PLOT_PATHS))
|
||||
lines.extend(["","9. Итоговый Git status","- Lab035 не добавлена в индекс и не закоммичена.","",git_status])
|
||||
REPORT_PATH.write_text("\n".join(lines)+"\n",encoding="utf-8")
|
||||
|
||||
@@ -31,12 +31,12 @@ from protocol.packet_erasure_fec import (
|
||||
from protocol.video_age_policy import AgePolicyPacket
|
||||
from protocol.video_frame_scheduler import FramePolicy, VideoFrameGroup
|
||||
from protocol.video_packet import decode_packet as decode_inner_packet
|
||||
from tests.lab028_video_packetization import (
|
||||
from experiments.lab028_video_packetization import (
|
||||
COMPOSITE_FPS,
|
||||
EncodedComposite,
|
||||
packets_for_composite,
|
||||
)
|
||||
from tests.lab033_priority_channel_scheduler import (
|
||||
from experiments.lab033_priority_channel_scheduler import (
|
||||
STREAM_CONTROL,
|
||||
STREAM_EMERGENCY,
|
||||
STREAM_TELEMETRY,
|
||||
@@ -44,7 +44,7 @@ from tests.lab033_priority_channel_scheduler import (
|
||||
build_workload as build_lab033_workload,
|
||||
deterministic_payload,
|
||||
)
|
||||
from tests.lab034_stale_video_drop import parity_for_partial
|
||||
from experiments.lab034_stale_video_drop import parity_for_partial
|
||||
|
||||
|
||||
OUTPUT_DIRECTORY = Path("data/processed/lab036")
|
||||
@@ -1318,7 +1318,7 @@ def write_report(
|
||||
f"- CRC-проверено общих пакетов: {workload.crc_packets_checked}; FEC-блоков: {workload.crc_blocks_checked}.",
|
||||
"",
|
||||
"7. Созданные файлы",
|
||||
"- tests/lab036_long_duration_scheduler.py",
|
||||
"- experiments/lab036_long_duration_scheduler.py",
|
||||
"- data/processed/lab036/lab036_summary.csv",
|
||||
"- data/processed/lab036/lab036_video_metrics.csv",
|
||||
"- data/processed/lab036/lab036_control_metrics.csv",
|
||||
@@ -42,8 +42,8 @@ from protocol.packet_erasure_fec import (
|
||||
from protocol.video_packet import decode_packet as decode_inner_packet
|
||||
from protocol.video_frame_scheduler import VideoFrameGroup
|
||||
from protocol.video_age_policy import AgePolicyPacket
|
||||
from tests.lab028_video_packetization import COMPOSITE_FPS, EncodedComposite, packets_for_composite
|
||||
from tests.lab033_priority_channel_scheduler import (
|
||||
from experiments.lab028_video_packetization import COMPOSITE_FPS, EncodedComposite, packets_for_composite
|
||||
from experiments.lab033_priority_channel_scheduler import (
|
||||
STREAM_CONTROL,
|
||||
STREAM_EMERGENCY,
|
||||
STREAM_TELEMETRY,
|
||||
@@ -51,7 +51,7 @@ from tests.lab033_priority_channel_scheduler import (
|
||||
build_workload as build_lab033_workload,
|
||||
deterministic_payload,
|
||||
)
|
||||
from tests.lab034_stale_video_drop import parity_for_partial
|
||||
from experiments.lab034_stale_video_drop import parity_for_partial
|
||||
|
||||
|
||||
OUTPUT_DIRECTORY = Path("data/processed/lab037")
|
||||
@@ -1186,13 +1186,13 @@ def write_report(
|
||||
"",
|
||||
"Созданные файлы",
|
||||
"- protocol/control_repetition.py",
|
||||
"- tests/lab037_lossy_full_link.py",
|
||||
"- experiments/lab037_lossy_full_link.py",
|
||||
*[f"- {path.as_posix()}" for path in (SUMMARY_CSV_PATH, COMMAND_CSV_PATH, VIDEO_CSV_PATH, EMERGENCY_CSV_PATH, REPORT_PATH, *PLOT_PATHS)],
|
||||
"",
|
||||
"Итоговый git status",
|
||||
"?? data/processed/lab037/",
|
||||
"?? protocol/control_repetition.py",
|
||||
"?? tests/lab037_lossy_full_link.py",
|
||||
"?? experiments/lab037_lossy_full_link.py",
|
||||
"",
|
||||
"Двоичные пакеты, JPEG, подробные пакетные журналы и дампы не сохранялись.",
|
||||
"Режим защиты автоматически не выбирается: все три режима представлены отдельно.",
|
||||
@@ -43,12 +43,12 @@ from protocol.packet_erasure_fec import (
|
||||
decode_outer_symbol,
|
||||
)
|
||||
from protocol.video_packet import decode_packet as decode_inner_packet
|
||||
from tests.lab033_priority_channel_scheduler import (
|
||||
from experiments.lab033_priority_channel_scheduler import (
|
||||
STREAM_CONTROL,
|
||||
STREAM_EMERGENCY,
|
||||
deterministic_payload,
|
||||
)
|
||||
from tests.lab037_lossy_full_link import (
|
||||
from experiments.lab037_lossy_full_link import (
|
||||
BAD_TIME_FRACTION,
|
||||
CHANNEL_RATES_KBPS,
|
||||
DURATION_SECONDS,
|
||||
@@ -1577,7 +1577,7 @@ def write_report(
|
||||
created = (
|
||||
Path("protocol/control_failsafe.py"),
|
||||
Path("protocol/emergency_ack.py"),
|
||||
Path("tests/lab038_control_failsafe.py"),
|
||||
Path("experiments/lab038_control_failsafe.py"),
|
||||
SUMMARY_CSV_PATH,
|
||||
COMMAND_CSV_PATH,
|
||||
EMERGENCY_CSV_PATH,
|
||||
@@ -1596,8 +1596,8 @@ def write_report(
|
||||
"?? protocol/control_failsafe.py",
|
||||
"?? protocol/control_repetition.py",
|
||||
"?? protocol/emergency_ack.py",
|
||||
"?? tests/lab037_lossy_full_link.py",
|
||||
"?? tests/lab038_control_failsafe.py",
|
||||
"?? experiments/lab037_lossy_full_link.py",
|
||||
"?? experiments/lab038_control_failsafe.py",
|
||||
"",
|
||||
"Lab038 не добавлен в Git и не закоммичен. Двоичные пакеты, JPEG, дампы и подробные пакетные журналы не сохранялись.",
|
||||
]
|
||||
@@ -24,9 +24,9 @@ from protocol.two_stage_failsafe import (
|
||||
forced_stop,
|
||||
integrate_kinematics,
|
||||
)
|
||||
from tests.lab033_priority_channel_scheduler import STREAM_EMERGENCY
|
||||
from tests.lab037_lossy_full_link import bad_intervals
|
||||
from tests.lab038_control_failsafe import (
|
||||
from experiments.lab033_priority_channel_scheduler import STREAM_EMERGENCY
|
||||
from experiments.lab037_lossy_full_link import bad_intervals
|
||||
from experiments.lab038_control_failsafe import (
|
||||
DURATION_SECONDS,
|
||||
EMERGENCY_SPREAD_US,
|
||||
MASTER_SEED,
|
||||
@@ -1776,7 +1776,7 @@ def write_report(
|
||||
lines.extend(f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}" for item in tests)
|
||||
created = (
|
||||
Path("protocol/two_stage_failsafe.py"),
|
||||
Path("tests/lab039_two_stage_braking.py"),
|
||||
Path("experiments/lab039_two_stage_braking.py"),
|
||||
SUMMARY_CSV_PATH,
|
||||
WATCHDOG_CSV_PATH,
|
||||
DISTANCE_CSV_PATH,
|
||||
@@ -37,9 +37,9 @@ from protocol.two_stage_failsafe import (
|
||||
TwoStageFailsafe,
|
||||
integrate_kinematics,
|
||||
)
|
||||
from tests.lab033_priority_channel_scheduler import STREAM_CONTROL, STREAM_EMERGENCY
|
||||
from tests.lab037_lossy_full_link import bad_intervals
|
||||
from tests.lab038_control_failsafe import DURATION_SECONDS, Frame, Unit, Workload, build_workload, packet_is_lost
|
||||
from experiments.lab033_priority_channel_scheduler import STREAM_CONTROL, STREAM_EMERGENCY
|
||||
from experiments.lab037_lossy_full_link import bad_intervals
|
||||
from experiments.lab038_control_failsafe import DURATION_SECONDS, Frame, Unit, Workload, build_workload, packet_is_lost
|
||||
|
||||
|
||||
OUTPUT_DIRECTORY = Path("data/processed/lab040")
|
||||
@@ -1650,7 +1650,7 @@ def write_report(
|
||||
created = (
|
||||
Path("protocol/persistent_emergency.py"),
|
||||
Path("protocol/safe_reset.py"),
|
||||
Path("tests/lab040_persistent_emergency.py"),
|
||||
Path("experiments/lab040_persistent_emergency.py"),
|
||||
SUMMARY_CSV,
|
||||
EMERGENCY_CSV,
|
||||
RESET_CSV,
|
||||
@@ -2,13 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import csv
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import IntEnum
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
import cv2
|
||||
@@ -94,7 +91,7 @@ VIDEO_OFFERED_LOAD_KBPS = 243.39286666666666
|
||||
TELEMETRY_OFFERED_LOAD_KBPS = 7.68
|
||||
|
||||
# Параметры торможения взяты из профиля "nominal" Lab039, а не подобраны заново.
|
||||
# tests/lab039_two_stage_braking.py: DecelerationProfile("nominal", 1.0, 3.0).
|
||||
# experiments/lab039_two_stage_braking.py: DecelerationProfile("nominal", 1.0, 3.0).
|
||||
# Оценка пути ниже одноступенчатая и потому консервативнее двухступенчатой
|
||||
# модели Lab039: она не учитывает предварительное замедление Stage1.
|
||||
LAB039_NOMINAL_STAGE2_MPS2 = 3.0
|
||||
@@ -1417,7 +1414,7 @@ def write_report(
|
||||
created = (
|
||||
Path("protocol/control_messages.py"),
|
||||
Path("protocol/session_state.py"),
|
||||
Path("tests/lab041_session_restart_safety.py"),
|
||||
Path("experiments/lab041_session_restart_safety.py"),
|
||||
SUMMARY_CSV,
|
||||
SESSION_CSV,
|
||||
REJECTION_CSV,
|
||||
@@ -1486,12 +1483,11 @@ def validate_outputs(
|
||||
assert not any(path.suffix.lower() in forbidden for path in actual)
|
||||
assert not any(path.name == "__pycache__" for path in OUTPUT_DIRECTORY.rglob("*"))
|
||||
|
||||
source = inspect.getsource(save_plots)
|
||||
tree = ast.parse(source)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in {"set_title", "set_xlabel", "set_ylabel"}:
|
||||
if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
|
||||
assert re.search("[А-Яа-яЁё]", node.args[0].value)
|
||||
# Прежде здесь лабораторная разбирала собственный исходный код, чтобы
|
||||
# убедиться, что подписи на графиках русские. Проверка снята: она
|
||||
# относится к оформлению, а не к корректности модели, и превращала
|
||||
# эксперимент в анализатор своего же текста. Подписи видны на готовых
|
||||
# PNG и проверяются при просмотре результатов.
|
||||
|
||||
|
||||
def main() -> None:
|
||||
1136
experiments/lab042_pluto_image_loopback.py
Normal file
1136
experiments/lab042_pluto_image_loopback.py
Normal file
File diff suppressed because it is too large
Load Diff
1832
experiments/lab043_pluto_to_rtlsdr.py
Normal file
1832
experiments/lab043_pluto_to_rtlsdr.py
Normal file
File diff suppressed because it is too large
Load Diff
485
experiments/lab043_prbs11_hardware.py
Normal file
485
experiments/lab043_prbs11_hardware.py
Normal file
@@ -0,0 +1,485 @@
|
||||
"""Один разрешённый аппаратный захват PRBS11 для Lab043.
|
||||
|
||||
Скрипт выполняет ровно один из заранее определённых опытов S или L. Он не
|
||||
подбирает параметры, не повторяет передачу при ошибке и не переходит к
|
||||
пакетам, CRC либо JPEG.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from experiments import lab043_pluto_to_rtlsdr as lab043
|
||||
|
||||
|
||||
PLUTO_URI = "ip:192.168.2.1"
|
||||
TX_GAIN_DB = -30.0
|
||||
RTL_GAIN_DB = 19.7
|
||||
RF_BANDWIDTH_HZ = 200_000
|
||||
CONTINUITY_MAX_PHASE_JUMP_RAD = 0.25
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapturedPrbsAnalysis:
|
||||
"""Полный результат обработки одного уже принятого S/L."""
|
||||
|
||||
processing_success: bool
|
||||
invalid_reason: str
|
||||
sections: dict
|
||||
calibration: lab043.CalibrationResult
|
||||
continuity: dict
|
||||
modes: dict[str, lab043.PrbsModeMetrics]
|
||||
|
||||
|
||||
def preflight_pluto() -> dict:
|
||||
"""Подтвердить IIO-контекст, PHY и первый TX-канал без передачи."""
|
||||
|
||||
import iio
|
||||
|
||||
context = iio.Context(PLUTO_URI)
|
||||
phy = context.find_device("ad9361-phy")
|
||||
tx_device = context.find_device("cf-ad9361-dds-core-lpc")
|
||||
tx_channel = tx_device.find_channel("voltage0", True) if tx_device is not None else None
|
||||
if phy is None:
|
||||
raise RuntimeError("IIO-контекст открыт, но ad9361-phy не найден")
|
||||
if tx_device is None or tx_channel is None:
|
||||
raise RuntimeError("IIO-контекст открыт, но первый TX-канал недоступен")
|
||||
return {
|
||||
"uri": PLUTO_URI,
|
||||
"context_opened": True,
|
||||
"phy_name": str(phy.name),
|
||||
"tx_device_name": str(tx_device.name),
|
||||
"first_tx_channel": str(tx_channel.id),
|
||||
}
|
||||
|
||||
|
||||
def _configure_rtlsdr(sdr) -> dict:
|
||||
sdr.sample_rate = float(lab043.SAMPLE_RATE_HZ)
|
||||
sdr.center_freq = float(lab043.CARRIER_HZ)
|
||||
sdr.gain = float(RTL_GAIN_DB)
|
||||
actual_sample_rate = float(sdr.sample_rate)
|
||||
actual_center = float(sdr.center_freq)
|
||||
raw_gain = float(sdr.gain)
|
||||
actual_gain = raw_gain if math.isclose(raw_gain, RTL_GAIN_DB, abs_tol=0.2) else None
|
||||
return {
|
||||
"requested_center_frequency_hz": float(lab043.CARRIER_HZ),
|
||||
"actual_center_frequency_hz": actual_center,
|
||||
"requested_sample_rate_hz": float(lab043.SAMPLE_RATE_HZ),
|
||||
"actual_sample_rate_hz": actual_sample_rate,
|
||||
"requested_gain_db": RTL_GAIN_DB,
|
||||
"raw_gain_readback_db": raw_gain,
|
||||
"actual_gain_db": actual_gain,
|
||||
}
|
||||
|
||||
|
||||
def preflight_rtlsdr() -> dict:
|
||||
"""Открыть RTL-SDR, прочитать короткий блок и закрыть до TX."""
|
||||
|
||||
from rtlsdr import RtlSdr
|
||||
|
||||
sdr = RtlSdr(device_index=0)
|
||||
try:
|
||||
parameters = _configure_rtlsdr(sdr)
|
||||
probe = np.asarray(sdr.read_samples(16_384), dtype=np.complex64)
|
||||
if len(probe) != 16_384 or not np.all(np.isfinite(probe)):
|
||||
raise RuntimeError("RTL-SDR не вернул полный конечный пробный блок")
|
||||
parameters["probe_sample_count"] = int(len(probe))
|
||||
parameters["probe_rms"] = float(np.sqrt(np.mean(np.abs(probe) ** 2)))
|
||||
return parameters
|
||||
finally:
|
||||
sdr.close()
|
||||
|
||||
|
||||
def configure_pluto() -> tuple[object, dict]:
|
||||
import adi
|
||||
|
||||
device = adi.Pluto(uri=PLUTO_URI)
|
||||
device.sample_rate = int(lab043.SAMPLE_RATE_HZ)
|
||||
device.tx_lo = int(lab043.CARRIER_HZ)
|
||||
device.tx_rf_bandwidth = int(RF_BANDWIDTH_HZ)
|
||||
device.tx_hardwaregain_chan0 = float(TX_GAIN_DB)
|
||||
device.tx_cyclic_buffer = False
|
||||
return device, {
|
||||
"requested_center_frequency_hz": int(lab043.CARRIER_HZ),
|
||||
"actual_center_frequency_hz": int(device.tx_lo),
|
||||
"requested_sample_rate_hz": int(lab043.SAMPLE_RATE_HZ),
|
||||
"actual_sample_rate_hz": int(device.sample_rate),
|
||||
"requested_gain_db": TX_GAIN_DB,
|
||||
"actual_gain_db": float(device.tx_hardwaregain_chan0),
|
||||
"requested_rf_bandwidth_hz": RF_BANDWIDTH_HZ,
|
||||
"actual_rf_bandwidth_hz": int(device.tx_rf_bandwidth),
|
||||
}
|
||||
|
||||
|
||||
def transmit_noncyclic_buffer_once(
|
||||
device,
|
||||
device_samples: np.ndarray,
|
||||
actual_sample_rate_hz: float,
|
||||
sleep_function=time.sleep,
|
||||
) -> float:
|
||||
"""Передать один нециклический буфер и не уничтожать его раньше времени.
|
||||
|
||||
В libiio v1 постановка блока в поток может завершиться до того, как DMA
|
||||
физически выведет все отсчёты. Поэтому буфер остаётся жив не меньше его
|
||||
расчётной длительности. Возвращаемое значение сохраняется в диагностике.
|
||||
"""
|
||||
|
||||
samples = np.asarray(device_samples, dtype=np.complex64)
|
||||
if samples.ndim != 1 or samples.size == 0:
|
||||
raise ValueError("TX-буфер должен быть непустым и одномерным")
|
||||
if not np.isfinite(actual_sample_rate_hz) or actual_sample_rate_hz <= 0.0:
|
||||
raise ValueError("Фактическая частота TX должна быть положительной")
|
||||
hold_seconds = len(samples) / actual_sample_rate_hz
|
||||
device.tx_destroy_buffer()
|
||||
device.tx(samples)
|
||||
sleep_function(hold_seconds)
|
||||
device.tx_destroy_buffer()
|
||||
return float(hold_seconds)
|
||||
|
||||
|
||||
def calibration_boundary_continuity(
|
||||
samples: np.ndarray,
|
||||
callback_boundaries,
|
||||
sections: dict,
|
||||
calibration: lab043.CalibrationResult,
|
||||
sample_rate_hz: float,
|
||||
) -> dict:
|
||||
"""Проверить фазу обоих тонов на callback-границах внутри калибровки."""
|
||||
|
||||
relevant = [
|
||||
int(row.accepted_end_sample)
|
||||
for row in callback_boundaries
|
||||
if sections["calibration_start_sample"] + 4_096
|
||||
<= row.accepted_end_sample
|
||||
<= sections["calibration_end_sample"] - 4_096
|
||||
]
|
||||
jumps: list[dict] = []
|
||||
for boundary in relevant:
|
||||
row = {"boundary_sample": boundary}
|
||||
for name, frequency_hz in (
|
||||
("low", calibration.f_low_hz),
|
||||
("high", calibration.f_high_hz),
|
||||
):
|
||||
before_indexes = np.arange(boundary - 4_096, boundary, dtype=np.float64)
|
||||
after_indexes = np.arange(boundary, boundary + 4_096, dtype=np.float64)
|
||||
before = np.mean(
|
||||
samples[boundary - 4_096 : boundary]
|
||||
* np.exp(-1j * 2.0 * np.pi * frequency_hz * before_indexes / sample_rate_hz)
|
||||
)
|
||||
after = np.mean(
|
||||
samples[boundary : boundary + 4_096]
|
||||
* np.exp(-1j * 2.0 * np.pi * frequency_hz * after_indexes / sample_rate_hz)
|
||||
)
|
||||
row[f"{name}_phase_jump_rad"] = float(np.angle(after * np.conj(before)))
|
||||
jumps.append(row)
|
||||
maximum = max(
|
||||
(
|
||||
abs(value)
|
||||
for row in jumps
|
||||
for key, value in row.items()
|
||||
if key.endswith("phase_jump_rad")
|
||||
),
|
||||
default=0.0,
|
||||
)
|
||||
return {
|
||||
"checked_boundary_count": len(relevant),
|
||||
"maximum_absolute_phase_jump_rad": float(maximum),
|
||||
"fixed_limit_rad": CONTINUITY_MAX_PHASE_JUMP_RAD,
|
||||
"confirmed_discontinuity_count": int(
|
||||
sum(
|
||||
max(abs(row["low_phase_jump_rad"]), abs(row["high_phase_jump_rad"]))
|
||||
> CONTINUITY_MAX_PHASE_JUMP_RAD
|
||||
for row in jumps
|
||||
)
|
||||
),
|
||||
"boundaries": jumps,
|
||||
}
|
||||
|
||||
|
||||
def analyze_captured_prbs(
|
||||
samples: np.ndarray,
|
||||
plan: lab043.PrbsTransmissionPlan,
|
||||
actual_rx_rate: float,
|
||||
actual_tx_rate: float,
|
||||
callback_boundaries=(),
|
||||
) -> CapturedPrbsAnalysis:
|
||||
"""Пройти тем же полным путём, что аппаратный S, но без обращения к SDR."""
|
||||
|
||||
calibration_samples, bpsk_samples, sections = lab043.split_calibration_and_bpsk(
|
||||
samples,
|
||||
plan,
|
||||
actual_rx_rate,
|
||||
actual_tx_rate,
|
||||
)
|
||||
calibration = lab043.estimate_refined_calibration(calibration_samples, actual_rx_rate)
|
||||
if not calibration.valid:
|
||||
reason = f"калибровка недостоверна: {calibration.invalid_reason}"
|
||||
modes = {
|
||||
mode: lab043._failed_prbs_metrics(mode, len(plan.payload_bits), reason)
|
||||
for mode in ("A", "B", "C", "D")
|
||||
}
|
||||
return CapturedPrbsAnalysis(
|
||||
processing_success=False,
|
||||
invalid_reason=reason,
|
||||
sections=sections,
|
||||
calibration=calibration,
|
||||
continuity={
|
||||
"checked_boundary_count": 0,
|
||||
"confirmed_discontinuity_count": 0,
|
||||
"reason": "тоны недостоверны",
|
||||
},
|
||||
modes=modes,
|
||||
)
|
||||
|
||||
continuity = calibration_boundary_continuity(
|
||||
np.asarray(samples, dtype=np.complex64),
|
||||
callback_boundaries,
|
||||
sections,
|
||||
calibration,
|
||||
actual_rx_rate,
|
||||
)
|
||||
if continuity["confirmed_discontinuity_count"]:
|
||||
reason = "в той же записи подтверждён фазовый разрыв"
|
||||
modes = {
|
||||
mode: lab043._failed_prbs_metrics(mode, len(plan.payload_bits), reason)
|
||||
for mode in ("A", "B", "C", "D")
|
||||
}
|
||||
return CapturedPrbsAnalysis(
|
||||
processing_success=False,
|
||||
invalid_reason=reason,
|
||||
sections=sections,
|
||||
calibration=calibration,
|
||||
continuity=continuity,
|
||||
modes=modes,
|
||||
)
|
||||
|
||||
modes = lab043.analyze_prbs_modes(
|
||||
bpsk_samples,
|
||||
plan.payload_bits,
|
||||
calibration.carrier_offset_hz,
|
||||
calibration.clock_scale,
|
||||
actual_rx_rate,
|
||||
known_pilot_bits=plan.pilot_bits,
|
||||
)
|
||||
mode_d = modes["D"]
|
||||
success = bool(mode_d.detected and mode_d.matched_bit_count == len(plan.payload_bits))
|
||||
reason = "" if success else (mode_d.failure_reason or "режим D не восстановил полную PRBS11")
|
||||
return CapturedPrbsAnalysis(
|
||||
processing_success=success,
|
||||
invalid_reason=reason,
|
||||
sections=sections,
|
||||
calibration=calibration,
|
||||
continuity=continuity,
|
||||
modes=modes,
|
||||
)
|
||||
|
||||
|
||||
def run_one_capture(label: str, output_directory: Path) -> dict:
|
||||
if label != "S":
|
||||
raise RuntimeError("На текущем этапе разрешён только один короткий опыт S")
|
||||
duration = (
|
||||
lab043.PRBS_SHORT_DURATION_SECONDS
|
||||
if label == "S"
|
||||
else lab043.PRBS_LONG_DURATION_SECONDS
|
||||
)
|
||||
plan = lab043.build_prbs11_transmission_plan(label, duration)
|
||||
capture_started = datetime.now(timezone.utc)
|
||||
|
||||
pluto_preflight = preflight_pluto()
|
||||
rtl_preflight = preflight_rtlsdr()
|
||||
time.sleep(0.25)
|
||||
|
||||
pluto, tx_parameters = configure_pluto()
|
||||
from rtlsdr import RtlSdr
|
||||
|
||||
sdr = RtlSdr(device_index=0)
|
||||
rx_parameters = _configure_rtlsdr(sdr)
|
||||
actual_tx_rate = float(tx_parameters["actual_sample_rate_hz"])
|
||||
actual_rx_rate = float(rx_parameters["actual_sample_rate_hz"])
|
||||
expected_rx_samples = lab043.receive_sample_count(
|
||||
len(plan.tx_samples),
|
||||
actual_tx_rate,
|
||||
actual_rx_rate,
|
||||
)
|
||||
|
||||
ready = threading.Event()
|
||||
tx_errors: list[str] = []
|
||||
tx_buffer_hold_seconds: list[float] = []
|
||||
|
||||
def transmit_once() -> None:
|
||||
if not ready.wait(timeout=10.0):
|
||||
tx_errors.append("RTL-SDR async-приём не подтвердил готовность")
|
||||
return
|
||||
time.sleep(lab043.RX_LEADING_MARGIN_SECONDS)
|
||||
try:
|
||||
hold_seconds = transmit_noncyclic_buffer_once(
|
||||
pluto,
|
||||
lab043.to_pluto_tx_samples(plan.tx_samples),
|
||||
actual_tx_rate,
|
||||
)
|
||||
tx_buffer_hold_seconds.append(hold_seconds)
|
||||
except Exception as error: # pragma: no cover - аппаратный путь
|
||||
tx_errors.append(f"{type(error).__name__}: {error}")
|
||||
|
||||
tx_thread = threading.Thread(target=transmit_once, daemon=True)
|
||||
tx_thread.start()
|
||||
samples, boundaries, async_diagnostics = lab043.capture_continuous_rtlsdr_async(
|
||||
sdr,
|
||||
expected_rx_samples,
|
||||
capture_ready_event=ready,
|
||||
buffer_bytes=lab043.RTL_ASYNC_BUFFER_BYTES,
|
||||
buffer_count=lab043.RTL_ASYNC_BUFFER_COUNT,
|
||||
warmup_callback_count=lab043.RTL_ASYNC_WARMUP_CALLBACK_COUNT,
|
||||
)
|
||||
tx_thread.join(timeout=15.0)
|
||||
if tx_thread.is_alive():
|
||||
raise RuntimeError("Поток единственной передачи не завершился")
|
||||
if tx_errors:
|
||||
raise RuntimeError("; ".join(tx_errors))
|
||||
|
||||
received = np.asarray(samples, dtype=np.complex64)
|
||||
if received.ndim != 1 or len(received) != expected_rx_samples:
|
||||
raise RuntimeError("RX завершён, но размер массива не совпал с ожидаемым")
|
||||
if not np.all(np.isfinite(received)):
|
||||
raise RuntimeError("RX завершён, но массив содержит нечисловые значения")
|
||||
boundary_rows = [asdict(row) for row in boundaries]
|
||||
waveform_sha256 = hashlib.sha256(
|
||||
np.asarray(plan.tx_samples, dtype=np.complex64).tobytes()
|
||||
).hexdigest()
|
||||
tx_waveform_duration_seconds = len(plan.tx_samples) / actual_tx_rate
|
||||
calibration_end_seconds = plan.calibration_sample_count / actual_tx_rate
|
||||
guard_end_seconds = plan.bpsk_start_sample / actual_tx_rate
|
||||
prbs_end_seconds = len(plan.tx_samples) / actual_tx_rate
|
||||
overload = {
|
||||
"peak_magnitude": float(np.max(np.abs(received))),
|
||||
"clipped_component_fraction": float(
|
||||
np.mean((np.abs(received.real) >= 0.999) | (np.abs(received.imag) >= 0.999))
|
||||
),
|
||||
}
|
||||
metadata = {
|
||||
"experiment": "Lab043 PRBS11 continuous async",
|
||||
"capture_label": label,
|
||||
"capture_started_utc": capture_started.isoformat().replace("+00:00", "Z"),
|
||||
"capture_order": 1 if label == "S" else 2,
|
||||
"capture_status": "captured",
|
||||
"processing_status": "pending",
|
||||
"processing_error": None,
|
||||
"actual_sample_rate_hz": actual_rx_rate,
|
||||
"center_frequency_hz": rx_parameters["actual_center_frequency_hz"],
|
||||
"rx_gain_db": rx_parameters["actual_gain_db"],
|
||||
"pluto_preflight": pluto_preflight,
|
||||
"rtl_preflight": rtl_preflight,
|
||||
"tx_parameters": tx_parameters,
|
||||
"rx_parameters": rx_parameters,
|
||||
"physical_scheme": "Pluto+ TX -> SMA -> AT30S 30 dB -> RTL-SDR RX; antennas removed",
|
||||
"waveform_sha256": waveform_sha256,
|
||||
"tx_waveform_duration_seconds": tx_waveform_duration_seconds,
|
||||
"callback_count": int(async_diagnostics["callback_count"]),
|
||||
"callback_boundaries": boundary_rows,
|
||||
"expected_intervals": {
|
||||
"relative_to_tx_start_seconds": {
|
||||
"calibration": [0.0, calibration_end_seconds],
|
||||
"guard": [calibration_end_seconds, guard_end_seconds],
|
||||
"bpsk_marker_pilot_prbs": [guard_end_seconds, prbs_end_seconds],
|
||||
},
|
||||
"rx_leading_margin_seconds": lab043.RX_LEADING_MARGIN_SECONDS,
|
||||
"rx_trailing_margin_seconds": lab043.RX_TRAILING_MARGIN_SECONDS,
|
||||
},
|
||||
"waveform": {
|
||||
"calibration_tones_hz": [-lab043.F_CAL_HZ, lab043.F_CAL_HZ],
|
||||
"calibration_duration_seconds": lab043.CALIBRATION_TONE_DURATION_SECONDS,
|
||||
"fixed_guard_seconds": lab043.CALIBRATION_TO_BPSK_GUARD_SECONDS,
|
||||
"single_initial_marker_bit_count": len(plan.marker_bits),
|
||||
"pilot_symbol_count": len(plan.pilot_bits),
|
||||
"pilot_duration_seconds": len(plan.pilot_bits) / lab043.SYMBOL_RATE,
|
||||
"pilot_polynomial": lab043.PILOT_POLYNOMIAL,
|
||||
"pilot_initial_state_hex": f"0x{lab043.PILOT_INITIAL_STATE:02X}",
|
||||
"pilot_sha256": hashlib.sha256(plan.pilot_bits.tobytes()).hexdigest(),
|
||||
"periodic_resynchronization": False,
|
||||
"prbs_polynomial": lab043.PRBS11_POLYNOMIAL,
|
||||
"prbs_initial_state_hex": f"0x{lab043.PRBS11_INITIAL_STATE:03X}",
|
||||
"prbs_useful_duration_seconds": plan.useful_duration_seconds,
|
||||
"transmitted_prbs_bit_count": len(plan.payload_bits),
|
||||
"transmitted_prbs_sha256": hashlib.sha256(plan.payload_bits.tobytes()).hexdigest(),
|
||||
"tx_sample_count": len(plan.tx_samples),
|
||||
"tx_samples_sha256": waveform_sha256,
|
||||
},
|
||||
"async_capture": async_diagnostics | {
|
||||
"expected_sample_count": expected_rx_samples,
|
||||
"callback_boundaries": boundary_rows,
|
||||
},
|
||||
"tx_buffer_hold_seconds": (
|
||||
tx_buffer_hold_seconds[0] if tx_buffer_hold_seconds else None
|
||||
),
|
||||
"overload": overload,
|
||||
}
|
||||
stamp = capture_started.strftime("%Y%m%d_%H%M%S")
|
||||
base = output_directory / f"lab043_prbs11_{label.lower()}_{stamp}"
|
||||
iq_path, json_path, analysis, processing_error = lab043.save_then_process_capture(
|
||||
base,
|
||||
received,
|
||||
metadata,
|
||||
lambda stored: analyze_captured_prbs(
|
||||
stored,
|
||||
plan,
|
||||
actual_rx_rate,
|
||||
actual_tx_rate,
|
||||
boundaries,
|
||||
),
|
||||
)
|
||||
if analysis is None:
|
||||
return {
|
||||
"iq_path": str(iq_path),
|
||||
"metadata_path": str(json_path),
|
||||
"capture_status": "captured",
|
||||
"processing_status": "processing_failed",
|
||||
"processing_error": processing_error,
|
||||
"tx_parameters": tx_parameters,
|
||||
"rx_parameters": rx_parameters,
|
||||
"async_capture": async_diagnostics,
|
||||
"overload": overload,
|
||||
}
|
||||
result = {
|
||||
"iq_path": str(iq_path),
|
||||
"metadata_path": str(json_path),
|
||||
"capture_status": "captured",
|
||||
"processing_status": "success",
|
||||
"processing_error": None,
|
||||
"processing_success": analysis.processing_success,
|
||||
"invalid_reason": analysis.invalid_reason,
|
||||
"calibration": asdict(analysis.calibration),
|
||||
"continuity": analysis.continuity,
|
||||
"modes": {mode: asdict(value) for mode, value in analysis.modes.items()},
|
||||
"tx_parameters": tx_parameters,
|
||||
"rx_parameters": rx_parameters,
|
||||
"async_capture": async_diagnostics,
|
||||
"overload": overload,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--label", choices=("S",), required=True)
|
||||
parser.add_argument("--output-directory", type=Path, default=Path("data/raw/lab043"))
|
||||
arguments = parser.parse_args()
|
||||
result = run_one_capture(arguments.label, arguments.output_directory)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
if result["processing_status"] != "success":
|
||||
return 2
|
||||
mode_d = result["modes"]["D"]
|
||||
return 0 if result["calibration"]["valid"] and mode_d["detected"] else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
1372
protocol/bpsk_radio.py
Normal file
1372
protocol/bpsk_radio.py
Normal file
File diff suppressed because it is too large
Load Diff
20
requirements-hardware.txt
Normal file
20
requirements-hardware.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
# SDR Rover Link — аппаратное окружение.
|
||||
#
|
||||
# Нужно только для лабораторных, работающих с PlutoSDR:
|
||||
# Lab024a, Lab024b, Lab025 и всё, начиная с Lab042.
|
||||
#
|
||||
# Устанавливается поверх расчётного окружения:
|
||||
# python -m pip install -r requirements.txt
|
||||
# python -m pip install -r requirements-hardware.txt
|
||||
#
|
||||
# Проверено 10 августа 2026 года на устройстве
|
||||
# Analog Devices PlutoSDR Rev.C (Z7010-AD9361), прошивка v0.32,
|
||||
# подключение по USB как RNDIS-адаптер, адрес ip:192.168.2.1.
|
||||
#
|
||||
# Замечание по диагностике: наличие связи с устройством проверяется
|
||||
# только успешным подключением через iio.Context. Команда ping и проверка
|
||||
# открытых портов недостоверны — при активном VPN трафик уходит в туннель
|
||||
# и создаёт видимость доступности несуществующего адреса.
|
||||
|
||||
pyadi-iio==0.0.21
|
||||
pylibiio==0.25
|
||||
21
requirements.txt
Normal file
21
requirements.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
# SDR Rover Link — расчётное окружение.
|
||||
#
|
||||
# Достаточно для запуска быстрых проверок (pytest), всех расчётных
|
||||
# лабораторных и построения графиков. Оборудование не требуется:
|
||||
# проект поднимается на компьютере без SDR.
|
||||
#
|
||||
# Установка:
|
||||
# python -m pip install -r requirements.txt
|
||||
#
|
||||
# Версии зафиксированы по фактически проверенному окружению
|
||||
# от 10 августа 2026 года, Python 3.13.1.
|
||||
#
|
||||
# Аппаратные лабораторные (Lab024, Lab025, Lab042 и далее) требуют
|
||||
# дополнительно requirements-hardware.txt.
|
||||
|
||||
numpy==2.2.6
|
||||
scipy==1.18.0
|
||||
matplotlib==3.11.1
|
||||
opencv-python==4.12.0
|
||||
Pillow==12.0.0
|
||||
pytest==9.1.1
|
||||
@@ -0,0 +1 @@
|
||||
"""Быстрые автоматические проверки ядра протокола."""
|
||||
|
||||
368
tests/test_bpsk_radio.py
Normal file
368
tests/test_bpsk_radio.py
Normal file
@@ -0,0 +1,368 @@
|
||||
"""Проверки protocol/bpsk_radio.py.
|
||||
|
||||
Модуль был собран переносом функций из Lab018, Lab019 и Lab023. Главная
|
||||
задача этих проверок — доказать, что перенос ничего не изменил: результаты
|
||||
сравниваются с оригиналами, загруженными из лабораторных в изоляции, без
|
||||
выполнения их модульного кода.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import math
|
||||
import pathlib
|
||||
import struct
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from protocol import bpsk_radio as radio
|
||||
from protocol.packet import MESSAGE_TYPE_IMAGE_FRAGMENT, build_packet, parse_packet
|
||||
|
||||
|
||||
PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
SHARED_CONSTANTS = {
|
||||
"RADIO_SYNC_WORD",
|
||||
"PREAMBLE_BIT_COUNT",
|
||||
"SYMBOL_RATE",
|
||||
"CFO_REFINEMENT_HALF_WIDTH_HZ",
|
||||
"CFO_REFINEMENT_STEP_HZ",
|
||||
}
|
||||
|
||||
|
||||
def _load_isolated(relative_path: str, function_names: set[str]) -> dict:
|
||||
"""Выполнить только заданные функции лабораторной, без кода модуля.
|
||||
|
||||
Лабораторные при импорте создают каталоги и пишут файлы, поэтому
|
||||
импортировать их в тестах нельзя.
|
||||
"""
|
||||
|
||||
path = PROJECT_ROOT / relative_path
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
kept = [
|
||||
node
|
||||
for node in tree.body
|
||||
if (isinstance(node, ast.FunctionDef) and node.name in function_names)
|
||||
or (isinstance(node, ast.Assign) and ast.unparse(node.targets[0]) in SHARED_CONSTANTS)
|
||||
]
|
||||
namespace: dict = {"np": np, "struct": struct}
|
||||
exec(compile(ast.Module(body=kept, type_ignores=[]), str(path), "exec"), namespace)
|
||||
return namespace
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def original_lab023() -> dict:
|
||||
return _load_isolated(
|
||||
"experiments/lab023_guarded_cfo_correction.py",
|
||||
{
|
||||
"bytes_to_bits",
|
||||
"bits_to_bytes",
|
||||
"bpsk_modulate",
|
||||
"bpsk_demodulate",
|
||||
"estimate_carrier_parameters",
|
||||
"correct_phase_and_frequency",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def original_lab019() -> dict:
|
||||
return _load_isolated(
|
||||
"experiments/lab019_bpsk_receiver.py",
|
||||
{
|
||||
"build_frame_marker",
|
||||
"find_radio_frame",
|
||||
"bytes_to_bits",
|
||||
"bits_to_bytes",
|
||||
"bpsk_modulate",
|
||||
"bpsk_demodulate",
|
||||
"root_raised_cosine_taps",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def original_lab018() -> dict:
|
||||
return _load_isolated("experiments/lab018_bpsk_radio_frame.py", {"root_raised_cosine_taps"})
|
||||
|
||||
|
||||
def _identical(left, right) -> bool:
|
||||
if isinstance(left, np.ndarray) or isinstance(right, np.ndarray):
|
||||
return np.array_equal(np.asarray(left), np.asarray(right))
|
||||
if isinstance(left, dict):
|
||||
return sorted(left) == sorted(right) and all(_identical(left[k], right[k]) for k in left)
|
||||
if isinstance(left, (tuple, list)):
|
||||
return len(left) == len(right) and all(_identical(a, b) for a, b in zip(left, right))
|
||||
return left == right
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- эквивалентность
|
||||
|
||||
|
||||
@pytest.mark.parametrize("length", [1, 7, 64, 255])
|
||||
def test_bit_conversion_matches_lab023(original_lab023: dict, length: int) -> None:
|
||||
data = bytes(np.random.default_rng(length).integers(0, 256, size=length, dtype=np.uint8))
|
||||
ours = radio.bytes_to_bits(data)
|
||||
assert _identical(ours, original_lab023["bytes_to_bits"](data))
|
||||
assert _identical(radio.bits_to_bytes(ours), original_lab023["bits_to_bytes"](ours))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("length", [8, 64, 256])
|
||||
def test_modulation_matches_lab023(original_lab023: dict, length: int) -> None:
|
||||
bits = np.random.default_rng(length).integers(0, 2, size=length).astype(np.uint8)
|
||||
ours = radio.bpsk_modulate(bits)
|
||||
assert _identical(ours, original_lab023["bpsk_modulate"](bits))
|
||||
assert _identical(radio.bpsk_demodulate(ours), original_lab023["bpsk_demodulate"](ours))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rolloff,sps,span", [(0.35, 32, 10), (0.35, 128, 10), (0.5, 64, 8)])
|
||||
def test_shaping_filter_matches_lab018(original_lab018: dict, rolloff: float, sps: int, span: int) -> None:
|
||||
assert _identical(
|
||||
radio.root_raised_cosine_taps(rolloff, sps, span),
|
||||
original_lab018["root_raised_cosine_taps"](rolloff, sps, span),
|
||||
)
|
||||
|
||||
|
||||
def test_frame_marker_matches_lab019(original_lab019: dict) -> None:
|
||||
"""Маркер возвращает пару: биты и символы. Совпасть должны обе."""
|
||||
|
||||
assert _identical(radio.build_frame_marker(), original_lab019["build_frame_marker"]())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seed", [1, 2, 3])
|
||||
def test_carrier_estimation_matches_lab023(original_lab023: dict, seed: int) -> None:
|
||||
"""Оценка ухода частоты обязана совпасть при произвольных фазе и уходе."""
|
||||
|
||||
rng = np.random.default_rng(seed)
|
||||
_, marker = radio.build_frame_marker()
|
||||
phase = float(rng.uniform(-3.0, 3.0))
|
||||
increment = float(rng.uniform(-0.005, 0.005))
|
||||
impaired = marker * np.exp(1j * (phase + increment * np.arange(len(marker))))
|
||||
impaired = impaired + 0.02 * rng.standard_normal(len(marker))
|
||||
|
||||
assert _identical(
|
||||
radio.estimate_carrier_parameters(impaired, marker),
|
||||
original_lab023["estimate_carrier_parameters"](impaired, marker),
|
||||
)
|
||||
assert _identical(
|
||||
radio.correct_phase_and_frequency(impaired, phase, increment),
|
||||
original_lab023["correct_phase_and_frequency"](impaired, phase, increment),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- новые функции кадра
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload_size", [1, 17, 512, radio.MAXIMUM_PROTOCOL_PACKET_BYTES])
|
||||
def test_frame_roundtrip(payload_size: int) -> None:
|
||||
packet = bytes(np.random.default_rng(payload_size).integers(0, 256, size=payload_size, dtype=np.uint8))
|
||||
bits, symbols, marker = radio.build_radio_frame(packet)
|
||||
|
||||
assert len(bits) == radio.radio_frame_bit_count(payload_size)
|
||||
assert len(symbols) == len(bits)
|
||||
assert len(marker) == radio.MARKER_BIT_COUNT
|
||||
assert radio.parse_radio_frame(bits) == packet
|
||||
|
||||
|
||||
def test_frame_rejects_sizes_outside_range() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
radio.build_radio_frame(b"")
|
||||
with pytest.raises(ValueError):
|
||||
radio.build_radio_frame(b"\x00" * (radio.MAXIMUM_PROTOCOL_PACKET_BYTES + 1))
|
||||
with pytest.raises(TypeError):
|
||||
radio.build_radio_frame("не байты")
|
||||
|
||||
|
||||
def test_frame_with_broken_sync_word_is_rejected() -> None:
|
||||
bits, _, _ = radio.build_radio_frame(b"payload")
|
||||
broken = bits.copy()
|
||||
broken[radio.PREAMBLE_BIT_COUNT] ^= 1
|
||||
assert radio.parse_radio_frame(broken) is None
|
||||
|
||||
|
||||
def test_truncated_frame_is_rejected() -> None:
|
||||
bits, _, _ = radio.build_radio_frame(b"payload")
|
||||
assert radio.parse_radio_frame(bits[:10]) is None
|
||||
assert radio.parse_radio_frame(bits[:-8]) is None
|
||||
|
||||
|
||||
def test_frame_carries_a_protocol_packet() -> None:
|
||||
"""Кадр должен переносить настоящий пакет так, чтобы CRC32 сошлась."""
|
||||
|
||||
packet = build_packet(b"fragment payload", MESSAGE_TYPE_IMAGE_FRAGMENT, 5)
|
||||
bits, _, _ = radio.build_radio_frame(packet)
|
||||
assert parse_packet(radio.parse_radio_frame(bits)).payload == b"fragment payload"
|
||||
|
||||
|
||||
def test_frame_search_finds_the_start_in_a_shaped_signal() -> None:
|
||||
"""Сквозная проверка: формирование, фильтрация и поиск кадра."""
|
||||
|
||||
samples_per_symbol = 32
|
||||
taps = radio.root_raised_cosine_taps(0.35, samples_per_symbol, 10)
|
||||
packet = build_packet(b"HELLO SDR", MESSAGE_TYPE_IMAGE_FRAGMENT, 1)
|
||||
bits, symbols, marker = radio.build_radio_frame(packet)
|
||||
|
||||
upsampled = np.zeros(len(symbols) * samples_per_symbol, dtype=complex)
|
||||
upsampled[::samples_per_symbol] = symbols
|
||||
matched = np.convolve(np.convolve(upsampled, taps, mode="full"), taps, mode="full")
|
||||
|
||||
found = radio.find_radio_frame(matched, marker, samples_per_symbol)
|
||||
start = found["start_symbol_index"]
|
||||
recovered = found["symbol_samples"][start : start + len(bits)]
|
||||
|
||||
assert radio.parse_radio_frame(radio.bpsk_demodulate(recovered)) == packet
|
||||
|
||||
|
||||
# ------------------------------------------------------ Lab043: общие примитивы
|
||||
|
||||
|
||||
def test_known_tone_peak_search_finds_both_windows() -> None:
|
||||
frequencies = np.linspace(-100_000.0, 100_000.0, 4001)
|
||||
powers = np.ones_like(frequencies)
|
||||
powers[np.argmin(np.abs(frequencies + 51_200.0))] = 100.0
|
||||
powers[np.argmin(np.abs(frequencies - 49_300.0))] = 80.0
|
||||
|
||||
peaks = radio.find_known_tone_peaks(frequencies, powers, 50_000.0, 30_000.0)
|
||||
|
||||
assert math.isclose(peaks["low_frequency_hz"], -51_200.0, abs_tol=1.0)
|
||||
assert math.isclose(peaks["high_frequency_hz"], 49_300.0, abs_tol=1.0)
|
||||
assert peaks["low_power"] == 100.0
|
||||
assert peaks["high_power"] == 80.0
|
||||
|
||||
|
||||
def test_known_tone_peak_search_returns_nan_without_bins() -> None:
|
||||
frequencies = np.linspace(-1_000.0, 1_000.0, 101)
|
||||
powers = np.ones_like(frequencies)
|
||||
peaks = radio.find_known_tone_peaks(frequencies, powers, 50_000.0, 1_000.0)
|
||||
|
||||
assert math.isnan(peaks["low_frequency_hz"])
|
||||
assert math.isnan(peaks["high_frequency_hz"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ppm", [20.0, -20.0, 100.0, -100.0])
|
||||
def test_two_tone_clock_estimate_has_correct_sign_and_magnitude(ppm: float) -> None:
|
||||
scale = 1.0 + ppm * 1e-6
|
||||
carrier_offset_hz = -1_250.0
|
||||
low = carrier_offset_hz - 50_000.0 * scale
|
||||
high = carrier_offset_hz + 50_000.0 * scale
|
||||
|
||||
estimate = radio.estimate_two_tone_offsets(low, high, 50_000.0)
|
||||
|
||||
assert math.isclose(estimate["carrier_offset_hz"], carrier_offset_hz, abs_tol=1e-9)
|
||||
assert math.isclose(estimate["clock_scale"], scale, abs_tol=1e-12)
|
||||
assert math.isclose(estimate["sample_clock_error_ppm"], ppm, abs_tol=1e-6)
|
||||
|
||||
|
||||
def test_two_tone_invalid_estimate_is_nan_not_zero() -> None:
|
||||
estimate = radio.estimate_two_tone_offsets(float("nan"), 50_000.0, 50_000.0)
|
||||
assert all(math.isnan(value) for value in estimate.values())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("carrier_offset_hz", [730.0, -730.0])
|
||||
def test_coarse_frequency_correction_handles_both_signs(carrier_offset_hz: float) -> None:
|
||||
sample_rate_hz = 20_000.0
|
||||
indexes = np.arange(20_000, dtype=np.float64)
|
||||
impaired = np.exp(1j * 2.0 * np.pi * carrier_offset_hz * indexes / sample_rate_hz)
|
||||
|
||||
corrected = radio.apply_coarse_frequency_correction(
|
||||
impaired,
|
||||
carrier_offset_hz,
|
||||
sample_rate_hz,
|
||||
)
|
||||
|
||||
assert np.max(np.abs(corrected - 1.0)) < 1e-9
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"carrier_offset_hz",
|
||||
[-10.0, -5.0, -2.0, -1.2, -1.0, -0.5, 0.5, 1.0, 1.2, 2.0, 5.0, 10.0],
|
||||
)
|
||||
def test_known_pilot_estimator_resolves_sub_hertz_cfo_with_hardware_like_phase_noise(
|
||||
carrier_offset_hz: float,
|
||||
) -> None:
|
||||
symbol_count = 1_280
|
||||
indexes = np.arange(symbol_count, dtype=np.float64)
|
||||
known = np.where((indexes.astype(np.int64) * 73 + 19) % 127 < 64, 1.0, -1.0).astype(
|
||||
np.complex128
|
||||
)
|
||||
estimates: list[float] = []
|
||||
valid_flags: list[bool] = []
|
||||
for repetition in range(96):
|
||||
random_generator = np.random.default_rng(
|
||||
43_000_000
|
||||
+ int(round((carrier_offset_hz + 20.0) * 1_000.0))
|
||||
+ repetition
|
||||
)
|
||||
phase_noise = random_generator.normal(0.0, 0.4691, symbol_count)
|
||||
received = known * np.exp(
|
||||
1j
|
||||
* (
|
||||
2.0 * np.pi * carrier_offset_hz * indexes / radio.SYMBOL_RATE
|
||||
+ phase_noise
|
||||
)
|
||||
)
|
||||
estimate = radio.estimate_known_pilot_carrier(received, known)
|
||||
valid_flags.append(estimate.valid)
|
||||
estimates.append(estimate.frequency_hz)
|
||||
|
||||
errors = np.asarray(estimates) - carrier_offset_hz
|
||||
assert all(valid_flags)
|
||||
assert np.all(np.sign(estimates) == np.sign(carrier_offset_hz))
|
||||
assert abs(float(np.mean(errors))) < 0.08
|
||||
assert float(np.std(errors, ddof=1)) < 0.18
|
||||
assert float(np.percentile(np.abs(errors), 95.0)) < 0.35
|
||||
|
||||
|
||||
def test_known_pilot_estimator_rejects_noise_instead_of_reporting_false_cfo() -> None:
|
||||
random_generator = np.random.default_rng(43_043)
|
||||
known = np.resize(np.asarray([-1.0, 1.0], dtype=np.complex128), 1_280)
|
||||
noise = (
|
||||
random_generator.normal(0.0, 1.0, len(known))
|
||||
+ 1j * random_generator.normal(0.0, 1.0, len(known))
|
||||
)
|
||||
|
||||
estimate = radio.estimate_known_pilot_carrier(noise, known)
|
||||
|
||||
assert not estimate.valid
|
||||
assert estimate.invalid_reason
|
||||
assert math.isnan(estimate.frequency_hz)
|
||||
assert math.isnan(estimate.phase_increment_rad_per_symbol)
|
||||
|
||||
|
||||
def _sample_at_positions(signal: np.ndarray, positions: np.ndarray) -> np.ndarray:
|
||||
indexes = np.arange(len(signal), dtype=np.float64)
|
||||
real = np.interp(positions, indexes, signal.real)
|
||||
imaginary = np.interp(positions, indexes, signal.imag)
|
||||
return real + 1j * imaginary
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ppm", [20.0, -20.0, 100.0, -100.0])
|
||||
def test_clock_resampling_direction_reduces_timing_error(ppm: float) -> None:
|
||||
scale = 1.0 + ppm * 1e-6
|
||||
sample_count = 200_000
|
||||
indexes = np.arange(sample_count, dtype=np.float64)
|
||||
reference = np.exp(1j * 2.0 * np.pi * 0.071 * indexes)
|
||||
|
||||
received_length = math.floor(sample_count / scale)
|
||||
received_positions = np.arange(received_length, dtype=np.float64) * scale
|
||||
received = _sample_at_positions(reference, received_positions)
|
||||
|
||||
corrected = radio.resample_for_clock_scale(received, scale)
|
||||
wrong_direction = radio.resample_for_clock_scale(received, 1.0 / scale)
|
||||
|
||||
uncorrected_count = min(len(received), len(reference))
|
||||
corrected_count = min(len(corrected), len(reference)) - 2
|
||||
wrong_count = min(len(wrong_direction), len(reference)) - 2
|
||||
uncorrected_error = float(
|
||||
np.mean(np.abs(received[:uncorrected_count] - reference[:uncorrected_count]) ** 2)
|
||||
)
|
||||
corrected_error = float(
|
||||
np.mean(np.abs(corrected[:corrected_count] - reference[:corrected_count]) ** 2)
|
||||
)
|
||||
wrong_error = float(
|
||||
np.mean(np.abs(wrong_direction[:wrong_count] - reference[:wrong_count]) ** 2)
|
||||
)
|
||||
|
||||
assert len(corrected) == round(len(received) * scale)
|
||||
assert corrected_error < uncorrected_error
|
||||
assert corrected_error < wrong_error
|
||||
155
tests/test_control_messages.py
Normal file
155
tests/test_control_messages.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Быстрые проверки формата управляющих сообщений: protocol/control_messages.py.
|
||||
|
||||
Перенесено из функциональных проверок Lab041, где эти свойства
|
||||
проверялись только при полном запуске лабораторной.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from protocol.control_messages import (
|
||||
COMMON_SIZE,
|
||||
ControlMessageError,
|
||||
ControlStateMessage,
|
||||
EmergencyAck,
|
||||
EmergencyStop,
|
||||
MessageContext,
|
||||
MessageType,
|
||||
MovementAuthorizeAck,
|
||||
MovementAuthorizeRequest,
|
||||
ResetAck,
|
||||
ResetRequest,
|
||||
RoverSafetyCode,
|
||||
SequenceComparison,
|
||||
SessionHello,
|
||||
SessionReject,
|
||||
SessionRejectReason,
|
||||
SessionStatus,
|
||||
compare_sequence,
|
||||
decode_message,
|
||||
encode_message,
|
||||
)
|
||||
|
||||
|
||||
CONTEXT = MessageContext(11, 22, 33, 44)
|
||||
|
||||
ALL_MESSAGES = (
|
||||
SessionHello(CONTEXT, True, 101),
|
||||
SessionStatus(CONTEXT, RoverSafetyCode.ROVER_SESSION_SYNCED_SAFE, False, 0),
|
||||
MovementAuthorizeRequest(CONTEXT, 201),
|
||||
MovementAuthorizeAck(CONTEXT, 201, True),
|
||||
ControlStateMessage(CONTEXT, 1.25, -0.5, False, True),
|
||||
EmergencyStop(CONTEXT, 301),
|
||||
EmergencyAck(CONTEXT, 301),
|
||||
ResetRequest(CONTEXT, 301, 401, 0.0, False),
|
||||
ResetAck(CONTEXT, 301, 401, True, SessionRejectReason.ACCEPTED),
|
||||
SessionReject(CONTEXT, MessageType.CONTROL_STATE, SessionRejectReason.CONTROL_EPOCH),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", ALL_MESSAGES, ids=lambda m: type(m).__name__)
|
||||
def test_roundtrip_is_exact(message) -> None:
|
||||
assert decode_message(encode_message(message)) == message
|
||||
|
||||
|
||||
def test_every_message_type_is_covered() -> None:
|
||||
"""Если добавится тип сообщения, этот тест обязан упасть."""
|
||||
|
||||
assert len(ALL_MESSAGES) == len(MessageType)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", ALL_MESSAGES, ids=lambda m: type(m).__name__)
|
||||
def test_encoding_starts_with_the_common_header(message) -> None:
|
||||
encoded = encode_message(message)
|
||||
assert len(encoded) >= COMMON_SIZE
|
||||
assert encoded[0] == 1
|
||||
|
||||
|
||||
def test_wrong_version_is_rejected() -> None:
|
||||
payload = bytearray(encode_message(SessionHello(CONTEXT, False, 0)))
|
||||
payload[0] = 2
|
||||
with pytest.raises(ControlMessageError):
|
||||
decode_message(bytes(payload))
|
||||
|
||||
|
||||
def test_unknown_type_is_rejected() -> None:
|
||||
payload = bytearray(encode_message(SessionHello(CONTEXT, False, 0)))
|
||||
payload[1] = 255
|
||||
with pytest.raises(ControlMessageError):
|
||||
decode_message(bytes(payload))
|
||||
|
||||
|
||||
def test_truncated_message_is_rejected() -> None:
|
||||
payload = encode_message(SessionHello(CONTEXT, False, 0))
|
||||
for cut in (0, 1, COMMON_SIZE - 1, len(payload) - 1):
|
||||
with pytest.raises(ControlMessageError):
|
||||
decode_message(payload[:cut])
|
||||
|
||||
|
||||
# ------------------------------------------------------- диапазоны команд движения
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"speed,turn,braking,permitted",
|
||||
[
|
||||
(15.1, 0.0, False, True), # выше предела
|
||||
(-0.1, 0.0, False, True), # отрицательная скорость
|
||||
(-5.0, 0.0, False, True),
|
||||
(1.0, 1.1, False, True), # поворот вне диапазона
|
||||
(1.0, -1.1, False, True),
|
||||
(1.0, 0.0, True, False), # торможение при ненулевой скорости
|
||||
(1.0, 0.0, False, False), # движение без разрешения
|
||||
],
|
||||
)
|
||||
def test_invalid_control_state_is_rejected(speed, turn, braking, permitted) -> None:
|
||||
with pytest.raises(ControlMessageError):
|
||||
encode_message(ControlStateMessage(CONTEXT, speed, turn, braking, permitted))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"speed,turn,braking,permitted",
|
||||
[
|
||||
(0.0, 0.0, True, False),
|
||||
(15.0, 0.0, False, True),
|
||||
(0.0, 0.0, False, False),
|
||||
(7.5, 1.0, False, True),
|
||||
(7.5, -1.0, False, True),
|
||||
],
|
||||
)
|
||||
def test_valid_control_state_is_accepted(speed, turn, braking, permitted) -> None:
|
||||
message = ControlStateMessage(CONTEXT, speed, turn, braking, permitted)
|
||||
assert decode_message(encode_message(message)) == message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
|
||||
def test_non_finite_speed_is_rejected(bad: float) -> None:
|
||||
with pytest.raises(ControlMessageError):
|
||||
encode_message(ControlStateMessage(CONTEXT, bad, 0.0, False, True))
|
||||
|
||||
|
||||
# ------------------------------------------------------- сравнение номеров сообщений
|
||||
|
||||
|
||||
def test_sequence_comparison_basic_order() -> None:
|
||||
assert compare_sequence(5, 4) is SequenceComparison.NEWER
|
||||
assert compare_sequence(4, 5) is SequenceComparison.NOT_NEWER
|
||||
assert compare_sequence(4, 4) is SequenceComparison.NOT_NEWER
|
||||
|
||||
|
||||
def test_sequence_comparison_wraps_at_32_bits() -> None:
|
||||
"""Переход через 0xFFFFFFFF обязан считаться новым, а не откатом."""
|
||||
|
||||
assert compare_sequence(0, 0xFFFFFFFF) is SequenceComparison.NEWER
|
||||
assert compare_sequence(1, 0xFFFFFFFE) is SequenceComparison.NEWER
|
||||
|
||||
|
||||
def test_ambiguous_half_range_is_rejected() -> None:
|
||||
"""Разность ровно 2^31 неотличима от отката и должна отклоняться."""
|
||||
|
||||
assert compare_sequence(0x80000000, 0) is SequenceComparison.AMBIGUOUS
|
||||
assert compare_sequence(0, 0x80000000) is SequenceComparison.AMBIGUOUS
|
||||
|
||||
|
||||
def test_just_below_the_ambiguous_boundary_is_newer() -> None:
|
||||
assert compare_sequence(0x7FFFFFFF, 0) is SequenceComparison.NEWER
|
||||
125
tests/test_lab042_reporting.py
Normal file
125
tests/test_lab042_reporting.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Проверки отчётности Lab042: неуспешный путь не должен маскироваться.
|
||||
|
||||
Три дефекта, найденные при разборе кабельного прогона, закрыты правками в
|
||||
experiments/lab042_pluto_image_loopback.py. Эти тесты фиксируют их, чтобы
|
||||
провал лаборатории нельзя было принять за успех:
|
||||
|
||||
1. EVM не занижается искусственно при слабом сигнале и не превращается в
|
||||
ноль, когда её нельзя вычислить;
|
||||
2. отсутствующее принятое изображение не объявляется созданным файлом;
|
||||
3. невыполнение критерия лаборатории даёт ненулевой код завершения.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from experiments.lab042_pluto_image_loopback import (
|
||||
RECEIVED_IMAGE_PATH,
|
||||
FunctionalTestResult,
|
||||
_error_vector_magnitude,
|
||||
created_files,
|
||||
transfer_meets_criteria,
|
||||
)
|
||||
|
||||
|
||||
def _reference(length: int = 512) -> np.ndarray:
|
||||
bits = np.random.default_rng(length).integers(0, 2, size=length).astype(np.float64)
|
||||
return (2.0 * bits - 1.0).astype(np.complex128)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ EVM
|
||||
|
||||
|
||||
def test_evm_is_near_zero_for_a_clean_signal() -> None:
|
||||
reference = _reference()
|
||||
assert _error_vector_magnitude(reference.copy(), reference) < 1e-9
|
||||
|
||||
|
||||
def test_evm_is_invariant_to_overall_gain() -> None:
|
||||
"""Одно и то же отношение сигнал/шум при разной амплитуде — одна EVM."""
|
||||
|
||||
rng = np.random.default_rng(1)
|
||||
reference = _reference()
|
||||
noise = 0.1 * (rng.standard_normal(len(reference)) + 1j * rng.standard_normal(len(reference)))
|
||||
|
||||
strong = _error_vector_magnitude(reference + noise, reference)
|
||||
weak = _error_vector_magnitude(0.001 * (reference + noise), reference)
|
||||
|
||||
assert math.isclose(strong, weak, rel_tol=1e-6)
|
||||
|
||||
|
||||
def test_evm_grows_when_the_signal_is_weak_and_noisy() -> None:
|
||||
"""Регрессия: прежняя нормировка на единичный эталон занижала EVM у
|
||||
|
||||
слабого сигнала почти до нуля. Теперь слабый шумный сигнал обязан дать
|
||||
заметную EVM, а не подделку под идеальный приём.
|
||||
"""
|
||||
|
||||
rng = np.random.default_rng(2)
|
||||
reference = _reference()
|
||||
noise = 0.5 * (rng.standard_normal(len(reference)) + 1j * rng.standard_normal(len(reference)))
|
||||
weak_noisy = 0.001 * (reference + noise)
|
||||
|
||||
assert _error_vector_magnitude(weak_noisy, reference) > 0.1
|
||||
|
||||
|
||||
def test_evm_is_not_a_number_when_it_cannot_be_computed() -> None:
|
||||
reference = _reference()
|
||||
assert math.isnan(_error_vector_magnitude(reference[:10], reference))
|
||||
assert math.isnan(_error_vector_magnitude(np.zeros(0, dtype=complex), np.zeros(0, dtype=complex)))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ состав файлов
|
||||
|
||||
|
||||
def _metrics(**overrides) -> SimpleNamespace:
|
||||
base = dict(
|
||||
image_reassembled=True,
|
||||
image_identical=True,
|
||||
fragments_recovered=10,
|
||||
fragment_count=10,
|
||||
)
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def test_received_image_listed_only_when_reassembled() -> None:
|
||||
with_image = created_files(_metrics(image_reassembled=True))
|
||||
without_image = created_files(_metrics(image_reassembled=False, image_identical=False))
|
||||
|
||||
assert RECEIVED_IMAGE_PATH in with_image
|
||||
assert RECEIVED_IMAGE_PATH not in without_image
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ критерий и код возврата
|
||||
|
||||
|
||||
def _all_passed() -> tuple[FunctionalTestResult, ...]:
|
||||
return tuple(FunctionalTestResult(f"{index:02d}", True, "ок") for index in range(11))
|
||||
|
||||
|
||||
def test_criteria_met_on_a_clean_transfer() -> None:
|
||||
assert transfer_meets_criteria(_metrics(), _all_passed()) is True
|
||||
|
||||
|
||||
def test_criteria_failed_when_image_differs() -> None:
|
||||
assert transfer_meets_criteria(_metrics(image_identical=False), _all_passed()) is False
|
||||
|
||||
|
||||
def test_criteria_failed_when_a_fragment_is_missing() -> None:
|
||||
metrics = _metrics(image_identical=False, fragments_recovered=9)
|
||||
assert transfer_meets_criteria(metrics, _all_passed()) is False
|
||||
|
||||
|
||||
def test_criteria_failed_when_a_functional_check_failed() -> None:
|
||||
tests = _all_passed()[:-1] + (FunctionalTestResult("bad", False, "провал"),)
|
||||
assert transfer_meets_criteria(_metrics(), tests) is False
|
||||
|
||||
|
||||
def test_criteria_failed_without_any_tests() -> None:
|
||||
assert transfer_meets_criteria(_metrics(), ()) is False
|
||||
891
tests/test_lab043_calibration.py
Normal file
891
tests/test_lab043_calibration.py
Normal file
@@ -0,0 +1,891 @@
|
||||
"""Быстрые синтетические проверки программной части Lab043."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy.signal import fftconvolve
|
||||
|
||||
from experiments import lab043_pluto_to_rtlsdr as lab043
|
||||
from experiments import lab043_prbs11_hardware as lab043_hardware
|
||||
from protocol import bpsk_radio as radio
|
||||
from protocol.image_fragments import split_image_bytes
|
||||
from protocol.packet import CRCError, MESSAGE_TYPE_TEXT, build_packet, parse_packet
|
||||
|
||||
|
||||
def test_receive_duration_is_derived_from_actual_transmit_length() -> None:
|
||||
assert lab043.receive_duration_seconds(4_800_000, 2_400_000.0) == 3.0
|
||||
assert lab043.receive_sample_count(4_800_000, 2_400_000.0, 2_399_900.0) == math.ceil(
|
||||
3.0 * 2_399_900.0
|
||||
)
|
||||
|
||||
|
||||
def test_continuous_blocks_are_joined_once_and_trimmed_at_end() -> None:
|
||||
blocks = (
|
||||
np.asarray([0, 1, 2], dtype=np.complex64),
|
||||
np.asarray([3, 4, 5], dtype=np.complex64),
|
||||
)
|
||||
combined = lab043.combine_continuous_blocks(blocks, expected_sample_count=5)
|
||||
assert np.array_equal(combined.real, np.arange(5))
|
||||
|
||||
|
||||
def test_short_continuous_capture_is_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="короче"):
|
||||
lab043.combine_continuous_blocks((np.zeros(9),), expected_sample_count=10)
|
||||
|
||||
|
||||
def test_async_collector_preserves_order_trims_target_and_records_boundaries() -> None:
|
||||
collector = lab043.ContinuousAsyncIqCollector(
|
||||
expected_sample_count=7,
|
||||
warmup_callback_count=1,
|
||||
)
|
||||
|
||||
assert not collector.add_callback(0, np.asarray([100, 101], dtype=complex))
|
||||
assert not collector.add_callback(1, np.asarray([0, 1, 2], dtype=complex))
|
||||
assert not collector.add_callback(2, np.asarray([3, 4, 5], dtype=complex))
|
||||
assert collector.add_callback(3, np.asarray([6, 7, 8], dtype=complex))
|
||||
|
||||
combined = collector.finalize()
|
||||
assert np.array_equal(combined.real, np.arange(7))
|
||||
assert len(combined) == 7
|
||||
|
||||
boundaries = collector.callback_boundaries
|
||||
assert [row.callback_index for row in boundaries] == [0, 1, 2, 3]
|
||||
assert [row.accepted_start_sample for row in boundaries] == [0, 0, 3, 6]
|
||||
assert [row.accepted_end_sample for row in boundaries] == [0, 3, 6, 7]
|
||||
assert boundaries[0].warmup_discarded
|
||||
assert boundaries[-1].trimmed_at_target
|
||||
assert boundaries[-1].source_sample_count == 3
|
||||
assert boundaries[-1].accepted_sample_count == 1
|
||||
|
||||
|
||||
def test_async_collector_rejects_duplicate_callback() -> None:
|
||||
collector = lab043.ContinuousAsyncIqCollector(4, warmup_callback_count=0)
|
||||
collector.add_callback(0, np.asarray([0, 1], dtype=complex))
|
||||
with pytest.raises(ValueError, match="продублирован"):
|
||||
collector.add_callback(0, np.asarray([2, 3], dtype=complex))
|
||||
|
||||
|
||||
def test_async_collector_rejects_missing_callback() -> None:
|
||||
collector = lab043.ContinuousAsyncIqCollector(4, warmup_callback_count=0)
|
||||
collector.add_callback(0, np.asarray([0, 1], dtype=complex))
|
||||
with pytest.raises(ValueError, match="пропуск"):
|
||||
collector.add_callback(2, np.asarray([2, 3], dtype=complex))
|
||||
|
||||
|
||||
def test_async_collector_rejects_early_finalize_and_extra_callback() -> None:
|
||||
collector = lab043.ContinuousAsyncIqCollector(2, warmup_callback_count=0)
|
||||
collector.add_callback(0, np.asarray([0], dtype=complex))
|
||||
with pytest.raises(RuntimeError, match="короче"):
|
||||
collector.finalize()
|
||||
assert collector.add_callback(1, np.asarray([1], dtype=complex))
|
||||
with pytest.raises(RuntimeError, match="уже набрал"):
|
||||
collector.add_callback(2, np.asarray([2], dtype=complex))
|
||||
|
||||
|
||||
def test_frame_concatenation_adds_no_lab043_gap() -> None:
|
||||
first = lab043.shape_frame_like_lab042(np.ones(16, dtype=complex), samples_per_symbol=8)
|
||||
second = lab043.shape_frame_like_lab042(-np.ones(12, dtype=complex), samples_per_symbol=8)
|
||||
continuous = lab043.concatenate_frames_without_new_gaps((first, second))
|
||||
assert len(continuous) == len(first) + len(second)
|
||||
assert np.array_equal(continuous[: len(first)], first)
|
||||
assert np.array_equal(continuous[len(first) :], second)
|
||||
|
||||
|
||||
def test_spectrum_method_finds_two_strong_tones_and_cfo() -> None:
|
||||
sample_rate_hz = 2_400_000.0
|
||||
sample_count = 2 * lab043.CALIBRATION_NFFT
|
||||
indexes = np.arange(sample_count, dtype=np.float64)
|
||||
carrier_offset_hz = 1_250.0
|
||||
clock_scale = 1.0 + 100.0e-6
|
||||
low_hz = carrier_offset_hz - lab043.F_CAL_HZ * clock_scale
|
||||
high_hz = carrier_offset_hz + lab043.F_CAL_HZ * clock_scale
|
||||
random_generator = np.random.default_rng(43)
|
||||
samples = (
|
||||
np.exp(1j * 2.0 * np.pi * low_hz * indexes / sample_rate_hz)
|
||||
+ 0.8 * np.exp(1j * 2.0 * np.pi * high_hz * indexes / sample_rate_hz)
|
||||
+ 0.002
|
||||
* (
|
||||
random_generator.standard_normal(sample_count)
|
||||
+ 1j * random_generator.standard_normal(sample_count)
|
||||
)
|
||||
)
|
||||
|
||||
estimate = lab043.estimate_calibration(samples, sample_rate_hz)
|
||||
|
||||
assert estimate.valid
|
||||
assert estimate.low_peak_excess_db > 10.0
|
||||
assert estimate.high_peak_excess_db > 10.0
|
||||
assert math.isclose(estimate.carrier_offset_hz, carrier_offset_hz, abs_tol=5.0)
|
||||
assert math.isclose(estimate.sample_clock_error_ppm, 100.0, abs_tol=60.0)
|
||||
|
||||
|
||||
def _synthetic_two_tones(
|
||||
low_hz: float,
|
||||
high_hz: float,
|
||||
seed: int,
|
||||
) -> tuple[np.ndarray, float]:
|
||||
sample_rate_hz = 240_000.0
|
||||
sample_count = 120_000
|
||||
indexes = np.arange(sample_count, dtype=np.float64)
|
||||
random_generator = np.random.default_rng(seed)
|
||||
samples = (
|
||||
np.exp(1j * 2.0 * np.pi * low_hz * indexes / sample_rate_hz)
|
||||
+ 0.8 * np.exp(1j * 2.0 * np.pi * high_hz * indexes / sample_rate_hz)
|
||||
+ 0.003
|
||||
* (
|
||||
random_generator.standard_normal(sample_count)
|
||||
+ 1j * random_generator.standard_normal(sample_count)
|
||||
)
|
||||
)
|
||||
return samples, sample_rate_hz
|
||||
|
||||
|
||||
def _phase_refinement_from_spectrum(
|
||||
samples: np.ndarray,
|
||||
sample_rate_hz: float,
|
||||
) -> tuple[lab043.CalibrationResult, radio.TwoTonePhaseRefinement]:
|
||||
spectrum_estimate = lab043.estimate_calibration(samples, sample_rate_hz)
|
||||
assert spectrum_estimate.valid
|
||||
phase_estimate = radio.refine_two_tone_frequencies_from_phase(
|
||||
samples,
|
||||
sample_rate_hz,
|
||||
spectrum_estimate.f_low_hz,
|
||||
spectrum_estimate.f_high_hz,
|
||||
block_samples=240,
|
||||
hop_samples=120,
|
||||
)
|
||||
return spectrum_estimate, phase_estimate
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tone_shift_hz",
|
||||
[-1.0, -0.5, -0.25, -0.1, 0.1, 0.25, 0.5, 1.0],
|
||||
)
|
||||
def test_phase_refinement_improves_known_sub_hertz_common_shift(
|
||||
tone_shift_hz: float,
|
||||
) -> None:
|
||||
true_low_hz = -lab043.F_CAL_HZ + tone_shift_hz
|
||||
true_high_hz = lab043.F_CAL_HZ + tone_shift_hz
|
||||
samples, sample_rate_hz = _synthetic_two_tones(
|
||||
true_low_hz,
|
||||
true_high_hz,
|
||||
seed=43_000 + int((tone_shift_hz + 2.0) * 100),
|
||||
)
|
||||
|
||||
old, refined = _phase_refinement_from_spectrum(samples, sample_rate_hz)
|
||||
old_max_error_hz = max(
|
||||
abs(old.f_low_hz - true_low_hz),
|
||||
abs(old.f_high_hz - true_high_hz),
|
||||
)
|
||||
refined_max_error_hz = max(
|
||||
abs(refined.low_frequency_hz - true_low_hz),
|
||||
abs(refined.high_frequency_hz - true_high_hz),
|
||||
)
|
||||
|
||||
assert refined_max_error_hz < 0.001
|
||||
assert refined_max_error_hz < old_max_error_hz
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"half_spacing_shift_hz",
|
||||
[-1.0, -0.5, -0.25, -0.1, 0.1, 0.25, 0.5, 1.0],
|
||||
)
|
||||
def test_phase_refinement_improves_known_sub_hertz_spacing_shift(
|
||||
half_spacing_shift_hz: float,
|
||||
) -> None:
|
||||
true_low_hz = -lab043.F_CAL_HZ - half_spacing_shift_hz
|
||||
true_high_hz = lab043.F_CAL_HZ + half_spacing_shift_hz
|
||||
true_offsets = radio.estimate_two_tone_offsets(
|
||||
true_low_hz,
|
||||
true_high_hz,
|
||||
lab043.F_CAL_HZ,
|
||||
)
|
||||
samples, sample_rate_hz = _synthetic_two_tones(
|
||||
true_low_hz,
|
||||
true_high_hz,
|
||||
seed=44_000 + int((half_spacing_shift_hz + 2.0) * 100),
|
||||
)
|
||||
|
||||
old, refined = _phase_refinement_from_spectrum(samples, sample_rate_hz)
|
||||
refined_offsets = radio.estimate_two_tone_offsets(
|
||||
refined.low_frequency_hz,
|
||||
refined.high_frequency_hz,
|
||||
lab043.F_CAL_HZ,
|
||||
)
|
||||
old_error_ppm = abs(
|
||||
old.sample_clock_error_ppm - true_offsets["sample_clock_error_ppm"]
|
||||
)
|
||||
refined_error_ppm = abs(
|
||||
refined_offsets["sample_clock_error_ppm"]
|
||||
- true_offsets["sample_clock_error_ppm"]
|
||||
)
|
||||
|
||||
assert refined_error_ppm < 0.001
|
||||
assert refined_error_ppm < old_error_ppm
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sample_clock_error_ppm", [100.0, -100.0])
|
||||
def test_public_refined_calibration_returns_complete_contract(
|
||||
sample_clock_error_ppm: float,
|
||||
) -> None:
|
||||
carrier_offset_hz = 1_234.5
|
||||
scale = 1.0 + sample_clock_error_ppm * 1e-6
|
||||
samples, sample_rate_hz = _synthetic_two_tones(
|
||||
carrier_offset_hz - lab043.F_CAL_HZ * scale,
|
||||
carrier_offset_hz + lab043.F_CAL_HZ * scale,
|
||||
seed=45_000 + int(sample_clock_error_ppm),
|
||||
)
|
||||
|
||||
result = lab043.estimate_refined_calibration(samples, sample_rate_hz)
|
||||
|
||||
assert isinstance(result, lab043.CalibrationResult)
|
||||
assert result.valid
|
||||
assert result.invalid_reason == ""
|
||||
assert math.isclose(result.carrier_offset_hz, carrier_offset_hz, abs_tol=0.01)
|
||||
assert math.isclose(
|
||||
result.sample_clock_error_ppm,
|
||||
sample_clock_error_ppm,
|
||||
abs_tol=0.01,
|
||||
)
|
||||
assert math.isfinite(result.residual_cfo_hz)
|
||||
assert math.isfinite(result.phase_fit_rmse_rad)
|
||||
assert result.peak_margin_low_db >= lab043.MINIMUM_TONE_EXCESS_DB
|
||||
assert result.peak_margin_high_db >= lab043.MINIMUM_TONE_EXCESS_DB
|
||||
|
||||
|
||||
def test_public_refined_calibration_rejects_weak_or_missing_tone_without_exception() -> None:
|
||||
sample_rate_hz = 240_000.0
|
||||
sample_count = 120_000
|
||||
indexes = np.arange(sample_count, dtype=np.float64)
|
||||
random_generator = np.random.default_rng(46_000)
|
||||
noise = 0.003 * (
|
||||
random_generator.standard_normal(sample_count)
|
||||
+ 1j * random_generator.standard_normal(sample_count)
|
||||
)
|
||||
one_tone = np.exp(1j * 2.0 * np.pi * lab043.F_CAL_HZ * indexes / sample_rate_hz)
|
||||
|
||||
weak = lab043.estimate_refined_calibration(noise, sample_rate_hz)
|
||||
missing = lab043.estimate_refined_calibration(one_tone + noise, sample_rate_hz)
|
||||
|
||||
for result in (weak, missing):
|
||||
assert isinstance(result, lab043.CalibrationResult)
|
||||
assert not result.valid
|
||||
assert result.invalid_reason
|
||||
assert math.isnan(result.f_low_hz)
|
||||
assert math.isnan(result.f_high_hz)
|
||||
assert math.isnan(result.carrier_offset_hz)
|
||||
assert math.isnan(result.sample_clock_error_ppm)
|
||||
|
||||
|
||||
def test_tones_below_ten_decibels_are_rejected_with_nan() -> None:
|
||||
frequencies = np.linspace(-100_000.0, 100_000.0, 4001)
|
||||
powers = np.ones_like(frequencies)
|
||||
powers[np.argmin(np.abs(frequencies + lab043.F_CAL_HZ))] = 9.0
|
||||
powers[np.argmin(np.abs(frequencies - lab043.F_CAL_HZ))] = 9.0
|
||||
|
||||
estimate = lab043.estimate_calibration_from_spectrum(frequencies, powers)
|
||||
|
||||
assert not estimate.valid
|
||||
assert math.isnan(estimate.f_low_hz)
|
||||
assert math.isnan(estimate.f_high_hz)
|
||||
assert math.isnan(estimate.carrier_offset_hz)
|
||||
assert estimate.failure_reason
|
||||
|
||||
|
||||
def _estimate(carrier_offset_hz: float, valid: bool = True) -> lab043.CalibrationResult:
|
||||
if not valid:
|
||||
return lab043._invalid_calibration(
|
||||
"нет тонов",
|
||||
noise_power=1.0,
|
||||
)
|
||||
return lab043.CalibrationResult(
|
||||
valid=True,
|
||||
invalid_reason="",
|
||||
f_low_hz=-50_000.0 + carrier_offset_hz,
|
||||
f_high_hz=50_000.0 + carrier_offset_hz,
|
||||
carrier_offset_hz=carrier_offset_hz,
|
||||
carrier_offset_ppm=carrier_offset_hz / lab043.CARRIER_HZ * 1e6,
|
||||
clock_scale=1.0,
|
||||
sample_clock_error_ppm=0.0,
|
||||
residual_cfo_hz=0.0,
|
||||
phase_fit_rmse_rad=0.0,
|
||||
peak_margin_low_db=20.0,
|
||||
peak_margin_high_db=21.0,
|
||||
noise_power=1.0,
|
||||
)
|
||||
|
||||
|
||||
def test_three_calibrations_keep_individual_failures_and_finite_statistics() -> None:
|
||||
summary = lab043.summarize_calibrations((_estimate(10.0), _estimate(14.0), _estimate(0, False)))
|
||||
|
||||
assert summary["capture_count"] == 3
|
||||
assert summary["failure_count"] == 1
|
||||
carrier = summary["carrier_offset_hz"]
|
||||
assert carrier["mean"] == 12.0
|
||||
assert carrier["minimum"] == 10.0
|
||||
assert carrier["maximum"] == 14.0
|
||||
assert carrier["standard_deviation"] == 2.0
|
||||
|
||||
|
||||
def test_all_invalid_calibrations_keep_nan_aggregates() -> None:
|
||||
summary = lab043.summarize_calibrations((_estimate(0, False),) * 3)
|
||||
assert summary["failure_count"] == 3
|
||||
assert math.isnan(summary["carrier_offset_hz"]["mean"])
|
||||
|
||||
|
||||
def test_calibration_summary_requires_exactly_three_captures() -> None:
|
||||
with pytest.raises(ValueError, match="ровно три"):
|
||||
lab043.summarize_calibrations((_estimate(10.0), _estimate(12.0)))
|
||||
|
||||
|
||||
def test_prbs11_has_full_2047_bit_period() -> None:
|
||||
sequence = lab043.prbs11(2 * lab043.PRBS11_LENGTH)
|
||||
first = sequence[: lab043.PRBS11_LENGTH]
|
||||
second = sequence[lab043.PRBS11_LENGTH :]
|
||||
|
||||
assert np.array_equal(first, second)
|
||||
assert not np.array_equal(first, np.roll(first, 23))
|
||||
assert not np.array_equal(first, np.roll(first, 89))
|
||||
|
||||
|
||||
def test_separate_pilot_is_fixed_balanced_and_independent_from_prbs11() -> None:
|
||||
pilot = lab043.pilot_prbs7()
|
||||
first_period = pilot[:127]
|
||||
second_period = pilot[127:254]
|
||||
|
||||
assert len(pilot) == 1_280
|
||||
assert np.array_equal(first_period, second_period)
|
||||
assert int(np.count_nonzero(first_period)) == 64
|
||||
assert not np.array_equal(pilot, lab043.prbs11(len(pilot)))
|
||||
longest_run = max(
|
||||
len(group)
|
||||
for group in np.split(pilot, np.flatnonzero(np.diff(pilot)) + 1)
|
||||
)
|
||||
assert longest_run <= 7
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"carrier_offset_hz",
|
||||
[-10.0, -5.0, -2.0, -1.2, -1.0, -0.5, 0.5, 1.0, 1.2, 2.0, 5.0, 10.0],
|
||||
)
|
||||
def test_fixed_pilot_estimator_statistics_match_hardware_s_phase_dispersion(
|
||||
carrier_offset_hz: float,
|
||||
) -> None:
|
||||
known = radio.bpsk_modulate(lab043.pilot_prbs7())
|
||||
indexes = np.arange(len(known), dtype=np.float64)
|
||||
estimates: list[float] = []
|
||||
for repetition in range(96):
|
||||
random_generator = np.random.default_rng(
|
||||
43_043_000
|
||||
+ int(round((carrier_offset_hz + 20.0) * 1_000.0))
|
||||
+ repetition
|
||||
)
|
||||
phase_noise = random_generator.normal(0.0, 0.4691, len(known))
|
||||
received = known * np.exp(
|
||||
1j
|
||||
* (
|
||||
2.0 * np.pi * carrier_offset_hz * indexes / lab043.SYMBOL_RATE
|
||||
+ phase_noise
|
||||
)
|
||||
)
|
||||
estimate = radio.estimate_known_pilot_carrier(received, known)
|
||||
assert estimate.valid
|
||||
estimates.append(estimate.frequency_hz)
|
||||
|
||||
errors = np.asarray(estimates) - carrier_offset_hz
|
||||
assert np.all(np.sign(estimates) == np.sign(carrier_offset_hz))
|
||||
assert abs(float(np.mean(errors))) < 0.08
|
||||
assert float(np.std(errors, ddof=1)) < 0.18
|
||||
assert float(np.percentile(np.abs(errors), 95.0)) < 0.35
|
||||
|
||||
|
||||
def test_fixed_pilot_estimator_rejects_noise_only_input() -> None:
|
||||
random_generator = np.random.default_rng(43_043)
|
||||
known = radio.bpsk_modulate(lab043.pilot_prbs7())
|
||||
noise = (
|
||||
random_generator.normal(0.0, 1.0, len(known))
|
||||
+ 1j * random_generator.normal(0.0, 1.0, len(known))
|
||||
)
|
||||
|
||||
estimate = radio.estimate_known_pilot_carrier(noise, known)
|
||||
|
||||
assert not estimate.valid
|
||||
assert math.isnan(estimate.frequency_hz)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("label", "duration_seconds", "expected_bits"),
|
||||
[("S", 0.25, 5_000), ("L", 2.0, 40_000)],
|
||||
)
|
||||
def test_prbs_transmission_plan_is_fixed_and_has_one_initial_marker(
|
||||
label: str,
|
||||
duration_seconds: float,
|
||||
expected_bits: int,
|
||||
) -> None:
|
||||
plan = lab043.build_prbs11_transmission_plan(label, duration_seconds)
|
||||
|
||||
assert len(plan.payload_bits) == expected_bits
|
||||
assert np.array_equal(
|
||||
plan.payload_bits,
|
||||
lab043.prbs11(expected_bits, lab043.PRBS11_INITIAL_STATE),
|
||||
)
|
||||
assert plan.bpsk_start_sample == (
|
||||
plan.calibration_sample_count + plan.fixed_guard_sample_count
|
||||
)
|
||||
assert plan.calibration_sample_count == 600_000
|
||||
assert plan.fixed_guard_sample_count == 240_000
|
||||
assert np.max(np.abs(plan.tx_samples)) <= 1.0
|
||||
if label == "S":
|
||||
assert len(plan.pilot_bits) == lab043.PILOT_SYMBOL_COUNT
|
||||
assert len(plan.pilot_bits) / lab043.SYMBOL_RATE == 0.064
|
||||
else:
|
||||
assert len(plan.pilot_bits) == 0
|
||||
|
||||
|
||||
def test_noncyclic_tx_buffer_is_held_for_full_waveform_duration() -> None:
|
||||
events: list[object] = []
|
||||
|
||||
class FakePluto:
|
||||
def tx_destroy_buffer(self) -> None:
|
||||
events.append("destroy")
|
||||
|
||||
def tx(self, samples: np.ndarray) -> None:
|
||||
events.append(("tx", samples.copy()))
|
||||
|
||||
samples = np.arange(12, dtype=np.float32).astype(np.complex64)
|
||||
duration = lab043_hardware.transmit_noncyclic_buffer_once(
|
||||
FakePluto(),
|
||||
samples,
|
||||
actual_sample_rate_hz=48.0,
|
||||
sleep_function=lambda seconds: events.append(("sleep", seconds)),
|
||||
)
|
||||
|
||||
assert duration == 0.25
|
||||
assert events[0] == "destroy"
|
||||
assert events[1][0] == "tx"
|
||||
assert np.array_equal(events[1][1], samples)
|
||||
assert events[2] == ("sleep", 0.25)
|
||||
assert events[3] == "destroy"
|
||||
|
||||
|
||||
def _synthetic_prbs_channel(
|
||||
transmitted: np.ndarray,
|
||||
sample_rate_hz: float,
|
||||
carrier_offset_hz: float,
|
||||
clock_scale: float,
|
||||
) -> np.ndarray:
|
||||
received_length = max(1, math.floor(len(transmitted) / clock_scale))
|
||||
receive_indexes = np.arange(received_length, dtype=np.float64)
|
||||
source_positions = np.minimum(receive_indexes * clock_scale, len(transmitted) - 1.0)
|
||||
source_indexes = np.arange(len(transmitted), dtype=np.float64)
|
||||
received = np.interp(source_positions, source_indexes, transmitted.real) + 1j * np.interp(
|
||||
source_positions,
|
||||
source_indexes,
|
||||
transmitted.imag,
|
||||
)
|
||||
return received * np.exp(
|
||||
1j * 2.0 * np.pi * carrier_offset_hz * receive_indexes / sample_rate_hz
|
||||
)
|
||||
|
||||
|
||||
def test_prbs_modes_measure_drift_and_resampling_reduces_it() -> None:
|
||||
samples_per_symbol = 16
|
||||
sample_rate_hz = lab043.SYMBOL_RATE * samples_per_symbol
|
||||
clock_scale = 1.0 - 100.0e-6
|
||||
carrier_offset_hz = 730.0
|
||||
plan = lab043.build_prbs11_transmission_plan(
|
||||
"S",
|
||||
0.25,
|
||||
sample_rate_hz=sample_rate_hz,
|
||||
samples_per_symbol=samples_per_symbol,
|
||||
)
|
||||
transmitted_bpsk = plan.tx_samples[plan.bpsk_start_sample :]
|
||||
received = _synthetic_prbs_channel(
|
||||
transmitted_bpsk,
|
||||
sample_rate_hz,
|
||||
carrier_offset_hz,
|
||||
clock_scale,
|
||||
)
|
||||
|
||||
modes = lab043.analyze_prbs_modes(
|
||||
received,
|
||||
plan.payload_bits,
|
||||
carrier_offset_hz,
|
||||
clock_scale,
|
||||
sample_rate_hz,
|
||||
samples_per_symbol=samples_per_symbol,
|
||||
)
|
||||
|
||||
assert modes["B"].detected
|
||||
assert modes["C"].detected
|
||||
assert modes["D"].detected
|
||||
assert modes["B"].matched_bit_count == len(plan.payload_bits)
|
||||
assert modes["D"].bit_error_rate == 0.0
|
||||
assert modes["B"].timing_sro_ppm < 0.0
|
||||
assert math.isclose(modes["B"].timing_sro_ppm, -100.0, abs_tol=40.0)
|
||||
assert abs(modes["D"].accumulated_timing_drift_samples) < abs(
|
||||
modes["B"].accumulated_timing_drift_samples
|
||||
)
|
||||
|
||||
|
||||
def test_marker_pilot_correction_and_unknown_prbs_follow_one_software_path() -> None:
|
||||
samples_per_symbol = 16
|
||||
sample_rate_hz = lab043.SYMBOL_RATE * samples_per_symbol
|
||||
coarse_cfo_hz = 700.0
|
||||
residual_cfo_hz = 1.2
|
||||
plan = lab043.build_prbs11_transmission_plan(
|
||||
"S",
|
||||
0.25,
|
||||
sample_rate_hz=sample_rate_hz,
|
||||
samples_per_symbol=samples_per_symbol,
|
||||
)
|
||||
transmitted_bpsk = plan.tx_samples[plan.bpsk_start_sample :]
|
||||
received = _synthetic_prbs_channel(
|
||||
transmitted_bpsk,
|
||||
sample_rate_hz,
|
||||
coarse_cfo_hz + residual_cfo_hz,
|
||||
1.0,
|
||||
)
|
||||
|
||||
modes = lab043.analyze_prbs_modes(
|
||||
received,
|
||||
plan.payload_bits,
|
||||
coarse_cfo_hz,
|
||||
1.0,
|
||||
sample_rate_hz,
|
||||
samples_per_symbol=samples_per_symbol,
|
||||
known_pilot_bits=plan.pilot_bits,
|
||||
)
|
||||
wrong_reference_modes = lab043.analyze_prbs_modes(
|
||||
received,
|
||||
np.zeros_like(plan.payload_bits),
|
||||
coarse_cfo_hz,
|
||||
1.0,
|
||||
sample_rate_hz,
|
||||
samples_per_symbol=samples_per_symbol,
|
||||
known_pilot_bits=plan.pilot_bits,
|
||||
)
|
||||
|
||||
assert set(modes) == {"A", "B", "C", "D"}
|
||||
assert modes["C"].pilot_estimate_valid
|
||||
assert modes["C"].pilot_cfo_applied
|
||||
assert math.isclose(
|
||||
modes["C"].estimated_pilot_cfo_hz,
|
||||
residual_cfo_hz,
|
||||
abs_tol=0.25,
|
||||
)
|
||||
assert abs(modes["C"].residual_pilot_cfo_hz) < 0.05
|
||||
assert modes["C"].bit_error_rate < modes["B"].bit_error_rate
|
||||
assert modes["C"].bit_error_rate == 0.0
|
||||
assert math.isclose(
|
||||
wrong_reference_modes["C"].estimated_pilot_cfo_hz,
|
||||
modes["C"].estimated_pilot_cfo_hz,
|
||||
abs_tol=1e-12,
|
||||
)
|
||||
assert wrong_reference_modes["C"].bit_error_rate > 0.4
|
||||
|
||||
|
||||
def test_calibration_and_bpsk_are_split_from_the_same_capture() -> None:
|
||||
samples_per_symbol = 16
|
||||
sample_rate_hz = lab043.SYMBOL_RATE * samples_per_symbol
|
||||
plan = lab043.build_prbs11_transmission_plan(
|
||||
"S",
|
||||
0.025,
|
||||
sample_rate_hz=sample_rate_hz,
|
||||
samples_per_symbol=samples_per_symbol,
|
||||
)
|
||||
leading = np.zeros(round(0.5 * sample_rate_hz), dtype=np.complex128)
|
||||
trailing = np.zeros(round(0.5 * sample_rate_hz), dtype=np.complex128)
|
||||
capture = np.concatenate((leading, plan.tx_samples, trailing))
|
||||
|
||||
calibration, bpsk, sections = lab043.split_calibration_and_bpsk(
|
||||
capture,
|
||||
plan,
|
||||
sample_rate_hz,
|
||||
sample_rate_hz,
|
||||
)
|
||||
|
||||
assert len(calibration) > 0.15 * sample_rate_hz
|
||||
assert len(bpsk) > len(plan.tx_samples) - plan.bpsk_start_sample
|
||||
assert sections["calibration_end_sample"] < sections["bpsk_nominal_start_sample"]
|
||||
|
||||
|
||||
def _continuous_synthetic_capture(
|
||||
plan: lab043.PrbsTransmissionPlan,
|
||||
tx_samples: np.ndarray | None = None,
|
||||
) -> np.ndarray:
|
||||
leading = np.zeros(
|
||||
round(lab043.RX_LEADING_MARGIN_SECONDS * lab043.SAMPLE_RATE_HZ),
|
||||
dtype=np.complex64,
|
||||
)
|
||||
trailing = np.zeros(
|
||||
round(lab043.RX_TRAILING_MARGIN_SECONDS * lab043.SAMPLE_RATE_HZ),
|
||||
dtype=np.complex64,
|
||||
)
|
||||
waveform = plan.tx_samples if tx_samples is None else tx_samples
|
||||
return np.concatenate((leading, waveform.astype(np.complex64), trailing))
|
||||
|
||||
|
||||
def test_full_short_s_processing_path_returns_calibration_and_modes() -> None:
|
||||
plan = lab043.build_prbs11_transmission_plan("S", 0.025)
|
||||
capture = _continuous_synthetic_capture(plan)
|
||||
|
||||
result = lab043_hardware.analyze_captured_prbs(
|
||||
capture,
|
||||
plan,
|
||||
lab043.SAMPLE_RATE_HZ,
|
||||
lab043.SAMPLE_RATE_HZ,
|
||||
)
|
||||
|
||||
assert result.processing_success
|
||||
assert result.calibration.valid
|
||||
assert set(result.modes) == {"A", "B", "C", "D"}
|
||||
assert result.modes["D"].detected
|
||||
assert result.modes["D"].matched_bit_count == len(plan.payload_bits)
|
||||
assert result.modes["D"].bit_error_rate == 0.0
|
||||
assert math.isfinite(result.modes["D"].evm_percent)
|
||||
|
||||
|
||||
def test_full_short_s_missing_prbs_is_not_reported_as_zero_ber() -> None:
|
||||
plan = lab043.build_prbs11_transmission_plan("S", 0.025)
|
||||
without_prbs = plan.tx_samples.copy()
|
||||
without_prbs[plan.bpsk_start_sample :] = 0.0
|
||||
capture = _continuous_synthetic_capture(plan, without_prbs)
|
||||
|
||||
result = lab043_hardware.analyze_captured_prbs(
|
||||
capture,
|
||||
plan,
|
||||
lab043.SAMPLE_RATE_HZ,
|
||||
lab043.SAMPLE_RATE_HZ,
|
||||
)
|
||||
|
||||
assert result.calibration.valid
|
||||
assert not result.processing_success
|
||||
assert not result.modes["D"].detected
|
||||
assert math.isnan(result.modes["D"].bit_error_rate)
|
||||
|
||||
|
||||
def test_full_short_s_invalid_calibration_makes_all_modes_na() -> None:
|
||||
plan = lab043.build_prbs11_transmission_plan("S", 0.025)
|
||||
one_tone_waveform = plan.tx_samples.copy()
|
||||
indexes = np.arange(plan.calibration_sample_count, dtype=np.float64)
|
||||
one_tone_waveform[: plan.calibration_sample_count] = 0.35 * np.exp(
|
||||
1j * 2.0 * np.pi * lab043.F_CAL_HZ * indexes / lab043.SAMPLE_RATE_HZ
|
||||
)
|
||||
capture = _continuous_synthetic_capture(plan, one_tone_waveform)
|
||||
|
||||
result = lab043_hardware.analyze_captured_prbs(
|
||||
capture,
|
||||
plan,
|
||||
lab043.SAMPLE_RATE_HZ,
|
||||
lab043.SAMPLE_RATE_HZ,
|
||||
)
|
||||
|
||||
assert not result.calibration.valid
|
||||
assert result.calibration.invalid_reason
|
||||
assert not result.processing_success
|
||||
for mode in result.modes.values():
|
||||
assert not mode.detected
|
||||
assert math.isnan(mode.bit_error_rate)
|
||||
|
||||
|
||||
def test_raw_survives_processing_exception_and_json_records_failure(tmp_path) -> None:
|
||||
samples = np.asarray([0.25 + 0.5j, -0.5 + 0.25j], dtype=np.complex64)
|
||||
metadata = {
|
||||
"capture_status": "captured",
|
||||
"processing_status": "pending",
|
||||
"processing_error": None,
|
||||
"capture_started_utc": "2026-08-19T12:00:00Z",
|
||||
"tx_parameters": {"gain_db": -30.0},
|
||||
"rx_parameters": {"gain_db": None},
|
||||
"waveform_sha256": "a" * 64,
|
||||
"callback_count": 2,
|
||||
"callback_boundaries": [{"callback_index": 0}, {"callback_index": 1}],
|
||||
"tx_waveform_duration_seconds": 0.25,
|
||||
"expected_intervals": {"calibration": [0.0, 0.1]},
|
||||
}
|
||||
|
||||
iq_path, json_path, result, error = lab043.save_then_process_capture(
|
||||
tmp_path / "failed_after_rx",
|
||||
samples,
|
||||
metadata,
|
||||
lambda _samples: (_ for _ in ()).throw(RuntimeError("synthetic failure")),
|
||||
)
|
||||
|
||||
document = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
assert iq_path.exists()
|
||||
assert json_path.exists()
|
||||
assert result is None
|
||||
assert error == "RuntimeError: synthetic failure"
|
||||
assert document["capture_status"] == "captured"
|
||||
assert document["processing_status"] == "processing_failed"
|
||||
assert document["processing_error"] == error
|
||||
assert document["raw_verified"]
|
||||
assert document["iq_sha256"] == hashlib.sha256(iq_path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def test_ber_is_measured_before_crc() -> None:
|
||||
expected = lab043.prbs11()
|
||||
received = expected.copy()
|
||||
received[[0, 100, 1000]] ^= 1
|
||||
|
||||
errors, bit_error_rate = lab043.bit_error_rate(expected, received)
|
||||
|
||||
assert errors == 3
|
||||
assert bit_error_rate == 3 / lab043.PRBS11_LENGTH
|
||||
|
||||
|
||||
def test_ber_is_nan_for_truncated_known_sequence() -> None:
|
||||
expected = lab043.prbs11()
|
||||
errors, bit_error_rate = lab043.bit_error_rate(expected, expected[:-1])
|
||||
assert errors == 0
|
||||
assert math.isnan(bit_error_rate)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("initial_sample_phase", [0, 3, 7, 15])
|
||||
def test_symbol_sample_phase_is_selected_separately(initial_sample_phase: int) -> None:
|
||||
samples_per_symbol = 16
|
||||
_, marker_symbols = radio.build_frame_marker()
|
||||
taps = radio.root_raised_cosine_taps(0.35, samples_per_symbol, 10)
|
||||
upsampled = np.zeros(len(marker_symbols) * samples_per_symbol, dtype=complex)
|
||||
upsampled[::samples_per_symbol] = marker_symbols
|
||||
transmitted = fftconvolve(upsampled, taps, mode="full")
|
||||
shifted = np.concatenate((np.zeros(initial_sample_phase), transmitted))
|
||||
matched = fftconvolve(shifted, taps, mode="full")
|
||||
|
||||
found = radio.find_radio_frame(matched, marker_symbols, samples_per_symbol)
|
||||
|
||||
assert found["sample_phase"] == initial_sample_phase
|
||||
assert found["score"] > 0.99
|
||||
|
||||
|
||||
def test_one_control_packet_passes_crc() -> None:
|
||||
assert lab043.control_packet_crc_roundtrip(b"known payload")
|
||||
|
||||
|
||||
def test_corrupted_control_packet_fails_crc() -> None:
|
||||
packet = bytearray(build_packet(b"known payload", MESSAGE_TYPE_TEXT, 43))
|
||||
packet[-1] ^= 1
|
||||
bits, _, _ = radio.build_radio_frame(bytes(packet))
|
||||
recovered = radio.parse_radio_frame(bits)
|
||||
assert recovered is not None
|
||||
with pytest.raises(CRCError):
|
||||
parse_packet(recovered)
|
||||
|
||||
|
||||
def test_modes_a_b_c_process_the_same_capture_and_c_recovers_prbs() -> None:
|
||||
clock_scale = 1.0 + 100.0e-6
|
||||
capture, expected = lab043.synthesize_known_bpsk_capture(
|
||||
carrier_offset_hz=730.0,
|
||||
clock_scale=clock_scale,
|
||||
samples_per_symbol=16,
|
||||
)
|
||||
|
||||
results = lab043.process_bpsk_modes(
|
||||
capture,
|
||||
expected,
|
||||
coarse_carrier_offset_hz=700.0,
|
||||
clock_scale=clock_scale,
|
||||
samples_per_symbol=16,
|
||||
)
|
||||
|
||||
assert set(results) == {"A", "B", "C"}
|
||||
assert results["C"].bit_error_rate == 0.0
|
||||
assert results["C"].fine_cfo_applied
|
||||
assert not math.isfinite(results["A"].bit_error_rate) or (
|
||||
results["A"].bit_error_rate > results["C"].bit_error_rate
|
||||
)
|
||||
assert results["B"].bit_error_rate > results["C"].bit_error_rate
|
||||
|
||||
|
||||
def test_missing_jpeg_fragment_never_produces_an_image() -> None:
|
||||
fragments = split_image_bytes(bytes(range(250)) * 5, image_id=43, fragment_data_size=128)
|
||||
assert len(fragments) == 10
|
||||
assert lab043.try_reassemble_complete_image(fragments[:-1]) is None
|
||||
|
||||
|
||||
def _complete_acceptance() -> lab043.AcceptanceResult:
|
||||
return lab043.AcceptanceResult(10, 10, 10, 10, True, True, True, True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"result",
|
||||
[
|
||||
lab043.AcceptanceResult(9, 10, 10, 10, True, True, True, True),
|
||||
lab043.AcceptanceResult(10, 10, 9, 10, True, True, True, True),
|
||||
lab043.AcceptanceResult(10, 10, 10, 9, False, False, False, True),
|
||||
lab043.AcceptanceResult(10, 10, 10, 10, True, True, True, False),
|
||||
],
|
||||
)
|
||||
def test_exit_zero_requires_every_acceptance_layer(result: lab043.AcceptanceResult) -> None:
|
||||
assert lab043.exit_code_for_acceptance(result) == 1
|
||||
|
||||
|
||||
def test_exit_zero_is_allowed_for_complete_acceptance() -> None:
|
||||
result = _complete_acceptance()
|
||||
assert result.radio_passed
|
||||
assert result.application_passed
|
||||
assert lab043.exit_code_for_acceptance(result) == 0
|
||||
|
||||
|
||||
def test_reference_iq_format_contains_reprocessing_metadata(tmp_path) -> None:
|
||||
metadata = {
|
||||
"actual_sample_rate_hz": 2_400_000.0,
|
||||
"center_frequency_hz": 435_000_000.0,
|
||||
"rx_gain_db": 19.7,
|
||||
"capture_started_utc": "2026-08-19T12:00:00Z",
|
||||
"capture_order": 1,
|
||||
"tx_parameters": {"gain_db": -10.0},
|
||||
"calibration": {"carrier_offset_hz": 123.0, "clock_scale": 1.00002},
|
||||
}
|
||||
samples = np.asarray([1 + 2j, 3 + 4j], dtype=np.complex64)
|
||||
|
||||
iq_path, metadata_path = lab043.save_reference_iq_capture(
|
||||
tmp_path / "reference",
|
||||
samples,
|
||||
metadata,
|
||||
)
|
||||
|
||||
restored = np.load(iq_path, allow_pickle=False)
|
||||
document = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
assert np.array_equal(restored, samples)
|
||||
assert document["sample_count"] == 2
|
||||
assert document["dtype"] == "complex64"
|
||||
assert len(document["iq_sha256"]) == 64
|
||||
assert document["calibration"]["clock_scale"] == 1.00002
|
||||
|
||||
|
||||
def test_reference_iq_rejects_incomplete_metadata(tmp_path) -> None:
|
||||
with pytest.raises(ValueError, match="Не хватает метаданных"):
|
||||
lab043.save_reference_iq_capture(tmp_path / "reference", np.zeros(4), {})
|
||||
|
||||
|
||||
def test_diagnostic_csv_txt_and_png_are_created_from_supplied_results(tmp_path) -> None:
|
||||
calibrations = (_estimate(10.0), _estimate(12.0), _estimate(14.0))
|
||||
capture, expected = lab043.synthesize_known_bpsk_capture(30.0, 1.0, 16)
|
||||
modes = lab043.process_bpsk_modes(capture, expected, 0.0, 1.0, 16)
|
||||
|
||||
paths = lab043.save_diagnostic_artifacts(
|
||||
tmp_path,
|
||||
calibrations,
|
||||
modes,
|
||||
_complete_acceptance(),
|
||||
)
|
||||
|
||||
assert len(paths) == 5
|
||||
assert all(path.exists() and path.stat().st_size > 0 for path in paths)
|
||||
summary = (tmp_path / "lab043_summary.csv").read_text(encoding="utf-8")
|
||||
assert "radio_passed" in summary
|
||||
assert summary.rstrip().endswith(",0")
|
||||
assert (tmp_path / "lab043_calibration.png").stat().st_size > 1000
|
||||
|
||||
|
||||
def test_all_embedded_lab043_checks_pass_without_hardware() -> None:
|
||||
results = lab043.run_functional_tests()
|
||||
assert len(results) == 10
|
||||
assert all(result.passed for result in results), results
|
||||
97
tests/test_packet.py
Normal file
97
tests/test_packet.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Быстрые проверки пакетного протокола: protocol/packet.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
|
||||
from protocol.packet import (
|
||||
CRC_SIZE,
|
||||
HEADER_SIZE,
|
||||
MAX_PAYLOAD_SIZE,
|
||||
MESSAGE_TYPE_ACK,
|
||||
MESSAGE_TYPE_IMAGE_FRAGMENT,
|
||||
MESSAGE_TYPE_TELEMETRY,
|
||||
MESSAGE_TYPE_TEXT,
|
||||
PROTOCOL_VERSION,
|
||||
SYNC_WORD,
|
||||
CRCError,
|
||||
PacketError,
|
||||
build_packet,
|
||||
parse_packet,
|
||||
)
|
||||
|
||||
|
||||
MESSAGE_TYPES = (
|
||||
MESSAGE_TYPE_TEXT,
|
||||
MESSAGE_TYPE_TELEMETRY,
|
||||
MESSAGE_TYPE_IMAGE_FRAGMENT,
|
||||
MESSAGE_TYPE_ACK,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message_type", MESSAGE_TYPES)
|
||||
@pytest.mark.parametrize("payload", [b"", b"H", b"HELLO SDR", bytes(range(256)), b"\x00" * 1024])
|
||||
def test_roundtrip_preserves_payload(payload: bytes, message_type: int) -> None:
|
||||
parsed = parse_packet(build_packet(payload, message_type, 7))
|
||||
assert parsed.payload == payload
|
||||
assert parsed.message_type == message_type
|
||||
assert parsed.sequence_number == 7
|
||||
assert parsed.version == PROTOCOL_VERSION
|
||||
|
||||
|
||||
def test_packet_starts_with_sync_word() -> None:
|
||||
packet = build_packet(b"data", MESSAGE_TYPE_TEXT, 1)
|
||||
assert struct.unpack(">H", packet[:2])[0] == SYNC_WORD
|
||||
|
||||
|
||||
def test_length_is_header_plus_payload_plus_crc() -> None:
|
||||
payload = b"x" * 300
|
||||
packet = build_packet(payload, MESSAGE_TYPE_TEXT, 1)
|
||||
assert len(packet) == HEADER_SIZE + len(payload) + CRC_SIZE
|
||||
|
||||
|
||||
def test_sequence_number_survives_full_range() -> None:
|
||||
for sequence_number in (0, 1, 65534, 65535):
|
||||
assert parse_packet(build_packet(b"a", MESSAGE_TYPE_TEXT, sequence_number)).sequence_number == sequence_number
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bit_index", [0, 7, 8, 23, 64, 100])
|
||||
def test_single_bit_corruption_is_detected(bit_index: int) -> None:
|
||||
"""CRC32 обязан ловить одиночное искажение бита в любом месте пакета."""
|
||||
|
||||
packet = bytearray(build_packet(b"HELLO SDR TELEMETRY", MESSAGE_TYPE_TELEMETRY, 3))
|
||||
byte_index, offset = divmod(bit_index, 8)
|
||||
assert byte_index < len(packet)
|
||||
packet[byte_index] ^= 1 << offset
|
||||
|
||||
with pytest.raises((CRCError, PacketError)):
|
||||
parse_packet(bytes(packet))
|
||||
|
||||
|
||||
def test_truncated_packet_is_rejected() -> None:
|
||||
packet = build_packet(b"HELLO", MESSAGE_TYPE_TEXT, 1)
|
||||
for cut in (0, 1, HEADER_SIZE - 1, len(packet) - 1):
|
||||
with pytest.raises(PacketError):
|
||||
parse_packet(packet[:cut])
|
||||
|
||||
|
||||
def test_oversized_payload_is_rejected() -> None:
|
||||
with pytest.raises((PacketError, ValueError)):
|
||||
build_packet(b"\x00" * (MAX_PAYLOAD_SIZE + 1), MESSAGE_TYPE_TEXT, 1)
|
||||
|
||||
|
||||
def test_wrong_sync_word_is_rejected() -> None:
|
||||
packet = bytearray(build_packet(b"HELLO", MESSAGE_TYPE_TEXT, 1))
|
||||
packet[0] ^= 0xFF
|
||||
with pytest.raises(PacketError):
|
||||
parse_packet(bytes(packet))
|
||||
|
||||
|
||||
def test_payload_is_not_shared_with_caller() -> None:
|
||||
"""Разбор не должен возвращать ссылку на изменяемый буфер."""
|
||||
|
||||
packet = bytearray(build_packet(b"HELLO", MESSAGE_TYPE_TEXT, 1))
|
||||
parsed = parse_packet(bytes(packet))
|
||||
assert isinstance(parsed.payload, bytes)
|
||||
288
tests/test_session_state.py
Normal file
288
tests/test_session_state.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""Проверки автоматов сеанса: protocol/session_state.py.
|
||||
|
||||
Свойства безопасности, перенесённые из функциональных проверок Lab041.
|
||||
До этого они проверялись только при полном запуске лабораторной на 180
|
||||
сочетаний, а пять из них в отчёте вообще не измерялись.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from protocol.control_messages import (
|
||||
ControlStateMessage,
|
||||
MessageContext,
|
||||
ResetRequest,
|
||||
SequenceComparison,
|
||||
compare_sequence,
|
||||
)
|
||||
from protocol.session_state import (
|
||||
GroundSessionController,
|
||||
GroundSessionState,
|
||||
RejectionCause,
|
||||
RoverSessionController,
|
||||
RoverSessionState,
|
||||
deterministic_identifier,
|
||||
)
|
||||
|
||||
|
||||
CONTROL_STREAM_ID = 20
|
||||
|
||||
|
||||
def active_pair(seed: int) -> tuple[GroundSessionController, RoverSessionController]:
|
||||
"""Довести обе стороны до состояния, в котором разрешено движение."""
|
||||
|
||||
ground = GroundSessionController(100 + seed)
|
||||
rover = RoverSessionController(200 + seed)
|
||||
status = rover.receive_hello(ground.start_synchronization())
|
||||
assert ground.receive_status(status)
|
||||
acknowledgement = rover.authorize_movement(ground.request_movement_authorization(1))
|
||||
assert ground.receive_movement_authorize_ack(acknowledgement)
|
||||
return ground, rover
|
||||
|
||||
|
||||
def control_from(ground, rover, sequence_number: int, speed: float = 1.0) -> ControlStateMessage:
|
||||
context = MessageContext(ground.ground_session_id, rover.rover_boot_id, rover.control_epoch, sequence_number)
|
||||
return ControlStateMessage(context, speed, 0.0, False, True)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ безопасный запуск
|
||||
|
||||
|
||||
def test_both_sides_boot_without_movement() -> None:
|
||||
ground = GroundSessionController(7)
|
||||
rover = RoverSessionController(8)
|
||||
assert ground.state is GroundSessionState.GROUND_BOOT
|
||||
assert not ground.movement_permitted
|
||||
assert rover.state is RoverSessionState.ROVER_BOOT_SAFE
|
||||
assert rover.safe
|
||||
|
||||
|
||||
def test_session_status_alone_does_not_permit_movement() -> None:
|
||||
ground = GroundSessionController(9)
|
||||
rover = RoverSessionController(10)
|
||||
assert ground.receive_status(rover.receive_hello(ground.start_synchronization()))
|
||||
assert ground.state is GroundSessionState.GROUND_SAFE_SYNCHRONIZED
|
||||
assert not ground.movement_permitted
|
||||
assert rover.safe
|
||||
|
||||
|
||||
def test_authorization_ack_does_not_restore_motion() -> None:
|
||||
ground, rover = active_pair(11)
|
||||
assert ground.current_speed_mps == 0.0
|
||||
assert not ground.movement_permitted
|
||||
assert rover.safe
|
||||
|
||||
|
||||
def test_movement_requires_a_new_operator_command() -> None:
|
||||
ground, rover = active_pair(12)
|
||||
assert rover.safe
|
||||
assert rover.receive_control(ground.new_operator_control(1.0), CONTROL_STREAM_ID).accepted
|
||||
assert rover.speed_mps == 1.0
|
||||
|
||||
|
||||
def test_restarted_rover_is_immediately_safe() -> None:
|
||||
ground, rover = active_pair(13)
|
||||
rover.receive_control(ground.new_operator_control(1.0), CONTROL_STREAM_ID)
|
||||
restarted = RoverSessionController(999)
|
||||
assert restarted.state is RoverSessionState.ROVER_BOOT_SAFE
|
||||
assert restarted.safe
|
||||
|
||||
|
||||
def test_new_ground_session_stops_the_old_command_stream() -> None:
|
||||
old_ground, rover = active_pair(14)
|
||||
old_command = old_ground.new_operator_control(1.0)
|
||||
new_ground = GroundSessionController(1000)
|
||||
rover.receive_hello(new_ground.start_synchronization())
|
||||
decision = rover.receive_control(old_command, CONTROL_STREAM_ID)
|
||||
assert decision.reason is RejectionCause.GROUND_SESSION_ID
|
||||
|
||||
|
||||
def test_duplicate_hello_is_idempotent() -> None:
|
||||
ground = GroundSessionController(30)
|
||||
rover = RoverSessionController(31)
|
||||
hello = ground.start_synchronization()
|
||||
first = rover.receive_hello(hello)
|
||||
second = rover.receive_hello(hello)
|
||||
assert first == second
|
||||
assert rover.duplicate_hellos == 1
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ старые пакеты
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,expected",
|
||||
[
|
||||
("ground_session_id", RejectionCause.GROUND_SESSION_ID),
|
||||
("rover_boot_id", RejectionCause.ROVER_BOOT_ID),
|
||||
("control_epoch", RejectionCause.CONTROL_EPOCH),
|
||||
],
|
||||
)
|
||||
def test_stale_identifier_is_rejected(field: str, expected: RejectionCause) -> None:
|
||||
"""Пакет предыдущего сеанса, запуска или периода управления не принимается."""
|
||||
|
||||
ground, rover = active_pair(hash(field) % 500)
|
||||
values = {
|
||||
"ground_session_id": ground.ground_session_id,
|
||||
"rover_boot_id": rover.rover_boot_id,
|
||||
"control_epoch": rover.control_epoch,
|
||||
"sequence_number": 1,
|
||||
}
|
||||
values[field] -= 1
|
||||
message = ControlStateMessage(MessageContext(**values), 1.0, 0.0, False, True)
|
||||
assert rover.receive_control(message, CONTROL_STREAM_ID).reason is expected
|
||||
|
||||
|
||||
def test_sequence_zero_is_accepted_in_a_new_session() -> None:
|
||||
ground, rover = active_pair(1)
|
||||
assert rover.receive_control(control_from(ground, rover, 0), CONTROL_STREAM_ID).accepted
|
||||
|
||||
|
||||
def test_sequence_wrap_is_treated_as_newer() -> None:
|
||||
ground, rover = active_pair(5)
|
||||
key = (ground.ground_session_id, rover.rover_boot_id, rover.control_epoch, CONTROL_STREAM_ID)
|
||||
rover.last_sequences[key] = 0xFFFFFFFF
|
||||
assert rover.receive_control(control_from(ground, rover, 0), CONTROL_STREAM_ID).accepted
|
||||
assert compare_sequence(0, 0xFFFFFFFF) is SequenceComparison.NEWER
|
||||
|
||||
|
||||
def test_ambiguous_half_range_is_rejected() -> None:
|
||||
ground, rover = active_pair(6)
|
||||
key = (ground.ground_session_id, rover.rover_boot_id, rover.control_epoch, CONTROL_STREAM_ID)
|
||||
rover.last_sequences[key] = 0
|
||||
decision = rover.receive_control(control_from(ground, rover, 0x80000000), CONTROL_STREAM_ID)
|
||||
assert not decision.accepted
|
||||
assert decision.ambiguous_sequence
|
||||
|
||||
|
||||
def test_replayed_command_is_rejected() -> None:
|
||||
ground, rover = active_pair(21)
|
||||
assert rover.receive_control(control_from(ground, rover, 10), CONTROL_STREAM_ID).accepted
|
||||
replay = rover.receive_control(control_from(ground, rover, 10), CONTROL_STREAM_ID)
|
||||
assert not replay.accepted
|
||||
assert replay.reason is RejectionCause.SEQUENCE_NUMBER
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ аварийное намерение
|
||||
|
||||
|
||||
def test_persisted_emergency_intent_survives_a_ground_restart() -> None:
|
||||
ground = GroundSessionController(15, persisted_emergency_intent=True, persisted_emergency_event_id=77)
|
||||
assert ground.emergency_intent
|
||||
assert not ground.movement_permitted
|
||||
hello = ground.start_synchronization()
|
||||
assert hello.emergency_intent
|
||||
assert hello.emergency_event_id == 77
|
||||
|
||||
|
||||
def test_emergency_acknowledgement_does_not_clear_the_intent() -> None:
|
||||
ground, rover = active_pair(16)
|
||||
acknowledgement = rover.receive_emergency(ground.request_emergency(88))
|
||||
assert acknowledgement is not None
|
||||
assert ground.receive_emergency_ack(acknowledgement)
|
||||
assert ground.emergency_intent
|
||||
assert not ground.movement_permitted
|
||||
|
||||
|
||||
def test_ordinary_command_does_not_release_the_latch() -> None:
|
||||
ground, rover = active_pair(20)
|
||||
rover.receive_emergency(ground.request_emergency(96))
|
||||
decision = rover.receive_control(control_from(ground, rover, 100), CONTROL_STREAM_ID)
|
||||
assert not decision.accepted
|
||||
assert rover.state is RoverSessionState.EMERGENCY_LATCHED
|
||||
|
||||
|
||||
def test_movement_authorization_is_refused_while_intent_stands() -> None:
|
||||
ground = GroundSessionController(40, persisted_emergency_intent=True, persisted_emergency_event_id=5)
|
||||
rover = RoverSessionController(41)
|
||||
ground.receive_status(rover.receive_hello(ground.start_synchronization()))
|
||||
with pytest.raises(RuntimeError):
|
||||
ground.request_movement_authorization(1)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ сброс
|
||||
|
||||
|
||||
def test_stale_reset_request_is_refused() -> None:
|
||||
ground, rover = active_pair(17)
|
||||
assert rover.receive_emergency(ground.request_emergency(90)) is not None
|
||||
request = ground.request_reset(91)
|
||||
stale_context = MessageContext(
|
||||
request.context.ground_session_id - 1,
|
||||
request.context.rover_boot_id,
|
||||
request.context.control_epoch,
|
||||
request.context.sequence_number,
|
||||
)
|
||||
stale = ResetRequest(stale_context, request.emergency_event_id, request.reset_request_id, 0.0, False)
|
||||
assert not rover.receive_reset(stale).acknowledgement.accepted
|
||||
|
||||
|
||||
def test_repeated_reset_is_idempotent() -> None:
|
||||
ground, rover = active_pair(18)
|
||||
rover.receive_emergency(ground.request_emergency(92))
|
||||
request = ground.request_reset(93)
|
||||
|
||||
first = rover.receive_reset(request)
|
||||
assert first.performed
|
||||
|
||||
repeated = rover.receive_reset(request)
|
||||
assert repeated.duplicate
|
||||
assert not repeated.performed
|
||||
assert repeated.acknowledgement.accepted
|
||||
|
||||
|
||||
def test_reset_requires_an_emergency_intent() -> None:
|
||||
ground, _ = active_pair(50)
|
||||
with pytest.raises(RuntimeError):
|
||||
ground.request_reset(1)
|
||||
|
||||
|
||||
def test_reset_does_not_restore_the_previous_command() -> None:
|
||||
ground, rover = active_pair(51)
|
||||
rover.receive_control(ground.new_operator_control(2.0), CONTROL_STREAM_ID)
|
||||
rover.receive_emergency(ground.request_emergency(60))
|
||||
assert rover.speed_mps == 0.0
|
||||
|
||||
decision = rover.receive_reset(ground.request_reset(61))
|
||||
assert decision.performed
|
||||
assert ground.receive_reset_ack(decision.acknowledgement)
|
||||
assert not ground.movement_permitted
|
||||
assert ground.current_speed_mps == 0.0
|
||||
assert rover.safe
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ идентификаторы
|
||||
|
||||
|
||||
def test_identifiers_are_reproducible_and_nonzero() -> None:
|
||||
assert deterministic_identifier(42, "ground") == deterministic_identifier(42, "ground")
|
||||
assert deterministic_identifier(42, "ground") != deterministic_identifier(42, "rover")
|
||||
assert deterministic_identifier(42, "ground") != deterministic_identifier(43, "ground")
|
||||
for seed in range(50):
|
||||
assert 0 < deterministic_identifier(seed, "ground") <= 0xFFFFFFFFFFFFFFFF
|
||||
|
||||
|
||||
def test_identifier_rejects_a_non_integer_seed() -> None:
|
||||
with pytest.raises(TypeError):
|
||||
deterministic_identifier("не число", "ground")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ границы скорости
|
||||
|
||||
|
||||
def test_negative_operator_speed_is_refused() -> None:
|
||||
"""Свойство, которое в отчёте Lab041 стояло литеральным нулём."""
|
||||
|
||||
ground, _ = active_pair(70)
|
||||
for speed in (-0.1, -5.0, 0.0):
|
||||
with pytest.raises(ValueError):
|
||||
ground.new_operator_control(speed)
|
||||
|
||||
|
||||
def test_speed_above_the_limit_is_refused_by_encoding() -> None:
|
||||
from protocol.control_messages import ControlMessageError, encode_message
|
||||
|
||||
ground, rover = active_pair(71)
|
||||
with pytest.raises(ControlMessageError):
|
||||
encode_message(control_from(ground, rover, 1, speed=15.1))
|
||||
@@ -3,9 +3,10 @@
|
||||
|
||||
Проверяет за одну-две секунды то, что ломается чаще всего:
|
||||
|
||||
1. Синтаксис всех файлов в protocol, tests и tools.
|
||||
1. Синтаксис всех файлов в protocol, tests, experiments и tools.
|
||||
2. Импорт каждого модуля protocol.
|
||||
3. Функциональные проверки тех лаб, которым не нужны данные эксперимента.
|
||||
3. Быстрые проверки ядра из tests под pytest.
|
||||
4. Функциональные проверки тех лаб, которым не нужны данные эксперимента.
|
||||
|
||||
Полный прогон лабы шлюз не заменяет.
|
||||
Он отвечает на один вопрос: не сломана ли кодовая база прямо сейчас.
|
||||
@@ -19,12 +20,15 @@ from __future__ import annotations
|
||||
import ast
|
||||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
CHECKED_DIRECTORIES = ("protocol", "tests", "tools")
|
||||
CHECKED_DIRECTORIES = ("protocol", "tests", "experiments", "tools")
|
||||
|
||||
|
||||
def check_syntax() -> list[str]:
|
||||
@@ -62,6 +66,41 @@ def check_protocol_imports() -> list[str]:
|
||||
return failures
|
||||
|
||||
|
||||
def check_core_tests() -> tuple[list[str], int]:
|
||||
"""
|
||||
Прогнать быстрые проверки ядра из tests под pytest.
|
||||
|
||||
Возвращает:
|
||||
failures:
|
||||
Список сообщений об ошибках.
|
||||
|
||||
passed:
|
||||
Количество пройденных проверок, ноль если разобрать вывод не удалось.
|
||||
"""
|
||||
|
||||
environment = dict(os.environ, PYTHONIOENCODING="utf-8")
|
||||
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", "tests", "-q"],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=environment,
|
||||
)
|
||||
|
||||
output = (completed.stdout + completed.stderr).strip()
|
||||
|
||||
if completed.returncode != 0:
|
||||
return [f"pytest: тесты ядра не пройдены:\n{output}"], 0
|
||||
|
||||
match = re.search(r"(\d+) passed", output)
|
||||
passed = int(match.group(1)) if match else 0
|
||||
|
||||
return [], passed
|
||||
|
||||
|
||||
def check_functional_tests() -> tuple[list[str], int, int]:
|
||||
"""
|
||||
Выполнить функциональные проверки лаб, не требующие данных эксперимента.
|
||||
@@ -81,13 +120,13 @@ def check_functional_tests() -> tuple[list[str], int, int]:
|
||||
executed = 0
|
||||
skipped = 0
|
||||
|
||||
for path in sorted((PROJECT_ROOT / "tests").glob("lab*.py")):
|
||||
for path in sorted((PROJECT_ROOT / "experiments").glob("lab*.py")):
|
||||
source = path.read_text(encoding="utf-8")
|
||||
|
||||
if "def run_functional_tests" not in source:
|
||||
continue
|
||||
|
||||
module_name = f"tests.{path.stem}"
|
||||
module_name = f"experiments.{path.stem}"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
@@ -128,8 +167,9 @@ def main() -> int:
|
||||
return 1
|
||||
|
||||
import_failures = check_protocol_imports()
|
||||
core_failures, passed = check_core_tests()
|
||||
test_failures, executed, skipped = check_functional_tests()
|
||||
failures = import_failures + test_failures
|
||||
failures = import_failures + core_failures + test_failures
|
||||
|
||||
if failures:
|
||||
print("Шлюз не пройден")
|
||||
@@ -137,7 +177,11 @@ def main() -> int:
|
||||
print(f" {failure}")
|
||||
return 1
|
||||
|
||||
print(f"Шлюз пройден: синтаксис в порядке, protocol импортируется, функциональных проверок выполнено {executed}, пропущено {skipped}")
|
||||
print(
|
||||
"Шлюз пройден: синтаксис в порядке, protocol импортируется, "
|
||||
f"тестов ядра пройдено {passed}, "
|
||||
f"функциональных проверок лаб выполнено {executed}, пропущено {skipped}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user