Дорожная карта: закрытие техдолга

Живой документ. Ведётся между сессиями работы над кодом: сюда сложен полный результат аудита, порядок работ и отметки о выполнении. Правьте статусы прямо здесь по мере закрытия пунктов.

Статус на 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 · ❌ отменено (с причиной)


Как это появилось

После того как была написана документация, проект начали развивать дальше — но под документацией остался слой долга. Отправной точкой были три симптома:

  1. lint_python красный в GitLab CI и блокирует пайплайн;

  2. iq_dump_rename.sh читает ENABLE_IQ_DUMP/IQ_DUMP_FILENAME без префикса FLOWGRAPH__, а соседний meteor.sh — с префиксом, поэтому переименование IQ-дампа при штатной конфигурации не срабатывало;

  3. 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 бессмысленно.

Где

Что

Было

Чем является

Dockerfile

FLOWGRAPHS_VER

2.1

стамп для dch -v в changelog собираемого .deb

build.sh

FLOWGRAPHS_VER

1.6

то же, но ниже дефолта — оттого и --allow-downgrades

Dockerfile

CLIENT_VERSION

2.2.2

вот это и есть «2.2» из README

build.sh

CLIENT_VERSION

2.0.0

локальная сборка ставила другую версию, чем CI

src/core/_version.py

__version__

2.0.0

то, что уходит на портал

build.sh

TAG

1.3.0

тег образа, которого никто не потреблял

pyproject.toml

version

0.1.0

плейсхолдер от uv init

Dockerfile / build.sh

GRSATNOGS_VER

3.1.0.1 / 3.0.0.1

README обещал 3.1.0.1

docker_lint не был сломан

Аудит предполагал, что джоба падает (DL3008 на неприпиненных apt-get и т. п.). Проверка запуском hadolint/hadolint:v2.12.0-alpine на текущем Dockerfileexit 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 нарушений

Корзина

Что сделано

Автофикс

ruff check --fix + точечный --unsafe-fixes по C408 SIM108 SIM102 TRY300 FURB162 UP031

Настоящие баги

BLE001/RUF013/DTZ*/SIM115/TRY203 починены по существу, а не заглушены (см. фазу 2)

Осознанно выключено

RUF012 (таблицы NORAD — атрибуты класса по замыслу), TRY003, TRY300, TRY301 — предложения по стилю, ошибок не ловят

Автофикс 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.toml version и 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 Потеря данных прохода ✅

Статус

Место

Проблема

api.py (upload_observation_data)

хвостовой except Exception возвращал управление как при успехе → вызывающий удалял невыгруженный файл. Контракт core/exceptions.py был вывернут наизнанку. Теперь пробрасывается FileNotUploadedError

api.py, ветка 403

«уже загружено» логировалось как error; переведено в warning с комментарием, что это единственный случай, когда ошибка портала означает успех

sending_data.py send_observation_data_again

data_to_send присваивался только в трёх ветках if/elif. Ветвление переписано на выбор хендлера, добавлена ветка else с logger.warning и continue. Флаг all_files_sent намеренно не сбрасывается: файл с неизвестным префиксом не станет отправляемым ни через минуту, ни через сутки. Чтение файла обёрнуто в except OSError

sending_data.py send_data_after_observation

перехват вокруг handler(file_path) расширен с FileNotFoundError до except Exception + logger.exception: падение на одном файле больше не отменяет остальные и не оставляет наблюдение в output/ навсегда

jobs/observation.py

run_post_script()/post_processing() завёрнуты в try/except; scheduler.add_job(send_data_after_observation) выполняется в любом случае

move_file.py

при существующем приёмнике после unlink() добавлен return — управление больше не проваливается в shutil.move на исчезнувшем файле

move_file.py

move_file_to_incomplete_directory возвращает bool. send_data_after_observation по нему решает, можно ли закрывать директорию наблюдения: файл, который не удалось ни выгрузить, ни перенести, больше не удаляется вместе с ней — директория остаётся в output/, в лог уходит error

read_files.py

перехват расширен до (json.JSONDecodeError, ValueError, KeyError, TypeError): кадр без pdu и с "pdu": null уходит сырыми байтами, как обещает докстринг

file_surveillance.py

__del__ удалён. Пачка досылается явно из execute_observation вызовом send_batch(force=True) сразу после file_observer.join()

file_surveillance.py

файл берётся в работу по on_closed и on_created; перед отправкой st_size сверяется с размером на момент постановки в очередь, растущий файл откладывается на следующий цикл. На inotify кадр уходит в первом же цикле, на наблюдателе без IN_CLOSE_WRITE — через 2 × BATCH_DELAY, но целым

jobs/observation.py + file_surveillance.py

снято как следствие двух правок выше: досылка синхронная и происходит до постановки задания send_data_after_observation, а отложенное задание send_batch найдёт pending пустым. Дедуп по пути даёт dict вместо list

api.py get_observation_jobs

response.json() и JobData.from_dict обёрнуты в try; ValueError/KeyError/TypeError логируются и гасятся, возвращается None. Различие None (запрос не удался) и [] (проходов нет), от которого зависит sync.py, сохранено

