Урок 1 из 4 · 40 мин

Устройство кода ТГИД и расчётного движка heatnet

Два репозитория: веб на Nuxt (Vuetify, Pinia, MapLibre, Cesium) и API на FastAPI (asyncpg, PostGIS, Celery). Запрос проходит цепочку слоёв API, долгое уходит в очередь города. Расчёт — пакет heatnet: чтение → модель → физика → решение → вывод, с ядром на Rust и профилями legacy/fixed через compat.Quirk. Изменения проверяют тестами без базы, e2e на стенде и сверкой движков на копиях баз.

Устройство кода ТГИД и расчётного движка heatnet

Кратко. Два репозитория: веб на Nuxt (Vuetify, Pinia, MapLibre, Cesium) и API на FastAPI (asyncpg, PostGIS, Celery). Запрос проходит цепочку слоёв API, долгое уходит в очередь города. Расчёт — пакет heatnet: чтение → модель → физика → решение → вывод, с ядром на Rust и профилями legacy/fixed через compat.Quirk. Изменения проверяют тестами без базы, e2e на стенде и сверкой движков на копиях баз.

Зачем это

Разработчику нужно знать, куда класть новый код и что сломается, если положить не туда. В ТГИД три типичных ловушки: кеш без города на общем API, маршрут без своей проверки входа и правка расчёта без сверки с эталоном. Урок объясняет устройство, из которого эти ловушки следуют.

Два репозитория и одна база

