# Контекст для ИИ-агента Страница-ориентир для ИИ-ассистентов, работающих с кодовой базой портала. Здесь собрано то, что нельзя вывести из кода за разумное время, и правила, нарушение которых ломает сборку или контракты. ## Что это за проект Портал СОНИКС — 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, русский) ``` Подробнее — [](../dev/architecture.md) и [](../dev/models.md). ## Инварианты Нарушение любого из них — дефект, а не стилистическое замечание. 1. **Миграции неизменяемы.** Файлы в `network/*/migrations/` не редактируются никогда. Только новые миграции через `makemigrations`. 2. **Секретов в коде нет.** Только `config("VAR_NAME", default="")` из `python-decouple`. Новая переменная попадает в `env-dist` и в [](../configuration.md) — это проверяет `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` сверяться с [](../rating.md). **Наблюдение хранит снимок передатчика и станции.** Поля `transmitter_*` и `station_*` на `Observation` — это состояние на момент планирования, а не указатель на справочник; `Observation.save()` снимает их при создании. Читать характеристики наблюдения по FK (`observation.transmitter.downlink_low`) значит показывать сегодняшнее значение под прошлогодней записью. Снимок будущего наблюдения двигает `refresh_future_observation_snapshots()` по `post_save` передатчика — и только для станции, чьи антенны держат новую частоту: станция, которая не услышит, должна остаться на прежней (инвариант 7). **Режимы станции — фильтр планирования, и молчание значит «умею всё».** Станция присылает `POST /api/v2/stations//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`, см. [](../stations.md)): абсолют прохода — `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-` — пароль DRF-токен владельца; своя станция и только её топики: чтение `state` и `jobs`, запись `status` и `online`. - `subscriber-` — пароль `User.subscription_key`; только чтение `satellites//frames`, при подписке **и** станции в сети. ACL получает только логин, без пароля, поэтому права выводятся из логина, а логин — из id, а не из имени пользователя Django: пользователь, назвавшийся `portal` или `station-5`, прав не получает. Кадры подписчикам публикует `decode_current_frame`, через неё проходит каждый новый кадр. Новый путь создания кадра, который её обойдёт, выпадет из трансляции; путь, который не должен транслироваться, передаёт `publish=False`, как суточный импорт SatNOGS. **Расписание станции едет по MQTT, REST — старт и фолбэк.** `publish_station_jobs()` (`network/base/mqtt.py`) публикует в `stations//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](https://github.com/celery/celery/issues/5059). Обёртки не «лишний слой». Обёртка вызывает 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` у `