Потоковый граф

Демодуляция целиком за пределами Python-кода: клиент запускает flowgraph_dispatcher — исполняемый файл из soniks-flowgraphs, собранный в образ. Задача класса Flowgraph (src/soniks_client/flowgraph.py) — построить командную строку, запустить процесс, пробросить его вывод в лог и корректно остановить.

Как строится командная строка

Все параметры лежат в словаре self.parameters, собираемом в конструкторе, и превращаются в аргументы механически:

args = [self.dispatcher_script]
for parameter, value in self.parameters.items():
    if value is not None:
        args.append(f"--{parameter}={value}")

Два следствия:

  • None = аргумент не передаётся вовсе. Поэтому в FlowgraphSettings столько полей типа X | None = None: это способ сказать «не трогай, пусть граф решает сам».

  • Имя поля настроек и имя аргумента связаны вручную, через словарь. Это не автоматический kebab-case от имени переменной: например, settings.flowgraph.RX_SAMP_RATE уезжает в --samp-rate-rx, а RF_GAIN — в --gain.

Часть параметров приходит не из настроек, а из самого прохода: mode, rx-freq, baud, norad-cat-id, пути к файлам. baud и norad-cat-id добавляются только если заданы.

Как добавить параметр графа

  1. Добавьте поле в FlowgraphSettings (src/core/configs/flowgraph.py).

  2. Добавьте строку в словарь self.parameters в Flowgraph.__init__ с тем именем аргумента, которое понимает flowgraph_dispatcher.

  3. Опишите переменную в справочнике.

Примечание

Оба bool уезжают как 0/1: flowgraph_dispatcher объявляет и --dc-removal, и --enable-iq-dump как type=int. До 2026-09-12 dc-removal передавался сырым значением, то есть --dc-removal=True, а int("True") роняет диспетчер с кодом 2 ещё до старта графа — FLOWGRAPH__DC_REMOVAL=true означало пустой проход. Сверено с тегом 2.6.0 (Фаза 3.6 в дорожной карте сети).

Соответствие режимов

Два словаря в src/core/configs/flowgraph.py связывают строку режима, присланную порталом, с конкретным скриптом satnogs. Это константы модуля, а не поля настроек: через окружение они не задаются.

  • SCRIPTS — символическое имя → имя файла скрипта ("FSK": "satnogs_fsk.py").

  • MODES — режим портала → {script_filename}. FSK, GFSK, GMSK, MSK и варианты AX.100 указывают на один satnogs_fsk.py: кадровую структуру различает диспетчер по --mode, клиенту она не нужна.

Примечание

script_filename не справочный, хотя выбор графа от него и не зависит: имя уходит шестым аргументом в satnogs-pre/satnogs-post, а оттуда в find_samp_rate.py, который по подстрокам в имени определяет частоту дискретизации IQ. См. врезку про SSTV_PD120 ниже.

До 2026-09-12 в таблице лежали ещё is_baudrate и framing — без единого читателя; сняты (Фаза 3.6 в дорожной карте сети).

Если портал прислал неизвестный режим, исход зависит от того, знает ли портал режимы станции. Пока клиент их не опубликовал (POST /api/v2/stations/<id>/status/, раз на процесс после первой успешной сверки расписания), берётся FLOWGRAPH__DEFAULT_MODE (по умолчанию FM) с предупреждением в лог. После публикации портал такой режим станции не планирует, и если он всё же пришёл — проход не начинается, в логе ошибка.

SSTV ведёт на satnogs_fm.py намеренно: в форке этот режим только пишет аудио, а картинку собирает библиотека sstv из sstv_wrapper.py уже после прохода.

Примечание

До версии 2.2.2 MODES["SSTV_PD120"] тоже брал SCRIPTS["SSTV"], то есть satnogs_fm.py. На выбор графа это не влияло — граф выбирает диспетчер по --mode, и его таблица всегда указывала на satnogs_sstv_pd120_demod.py. Ломалось другое: имя скрипта уходит в satnogs-pre/satnogs-post, а оттуда в scripts/find_samp_rate.py, который ищет в нём подстроку _sstv. Без неё функция возвращала 48000 вместо 4·4160·4 = 66560 — реальной out_samp_rate графа. Следствий было два, оба тихие: gr-satellites получал UDP-поток IQ, размеченный неверной частотой, а IQ-дамп — неверную частоту в имени файла.

