А1 — структурные правки решателя воздуха: план (А1.1)

Задание для исполнителя кода (А1.2, dp-engineer) и основа ревью (А1.3). Источник задач — отбор Морриса (docs/research/air-model-sensitivity.md; журнал docs/archive/plan/air-model-progress.md → «Итог отбора Морриса»). Пробы «сейчас / после» без правок решателя — tools/research/a1/ (подклассы PrtAir, SplitAir, HAir в probe.py; числа в out/*.json; воспроизведение — README.md там же). Строки кода — по feature/air-model на 30.09.2026 (28ab3de).

Коротко

ПравкаЧто не так сейчасЧто делаемГде
1. Pr_tParams.pr_t объявлен, но khf = nuf (air.py:701) — K_θ ≡ K_m; в GPU параметра нетK_θ = K_m/Pr_t, K_θ,h = K_h/Pr_t; Pr_t — параметр обоих решателей (Params.pr_t, AirCase.p.pr_t, слот P_IPRT)air.py ядро build_heat; air_picard.glsl:heat; air_case.gd
2. τ только для диабатического θ′−θ′/τ гасит и адиабатические θ′ = −γ·δz (устойчивое обтекание): седловина без нагрева зависит от τ (×1,71 / 1,90 / 1,99 при τ 1800 / 7200 / 21600 с — проба)второй переносимый скаляр θ′_d (диабатическая часть): L θ′_d = Q − θ′_d/τ; полное θ′: L θ′ = Q − w γ − θ′_d/τ (схема A′). Проба: ×2,051 / 2,051 / 2,055 — от τ не зависит (Δ = 0,004 < 0,05)оба решателя: ядро тепла (два прохода), новые буферы thd, thbd, ореол окна от родителя, тёплый старт, невязка, баланс тепла
3. closure = const + heat_mode = cbl_closure при const не считает h → AttributeError: 'Air' object has no attribute 'h_bl' (проба)толщина слоя h, w*, L — всегда (Троен–Март), при const постоянна только вязкостьтолько air.py (_closure); в GPU closure нет и не нужен

Решения на шлюз пользователя (раздел 6): значение Pr_t (рекомендация 0,85; варианты 1,0 / 0,74 / 0,95); смысл и значение τ (оставить 7200 с, смысл сужается до диабатической части); следствия правки 2 — седловина уходит от ×1,9 к ×2,05 при цели пилота 1,5 ± 0,2 (физика модели при Fr ≈ 2, не подгонять) и подъём у старта в штиль Онгудай 12:00 ниже на 15 % (0,52 → 0,44 м/с в окне 50 м; при ветре без изменений): крышка слоя перемешивания перестаёт «протекать» через τ.

Контракты: C1 → v2 (схема, reference.md, фикстуры ref/, picard/, window/ — пересчёт), C2 → v3 (AirCase.p.pr_t, тёплый старт с thd), C7 → v2 (parent_data/window_state с thd, θ′_d ореола); C3, C4 без изменений (канал theta = полное θ′). Подробно — раздел 5.

Объём для исполнителя: ≈ 1,5–2 дня работы (air.py 0,5; GPU 0,5–1; фикстуры, тесты, документы 0,5).


1. Правка 1 — турбулентное число Прандтля

Физика

Уравнение тепла модели (reference.md → «Уравнения»):

∇·(u θ′) + w dθ̄/dz = ∇·(K_θ ∇θ′) + Q − θ′/τ − s_θ (θ′ − θ_b),   сейчас K_θ = K_m (Pr_t = 1).

K-теория первого порядка: K_θ = K_m/Pr_t. Измерения нейтрального приземного слоя дают Pr_t < 1:

  • Businger, Wyngaard, Izumi, Bradley (1971) JAS 28:181 — Pr_t = 0,74 при κ = 0,35; пересчёт на κ = 0,40 (Högström 1988 BLM 42:55) даёт ≈ 0,85–0,95;
  • Högström (1996) BLM 78:215 — обзор: нейтрально φ_h(0) ≈ 0,95 ± 0,05 при φ_m(0) = 1, т. е. Pr_t ≈ 0,95;
  • Kays (1994) J. Heat Transfer 116:284 «Turbulent Prandtl number — where are we?» — для сдвиговых течений вдали от стенки 0,85 (инженерный стандарт);
  • Li (2019) Atmos. Res. 216:86 — обзор для АПС: нейтрально 0,7–1,0, при неустойчивости падает (0,5–0,7 у земли), при устойчивости растёт (> 1 при Ri ≳ 0,2);
  • K-профильные схемы с нелокальным членом (Troen & Mahrt 1986 BLM 37:129; Holtslag & Boville 1993 J. Climate 6:1825; YSU — Hong, Noh, Dudhia 2006 MWR 134:2318) задают Pr ≈ 1 в конвективном слое: часть потока тепла несёт противоградиентный член, и Pr_t локальной диффузии поднимают, чтобы не считать его дважды. В нашей модели нелокальный перенос — распределение источника Q по слою (heat_mode = cbl), не противоградиентный поток, поэтому этот довод к K_θ ниже z_i применим лишь частично.

Варианты на шлюз:

Pr_tДовод заДовод против
0,85 (рекомендация)измеренное нейтральное значение (Kays 1994; середина 0,74–0,95 Businger/Högström); K_θ работает в нейтрали, в устойчивом воздухе над z_i и в механическом слое — там Pr_t < 1 измеренв конвективном слое K-профильные схемы держат Pr ≈ 1
1,0как Троен–Март / HB93 / YSU при нелокальном члене; статус-кво, фикстуры от этого пункта не меняютсяизмерения нейтрали дают меньше; параметр «для галочки»
0,74 / 0,95классика Businger 1971 / Högström 1996 при κ = 0,40 (наше κ)края диапазона

Зависимость от устойчивости (Pr_t(Ri)) не вводим: устойчивость уже в F(Ri) местного K; при cbl конвективный перенос — нелокальный источник. Записать в «Границы модели».

Насколько это меняет ответ. Проба probe.py prt (Онгудай 12:00, цепочка 400 → 100 → 50 м, air.py):

U10Pr_tK_θ max, 400 мподъём у старта, 400 м / окно 50 мветер 50 м над стартомθ′ 50 м над стартом
01,08620,395 / 0,5160,800,72
00,859630,382 / 0,5050,750,72
00,7410580,370 / 0,4960,710,71
31,010251,199 / 1,3325,060,32
30,8511501,197 / 1,3285,050,31
30,7412681,195 / 1,3265,040,31

При cbl эффект мал: 0,01–0,02 м/с по подъёму (Моррис давал 0,1–0,17, но при перемешанном плане, где Pr_t сильнее всего работал вместе с heat_mode = surface). Выбирать Pr_t по литературе, не по подъёму.

Дискретная форма

В шаблоне тепла для соседа q по оси d: dif = ½(𝒦[idx] + 𝒦[q])/h² · (1/Pr_t), 𝒦 = K по z, K_h по x, y. Один множитель 1/Pr_t на все три оси (одно определение K_θ = K/Pr_t; горизонтальная часть K_h в слое перемешивания и так равна K, где преобладает Смагоринский — численная подсеточная диффузия, множитель безвреден). Замечание: MAir Морриса делил только вертикальную часть; расхождение второго порядка.

Места

air.py

  • Params.pr_t (стр. 510) — есть; комментарий: «турбулентное число Прандтля, K_θ = K_m/Pr_t (Kays 1994: 0,85)»; значение по решению шлюза.
  • Ядро CUDA build_heat (стр. 232–266): аргумент real inv_prt; стр. 259: dif = 0.5*(KK[idx]+KK[q])*ih*ih*inv_prt.
  • Air.__init__ стр. 701: убрать псевдоним self.khf = self.nuf; завести self.inv_prt = 1/prm.pr_t.
  • Air.build_heat (стр. 964–971): передавать self.nuf, self.nuh, …, R(self.inv_prt).
  • Air.heat_budget (стр. 1141): K = self.nuf.get()*self.inv_prt вместо self.khf.
  • reference.md: строки 98, 135, 199, 263 («Pr_t = 1») → K_θ = K/Pr_t, значение и ссылка.

GPU

  • air_picard.glsl стр. 38–43: const int P_IPRT = 20; (слот 19 занят P_NEST).
  • air_picard.glsl:heat стр. 434–435: float kd = (d == 2 ? nu[idx] + nu[q] : nuh[idx] + nuh[q]) * prm[P_IPRT];.
  • air_case.gd: p.pr_t в словаре p (стр. 45–71, рядом с tau_cool); после prm.resize(32) (стр. 398): prm[P_IPRT] = 1.0 / float(p.pr_t); константы P_NEST, P_IPRT — в AirCase (сейчас P_NEST объявлен в air_window_case.gd:15).
  • Проверка: блок «шаблон тепла C» теста test_air_picard_gpu против фикстур ref/, посчитанных с Pr_t ≠ 1 (см. раздел 4, шаг 1).

2. Правка 2 — выхолаживание только диабатической части θ′

Физика

θ′ в модели — отклонение от фона θ̄(z) погоды на час. У него два источника:

  • адиабатический −w dθ̄/dz: устойчиво стратифицированный воздух, поднятый/опущенный над рельефом, холоднее/теплее фона на θ′_a = −γ·δz. Это консервативная величина (потенциальная температура частицы не меняется), она и есть механизм блокировки, обтекания в обход, волн. Её единственный физический сток — турбулентное перемешивание (уже в ∇·(K_θ∇θ′)) и радиационное выхолаживание с масштабом суток (Newtonian cooling ≈ 1–2 К/сутки, τ_rad ~ 10⁵ с), на масштабе области (10 км / 5 м/с = 30 мин) пренебрежимое;
  • диабатический Q — нагрев от поверхности. Стационарной задаче нужен сток: без него в штиль θ′ растёт неограниченно. Физически сток — то, что фон θ̄(z, t) погоды уже включает средний прогрев слоя за день: правильный источник — Q − ⟨Q⟩ (отклонение от среднего по области прогрева), а −θ′_d/τ — его замена релаксацией; при типовом ⟨Q⟩ ≈ H̄/(ρc_p z_i) ≈ 300/(1206·1500) ≈ 1,7·10⁻⁴ К/с и θ′_d ~ 1 К τ = θ′_d/⟨Q⟩ ≈ 6000 с, т. е. τ = 7200 с согласуется с этим смыслом.

Сейчас −θ′/τ действует на сумму и за 7200 с (при 5 м/с — 36 км пути!) съедает адиабатическое охлаждение поднятого воздуха: блокировка слабеет, поток легче переваливает хребет, ×(седло/склон) зависит от τ (Моррис S = 3,3; проба ниже). Правка: релаксация только θ′_d.

τ после правки. Смысл: время, за которое диабатическая аномалия растворяется в среднем прогреве слоя (перемешивание с фоном, чей прогрев уже в θ̄). Значение 7200 с оставить (Моррис: диапазон 1800–21600 с двигает подъём у старта на 0,2 м/с — калибровать позже по LES, план п. 5). Вариант для шлюза: заменить релаксацию на Q − ⟨Q⟩ по слою (без τ) — физичнее, но это другая постановка (стационарность в штиль без выноса не гарантирована), не в А1.

Схема разделения (выбор: A′ — «полное θ′ + диабатическое θ′_d»)

Оператор L θ = ∇·(u θ) − ∇·(K_θ ∇θ) линеен при замороженных u, K (Пикар). Решаем два скаляра:

L θ′_d = Q − θ′_d/τ − s_θ (θ′_d − θ_b,d)                (диабатическая часть)
L θ′   = Q − w dθ̄/dz − θ′_d/τ − s_θ (θ′ − θ_b)          (полное θ′; τ входит явным источником)

Разность θ′ − θ′_d = θ′_a удовлетворяет L θ′_a = −w γ − s_θ(θ′_a − θ_b,a) — адиабатическая часть без τ. Полное θ′ остаётся основным полем: плавучесть в build_mom, N² в kloc, ореол окна thb, тёплый старт th, канал theta игры — всё без изменений. θ′_d — вспомогательный скаляр (буфер thd, ореол thbd).

Почему не иначе:

  • Разложение без второго скаляра невозможно. θ′_a = −γ·δz — нелокальный функционал траекторий (интеграл −wγ вдоль линии тока); в стационарном эйлеровом решателе его не выразить через поля в клетке. Любой «локальный» признак (знак θ′, наличие Q в столбце, высота) ошибается там, где важно — поднятый холодный воздух над прогретым склоном.
  • Вариант A (θ′_a и θ′_d, θ′ = сумма) эквивалентен, но требует суммы в build_mom, kloc, ореоле, канале игры, тёплом старте — больше правок и буферов в чтении; A′ трогает только шаг тепла.
  • τ → ∞ (сутки, радиация) — в штиль стока нет, стационарного решения нет (потому τ и появился).
  • Q − ⟨Q⟩ без τ — другая постановка, отложено (выше).

Численно: в уравнении полного θ′ пропадает 1/τ из диагонали (диагональное преобладание чуть меньше, адиабатическая часть теряет искусственный сток) — проба: седловина сошлась за 161–181 итераций против 101–171; Онгудай — см. таблицу ниже. Источник −θ′_d/τ берётся от θ′_d этой итерации (порядок: собрать шаблон θ′_d → прогонки θ′_d → собрать шаблон θ′ → прогонки θ′).

Полунеявная плавучесть cplz = couple·g/θ0·max(γ_w,0)·s_th, s_th = Δτ_θ/(1 + Δτ_θ/τ) (air.py:675, air_case.gd:268): s_th — эффективная постоянная времени уравнения θ′ на псевдошаге; после правки у полного θ′ нет 1/τ, согласованное значение s_th = Δτ_θ (1200 с вместо 1029 с). Неподвижная точка от cplz не зависит (член и в диагонали, и в правой части), это численный коэффициент; меняем ради согласованности, фикстуры (cplz, Kz, проводимости V-цикла) пересчитываются вместе со всем.

Поведение

  • Границы области («жёсткие»): θ′ и θ′_d в ореоле = 0; губка s_θ (потолок и приточные грани) тянет оба к 0. Адиабатическое θ′ на притоке 0 — воздух не возмущён; у потолка — гашение волн, как и было.
  • Окна (C7): ореол и цель зоны релаксации — от родителя, оба скаляра: thb ← θ′ родителя, thbd ← θ′_d родителя (трилинейно, как сейчас θ′). Решение без нагрева: Q = 0 → θ′_d ≡ 0 (родитель без нагрева даёт нулевой θ′_d; parent_data.mech.thd — нули).
  • Тёплый старт: state()/warm/window_state() несут thd; старого состояния без thd не бывает (совместимость не нужна), но безопасно: нет ключа → 0 (θ′_d сходится с нуля).
  • Сдвиг окна (air_window:shift): thd переносится из старого окна так же, как th.
  • meta() / поле игры (C3, C4): канал theta = полное θ′ — термикам (AirThermals: θ̄ + θ′ для потолка частицы) и болтанке (T_N2) нужна полная стратификация. θ′_d в игру не отдаём (термики читают heat напрямую); возможный будущий канал — вне А1.
  • Решение без нагрева: θ′_d ≡ 0 точно (Q = 0, ореол 0, старт 0). Второй проход тепла там можно пропустить, но программы GPU (_progs) общие для пары случаев — в А1 считать всегда (простота); оптимизация — ключ программы с признаком нагрева (по желанию, если замер шага покажет > +15 %).
  • Баланс тепла (heat_budget, тест GPU heat_budget): выхолаживание = Σ θ′_d/τ (не θ′).

Проба «сейчас / после» (probe.py saddle, седловина Морриса: хребет 500 м, седло 250 м, 100 м, 5 м/с

вдоль оси, N = 0,01, без нагрева; probe.py ongudai)

τ, ссейчас ×20 мсейчас ×50 мсейчас за седлом 50 мпосле ×20 мпосле ×50 мпосле за седломитераций сейчас / после
18001,7081,5860,932,0511,8671,29101 / 161
72001,9001,7441,142,0511,8671,29151 / 171
216001,9921,8201,242,0551,8711,30171 / 181
Δ(1800 − 21600)−0,284−0,233−0,004−0,004

Условие приёмки (2) «Δ < 0,05» прототип выполняет. θ′_d ≡ 0 в этом случае (проверено), θ′_min = −1,39 К (адиабатическое охлаждение поднятого воздуха; было −1,18…−1,36 в зависимости от τ). Следствие: седловина уходит к ×2,05 при цели пилота 1,5 ± 0,2 — искусственный сток маскировал физику модели (Fr ≈ 2, поток переваливает хребет, reference.md → развилка 2); решать на калибровке по устойчивости дня, не τ.

Онгудай 12:00 (probe.py ongudai, цепочка 400 → 100 → 50 м, Pr_t = 1, air.py float32):

U10итераций 400 / 100 / 50 мподъём у старта 400 м / окно 50 мw и ветер на 50 м над стартомθ′ 50 м над стартомθ′_d / θ′_a диапазон (400 м)
0сейчас61 / 71 / 1610,395 / 0,5160,19 / 0,800,72—
0после151 / 71 / 1510,253 / 0,4410,05 / 0,530,580…0,98 / −0,98…+0,72
3сейчас91 / 41 / 711,199 / 1,3320,72 / 5,060,32—
3после131 / 41 / 711,202 / 1,3360,72 / 5,090,320…0,52 / −0,37…+1,44

Все статусы ok. При ветре разницы нет (адиабатические θ′ выносятся потоком). В штиль подъём у старта падает на 15 % (окно 50 м: 0,52 → 0,44 м/с; область 400 м: 0,39 → 0,25): термики над склоном перехлёстывают верх слоя перемешивания и в устойчивом слое над z_i охлаждаются адиабатически на ~1 К; раньше τ за 2 ч стирал это охлаждение (и адиабатический нагрев опускания, +0,7 К), крышка слоя «протекала», средний подъём тянулся выше. Теперь крышка работает как в физике (зона вовлечения — предел термика), но это заметное пилоту изменение (0,08 м/с в окне при пороге 0,1) — на шлюз (раздел 6, п. 5). Сходимость: область в штиль 151 итерация против 61 (1,5 с против 0,6 с на 4070 SUPER) — второй скаляр и отсутствие стока 1/τ у полного θ′; окно 50 м не хуже. Баланс тепла rel — в out/ongudai.json.

Буферы и параметры (имена, единицы, раскладка)

air.py (Air)GPU (AirPicardJob.buf, air_picard.glsl)Что
θ′ полноеth (есть)th (есть)К, N с ореолом, клетки
θ′_dthd — новый cp.zeros(shape)thd — новый gpu.buffer(n)К, N с ореолом; 0 в земле и вне cell = 1 (как th)
цель губки / ореол θ′_dthbd — новый (0; окно — из родителя в set_nest_bc)thbd — новый (0; окно — air_window:nest)К, N
шаблон θ′_dCtd (7, N), btd (N), ctd (N, поправка 2-го порядка)тот же Cu (8N: C0..C6, b) по очереди: сначала θ′_d, потом θ′—
Q_eff = Q − θ′_d/τQeff (N), считается перед сборкой θ′в ядре: qsrc[idx] − thd[idx]·prm[P_ITAU] при pc.i0.w == 1К/с
1/Pr_tinv_prt (float), аргумент ядра build_heatprm[P_IPRT = 20]—
τprm.tau_cool → inv_tau ядра: 1/τ для θ′_d, 0 для θ′prm[P_ITAU] — только при pc.i0.w == 0; при 1 — 01/с
dθ̄/dzgam для θ′, gam0 = zeros(NZ) для θ′_dlev[L_GAM] при pc.i0.w == 1, 0 при 0К/м
решение без нагрева—thdm (копия thd в _rec_copy_wmech)для state(true), parent_data.mech
тёплый старт / состояниеstate() → +thd; init_from(st) → thd = st.get("thd", 0)warm.thd, state() → thd/thdm, parent_data.heat/mech.thd, window_state()N
окно: родительset_nest_bc: трилинейно P.thd (маска cell ≠ 0) → thbd, thd ореолаpar_thd (буфер родителя), ядро nest → thbd; old_thd → ядро shiftN родителя / старого окна
невязка θ′_dresiduals() → thd_rms, thd_max; критерий th_rms = max(θ′, θ′_d)_resid(Cu, thd, 3) в слоты S_R2D = 22, S_RMAXD = 23 (AirGpu.SCALARS = 64, 20–21 — S_NET окна); _read_residuals — max двухК/с
cplzs_th = prm.dtau_th (стр. 675)s_th = dth (air_case.gd:268)с

Ядро build_heat/heat в дискретном виде (клетка типа 1), режим m ∈ {0 — θ′_d, 1 — θ′}:

x = m ? θ′ : θ′_d;   diag = 1/Δτ_θ + (m ? 0 : 1/τ) + s_θ
b = x/Δτ_θ + (m ? Q − θ′_d/τ − γ_k·½(w[k] + w[k+1]) : Q) + s_θ·(m ? θ_b : θ_b,d) − corr2(x)
по 6 граням: перенос против потока как сейчас; сосед не земля → dif = ½(𝒦[idx] + 𝒦[q])/h²/Pr_t,
  diag += dif, C_q −= dif   (𝒦 = K по z, K_h по x, y)
не-неизвестная клетка: C = (1, 0…), b = x

Порядок в итерации (п. 7 reference.md → «Итерация Пикара»): шаблон θ′_d → heat_sweeps × (z, x, y) для θ′_d → шаблон θ′ (с новым θ′_d) → heat_sweeps × (z, x, y) для θ′. Невязка (residuals/_rec_check): шаблон θ′_d → r_d; шаблон θ′ → r; критерий по max.

3. Правка 3 — толщина слоя h при closure = const

Air._closure (air.py:734–788) при const возвращает на стр. 748–749, не задав self.h_bl, self.wstar; дальше __init__ читает self.h_bl для нагрева cbl (стр. 667) → AttributeError (проба probe.py const); λ = max(λ, λ/h·h) при const молча падает на λ = 40 м (стр. 694: hasattr). Смысл: h — свойство слоя (Троен–Март: z_i − h_s, 0,3 u*/f, Зилитинкевич), не замыкания; постоянная вязкость — только вид K(z).