Сознательно не сделано: пересканирование output/ заданием resending. resending_jobs не знает, идёт ли сейчас проход, и подхватил бы output/<id> работающего наблюдения, удалив директорию у него из-под ног. Директория, оставленная в output/ из-за неудачного переноса в incomplete/, разбирается оператором по записи в логе.

Заодно: tests/conftest.py подменяет Hamlib на MagicMock — пакет собирается только внутри образа, а soniks_client.jobs.__init__ тянет его по цепочке импортов, из-за чего тесты на выгрузку файлов не собирались ни на хосте, ни в CI. То же самое делает docs/conf.py для autodoc.

2.2 Оборудование и жизненный цикл прохода ✅

Статус

Место

Проблема

observation.py (главный цикл)

run() завёрнут в try/finally: _stop_observation() вызывается при любом исходе, GNU Radio и потоки rig/rotator больше не переживают сломанный проход

observation.py (run_pre_script/run_post_script)

вывод скрипта читается отдельным потоком (как в flowgraph.py), а process.wait(timeout) стал достижим. Введена настройка OBSERVATION__SCRIPT_TIMEOUT_IN_SECONDS (900 с): декодеры и SSTV идут минутами, ограничивать нужно по-настоящему зависший процесс. stdout закрывается в finally

🔧

antenna/rig.py, antenna/rotator.py

синглтоны на импорте заменены на get_rig_controller() / get_rotator_controller() с lru_cache: контроллер строится при первом проходе, а не при импорте, и неизвестная ANTENNA__ROTATOR__MODEL теперь пишет ошибку в лог вместо трейсбека на старте клиента. ⬜ Осталось: конфликт наложившихся проходов здесь не решается — радио физически одно, разводить проходы должен планировщик (см. 2.3). Импортируемость antenna/ вне контейнера тоже осталась: причина — import Hamlib на уровне модуля; в тестах закрыто заглушкой в conftest.py

communication_session.py

_session_thread сбрасывается в None даже после неудачного join(timeout=5): объект сессии больше не остаётся навсегда мёртвым. Неостановленный поток логируется отдельной строкой

communication_session.py

rotator.connect()/rig.connect() перенесены внутрь try: неудачное подключение логируется, поток выходит штатно, парковка не выполняется на неоткрытом устройстве

communication_session.py

except Exception: pass в цикле пробуждения ротатора заменён на logger.warning с текстом ошибки

communication_session.py

set_session_parameters базового класса возвращает bool; оба потомка проверяют результат и не перестраивают состояние на живой сессии

satellite_parameters.py

события Skyfield группируются в целые пролёты «восход-кульминация-заход» (_group_events_into_passes), из них берётся ближайший к середине окна наблюдения. Обрезанные краями окна пролёты отбрасываются

antenna/rig.py, antenna/rotator.py

после open() проверяется error_status, при ошибке — logger.error с расшифровкой Hamlib.rigerror и EquipmentConnectionError. Проверять каждый set_freq намеренно не стали: при RIG_UPDATE_INTERVAL=0.1 это 10 лишних проверок в секунду, а неоткрытое соединение теперь и так не даёт начать сессию

antenna/rig.py

VFO читается один раз при connect() и кэшируется — вдвое меньше сетевых вызовов к rigctld во время прохода

tracking/flip.py

_is_flip_required разворачивает азимуты в непрерывную последовательность общим хелпером базового класса closest_equivalent и считает север пересечённым, если путь вышел за [0, 360). Сырое сравнение не видело пролёт 10° → 20° → 350°

tracking/range_250.py

стратегия помнит азимут упора, на который команда уже отправлена: повторная отправка и повторное предупреждение в лог подавляются до тех пор, пока цель не вернётся в диапазон

main.py

Application принимает фабрику планировщика и на перезапуске строит новый экземпляр; scheduler.py переписан с общего объекта на create_scheduler()

main.py

shutdown(wait=False): обработчик сигнала больше не блокируется до конца прохода. С wait=True корректное завершение всё равно не успевало — Docker добивает SIGKILL через 10 с

flowgraph.py

start() пробрасывает OSError наружу (ловится в execute_observation), а не оставляет self.process is None при продолжающемся проходе

flowgraph.py

stop() получил finally: поток логирования join-ится и stdout закрывается на обоих путях, включая «SIGINT не помог»

flowgraph.py

булевы флаги: --dc-removal=True против --enable-iq-dump=1. Закрыто 2026-09-12 сверкой с диспетчером 2.6.0: оба type=int, --dc-removal=True ронял его с кодом 2. Решение 68 в дорожной карте сети

2.3 Планировщик ✅

Статус

Место

Проблема

sync.py

_create_job_data не передавал norad_cat_id, поэтому сравнение с данными портала никогда не совпадало: каждый проход удалялся и добавлялся заново раз в минуту. Все поля теперь берутся из kwargs, которыми задание и создавалось

sync.py

start тоже перенесён на kwargs — снята завязка на trigger.run_date, то есть на конкретный тип триггера

