Compare commits

...

8 Commits

Author SHA1 Message Date
LittleSam129
f0fa7e8a46 Lab043: validate pilot-aided short BPSK link 2026-08-19 18:14:13 +03:00
LittleSam129
fa2e473c6d Lab042: validate Pluto image hardware loopback 2026-08-17 18:30:16 +03:00
LittleSam129
fce14f038e Lab042: transmit an image over the air, add the air loopback mode
First transmission through a real radio path in the project. A 4756-byte
JPEG went from transmitter to receiver over two antennas 60 cm apart at
225 MHz and reassembled byte-identical, reproduced across three runs.

The cable loopback still needs an SMA male-to-male adapter that has not
arrived, so this air run is a track verification, not the controlled
measurement: room levels are not held the way a cable holds them. Its
purpose was to exercise the device wrapper the models cannot cover.

Four defects the software model passed but the hardware exposed, each
reproduced and fixed:

- a 566 528-sample frame did not fit the 262 144 receive buffer, and the
  sample stream is not continuous across separate reads, so a frame on the
  seam was destroyed. Reception now uses one buffer sized to the frame
- carrier correction was always applied, but with a shared reference the
  true offset is near zero and the 80-symbol marker estimate is noise;
  correcting by it rotated phase along the whole frame, 65% bit errors
  measured. Ported should_apply_cfo_correction from Lab023
- receiver gain of 20 dB left the signal at 0.0003 of full scale. Levels
  set by measurement: RX 50 dB, TX -10 dB give zero bit errors with margin
- the error-vector metric returned zero on a failed receive, reporting a
  perfect signal where there was none, the same class of defect as the
  written-in figures of Lab041. Replaced with an explicit not-computed value

The air TX gain ceiling is now derived from the link budget by antenna
distance rather than a constant. Three loopback modes: software, cable,
air. PROJECT_LOG entry 019 records the run and the defects.

Software and cable results stay reproducible; 125 tests pass, gate passes.
2026-08-17 11:50:04 +03:00
LittleSam129
13f183f7c7 Run the core tests from the gate and finish the layout rename
The gate now runs pytest as well, so one command answers whether the code
base is broken: syntax, protocol imports, the 125 core tests, then the lab
functional suites. It fails when a test fails, verified by feeding it a
deliberately broken test.

Two leftovers from moving the labs into experiments/ are fixed: the lab
command still pointed at tests/, and the hook only watched protocol and
tests, so edits under experiments/ and tools/ triggered nothing.

Profiling the 3.6 s gate: 0.24 s syntax, 0.12 s imports, 0.61 s pytest,
2.7 s lab functional suites, of which Lab042's software loopback is 1.14 s
because it pushes a whole image through the chain. The pytest addition is
the small part. prepare_jpeg is now cached, since six checks call it and
each call re-encoded the image at several qualities.

README described the gate without the tests it now runs.
2026-08-11 13:40:05 +03:00
LittleSam129
a37ad1f7b3 Lab042: transmit a JPEG through a cable loopback
First lab in the project that switches the transmitter on. Lab001-Lab041
built the whole chain but never called into an SDR to send anything.

Two modes. Software closes the channel in memory and needs no hardware;
it is the reference the hardware run is measured against, so the
difference between them is what the radio path contributes. Hardware
drives one PlutoSDR with its transmit output looped back through a 30 dB
attenuator into its own receive input.

Everything outside the device wrapper is reused from protocol/ unchanged:
image_fragments, packet and the primitives in bpsk_radio.

Software run: a 4756-byte JPEG in 10 fragments, 5 272 064 samples, 2.059 s
of signal, 10 of 10 fragments recovered, image byte-identical, repeatable
across runs. Channel rate 18.475 kbit/s, below the 20 kbit/s symbol rate
because of framing and guards.

Three defects found by the verifier and fixed:

- the guard interval was only appended after the frame. Lab018 puts one on
  both sides, and the leading one is the one that matters: the first
  samples of a transmission are unreliable, so the preamble would have
  landed exactly on the transient. Invisible in software mode, which has
  no converter and no transient
- transmission was started before reception, contradicting section 8 of
  the specification and its own trap 3. The receiver now runs first and
  the first buffer is discarded, as in Lab024a
- packet and frame size were reported from the first frame only, while
  the tenth is 172 bytes against 536. Sizes are now per frame in the CSV
  and the report gives the honest range

Also fixed before verification: the JPEG did not fit its own 5000-byte
limit, so quality is now lowered until it does and reported as a result;
and the wall-clock rate was presented as a channel rate, which in software
mode measures computation, not a channel.

Artifacts are left out deliberately. data/processed/lab042 currently holds
the software reference; the committed results should be the real
experiment, which needs the cable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:28:47 +03:00
LittleSam129
46b10b1243 Add the GitLab CI pipeline
Three stages, all fast: the gate, then the core tests, then style.

Only the calculation environment is installed, no pyadi-iio or libiio, so
the pipeline needs no hardware. Laboratory experiments stay out of it:
they run for minutes, need inputs from data/raw that are not in the
repository, and some need a PlutoSDR attached. Those remain manual.

The style job is allow_failure for now. Lab001-Lab041 use a distinctive
layout with one argument per line, and normalising it would mean touching
50 files whose results are already recorded. The restriction lifts once
we agree on the rules.

Test results are published as a JUnit report so failures show up in the
merge request rather than only in the job log.

Both jobs verified locally: gate passes, 125 tests pass in 0.26 s.