Правка (только air.py): разделить _closure на _bl_depth() (u*, H_s, h_s, h, w*, L, unst — стр. 750–771, всегда) и профиль K (стр. 772–785 при hb; np.full(shape, nu_const) при const); closure_info при const — dict(kind="const", nu=nu_const, h_max=…). Убрать hasattr на стр. 694. Проба (HAir, heated_slope fixtures.py, штиль): const + cbl считается, 51 итерация, h_max 1348 м, w_max 0,69 м/с (hb: 41 итерация, 0,60 м/с). В GPU closure/nu_const/adv2/limiter — исследовательские ручки, в AirCase.p их нет и не добавляем (записать в C2).

tools/research/morris/model.py: класс MAir (Pr_t и h при const) становится лишним — удалить, оставив A.Air (в docstring отметить: Моррис считался с Pr_t только по вертикали). recal/run_grid.py импортирует model ради params, GpuLock, free, meta — работает без MAir.


4. Порядок правок и проверки на каждом шаге

Каждый шаг — отдельный коммит; после шага — указанная проверка. Godot — только с XDG_DATA_HOME=$(mktemp -d); GPU-пробы под flock /tmp/heat_ca_gpu.lock; venv с CuPy и brotli — tools/research/morris/README.md.

Шаг 0 — числа «до». tools/research/a1/out/{saddle,prt,const,heat,ongudai}.json уже есть (этот план); для Askervein — tools/research/recal/out/runs_check25.jsonl → χ² 68,06 (a1/askervein_chi2.py).