sync.py

if not jobs: return заменено на if jobs is None. Пустой список — это «портал снял все проходы», и отменённый последний проход теперь снимается со станции

sync.py

remove_job завёрнут в _remove_job() с перехватом JobLookupError: задание, которое только что отработало и сняло себя само, больше не обрывает остаток цикла синхронизации

sync.py, jobs/_log.py

классификация по вхождению подстроки PREFIX in job.name заменена на settings.jobs.is_observation_job() (проверка префикса). Имя вроде send_data_for_job_17 больше не считается проходом

jobs/_log.py

int(next_observation_job.id) убран (id портала — строка), сортировка и время взяты из kwargs["start"] вместо trigger.run_date

sending_data.py

защита от параллельного обхода одной директории поставлена в саму send_observation_data_again, а не в вызывающего: replace_existing=True переставляет запланированное задание, но не отменяет уже запущенное. Так закрыты все вызывающие сразу, а не только resending.py

Наложившиеся проходы — отменено решением мейнтейнера. Премиса верна: защиты нет, и MAX_INSTANCES=1 её не даёт — эта настройка APScheduler ограничивает одновременные экземпляры одного задания по id, а наложившиеся проходы приходят разными id. Но портал таких проходов станции не выдаёт, то есть риск теоретический, а лечение — процесс-wide лок в execute_observation — означало бы осознанный отказ от второго наблюдения. Платить отказом за не встречающийся на практике случай не стали. Если портал когда-нибудь начнёт выдавать пересекающиеся окна, образец лока в проекте уже есть — _resending_lock в jobs/sending_data.py.

2.4 ✅ Shell-скрипты: env-имена

Расхождение оказалось шире, чем считалось: не два места, а четыре.

Статус

Место

Было

Стало

iq_dump_rename.sh

ENABLE_IQ_DUMP, IQ_DUMP_FILENAME

FLOWGRAPH__*

bandscan.sh

RX_SAMP_RATE с лишней E

FLOWGRAPH__RX_SAMP_RATE

bandscan.sh

PPM_ERROR, RF_GAIN, OTHER_SETTINGS без префикса — в той же команде, где рядом стояли корректные $FLOWGRAPH__RX_SAMP_RATE и $FLOWGRAPH__ANTENNA

FLOWGRAPH__*

satdump.sh

UDP_DUMP_PORT, UDP_DUMP_HOST без префикса (не было задокументировано)

снято: скрипт удалён целиком, см. 2.8

