Устройство кода ТГИД и расчётного движка 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 и удаляют после проверки.
Как сделать в ТГИД
- Где что лежит, слои, формат ошибок — Устройство кода.
- Команды проверок и сборка стенда — Тесты и стенды.
- Переменные движка — Расчётные движки; отличия fixed от десктопа — Расчётный движок.
- Правила кода для нескольких городов — Несколько городов.
Задания
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 остался побайтно как десктоп.
Ответ
- Новый член
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). Ещё кеш никогда не сбрасывается: после правки сети граф
устареет — нужна версия данных или срок жизни.