Шаг 1 — Pr_t в air.py. Ядро build_heat + inv_prt, Params.pr_t = значение шлюза, heat_budget, reference.md. Проверка: probe.py prt с A.Air (без PrtAir) даёт числа таблицы раздела 1 (совпадение с PrtAir до 1e-4 м/с); Askervein run_grid.py check25 не меняется (нет нагрева, θ′ ≡ 0).

Шаг 2 — Pr_t в GPU. P_IPRT, p.pr_t, ядро heat. Проверка: пересчитать ref/ (fixtures.py) с pr_t шлюза (и для heated_slope явно ≠ 1, чтобы блок был чувствителен), test_air_picard_gpu блоки ≤ 1e-5.

Шаг 3 — h при const (air.py). _bl_depth. Проверка: probe.py const без HAir → const+cbl ok; hb-случаи побитно прежние (ref/ не меняются: сравнить .bin до/после).

Шаг 4 — разделение θ′ в air.py. Массивы thd, thbd, Ctd/btd/ctd, Qeff; build_heat двухпроходный (θ′_d: gam = 0, inv_tau = 1/τ, ореол thbd; θ′: Q_eff = Q − θ′_d/τ, inv_tau = 0), heat_step, residuals (критерий по обоим), set_ghosts_background, init_*, state, set_nest_bc (θ′_d ореола), heat_budget, adv2_heat для θ′_d при adv2; s_th = dtau_th. Образец — SplitAir в a1/probe.py (там всё это как подкласс; в air.py — прямо в Air, одним ядром build_heat с параметрами). Проверка: probe.py saddle на A.Air даёт «после» из таблицы раздела 2 (Δ < 0,05); probe.py heat — now = split (θ′_a ≡ 0 в слое перемешивания, отличие ≤ 1e-4 К); probe.py ongudai — таблица раздела 2. Баланс тепла (heat_budget): Онгудай rel ≤ 2e-3 (проба: 0,3–1,9e-3); седловина без нагрева — rel 7,5e-3 при критерии th_rms 5e-7 и 1,2e-3 при 5e-8 (проверено: bg = Σγw ≈ 0, баланс — губка против выноса, невязка задаётся критерием остановки, не ошибкой схемы; у «сейчас» rel меньше только потому, что в масштабе стоит большой член выхолаживания).

