Стенд

Реальная станция с SDR, подключённая к живой сети, — единственный способ проверить клиент по-настоящему. Ни ruff, ни pytest, ни чтение кода не ловят класс отказов, ради которого стенд и нужен: граф, умерший до первого отсчёта, выглядит снаружи как досрочно закончившееся наблюдение, без единой ошибки в логе.

tools/stand.sh — набор команд для работы со стендом. Определение агента, который ими пользуется, — в .claude/agents/stand.md.

Настройка

Ничего специфичного для конкретного стенда в репозитории нет. Адрес, каталоги и учётные данные задаются переменными окружения и ~/.ssh/config:

Переменная

Смысл

По умолчанию

STAND_SSH

назначение ssh, alias из ~/.ssh/config

soniks-stand

STAND_DIR

каталог тестовой станции на стенде

~/soniks-stand

STAND_PROD_DIR

каталог рабочей станции, гасимой на время работы

пусто — не гасить

STAND_IMAGE

тег образа тестовой станции

пусто — общий с рабочей

STAND_VERSION

версия, которую собранный образ сообщает порталу

git describe плюс -stand

STAND_BUILD_JOBS

параллельных заданий сборки

2

STAND_BASE_IMAGE

готовая база для build: собирается только клиентская стадия поверх неё

пусто — полная сборка

STAND_OUT

локальный каталог собранных артефактов

./stand-runs

STAND_WATCH_TIMEOUT

предел ожидания конца прохода, секунды

7200

STAND_SETTLE

добор после прохода на постобработку и выгрузку, секунды

180

Соединение мультиплексируется через ControlMaster. При парольной аутентификации мастер-соединение открывается один раз, вручную:

ssh -o ControlMaster=auto -o ControlPath=~/.ssh/cm-stand -o ControlPersist=10m "$STAND_SSH" true

Дальше stand.sh ходит по готовому сокету и пароля не спрашивает.

Тестовой станции нужен свой .env в $STAND_DIR. Две переменные в нём обязаны отличаться от станционных умолчаний, иначе собирать будет нечего:

OBSERVATION__REMOVE_OBSERVATION_DATA=False     # каталог едет в complete/, а не удаляется
OBSERVATION__REMOVE_WATERFALL_RAW_FILES=False  # сырой .dat выживает для вердикта

Взаимоисключение станций

Если на стенде уже стоит рабочая станция, одновременно с тестовой она работать не может: container_name — глобальное имя, а не имя в пределах проекта, да и SDR физически один. deploy гасит рабочую и поднимает тестовую, deploy --restore возвращает как было. Возвращать обязательно: пока поднята тестовая, рабочая не принимает ничего.

Цикл

tools/stand.sh contract          # дифф аргументов клиент ↔ диспетчер, ничего не останавливая
tools/stand.sh deploy            # выкатить src/, погасив рабочую станцию
tools/stand.sh next-pass 5       # ближайшие проходы из расписания портала
tools/stand.sh watch <id>        # дождаться конца прохода и постобработки
tools/stand.sh collect <id>      # забрать артефакты в $STAND_OUT/<id>/
tools/stand.sh verdict <id>      # проверки по таблице
tools/stand.sh deploy --restore  # вернуть рабочую станцию

deploy монтирует src/ поверх /src в контейнере и образ не пересобирает: правки Dockerfile, scripts/, C++ и всего, что приезжает из соседних репозиториев, так не доедут. Побочный эффект — src/core перекрывает сгенерированный в образе _version.py, и станция сообщает порталу версию из исходников, а не из тега образа.

Приёмка релиза: deploy --no-src

Для проверки опубликованного образа перед раскатом на парк монтировать исходники нельзя — из-за того самого побочного эффекта станция сообщит порталу заглушку 0.0.0, и разделить наблюдения по версиям будет нечем.

export STAND_IMAGE=sonikspace/soniks-client:2.3.0
tools/stand.sh deploy --no-src

Флаг убирает и rsync src/, и монтирование: контейнер работает ровно тем кодом, что уедет в парк, и рапортует порталу версию из тега. STAND_IMAGE обязателен — без него проверять нечего, скрипт откажется.

Так же проверяется HEALTHCHECK образа: репозиторный docker-compose.yml на стенд не едет, у станции свой, поэтому здоровье считает именно то, что записано в Dockerfile.

docker inspect --format '{{json .State.Health}}' soniks-client-test
docker exec soniks-client-test wget -qO- http://127.0.0.1:8080/healthz

Пересборка образа

Диспетчер флоуграфов, gr-satnogs и скрипты лежат внутри образа, а не в src/. Правка в них доезжает до стенда только пересборкой:

export STAND_IMAGE=sonikspace/soniks-client:stand-test
tools/stand.sh build                 # rsync дерева в $STAND_DIR/build/ + docker build
tools/stand.sh deploy                # override пиннит STAND_IMAGE
tools/stand.sh contract              # ради чего всё и затевалось

STAND_IMAGE здесь не украшение. Оба compose пиннят latest-addons, и обе станции хоста — тестовая и рабочая — тянут один и тот же тег. Пересборка под этим именем молча подменила бы образ рабочей станции при deploy --restore. Отдельный тег разводит их: deploy вписывает image: в переопределение только когда переменная задана, без неё поведение прежнее.

