# Дорожная карта: закрытие техдолга Живой документ. Ведётся между сессиями работы над кодом: сюда сложен полный результат аудита, порядок работ и отметки о выполнении. Правьте статусы прямо здесь по мере закрытия пунктов. **Статус на 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` на текущем `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 нарушений | Корзина | Что сделано | |---|---| | Автофикс | `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/` работающего наблюдения, удалив директорию у него из-под ног. Директория, оставленная в `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 в [дорожной карте сети](roadmap-network.md) | ### 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` давал имя `...__.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//`, беспрефиксные `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.client` — `libsox-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.py` — `get_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.py` — `Observation._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.py` — `JobData.from_dict`/`parse_datetime`: `baud: null` → `0`, отсутствующие `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 после фазы 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` рендерятся как `
/
`, а не литеральным текстом с двоеточиями * ✅ `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 [дорожной карты сети](roadmap-network.md): диспетчер объявляет оба как `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 [дорожной карты сети](roadmap-network.md). Исходный текст: (из пункта 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` — общий запуск подпроцесса для скриптов и потокового графа: одинаковые шесть 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=` перезаписывал всё соответствие целиком), хотя `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` — потребителя для них определять вместе с диспетчером.