Шаг 5 — разделение θ′ в GPU (область). Буферы thd, thbd, thdm; ядро heat с режимом pc.i0.w (0 — θ′_d, 1 — θ′); bc пишет thd ореола; _rec_heat_step, _rec_check (невязка θ′_d — слоты S_R2D = 22, S_RMAXD = 23; S_NET = 20, 21 заняты окном; AirGpu.SCALARS = 64 — места хватает; _read_residuals читает 20 скаляров → 24), _read_residuals (th_rms = max двух), warm, state, parent_data, _rec_copy_wmech. Проверка: fixtures.py → новые массивы (in_thd, Ch_d, bh_d, heat_thd, Ch, bh, heat_th, sol_thd) и блок 6 теста; picard_gpu_refs.py → test_air_picard_gpu решения (≤ 1e-3·u_s, θ′ ≤ 0,05 К), баланс тепла (cool по thd).

Шаг 6 — окна GPU. air_window:nest (+pthd → thbd), shift (+old_thd → thd), _upload_case (par_thd, old_thd), window_state. Проверка: window_gpu_refs.py → test_air_window_gpu (поле окна, граница, баланс, побитная повторяемость, клипмап в рантайме).

Шаг 7 — документы и контракты. reference.md (разделы «Уравнения», «Турбулентная вязкость», «Тепло», «Граничные условия», «Итерация Пикара», «Физика», «7. Эталоны»), docs/contracts/air-model.md по разделу 5 (К1 фиксирует до А1.2, исполнитель правит тесты контрактов в том же коммите), morris/model.py без MAir, docs/guide/air-model.md/air_model_gpu.md если там описан буфер тепла. Рецепт приёмки — раздел 6.