Сборка идёт из исходников (gr-satnogs, .deb флоуграфов) и на одноплатнике занимает десятки минут. STAND_BUILD_JOBS по умолчанию 2, а не 4 как в Dockerfile: умолчание рассчитано на раннер CI, а на стенде памяти около гигабайта на ядро, и четыре g++ на шаблонах gr-satnogs уводят сборку в OOM.

Версию в образ build передаёт так же, как CI, — аргументом сборки CLIENT_VERSION. Без него уехала бы заглушка 0.0.0 из Dockerfile, станция сообщила бы её порталу, и наблюдения стенда стало бы нечем отличить от чужих. Умолчание — git describe --tags --always плюс суффикс -stand: 2.3.0-stand на самом теге, 2.3.0-1-ge1fe2fa-stand коммитом позже.

Суффикс не косметика. Собранный здесь образ — не тот артефакт, что уедет в парк: одна платформа, свой кэш, в registry не публикуется. Назовись он ровно тегом, его наблюдения смешались бы с наблюдениями настоящего релиза в одной выборке, и сравнение версий ниже показало бы не то, что нужно.

Корневой .env в build не попадает — исключён явно. Образу он и не нужен: станция получает окружение из .env в $STAND_DIR при up.

Предупреждение

Собранный на стенде образ — один arm64 локально, в registry он не уезжает. Для парка нужен релизный тег: мультиарх latest-addons публикует только джоба docker из .gitlab-ci.yml, и только по тегу.

docker-compose.yml на стенд тоже не едет: он там свой, от станции, и правками репозитория не обновляется. Поэтому ключи, которые прогон обязан проверить, продублированы в генерируемом docker-compose.override.yml — сейчас это PATHS__BASE и stop_grace_period. Меняете их в корневом docker-compose.yml — меняйте и в cmd_deploy, иначе стенд проверит не то, что уедет на станции.

next-pass спрашивает расписание анонимно: JobView на портале не задаёт permission_classes, токен нужен только чтобы обновился last_seen. Запрос исполняется со стенда — сеть до портала там заведомо есть.

Дифф контракта

contractdeploy в конце своей работы) печатает сравнение аргументов, которые построил бы Flowgraph, с опциями, которые понимает flowgraph_dispatcher. Стоит секунды, а ловит весь класс отказов «диспетчер не знает аргумент»: parse_args() выходит с кодом 2 до запуска графа, Popen при этом успешен, и клиент через секунду штатно закрывает наблюдение.

Аргументы со значением None графу не уезжают, но в диффе печатаются тоже — станция, задавшая такой параметр в своём .env, получит ровно тот же тихий отказ. Строки с пометкой «латентный» — заряженные мины, а не шум.

contract ничего не останавливает и тестовой станции не требует, поэтому гонять его можно прямо на работающей станции — и стоит после каждого обновления образа, где могли разъехаться версии клиента и флоуграфов.

Таблица вердикта

Источник — Дорожная карта: надёжность сети, раздел «Стенд». Три строки проверяются не так прямо, как звучат:

Проверка

Порог

Чем является на самом деле

код возврата диспетчера

0

косвенная: клиент код возврата нигде не логирует. Проверяется отсутствие unrecognized arguments плюс признаки живого графа в логе

unrecognized arguments

отсутствует

прямая. stderr диспетчера слит в stdout, строка доезжает до лога

payload.ogg

есть, размер > 0

прямая

водопад .dat

не меньше WATERFALL__MIN_VALID_SAMPLES

роадмап говорит «строк», код сравнивает отсчёты (plot.py:133). Вердикт следует коду, печатая рядом строки, каналы и отсчёты

client_metadata.signal.snr_db

есть, не null

прямая, читается из наблюдения на портале

файлы data_*

хотя бы один

WARN, не FAIL: «известность передатчика» из клиента не определяется

incomplete/

пусто

прямая

Вердикт — данные, а не приговор. FAIL на реальном проходе может означать и дефект клиента, и низкое прохождение, и молчавший спутник. Прежде чем объявлять находку, посмотрите snr_db и водопад и проверьте, повторяется ли отказ на соседнем проходе того же спутника.

Сравнение версий на портале

Вердикт судит один проход. Отличить дефект версии от геометрии пролёта одним проходом нельзя: шестиградусный пролёт не даёт кадров и на исправном клиенте. Нужна выборка — её собирает tools/portal_stats.py:

python3 tools/portal_stats.py --station 2 \
    --start 2026-08-26T00:00:00Z --end 2026-08-27T00:00:00Z

Только stdlib, анонимно, без базы и без браузера. Скрипт идёт по курсорной пагинации /api/observations/, разбирает client_metadata (строка JSON внутри JSON, величины отформатированы вместе с единицами: "15.8 dB") и группирует наблюдения по client_version — по версии на момент приёма, а не по текущей версии станции. Поэтому старые и новые наблюдения одной станции сравнимы напрямую, одной антенной и с одного места.

Столбец — наблюдения, у которых версии нет вовсе. Это не чужой клиент, а отказы: у них не бывает измеренного SNR. Их доля и есть главная величина приёмки. Если новая версия её увеличила, раскатывать нельзя, как бы ни выглядели остальные строки.

Метрики берутся из полей наблюдения, а не из status портала: тот производный от «пришёл ли хоть один кадр» и искажён дефектом rate_observation (Дорожная карта: надёжность сети). Сравнивать имеет смысл только сопоставимые выборки — состав спутников и высоты пролётов между периодами обязаны совпадать, иначе разница расписания выдаст себя за разницу версий.