Registering a runner on git.arconlab.ru is the one part that has to be
done on the server side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:38:35 +03:00
LittleSam129
0ae04ae4a8 Close the remaining audit findings
Environment, README, gitignore and the source self-parsing in Lab041.

requirements.txt and requirements-hardware.txt pin the versions actually
verified today on Python 3.13.1, split so the project comes up on a
machine with no SDR: pyadi-iio and libiio are only needed from Lab042 on.

The gitignore contradiction is fixed in the direction that keeps the
project reproducible. data/raw/ was ignored while six files were tracked
anyway, and four of them are the source images Lab007, Lab009, Lab011 and
Lab012 read as input. Untracking them would have broken those labs, so
the rule now carries explicit exceptions instead. Recorded in the README:
lab026_rover_source.mp4 is genuinely absent and Lab026 and Lab027 cannot
run without it.

README replaced. It claimed the next task was to write the first protocol
module, 41 labs later. It now states what exists, that the transmitter
has never been switched on, how to install either environment, how to run
tests, the gate and a lab, and that rover/, ground_station/ and gnuradio/
are still empty. Added rule 9: every number in a report must come from
the current run, transferred values must name their source.

Lab041 no longer parses its own source to check that plot labels are in
Russian. That belonged to presentation, not to model correctness, and
turned the experiment into an analyser of its own text; ast, inspect and
re are no longer imported. Rerun: 180 combinations, 24/24 checks, five
CSV and seven PNG, unchanged.

125 tests pass, gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:33:14 +03:00
LittleSam129
92289bdf7c Cover control messages and session state; record the RF decision
Two more suites, 69 checks, bringing the fast tests to 125 in 0.3 s.

test_control_messages: roundtrip for all ten message types, a guard that
fails if a type is added without a test, wrong version, unknown type,
truncation, the control-state range rules including negative and
non-finite speed, and sequence comparison across the 32-bit wrap and the
ambiguous 2^31 boundary.

test_session_state: the safety properties that previously only ran inside
Lab041. Safe boot on both sides, status and authorization not being a
movement command, a new operator command being required, stale session,
boot and epoch identifiers, replay, sequence wrap, ambiguity, persisted
emergency intent surviving a restart, acknowledgement not clearing it,
ordinary commands not releasing the latch, idempotent reset, and reset
not restoring the previous command.

Two of these were literal zeros in the Lab041 report and measured
nothing: negative speed and speed above the limit are now genuinely
exercised against the encoder.

PROJECT_LOG entry 018 records the RF architecture decision: 200-250 MHz
with frequency hopping, a directional ground antenna and spread spectrum
for the command channel, with the rejected alternatives and why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:29:47 +03:00
29 changed files with 6689 additions and 279 deletions

View File

@@ -12,7 +12,7 @@ argument-hint: <номер лабы> [краткая постановка]
Запусти агента `Explore` с запросом: что в `protocol/` уже относится к этой задаче, Запусти агента `Explore` с запросом: что в `protocol/` уже относится к этой задаче,
какие модули придётся трогать, где есть пересечение или дублирование, как устроены какие модули придётся трогать, где есть пересечение или дублирование, как устроены
две-три ближайшие по смыслу лабы в `tests/`. две-три ближайшие по смыслу лабы в `experiments/`.
Прочитай сам последние записи в `PROJECT_LOG.md` — там указано, чем должен Прочитай сам последние записи в `PROJECT_LOG.md` — там указано, чем должен
заниматься следующий этап. заниматься следующий этап.

View File

@@ -19,7 +19,7 @@ import subprocess
import sys import sys
PROJECT_ROOT = Path(__file__).resolve().parents[2] 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: def main() -> int:

17
.gitignore vendored
View File

@@ -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

60
.gitlab-ci.yml Normal file
View 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
# Проверка стиля. Пока не блокирует конвейер: в лабораторных Lab001Lab041
# принято своеобразное оформление с переносами аргументов, и приводить их
# к единому стилю сейчас означало бы тронуть 50 файлов с зафиксированными
# результатами. Ограничение снимается, когда договоримся о правилах.
стиль:
stage: lint
allow_failure: true
before_script:
- python -m pip install --quiet ruff
script:
- ruff check protocol/ tests/ tools/

View File

