Контракты модуля air-nn

План — docs/plan/air_nn.md, журнал — docs/archive/plan/air-nn-progress.md. Контракты волны 0 и далее (N1–N6, §8.1 плана) будут добавлены перед своими задачами. Ниже — только стыки этапа П (пилот); код пилота — исследовательский (tools/research/air_nn_pilot/), контракты пилота не переходят в N1–N4 автоматически.

Изменение интерфейса — только через координатора: версия +1, что изменилось, уведомление потребителей.

П1. Образец набора пилота (версия 3)

Владелец: NN-P1 (генератор). Потребители: NN-P2 (загрузчик, обучение, оценка), NN-P3 (проверка).

  • Каталог набора: $AIR_NN_DATA/pilot/datasets/<версия решателя>/<имя набора>/; <версия решателя> — s0-<7 знаков хеша> от кода tools/research/air3d/*.py (решатель не правится); manifest.json, plan.json (план условий с зерном), state.sqlite (статусы — §4.5 плана: cases, events, meta), cases/<id>.npz. AIR_NN_DATA — из окружения, по умолчанию ~/air_nn_data (через конфиг, не зашит в код).
  • Случай = место × условия (час, U10, откуда дует, t_max, облачность); два решения эталона AM-01 («как игра»): с нагревом (h) и без (m). Область 400 м, 96 × 96, центр места в (0, 0); окна 100 м 64 × 64 у стартов.
  • cases/<id>.npz, все массивы float16, раскладка air-lite (research/air-lite 7cc7e33, gen.py) без изменений:
    ключформачто
    d400_h(4, 13, 96, 96)u, v, w, θ′ с нагревом
    d400_m(3, 13, 96, 96)u, v, w без нагрева (механика)
    d400_hc(96, 96)высота рельефа клетки, м над морем
    d400_H(96, 96)поток явного тепла в решатель, Вт/м² (вход, как AirPlace.solar_flux)
    d400_hbl(96, 96)толщина слоя решателя, м
    w<k>_h, w<k>_m, w<k>_hc, w<k>_H, w<k>_hblто же для окна k, 64 × 64окна 100 м (пилот их не обучает)
    Оси: [канал, высота, j, i], j — север (y), i — восток (x); u — на восток, v — на север, w — вверх, м/с;
    θ′ — отклонение от фона, К. Высоты над рельефом клетки (AGL): 25, 50, 75, 100, 150, 200, 300, 400, 600, 800,
    1100, 1500, 2000 м (plan.json → agl). Значения конечные (NaN нет — проверяется тестом).
  • Метаданные случая (условия, day, profile = α, max_profile, класс устойчивости, высота/азимут солнца, сетка уровней d400/w<k>: dx, dz, x0, y0, nx, ny, nz, z_bot, статусы и итерации решений) — строка cases в state.sqlite (JSON), как строка runs.jsonl air-lite. Для анализа — выгрузка скриптом.
  • Места: 4 встроенных (ongudai, aushkul, altai, askarovo), 5 синтетик air-lite (s_*) и процедурные рельефы p_<NNN> (зерно в плане). Координаты мест: x — восток, y — север, м от центра.
  • Инварианты: файл появляется только целиком (временное имя → fsync → переименование), статус done — только после файла; повтор случая даёт побитно тот же файл.
  • v2 (02.10, этап П-2): + набор terrain (имя в configs/dataset.yaml) — только область: в cases/<id>.npz ключи d400_h, d400_m, d400_hc, d400_H, d400_hbl (формы, dtype, оси — как выше), окон w<k>_* нет; manifest.json/meta.contract = П1 v2. Места — t_<NNNN> из индекса П6 (пул и отложенные системы, все строки индекса) + при желании прежние места; условия — n_cond на место (10–12, конфиг), распределение и стратификация по часу × облачности — как plan_rows (каждый 12-й — штиль), своё зерно. plan.json → centers[t_*] — 3 точки наибольшего превышения над окрестностью 2 км (window_centers), только для оценки (окна по ним не считаются). В метаданных случая — + ctx (lat, lon, month, day, utc_offset_h, долина) и place (system, part из П6). Решение области в terrain побитно равно d400_* того же случая, посчитанного с окнами (окна решаются после области и её не меняют) — тест NN-P6. Уточнение 02.10 (без смены версии): если генератор ограничивает итерации несошедшихся решений (предел/ранний отказ — NN-P6), предел записывается в manifest.json и plan.json (solver.max_iter или аналог), образец сохраняется и при статусе max (как в v1), статус и число итераций — в метаданных случая; тест побитного равенства «область без окон = d400_* с окнами» — при одинаковом пределе.
  • v2: пилот читает несколько наборов (список в config.yaml: main v1 + terrain v2); id случаев уникальны между наборами (префиксы мест разные); окна набора v1 пилот не читает.
  • Контрактный тест (tools/research/air_nn_pilot/tests/test_contract_sample.py): ключи, формы, dtype, конечность, совпадение agl и мест с plan.json на образцах набора (или на мини-наборе smoke); v2 — оба варианта (с окнами и только область) по meta.contract.
  • v3 (03.10; решение пользователя по Q2 — вариант Б): цель несошедшегося решения — среднее поздних состояний. Для решения (h или m отдельно) со статусом max (не сошлось до solver.max_iter) генератор сохраняет в d400_* среднее арифметическое снимков полей решения (u, v, w, θ′ — все каналы) на итерациях late_from, late_from + late_step, …, max_iter (конфиг configs/dataset.yaml, по умолчанию 500…1000 через 50 — 11 снимков; снимки берутся без правки решателя); сошедшееся решение — конечное состояние, как в v2. d400_hc, d400_H, d400_hbl — как v2. В метаданных каждого решения — target (final | late_mean), late_n (число снимков, 1 для final), late_spread60_p90 (м/с; собственный разброс решения: для каждой клетки области без edge_cells на 60 м (линейно между 50 и 75 м) — RMS по снимкам |Δ(u, v)| от среднего, затем 90-й процентиль по клеткам; 0 для final). В manifest.json/plan.json — solver.late_mean = {from, step}, meta.contract = П1 v3. Повтор случая — побитно тот же файл. Набор main v1 не пересчитывается: его решения max — с целью «последнее состояние» (потребитель считает target = last для v1 по статусу max, final — для сошедшихся). Контрактный тест — + вариант v3 (ключи метаданных, late_n = числу снимков по solver.late_mean у max, 1 у сошедшихся). Уточнение 03.10 (без смены версии): решение со статусом diverged — target = final (как сошедшееся по форме, в оценке — группа несошедшихся); late_from и late_step кратны 10 (решатель зовёт обратный вызов раз в 10 итераций).

П2. Вход и выход сети области пилота (версия 5)

Владелец: NN-P2. Потребители: оценка и отчёт пилота (NN-P2), экспорт ONNX (замер ORT).

  • Поворот: на k·90° так, чтобы ветер дул «с запада» в секторе ±45°; остаток угла — в числа (cos, sin). Поворот вращением; отражение поперёк ветра — только как аугментация обучения (v4, ниже). Обратный поворот выхода — точный (тест: поворот ×4 = тождество).

  • Карты входа (float32, фиксированные нормировки, не статистика набора): рельеф hc − mean(hc) /1000 м, поток тепла d400_H /800 Вт/м²; прочие карты — по выбору NN-P2, только из того, что есть у игры без решателя (рельеф, вода, солнце), с записью в README.

  • v2 (02.10, по итогу NN-P2): + координатные карты x′, y′ клетки в повёрнутой системе (сторона притока области — граничное условие решателя; игре известны тривиально); 18 чисел FiLM, θ′ в К без масштаба — список в README пилота.

  • Числа (FiLM): U10/10, cos/sin остатка угла, признак и параметры нагрева/устойчивости из метаданных (profile, day: z_i, z_lcl над средним рельефом /1000 м, α, max_profile, высота солнца), — список и нормировки в README.

  • Выход: 13 высот × 7 каналов = 91 карта 96 × 96 в повёрнутой системе: без нагрева (u∥, u⊥, w), с нагревом (u∥, u⊥, w, θ′). Скорости — отклонение от профиля притока (по profile), делённое на max(U10, 1 м/с); θ′ — в К (или на конвективный масштаб — с записью). Обратное преобразование в м/с и К в исходной системе — одна функция, ею пользуются оценка и отчёт.

  • Деление (детерминированно, по хешу id и зерну из конфига): (б) ongudai целиком вне обучения и проверки; (а) доля случаев каждого места обучения — «новые условия»; (в) обучение на 5/10/20/40 рельефах из пула (встроенные кроме ongudai + синтетика + процедурные), вложенными подмножествами, оценка — на ongudai и отложенных процедурных рельефах.

  • v3 (02.10, этап П-2; решение пользователя): карты входа — 9 (float32, 96 × 96, повёрнутая система, порядок prep.MAP_NAMES), нормировки — фиксированные константы (не статистика набора и не min-max случая). ê′ = (cos r, sin r) — точное направление «куда дует» в повёрнутой системе (r — остаток поворота), ê′⊥ = (−sin r, cos r) — влево от потока; ∇h — центральные разности np.gradient(hc′, 400 м) (у края — односторонние); G_σ — гауссово сглаживание scipy.ndimage.gaussian_filter(hc′, σ/400 м, mode="nearest").

    #имяформуланормировка
    0terrainhc′ − mean(hc′)/1000 м
    1heat_fluxd400_H/800 Вт/м²
    2, 3x, yцентр клетки x′, y′/19 200 м
    4slope_along∇h · ê′ (> 0 — склон поднимается по ветру: наветренный)/0,3
    5slope_cross∇h · ê′⊥/0,3
    6tpi_2khc′ − G_σ(hc′), σ = 2000 м (> 0 — гребень/вершина)/300 м
    7tpi_8khc′ − G_σ(hc′), σ = 8000 м/1000 м
    8shelterSx (Winstral 2002): max по d ∈ {200, 400, …, 4000 м} от atan((h(p − d·ê′) − h(p))/d), h — билинейно по hc′, вне области — ближайшая клетка; > 0 — затенено наветренным рельефом, < 0 — открыто/0,3 рад
    Константы карт 4–8 (σ, шаг и дальность Sx, нормировки) владелец NN-P5 может изменить один раз до массового
    счёта с обоснованием через координатора (версия контракта не меняется, если меняются только числа — запись здесь).
    Числа FiLM (18) и выход (91 канал) — без изменений.
  • Деление v3 (детерминированно по хешу id и зерну): (б) ongudai — вне обучения и проверки; (г) отложенные горные системы — все места П6 с part = holdout (главная оценка); (б′) 5 отложенных процедурных p_*; пул обучения — места П6 part = pool + встроенные кроме ongudai + синтетика + прочие процедурные; (а) у каждого места пула доля случаев «новые условия» (тест) и проверка, остальное — обучение; (в) кривая — вложенные подмножества мест П6 пула размером 25/50/100/200/300 в порядке «по слоям stratum П6 по кругу, внутри слоя — по хешу»; прочие места пула входят в каждую точку; точка с полным пулом = основная сеть (отдельно не обучается); оценка кривой — на (г) и (б).

  • v4 (02.10, решение пользователя): отражение поперёк ветра (y′ → −y′) — точная аугментация обучения. Основание: в решателе (air3d/air.py) силы Кориолиса нет (f_cor — только модуль для высоты пограничного слоя); если офлайн-решатель получит Кориолис/поворот Экмана — отражение убрать (версия +1). Отражение R применяется к повёрнутому образцу: массивы 96 × 96 разворачиваются по оси j (a[..., ::-1, :]), затем знаки:

    чтоправило
    карты terrain, heat_flux, x, slope_along, tpi_2k, tpi_8k, shelterтолько разворот по j
    карта yразворот по j и смена знака (итог совпадает с исходной картой y′ — проверяется тестом)
    карта slope_crossразворот по j и смена знака
    числа FiLMsin r → −sin r; поперечная составляющая направления на солнце (если есть в числах) → минус; остальные без изменений
    выход: u⊥ (без нагрева и с нагревом)разворот по j и смена знака
    выход: u∥, w (оба набора), θ′только разворот по j
    Инварианты (тест NN-P5): R∘R = тождество (вход, числа, цель); карты из отражённого рельефа и −r (prep) = R(карты
    исходного) — согласованность входа и цели. В обучении — R с вероятностью 1/2 при каждом показе примера,
    детерминированно от зерна, эпохи и индекса (продолжение после прерывания даёт тот же итог); оценка, проверка, кривая
    и экспорт — без отражения. Остальное (карты, нормировки, числа, 91 канал) — как v3.
  • v5 (03.10, пилот П-3): физическая кодировка — вход 27 карт (первые 9 = v4), выход 117 каналов (разгон и поворот к линейной базе Б1, отрыв от склона, θ′), упаковка по высоте внутри сети; выбор encoding в конфиге, v4 остаётся. Полностью — docs/contracts/air-nn-p3.md.

П3. Отчёт пилота (версия 4)

Владелец: NN-P2. Потребители: пользователь (шлюз ШП), координатор.

  • Один файл $AIR_NN_DATA/pilot/reports/<дата>_<имя>/report.md (+ figures/, metrics.json), строится скриптом.

  • Ключевые числа на 60 м над стартами (интерполяция между 50 и 75 м, билинейно по горизонтали): ошибка ветра (|Δ скорости| с нагревом), ошибка подъёма (w без нагрева и с нагревом), RMS ветра в 1 км от старта; доля случаев с ошибкой ветра < 0,3 м/с и подъёма < 0,1 м/с. Для (а) и (б): сеть против базовых линий — профиль притока без поправки, среднее по набору, регрессия air-lite (метрики out/fit_metrics.json: rmse/skill тех же целей build.py на фолдах «место ongudai» и «новые условия»; оговорка — air-lite на окнах 100 м, сеть на области 400 м). Кривая (в). 5–8 картинок срезов «решатель / сеть / разница». Время ORT на CPU. Вывод по ориентирам ШП (§8.2 плана) — по числам отчёта, без ручных вставок.

  • v2 (02.10, этап П-2; критерий пользователя): точки оценки — (1) все клетки области без edge_cells у края на 60 м над рельефом (линейно между 50 и 75 м); (2) гребни — клетки с tpi_2k случая ≥ его 90-го процентиля; (3) прежние точки plan.json → centers. Ошибка ветра — |Δ(u, v)| с нагревом, «ок» при ≤ max(0,3 м/с; 0,1·|V_решателя|); подъём — |Δw| без и с нагревом, «ок» при < 0,1 м/с. Наборы: (г) отложенные горные системы (главное), (б) Онгудай, (б′) отложенные процедурные, (а) знакомые рельефы + новые условия (раздельно — места П6 и прежние), обучающие. Смещение: знаковая ошибка скорости e = |V_сети| − |V_решателя| (с нагревом), среднее по клеткам области — по наборам × 13 высот, × корзины U10 (0–2, 2–4, 4–6, ≥ 6 м/с), отдельно на гребнях на 60 м; то же для w. Правило ШП-2 (числа порогов — в config.yaml): на (г) доли «ок» ветра и обоих подъёмов ≥ 90 % и |среднее e| ≤ max(0,1 м/с; 2 % средней |V_решателя|) на высотах ≤ 300 м, в каждой корзине U10 с ≥ 20 случаями и на гребнях (порог смещения max(0,1 м/с; 2 %) подтверждён пользователем 02.10). Кривая (в) 25…300 на (г) и (б): доля «ок» ветра, медианы ошибок, среднее e. Таблица признаков рельефа наборов (уклон 400 м p50/p95, размах) — из П6. Базовые линии, срезы, ORT — как v1.

  • v3 (03.10; решения пользователя по Q2 и Q3): все числа v2 по наборам и кривой — раздельно для сошедшихся и несошедшихся решений (+ число случаев в каждой группе): ошибка ветра и w с нагревом — по статусу решения h, w без нагрева — по статусу m; цель несошедшихся — target из П1 v3 (late_mean, у v1 — last). Для несошедшихся — ещё отношение ошибки ветра сети к собственному разбросу решения (late_spread60_p90): медиана по случаям. Вердикт ШП-2: (1) «отказ от направления», если медиана ошибки ветра сети на (г) (все случаи) ≥ refuse_ratio (1,0, config.yaml) × наименьшей из медиан базовых линий на (г) — подтверждено пользователем 03.10; иначе (2) «идём в волну 0», если правило v2 выполнено на сошедшихся решениях (г); иначе (3) «правим подход». Правило v2 на несошедшихся — отдельная строка таблицы ШП-2 (выполнено/нет, доли «ок»), в вердикт не входит: цель несошедшихся шумит сильнее порога (собственный разброс p90 ≈ 0,9 м/с против 0,3 — NN-P6), решение координатора 03.10.

  • v4 (03.10, пилот П-3): + таблица вариантов П-3 и сводка заменимости (ρ = ошибка сети / погрешность решателя), вердикт ШП-3 — docs/contracts/air-nn-p3.md.

П6. Вырезка места (версия 3)

Владелец: NN-P4 (рельефы). Потребители: NN-P6 (генератор, через places.py), NN-P5 (деление, оценка), NN-P7.

  • Каталоги ($AIR_NN_DATA/pilot/): сырьё — raw/terrarium/<z>/<x>/<y>.png (раскладка кеша игры) + raw/manifest.json (источник, шаблон URL, атрибуция); вырезки — tiles/v3/ (v2 — tiles/v2/, v1 — tiles/v1/; не используются после v3): manifest.json (команда, коммит, конфиг и зёрна выбора, источник и лицензия, число мест по частям/слоям/системам, размеры, дата, complete), index.csv, cut/<id>.npz, figures/ (гистограммы признаков). Временное — tmp/.
  • Путь рельефа = рантайм игры (scripts/terrain/terrarium_loader.gd + configs/world.json → runtime_terrain, слой detail): уровень z = layer_zoom (zoom 12, понижение при шаге < min_spacing_m 18 м), высота R·256 + G + B/256 − 32768 м, узлы — центры пикселей web-mercator, метрическая сетка с шагом пикселя на широте центра (_plan_layer), без сглаживания. Затем — билинейно на сетку 25 м 1601 × 1601 с центром в (lat, lon) (x — восток, y — север, узел (800, 800) — центр) → h; клетки 400 м — блочное среднее 16 × 16 узлов (air3d/terrain.block_mean, область x, y ∈ [−19 200, 19 200] м). Расхождение с игрой (записано, исправляется в NN-4/N1): AirPlace.block_mean берёт f = round(dx/s) узлов слоя рантайма (s ≈ 18–38 м) — клетка f·s ≠ 400 м; пилот считает точные 400 м. Вход сети в игре (N1) обязан давать точные клетки 400 м той же функцией.
  • index.csv (строка на место, порядок — по id): id (t_0000…), lat, lon (°, центр), system (ключ горной системы из конфига или other), part (pool | holdout), stratum (метка слоя стратификации), zoom, src_spacing_m, h_mean, h_min, h_max (по hc400, м), relief_m (= h_max − h_min), slope_p50, slope_p95 (|∇hc400| центральными разностями, м/м, без 1 клетки у края), tpi2k_p95 (м), sea_frac (доля узлов h ≤ 0,5 м), sha256 (файла вырезки).
  • cut/<id>.npz: h float32 (1601, 1601), м над морем, оси [j — север, i — восток]; hc400 float64 (96, 96) — блочное среднее h; meta — JSON-строка (lat, lon, zoom, src_spacing_m, список тайлов, версия формата П6 v1). Значения конечные.
  • Инварианты: квадраты пула не перекрываются (|Δx| ≥ 38,4 км или |Δy| ≥ 38,4 км — по одной из осей; уточнено 02.10 по вопросу NN-P4) и не ближе 100 км к Онгудаю; места отложенных систем не ближе 50 км к местам пула; доля моря sea_frac ≤ порога конфига; размах relief_m ≤ 3000 м (решение пользователя 02.10, конфиг); отложенные горные системы — Кавказ, Пиренеи, Аппалачи (решение пользователя 02.10) и Южные Альпы Новой Зеландии (v2, решение пользователя 03.10), по 15 мест; один конфиг → побитно те же index.csv и cut/*.npz; файл появляется целиком (временное имя → fsync → переименование); стадии продолжаются той же командой.
  • Загрузчик: places.location("t_0000") → объект с h, info (spacing 25, x0 = y0 = −20 000), water = None, meta (center_lat, center_lon), sites (пусто), height_at(x, z) (как SynthLocation); places.context("t_…") — дата reference_context (как у игры для выбранной точки), lat/lon места, utc_offset_h = round(lon/15) и W.ground_context(height_at). Вода — нет (границы пилота: озёра в горах — как суша).
  • Контрактный тест tests/test_contract_terrain.py (координатор; владелец дополняет): столбцы и типы индекса, ключи/формы/dtype/конечность вырезок, hc400 = block_mean(h) ≤ 1e-6 м, sha256, инварианты частей и расстояний.
  • v2 (03.10; решение пользователя по Q1): + отложенная система Южные Альпы Новой Зеландии (15 мест; на v1 отложенные системы оказались положе пула — медиана уклона 0,068); перевыбор (пул 300 — с новым правилом «не ближе 50 км к местам отложенных систем», в т. ч. новозеландских) и нарезка в tiles/v2/ (сырьё raw/ общее, докачиваются недостающие тайлы). Формат index.csv, cut/<id>.npz, загрузчика — без изменений; meta вырезки — П6 v2. Потребители берут путь набора вырезок из конфига (configs/dataset.yaml, configs/terrain.yaml), не зашивают v1; тесты на настоящем индексе — число отложенных систем и мест из конфига (4 × 15), не константа 45.
  • v3 (03.10; решение пользователя по Q4 — вариант Б): + инвариант дно квадрата h_min ≤ 3000 м над морем (конфиг; решатель «как игра» и фон погоды откалиброваны до ~3 км — риск §9 плана); перевыбор и нарезка в tiles/v3/ (сырьё raw/ общее), состав систем и числа мест — как v2 (пул 300, 4 × 15); meta вырезки — П6 v3. Потребители — путь из конфига; контрактный тест — + h_min ≤ порога конфига.

П4. Таблица параметров поверхности (версия 1)

Владелец: NN-2а. Потребители: NN-2 (офлайн-решатель, баланс радиации), N1 (каналы α, β, z0 входа сети).

  • docs/research/surface_params.md (люди) + docs/research/surface_params.csv (конвейер), одна строка на класс ESA WorldCover v200 (код класса: 10 деревья, 20 кустарник, 30 луг, 40 пашня, 50 застройка, 60 голый грунт/скалы, 70 снег/лёд, 80 вода, 90 болото, 95 мангры, 100 мох/лишайник) + строки сезонного снега (snow_fresh, snow_old).
  • Столбцы CSV: class_code, class_name, albedo, albedo_min, albedo_max, bowen, bowen_min, bowen_max, z0_m, z0_min_m, z0_max_m, g_frac, g_frac_min, g_frac_max, sources (числа — безразмерные/метры, sources — ключи из списка литературы документа через ;). Выбранное значение — внутри диапазона; диапазон = значение, если источник один.
  • Не код и не вход решателя напрямую: NN-2 решает, как из таблицы строится поток H (§2.3 плана).

П5. Пробный стык ONNX Runtime ↔ Godot (пробник, не N5; версия 2)

Владелец: NN-7а. Потребители: NN-T5, NN-7 (решения по сборке; API N5 будет описан отдельно).

  • Каталог native/air_nn_probe/ (исходники, build.sh, README); бинарники ORT и сборки — вне git, путь в README.
  • Класс AirNnProbe (имя с Probe — не финальный): load(path: String) -> int (0 — успех, иначе код ошибки), run(input: PackedFloat32Array, shape: PackedInt64Array) -> PackedFloat32Array (вход [1, C, H, W] float32, порядок C-подобный; выход — плоский массив первого выхода модели), last_error() -> String, форма выхода — output_shape() -> PackedInt64Array. Пустой массив — ошибка.
  • v2 (02.10, по итогу NN-7а): + set_intra_op_threads(n) / get_intra_op_threads() (по умолчанию 1, действует на следующий load); коды load: 1 — файл не читается, 2 — ORT не создал сессию, 3 — нет входа/выхода, 4 — не загрузилась библиотека ORT. ORT грузится расширением из своего каталога (Linux — dlmopen(LM_ID_NEWLM) из‑за конфликта символов libstdc++ с addons/debug_draw_3d). Потребителей пока нет.
  • После NN-7б (решение пользователя 02.10: addons/debug_draw_3d удаляется) ожидается обычный dlopen на Linux — проверяется в NN-7б; dlmopen в пробнике остаётся до её приёмки.
  • Модель-пустышка — малый Conv (ONNX opset 17), эталон — onnxruntime в Python (venv пилота); совпадение ≤ 1e-5.