Дорожная карта: закрытие техдолга¶
Живой документ. Ведётся между сессиями работы над кодом: сюда сложен полный результат аудита, порядок работ и отметки о выполнении. Правьте статусы прямо здесь по мере закрытия пунктов.
Статус на 2026-08-22: фазы 1, 2 и 3 закрыты целиком, вместе с ними закрыты
разделы «Тесты» и «Правки в документации». Архитектурный долг закрыт:
пункты 1, 3, 4, 5, 6, 7 сделаны, 2 и 8 отменены с обоснованием (первый
оказался уже сделанным, у второго нет лечения дешевле болезни). Эталон,
снятый перед выносом _around_observation, попутно вскрыл настоящий баг
живой станции — уборка после satnogs-post блокировала проход до
следующего; починен, см. пункт 3. Дальше — сессия на реальной станции.
Решения мейнтейнера по открытым вопросам приняты: версии зависимостей в CI
закреплены через uv.lock, свои дефолты у scripts/test-flowgraph.sh сняты,
наложившиеся проходы оставлены как есть (❌ с обоснованием), а
is_baudrate/framing перенесены в раздел совместной сессии — они заложены
на будущее, и потребителя для них определять вместе с диспетчером.
Открытыми остаются два пункта: --allow-downgrades в Dockerfile (1.5) и
корневой .env станции, который разошёлся с шаблоном (см. конец раздела
«Правки в документации»). Шесть пунктов ждут совместной сессии с
soniks-flowgraphs — они помечены ⏸.
Обозначения: ✅ сделано · 🔧 в работе · ⬜ не начато · ⏸ отложено до
совместной сессии с soniks-flowgraphs · ❌ отменено (с причиной)
Как это появилось¶
После того как была написана документация, проект начали развивать дальше — но под документацией остался слой долга. Отправной точкой были три симптома:
lint_pythonкрасный в GitLab CI и блокирует пайплайн;iq_dump_rename.shчитаетENABLE_IQ_DUMP/IQ_DUMP_FILENAMEбез префиксаFLOWGRAPH__, а соседнийmeteor.sh— с префиксом, поэтому переименование IQ-дампа при штатной конфигурации не срабатывало;bandscan.shссылался наFLOWGRAPH__RX_SAMP_RATEс лишней буквойE— такой переменной не существует.
Полный аудит (Python-код, shell-скрипты, CI/Docker, скрипты декодеров, сверка документации с кодом) показал, что за этими симптомами стоит ещё три класса проблем: потеря данных прохода, оборудование в неопределённом состоянии и фичи, которые задокументированы, но физически не работают.
Порядок работ: сначала зелёный CI, затем баги по разделам сверху вниз.
Поправки к исходным предположениям¶
Три вещи, которые считались верными, аудит опроверг. Здесь они зафиксированы, чтобы не воскресали.
Версии: «2.2 / 2.1 / 1.6» — это три разные сущности, а не одна¶
Тега soniks-flowgraphs:2.2 не существует и никогда не существовало: код
flowgraphs тянется из ветки soniks (Dockerfile, FLOWGRAPHS_BRANCH).
Проверять registry бессмысленно.
Где |
Что |
Было |
Чем является |
|---|---|---|---|
|
|
|
стамп для |
|
|
|
то же, но ниже дефолта — оттого и |
|
|
|
вот это и есть «2.2» из README |
|
|
|
локальная сборка ставила другую версию, чем CI |
|
|
|
то, что уходит на портал |
|
|
|
тег образа, которого никто не потреблял |
|
|
|
плейсхолдер от |
|
|
|
README обещал |
docker_lint не был сломан¶
Аудит предполагал, что джоба падает (DL3008 на неприпиненных apt-get и
т. п.). Проверка запуском hadolint/hadolint:v2.12.0-alpine на текущем
Dockerfile — exit 0. Джоба зелёная, чинить нечего.
SSTV работает и всегда работал¶
Аудит записал SSTV в мёртвые фичи, потому что искал пакет sstv в
packages.pip и packages.client. На самом деле он ставится из исходников
(git clone https://gitlab.com/k.starikov/sstv.git + pip install . в
Dockerfile). В силе остаются только баги самого враппера — они реальные и
бьют по работающей фиче.
Там же: "SSTV": SCRIPTS["FM"] — корректно. SCRIPTS["FM"] и
SCRIPTS["SSTV"] указывают на один и тот же satnogs_fm.py, а декодирует
библиотека sstv через sstv_wrapper.py. А вот SSTV_PD120 был сломан — см.
раздел 2.8.
Списки NORAD в sstv_wrapper.py и imagedecode.py — не дубль¶
Аудит записал «12+ NORAD перечислены независимо в двух таблицах» в дубликаты. На самом деле списки отвечают на разные вопросы, а пересечение случайно:
SSTVDecoder.SSTV_NORADS— «спутник передаёт SSTV»;*Decode.supported_norad— «этим кадрам подходит такой-то сборщик картинки».
Проверка по спискам: 59112, 61765, 61766, 63427, 67279 есть в списке
SSTV и ни в одном декодере изображений; 53382, 53384, 57217 —
наоборот. Свести их — завести ложную абстракцию, которая при первом же новом
спутнике разъедется.
BANDSCAN_SAMPLERATE действительно наследует FLOWGRAPH__RX_SAMP_RATE¶
Раздел «Правки в документации» требовал убрать это утверждение как ложное.
Проверено по scripts/bandscan.sh: строка
: "${BANDSCAN_SAMPLERATE:=$FLOWGRAPH__RX_SAMP_RATE}" на месте и работает.
Ложным было не оно, а соседнее предупреждение — про RX_SAMP_RATEE с удвоенной
E; опечатка исправлена в 2.4, и наследование с тех пор реально. Поэтому
утверждение оставлено, а удалено предупреждение.
Список мёртвого кода в фазе 3 был неполон¶
Аудит перечислил 17 имён и оценил их в «около 1000 строк». Проверка (grep по
каждому имени плюс анализ достижимости по AST от класса Waterfall): все 17
действительно мертвы, но не мертвы только они. Сверх списка не вызывались
ещё четыре функции — _interp_cross, _carrier_freq_row, _top2_peaks_per_row,
_peak_fwhm (последние три существовали ради удалённого
_estimate_deviation_fft_only) — и пять констант модуля: OFFSET_IN_STDS,
SCALE_IN_STDS, WATERFALL_GAUSSIAN_SIGMA_TIME, WATERFALL_GAUSSIAN_SIGMA_FREQ,
WATERFALL_GAUSSIAN_RADIUS. Итог — 915 строк (41 % файла), а не «около 1000».
Ловушка списка: _find_peaks мёртв, а похожий по имени _find_peaks_db —
живой, вызывается из _estimate_deviation_v7 дважды. Удаление по подстроке
снесло бы работающий оценщик.
satnogs-post ничего не должен выключать по GPIO¶
Аудит записал асимметрию: якобы satnogs-pre включает LNA и PA через
gpio.py -f, а satnogs-post их не выключает. Проверено по scripts/gpio.py:
флаг -f трогает только gpio1 — реле выбора антенны VHF/UHF по частоте
прохода. LNA (gpio0), PA (gpio2) и ROT (gpio3) переключаются флагами
-l, -p, -r, которых нет ни в одном хуке. Выключать после прохода нечего;
реле антенны остаётся в положении прошлого прохода и безусловно
переставляется в начале следующего.
--allow-downgrades нужен не только из-за build.sh¶
Пункт 1.5 записал, что флаг «требовал только downgrade из build.sh», и
потому «теперь не нужен». Проверка грепом: флаг стоит в трёх местах —
Dockerfile:48, :63 и :162. Первые два ставят локально собранные .deb,
версия которых берётся из dch -v $GRSATNOGS_VER и dch -v $FLOWGRAPHS_VER
(3.1.0.1 и 2.1); если в apt-репозитории лежит версия выше, это настоящий
downgrade — независимо от того, что делает build.sh. Проверить, так ли это
сейчас, можно только сборкой образа, поэтому снятие флага остаётся ⬜, но уже
не с формулировкой «выгоды нет», а «премиса неполна».
post_processing был покрыт тестом наполовину¶
Роадмап говорил, что post_processing и _around_observation «не покрыты
тестами». Для первого это неверно: tests/test_waterfall.py:162 уже вызывал
Observation.post_processing с утиным SimpleNamespace и покрывал ветку
«границы шкалы не прибиты гвоздями», заодно задевая удаление сырого .dat.
Не покрыты были сборка метаданных, блок signal, обе ветки отказа построения
и сохранение .dat при упавшем построении. У _around_observation премиса
подтвердилась: тестов не было ни одного.
Список архитектурного долга ошибался пять раз¶
Проверка премис всех восьми пунктов перед началом работы (см. раздел «Архитектурный долг»).
Пункт 2 был сделан целиком, а не «наполовину». Формулировка «конструктор
Observation уже принимает их аргументами, так что переход недорогой»
описывает работу, которая закончилась в 2.2: get_rig_controller() и
get_rotator_controller() с lru_cache стоят на месте (antenna/rig.py:83,
antenna/rotator.py:65), присваиваний вида rig_controller = RigController(...)
на уровне модуля в репозитории нет ни одного, единственная точка получения —
jobs/observation.py:43. Переходить не к чему.
Пункт 1 оказался вдвое меньше, чем «самая инвазивная правка в списке».
Проверено грепом по всему src/: settings трогается на уровне модуля один
раз — scheduler.py:10. Дефолтов аргументов, декораторов и модульных
констант, вычисляемых из настроек, — ноль; остальные 24 файла обращаются к
settings только внутри функций. Настоящий eager-эффект — не сам объект
настроек, а два mkdir (path.py, log.py) и экспортируемые имена
logger/raw_logger. Отсюда решение чинить побочки, а не объект.
Пункт 5: производителей Metadata три, а не два, и есть четвёртое,
конфликтующее описание того же контракта. Кроме Flowgraph.get_metadata и
Observation._get_metadata блок собирает post_processing
(metadata["signal"] = signal_metadata), а api.py объявлял параметр как
dict[str, dict[str, str]], не импортируя Metadata вовсе.
Пункт 6 подтверждён, но ловушка была теоретической. PATHS__OUTPUT,
PATHS__COMPLETE и PATHS__INCOMPLETE не встречаются ни в client/.env, ни в
docs/station/environment_variables.md — задокументированы только PATHS__BASE
и три PATHS__*_DIR. Никто на них не наступал. Зато рядом нашёлся настоящий
баг: raise RuntimeError("Ошибка при создании директории %s", path) —
%-форматирование в конструкторе исключения не работает, сообщение печаталось
кортежем.
Пункт 8 подтверждён формально, но чинить его нечем — отменён. Обоснование в самом пункте.
Фаза 1 — Зелёный CI ✅¶
1.1 ✅ Корневая причина lint_python: регресс ruff, а не кода¶
Проверено запуском:
ruff 0.8.4 (нижняя граница в pyproject) → All checks passed!
ruff 0.16.4 (что ставил `pip install ruff`) → Found 104 errors
.gitlab-ci.yml делал pip install ruff без пина. Ruff 0.16 расширил
набор правил по умолчанию до 416, а extend-select = ["I"] расширял уже их, а
не старые E4,E7,E9,F. Джоба сломалась сама, без единой правки кода.
Сделано:
версия ruff пиннута через переменную
GITLAB_CI_RUFF_VERSIONв.gitlab-ci.ymlи продублирована в[project.optional-dependencies].dev;extend-selectзаменён на явныйselect— пока набор правил неявный, следующий релиз ruff меняет поведение CI сам по себе.
1.2 ✅ Разбор 104 нарушений¶
Корзина |
Что сделано |
|---|---|
Автофикс |
|
Настоящие баги |
|
Осознанно выключено |
|
Автофикс UP031 ("%s" % x → .format) откачен вручную: в проекте принято
ленивое логирование logger.log(level, "...%s", value).
1.3 ✅ Убрано исключение gpio.py из ruff¶
pyproject.toml глушил весь файл ради 15 F403/F405 от двух
from mcp2221.* import *. Звёздочные импорты заменены на явные (8 имён),
исключение удалено, файл вернулся под линт.
Заодно там же починен copy-paste (см. 2.6): gpio_write_powerup_direction(0, …)
стоял во всех четырёх блоках, из-за чего GPIO1/2/3 не настраивались.
1.4 ✅ docker_lint — проверен, зелёный¶
См. «Поправки». Изменений не потребовалось.
1.5 ✅ Версии сведены к одному источнику¶
build.shпереписан: он больше не дублирует ARG изDockerfile. Раньше переопределялCLIENT_VERSION,GRSATNOGS_VERиFLOWGRAPHS_VERсвоими значениями, и локальная сборка молча отличалась от CI-сборки.Тег образа приведён к
sonikspace/soniks-client:latest-addons— тому, что ждут обаdocker-compose.yml. Теперь./build.sh && docker compose upподнимает собранный образ, а не тянет из registry.pyproject.tomlversionиsrc/core/_version.pyприведены к2.2.2(значениеARG CLIENT_VERSIONвDockerfile).В
Dockerfileдобавлен комментарий, что*_VERидут только в changelog.deb, а код берётся из ветки.
⬜ Осталось: --allow-downgrades в Dockerfile теперь не нужен (его требовал
только downgrade из build.sh), но снимать не стали — выгоды нет, риск
сломать пересборку есть.
1.6 ✅ Починена сборка колеса¶
packages = ["src/main"] указывал на несуществующий путь — проверено, что
hatchling собирал колесо молча и пустым. Теперь
packages = ["src/core", "src/soniks_client"]; проверено, что оба корня в
.whl присутствуют.
Заодно объявлен numpy>=1.26,<2 (граница была невидимой: GNU Radio 3.10
требует numpy<2, Dockerfile пиннил ==1.26.4, а uv.lock резолвил
2.2.2) и добавлен pytest в dev.
1.7 ✅ Добавлена джоба pytest¶
Единственный тест-файл не запускался в CI ни разу. Теперь запускается.
Заодно починен запуск тестов локально: любой импорт из src/ инстанцирует
Settings и читает .env, поэтому на машине разработчика тесты падали на его
собственной конфигурации станции — даже документированной командой. Введена
переменная SONIKS_ENV_FILE (по умолчанию .env, поведение не меняется), а
tests/conftest.py подменяет файл и выставляет обязательные STATION__*.
Теперь достаточно pytest tests -q.
1.8 ✅ Добавлена джоба lint_shell (shellcheck)¶
--severity=warning: info-уровень — это в основном намеренное word splitting
($ROTCTL как «host port», $ARGS как список аргументов флоуграфа).
1.9 ✅ Гигиена¶
.dockerignoreне исключал*.csv— три файла выгрузок вscripts/весом 16 МБ уезжали в/usr/local/binобраза при локальной сборке. Исключены и удалены физически..gitignore— добавлен.pytest_cache.sign_offпечатал подробное сообщение об ошибке прямо вgrep -q, который его выбрасывал: упавшая джоба не говорила, какой коммит виноват. Исправлено.В джобу
dockerдобавленGIT_SUBMODULE_STRATEGY: recursive— без него на чистом раннереsoniks-satyaml/пуст (дефолт GitLab —none), иCOPY soniks-satyaml/satyaml/*роняет сборку.
Фаза 2 — Баги¶
Порядок: сверху вниз по разделам. Раздел закрывается целиком, после каждого —
ruff check . и тесты.
2.1 Потеря данных прохода ✅¶
Статус |
Место |
Проблема |
|---|---|---|
✅ |
|
хвостовой |
✅ |
|
«уже загружено» логировалось как |
✅ |
|
|
✅ |
|
перехват вокруг |
✅ |
|
|
✅ |
|
при существующем приёмнике после |
✅ |
|
|
✅ |
|
перехват расширен до |
✅ |
|
|
✅ |
|
файл берётся в работу по |
✅ |
|
снято как следствие двух правок выше: досылка синхронная и происходит до постановки задания |
✅ |
|
|
Сознательно не сделано: пересканирование output/ заданием resending.
resending_jobs не знает, идёт ли сейчас проход, и подхватил бы output/<id>
работающего наблюдения, удалив директорию у него из-под ног. Директория,
оставленная в output/ из-за неудачного переноса в incomplete/, разбирается
оператором по записи в логе.
Заодно: tests/conftest.py подменяет Hamlib на MagicMock — пакет
собирается только внутри образа, а soniks_client.jobs.__init__ тянет его по
цепочке импортов, из-за чего тесты на выгрузку файлов не собирались ни на
хосте, ни в CI. То же самое делает docs/conf.py для autodoc.
2.2 Оборудование и жизненный цикл прохода ✅¶
Статус |
Место |
Проблема |
|---|---|---|
✅ |
|
|
✅ |
|
вывод скрипта читается отдельным потоком (как в |
🔧 |
|
синглтоны на импорте заменены на |
✅ |
|
|
✅ |
|
|
✅ |
|
|
✅ |
|
|
✅ |
|
события Skyfield группируются в целые пролёты «восход-кульминация-заход» ( |
✅ |
|
после |
✅ |
|
VFO читается один раз при |
✅ |
|
|
✅ |
|
стратегия помнит азимут упора, на который команда уже отправлена: повторная отправка и повторное предупреждение в лог подавляются до тех пор, пока цель не вернётся в диапазон |
✅ |
|
|
✅ |
|
|
✅ |
|
|
✅ |
|
|
✅ |
|
булевы флаги: |
2.3 Планировщик ✅¶
Статус |
Место |
Проблема |
|---|---|---|
✅ |
|
|
✅ |
|
|
✅ |
|
|
✅ |
|
|
✅ |
|
классификация по вхождению подстроки |
✅ |
|
|
✅ |
|
защита от параллельного обхода одной директории поставлена в саму |
❌ Наложившиеся проходы — отменено решением мейнтейнера. Премиса верна:
защиты нет, и MAX_INSTANCES=1 её не даёт — эта настройка APScheduler
ограничивает одновременные экземпляры одного задания по id, а
наложившиеся проходы приходят разными id. Но портал таких проходов станции
не выдаёт, то есть риск теоретический, а лечение — процесс-wide лок в
execute_observation — означало бы осознанный отказ от второго наблюдения.
Платить отказом за не встречающийся на практике случай не стали. Если портал
когда-нибудь начнёт выдавать пересекающиеся окна, образец лока в проекте уже
есть — _resending_lock в jobs/sending_data.py.
2.4 ✅ Shell-скрипты: env-имена¶
Расхождение оказалось шире, чем считалось: не два места, а четыре.
Статус |
Место |
Было |
Стало |
|---|---|---|---|
✅ |
|
|
|
✅ |
|
|
|
✅ |
|
|
|
❌ |
|
|
снято: скрипт удалён целиком, см. 2.8 |
Защита от повторения ✅ — добавлен tests/test_script_env_names.py: он
вытаскивает из scripts/* все имена вида SECTION__FIELD и сверяет со списком
полей pydantic Settings. Опечатка или потерянный префикс теперь роняют тест, а
не «просто молча не работают». Переменные без __ (BANDSCAN_*, GRSAT_*,
IQ_DUMP_RENAME, …) живут только в скриптах и намеренно не проверяются.
2.5 Shell-скрипты: остальное ✅¶
Статус |
Место |
Проблема |
|---|---|---|
✅ |
|
|
✅ |
|
|
✅ |
|
целочисленное сравнение |
✅ |
|
не было |
✅ |
|
PID-файл ключуется по |
✅ |
|
директория наблюдения создаётся перед декодированием. Заодно исправлен сам путь: |
✅ |
|
|
✅ |
|
|
✅ |
|
признак «включено» приведён к общему для проекта |
✅ |
|
добавлен |
✅ |
|
|
✅ |
|
вызывает диспетчер и принимает режим первым аргументом: |
✅ |
|
дефолты проставлены на все девять читаемых переменных, включая |
✅ |
|
|
✅ |
|
позиция вынесена в |
✅ |
|
путь до |
✅ |
|
работа перенесена в |
✅ |
|
добавлены |
❌ |
|
асимметрия по GPIO — премиса неверна, см. «Поправки». Правка снята |
Побочно закрыто: client/.env получил блок скриптовых переменных
(ROT_PARK*, GPIO_ENABLE, IQ_DUMP_*, METEOR_NORAD, BANDSCAN_*) с
пояснением, что признак «включено» везде TRUE/YES/1 без учёта регистра.
Раньше документация предупреждала, что опечатка в них тихо отключает функцию,
а самих переменных в шаблоне не было ни одной.
Тест из 2.4 сработал: tests/test_script_env_names.py поймал
FLOWGRAPH__BAUD в новой версии test-flowgraph.sh — такого поля в Settings
нет, скорость передачи приходит с портала вместе с проходом.
2.6 Скрипты-декодеры ✅¶
Статус |
Место |
Проблема |
|---|---|---|
✅ |
|
|
✅ |
|
Lucky7 и Sharjahsat делали |
✅ |
|
все |
✅ |
|
две ветки с байт-в-байт одинаковыми телами схлопнуты (нашёл |
✅ |
|
|
✅ |
|
|
✅ |
|
уровень логирования берётся через |
✅ |
|
|
✅ |
|
|
✅ |
|
|
✅ |
|
справка уходит в |
✅ |
|
|
✅ |
|
лог открывается на |
✅ |
|
|
✅ |
|
пути строятся из |
✅ |
|
|
✅ |
|
точное |
✅ |
|
|
✅ |
|
разбор вынесен в новый |
❌ |
|
NORAD в двух таблицах — премиса неверна, см. «Поправки». Правка снята |
✅ |
|
удалён вместе со строкой в |
2.7 Водопад ✅¶
Статус |
Место |
Проблема |
|---|---|---|
✅ |
|
ветка |
✅ |
|
метка времени водопада была naive, а маркеры кадров — aware; работало только по совпадению |
✅ |
|
|
✅ |
|
|
✅ |
|
|
✅ |
|
удаление DC-пика выполняется только при |
✅ |
|
ветка |
✅ |
|
введён флаг |
✅ |
|
|
✅ |
|
|
2.8 Фичи, которые не работали ✅¶
Удалено (решение: сценариев использования нет):
Статус |
Что |
Почему |
|---|---|---|
✅ |
SatDump целиком |
бинарь |
✅ |
|
спутник JY1Sat (43803) сошёл с орбиты. Удалён класс и ветка диспетчера; снялась находка про |
К починке:
Статус |
Фича |
Проблема |
|---|---|---|
⏸ |
Режим |
правка на стороне клиента в одну строку ( |
✅ |
|
проверено: |
✅ |
|
|
✅ |
|
возвращён как дополнение к |
✅ |
|
заработал сам вместе с |
2.9 CI/Docker — структурные риски ✅¶
Статус |
Место |
Проблема |
|---|---|---|
✅ |
джоба |
сабмодуль |
✅ |
|
16 МБ CSV уезжали в образ |
✅ |
джоба |
сборка шла на каждом коммите в основную ветку под три платформы, две из трёх под QEMU и сериализованные |
✅ |
|
|
✅ |
|
тег релиза перезаписывал |
✅ |
|
|
✅ |
|
|
✅ |
|
безусловная замена прошивки UHD B210 вариантом LibreSDR — так задумано, станции только на LibreSDR; теперь это написано в комментарии. |
✅ |
корневой |
добавлен |
✅ |
|
|
✅ |
|
|
✅ |
|
опечатка |
✅ |
|
|
✅ |
|
всплыло по ходу сессии: namespace исчерпал квоту compute-минут шаренных раннеров, и пайплайн на них не стартует вообще. Джобы адресованы раннеру проекта #38543418 «SONIKS Dev Stand» через |
Фаза 3 — waterfall.py: разбить на модули, логику не менять ✅¶
Было 2214 строк и ~40 приватных функций в одном файле. Стало: 915 строк удалено, остаток разложен в пакет из трёх модулей. Ни одна живая строка не менялась — только удаление и перенос срезами.
✅ Подтверждена смерть.
grep -rnпоsrc/,scripts/,tests/,docs/(безdocs/_build/) по всем 17 именам плюс сплошной анализ достижимости по AST от классаWaterfall. Все 17 мертвы, но список аудита оказался неполон — см. «Поправки». Удаление велось по результату анализа, а не по списку.✅ Эталон зафиксирован на синтетическом
.dat(реального в репозитории нет, см. новый пункт про доступ к станции). Генератор пишет 800 строк × 1024 бина: шум −100 дБ, всплески с двумя тонами 2-FSK на ±800 Гц, остаточный доплеровский снос и посторонняя CW-помеха на +9 кГц. Оценщик на нём срабатывает содержательно —2-FSK / wide-FSK (h≥1), Δf = 799.22 Гц при заданных 800, SNR 37.3 дБ, помеха отброшена; вanalysis66 ключей. Снимок:sha256PNG,get_signal_metadata()и весьanalysisцеликом. Прогон дважды подряд даёт тот жеsha256— эталон детерминирован. Файлы эталона лежат вне репозитория и в git не уезжают.✅ Мёртвое удалено: 20 функций, 5 констант,
_SDR_CMAPи шапка «ОЦЕНКА ДЕВИАЦИИ V5» (описывала удалённый оценщик). 2214 → 1261 строка (−41 %). Заодно исчезли два локальных импорта необъявленных зависимостей —scipy.ndimage.convolve1dиephem: ни того, ни другого нет вpyproject.toml, обе строки были недостижимы.✅ Остаток разложен в
src/soniks_client/waterfall/:dsp.py(333 строки, 12 примитивов, зависит только от numpy),deviation.py(519 строк,_estimate_deviation_v7как есть + шапка алгоритма V7),plot.py(434 строки, классWaterfallи весь matplotlib),__init__.py(11 строк, реэкспорт).observation.pyне менялся —from soniks_client.waterfall import Waterfallработает через реэкспорт. Вtests/test_waterfall.pyпоправлена одна фикстура:norm_callsбралаwaterfall_module.mcolors, теперь берётwaterfall.plot.mcolors— патчится тот же объект модуля matplotlib.✅ Сверено с эталоном после шага 3 и после шага 4:
sha256PNG совпадает побайтово,get_signal_metadata()и все 66 ключейanalysisравны. Плюс 91 тест иruffзелёные после каждого шага.
Документация приведена к новой структуре: development/waterfall.md описывает
пакет таблицей «модуль — что внутри — зависимости» вместо «~2200 строк и
несколько поколений оценщиков»; development/architecture.md — ~1300 строк
вместо ~2200; api/soniks_client.md разложен на три automodule
(.plot с :members: Waterfall, .deviation, .dsp), проверено строгой
сборкой -W: предупреждений нет, в api/soniks_client.html те же 34 записи и
Waterfall.plot / .get_signal_metadata / .analysis на месте.
Рефакторинг самого _estimate_deviation_v7 (455 строк, словарь на 80 ключей,
секция «legacy keys» внутри) — отдельная задача, в разбиение не входила.
Тесты¶
Было: один файл на 106 строк — стратегии слежения.
Статус |
Что |
|---|---|
✅ |
|
✅ |
|
✅ |
|
✅ |
|
✅ |
|
✅ |
|
✅ |
|
✅ |
|
✅ |
|
✅ |
|
✅ |
|
✅ |
|
✅ |
|
✅ |
|
✅ |
|
Красный CI после фазы 3 — test_pass_azimuths_are_stable падал на
256.9077 != 256.85 ± 0.01. К водопаду отношения не имеет: локально тесты
зелёные, потому что uv run берёт uv.lock со skyfield 1.49, а джоба
pytest ставит pip install . pytest без lock и получает 1.55. Причина
воспроизведена в чистом venv: азимуты восхода и захода считаются по
пересечению горизонта и зависят от встроенных в skyfield таблиц ΔT, которые
обновляются с релизами — AOS уезжает на 0.055°, LOS на 0.041°, кульминация
(экстремум, а не пересечение) совпадает точно. Допуск в тесте поднят с 0.01°
до 0.5° и вынесен в именованную константу с объяснением: соседний пролёт
отличается на 3.5°, 19.5° и 35.6°, поэтому проверка «выбран тот пролёт» —
ради которой тест и написан — держится с запасом на порядок. Прогнано в обоих
окружениях: 91 тест зелёный и на 1.49, и на 1.55.
✅ Версии зависимостей в CI закреплены. Джоба pytest переведена с
pip install . pytest на uv sync --locked --extra dev + uv run --no-sync:
версии берутся строго из uv.lock, а не перерешиваются по диапазонам
pyproject.toml. Проверено локально, что гарантия настоящая — при сдвиге
границы skyfield в pyproject.toml команда возвращает 1 с «lockfile needs
to be updated», а не молча резолвит заново. Следствие записано в
contributing.md: правка зависимости требует uv lock в том же коммите.
uv ставится в существующий образ через pip, а не сменой
GITLAB_CI_PYTHON_IMAGE — его используют три джобы. Версия запинена
переменной GITLAB_CI_UV_VERSION по образцу GITLAB_CI_RUFF_VERSION:
uv.lock имеет revision = 3, старый uv его не прочитает.
⬜ Осознанный остаток: джоба pages осталась на pip install . -r docs/requirements.txt. Её зависимости в uv.lock не описаны, а сборка
документации не является гейтом корректности станции. Если Sphinx или тема
сломают сборку чужим релизом — лечить пином в docs/requirements.txt.
Правки в документации ✅¶
Документация в целом точна — проверена поле за полем. Правилось то, что разъехалось с кодом за фазу 2.
✅
station/environment_variables.md—WATERFALL__AUTORANGE/THRESHOLD/MIN_VALID_SAMPLESописаны по фактическому коду: автоподбор включается, когдаplot()вызван без границ; порог работает в дополнение кnp.isfinite;DEFAULT_MIN/MAX_VALUE— фолбэк при нехватке отсчётов. Снято неверное «нижеMIN_VALID_SAMPLESводопад считается пустым и не выгружается» — на выгрузку настройка не влияет вовсе. В таблицу bandscan дописаныBANDSCAN_DEVICEиAPP_PATH, обязательностьFLOWGRAPH__RX_SAMP_RATE/ANTENNAи дефолтыPPM_ERROR/RF_GAIN/OTHER_SETTINGS. ДобавленыGRSAT_APP,SSTV_TIMEOUT,ROT_PARK_POSITIONиOBSERVATION__SCRIPT_TIMEOUT_IN_SECONDS(тип по коду —float, неint). Удалён раздел SatDump. Исправлено описаниеFLOWGRAPH__UDP_DUMP_HOST/PORT: единственный потребитель —grsat.py, а не «SatDump и другие внешние декодеры»✅
station/configuration.md— «без этих пяти значений клиент не запустится» уточнено: не стартует он без четырёх числовых, аSTATION__TOKEN=проходит валидацию как пустая строка. Добавлено отдельное предупреждение — с пустым токеном клиент поднимется, но портал не примет ни один запрос. Вoperations.mdформулировки про пять значений не оказалось, а вenvironment_variables.mdпредупреждение добавлено рядом с таблицей✅
development/scripts.md— удалены разделы про SatDump (satdump.sh+satdump_transfer.py), строкаExternalDecodeиз таблицы декодеров и упоминаниеsatdump.shв описанииsatnogs-pre. Раздел «Известные расхождения» удалён целиком: оба блока закрыты в 2.4. Описана сигнатураtest-flowgraph.sh [MODE] [FREQ] [BAUD]и работа через диспетчер. В разделеsat.cfgописан путь файла и копирование в~/.gr_satellites/при первом запускеgrsat.pyс сохранением правок оператора. Уточнён статусLucky7Decode/SharjahsatDecode: к диспетчеру подключены, в коде помеченыWIP, разбор кадров починен в 2.6✅
development/waterfall.md— сигнатураplot(vmin, vmax); в список исключений добавленTimestampError; отмечено, чтоn_spec_peaks,n_bursts,n_valid_rowsуходят числами, аmodulation/deviation_method/method— текстом; ключmethodдобавлен в таблицу вместе с пояснением про legacy-bw99_hz. Описано, чтоget_signal_metadata()доplot()отдаётNone, а сырой.datпереживает упавшее построение✅
development/flowgraph.md— расхождение в булевых флагах описано как есть:--enable-iq-dump=1против--dc-removal=True, со ссылкой на ⏸-пункт.SSTV_PD120описан фактически:MODESберётSCRIPTS["SSTV"], то естьsatnogs_fm.py. Вывод «проходы PD120 молча идут обычным FM» оказался неверен и снят при закрытии п. 13 дорожной карты сети: граф выбирает диспетчер по--mode, а неверное имя портило частоту дискретизации вfind_samp_rate.py. Исправлено «из UDP-потока читают внешние декодеры вроде SatDump»✅
development/architecture.md— «только оркестратор» уточнено: водопад на 2200 строк numpy считает сам. В таблице постоянных заданий явно сказано, что в первом столбце имена функций, а не id заданий APScheduler. Дописан абзац проcreate_scheduler()иget_rig_controller()/get_rotator_controller()— синглтон на импорте остался только уsettings✅
conf.py— добавленmyst_enable_extensions = ["deflist"]. Проверено сборкой: списки определений вindex.mdрендерятся как<dt>/<dd>, а не литеральным текстом с двоеточиями✅
conf.py+requirements.txt+ джобаpages— разделapi/был пуст, премиса подтверждена запуском в чистомpython:3.11-slim: каждый модуль падал наModuleNotFoundError(pydantic_settings,numpy), а сборка при этом завершалась успешно, только с предупреждениями. Лечится не дублированием зависимостей, а установкой самого проекта:pip install . -r docs/requirements.txt. После правкиapi/core.htmlвыросла с 9 записей до 183,soniks_client.html— с 11 до 34,antennaиjobsнаполнились с нуля, предупреждений в сборке не осталось.autodoc_mock_importsтрогать не понадобилось, аSTATION__*для импортаconf.pyвыставлял и раньше✅
client/.env— 17 строк без=(13FLOWGRAPH__*и 4WATERFALL__*) закомментированы через#с дефолтными значениями. Для python-dotenv такая строка была «напоминанием», а для парсераenv_fileв Docker Compose — «унаследовать из окружения хоста», то есть поведение станции зависело от того, что экспортировано в шелле. Рядом — предупреждение, что при раскомментировании нужно подставить значение: пустое значение типизированного поля это ошибка валидации, а не дефолт✅
client/.env— плейсхолдерANTENNA__ROTATOR__PORT=<необходимо указать…>закомментирован. ЗаодноANTENNA__ROTATOR__MODELприведена кROT_MODEL_NETROTCTL: в шаблоне стоялROT_MODEL_GS232(прямой COM-порт) вместе с сетевым плейсхолдером вида10.**.*.***:****, то есть шаблон противоречил сам себе и документированной схеме сrotctld✅
contributing.md— «единственный проверяющий инструмент в CI — ruff» заменено на четыре джобы; описан пин версии ruff и явныйselect; команда тестов приведена к--extra devс объяснением, что изоляцию даётtests/conftest.py; перечислено, что покрыто тестами сейчас; в команду сборки документации добавлена установка проекта; дописан абзац про раннер стенда иinterruptible✅
CLAUDE.md— команды приведены к фактическим (четыре проверки, установка проекта перед сборкой документации). Файл в.gitignoreи в коммит не попадает — правка локальная, для будущих сессий на этой машине⬜ Остаётся мейнтейнеру: корневой
.envразошёлся сclient/.env— содержит удалённую настройкуANTENNA__ROTATOR__FLIPи несуществующуюFLOWGRAPH__DISABLE_DECODED_DATA, а также строки без=(FLOWGRAPH__PPM_ERROR,FLOWGRAPH__ENABLE_IQ_DUMP) — из-за них любой импортsrc/вне тестов падаетValidationError. Это живая станция, из репозитория не правится
Совместная сессия с soniks-flowgraphs ⏸¶
Планируется после закрытия всех фаз — то есть уже сейчас, фазы и
архитектурный долг закрыты. Сюда сложены правки, которые нельзя сделать из
одного репозитория, потому что контракт между клиентом и потоковыми графами
проверяется только с двух сторон сразу. Клиент строит командную строку в
flowgraph.py, а разбирает её flowgraph_dispatcher из soniks-flowgraphs —
здесь его нет.
✅ Нормализация булевых флагов CLI (из 2.2) — сделано 2026-09-12, решение 68 дорожной карты сети: диспетчер объявляет оба как
type=int,--dc-removal=Trueронял его. Исходный текст: сейчас--dc-removal=Trueи--enable-iq-dump=1— два разных представления одного типа. Менять вслепую нельзя: если диспетчер сравнивает со строкой"True", приведение к0/1тихо выключит DC removal там, где он работает. В ту же сессию: сверить весь набор--kebab-caseаргументов изFlowgraph.parametersс тем, что диспетчер действительно принимает.⏸
SSTV_PD120(из 2.8) — правка на стороне клиента в одну строку: вcore/configs/flowgraph.pyрежим"SSTV_PD120"берётSCRIPTS["SSTV"](то естьsatnogs_fm.py) вместо объявленного там жеSCRIPTS["SSTV_PD120"]=satnogs_sstv_pd120_demod.py, поэтому проходы PD120 молча идут обычным FM. Проверить, что граф действительно рабочий, можно только изsoniks-flowgraphs.⏸
ipc: host+cap_add: SYS_NICE(из 2.9) — в 2.9 они разъехались по двумdocker-compose.ymlи сведены объединением, но вопрос «лечат ли они ту самую ошибку GNU Radio» остался без ответа. Если не лечат — их надо убирать из обоих файлов, а не тиражировать на станции:ipc: hostобнуляетshm_sizeи снимает изоляцию IPC. Проверяется запущенным графом.⏸
scripts/gnuradio/vmcircbuf_default_factoryкладётся не туда (находка 2.9) —COPY scripts/*разворачивает поддиректории плоско, поэтому файл оказывается в/usr/local/bin/, где GNU Radio его не читает. Ровно тот же случай, чтоsat.cfgв 2.8 ($HOME/.gr_satellites/), только адрес назначения отсюда не проверить.development/scripts.mdпри этом утверждает, что файл «фиксирует реализацию кольцевого буфера» — то есть либо чинить путь, либо править документацию и удалять файл. Вслепую не чинилось намеренно.✅
is_baudrateиframingв таблицеMODES— сняты 2026-09-12, решение 68 дорожной карты сети. Исходный текст: (из пункта 7 архитектурного долга) — читателей ноль, но данные заложены на будущее и больше нигде в проекте не описаны. Естественный потребитель уis_baudrateесть уже сейчас:build_script_argvиFlowgraphкладут--baudбезусловно, даже когда режим скорости не использует (APT, SSTV). Но проверить, что диспетчер делает с лишним--baud— и нужен ли ему вообщеframing, — можно только изsoniks-flowgraphs. Пока данные лежат как есть,development/flowgraph.mdчестно говорит, что они не работают.⏸ Версии
FLOWGRAPHS_VER/ веткаsoniks(из «Поправок») — стамп для changelog.debживёт вDockerfile, а код тянется из ветки; свести это к одному источнику можно только договорившись между репозиториями.
Работа на реальной станции ⬜¶
Отдельная сессия с доступом к живой станции: проводить наблюдения реальных спутников и править проект по результату. Что этим закрывается:
⬜ Эталон водопада на настоящем
.dat. Фаза 3 сверялась с синтетическим сигналом — он детерминирован и содержателен, но покрывает только те ветки_estimate_deviation_v7, куда попадает сам. Живая запись прогоняет классификатор модуляций, насыщение полосы и отбрасывание коллизий на том, что реально приходит из эфира.⬜ Проверка оценщика по существу, а не на регресс: сходятся ли
ppm_errorиcarrier_offset_hzс известным уходом опорного генератора станции, правильно ли определяется модуляция у спутников с паспортной девиацией.⬜ Заодно — то, что нельзя проверить с хоста:
Hamlib, ротатор, полный проход от планировщика до выгрузки на портал.
Найдено попутно 2026-08-26, не чинилось: сырой .dat, который
post_processing.py сознательно оставляет на диске при неудавшемся водопаде
(«сохранить для разбора»), через минуту удаляется вместе с директорией
наблюдения — _close_observation_directory() при
OBSERVATION__REMOVE_OBSERVATION_DATA=True не разбирает, что внутри. То есть
разбирать нечего ровно в том случае, ради которого файл и сохранялся. Чинится
либо исключением raw_waterfall из удаления, либо переносом такой директории в
complete/; решать вместе с эталоном водопада выше.
Архитектурный долг (отдельным заходом)¶
Сюда вынесено то, что задевает каждый модуль и потому не делается вместе с багами. Премисы всех восьми пунктов проверены перед началом работы; пять расхождений записаны в «Поправки к исходным предположениям».
Закрыто: 1, 3, 4, 5, 6, 7. Отменено: 2 (уже сделан), 8 (лечить нечем).
1 ✅ Побочные эффекты убраны с импорта¶
Премиса подтверждена, но объём оказался вдвое меньше: settings трогается на
уровне модуля ровно один раз (scheduler.py:10), дефолтов аргументов и
декораторов из настроек нет вовсе. Диск же трогали два mkdir — валидатор
PathSettings и configure_logger(). Поэтому вместо get_settings() с
lru_cache (правка 20 файлов ради объекта, который никому не мешает) ленивыми
сделаны сами побочки:
core/configs/__init__.py—logger/raw_loggerберутся какlogging.getLogger("app")иlogging.getLogger("row_logs"): это бесплатно и не трогает диск. Обработчики на те же объекты вешает новыйconfigure_runtime(), он же создаёт рабочие директории. 22 импортёраloggerправить не пришлось вовсе — имя осталось тем же объектом.src/main.py—configure_runtime()первой строкойmain().docs/conf.py— снята подстановкаPATHS__BASE/LOG__DIRECTORYвоmkdtemp: она существовала только ради побочки импорта.STATION__*остались, валидация никуда не делась.tests/conftest.py— временные пути оставлены осознанно, как страховка на случай теста, который позовётconfigure_runtime(); в докстринге написано, что это уже не обходной путь.
Эталон снят до правки и сейчас зелёный:
tests/test_import_side_effects.py отдельным процессом проверяет, что
import core.configs не создаёт ни одной директории, а
configure_runtime() создаёт все четыре плюс каталог логов. На старом коде
оба теста падали.
2 ❌ Отменён: уже сделан в 2.2¶
get_rig_controller()/get_rotator_controller() с lru_cache на месте,
модульных синглтонов железа в репозитории нет. Подробности — в «Поправках».
3 ✅ Из Observation вынесено всё, что не зависит от состояния прохода¶
Премиса подтверждена и уточнена: было 448 строк, 15 методов, семь
ответственностей, при этом наружу торчат всего 6 членов — единственный
потребитель jobs/observation.py зовёт конструктор,
set_observation_parameters, observation_directory, run_pre_script,
run, run_post_script и post_processing.
Эмпирическое подтверждение god-объекта: оба существовавших теста вынуждены
были подделывать self — test_rx_device_selection.py звал
_select_rx_device_by_frequency как unbound с self=None, а
test_waterfall.py подсовывает post_processing утиный SimpleNamespace,
потому что настоящий Observation в тесте не сконструировать (Hamlib).
Вынесены две ответственности, обе не зависят ни от состояния прохода, ни от железа:
soniks_client/rx_device.py—select_rx_device_by_frequency(). Метод не использовалselfвовсе, так что перенос механический; тест перестал передаватьself=Noneи зовёт обычную функцию.soniks_client/observation_files.py—create_observation_files()и датаклассObservationFiles._create_observation_file_pathsвместо 28 строк логики раскладывает готовый результат по прежним атрибутам, так что остальные 10 обращений кself.payload_ogg_pathи соседям не тронуты._generate_filenameудалён.
Эталон снят до правки прогоном старого кода: имена для FM и APT
зафиксированы в tests/test_observation_files.py (включая то, что APT
получает готовый путь файла, а не префикс) и после переноса совпали.
observation.py — 448 → 378 строк.
Хвост пункта: post_processing и _around_observation ✅¶
Вынесены вторым заходом, вместе с эталоном. Объём премиса подтвердила: 80 и
62 строки (роадмап говорил 63). Оба метода трогали self неглубоко —
post_processing четыре члена, _around_observation шесть полей прохода
плюс _log_script_output, который self не использовал вовсе.
Эталон снят до переноса и прогнан на старом коде:
tests/test_post_processing.py(6 тестов) — штатный путь, оба значенияREMOVE_WATERFALL_RAW_FILES, обрезанный заголовок, пустой водопад, упавший анализ,get_signal_metadata() → None. Все шесть зелёные до переноса;tests/test_observation_scripts.py(5 тестов) — командная строка целиком через/bin/echo(он печатает свои аргументы, поэтому за один прогон фиксируются и порядок, и разрешениеmode → script_filename), фолбэк наDEFAULT_MODE, таймаут-килл, отсутствующий скрипт;tests/test_flowgraph.py(6 тестов) — понадобился потому, что сведение запуска подпроцесса меняетFlowgraph.start/stop, а тестов на класс не было ни одного.
Вынесено:
soniks_client/post_processing.py—build_waterfall(): построение PNG, анализ сигнала и судьба сырого.dat.Observation.post_processingосталась оркестратором на девять строк — вызов, сборка метаданных, PUT. Отправка намеренно не уехала:_get_metadata()собирает данные изFlowgraphи станции, к водопаду отношения не имеющие;soniks_client/observation_scripts.py—build_script_argv()(чистая функция) иrun_script()._log_script_outputуехал туда же;soniks_client/subprocess_log.py— общий запуск подпроцесса для скриптов и потокового графа: одинаковые шесть kwargsPopenбыли записаны дважды, а расхождение вbufsize/encoding/errorsмолча ломает чтение вывода.
observation.py — 378 → 243 строки, flowgraph.py — 184 → 157.
Найденный при этом баг: уборка после satnogs-post вешала проход ✅¶
Эталонный тест на таймаут показал, что _around_observation возвращает
управление через 30 секунд при таймауте 0.5. Причина изолирована двумя
прогонами:
Сценарий |
Было |
Стало |
|---|---|---|
Скрипт висит сам, потомков нет |
0.50 с |
0.50 с |
Скрипт вышел, оставив фонового потомка |
30.00 с |
5.00 с |
Блокировал не wait(timeout), а process.stdout.close() в finally: он ждёт
лок буфера, который держит поток-читатель, застрявший на чтении пайпа. Пайп
при этом держит уже не сам скрипт, а его фоновый потомок, унаследовавший
stdout. В проекте такой потомок ровно один и он штатный — bandscan.sh start из satnogs-post запускает подоболочку без перенаправления
вывода (scripts/bandscan.sh:81), и живёт она до bandscan.sh stop
следующего прохода.
Последствие на станции с BANDSCAN_ENABLE=TRUE: в execute_observation за
run_post_script() стоят post_processing() и постановка задания
send_data_after_observation, то есть водопад и выгрузка данных прохода
откладывались до начала следующего прохода. Таймаут SCRIPT_TIMEOUT_IN_SECONDS
от этого не спасал — сам скрипт к тому моменту давно завершился.
Починено по корню, в общей уборке: stdout закрывает сам поток-читатель
(в его finally, где лока нет), а вызывающий только ждёт поток ограниченное
время и пишет предупреждение, если тот ещё жив. Закрывать из вызывающего
нельзя было и через GC: __del__ буфера позвал бы тот же блокирующий
close() в произвольном потоке. Правка досталась и Flowgraph — она в общем
модуле.
Порог ожидания вынесен в subprocess_log.JOIN_TIMEOUT_IN_SECONDS (5 с, как и
было зашито). Ловушка описана в development/scripts.md и
development/architecture.md: фоновому процессу из хука вывод нужно
перенаправлять в файл, как делает grsat.py.
4 ✅ Настоящие дубли сведены, расходящееся поведение выправлено¶
Премиса подтверждена частично, а предусловие оказалось выполнено неполно:
роадмап говорил «сводить после появления тестов на судьбу файлов», но из трёх
функций tests/test_file_fate.py покрывал send_data_after_observation и
_resend_observation_directory, а send_data_during_observation — ни одним.
Поэтому сначала написаны три теста на during, и они же показали, что из трёх
заявленных расхождений реально только одно:
✅ чтение файла вне
try— подтверждено, тест падал. Первый же нечитаемый кадр обрывал всю пачку. Ровно этот класс ошибки чинили в 2.1, но только вafter; соседний вызывающий остался без правки — классический недочинённый корень. Теперь чтение обёрнуто так же, кадр остаётся на диске и его подбирает выгрузка после прохода.❌ «игнорируется возврат
move_file_to_incomplete_directory, единственная копия данных может исчезнуть без следа» — неверно. При неудачном переносе функция возвращаетFalseи оставляет файл на месте, аduring, в отличие отafter, ничего не удаляет и директорию не закрывает. Терять нечего; тест это подтверждает.❌ «
delete_data_filesна всю пачку» — не дефект. Наблюдение удалено на портале, данные бесполезны;afterи_resendв том же случае сносят всю директорию. Поведение согласовано, а не расходится.
Сведено то, что действительно дублировалось:
_handlers_by_prefix()— соответствие «префикс файла → читатель» было записано дважды: словарём вafterи цепочкойif/elifв_resend. Цепочка на 20 строк заменена поиском по общей таблице. Таблица собирается на каждый вызов, а не в константе модуля: и префиксы, и читатели подменяются в тестах._close_observation_directory()— одинаковый хвост «удалить либо перенести вcomplete» изafterи_resend.
Полное слияние трёх функций в одну сознательно не делалось. Общий у них
только скелет «прочитать → выгрузить → пристроить файл», а политика судьбы
различается по существу: during решает судьбу каждого файла отдельно,
after — судьбу директории целиком по флагу closable, _resend работает с
директорией, которая уже лежит в incomplete, и потому ничего никуда не
переносит. Свести их можно только функцией с четырьмя колбэками на три
вызывающих — это больше механики, чем она убирает, и читается хуже трёх
явных функций.
5 ✅ Metadata приведён к правде¶
Производителей оказалось три, а не два, плюс четвёртая конфликтующая
аннотация в api.py (см. «Поправки»). Прежний алиас
dict[str, dict[str, str | dict[str, str | int]]] не выполнял ни один из них:
в корне лежат float (координаты станции) и int (frequency), а на третьем
уровне — None у 12 ключей и float у ppm/gain.
Ужесточать тип нельзя: структуру задаёт портал, а не клиент (plot.py уже
переименовывает bw_99_hz в bw99_hz под чужую схему). Поэтому Metadata
объявлен dict[str, Any] с комментарием, из каких трёх мест он собирается и
почему это открытый JSON-контракт, а не схема. api.py переведён на этот же
алиас — четвёртого описания больше нет.
6 ✅ core/configs/path.py¶
Поля OUTPUT/COMPLETE/INCOMPLETE удалены целиком вместе с валидатором
validate_and_create_directories. Пути стали свойствами, считаемыми от
BASE + *_DIR, — задать их снаружи теперь не «невозможно, хотя выглядит
наоборот», а просто нечего. Создание директорий переехало в
create_directories(), который зовёт configure_runtime() (см. пункт 1).
Попутно исправлен настоящий баг: raise RuntimeError("Ошибка при создании директории %s", path) печатался кортежем — %-форматирование в конструкторе
исключения не работает. Заодно ушло self.BASE: Path = Path(self.BASE),
подменявшее рантайм-тип поля мимо аннотации.
Потребителей правка не задела: весь код ходит через свойства *_path, полей
и BASE никто не читал. Фикстура tests/test_file_fate.py вместо трёх
monkeypatch.setattr подменяет один BASE.
7 ✅ Таблицы модуляций вынесены из класса настроек¶
SCRIPTS (14 записей) и MODES (22 записи) стали константами модуля
core/configs/flowgraph.py; переезжали вместе, потому что MODES ссылается
на SCRIPTS в скоупе класса. DEFAULT_MODE остался полем настроек — он
задокументирован как настоящая переменная FLOWGRAPH__DEFAULT_MODE.
Правка не косметическая: полями pydantic таблицы были переопределяемы из
окружения (FLOWGRAPH__MODES=<json> перезаписывал всё соответствие целиком),
хотя station/environment_variables.md прямо утверждал «через .env их не
переопределяют». Код приведён к тому, что уже написано в документации.
Эталон снят до переноса: tests/test_flowgraph_modes.py фиксирует все 22
записи соответствия «режим → скрипт», требует, чтобы каждый script_filename
был объявлен в SCRIPTS, и чтобы DEFAULT_MODE сам был в таблице. После
переноса снимок совпал запись в запись.
Заодно выяснено, что из трёх ключей MODES клиент читает только
script_filename: у is_baudrate (22 вхождения) и framing (8) читателей
ноль, хотя development/flowgraph.md описывал их как работающие. Документация
исправлена, сами данные не тронуты — решение об их судьбе за мейнтейнером
(см. «Открытые вопросы»).
8 ❌ Отменён: лечить нечем¶
Премиса формально верна, но только для 19 флагов из 29, и лечения у неё нет.
Имя аргумента из имени поля не выводится: RX_SAMP_RATE → --samp-rate-rx
(слова переставлены), RX_BANDWIDTH → --bw, RF_GAIN → --gain,
IQ_DUMP_FILENAME → --iq-file-path. Два флага приходят вообще из другой
секции настроек (--rigctl-host/--rigctl-port из settings.antenna.rig),
а ещё восемь живут ровно в одном месте — это аргументы конструктора (mode,
rx-freq, пути файлов, baud, norad-cat-id), никакого «второго места»
у них нет.
Собрать словарь автоматически можно только навесив имя аргумента метаданными
на каждое поле pydantic — то есть завести схему сложнее того 27-строчного
словаря, который она заменит. Сверх того весь контракт CLI уже висит в
⏸-разделе «Совместная сессия с soniks-flowgraphs»: что именно принимает
диспетчер, из этого репозитория не проверить.
Заодно исправлено расхождение внутри документации: development/architecture.md
утверждал «достаточно добавить поле в FlowgraphSettings — дальше оно доедет
до командной строки само», прямо противореча development/flowgraph.md
(«имя поля настроек и имя аргумента связаны вручную, через словарь»).
Права вторая страница.
Находки, оставленные мейнтейнеру — закрыты¶
Обнаружены при проверке премис, правок в той сессии не требовали. Решения приняты, все три закрыты:
✅
scripts/test-flowgraph.sh— третье место с теми же флагами, и со своими дефолтами. Свои дефолты сняты:FLOWGRAPH__RX_SAMP_RATEстала обязательной (было3e6),--gainпередаётся только при заданномFLOWGRAPH__RF_GAIN(было безусловное32противNoneв настройках — то есть графу уезжало усиление, которого реальный проход не задаёт). Совпадающие сSettingsдефолты (PPM_ERROR,ANTENNA,ANTENNA__RIG__*,FLOWGRAPH_DISPATCHER,SOAPY_RX_DEVICE) оставлены — подset -uони нужны на полунастроенной станции, и подменой конфигурации не являются.Заодно вскрылась вторая, более острая ошибка того же рода:
OBSERVATION__SOAPY_RX_DEVICEуезжал в--soapy-rx-deviceсырым, а на проходе он проходит черезselect_rx_device_by_frequency(). На станции со списком «диапазон:устройство» скрипт передавал графу весь список одной строкой — ровно то, что чинили в 2.5 дляbandscan.sh. Там пришлось заводить отдельнуюBANDSCAN_DEVICE, потому что у обзора диапазона нет частоты прохода; здесь частота есть, поэтому переиспользована та же функция, что и на проходе. Проверено на трёх случаях (одно устройство, список VHF, список UHF) и на ветке отказа — при частоте вне всех диапазонов скрипт выходит с сообщением, а не запускает граф вслепую.Строка
ARGSпереведена на массив (идиомаRX_OPTSизbandscan.sh): без этого условный--gainне сделать. Побочноshellcheckстал чистым и на info-уровне.✅ ~~Тестов на сборку командной строки
Flowgraphнет вовсе.~~ Закрыто вместе с хвостом пункта 3:tests/test_flowgraph.pyпонадобился всё равно, потому что сведение запуска подпроцесса меняетstart/stop.⏸
is_baudrateиframingвMODESне читает никто. Решение мейнтейнера: оставить, данные заложены на будущее. Перенесено в раздел совместной сессии сsoniks-flowgraphs— потребителя для них определять вместе с диспетчером.