@@ -434,3 +434,160 @@ 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 ГГц дифракция исчезает и для наземного ровера без прямой видимости эти каналы мертвы. Кроме того, подавление направлено на диапазон, в котором связь действительно работает, а не на тот, который способно настроить оборудование.
**Диапазоны 330433 МГц.** Отклонены как плотно занятые и общеизвестные.
## Принято
Рабочий диапазон **200250 МГц**. Обоснование: четвертьволновая антенна 33 см пригодна для установки на ровер, дифракция ещё эффективна, участок заметно свободнее любительского 144 МГц и УКВ выше 300 МГц. Потери на 10 км составляют около 99,5 дБ.
Совокупность мер против подавления:
1. **Скачки по частоте в пределах 200250 МГц.** Требуют от заградительной помехи на 22 дБ больше мощности при неизменной антенне и механике ровера.
2. **Направленная антенна наземной станции.** Даёт 1020 дБ усиления и пространственно подавляет помеху, приходящую с иного направления.
3. **Расширение спектра для команд управления.** Поток команд составляет 10,88 кбит/с, поэтому расширение для него реализуемо в узкой полосе, в отличие от видеопотока.
Видео остаётся некритичным классом: при подавлении управление сохраняется в узкой полосе, а ровер переходит к безопасному поведению по уже реализованной логике.
## Запрещённые участки
- 108137 МГц — авиационная связь.
- 243 МГц — международная аварийная частота.
## Ограничения решения
- Нижняя граница 70 МГц определяется приёмником Pluto и без смены оборудования не преодолевается.
- Скачки требуют согласованной последовательности у обеих сторон, то есть зависят от криптографической аутентификации, которая пока не реализована.
- Расчёты выполнены для свободного пространства. Потери на препятствиях 2040 дБ учтены оценочно и подлежат измерению.
- Дальность 10 км без усилителя мощности не достигается.
## Влияние на ближайшие работы
На Lab042 не влияет: там кабельная петля, несущая 435 МГц выбрана произвольно. Диапазон 200250 МГц вступает в силу начиная с эфирных испытаний.
---
# Запись 019
## Дата
10 августа 2026 года
## Тема
Lab042: первая передача изображения через реальный радиотракт. Эфирная проверка на паре антенн.
## Причина эфирного прогона
Кабельная петля требует переходника SMA папа-папа, которого пока нет: пигтейлы к антеннам оканчиваются папой, а разъёмы Pluto — мамы. Чтобы не простаивать, выполнена вспомогательная эфирная проверка на двух антеннах, разнесённых на 60 см. Она не заменяет кабельный опыт: уровни в помещении не контролируются строго, возможны переотражения. Её задача — снять последнюю неизвестность в коде, а именно обвязку вокруг устройства, которая в моделях не проверяется.
## Условия
- Устройство Analog Devices PlutoSDR Rev.C, чип AD9361, прошивка v0.32.
- Несущая 225 МГц, внутри целевого диапазона Записи 018.
- Антенны, рассчитанные на 108 МГц, работают на несущей как полуволновые. Частота передачи и частота, на которую рассчитана антенна, различны, поэтому в авиационный диапазон 108137 МГц опыт не заходит.
- Изображение 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.

362
README.md
View File

@@ -4,330 +4,170 @@
**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 МГц | | Рабочий диапазон | 200250 МГц, см. Запись 018 журнала |
| Основной тип данных | телеметрия и отдельные изображения | | Основной тип данных | телеметрия, команды и изображения |
| Формат изображения | JPEG | | Формат изображения | JPEG |
| Частота передачи изображений | отдельные кадры, не видеопоток |
| Действие при потере связи | безопасная остановка ровера | | Действие при потере связи | безопасная остановка ровера |
Дальность 10 км без прямой видимости является целевым исследовательским требованием, а не гарантированной характеристикой первой версии системы. Дальность 10 км без прямой видимости целевое исследовательское требование, а не гарантированная характеристика первой версии.
--- ---
## 3. Почему не используется обычное FPV-видео ## 3. Почему не используется обычное FPV-видео
Типовые FPV-системы работают преимущественно в диапазонах 2,4 и 5,8 ГГц. Типовые FPV-системы работают в диапазонах 2,4 и 5,8 ГГц. Для наземного робота это плохо: велики потери у поверхности земли, сильное затенение рельефом и растительностью, а главное — на таких частотах практически нет дифракции, поэтому за препятствием связь пропадает.
Для наземного робота такие диапазоны имеют ряд ограничений: В этом проекте вместо непрерывного видеопотока передаются отдельные сжатые изображения, что резко снижает требуемую скорость канала.
- высокие потери при работе у поверхности земли; ---
- затенение рельефом, зданиями и растительностью;
- быстрое ухудшение изображения при слабом сигнале;
- высокая загруженность распространённых диапазонов;
- зависимость от постоянного широкополосного видеопотока.
В этом проекте вместо непрерывного видео предполагается передавать отдельные сжатые изображения. ## 4. Текущее состояние
Пример: **Выполнено:** лабораторные Lab001Lab042. Пройден путь от структуры пакета до первой передачи изображения через реальный радиотракт.
```text Что реализовано и проверено:
Камера
- пакетный протокол с CRC32, фрагментация и сборка изображений;
JPEG-изображение - модуляция и демодуляция BPSK, формирующий фильтр, кадровая синхронизация, оценка и коррекция ухода несущей;
- блочное исправление стираний над GF(256), перемежение, подбор параметров;
Разбиение на пакеты - приоритетное обслуживание команд, телеметрии и видео;
- двухступенчатая безопасная остановка, сторожевой таймер, постоянное аварийное намерение, безопасный сброс;
Передача через SDR - идентификаторы сеанса, безопасный запуск, отклонение пакетов предыдущего сеанса;
- передача 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 — приёмник; ```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/ лабораторные Lab001Lab041
├── 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
```
--- ---

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View 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
1 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
2 0 536 4384 0.9991557547061216 19 -0.13990840791652204 0.9997935741467082 False 0.3400949142192574 True True True True
3 1 536 4384 0.9986269709852709 51 -0.6704420071784511 0.999254107740255 False 0.1962803864132724 True True True True
4 2 536 4384 0.9988503105697645 75 0.16739056727394086 0.9997145042457541 False 0.16392107712020013 True True True True
5 3 536 4384 0.9984613689137739 69 0.12541328438695132 0.9993958830571172 False 0.18678168516977972 True True True True
6 4 536 4384 0.9986165925127464 37 0.522370456657967 0.9996364225258064 False 0.15291774582164716 True True True True
7 5 536 4384 0.9988383991200348 32 -0.1940305233482036 0.9995177607884775 False 0.1768680391093208 True True True True
8 6 536 4384 0.9993940824417119 51 0.35917365385569155 0.9997260503953309 False 0.1442403116319193 True True True True
9 7 536 4384 0.9986343538268945 75 0.2939902862233339 0.9996675003381007 False 0.17829502789805413 True True True True
10 8 536 4384 0.9988608071024292 69 -0.7492708746413825 0.9995548745704333 False 0.15854008568562075 True True True True
11 9 172 1472 0.9984590339381546 60 0.5753674284248689 0.9992453603986811 False 0.232235822035248 True True True True

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View 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
1 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
2 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

View 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` также
не выполняются.

