Контекст для ИИ-агента¶
Страница-ориентир для ИИ-ассистентов, работающих с кодовой базой портала. Здесь собрано то, что нельзя вывести из кода за разумное время, и правила, нарушение которых ломает сборку или контракты.
Что это за проект¶
Портал СОНИКС — Django-монолит, координатор сети наземных станций приёма сигналов со спутников. Out-of-tree форк SatNOGS Network. Основной репозиторий — на GitLab, merge-request’ы туда.
Соседние проекты сети: soniks-client (ПО станции) и soniks-flowgraphs
(флоуграфы GNU Radio). У обоих своя документация на sonik.space/docs/.
Карта репозитория¶
network/
├── base/ ядро: модели, логика, задачи, орбитальная механика
│ ├── models.py вся доменная модель
│ ├── views/ HTML-представления, разбиты по доменам
│ ├── tasks.py ~35 задач Celery
│ ├── rating_tasks.py оценка наблюдений
│ ├── scheduling.py расчёт окон
│ ├── auto_scheduling.py автопланирование
│ ├── launch_scheduler.py планирование запусков
│ ├── tle_priority.py отбор победителя среди источников TLE
│ ├── tle_utils.py TLE ↔ OMM, Alpha-5, контрольная сумма
│ ├── spacetrack_client.py клиент Space-Track
│ └── system_status.py сборщики страницы «Система»: Docker API, хост, Celery
├── api/ DRF: views.py, serializers.py, filters.py
│ urls.py — маршруты /api/, urls_v2.py — /api/v2/,
│ где живёт всё новое (см. Инвариант 7)
├── users/ кастомный пользователь
├── analytics/ суточные метрики
├── celery.py расписание периодических задач
├── settings.py все настройки через config()
└── urls.py маршруты верхнего уровня
docs/ эта документация (MyST, русский)
Подробнее — Архитектура и Доменная модель.
Инварианты¶
Нарушение любого из них — дефект, а не стилистическое замечание.
Миграции неизменяемы. Файлы в
network/*/migrations/не редактируются никогда. Только новые миграции черезmakemigrations.Секретов в коде нет. Только
config("VAR_NAME", default="")изpython-decouple. Новая переменная попадает вenv-distи в Конфигурация — это проверяетnetwork/base/test_settings_documented.py.Сгенерированные файлы не правятся руками:
requirements*.txt,constraints.txt(генератор —contrib/refresh-requirements.shизsetup.cfg),soniks-network-api-client/(openapi-generator),network/_version.py(versioneer).Размещение кода. HTML-представления —
network/base/views/<домен>.py, эндпоинты API —network/api/views.pyклассами DRF.Локализация. Пользовательские строки — через
gettext_lazy(_("...")). Комментарии и docstring’и — на английском.Стиль.
ruff: 88 символов, 4 пробела, двойные кавычки, сортировка импортов (стандартная библиотека → сторонние →network.*), docstring’и в стиле Google.Ответ станции, которая не просила, не меняется. Парк не обновится целиком никогда: часть станций в закрытых сетях, часть у волонтёров. Поэтому новое поле существующего ответа — только по явному опт-ину клиента (
capabilitiesв/api/jobs/), а новый функционал — в/api/v2/(network/api/urls_v2.py). Проверка правки: возьмите станцию, которая никогда не обновится; если после правки она ведёт себя иначе — правка неверна независимо от пользы.
Хрупкие места¶
Здесь легко сделать «очевидное» изменение и сломать работающий контракт.
Строка has already been uploaded в network/api/views.py — часть проводного
контракта: клиент станции определяет успешный повтор загрузки по этой подстроке.
Не переформулировать, пока весь парк клиентов не перейдёт на поле code.
Источник Manual для публикуемых TLE. POST /api/tles/ сохраняет набор с
источником Manual намеренно — это первый элемент TLE_SOURCE_PRIORITY, поэтому свежий
опубликованный набор выигрывает отбор и начинает определять планирование всей сети.
Заводить новый источник — значит менять приоритеты. Так заведён Celestrak SupGP:
ниже каталога, выше SatNOGS.
Аппарат запуска до каталога опознаётся по имени. Предстартовый импорт
(launch_supgp.py) и импорт по COSPAR для запуска с составом ищут спутник по
нормализованному имени и алиасам (importers.name_keys), и только среди аппаратов
этого запуска и аппаратов future без запуска — не по всему каталогу, иначе TLE
достанется старому тёзке. Девятизначный номер CelesTrak никуда не пишется. Имя
заглушки импорта — её связь с файлом: переименованная без алиаса, она получит дубль
при следующем опросе. «Опознан» — это catalogued_q, одно определение на страницу,
афишу и выборку импорта.
Кадры отдаются из копии в БД. /api/demoddata/ и сообщения MQTT берут кадр и
payload_json из DemodDataContent (DemodData.frame_bytes() и payload_text()), а не
из файлов: GET в S3 на каждую строку делал страницу из 25 кадров десятисекундной.
Копию пишут decode_current_frame (кадр) и decode_demoddata (кадр и payload), файлы в
хранилище пишутся как раньше. Новый путь приёма кадров обязан ставить
decode_current_frame, иначе его кадры будут читаться из S3. Payload хранится текстом,
а не в jsonb: декодер может выдать NaN, который json.dumps пишет, а jsonb не принимает.
Оценка наблюдений. Сравнение ForeignKey downlink_mode со списком строк всегда
истинно — именно так возник баг завышенных «good». Оценка читает строковый
Observation.transmitter_mode из снимка. При правках rate_observation
сверяться с Оценка наблюдений.
Наблюдение хранит снимок передатчика и станции. Поля transmitter_* и
station_* на Observation — это состояние на момент планирования, а не
указатель на справочник; Observation.save() снимает их при создании. Читать
характеристики наблюдения по FK (observation.transmitter.downlink_low) значит
показывать сегодняшнее значение под прошлогодней записью. Снимок будущего
наблюдения двигает refresh_future_observation_snapshots() по post_save
передатчика — и только для станции, чьи антенны держат новую частоту: станция,
которая не услышит, должна остаться на прежней (инвариант 7).
Режимы станции — фильтр планирования, и молчание значит «умею всё». Станция
присылает POST /api/v2/stations/<id>/status/ документ
{"modes": [...], "satellites": [...], "client_version": "...", "config": {...}}; портал сливает его
по ключам верхнего уровня в Station.reported_status (JSON) — метод
Station.merge_reported_status, тот же для моста MQTT, который пишет туда
connection, — и время приёма в reported_status_at. Клиент с 2026-09-11 пишет
ещё sdr (найденные приёмники) и calibration (свип по усилению); команды к нему
— Station.commands, блок commands документа state/ (rescan_sdr,
calibrate_gain), выполняются раз на отметку at. Форма настроек показывает и
качество приёма (network/base/reception_quality.py, решения 75–76 дорожной
карты): по последним 50 наблюдениям станции с signal.noise_dbhz в
client_metadata — медиана и разброс полки, медиана snr_db по проходам с
кадрами, доля signal.saturated. Считается на лету во view, ничего не хранит и
не правит; client_metadata — строка JSON, числа в signal — строки с
единицами, старые клиенты без noise_dbhz в окно не входят. Рядом — оценка
поправки ppm и дрейфа передатчика (network/base/ppm_estimate.py, см.
Наземные станции): абсолют прохода — reception.ppm − signal.ppm_error
(SIGN = -1, сверено по проду: у станций с ppm 0 остаток +2…+5, ручные
поправки отрицательные с остатком около нуля); обе оценки — медиана медиан
(по спутникам внутри станции, по станциям внутри передатчика), проходы без блока
reception не считаются, потому что неизвестно, с какой поправкой их мерили.
Пишет только ночная auto_tune_frequency_corrections: ppm — станциям с
Station.auto_ppm через bump_config_generation() + publish_station_state;
дрейф — только при AUTO_TRANSMITTER_DRIFT, через Transmitter.save() (сигнал
обновляет снимки будущих наблюдений) и строку TransmitterDriftLog. Все пути
планирования — get_available_stations, create_new_observation,
check_transmitter_station_pairs, predict_candidate_windows, сетевая стадия
auto_schedule_station_network и get_available_transmitter запусков — пропускают
передатчик, чей downlink_mode не в списке. Станция без документа или без непустого
списка modes планируется на всё, как раньше (инвариант 7); передатчик без режима не
фильтруется. Исключение по satyaml (решение 74, с 2026-09-12): передатчик, чей
спутник (NORAD) есть в reported_status.satellites, пропускается при любом режиме —
клиент декодирует его gr-satellites, а satnogs-граф подбирает по модуляции сам.
Кроме режимов NOT_SATYAML_MODES — LoRa (с 2026-09-24) и LRPT (с 2026-10-04,
решение 214): у gr-satellites их нет, а satyaml Метеоров описывает FSK-телеметрию,
поэтому такой передатчик получает только станция с режимом в modes — и в
Python-правиле, и в SQL-двойнике. LRPT к тому же в EXPLICIT_ONLY_MODES: станция
без modes (клиент ≤ 2.2.2) приняла бы его как FM.
LoRa-передатчик без рабочих params (is_unconfigured_lora, с 2026-09-30) не получает
никто: станция настраивает демодулятор по ним, а у записей старше поля они пусты.
Transmitter.clean() пустой params пропускает намеренно — ошибка на поле, которого
нет в публичной форме правки, роняла её с ValueError.
Пустой список — нет исключений, а не «не декодирует ничего»: станция без sat-data
bundle шлёт [] в каждом heartbeat, и 400 на него ослепил бы её целиком.
Правило одно — is_transmitter_mode_supported в
network/base/validators.py (плюс его SQL-двойник в get_available_transmitter
запусков — оба читают reported_satellites): новый путь планирования, который его
не вызовет, снова отдаст клиенту незнакомый режим, и тот молча примет его как FM. Новый ключ документа
добавляется полем в StationReportedStatusSerializer, без миграции; незаявленные
ключи отбрасываются. Писателей три, и заменять документ целиком нельзя ни одному
из них: клиент (modes, satellites, client_version, config, sdr, calibration),
manage.py mqtt_bridge (connection) и с 2026-09-11 soniks-agent (agent:
текущий и предыдущий digest образа клиента, режим, итог последнего применения —
StationAgentReportSerializer). ReleaseChannel.client_image принимает только
ссылку по digest (имя@sha256:…): тег там — раскат парка на «что угодно завтра»
без точки отката.
Поля конфигурации станции — контракт с клиентом. STATION_CONFIG_FIELDS в
network/base/station_config.py — единственный список того, что портал меняет на
станции; ключи — имена переменных окружения клиента, и его MANAGED_FIELDS
(soniks_client/remote_config.py) обязан совпадать: ключ, которого клиент не знает,
отклоняет весь документ. Форма, сериализатор config.actual и state/ читают этот
же список. Добавлять поле — сначала в клиент, потом сюда.
/api/v2/mqtt/auth/ и /acl/ — только внутри compose. Это проверка токена по
запросу без аутентификации; nginx обязан отвечать на них 404 снаружи
(docs/dev/deploy.md). В OpenAPI они не входят намеренно.
У брокера три личности:
portal— любые топики, но только с паролемMQTT_PORTAL_PASSWORD.station-<id>— пароль DRF-токен владельца; своя станция и только её топики: чтениеstateиjobs, записьstatusиonline.subscriber-<user id>— парольUser.subscription_key; только чтениеsatellites/<sat_id>/frames, при подписке и станции в сети.
ACL получает только логин, без пароля, поэтому права выводятся из логина, а логин —
из id, а не из имени пользователя Django: пользователь, назвавшийся portal или
station-5, прав не получает. Кадры подписчикам публикует decode_current_frame,
через неё проходит каждый новый кадр. Новый путь создания кадра, который её
обойдёт, выпадет из трансляции; путь, который не должен транслироваться, передаёт
publish=False, как суточный импорт SatNOGS.
Расписание станции едет по MQTT, REST — старт и фолбэк. publish_station_jobs()
(network/base/mqtt.py) публикует в stations/<id>/jobs retained-снимок — список
Observation.objects.jobs() через JobSerializer со всеми opt-in полями, один
запрос на REST и MQTT. Его ставят сигналы post_save/post_delete наблюдения с
будущим start (signals.py, ключ кеша на 2 с схлопывает массовое планирование в
одну задачу) и явно — два bulk-пути в tasks.py, которые сигналов не дают. Новый
писатель будущих наблюдений через .update()/bulk_update() обязан позвать
publish_station_jobs_task сам. Порог планирования — Station.scheduling_lead():
reported_status.scheduling.lead_seconds, пока connection == "online", иначе
OBSERVATION_DATE_MIN_START; применяется в одном месте, create_new_observation(),
сдвигом начала, а не отказом.
Обёртки задач в network/celery.py. shared_task не регистрируются в beat
напрямую — это обход бага Celery.
Обёртки не «лишний слой». Обёртка вызывает shared-задачу как обычную функцию, поэтому
autoretry_for и собственные time_limit ставятся на обёртку, а не на @shared_task:
Task.retry() у задачи, вызванной напрямую, просто перебрасывает исключение.
Лимит времени любой задачи меньше REDIS_VISIBILITY_TIMEOUT. Задачи подтверждаются
после выполнения (acks_late), и брокер переотправляет неподтверждённое сообщение через
visibility_timeout; задача, которая к тому моменту ещё работает, запускается второй раз
параллельно. То же касается countdown у apply_async. Проверка —
CeleryTaskLimitsTest в test_settings_wiring.py; поднимать лимит и таймаут вместе.
Условно регистрируемые задачи. zip_audio_files и archive_audio_zip_files
включаются только при своих флагах и выключенном USE_S3_STORAGE_FOR_AUDIO;
parse_data_from_satnogs — только при ENVIRONMENT=production.
Транзакционная граница планирования — одна, lock_stations_for_scheduling.
Контекст-менеджер в network/base/scheduling.py берёт select_for_update на станции
в порядке pk внутри transaction.atomic(); через него идут все шесть писателей
(API-сериализатор, view планирования, launch_scheduler, три задачи автопланирования),
проверка — test_scheduling_lock.py. Новый путь записи наблюдений, который его обойдёт,
вернёт гонку между проверкой перекрытий и вставкой.
Фан-аут refresh_latest_tle_sets после каждого fetch_tle. Любое изменение
в select_latest_tle умножается на размер каталога, а не применяется один раз.
При равном источнике и равной эпохе побеждает строка, записанная позже.
Состояние станции не хранится. Колонки Station.status больше нет: состояние
выводится из last_seen, is_available и testing. Отсюда два следствия. Первое:
запрос «онлайн» — это сравнение с часами, поэтому Station.objects.connected(),
вычисленный в теле класса (queryset поля формы или сериализатора), заморозит порог
на момент импорта — такие места переопределяют queryset в __init__. Второе: в v1
/api/stations/ четвёртое состояние unavailable читается как Offline, и словарь
из трёх слов трогать нельзя — см. StationSerializer.get_status.
Каталог у нас локальный, у апстрима его нет. Satellite, Transmitter, Tle,
Mode — наши таблицы; satnogs-network эти модели у себя удалил и ходит за ними в
SatNOGS DB по HTTP. Сравнивать нас нужно с объединением satnogs-network +
satnogs-db, а не с одним из них.
Словарь статуса спутника хранится один, а публикуется другой. В базе лежат слова
satnogs-db — in orbit, future, re-entered, launch failed, — потому что оттуда
их приносит fetch_data. Наружу в v1 идёт довоенный словарь (alive/dead) через
Satellite.status_badge и SATELLITE_STATUS_BADGES. Ветвиться в коде на строку
статуса можно только по хранимому значению; на то, что видит клиент, — нельзя.
Ключи каталога приходят из SatNOGS DB как есть. update_transmitters и
update_satellites передают полученный словарь в objects.create(**row), выбрасывая
лишь заранее перечисленные ключи. Появившееся у источника поле, которого нет в нашей
модели, роняет задачу импорта TypeError — и роняет внутри try, ловящего только
RequestException. Так уже случалось с reception_status и с params.
Имена enum-компонентов в схеме глобальны. @extend_schema_field(ChoiceField(...))
на методе сериализатора создаёт компонент, названный по имени поля. Метод
get_transmitter_status таким образом молча переписал TransmitterStatusEnum —
словарь статуса самого передатчика, где есть третье значение invalid. Для полей,
чьё имя совпадает с полем модели, enum в схему не публикуем; сверяться нужно по
git diff openapi.yml, а не по факту успешной генерации.
Вкладки — свой механизм, и он читает адресную строку. scripts.js держит
пару data-parent-tab / data-target-tab (Bootstrap’овский JS вкладок не
используется). data-bs-toggle="tab" на такую ссылку ставить нельзя: роли ARIA
(tablist/tab/tabpanel) и roving tabindex пишет init_tabs() из JS, а Tab из
Bootstrap забирает любой [data-bs-toggle="tab"], у чьего списка есть
role="tablist", — и начинает переключать панели и ловить стрелки параллельно.
Стрелки активируют вкладку через click(): на клике вкладки висит audio.js.
Разметку, подгруженную после загрузки, надо отдать в window.init_tabs(root).
Hash пишет обработчик клика через history.replaceState (без прыжка к панели);
scripts.js при загрузке открывает вкладку, названную в hash, и вкладку,
внутри которой лежит названный элемент — на этом держатся ссылки на секции,
переехавшие внутрь вкладок (spectrum.js открывает страницу спутника на
#transmitters-cards). Убирая id из панели или из секции внутри неё, надо
проверить, кто на неё ссылается.
data-content у <option> экранируется дважды. init_selects в app.js читает
его через dataset и вставляет как HTML, так что строка проходит два слоя: текст внутри
разметки и значение атрибута. create_transmitter_option (в observation_new.js и
autoschedule.js) экранирует поля, собирает разметку и экранирует её ещё раз; одного
прохода мало — апостроф в описании передатчика закрывал атрибут.
Общие JS-хелперы живут в app.js: show_alert (сообщение — текст или Node, не
разметка), get_json, post, radians/degrees, рядом — escapeHtml из
html_escape.js; оба файла в общем бандле base.html. Второе определение любого из них
роняет tests/js/html_escape.test.js. Jest-харнессы берут настоящие функции через
tests/js/app_helpers.js.
locked_fields — контракт между админкой и всеми импортёрами. Колонка на
Satellite со списком имён полей, которые человек заморозил; набор допустимых имён —
LOCKABLE_FIELDS в network/base/models.py, чекбоксы поверх него рисует форма
админки. Любой импортёр — скрейпер, загрузчик JSON, будущий источник — обязан прочитать
список и пропустить перечисленное. Новое импортируемое поле добавляется в
LOCKABLE_FIELDS, а не отдельной булевой колонкой. Автоблокировки по факту правки нет
намеренно: галочку ставит человек.
Описания спутника — шесть колонок, и пустой язык законен. short_description_* и
description_* по ru/en/zh. get_language() отдаёт тег zh-hans, а колонка
называется _zh, — соответствие держится на Satellite._localized, который режет тег
по дефису. Порядок отката: текущий язык → en → любой заполненный → пусто, и на пустом
шаблон не рисует блок вовсе. Перевода в портале нет: обмен идёт командами
dump_satellite_descriptions / load_satellite_descriptions.
Селекты и слайдеры инициализируются по классу из app.js. init_selects
читает те же data-*-атрибуты, что писал bootstrap-select, и init_slider
пишет значение обратно в скрытый input строкой "min,max" — на этом контракте
держится чтение параметров в autoschedule.js. Убирать атрибуты из разметки или
input из-под слайдера нельзя, не поправив обе стороны.
dayjs без плагинов — не замена moment. Ядро dayjs не умеет ни .utc(), ни
строгий разбор по формату; и то и другое включает js/dayjs_setup.js, который
обязан идти сразу за тегами библиотеки на каждой странице, где она подключена.
Без него dayjs.utc(...) — undefined, а не ошибка разбора, то есть страница
падает в первом же обработчике. И, в отличие от moment, объекты dayjs
неизменяемы: t.add(...) возвращает новый объект, а не меняет t — цикл,
написанный по-моментовски, станет бесконечным (polar_svg.js из-за этого
считает шаги в миллисекундах, без библиотеки вовсе).
Лимиты Space-Track. Клиент логинится один раз за вызов и делает один массовый запрос. Запрос на спутник или более частый вызов выберет лимит аккаунта. Стенд и прод работают под разными учётками.
collectstatic --clear выполняется при каждом старте web. Всё, что положено в
STATIC_ROOT в обход collectstatic, будет стёрто. В том числе бандлы компрессора,
поэтому COMPRESS_ENABLED на сервере включается только вместе с COMPRESS_OFFLINE:
иначе кэш после деплоя называет файлы, которых уже нет.
Блоки javascript и css рендерятся одинаково для любого запроса. Офлайн-манифест
компрессора ключуется отрендеренным содержимым блока; {% if %} или {{ }} внутри —
это OfflineGenerationError (500) на варианте, которого не было при compress. Условие
живёт в скрипте, а не вокруг тега. Подробности — docs/dev/frontend.md, «Сборка статики
в контейнере».
CSP без unsafe-*. style="…" в разметке, в том числе в строке для innerHTML,
браузер молча отбрасывает; полоса остаётся нулевой ширины, скрытый элемент — видимым.
Размеры идут через data-width/data-height и apply_sizes (app.js), скрытие — через
атрибут hidden, и показывает элемент снятие hidden, а не style.display: Bootstrap
держит [hidden] с !important. nonce есть только у <style> плеера wavesurfer: его
носит <meta name="csp-nonce"> в блоке meta двух страниц наблюдения, не в блоке
javascript (см. пункт выше). Умолчания CSP_* до прода сами не доходят: .env при
деплое собирается из серверного $FILES_FOLDER/env-dist, и заданная там переменная
перекрывает settings.py. Подробности — docs/dev/frontend.md, «Политика безопасности
контента».
Сбор тестов. python_files = tests.py test_*.py — оба образца обязательны:
наборы приложений живут в tests.py, всё новое в test_*.py. С одним первым
десять модулей не собирались вовсе, и никто об этом не сообщал.
Проверки¶
tox -e ruff # форматирование
tox -e deps,pytest # зависимости + тесты
tox -e docs # документация, -W: предупреждение = ошибка
npm run lint # eslint + stylelint
npm test # jest
Документация собирается с -W, поэтому новая страница обязана попасть в toctree,
а ссылка на несуществующий документ роняет сборку.
Совместимость версий¶
Python 3.12 (Dockerfile и CI-образ), Django 5.2 LTS, DRF 3.18, Celery 5.6, PostgreSQL 15.
Фронт — Bootstrap 5.3, Tom Select, noUiSlider, dayjs; SCSS собирает dart-sass из пакета
sass-embedded. Документация — sphinx>=8 и myst-parser>=4 в docs/requirements.txt;
потолок под Python 3.9 снят вместе с переходом на 3.12.
Где искать ответ¶
Вопрос |
Страница |
|---|---|
Что делает портал |
|
Как запустить |
|
Что означает переменная окружения |
|
Как устроена модель данных |
|
Как считаются пролёты |
|
Откуда берутся TLE |
|
Как считается TLE для запуска |
|
Что за задача в расписании |
|
Какой эндпоинт за что отвечает |
|
Как выкатывается |