# Стенд Реальная станция с 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`. При парольной аутентификации мастер-соединение открывается один раз, вручную: ```bash 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` возвращает как было. Возвращать обязательно: пока поднята тестовая, рабочая не принимает ничего. ## Цикл ```bash tools/stand.sh contract # дифф аргументов клиент ↔ диспетчер, ничего не останавливая tools/stand.sh deploy # выкатить src/, погасив рабочую станцию tools/stand.sh next-pass 5 # ближайшие проходы из расписания портала tools/stand.sh watch # дождаться конца прохода и постобработки tools/stand.sh collect # забрать артефакты в $STAND_OUT// tools/stand.sh verdict # проверки по таблице tools/stand.sh deploy --restore # вернуть рабочую станцию ``` `deploy` монтирует `src/` поверх `/src` в контейнере и образ **не пересобирает**: правки `Dockerfile`, `scripts/`, C++ и всего, что приезжает из соседних репозиториев, так не доедут. Побочный эффект — `src/core` перекрывает сгенерированный в образе `_version.py`, и станция сообщает порталу версию из исходников, а не из тега образа. ## Приёмка релиза: `deploy --no-src` Для проверки опубликованного образа перед раскатом на парк монтировать исходники нельзя — из-за того самого побочного эффекта станция сообщит порталу заглушку `0.0.0`, и разделить наблюдения по версиям будет нечем. ```bash export STAND_IMAGE=sonikspace/soniks-client:2.3.0 tools/stand.sh deploy --no-src ``` Флаг убирает и `rsync src/`, и монтирование: контейнер работает ровно тем кодом, что уедет в парк, и рапортует порталу версию из тега. `STAND_IMAGE` обязателен — без него проверять нечего, скрипт откажется. Так же проверяется `HEALTHCHECK` образа: репозиторный `docker-compose.yml` на стенд не едет, у станции свой, поэтому здоровье считает именно то, что записано в `Dockerfile`. ```bash 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/`. Правка в них доезжает до стенда только пересборкой: ```bash 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`. ```{warning} Собранный на стенде образ — один 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`. Запрос исполняется со стенда — сеть до портала там заведомо есть. ## Дифф контракта `contract` (и `deploy` в конце своей работы) печатает сравнение аргументов, которые построил бы `Flowgraph`, с опциями, которые понимает `flowgraph_dispatcher`. Стоит секунды, а ловит весь класс отказов «диспетчер не знает аргумент»: `parse_args()` выходит с кодом 2 **до** запуска графа, `Popen` при этом успешен, и клиент через секунду штатно закрывает наблюдение. Аргументы со значением `None` графу не уезжают, но в диффе печатаются тоже — станция, задавшая такой параметр в своём `.env`, получит ровно тот же тихий отказ. Строки с пометкой «латентный» — заряженные мины, а не шум. `contract` ничего не останавливает и тестовой станции не требует, поэтому гонять его можно прямо на работающей станции — и стоит после каждого обновления образа, где могли разъехаться версии клиента и флоуграфов. ## Таблица вердикта Источник — [](../roadmap-network.md), раздел «Стенд». Три строки проверяются не так прямо, как звучат: | Проверка | Порог | Чем является на самом деле | |---|---|---| | код возврата диспетчера | 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`: ```bash 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` ([](../roadmap-network.md)). Сравнивать имеет смысл только сопоставимые выборки — состав спутников и высоты пролётов между периодами обязаны совпадать, иначе разница расписания выдаст себя за разницу версий.