View File

@@ -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:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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())

View File

@@ -17,6 +17,7 @@ Lab019 и Lab023 при импорте создают каталоги и сох
from __future__ import annotations from __future__ import annotations
import struct import struct
from dataclasses import dataclass
import numpy as np import numpy as np
@@ -31,8 +32,55 @@ SYMBOL_RATE = 20_000
CFO_REFINEMENT_HALF_WIDTH_HZ = 200.0 CFO_REFINEMENT_HALF_WIDTH_HZ = 200.0
CFO_REFINEMENT_STEP_HZ = 1.0 CFO_REFINEMENT_STEP_HZ = 1.0
# Пороги разрешения частотной коррекции. Значения из Lab023. Смысл в том,
# что при малом истинном уходе оценка по короткому маркеру состоит почти
# целиком из шума, и коррекция таким значением портит длинный кадр сильнее,
# чем отсутствие коррекции вообще.
CFO_DEAD_ZONE_HZ = 15.0
MINIMUM_PHASE_CONSISTENCY = 0.55
# Критерии рабочей оценки малой остаточной CFO по отдельному известному
# пилоту Lab043. Они зафиксированы до нового аппаратного опыта.
KNOWN_PILOT_BLOCK_SYMBOL_COUNT = 20
MINIMUM_KNOWN_PILOT_BLOCK_COUNT = 8
MINIMUM_KNOWN_PILOT_COHERENCE = 0.50
MAXIMUM_KNOWN_PILOT_PHASE_FIT_RMSE_RAD = 0.25
MAXIMUM_PROTOCOL_PACKET_BYTES = 4096 MAXIMUM_PROTOCOL_PACKET_BYTES = 4096
@dataclass(frozen=True)
class TwoTonePhaseRefinement:
"""Явный результат фазового уточнения двух известных тонов."""
valid: bool
invalid_reason: str
low_frequency_hz: float
high_frequency_hz: float
carrier_offset_hz: float
tone_spacing_hz: float
residual_cfo_hz: float
phase_fit_rmse_rad: float
low_phase_fit_rms_rad: float
high_phase_fit_rms_rad: float
paired_phase_fit_rms_rad: float
block_count: int
@dataclass(frozen=True)
class KnownPilotCarrierEstimate:
"""Оценка малой остаточной CFO по отдельному известному BPSK-пилоту."""
valid: bool
invalid_reason: str
frequency_hz: float
phase_increment_rad_per_symbol: float
initial_phase_rad: float
phase_fit_rmse_rad: float
mean_block_coherence: float
block_count: int
known_symbol_count: int
# Перенесено дословно из Lab023 (tests/lab023_guarded_cfo_correction.py). # Перенесено дословно из Lab023 (tests/lab023_guarded_cfo_correction.py).
def bytes_to_bits( def bytes_to_bits(
data: bytes, data: bytes,
@@ -129,6 +177,496 @@ def bpsk_demodulate(
).astype(np.uint8) ).astype(np.uint8)
def find_known_tone_peaks(
frequency_axis_hz: np.ndarray,
power_spectrum: np.ndarray,
expected_offset_hz: float,
search_half_width_hz: float,
) -> dict:
"""Найти максимумы мощности около двух известных симметричных тонов.
Функция не задаёт способ оценки шумового фона и порог достоверности:
эти решения принадлежат конкретному эксперименту. Если в одном из
поисковых окон нет конечных значений мощности, соответствующие частота
и мощность возвращаются как ``NaN``.
"""
frequencies = np.asarray(frequency_axis_hz, dtype=np.float64)
powers = np.asarray(power_spectrum, dtype=np.float64)
if frequencies.ndim != 1 or powers.ndim != 1:
raise ValueError("Ось частот и спектр мощности должны быть одномерными")
if len(frequencies) != len(powers):
raise ValueError("Ось частот и спектр мощности должны иметь одинаковую длину")
if not np.isfinite(expected_offset_hz) or expected_offset_hz <= 0.0:
raise ValueError("Ожидаемый отступ тона должен быть положительным")
if not np.isfinite(search_half_width_hz) or search_half_width_hz <= 0.0:
raise ValueError("Полуширина окна поиска должна быть положительной")
def peak_in_window(center_hz: float) -> tuple[float, float]:
in_window = (
(frequencies >= center_hz - search_half_width_hz)
& (frequencies <= center_hz + search_half_width_hz)
& np.isfinite(frequencies)
& np.isfinite(powers)
& (powers >= 0.0)
)
indexes = np.flatnonzero(in_window)
if indexes.size == 0:
return float("nan"), float("nan")
peak_index = int(indexes[np.argmax(powers[indexes])])
return float(frequencies[peak_index]), float(powers[peak_index])
low_frequency_hz, low_power = peak_in_window(-expected_offset_hz)
high_frequency_hz, high_power = peak_in_window(expected_offset_hz)
return {
"low_frequency_hz": low_frequency_hz,
"high_frequency_hz": high_frequency_hz,
"low_power": low_power,
"high_power": high_power,
}
def estimate_two_tone_offsets(
low_frequency_hz: float,
high_frequency_hz: float,
known_tone_offset_hz: float,
) -> dict:
"""Оценить грубую CFO и отношение тактов по двум известным тонам.
``clock_scale`` определён как отношение масштаба TX к масштабу RX.
Поэтому для перехода принятой последовательности на временную сетку
передатчика её ожидаемая длина равна ``round(N * clock_scale)``.
Невычислимая оценка представляется только значениями ``NaN``.
"""
if not np.isfinite(known_tone_offset_hz) or known_tone_offset_hz <= 0.0:
raise ValueError("Известный отступ тона должен быть положительным")
invalid = {
"carrier_offset_hz": float("nan"),
"clock_scale": float("nan"),
"sample_clock_error_ppm": float("nan"),
}
if not np.isfinite(low_frequency_hz) or not np.isfinite(high_frequency_hz):
return invalid
if high_frequency_hz <= low_frequency_hz:
return invalid
carrier_offset_hz = (low_frequency_hz + high_frequency_hz) / 2.0
clock_scale = (
(high_frequency_hz - low_frequency_hz)
/ (2.0 * known_tone_offset_hz)
)
if not np.isfinite(clock_scale) or clock_scale <= 0.0:
return invalid
return {
"carrier_offset_hz": float(carrier_offset_hz),
"clock_scale": float(clock_scale),
"sample_clock_error_ppm": float((clock_scale - 1.0) * 1e6),
}
def _tone_block_projections(
samples: np.ndarray,
sample_rate_hz: float,
coarse_frequency_hz: float,
block_samples: int,
hop_samples: int,
) -> tuple[np.ndarray, np.ndarray]:
"""Спроецировать фазово-непрерывный сигнал на грубую частоту тона."""
received = np.asarray(samples, dtype=np.complex128)
if received.ndim != 1:
raise ValueError("Комплексные отсчёты должны быть одномерными")
if not np.isfinite(sample_rate_hz) or sample_rate_hz <= 0.0:
raise ValueError("Частота дискретизации должна быть положительной")
if not np.isfinite(coarse_frequency_hz):
raise ValueError("Грубая частота тона должна быть конечной")
if not isinstance(block_samples, (int, np.integer)) or block_samples < 3:
raise ValueError("Размер блока должен быть целым числом не меньше трёх")
if not isinstance(hop_samples, (int, np.integer)) or hop_samples <= 0:
raise ValueError("Шаг блоков должен быть положительным целым числом")
if len(received) < block_samples + 2 * hop_samples:
raise ValueError("Для фазовой оценки нужны не менее трёх блоков")
starts = np.arange(
0,
len(received) - block_samples + 1,
hop_samples,
dtype=np.int64,
)
sample_indexes = np.arange(len(received), dtype=np.float64)
mixed = received * np.exp(
-1j * 2.0 * np.pi * coarse_frequency_hz * sample_indexes / sample_rate_hz
)
window = np.hanning(block_samples)
projections = np.asarray(
[
np.sum(mixed[start : start + block_samples] * window)
for start in starts
],
dtype=np.complex128,
)
center_times_seconds = (
starts.astype(np.float64) + (block_samples - 1) / 2.0
) / sample_rate_hz
return center_times_seconds, projections
def _weighted_phase_frequency(
center_times_seconds: np.ndarray,
projections: np.ndarray,
) -> tuple[float, float]:
"""Оценить наклон развёрнутой фазы и СКО остатка линейной модели."""
times = np.asarray(center_times_seconds, dtype=np.float64)
values = np.asarray(projections, dtype=np.complex128)
weights = np.abs(values) ** 2
weight_sum = float(np.sum(weights))
if (
times.ndim != 1
or values.ndim != 1
or len(times) != len(values)
or len(times) < 3
or not np.all(np.isfinite(times))
or not np.all(np.isfinite(values))
or not np.isfinite(weight_sum)
or weight_sum <= 0.0
):
raise ValueError("Фазовые проекции не позволяют оценить частоту")
phases = np.unwrap(np.angle(values))
mean_time = float(np.sum(weights * times) / weight_sum)
mean_phase = float(np.sum(weights * phases) / weight_sum)
centered_times = times - mean_time
denominator = float(np.sum(weights * centered_times**2))
if not np.isfinite(denominator) or denominator <= 0.0:
raise ValueError("Временные точки не позволяют оценить наклон фазы")
slope_rad_per_second = float(
np.sum(weights * centered_times * (phases - mean_phase)) / denominator
)
intercept = mean_phase - slope_rad_per_second * mean_time
residuals = phases - (intercept + slope_rad_per_second * times)
phase_fit_rms_rad = float(
np.sqrt(np.sum(weights * residuals**2) / weight_sum)
)
return slope_rad_per_second / (2.0 * np.pi), phase_fit_rms_rad
def refine_tone_frequency_from_phase(
samples: np.ndarray,
sample_rate_hz: float,
coarse_frequency_hz: float,
block_samples: int,
hop_samples: int | None = None,
) -> dict:
"""Уточнить частоту тона по наклону фазы когерентных проекций.
Входной участок должен быть фазово-непрерывным. Функцию нельзя применять
через границы отдельных аппаратных чтений, если непрерывность их фазы не
доказана. Грубая частота должна быть достаточно точной, чтобы остаточная
фаза между соседними блоками разворачивалась без неоднозначности.
"""
hop = block_samples // 2 if hop_samples is None else hop_samples
times, projections = _tone_block_projections(
samples,
sample_rate_hz,
coarse_frequency_hz,
block_samples,
hop,
)
residual_frequency_hz, phase_fit_rms_rad = _weighted_phase_frequency(
times,
projections,
)
return {
"frequency_hz": float(coarse_frequency_hz + residual_frequency_hz),
"residual_frequency_hz": float(residual_frequency_hz),
"phase_fit_rms_rad": float(phase_fit_rms_rad),
"block_count": int(len(projections)),
}
def refine_two_tone_frequencies_from_phase(
samples: np.ndarray,
sample_rate_hz: float,
low_coarse_frequency_hz: float,
high_coarse_frequency_hz: float,
block_samples: int,
hop_samples: int | None = None,
) -> TwoTonePhaseRefinement:
"""Уточнить несущую и расстояние двух тонов на непрерывном участке.
Несущая получается из двух индивидуальных наклонов фазы. Расстояние тонов
оценивается по фазе произведения верхней проекции на сопряжённую нижнюю:
общая фазовая ошибка приёмника при этом сокращается. Итоговые частоты
строятся из общей оценки центра и парной оценки расстояния.
"""
if high_coarse_frequency_hz <= low_coarse_frequency_hz:
raise ValueError("Частота верхнего тона должна быть выше частоты нижнего")
hop = block_samples // 2 if hop_samples is None else hop_samples
times, low_projections = _tone_block_projections(
samples,
sample_rate_hz,
low_coarse_frequency_hz,
block_samples,
hop,
)
high_times, high_projections = _tone_block_projections(
samples,
sample_rate_hz,
high_coarse_frequency_hz,
block_samples,
hop,
)
if not np.array_equal(times, high_times):
raise RuntimeError("Временные сетки двух тонов не совпали")
low_residual_hz, low_rms_rad = _weighted_phase_frequency(times, low_projections)
high_residual_hz, high_rms_rad = _weighted_phase_frequency(times, high_projections)
spacing_residual_hz, paired_rms_rad = _weighted_phase_frequency(
times,
high_projections * np.conj(low_projections),
)
individual_low_hz = low_coarse_frequency_hz + low_residual_hz
individual_high_hz = high_coarse_frequency_hz + high_residual_hz
center_hz = (individual_low_hz + individual_high_hz) / 2.0
spacing_hz = (
high_coarse_frequency_hz
- low_coarse_frequency_hz
+ spacing_residual_hz
)
low_frequency_hz = float(center_hz - spacing_hz / 2.0)
high_frequency_hz = float(center_hz + spacing_hz / 2.0)
residual_cfo_hz = float(
center_hz
- (low_coarse_frequency_hz + high_coarse_frequency_hz) / 2.0
)
phase_fit_rmse_rad = float(
np.sqrt(np.mean(np.square((low_rms_rad, high_rms_rad, paired_rms_rad))))
)
numeric = (
low_frequency_hz,
high_frequency_hz,
center_hz,
spacing_hz,
residual_cfo_hz,
phase_fit_rmse_rad,
low_rms_rad,
high_rms_rad,
paired_rms_rad,
)
valid = bool(all(np.isfinite(value) for value in numeric))
if not valid:
nan = float("nan")
return TwoTonePhaseRefinement(
valid=False,
invalid_reason="фазовое уточнение вернуло невычислимое значение",
low_frequency_hz=nan,
high_frequency_hz=nan,
carrier_offset_hz=nan,
tone_spacing_hz=nan,
residual_cfo_hz=nan,
phase_fit_rmse_rad=nan,
low_phase_fit_rms_rad=nan,
high_phase_fit_rms_rad=nan,
paired_phase_fit_rms_rad=nan,
block_count=int(len(low_projections)),
)
return TwoTonePhaseRefinement(
valid=True,
invalid_reason="",
low_frequency_hz=low_frequency_hz,
high_frequency_hz=high_frequency_hz,
carrier_offset_hz=float(center_hz),
tone_spacing_hz=float(spacing_hz),
residual_cfo_hz=residual_cfo_hz,
phase_fit_rmse_rad=phase_fit_rmse_rad,
low_phase_fit_rms_rad=float(low_rms_rad),
high_phase_fit_rms_rad=float(high_rms_rad),
paired_phase_fit_rms_rad=float(paired_rms_rad),
block_count=int(len(low_projections)),
)
def apply_coarse_frequency_correction(
samples: np.ndarray,
carrier_offset_hz: float,
sample_rate_hz: float,
) -> np.ndarray:
"""Убрать измеренный сдвиг несущей из комплексных отсчётов."""
received = np.asarray(samples, dtype=np.complex128)
if received.ndim != 1:
raise ValueError("Комплексные отсчёты должны быть одномерными")
if not np.isfinite(carrier_offset_hz):
raise ValueError("Сдвиг несущей должен быть конечным")
if not np.isfinite(sample_rate_hz) or sample_rate_hz <= 0.0:
raise ValueError("Частота дискретизации должна быть положительной")
sample_indexes = np.arange(len(received), dtype=np.float64)
correction = np.exp(
-1j * 2.0 * np.pi * carrier_offset_hz * sample_indexes / sample_rate_hz
)
return received * correction
def resample_for_clock_scale(
samples: np.ndarray,
clock_scale: float,
) -> np.ndarray:
"""Передискретизировать отсчёты на временную сетку передатчика.
Используется комплексная линейная интерполяция. Она детерминирована,
не требует рационального приближения ppm-отношения и подходит для
сильно передискретизированного BPSK-тракта. Направление преобразования
закрепляется синтетическими тестами для ошибок обоих знаков.
"""
received = np.asarray(samples, dtype=np.complex128)
if received.ndim != 1:
raise ValueError("Комплексные отсчёты должны быть одномерными")
if not np.isfinite(clock_scale) or clock_scale <= 0.0:
raise ValueError("Масштаб такта должен быть положительным")
if received.size == 0:
return received.copy()
if received.size == 1:
return np.repeat(received, max(1, round(clock_scale)))
output_length = max(1, round(len(received) * clock_scale))
output_indexes = np.arange(output_length, dtype=np.float64)
source_positions = np.minimum(output_indexes / clock_scale, len(received) - 1.0)
source_indexes = np.arange(len(received), dtype=np.float64)
real = np.interp(source_positions, source_indexes, received.real)
imaginary = np.interp(source_positions, source_indexes, received.imag)
return real + 1j * imaginary
def estimate_known_pilot_carrier(
received_symbols: np.ndarray,
known_symbols: np.ndarray,
symbol_rate: float = SYMBOL_RATE,
block_symbol_count: int = KNOWN_PILOT_BLOCK_SYMBOL_COUNT,
minimum_block_coherence: float = MINIMUM_KNOWN_PILOT_COHERENCE,
maximum_phase_fit_rmse_rad: float = MAXIMUM_KNOWN_PILOT_PHASE_FIT_RMSE_RAD,
) -> KnownPilotCarrierEstimate:
"""Оценить малую остаточную CFO по отдельному известному BPSK-пилоту.
Известные знаки удаляются, затем символы объединяются в короткие
когерентные блоки. Развёрнутая фаза блоков аппроксимируется устойчивой
линейной моделью Huber. Полезная нагрузка для оценки не используется.
"""
received = np.asarray(received_symbols, dtype=np.complex128)
known = np.asarray(known_symbols, dtype=np.complex128)
if received.ndim != 1 or known.ndim != 1:
raise ValueError("Принятый и известный пилоты должны быть одномерными")
if len(received) != len(known):
raise ValueError("Принятый и известный пилоты должны иметь одинаковую длину")
if not np.isfinite(symbol_rate) or symbol_rate <= 0.0:
raise ValueError("Символьная скорость должна быть положительной")
if not isinstance(block_symbol_count, (int, np.integer)) or block_symbol_count < 2:
raise ValueError("Размер когерентного блока должен быть целым и не меньше двух")
if not 0.0 < minimum_block_coherence <= 1.0:
raise ValueError("Порог когерентности должен находиться в интервале (0, 1]")
if not np.isfinite(maximum_phase_fit_rmse_rad) or maximum_phase_fit_rmse_rad <= 0.0:
raise ValueError("Порог ошибки фазовой модели должен быть положительным")
if not np.all(np.isfinite(received)) or not np.all(np.isfinite(known)):
raise ValueError("Пилот не должен содержать нечисловые значения")
if np.any(np.abs(known) <= 0.0):
raise ValueError("Все известные символы пилота должны быть ненулевыми")
block_count = len(received) // block_symbol_count
if block_count < MINIMUM_KNOWN_PILOT_BLOCK_COUNT:
raise ValueError(
f"Для оценки нужны не менее {MINIMUM_KNOWN_PILOT_BLOCK_COUNT} когерентных блоков"
)
usable_count = block_count * block_symbol_count
despread = received[:usable_count] * np.conj(known[:usable_count])
blocks = despread.reshape(block_count, block_symbol_count)
block_sums = np.sum(blocks, axis=1)
block_magnitude_sums = np.sum(np.abs(blocks), axis=1) + 1e-12
block_coherences = np.abs(block_sums) / block_magnitude_sums
mean_block_coherence = float(np.mean(block_coherences))
centers_symbols = (
np.arange(block_count, dtype=np.float64) * block_symbol_count
+ (block_symbol_count - 1) / 2.0
)
center_times_seconds = centers_symbols / symbol_rate
phases = np.unwrap(np.angle(block_sums))
base_weights = np.maximum(np.abs(block_sums), 1e-12)
design = np.column_stack((np.ones(block_count), center_times_seconds))
weights = base_weights.copy()
coefficients = np.zeros(2, dtype=np.float64)
for _iteration in range(12):
root_weights = np.sqrt(weights)
new_coefficients, *_unused = np.linalg.lstsq(
design * root_weights[:, np.newaxis],
phases * root_weights,
rcond=None,
)
residuals = phases - design @ new_coefficients
residual_median = float(np.median(residuals))
robust_scale = (
1.4826 * float(np.median(np.abs(residuals - residual_median)))
+ 1e-9
)
huber_limit = 1.345 * robust_scale
robust_weights = np.ones_like(residuals)
outliers = np.abs(residuals) > huber_limit
robust_weights[outliers] = huber_limit / np.abs(residuals[outliers])
weights = base_weights * robust_weights
if np.allclose(coefficients, new_coefficients, rtol=0.0, atol=1e-12):
coefficients = new_coefficients
break
coefficients = new_coefficients
residuals = phases - design @ coefficients
phase_fit_rmse_rad = float(
np.sqrt(np.sum(base_weights * residuals**2) / np.sum(base_weights))
)
frequency_hz = float(coefficients[1] / (2.0 * np.pi))
initial_phase_rad = float(coefficients[0])
phase_increment = float(2.0 * np.pi * frequency_hz / symbol_rate)
invalid_reasons: list[str] = []
if not all(
np.isfinite(value)
for value in (frequency_hz, initial_phase_rad, phase_fit_rmse_rad, mean_block_coherence)
):
invalid_reasons.append("оценка содержит нечисловое значение")
if mean_block_coherence < minimum_block_coherence:
invalid_reasons.append(
f"когерентность {mean_block_coherence:.4f} ниже порога {minimum_block_coherence:.2f}"
)
if phase_fit_rmse_rad > maximum_phase_fit_rmse_rad:
invalid_reasons.append(
f"RMSE фазы {phase_fit_rmse_rad:.4f} выше порога {maximum_phase_fit_rmse_rad:.2f} рад"
)
valid = not invalid_reasons
nan = float("nan")
return KnownPilotCarrierEstimate(
valid=valid,
invalid_reason="; ".join(invalid_reasons),
frequency_hz=frequency_hz if valid else nan,
phase_increment_rad_per_symbol=phase_increment if valid else nan,
initial_phase_rad=initial_phase_rad if valid else nan,
phase_fit_rmse_rad=phase_fit_rmse_rad,
mean_block_coherence=mean_block_coherence,
block_count=block_count,
known_symbol_count=len(known),
)
# Перенесено дословно из Lab023 (tests/lab023_guarded_cfo_correction.py). # Перенесено дословно из Lab023 (tests/lab023_guarded_cfo_correction.py).
def estimate_carrier_parameters( def estimate_carrier_parameters(
received_symbols: np.ndarray, received_symbols: np.ndarray,
@@ -800,3 +1338,35 @@ def radio_frame_bit_count(protocol_packet_bytes: int) -> int:
+ RADIO_HEADER_BIT_COUNT + RADIO_HEADER_BIT_COUNT
+ protocol_packet_bytes * 8 + protocol_packet_bytes * 8
) )
# Перенесено дословно из Lab023 (experiments/lab023_guarded_cfo_correction.py).
def should_apply_cfo_correction(
estimated_frequency_hz: float,
phase_consistency: float,
coherence_gain: float,
) -> bool:
"""
Разрешить CFO-коррекцию только при наличии
достаточных оснований.
Требования:
1. Оценка находится вне мёртвой зоны.
2. Межсимвольное вращение достаточно согласованно.
3. Компенсация CFO действительно повышает
когерентность известного маркера.
"""
MINIMUM_COHERENCE_GAIN = 0.02
return (
abs(
estimated_frequency_hz
)
>= CFO_DEAD_ZONE_HZ
and phase_consistency
>= MINIMUM_PHASE_CONSISTENCY
and coherence_gain
>= MINIMUM_COHERENCE_GAIN
)

20
requirements-hardware.txt Normal file
View 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
View 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

View File

@@ -9,6 +9,7 @@
from __future__ import annotations from __future__ import annotations
import ast import ast
import math
import pathlib import pathlib
import struct import struct
@@ -210,3 +211,158 @@ def test_frame_search_finds_the_start_in_a_shaped_signal() -> None:
recovered = found["symbol_samples"][start : start + len(bits)] recovered = found["symbol_samples"][start : start + len(bits)]
assert radio.parse_radio_frame(radio.bpsk_demodulate(recovered)) == packet 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

View 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

View 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

View 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

288
tests/test_session_state.py Normal file
View 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))