5. Что меняется в контрактах, фикстурах, тестах, инструментах

C1 → v2 (docs/contracts/air-model.md:38–63): «Дискретизация» — reference.md меняется (уравнение θ′ с двумя скалярами, K_θ = K/Pr_t, s_th = Δτ_θ, порядок итерации п. 7: «шаблон θ′_d → прогонки → шаблон θ′ → прогонки», критерий остановки по обоим, баланс тепла); фикстуры ref/ — пересчёт fixtures.py всех четырёх случаев, новые массивы in_thd, Ch_d (7N), bh_d, heat_thd, sol_thd; params в JSON — с pr_t; picard/ (picard_gpu_refs.py, ~3 мин) и window/ (window_gpu_refs.py, ~5 мин) — пересчёт (меняются все решения с нагревом и седловина saddle без нагрева; heat_budget с новым cool). Тесты test_c1_ref_fixture_format, test_c1_ref_mask_rule, test_c1_ref_solution_div_free — по формату проходят после пересчёта; test_contract_versions — константы версий.

C2 → v3 (стр. 65–87): AirCase.p + pr_t; запись «closure, nu_const, adv2, limiter — исследовательские параметры Params, в AirCase.p их нет (GPU: hb, 1-й порядок)»; warm/state() — {u, v, w, th, thd, p}. meta() без изменений.

