Compare commits
2 Commits
2da80d0908
...
0ae04ae4a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ae04ae4a8 | ||
|
|
92289bdf7c |
17
.gitignore
vendored
17
.gitignore
vendored
@@ -29,9 +29,24 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
env/
|
env/
|
||||||
|
|
||||||
# Необработанные данные и IQ-записи
|
# Необработанные данные и IQ-записи.
|
||||||
|
# Правило закрывает крупные записи отсчётов и видео: они воспроизводятся
|
||||||
|
# заново и в историю не помещаются.
|
||||||
data/raw/
|
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
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
|||||||
@@ -434,3 +434,57 @@ git version 2.51.1.windows.1
|
|||||||
## Следующий этап
|
## Следующий этап
|
||||||
|
|
||||||
Криптографическая аутентификация отложена. Сначала выполняется этап стабилизации: быстрые тесты `protocol/` вместо единственной точки входа через лабораторную, разделение быстрых проверок и экспериментов, воспроизводимое описание окружения.
|
Криптографическая аутентификация отложена. Сначала выполняется этап стабилизации: быстрые тесты `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 МГц вступает в силу начиная с эфирных испытаний.
|
||||||
|
|||||||
361
README.md
361
README.md
@@ -4,330 +4,169 @@
|
|||||||
|
|
||||||
**SDR Rover Link** — учебно-исследовательский проект по разработке цифрового радиоканала для наземного робототехнического комплекса с использованием программно-определяемого радио — SDR, Software Defined Radio.
|
**SDR Rover Link** — учебно-исследовательский проект по разработке цифрового радиоканала для наземного робототехнического комплекса с использованием программно-определяемого радио — SDR, Software Defined Radio.
|
||||||
|
|
||||||
Проект должен пройти полный путь от передачи простого текстового пакета до передачи телеметрии и отдельных JPEG-изображений через реальный радиоканал.
|
Проект должен пройти полный путь от передачи простого текстового пакета до передачи телеметрии и изображений через реальный радиоканал.
|
||||||
|
|
||||||
Основная задача проекта — не только получить работающую систему, но и последовательно изучить все этапы цифровой радиосвязи:
|
Основная задача — не только получить работающую систему, но и последовательно изучить все этапы цифровой радиосвязи: формирование данных, разбиение на пакеты, контроль целостности, исправление ошибок, цифровую модуляцию, формирование IQ-сигнала, передачу через SDR, приём, демодуляцию и восстановление исходных данных.
|
||||||
|
|
||||||
- формирование данных;
|
|
||||||
- разбиение данных на пакеты;
|
|
||||||
- контроль целостности;
|
|
||||||
- исправление ошибок;
|
|
||||||
- цифровую модуляцию;
|
|
||||||
- формирование IQ-сигнала;
|
|
||||||
- передачу через SDR;
|
|
||||||
- приём и демодуляцию;
|
|
||||||
- восстановление исходных данных.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Целевой сценарий
|
## 2. Целевой сценарий
|
||||||
|
|
||||||
Разрабатываемая система предназначена для исследовательского наземного ровера.
|
|
||||||
|
|
||||||
Целевые требования:
|
|
||||||
|
|
||||||
| Параметр | Требование |
|
| Параметр | Требование |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Дальность связи | до 10 км |
|
| Дальность связи | до 10 км |
|
||||||
| Скорость ровера | до 25 км/ч |
|
| Скорость ровера | до 25 км/ч |
|
||||||
| Прямая радиовидимость | может отсутствовать |
|
| Прямая радиовидимость | может отсутствовать |
|
||||||
| Рабочие частоты | ниже 350 МГц |
|
| Рабочий диапазон | 200–250 МГц, см. Запись 018 журнала |
|
||||||
| Основной тип данных | телеметрия и отдельные изображения |
|
| Основной тип данных | телеметрия, команды и изображения |
|
||||||
| Формат изображения | JPEG |
|
| Формат изображения | JPEG |
|
||||||
| Частота передачи изображений | отдельные кадры, не видеопоток |
|
|
||||||
| Действие при потере связи | безопасная остановка ровера |
|
| Действие при потере связи | безопасная остановка ровера |
|
||||||
|
|
||||||
Дальность 10 км без прямой видимости является целевым исследовательским требованием, а не гарантированной характеристикой первой версии системы.
|
Дальность 10 км без прямой видимости — целевое исследовательское требование, а не гарантированная характеристика первой версии.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Почему не используется обычное FPV-видео
|
## 3. Почему не используется обычное FPV-видео
|
||||||
|
|
||||||
Типовые FPV-системы работают преимущественно в диапазонах 2,4 и 5,8 ГГц.
|
Типовые FPV-системы работают в диапазонах 2,4 и 5,8 ГГц. Для наземного робота это плохо: велики потери у поверхности земли, сильное затенение рельефом и растительностью, а главное — на таких частотах практически нет дифракции, поэтому за препятствием связь пропадает.
|
||||||
|
|
||||||
Для наземного робота такие диапазоны имеют ряд ограничений:
|
В этом проекте вместо непрерывного видеопотока передаются отдельные сжатые изображения, что резко снижает требуемую скорость канала.
|
||||||
|
|
||||||
- высокие потери при работе у поверхности земли;
|
---
|
||||||
- затенение рельефом, зданиями и растительностью;
|
|
||||||
- быстрое ухудшение изображения при слабом сигнале;
|
|
||||||
- высокая загруженность распространённых диапазонов;
|
|
||||||
- зависимость от постоянного широкополосного видеопотока.
|
|
||||||
|
|
||||||
В этом проекте вместо непрерывного видео предполагается передавать отдельные сжатые изображения.
|
## 4. Текущее состояние
|
||||||
|
|
||||||
Пример:
|
**Выполнено:** лабораторные Lab001–Lab041. Пройден путь от структуры пакета до сеансов связи с защитой от пакетов предыдущего сеанса.
|
||||||
|
|
||||||
```text
|
Что реализовано и проверено:
|
||||||
Камера
|
|
||||||
↓
|
- пакетный протокол с CRC32, фрагментация и сборка изображений;
|
||||||
JPEG-изображение
|
- модуляция и демодуляция BPSK, формирующий фильтр, кадровая синхронизация, оценка и коррекция ухода несущей;
|
||||||
↓
|
- блочное исправление стираний над GF(256), перемежение, подбор параметров;
|
||||||
Разбиение на пакеты
|
- приоритетное обслуживание команд, телеметрии и видео;
|
||||||
↓
|
- двухступенчатая безопасная остановка, сторожевой таймер, постоянное аварийное намерение, безопасный сброс;
|
||||||
Передача через SDR
|
- идентификаторы сеанса, безопасный запуск, отклонение пакетов предыдущего сеанса.
|
||||||
↓
|
|
||||||
Сборка изображения на наземной станции
|
**Что ещё не сделано:** передатчик ни разу не включался. Весь тракт проверен на моделях и на приёме, но не в эфире.
|
||||||
|
|
||||||
|
**Ближайшая работа:** Lab042 — передача JPEG через кабельную петлю на одном PlutoSDR. Техническое задание: [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 — приёмник;
|
```bash
|
||||||
- ADALM-PlutoSDR — приёмопередатчик, ожидается поступление;
|
python -m pytest tests/ -q
|
||||||
- HackRF One — может временно использоваться при необходимости.
|
```
|
||||||
|
|
||||||
### Вычислительное оборудование
|
**Дешёвый шлюз** — синтаксис, импорт `protocol`, функциональные проверки лабораторных:
|
||||||
|
|
||||||
- компьютер под управлением Windows;
|
```bash
|
||||||
- Raspberry Pi — планируется для установки на ровер;
|
python tools/quick_gate.py
|
||||||
- бортовой вычислитель ровера — будет выбран позднее.
|
```
|
||||||
|
|
||||||
### Программное обеспечение
|
**Лабораторный эксперимент** — минуты, пишет результаты в `data/processed/`:
|
||||||
|
|
||||||
- Python 3.12;
|
```bash
|
||||||
- Anaconda;
|
python -m experiments.lab041_session_restart_safety
|
||||||
- Spyder;
|
```
|
||||||
- Git;
|
|
||||||
- GNU Radio — будет установлен на следующем этапе;
|
|
||||||
- libiio и драйверы PlutoSDR — будут установлены после получения устройства.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Предварительно рассматриваемые диапазоны
|
## 8. Структура репозитория
|
||||||
|
|
||||||
В рамках проекта рассматриваются:
|
|
||||||
|
|
||||||
- 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. Структура репозитория
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
SDR_Rover
|
SDR_Rover
|
||||||
│
|
├── protocol/ реализация протокола и радиотракта
|
||||||
├── README.md
|
├── experiments/ лабораторные Lab001–Lab041
|
||||||
├── docs
|
├── tests/ быстрые автоматические проверки
|
||||||
├── protocol
|
├── tools/ вспомогательные программы и шлюз
|
||||||
├── rover
|
├── docs/ технические задания и исследования
|
||||||
├── ground_station
|
├── data/ исходные данные и результаты
|
||||||
├── gnuradio
|
├── rover/ пока пусто
|
||||||
├── tests
|
├── ground_station/ пока пусто
|
||||||
└── tools
|
└── gnuradio/ пока пусто
|
||||||
```
|
```
|
||||||
|
|
||||||
### `docs`
|
|
||||||
|
|
||||||
Документация, лабораторные работы, схемы и отчёты.
|
|
||||||
|
|
||||||
### `protocol`
|
### `protocol`
|
||||||
|
|
||||||
Реализация пакетного протокола:
|
Ядро системы. Пакет с CRC32, фрагментация изображений, кадр канала, исправление стираний, приоритеты, аварийная логика, сеансы связи, примитивы BPSK-радиотракта.
|
||||||
|
|
||||||
- структура пакета;
|
### `experiments`
|
||||||
- CRC;
|
|
||||||
- кодирование;
|
|
||||||
- декодирование;
|
|
||||||
- типы сообщений.
|
|
||||||
|
|
||||||
### `rover`
|
Лабораторные работы. Это исследовательские программы: они моделируют канал, выполняют сотни повторов, строят CSV, графики и отчёты. **Быстрыми тестами они не являются.** При импорте создают каталоги и пишут файлы, поэтому импортировать их не следует — для повторного использования кода служит `protocol/`.
|
||||||
|
|
||||||
Программы, которые будут работать на борту ровера:
|
|
||||||
|
|
||||||
- получение изображения;
|
|
||||||
- сжатие JPEG;
|
|
||||||
- передача телеметрии;
|
|
||||||
- формирование радиопакетов.
|
|
||||||
|
|
||||||
### `ground_station`
|
|
||||||
|
|
||||||
Программы наземной станции:
|
|
||||||
|
|
||||||
- приём пакетов;
|
|
||||||
- сборка изображений;
|
|
||||||
- отображение телеметрии;
|
|
||||||
- управление передачей.
|
|
||||||
|
|
||||||
### `gnuradio`
|
|
||||||
|
|
||||||
Схемы GNU Radio Companion:
|
|
||||||
|
|
||||||
- передатчик;
|
|
||||||
- приёмник;
|
|
||||||
- BPSK-модем;
|
|
||||||
- QPSK-модем;
|
|
||||||
- тестовые генераторы.
|
|
||||||
|
|
||||||
### `tests`
|
### `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. Каждый новый термин сначала объясняется, затем используется.
|
1. Каждый новый термин сначала объясняется, затем используется.
|
||||||
2. Каждый этап должен завершаться работающим результатом.
|
2. Каждый этап завершается работающим результатом.
|
||||||
3. Радиоканал и пакетный протокол разрабатываются отдельно.
|
3. Радиоканал и пакетный протокол разрабатываются отдельно.
|
||||||
4. Код проверяется автоматическими тестами.
|
4. Код проверяется автоматическими тестами.
|
||||||
5. Все существенные решения фиксируются в документации.
|
5. Все существенные решения фиксируются в журнале.
|
||||||
6. Сложность увеличивается постепенно.
|
6. Сложность увеличивается постепенно.
|
||||||
7. Испытания по эфиру начинаются только после проверки через виртуальный канал.
|
7. Испытания по эфиру начинаются только после проверки через модель.
|
||||||
8. Передатчик сначала проверяется через аттенюатор или эквивалент нагрузки.
|
8. Передатчик сначала проверяется через аттенюатор или эквивалент нагрузки.
|
||||||
|
9. **Каждое число в отчёте должно вычисляться из измерений текущего запуска.** Перенесённые из других лабораторных величины сопровождаются указанием источника. Вписанные вручную результаты недопустимы.
|
||||||
|
|
||||||
---
|
Девятое правило добавлено после аудита достоверности, см. Запись 017 журнала.
|
||||||
|
|
||||||
## 9. Текущий статус
|
|
||||||
|
|
||||||
Выполнено:
|
|
||||||
|
|
||||||
- установлен Python;
|
|
||||||
- установлена Anaconda;
|
|
||||||
- выбран Spyder;
|
|
||||||
- установлен Git;
|
|
||||||
- создан локальный Git-репозиторий;
|
|
||||||
- создана базовая структура каталогов.
|
|
||||||
|
|
||||||
Следующая задача:
|
|
||||||
|
|
||||||
> Создать первый Python-модуль пакетного протокола и передать сообщение через виртуальный канал без SDR-оборудования.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Ближайший контрольный результат
|
|
||||||
|
|
||||||
Первая рабочая версия должна выполнять следующий цикл:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Текстовое сообщение
|
|
||||||
↓
|
|
||||||
Преобразование в байты
|
|
||||||
↓
|
|
||||||
Формирование пакета
|
|
||||||
↓
|
|
||||||
Добавление CRC
|
|
||||||
↓
|
|
||||||
Имитация радиоканала
|
|
||||||
↓
|
|
||||||
Проверка CRC
|
|
||||||
↓
|
|
||||||
Восстановление сообщения
|
|
||||||
```
|
|
||||||
|
|
||||||
Первое тестовое сообщение:
|
|
||||||
|
|
||||||
```text
|
|
||||||
HELLO SDR
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ Lab041 — сеансы связи, безопасный запуск и защ
|
|||||||
Созданные файлы
|
Созданные файлы
|
||||||
- protocol/control_messages.py
|
- protocol/control_messages.py
|
||||||
- protocol/session_state.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_summary.csv
|
||||||
- data/processed/lab041/lab041_session_metrics.csv
|
- data/processed/lab041/lab041_session_metrics.csv
|
||||||
- data/processed/lab041/lab041_rejection_metrics.csv
|
- data/processed/lab041/lab041_rejection_metrics.csv
|
||||||
|
|||||||
@@ -2,13 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ast
|
|
||||||
import csv
|
import csv
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
import inspect
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
@@ -1486,12 +1483,11 @@ def validate_outputs(
|
|||||||
assert not any(path.suffix.lower() in forbidden for path in actual)
|
assert not any(path.suffix.lower() in forbidden for path in actual)
|
||||||
assert not any(path.name == "__pycache__" for path in OUTPUT_DIRECTORY.rglob("*"))
|
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):
|
# PNG и проверяются при просмотре результатов.
|
||||||
assert re.search("[А-Яа-яЁё]", node.args[0].value)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|||||||
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
|
||||||
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
|
||||||
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))
|
||||||
Reference in New Issue
Block a user