View File

@@ -3,9 +3,10 @@
Проверяет за одну-две секунды то, что ломается чаще всего: Проверяет за одну-две секунды то, что ломается чаще всего:
1. Синтаксис всех файлов в protocol, tests и tools. 1. Синтаксис всех файлов в protocol, tests, experiments и tools.
2. Импорт каждого модуля protocol. 2. Импорт каждого модуля protocol.
3. Функциональные проверки тех лаб, которым не нужны данные эксперимента. 3. Быстрые проверки ядра из tests под pytest.
4. Функциональные проверки тех лаб, которым не нужны данные эксперимента.
Полный прогон лабы шлюз не заменяет. Полный прогон лабы шлюз не заменяет.
Он отвечает на один вопрос: не сломана ли кодовая база прямо сейчас. Он отвечает на один вопрос: не сломана ли кодовая база прямо сейчас.
@@ -19,7 +20,10 @@ from __future__ import annotations
import ast import ast
import importlib import importlib
import inspect import inspect
import os
from pathlib import Path from pathlib import Path
import re
import subprocess
import sys import sys
import traceback import traceback
@@ -62,6 +66,41 @@ def check_protocol_imports() -> list[str]:
return failures 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]: def check_functional_tests() -> tuple[list[str], int, int]:
""" """
Выполнить функциональные проверки лаб, не требующие данных эксперимента. Выполнить функциональные проверки лаб, не требующие данных эксперимента.
@@ -128,8 +167,9 @@ def main() -> int:
return 1 return 1
import_failures = check_protocol_imports() import_failures = check_protocol_imports()
core_failures, passed = check_core_tests()
test_failures, executed, skipped = check_functional_tests() test_failures, executed, skipped = check_functional_tests()
failures = import_failures + test_failures failures = import_failures + core_failures + test_failures
if failures: if failures:
print("Шлюз не пройден") print("Шлюз не пройден")
@@ -137,7 +177,11 @@ def main() -> int:
print(f" {failure}") print(f" {failure}")
return 1 return 1
print(f"Шлюз пройден: синтаксис в порядке, protocol импортируется, функциональных проверок выполнено {executed}, пропущено {skipped}") print(
"Шлюз пройден: синтаксис в порядке, protocol импортируется, "
f"тестов ядра пройдено {passed}, "
f"функциональных проверок лаб выполнено {executed}, пропущено {skipped}"
)
return 0 return 0