C3, C4 — без изменений: theta = полное θ′ (записать явно: «θ′ = θ′_a + θ′_d, канал — сумма»).

C7 → v2 (стр. 217–265): AirPicardJob.parent_data() → heat/mech: {u, v, w, th, thd}; AirWindowJob.window_state() → те же с thd; ореол окна: «θ′ и θ′_d ореола — трилинейно из родителя». Тест test_c7_window_grid_and_api — если проверяет ключи parent_data/state, добавить thd.

Тесты GPU: test_air_picard_gpu.gd блок 6 (стр. 344–365: два прохода, _heat(0)/_heat(1), массивы Ch_d/bh_d/heat_thd), heat_budget (стр. 747–823: cool по thd, nu × 1/Pr_t в диффузии через границу); test_air_window_gpu.gd — heat_budget (стр. 216+) наследует; test_air_picard_bench.gd — замер шага (ожидание: +20–30 % на шаг тепла, +8–12 % на итерацию; записать в docs/guide/air-model-gpu.md). test_air_gpu_blocks.gd, test_air_runtime_gpu.gd, test_air_thermals.gd, test_air_place.gd — по смыслу не трогаются; прогнать.

Инструменты: morris/model.py — убрать MAir (см. раздел 3); recal/run_grid.py — без правок; tune/askervein_runs.py, synth.py, ref_study.py, real.py — без правок (θ′_d внутри Air); to_game_field.py/common.py/study.py — прикидка solver.py (мёртвый код с 449f703, в игру и эталон не входит) — не трогать; docs/research/air-model-sensitivity.md — примечание о правках (после А1.3).