Репозиторий Главное
веб (web-itwin) pages/index.vue — вся карта; окна в components/, состояние — Pinia stores/, клиент API — services/api/*, словари — locales/
API (itwin-api) main.py — сборка; routers/ — маршруты; database/ — запросы и предметная логика; worker.py — задачи Celery; heatnet/ и native/ — расчёт

Десктоп и веб работают с одной базой города: таблицы сети и результатов (us_out, ut_out, pt_out …) общие, свои таблицы веба — tgid_*. Их создаёт сам API (database/city_schema.py), а не скрипт администратора: десктоп при переконвертации пересоздаёт базу, и всё веб-специфичное должно возвращаться само.

Путь запроса

Слои снаружи внутрь: перевод ошибок (api_i18n.py) → город по префиксу /city/<код>/ (city_context.py) → метрики → журнал действий → маскирование ПДн → роль PostgreSQL → шлюз AUTH_REQUIRED_GET → CORS → маршрут. Порядок не случаен: перевод снаружи видит ошибки всех слоёв, город известен раньше всех, кому нужна база, журнал пишет итог уже после маскирования и проверки входа.

Дальше — зависимости маршрута: вход и права (require_permission(...)), флаг записи (require_mutations_enabled()). Маршрут берёт соединение из пула своего города и вызывает функцию из database/. Долгое (расчёт, выгрузка, запекание тайлов) уходит в Celery: ответ — task_id, ход — GET /api/v1/task/{task_id}.

Общий API для всех городов задаёт два правила кода: город — cities.current_city(), а не DB_NAME; любой кеш процесса, построенный по базе, — с ключом cities.cache_key().

Пакет heatnet

Переписанный движок десктопа: те же формулы и те же таблицы результатов, поэтому расчёт из веба виден в десктопе.

options, compat  — аргументы расчёта (как у прежнего движка) и набор особенностей
io               — чтение фрагмента и справочников, запись *_out (COPY; HEATNET_WRITE=insert — прежний INSERT)
model            — сеть, подсети, порядок обхода
physics          — сопротивления, расходы потребителей, графики, нормы потерь, тепло внутренних схем
solve            — hydraulic (Ньютон), thermal, regulators, subnet
pipeline         — стадии расчёта фрагмента; сами стадии собирает stages.py из io и output
output           — строки результатов, проверки, обобщённые потребители, дроссели
report           — протокол и коды сообщений

pipeline.py повторяет порядок прежнего движка: заголовок протокола → чтение → проверка температуры → расчёт каждой слабосвязной подсети с узлом заданного напора → запись → дроссели → итог. Порядок строк протокола тоже сохранён: протокол разбирают веб и десктоп.

Профили через compat.Quirk

Переписывание шло в два шага: сначала heatnet совпал с прежним sety побайтно, в том числе в его ошибках, затем каждая ошибка исправлялась отдельно. Каждая особенность — член перечисления Quirk с описанием, где она была в старом коде. Код, который её воспроизводит, спрашивает quirks.has(Quirk.X).

LEGACY = все члены Quirk                 (39)
FIXED  = принятые исправления            (35)
профиль legacy = LEGACY
профиль fixed  = LEGACY − FIXED          (остались 4: ZERO_AS_MISSING, DIAG_REG_ALL_ROWS,
                                                     HEAT_FIXED_ITERATIONS, AVTOMAT_IGNORED)

Выбор — Quirks.from_env: HEATNET_PROFILE и HEATNET_QUIRKS_OFF=ИМЯ,ИМЯ (выключить сверх профиля — для отчёта «было/стало»). Неизвестный профиль или имя — ошибка с текстом «допустимо …», а не тихое умолчание. Почему так, а не правкой «на месте»: legacy остаётся рабочим эталоном для сверки с десктопом, а каждое исправление можно включить, измерить и описать отдельно.

Ядро на Rust

native/heatnet_core (модуль heatnet._core) считает тяжёлые места: тепловую матрицу, нагрузки потребителей, расходы. Переключатель — heatnet/native.py:

HEATNET_CORE Что считает
python (по умолчанию, и любое неизвестное значение) только Python
rust горячие места — ядро, остальное (сообщения, ошибки данных) — Python; результат тот же бит в бит
check обе ветки, берётся Python, расхождения — в stderr

Нет модуля — Python-ветка и одна строка в stderr. Это намеренно: ядро — ускорение, а не новая физика.

Сверка движков

scripts/golden/engine_parity.py считает «фрагмент × режим» двумя движками (--base, --new, legacy или heatnet) на копии базы и сравнивает все таблицы результатов, сообщения протокола и SQL. Работает только с базой на _copy; режимы, которые пишут в исходные таблицы, по умолчанию не запускаются. Код возврата 1 — есть расхождения. --new-quirks-off ИМЯ показывает, что изменит одно исправление. Эталоны десктопа — run_reference.py check, замеры — bench_heatnet.py.

Тесты и стенды

Что Где Нужна база
веб: lint, typecheck, vitest npm run check нет
API: маршруты через TestClient с подменой зависимостей pytest tests/… нет
e2e Playwright e2e/, на живом стенде копия
расчёт против эталонов tests/heatnet/, tests/golden/, engine_parity.py копия и эталоны

Стенд — API, воркер и веб на копии базы со своим REDIS_DB: с общим номером воркер стенда заберёт чужие задачи и выполнит их на своей базе. Записи тестов помечают SYNTH-E2E и удаляют после проверки.

Как сделать в ТГИД

Задания

1. Куда положить

Куда в коде добавить: (а) перевод нового текста ошибки на казахский; (б) новую таблицу веба в базе города; (в) новый инструмент для ИИ-клиентов; (г) новую кнопку записи в окне, которая должна гаснуть при выключенной записи на сервере?

Ответ

(а) Шаблон текста в api_messages.py (тест tests/test_api_i18n.py проверит подстановки). (б) Пункт Item в database/city_schema.py и SQL в sql/ — API применит его сам при запуске и после переконвертации. (в) Запись в реестре tgid_mcp/tools.py: модель параметров, функция, право, пометка «только чтение» — инструмент появится у MCP и у помощника. (г) Кнопка смотрит useMutationsEnabled, а маршрут API — require_mutations_enabled(): прятать кнопку недостаточно.

2. Исправить особенность прежнего движка

Вы нашли в heatnet ещё одно место, где воспроизводится ошибка десктопа. Опишите шаги исправления так, чтобы профиль legacy остался побайтно как десктоп.

Ответ
  1. Новый член Quirk с описанием: где в старом коде и в чём ошибка. 2. Старое поведение — под if quirks.has(Quirk.X), новое — в ветке без особенности. 3. Член остаётся в LEGACY (он там автоматически: LEGACY = frozenset(Quirk)) и добавляется в FIXED. 4. engine_parity.py на копиях с --new-quirks-off X — отчёт «было/стало», затем запись в docs/heatnet-corrections.md. Legacy при этом не меняется.

3. Прочитайте stderr расчёта

В выводе воркера при HEATNET_CORE=rust:

heatnet: HEATNET_CORE=rust, ядро не загружено (No module named 'heatnet._core') — расчёт на Python

Числа изменились? Что проверить?

Ответ

Числа те же: Python-ветка даёт результат бит в бит, медленнее только время. Проверьте, что файл heatnet/_core.pyd, собранный под текущий коммит, лежит рядом с кодом heatnet: в git его нет, он выкатывается отдельно. Прежний файл при замене переименовывают — идущий расчёт держит его открытым.

4. Найдите ошибку в кеше

_graph_cache: dict[int, Graph] = {}

async def network_graph(conn, fragment_id: int) -> Graph:
    if fragment_id not in _graph_cache:
        _graph_cache[fragment_id] = await build_graph(conn, fragment_id)
    return _graph_cache[fragment_id]
Ответ

На общем API номера фрагментов разных городов совпадают: граф фрагмента 74 одного города вернётся запросу другого. Ключ — (cities.cache_key(), fragment_id). Ещё кеш никогда не сбрасывается: после правки сети граф устареет — нужна версия данных или срок жизни.

Проверьте себя

Решено 0 из 6
1Как в heatnet устроен профиль fixed?
2HEATNET_CORE=rust, а модуль heatnet._core не собран. Что будет с расчётом?
3Зачем режим HEATNET_CORE=check?
4Вы добавили в API кеш графа сети на уровне модуля с ключом «номер фрагмента». Что сломается на общем API?
5Почему маршрут чтения данных сети должен иметь свою проверку входа, а не полагаться на шлюз?
6Где разработчику проверить расчёт против эталонов, не трогая рабочие базы?