Как добавить режим демодуляции

Нужны обе части, иначе не заработает:

  1. Запись в конфигурации. Добавьте скрипт в SCRIPTS и режим в MODES в src/core/configs/flowgraph.py. Ключ в MODES должен буква в букву совпадать со строкой режима, которую присылает портал.

  2. Скрипт в образе. Файл satnogs_*.py должен присутствовать в контейнере — он приходит из gr-satnogs или soniks-flowgraphs. Если его там нет, потребуется правка Dockerfile или версии соответствующего компонента.

Проверять — scripts/test-flowgraph.sh внутри контейнера: он гоняет граф без ожидания реального прохода.

Совет

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

Жизненный цикл процесса

Flowgraph.start()   # Popen, stdout+stderr в одну трубу, поток-логгер
Flowgraph.is_running  # process.poll() is None
Flowgraph.stop()    # SIGINT → wait(10 c) → kill

Остановка сигналом SIGINT, а не SIGKILL, — принципиально: по нему GNU Radio успевает закрыть выходные файлы. Убийство процесса оставит повреждённый payload.ogg и обрезанный .dat водопада. Через 10 секунд ожидания процесс всё же убивается принудительно, с предупреждением в логе.

Вывод графа читает демон-поток _log_output и пишет его построчно с префиксом [Flowgraph] на уровне LOG__FLOWGRAPH_LEVEL. На DEBUG строк очень много.

Метаданные

get_metadata() возвращает блок, который уезжает на портал вместе с результатами наблюдения:

{"radio": {"name": "gr-satnogs",
           "version": gr_satnogs_git_hash[:8],
           "flowgraphs": flowgraphs_git_hash[:8],
           "gr_satellites": gr_satellites_git_hash[:8],
           "sat_data": applied_version(),      # версия bundle, None — из образа
           "exit_code": self.exit_code,
           "mode_known": mode in MODES,
           "decoder": "gr-satellites" | "satnogs",  # дописывает Observation
           "parameters": self.parameters}}

decoder — единственный выбор декодера на проход (Фаза 3.2 дорожной карты сети): Observation спрашивает sat_data.decoder_for(norad) — есть satyaml для NORAD в каталоге пакета satellitesgr-satellites, нет → satnogs. NORAD берётся из norad_cat_id портала, а без него — из второй строки TLE. То же значение уезжает скриптам прохода переменной SONIKS_DECODER, по ней grsat.py start решает, запускать ли gr_satellites вовсе. Ключ кладётся в radio рядом с mode_known, а не в parameters: те целиком превращаются в argv диспетчера.

То есть на портал уходит полный набор параметров графа. Это удобно для разбора неудачных приёмов, но означает, что случайно добавленный в self.parameters секрет окажется в публичных метаданных наблюдения.

exit_code — код возврата, если граф кончился сам, до того как его попросили остановиться; None означает штатный проход. Без него упавший граф неотличим от успешного наблюдения: клиент код возврата не читал вовсе, и проход, на котором диспетчер вышел по argparse (код 2, расхождение контракта), выглядел просто как наблюдение с пустой директорией. Судить по process.returncode нельзя — на штатном пути там всегда -2 от SIGINT.

Выходные файлы

Граф пишет три вещи, пути к ним конструирует Observation:

Файл

Аргумент

Что это

payload_<id>_<время>.ogg

--file-path

Аудиозапись прохода

raw_waterfall_<id>_<время>.dat

--waterfall-file-path

Сырой водопад, см. Водопад

data_<id>_*

--decoded-data-file-path

Декодированные кадры (префикс)

Кадры data_* подхватывает наблюдатель файловой системы и отправляет на портал не дожидаясь конца прохода — см. Загрузку данных.

Дополнительно граф может отдавать IQ: в файл (--enable-iq-dump, --iq-file-path) и в UDP-поток (--udp-dump-host, --udp-dump-port). UDP-поток читает grsat.py — по нему кадры уходят декодеру gr_satellites, см. Скрипты.