Что может сломаться:

  • AirGpu.SCALARS меньше 24 → расширить; gpu.download(gpu.scalars, 20) в _read_residuals — читать 24;
  • размер буфера Cu (8N) хватает для обоих шаблонов по очереди, но между _heat(0) и _heat(1) идут прогонки θ′_d — порядок записи в одну программу строго последовательный (как сейчас);
  • окно без нагрева при родителе с mech без thd (старый parent_data) — падение на ключе: делать src.get("thd") → нули;
  • fixtures.py set_state/in_* — добавить thd, иначе блок θ′ теста стартует не с того состояния;
  • AirRuntime._warm хранит state() целиком — растёт на 1 буфер (окно 50 м: +1 МБ) — приемлемо;
  • сходимость: у полного θ′ нет 1/τ — на седловине +10–60 итераций; штиль 12:00 в окне 50 м — см. таблицу Онгудая; если где-то станет «max», это не откатывать, а разобрать в А2 («сходимость в штиль»).

6. Рецепт приёмки А1 и решения на шлюз

Все запуски — из корня копии; PY = venv Морриса; GPU под замком.

#УсловиеКак измеритьЧисло / допуск
(1)Pr_t ≠ 1 меняет K_θ и подъём, одинаково в обоих решателяхair.py: flock … $PY tools/research/a1/probe.py prt (после правки PrtAir можно заменить на A.Air — числа те же) → out/prt.json; GPU: test_air_picard_gpu блок «шаблон тепла C/b» против ref/heated_slope с pr_t ≠ 1 и решение против picard/air.py: K_θ max 400 м 862 → 963 (Pr_t 1 → 0,85), подъём в окне 50 м 0,516 → 0,505 (штиль), 1,332 → 1,328 (3 м/с); GPU: блоки ≤ 1e-5 отн., решение ≤ 1e-3·u_s, θ′ ≤ 0,05 К (различие 1 против 0,85 по шаблону — 15 %, блок его видит; по решению при 3 м/с — 0,003 м/с, ниже допуска — поэтому чувствительность даёт блок, не решение)
(2)седловина без нагрева не зависит от τflock … $PY tools/research/a1/probe.py saddle (после правки — A.Air и есть «split»)сейчас Δ×20 м = 0,284; после ≤ 0,05 (прототип 0,004)
(3)const + cblflock … $PY tools/research/a1/probe.py constnow_const_cbl.status = "ok", итераций ~51, h_max 1348 м
(4)Askervein при перекалибровкеcd tools/research/recal && $PY run_grid.py check25 (без внешнего flock: run_grid.py сам берёт замок /tmp/heat_ca_gpu.lock, внешний даёт взаимоблокировку; уже посчитанную точку он пропускает — поэтому старый out/runs_check25.jsonl сначала отложить, переименовав, а после сверки вернуть), затем $PY ../a1/askervein_chi2.py out/runs_check25.jsonlχ² 68,06 ± 2 (нейтрально, θ′ ≡ 0, Pr_t не действует; правки не должны его трогать). Группы: наветренная 7,1, вершина 6,4, подветренная 25,4, линия B 6,7, профиль RS 22,5
(5)тестыXDG_DATA_HOME=$(mktemp -d) godot --headless --path . res://tests/run_tests.tscn -- --filter=test_air_contracts; то же test_air_picard_gpu, test_air_window_gpu, test_air_runtime_gpu, test_air_gpu_blocks, test_air_thermalsвсе зелёные; допуски прежние (блоки 1e-5; решение 1e-3·u_s / 0,05 К; окно то же; побитная повторяемость окна)