Защита от повторения ✅ — добавлен tests/test_script_env_names.py: он вытаскивает из scripts/* все имена вида SECTION__FIELD и сверяет со списком полей pydantic Settings. Опечатка или потерянный префикс теперь роняют тест, а не «просто молча не работают». Переменные без __ (BANDSCAN_*, GRSAT_*, IQ_DUMP_RENAME, …) живут только в скриптах и намеренно не проверяются.

2.5 Shell-скрипты: остальное ✅

Статус

Место

Проблема

bandscan.sh

rx_sdr -d "$FLOWGRAPH__RX_SAMP_RATE"-d это селектор устройства, а не частота дискретизации (она уже правильно уходила в -s). Захват шёл не с того SDR. Введена отдельная BANDSCAN_DEVICE: OBSERVATION__SOAPY_RX_DEVICE может содержать список «диапазон:устройство», из которого клиент выбирает приёмник по частоте прохода, и угадывать его в скрипте неправильно

bandscan.sh

echo $! после пайплайна писал PID rffft, а не rx_sdr → после stop приёмник продолжал держать SDR, и следующее наблюдение не стартовало. Теперь пайплайн запускается в подоболочке под set -m, а stop гасит всю группу процессов

bandscan.sh

целочисленное сравнение ${BANDSCAN_FREQ%.*} падало на записи вида 435e6, которая используется в проекте повсеместно. Теперь printf '%.0f'

iq_dump_rename.sh

не было set, не проверялось существование файла дампа, не проверялся код возврата find_samp_rate.py (пустой $SAMP давал имя ..._<id>_.raw)

meteor.sh

PID-файл ключуется по $ID наблюдения. Заодно снялась завязка на STATION__ID, который под set -u вообще не имел дефолта

meteor.sh

директория наблюдения создаётся перед декодированием. Заодно исправлен сам путь: PATHS__OUTPUT_DIR — это имя поддиректории (output), а не абсолютный путь, поэтому картинка ложилась мимо директории наблюдения и клиент её не выгружал

meteor.sh

exit 0 на штатном «фича выключена»

meteor.sh

find_samp_rate.py вызывается в условии (if ! SAMP=$(…) || [ -z "$SAMP" ]), фолбэк на 144000 снова достижим

meteor.sh

признак «включено» приведён к общему для проекта ^(TRUE|YES|1)$ без учёта регистра

meteor.sh

добавлен pipefail, пайплайн демодулятора и декодера обёрнут в if: падение демодуляции больше не отчитывается как успех, но и не убивает скрипт до удаления PID-файла

test-flowgraph.sh

source /.env удалён — переменные и так есть в окружении контейнера

test-flowgraph.sh

вызывает диспетчер и принимает режим первым аргументом: test-flowgraph.sh [MODE] [FREQ] [BAUD]. Теперь он действительно проверяет режим

test-flowgraph.sh

дефолты проставлены на все девять читаемых переменных, включая FLOWGRAPH__RF_GAIN

rotor-park.sh

ROT_PARK и ROT_PARK_POSITION добавлены в client/.env вместе с остальными скриптовыми переменными — раньше в шаблоне не было ни одной из них

rotor-park.sh

позиция вынесена в ROT_PARK_POSITION (по умолчанию 180 90): она зависит от физической установки мачты

liveupdate-satyaml.sh

путь до satyaml спрашивается у самого пакета satellites вместо захардкоженного /usr/local/lib/python3.11/dist-packages/

liveupdate-satyaml.sh

работа перенесена в mktemp -d с явной уборкой (не через trap EXIT — ниже стоит exec). Клоны больше не оседают в volume /var/lib/soniks-client

satnogs-pre, satnogs-post

добавлены set -uo pipefail и обёртка run(): ненулевой код каждого дочернего скрипта пишется в лог. Именно -u, а не -e — упавший декодер не должен отменять остальную постобработку и уборку

satnogs-post

асимметрия по 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 Скрипты-декодеры ✅

Статус

Место

Проблема

imagedecode.py

write_image(self, *image_count) + image_count[0], а три вызова шли без аргументовIndexError ровно в момент, когда спутник наконец отдал картинку (CAS-5A 54684 достижим). Сигнатура заменена на image_count: int = 1

imagedecode.py

Lucky7 и Sharjahsat делали for row in self.frames, где frames — кортежи (ts, hex). len(row) всегда 2, guard’ы отбрасывали 100 % кадров: декодеры никогда не срабатывали. Исправлено на for _ts, row in ...

imagedecode.py

все datetime приведены к aware UTC; сравнение с datetime.min тоже (иначе после правки оно бы всегда давало False и ломало разбиение на сеансы связи)

imagedecode.py

две ветки с байт-в-байт одинаковыми телами схлопнуты (нашёл SIM114)

gpio.py

gpio_write_powerup_direction(0, …) во всех четырёх блоках — GPIO1/2/3 не настраивались

imagedecode.py

self.main() в ImageDecode.__init__ завёрнут в try/except Exception + logger.exception. Одна точка на все семь декодеров и CLI: через базовый __init__ проходят все. Точечные guard’ы на каждый int(…, 16) не ставились — те же последствия ценой правок в пяти классах

imagedecode.py

уровень логирования берётся через getattr(logging, name.upper(), logging.INFO) — как уже сделано в grsat.py. Проверено: LOG__SCRIPT_LEVEL=BOGUS больше не роняет импорт

imagedecode.py

basicConfig перенесён в if __name__ == "__main__". Проверено запуском: GRSAT_LOG_LEVEL=DEBUG до правки давал 0 строк DEBUG, после — работает

imagedecode.py

SputnixDecode._find_usp_offset — разбор кадра (AX.25 + сырой USP) вынесен из main, вложенность с 6 до 3; StratosatDecode._scan_image_starts — первый проход по кадрам. Сборка чанков сведена к общему ImageDecode._assemble_chunks. Заодно свёрнуто мёртвое ветвление в Stratosat: инвариант image_count == len(images_ts) - 1 держится с первой итерации, поэтому обе ветки if/elif всегда добавляли элемент — осталось images_ts.append(ts), а min_offset в Sputnix оказался равен min(chunks) и убран

find_samp_rate.py vs grsat.py

grsat.py импортирует find_samp_rate; его классметоды find_samp_rate/find_decimation удалены. Ветки объединены: _gfsk/_gmsk/_usp (были только в grsat) + _lrpt (был только в standalone). Формулировка аудита про 4×baud неточна: find_decimation даёт для 9600 бод 6, то есть 57600, а не 38400

find_samp_rate.py

справка уходит в stderr с кодом 1. Проверено: SAMP=$(…) теперь пуст, а не содержит текст справки

sstv_wrapper.py

Popen(...).wait()subprocess.run(..., timeout=): run сам убивает процесс по TimeoutExpired. Предел вынесен в SSTV_TIMEOUT (600 с) — длительность декодирования зависит от длины прохода

sstv_wrapper.py

лог открывается на "a"

sstv_wrapper.py

sorted(...) перед выбором .ogg

grsat.py

пути строятся из PATHS__BASE/PATHS__OUTPUT_DIR/LOG__DIRECTORY с теми же дефолтами, что в core/configs; имя директории — из провалидированного int, а не сырой строки. Имена подхватил tests/test_script_env_names.py

grsat.py

_wait_exit(pid, timeout) на kill(pid, 0): после SIGINT ждём до 10 с фактического ухода, затем SIGKILL + 5 с. PID-файл удаляется после того, как процесса не стало. Проверено на процессе с SIG_IGN на SIGINT: 10 с ожидания, затем убит

grsat.py

точное == для start/stop/sstv. Проверено: restart теперь даёт «Неизвестная команда»

grsat.py

exit(1)

grsat.py / imagedecode.py

разбор вынесен в новый scripts/kiss.py (parse_kiss_frames(infile, default_ts)), отдаёт сырые байты; hex остаётся заботой imagedecode. Расхождение было только в метке времени кадров до первой метки — теперь это параметр. Отдельный модуль, а не метод imagedecode: импорт декодера изображений в grsat.py намеренно необязателен, а разбор KISS лежит на пути выгрузки кадров на портал

sstv_wrapper.py / imagedecode.py

NORAD в двух таблицах — премиса неверна, см. «Поправки». Правка снята

meminfo.py

удалён вместе со строкой в development/scripts.md; там же добавлен kiss.py

2.7 Водопад ✅

Статус

Место

Проблема

waterfall.py

ветка collisions в plot() вызывала self._plot_collisions(...), которого в классе не существует — гарантированный AttributeError. Ветка и четыре неиспользуемых параметра удалены, сигнатура сведена к plot(vmin, vmax)

waterfall.py

метка времени водопада была naive, а маркеры кадров — aware; работало только по совпадению rcParams['timezone'] == 'UTC'. Всё приведено к aware UTC, tz=UTC задан явно локатору и форматтеру оси

waterfall.py

_read_data мапил в WaterfallFileError только FileNotFoundError/OSError; обрезанный заголовок давал IndexError, неUTF-8 метка — UnicodeDecodeError, и оба уходили наверх мимо контракта исключений

waterfall.py

plt.figure/plt.close заменены на явные Figure() + FigureCanvasAgg(fig). import matplotlib.pyplot убран целиком — он использовался ровно в этих двух строках, поэтому глобального менеджера фигур в процессе больше нет, и наложившиеся постпроцессинги в воркерах APScheduler не делят состояние. matplotlib.use("Agg") не понадобился (pyplot не импортируется), plt.close(fig) — тоже: автономную фигуру никто не держит, её забирает GC

waterfall.py

get_signal_metadata читает bw_99_hz — ключ, который действительно пишут оценщики. Имя на выходе оставлено bw99_hz: так его ждёт портал и так описано в development/waterfall.md. bw99_hz внутри словаря оценщика — legacy-ключ, всегда None

waterfall.py

удаление DC-пика выполняется только при nchan >= 5, иначе logger.debug и пропуск. При более узком спектре center_bin + 2 давал IndexError, а center_bin - 2 молча заворачивался в хвост массива

waterfall.py

ветка hasattr(settings.waterfall, 'CMAP') удалена (на pydantic-модели без такого поля она всегда ложна), cmap='jet' задан прямо в imshow. _SDR_CMAP не тронут — он в списке мёртвого кода фазы 3

observation.py

введён флаг waterfall_built; сырой .dat удаляется только после успешного plot(). При упавшем построении файл остаётся, в лог уходит warning с путём

observation.py

except (…): pass заменён на logger.error с классом исключения: сами они без сообщения, подробности пишет waterfall.py, а здесь важен факт и вид отказа

waterfall.py

Waterfall.analysis инициализируется None, а не {}; get_signal_metadata() до plot() возвращает None вместо пустой метадаты. Заодно из get_signal_metadata убран всегда-истинный hasattr и удалено мёртвое присваивание _ncols_full

2.8 Фичи, которые не работали ✅

Удалено (решение: сценариев использования нет):

Статус

Что

Почему

SatDump целиком

бинарь satdump не собирался и не ставился ни в Dockerfile, ни в packages.*; под set -e command -v satdump убивал скрипт молча. Удалены scripts/satdump.sh, scripts/satdump_transfer.py и вызовы из satnogs-pre/satnogs-post. Заодно снялись находки: путь /usr/bin/satdump_transfer.py вместо /usr/local/bin/, запись PNG мимо output/<observation_id>/, беспрефиксные UDP_DUMP_*, sleep 180 в цепочке постобработки

ExternalDecode / jy1sat_ssdv

спутник JY1Sat (43803) сошёл с орбиты. Удалён класс и ветка диспетчера; снялась находка про Popen без wait(), удалявший свои же .ssdv

К починке:

Статус

Фича

Проблема

Режим SSTV_PD120

правка на стороне клиента в одну строку (SCRIPTS["SSTV"]SCRIPTS["SSTV_PD120"] в configs/flowgraph.py), но работоспособность самого графа satnogs_sstv_pd120_demod.py проверяется только из soniks-flowgraphs. Перенесено в раздел совместной сессии

sat.cfg

проверено: --satcfg — реальная опция gr_satellites и читает строго ~/.gr_satellites/sat.cfg; HOME станции (/var/lib/soniks-client) перекрыт named volume. Мёртвая COPY /usr/var/lib/… удалена из Dockerfile — файл и так попадает в образ через COPY scripts/*, то есть вне volume. grsat.py получил _ensure_satcfg(): перед стартом gr_satellites копирует sat.cfg рядом с собой в ~/.gr_satellites/, если его там нет. Существующий файл не перезаписывается — правки оператора переживают перезапуск

WATERFALL__AUTORANGE, DEFAULT_MIN/MAX_VALUE

post_processing вызывает plot() без границ. Автоподбор включается по vmin is None, а при AUTORANGE=False внутри подставляются те же MIN_VALUE/MAX_VALUE — на станциях с дефолтом картинка не меняется

WATERFALL__THRESHOLD

возвращён как дополнение к np.isfinite(): isfinite убирает -inf пустых бинов, порог — заведомо нефизические уровни. Дефолт (-200 дБ) реальный шум не трогает

WATERFALL__MIN_VALID_SAMPLES

заработал сам вместе с AUTORANGE: он стоит именно в этой ветке. Формулировка аудита («пустота водопада решается по wf_data['data'].size, настройка не участвует») неточна — настройка участвовала, но в недостижимой ветке

2.9 CI/Docker — структурные риски ✅

Статус

Место

Проблема

джоба docker

сабмодуль soniks-satyaml не инициализировался

.dockerignore

16 МБ CSV уезжали в образ

джоба docker

сборка шла на каждом коммите в основную ветку под три платформы, две из трёх под QEMU и сериализованные max-parallelism = 1. Не укладывалась в таймаут джобы и не завершалась никогда: latest-addons не обновлялся, а раннер был занят впустую. Правила сведены к if: $CI_COMMIT_TAG

.gitlab-ci.yml

linux/arm/v7 снят из GITLAB_CI_DOCKER_BUILDX_PLATFORMS: 32-битных станций нет. Осталось linux/arm64 (Raspberry Pi 4, на них большинство станций) + linux/amd64 (собирается нативно на раннере)

.gitlab-ci.yml

тег релиза перезаписывал latest-addons и версионного образа не создавал. bake получает два тега: ${GITLAB_CI_DOCKER_IMAGE}:${CI_COMMIT_TAG} и +=…:latest-addons. latest-addons обязателен — оба docker-compose.yml тянут именно его

.gitlab-ci.yml

container_scanning сканировал образ в CI_REGISTRY, куда ничего не пушится. Легаси CI_APPLICATION_REPOSITORY/CI_APPLICATION_TAG заменены на CS_IMAGE опубликованного образа + CS_REGISTRY_USER/_PASSWORD. Джоба сведена к тегу: у неё needs: [docker], и на ветке без docker пайплайн вообще перестал бы создаваться

Dockerfile

.deb передавались между стадиями через --mount=type=cache. Заменено на --mount=type=bind,from=builder: жёсткая зависимость от стадии, вытеснять нечего, и слоя с пакетами в образе не остаётся — в отличие от COPY --from, где .deb осели бы навсегда. Builder кладёт их в /debs вне /usr/local, который целиком копируется в runner. Заодно ушли dpkg --print-architecture (при multi-platform у каждой платформы своя builder-стадия) и # hadolint ignore=SC2046. Поправка к премисе: на холодном кэше образ не «уезжал без gr-satnogs» — нераскрытый glob уходил в apt-get литералом и ронял сборку

Dockerfile

безусловная замена прошивки UHD B210 вариантом LibreSDR — так задумано, станции только на LibreSDR; теперь это написано в комментарии. find завёрнут в проверку [ -n "$TARGETS" ], ветка else пишет предупреждение: отсутствующий /usr/share/uhd больше не обрывает &&-цепочку. Пайп xargs заменён на цикл for — hadolint ловит DL4006 на любом пайпе в RUN

корневой docker-compose.yml

добавлен user: '9999' — клиент больше не работает от root, и sat.cfg из 2.8 ложится в /var/lib/soniks-client, а не мимо volume в /root. ipc: host и cap_add: SYS_NICE перенесены в client/docker-compose.yml: сервис soniks-client стал идентичным в обоих файлах с точностью до build: .. Закомментированный rotctld намеренно оставлен разным — в корневом это контроллер стенда мейнтейнера (MODEL 606, /dev/ttyUSB0, ±250°), о чём теперь сказано в комментарии со ссылкой на канонический шаблон в client/ и station/rotator.md

Dockerfile

RUN chmod 0755 /usr/local/bin/* удалён, права ставит сам COPY --chmod=0755 scripts/*. Права бинарей из builder-стадии больше не сбрасываются

Dockerfile

--no-install-recommends добавлен в единственный apt-get install, где его не было. Проверено симуляцией apt-get install -s в базовом образе: 63 пакета против 37. Уезжают build-essential, dpkg-dev, make, patch, fakeroot (тулчейн в рантайм-образе), openssh-client/xauth (клонирование в liveupdate-satyaml.sh идёт по HTTPS), gpsd/gpsd-tools/libgps28 (ни src/, ни scripts/ не обращаются к gpsd). ca-certificates в списке уезжающих нет. Единственное, что возвращено явной строкой в packages.clientlibsox-fmt-base: формат-плагины к уже перечисленному там libsox3, без них библиотека остаётся без обработчиков форматов

.gitlab-ci.yml

опечатка DOCKER_REGESTRY_* снята мейнтейнером в настройках проекта GitLab (DOCKER_REGISTRY_LOGIN/DOCKER_REGISTRY_PASSWORD), репозиторий приведён к новым именам в джобах docker и container_scanning. Фолбэка на старое написание нет — его больше не существует

.gitlab-ci.yml

interruptible: true задан в новом блоке default: и потому накрывает и джобы подключённых шаблонов. Поимённо interruptible: false у docker, release_notes, release, container_scanning: они идут только по тегу, вытеснять их нечем, а прерванная публикация оставила бы тег наполовину запушенным

.gitlab-ci.yml

всплыло по ходу сессии: namespace исчерпал квоту compute-минут шаренных раннеров, и пайплайн на них не стартует вообще. Джобы адресованы раннеру проекта #38543418 «SONIKS Dev Stand» через default: tags: [$GITLAB_CI_RUNNER_TAG], значение client. Executor проверен по логу джобы (Preparing the "docker" executor, gitlab-runner 17.0.0) — значит image:/services: работают как прежде и переписывать джобы не пришлось. Тег через переменную, а не литералом: сменится раннер — правится одно значение. Джобе docker дополнительно нужен privileged = true в [runners.docker] стенда (dind + docker run --privileged для binfmt) — это настройка раннера, из репозитория не чинится


Фаза 3 — waterfall.py: разбить на модули, логику не менять ✅

Было 2214 строк и ~40 приватных функций в одном файле. Стало: 915 строк удалено, остаток разложен в пакет из трёх модулей. Ни одна живая строка не менялась — только удаление и перенос срезами.

  1. Подтверждена смерть. grep -rn по src/, scripts/, tests/, docs/ (без docs/_build/) по всем 17 именам плюс сплошной анализ достижимости по AST от класса Waterfall. Все 17 мертвы, но список аудита оказался неполон — см. «Поправки». Удаление велось по результату анализа, а не по списку.

  2. Эталон зафиксирован на синтетическом .dat (реального в репозитории нет, см. новый пункт про доступ к станции). Генератор пишет 800 строк × 1024 бина: шум −100 дБ, всплески с двумя тонами 2-FSK на ±800 Гц, остаточный доплеровский снос и посторонняя CW-помеха на +9 кГц. Оценщик на нём срабатывает содержательно — 2-FSK / wide-FSK (h≥1), Δf = 799.22 Гц при заданных 800, SNR 37.3 дБ, помеха отброшена; в analysis 66 ключей. Снимок: sha256 PNG, get_signal_metadata() и весь analysis целиком. Прогон дважды подряд даёт тот же sha256 — эталон детерминирован. Файлы эталона лежат вне репозитория и в git не уезжают.

  3. Мёртвое удалено: 20 функций, 5 констант, _SDR_CMAP и шапка «ОЦЕНКА ДЕВИАЦИИ V5» (описывала удалённый оценщик). 2214 → 1261 строка (−41 %). Заодно исчезли два локальных импорта необъявленных зависимостей — scipy.ndimage.convolve1d и ephem: ни того, ни другого нет в pyproject.toml, обе строки были недостижимы.

  4. Остаток разложен в 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.

  5. Сверено с эталоном после шага 3 и после шага 4: sha256 PNG совпадает побайтово, 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 строк — стратегии слежения.

Статус

Что

tests/conftest.py — изоляция от .env станции, тесты запускаются одной командой

tests/test_script_env_names.py — сверка env-имён в скриптах с полями Settings

FlipStrategy._is_flip_required на переходе через 0/360 и _clamp_min_elevation, в том числе под flip — дописано в tests/antenna/tracking/test_strategies.py. Там же упор ±250° в Range250Strategy

tests/antenna/test_satellite_parameters.py — группировка событий Skyfield в целые пролёты (обрезанные краями окна отбрасываются)

tests/test_file_fate.py — судьба файла по итогу выгрузки (sending_data.py + move_file.py) с поддельным upload_observation_data и tmp_path. Проверено, что все пять тестов падают на коде до правок раздела 2.1

tests/test_file_surveillance.py — накопление кадров в пачку: растущий файл откладывается, force=True отправляет сразу, on_created + on_closed по одному файлу не дают двойной выгрузки

tests/test_decoder_scripts.py — разбор KISS (kiss.py), частота дискретизации, изоляция битого кадра в ImageDecode, _find_usp_offset, _assemble_chunks. Проверено, что семь из одиннадцати тестов падают на коде до правок раздела 2.6. В 2.8 добавлен _ensure_satcfg: копия в пустой HOME и сохранность правок оператора

tests/test_waterfall.py — разбор .dat на синтетическом заголовке в 52 байта: все четыре ветки исключений _read_data (нет файла, директория вместо файла, обрезанный заголовок, метка времени не в UTF-8) плюс EmptyWaterfallError на заголовке без строк. Там же три теста на правки 2.7 — get_signal_metadata() до plot(), ключ bw_99_hz, plot() на спектре из четырёх бинов; проверено, что все три падают на коде до правок. В 2.8 добавлены три теста на границы шкалы (перехват PowerNorm): автоподбор по данным, THRESHOLD с фолбэком на DEFAULT_MIN/MAX_VALUE и post_processing, который больше не прибивает границы гвоздями

tests/antenna/test_pass_azimuths.pyget_azimuths_satellite_pass целиком на фиксированном TLE ISS и координатах станции: пролёт находится, три азимута лежат в [0, 360), значения зафиксированы регрессией с допуском 0.01°. В окне поиска (±2 часа) лежат два пролёта, поэтому тест проверяет и выбор ближайшего к середине наблюдения. Отдельный случай — станция на широте 89°, где ISS не поднимается над горизонтом: три попытки с расширением окна исчерпываются и возвращается None. Проверено, что load.timescale() берёт встроенные данные и в сеть не ходит

tests/test_sync.py — равенство JobData, отбор заданий по префиксу, пустое расписание против упавшего запроса, JobLookupError на отработавшем задании. Проверено, что тест на norad_cat_id падает на коде до правки

tests/test_rx_device_selection.pyObservation._select_rx_device_by_frequency: одно устройство без диапазона, выбор по диапазону, включительные границы, все три ветки raise. Заодно зафиксировано, что записи разбираются лениво и по порядку: опечатка в последней записи не замечается, пока частота попадает в более раннюю, — проявится она только на проходе в её диапазоне

tests/test_post_processing.py — постобработка прохода целиком: штатный путь (PNG построен, сырой .dat убран, блок signal добавлен к метаданным приёмного тракта), REMOVE_WATERFALL_RAW_FILES=False, обрезанный заголовок и пустой водопад (сырой файл уцелел, метаданные всё равно ушли), упавший анализ и get_signal_metadata() None. Эталон перед выносом post_processing из класса

tests/test_observation_scripts.py — скрипты вокруг прохода: командная строка целиком (через /bin/echo, который печатает свои аргументы), фолбэк на DEFAULT_MODE для неизвестного режима, таймаут-килл зависшего скрипта, отсутствующий скрипт. Пятый тест — на найденный здесь же баг: скрипт, оставивший фонового потомка, больше не задерживает проход; до правки он падал на 30 секундах вместо 0.5

tests/test_flowgraph.py — сборка командной строки (--kebab-case=значение, поля None не передаются, baud/norad-cat-id только когда заданы), вывод в лог с префиксом и уровнем LOG__FLOWGRAPH_LEVEL, остановка живого и уже мёртвого графа, OSError при отсутствующем диспетчере. Класс не импортировался ни в один тест — этим закрыта находка «тестов на сборку командной строки нет вовсе»

tests/test_models.pyJobData.from_dict/parse_datetime: baud: null0, отсутствующие tle* и norad_cat_id, id приводится к str, KeyError на отсутствующем обязательном поле (его гасит api.py), равенство dataclass, на котором стоит сверка расписания в sync.py. По временным зонам зафиксировано фактическое поведение datetime.fromisoformat: …Z и +03:00 дают aware, строка без смещения — naive. Портал сейчас присылает форму со смещением; в контейнере TZ=Etc/UTC, поэтому naive совпал бы с UTC, а на машине разработчика — нет

Красный CI после фазы 3test_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.mdWATERFALL__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 строк без = (13 FLOWGRAPH__* и 4 WATERFALL__*) закомментированы через # с дефолтными значениями. Для 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__.pylogger/raw_logger берутся как logging.getLogger("app") и logging.getLogger("row_logs"): это бесплатно и не трогает диск. Обработчики на те же объекты вешает новый configure_runtime(), он же создаёт рабочие директории. 22 импортёра logger править не пришлось вовсе — имя осталось тем же объектом.

  • src/main.pyconfigure_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-объекта: оба существовавших теста вынуждены были подделывать selftest_rx_device_selection.py звал _select_rx_device_by_frequency как unbound с self=None, а test_waterfall.py подсовывает post_processing утиный SimpleNamespace, потому что настоящий Observation в тесте не сконструировать (Hamlib).

Вынесены две ответственности, обе не зависят ни от состояния прохода, ни от железа:

  • soniks_client/rx_device.pyselect_rx_device_by_frequency(). Метод не использовал self вовсе, так что перенос механический; тест перестал передавать self=None и зовёт обычную функцию.

  • soniks_client/observation_files.pycreate_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.pybuild_waterfall(): построение PNG, анализ сигнала и судьба сырого .dat. Observation.post_processing осталась оркестратором на девять строк — вызов, сборка метаданных, PUT. Отправка намеренно не уехала: _get_metadata() собирает данные из Flowgraph и станции, к водопаду отношения не имеющие;

  • soniks_client/observation_scripts.pybuild_script_argv() (чистая функция) и run_script(). _log_script_output уехал туда же;

  • soniks_client/subprocess_log.py — общий запуск подпроцесса для скриптов и потокового графа: одинаковые шесть kwargs Popen были записаны дважды, а расхождение в 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 — потребителя для них определять вместе с диспетчером.