Контекст для ИИ-агента

Страница-ориентир для ИИ-ассистентов, работающих с кодовой базой портала. Здесь собрано то, что нельзя вывести из кода за разумное время, и правила, нарушение которых ломает сборку или контракты.

Что это за проект

Портал СОНИКС — 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, русский)

Подробнее — Архитектура и Доменная модель.

Инварианты

Нарушение любого из них — дефект, а не стилистическое замечание.

  1. Миграции неизменяемы. Файлы в network/*/migrations/ не редактируются никогда. Только новые миграции через makemigrations.

  2. Секретов в коде нет. Только config("VAR_NAME", default="") из python-decouple. Новая переменная попадает в env-dist и в Конфигурация — это проверяет network/base/test_settings_documented.py.

  3. Сгенерированные файлы не правятся руками: requirements*.txt, constraints.txt (генератор — contrib/refresh-requirements.sh из setup.cfg), soniks-network-api-client/ (openapi-generator), network/_version.py (versioneer).

  4. Размещение кода. HTML-представления — network/base/views/<домен>.py, эндпоинты API — network/api/views.py классами DRF.

  5. Локализация. Пользовательские строки — через gettext_lazy (_("...")). Комментарии и docstring’и — на английском.

  6. Стиль. ruff: 88 символов, 4 пробела, двойные кавычки, сортировка импортов (стандартная библиотека → сторонние → network.*), docstring’и в стиле Google.

  7. Ответ станции, которая не просила, не меняется. Парк не обновится целиком никогда: часть станций в закрытых сетях, часть у волонтёров. Поэтому новое поле существующего ответа — только по явному опт-ину клиента (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)

Как считается TLE для запуска

Песочница орбит

Что за задача в расписании

Задачи Celery

Какой эндпоинт за что отвечает

Обзор эндпоинтов

Как выкатывается

CI/CD и деплой