Дополнительно: probe.py ongudai до/после (таблица раздела 2) — итерации не хуже ×1,5, статусы ok; test_air_picard_bench — время итерации (записать).

Решения пользователя (шлюз до А1.2):

  1. Pr_t: 0,85 (рекомендация) / 1,0 / 0,74 / 0,95 — раздел 1. Эффект на подъём при cbl ≤ 0,02 м/с.
  2. τ = 7200 с оставить; смысл — релаксация только диабатической части (раздел 2). Альтернатива Q − ⟨Q⟩ — не в А1.
  3. Принять следствие правки 2: седловина ×2,05 (цель пилота 1,5 ± 0,2, было ×1,9 при τ = 7200) — физика модели при Fr ≈ 2; в «Границы модели»; вопрос устойчивости дня — калибровка, не решатель.
  4. Pr_t — общий множитель для вертикальной и горизонтальной диффузии тепла (рекомендация) или только вертикальный (как MAir Морриса).
  5. Принять следствие правки 2 в штиль: подъём у старта Онгудай 12:00 ниже на 15 % (окно 50 м 0,52 → 0,44 м/с; при ветре без изменений) — крышка слоя перемешивания перестаёт «протекать». Это физика (адиабатическое охлаждение перехлёста термика в устойчивом слое над z_i), не настройка; компенсировать параметрами нельзя — только калибровкой нагрева по LES (план п. 5). Порог заметности пилоту 0,1 м/с — на грани.

7. Открытые вопросы (К1)

  • Ускорение штиля на области 400 м (61 → 151 итераций) — приемлемо для загрузки (+0,9 с), но если «Рассчитываем ветер» станет заметен, это вход в А2 (сходимость в штиль), не откат правки.
  • Пропуск второго прохода тепла у решения без нагрева — в А1 не делать; решить после замера bench.
  • Повторить Моррис (журнал п. 6) — после А1–А3; MAir удалён, факторы pr_t, tau_cool теперь настоящие.
  • α в игре (журнал 30.09) от А1 не зависит.

8. Решения К1 при приёмке плана (30.09.2026)

План принят. Для А1.2:

  • Pr_t = 0,85 — решение пользователя 30.09.2026 (окончательно); до решения стояло временно (шлюз п. 1 уходит пользователю через главную сессию, но А1.2 не ждёт: значение — одна константа в Params.pr_t (air.py) и AirCase.p.pr_t (air_case.gd); фикстуры несут pr_t в params JSON, тесты берут его оттуда, поэтому смена значения после решения — две строки и документы, без пересчёта эталонов). heated_slope в ref/ — с явным pr_t ≠ 1 (как в шаге 2).
  • Множитель 1/Pr_t — общий для вертикальной и горизонтальной диффузии тепла (п. 4 шлюза решает К1: одно определение K_θ = K/Pr_t; горизонтальная K_h в слое перемешивания — та же турбулентная вязкость).
  • τ = 7200 с оставить, смысл — релаксация только θ′_d (п. 2; в рамках плана, значение не меняется).
  • Следствия правки 2 (седловина ×2,05; подъём у старта в штиль −15 %; область в штиль 61 → 151 итераций) — физика модели, не откатывать и не подгонять; в «Границы модели» и в отчёт пользователю (п. 3, 5 — к сведению); сходимость в штиль — А2.
  • Контракты C1 v2, C2 v3, C7 v2 зафиксированы К1 до А1.2 (docs/contracts/air-model.md); исполнитель дописывает контрактные проверки новых ключей (thd) в том же шаге, что код.