Б2 «α и λ в игре: профиль притока по устойчивости, пересчёт эталонов и цена» — записка

Волна Б модели воздуха, исполнитель Б2, ветка air/b2 (feature/air-model 00ae28a + air/b1). Контракт — C2 v4 (docs/contracts/air-model.md). Код, данные, команды — tools/research/b2/README.md.

Коротко

  • Одна функция профиля WindProfile (scripts/atmosphere/wind_profile.gd, static) для решателя (AirPlace.domain_case, AirWindowCase → AirCase.p.alpha, p.max_profile) и аналитического ветра (WindModel через Atmosphere). Эталон — tools/research/air3d/wind_prof.py (те же таблицы), его вызывает real.case → real.make.
  • α по устойчивости: α = 0,24 · r(класс Паскуилла–Тёрнера), r — Irwin 1979 к D. max_profile = (z_sat/10)^α, z_sat = 0,3 · 0,3 u*/f (правило rules.py, C10 v3). λ: lam 40 м, lam_frac 0,0158.
  • Конфиг: wind.shear_exponent_neutral 0,24 и wind.z_sat_frac 0,3; shear_exponent и max_profile_factor удалены.
  • Эталон и игра для Онгудая 12:00 дают одно α/max_profile (test_air_place): 0 м/с — A, 0,112 / 1,149112; 3 м/с — B, 0,112 / 1,404479; 6 м/с — D, 0,240 / 2,445407 (игра и эталон совпадают до 1e-9).
  • Днём профиль мягче прежнего, в нейтраль — круче. Полдень, ясно, 3 м/с: на 300 м 4,2 м/с вместо 4,8; облачно или 20:00 — 6,2; 6 м/с в любой час — 13,6 вместо 9,7.
  • Устойчивые классы E/F (вечер после ≈ 20:25) с нейтральным z_sat давали на 300 м до 14 × U10 (C2 v4). По решению К2 (C2 v5, § 4) z_sat для E, F — по толщине устойчивого слоя 0,4 √(u*L/f), L по Golder 1972: при 3 м/с на 300 м 2,14 × (E) и 1,97 × (F) U10.
  • Цена: штиль с нагревом на 200 м — 1101 итерация эталона вместо 251 (×4,4, как ждали по Б1); прочее — § 5.

1. Функция профиля

WindProfile.alpha(u10, sun_elev_deg, cover) -> float
WindProfile.max_profile(alpha, u10, z0, f_cor) -> float          # (z_sat/10)^α
WindProfile.z_sat(u10, z0, f_cor) -> float                       # z_sat_frac · 0,3 · κ·max(u10, 0,5)/ln(10/z0) / f
WindProfile.stability_class(u10, sun_elev_deg, cover) -> int     # 0..5 = A..F
WindProfile.net_radiation_index(sun_elev_deg, cover) -> int      # −2..4
WindProfile.sun_elevation(ctx, hour) -> float                    # SunClock.solar_position, без запаздывания прогрева
WindProfile.apply_to_case(c: AirCase, ctx, hour, cover)          # c.p.alpha, c.p.max_profile по ветру случая

Класс устойчивости — таблица Тёрнера (Turner 1964; таблица и правила индекса радиации — EPA-454/R-99-005, табл. 6-4, 6-5; Pasquill 1961). Строки — ветер на 10 м в узлах, столбцы — индекс радиации NRI; 1 = A … 7 = G (G → F):

узлы \ NRI43210−1−2
0–1AABCDFG
2–3ABBCDFG
4–5ABCDDEF
6BBCDDEF
7BBCDDDE
8–9BCCDDDE
10CCDDDDE
11CCDDDDD
≥ 12CDDDDDD

NRI: сплошная облачность (1,0) — 0; ночь — облачность ≤ 0,4 → −2, иначе −1; день — класс инсоляции по высоте солнца (> 60° — 4, 35–60° — 3, 15–35° — 2, ≤ 15° — 1), облачность > 0,5 — минус 2, не ниже 1.

Решения, которых нет в таблице (обоснование — в комментариях кода):

  • Ночь Тёрнера («час до заката — час после восхода») задана высотой солнца < 8°: в июле на 50° с. ш. через час после восхода солнце ≈ 9°, за час до заката ≈ 7° (Онгудай: 20:00 — 10,8°, день; 20:30 — 6,4°, ночь).
  • Облачность игры — WeatherModel.sky_params(sky).cover: ясно 0, переменная 0,3, облачно 0,85. Нижней границы облаков в игре нет; «> 0,5 — минус 2» — ветка Тёрнера для границы ниже 7000 футов (кучевые и слоистые). Перистая пелена (cirrus_cover) в класс не входит — у Тёрнера высокая облачность почти не меняет класс.
  • Нижний предел U10 = 0,5 м/с только для z_sat (порог трогания анемометра, EPA-454/R-99-005); в штиль ветра нет, профиль решателю не нужен.
  • r по Irwin 1979, сельская местность: A 0,07, B 0,07, C 0,10, D 0,15, E 0,35, F 0,55 → r = 0,47; 0,47; 0,67; 1; 2,33; 3,67; α = 0,112; 0,112; 0,16; 0,24; 0,56; 0,88.

Где ставится:

  • решатель: AirPlace.domain_case и AirWindowCase.window_at — WindProfile.apply_to_case (час случая, облачность дня AirPlace.day(...).cover, ветер случая; z0 и f — из c.p). AirCase.p.max_profile по умолчанию NAN — правило z_sat при α случая (AirCase.max_profile_used), как Params.max_profile = None в air.py (синтетика фикстур ref/);
  • эталон: air.Params(alpha=0,24, max_profile=None, lam_frac=0,0158); real.case пишет c.alpha, c.max_profile (wind_prof.for_hour), real.make ставит их в Params (ref_study.py, генераторы фикстур, lee_fields.py — все через real);
  • аналитический ветер: WindModel.set_conditions(sun_elev_deg, cover) и set_wind пересчитывают α и предел (z0 0,1 м и f — AirCase.Z0, F_COR); Atmosphere берёт высоту солнца часа из погоды (WeatherModel.derive → _derived.sun_elev_deg, новое поле; пресет без часа — clouds.sun_elevation_deg) и облачность прогноза (_derived.sky), обновляет при configure, шаге хода дня (_apply_day) и мягком обновлении погоды. roughness_height_m (пол профиля 1 м) и множитель высоты над стартом (altitude_*) — без изменений.
  • λ: AirCase.LAM_FRAC 0,0158 (одно значение у решателя и масштаба 3 FieldTurbulence, как требует AM-09б), p.lam 40.

2. α и max_profile для Онгудая (15 июля, UTC+7)

Из tools/research/b2/out/profile_table.md (эталон) = out/game_profile.txt (игра) — совпадают во всех строках. Ветер, переменная облачность — как ясно (0,3 ≤ 0,5).

час (солнце)ясно: 0 / 3 / 6 м/соблачно: 0 / 3 / 6 м/с
9 (32,1°)B 0,112 / C 0,160 / D 0,240C 0,160 / D 0,240 / D 0,240
12 (56,8°)A 0,112 / B 0,112 / D 0,240C 0,160 / D 0,240 / D 0,240
15 (55,1°)A 0,112 / B 0,112 / D 0,240C 0,160 / D 0,240 / D 0,240
20 (10,8°)C 0,160 / D 0,240 / D 0,240C 0,160 / D 0,240 / D 0,240
20:30 (6,4°)F 0,880 / F 0,880 / D 0,240F 0,880 / E 0,560 / D 0,240

max_profile (z_sat: штиль — 35 м по пределу 0,5 м/с, 3 м/с — 208 м, 6 м/с — 415 м): α 0,112 — 1,149 / 1,404 / 1,518; α 0,16 — 1,220 / 1,625 / —; α 0,24 — — / 2,071 / 2,445. Устойчивые (C2 v5, z_sat по устойчивому слою): E 0,56 при 3 м/с — z_sat 38,9 м, 2,138; F 0,88 — штиль z_sat 8,8 м, 0,896; 3 м/с — 21,6 м, 1,971; 5 м/с — 27,9 м, 2,468.

Сверка с rules.py (C10 v3) на контрольных входах — до 1e-9 (числа записаны в test_c2_params_match_reference): (α 0,24; 3 м/с; z0 0,1; f 1,13e-4) → z_sat 207,539 м, max_profile 2,070637; (0,235; 8,8; 0,03; 1,23e-4) → 443,372 м, 2,437757.

3. Профиль ветра WindModel до/после (ветер / U10 над рельефом)

До: α 0,14, предел 1,8 — все часы одинаково: 2 м 0,798; 10 м 1; 50 м 1,253; 100 м 1,380; 300 м 1,610.

условиякласс2 м10 м50 м100 м300 м300 м при U10, м/с (до → после)
9:00 ясно 3 м/сC0,77311,2941,4451,6254,8 → 4,9
12, 15 ясно 3 м/сB0,83511,1981,2941,4044,8 → 4,2
9–20 облачно 3 м/с; 20:00 ясно 3 м/сD0,68011,4711,7382,0714,8 → 6,2
любой час до 20:00, 6 м/сD0,68011,4711,7382,2629,7 → 13,6
20:30 облачно 3 м/сE0,40712,1382,1382,1384,8 → 6,4 (v4: 16,4)
20:30 ясно 3 м/сF0,24311,9711,9711,9714,8 → 5,9 (v4: 43,3)

У земли (ниже 10 м) ветер днём почти прежний (B: 0,835 против 0,798), в нейтраль — на 15 % слабее (0,680).

4. E/F: насыщение по устойчивому слою (C2 v5)

Классы E/F появляются только вечером после ≈ 20:25 (солнце < 8°): в ясную ночь F до 6 узлов (3,3 м/с), E — 7–10 узлов (до 5,4 м/с); облачно — F до 3 узлов (1,8 м/с), E — 4–6 узлов. В игре это полёт со старта 20:00 и ход часов до 21:00 (time.max_hour); сам старт 20:00 — C/D. При 6 м/с класс D в любой час.

v4 (первый вариант Б2): z_sat — толщина нейтрального слоя; F давал на 300 м 14,4 × U10 (43 м/с при 3 м/с), E — 5,5 ×. Нефизично: ночной устойчивый слой много тоньше нейтрального.

v5 (решение К2): z_sat = 0,3·h, h = min(0,3 u*/f, 0,4 √(u*·L/f)) для E, F (Зилитинкевич; как air._bl_depth и AirCase._closure), для D и неустойчивых — 0,3 u*/f (как rules.py). L — Golder 1972: 1/L = a + b·lg z0 (A −0,096/0,029, B −0,037/0,029, C −0,002/0,018, D 0/0, E 0,004/−0,018, F 0,035/−0,036; Myrup & Ranzieri 1976, Seinfeld & Pandis). При z0 0,1 м: L(E) = 45,5 м, L(F) = 14,1 м. Сигнатура max_profile(alpha, u10, z0, f_cor, cls) (по умолчанию D), то же в wind_prof.py; класс передают apply_to_case, WindModel, for_hour.

класс, U10z_sat, мmax_profile300 м, м/с
E, 1 / 3 / 5 м/с22,4 / 38,9 / 50,21,572 / 2,138 / 2,4671,6 / 6,4 / 12,3
F, 1 / 3 / 5 м/с12,5 / 21,6 / 27,91,216 / 1,971 / 2,4681,2 / 5,9 / 12,3

На высоте ветер теперь ≈ нейтральному (D при 3 м/с — 2,07 × U10), сдвиг сосредоточен в нижних 20–40 м. Оговорка: в штиль F даёт z_sat 8,8 м < 10 м (max_profile 0,896) — при U10 ≲ 0,7 м/с ветер на 10 м в WindModel чуть меньше U10; ветер там ≈ 0, на полёт не влияет. Дневные случаи (A–D) не изменились — фикстуры и bench не пересчитывались (проверено прогоном GPU-тестов, § 6).

5. Цена на сетках игры до/после

Замеры — tools/research/b2/bench.sh (логи out/bench/{base,b2}_*.log, сводка out/cost.md, cost.py). «До» — копия на базовом коммите dd29bd4 (git worktree), «после» — e674c49; один GPU (RTX 4070 SUPER), по очереди под замком. Монитор был погашен (DPMS): vsync X11 давал ~1 кадр/с, и первый замер через tools/gpu_tests.sh упёрся в таймауты 60 с — отброшен; оба замера сделаны с --disable-vsync --max-fps 60 (как монитор 60 Гц). Итерации детерминированы, стена — шумная.

Bench test_air_picard_bench (решение GPU, итераций (эталон f32); GPU, с). Цена итерации не изменилась: 400 м 4,03 мс (без θ′_d 3,18), 200 м 14,2 мс (11,1).

сеткаветер, нагревдопосле
400 мштиль, с нагревом150 (161); 0,68200 (201); 0,90
400 м3 м/с, с нагревом130 (131); 0,60190 (191); 0,85
400 м3 м/с, пара (без + с)280 (151 + 131); 1,16420 (231 + 191); 1,69
400 м6 м/с, с нагревом130 (131); 0,60130 (131); 0,60
200 мштиль, с нагревом250 (251); 3,85 (стена 6,9 с)1100 (1101); 16,6 (стена 25,3 с)
200 м3 м/с, с нагревом170 (171); 2,66210 (201); 3,26
200 м3 м/с, пара370 (191 + 171); 5,13430 (221 + 201); 5,97
200 м6 м/с, с нагревом180 (181); 2,81190 (191); 2,96
400 м, тёплый старт12:00 холодный / тот же тёплым / 12:30 холодный / от 12:00130 / 20 / 120 / 50190 / 20 / 180 / 70

Игра (AirRuntime, GPU-тесты test_air_, Онгудай 12:00, 3 м/с, 150°):

чтодопосле
загрузка, область 400 м3,08 с, итер. [150, 130]3,66 с, [230, 190]
загрузка с окнами («Рассчитываем ветер»)5,54 с стены; окна 100 м [40, 40], 50 м [50, 60]6,18 с (+12 %); [40, 40], [50, 50]
итераций цепочки 400 → 100 → 50 (без + с нагревом)470600 (+28 %)
пересчёт по сроку 12:15, тёплыйGPU 1,15 с, [80, 130]GPU 1,53 с, [120, 190]
пересчёт 12:15 с окнами6,04 с6,38 с
смена ветра 3 → 4 м/с, тёплыйGPU 1,28 с, [110, 120]GPU 1,38 с, [120, 130]

Откуда рост (split.py → out/split.md, эталон air.py f32, итераций):

областьветернагревдотолько профильтолько λ/hпосле
400 мштильда161161201201
400 м3 м/сда131171141191
400 м3 м/снет151221161231
200 мштильда25125111011101
200 м3 м/сда171181181201

Штиль дорожает от λ/h (×4,4 на 200 м — как в Б1: 1101 против 261), ветер — от профиля: в полдень при 3 м/с (класс B) ветер на высоте 1,40 × U10 вместо 1,8 × — поле «слабее», псевдовремя релаксации то же, нужно больше итераций. Ожидание Б1 «цепочка игры +14 %» — для штиля с нагревом при прежнем α; цепочка игры при 3 м/с — +28 % итераций, +12 % стены на загрузке.

6. Что изменилось в тестах и фикстурах

  • tests/contracts/test_air_contracts.gd::test_c2_params_match_reference — инвариант v4: общие ключи Params() = AirCase.p; Params().alpha = AirCase.p.alpha = α_N = конфиг = WindProfile.alpha_n(); старых ключей нет; z_sat/max_profile = числа rules.py на 4 контрольных входах; 8 строк таблицы Тёрнера; WindModel и случай решателя на 12:00, 3 м/с дают одно α и предел. Контракты 26/26.
  • test_air_place — α и max_profile domain_case 400 и 200 м, 0/3/6 м/с = профили эталона из фикстур picard/.
  • test_weather_day::test_wind_profile_follows_hour — WindModel в атмосфере берёт α/предел из WindProfile по часу погоды; 20:00 круче полудня.
  • test_air_picard_gpu, test_air_window_gpu — профиль ветра случая из фикстуры (profiles), TestAirPicard.set_profile.
  • Фикстуры пересчитаны теми же генераторами (tools/research/b2/regen.sh), в JSON — params (lam, lam_frac, pr_t, k_relax) и profiles по ветру (alpha, max_profile, класс, высота солнца). Итерации эталона (f32), до → после:
фикстураслучайдопосле
picard 400 мштиль с нагревом / без161 / 11201 / 11
3 м/с с нагревом / без131 / 151191 / 231
6 м/с с нагревом / без131 / 131131 / 121
picard 200 мштиль с нагревом / без251 / 111101 / 11
3 м/с с нагревом / без171 / 191201 / 221
6 м/с с нагревом / без181 / 181191 / 181
window 100 м3 м/с с нагревом / без41 / 4141 / 41
6 м/с с нагревом / без51 / 5151 / 41
window 50 м3 м/с с нагревом / без61 / 5151 / 51
refflat_wind / agnesi / heated_slope / saddle10 / 710 / 60 / 32030 / 780 / 60 / 470

field/, thermals/, blocks/ не пересчитывались: это входы проверок чтения поля и блоков, от Params не зависят.

Прогоны тестов (после, e674c49 + тест хода дня):

  • контракты --filter=test_air_contracts — 26/26;
  • GPU tools/research/b2/bench.sh (= gpu_tests.sh --filter=test_air_ + --disable-vsync --max-fps 60) — 58/58 (до — тоже 58/58);
  • headless все папки (headless.sh, по 8 процессов): core 5, audio 12, contracts 26, instruments 47, net 28, game 116, world 11, tasks 37, vegetation 21, world_objects 31 — без падений; flight 80 — 1, atmosphere 124 (17 GPU пропущено) — 2, ui 78 — 1, terrain 63 — 1, stability 1 — 1:
    • test_takeoff::test_headwind_launch (atlas, 4 м/с — срыв nose_high) и stability/test_matrix (Аушкуль, training, strong — «взлёт не провален») — падают и на базе (копия dd29bd4), не регрессия;
    • test_net_screen — порт занят (port_busy), test_surface::test_forest_mask_loaded — порог времени под нагрузкой (0,61 с против 0,5) — известные, не регрессии;
    • сдвиг от профиля (на базе зелёные): test_lee_rotor::test_lee_sink_and_rotor_scale_with_wind — 8 м/с: σ ротора 5,42 при u 11,52 (0,47·u, порог 0,5·u); на базе 5,02 при 9,88 (0,51·u). Класс D (α 0,24, предел 2,45 против 0,14 и 1,8) — ветер в подветренных точках на 20–80 м сильнее на 17 %, σ аналитического ротора выросла на 8 %, отношение упало. test_wave::test_synthetic_ridge_lens_matches_lift — подъём в центре линзы 3,96 при пороге 4,00 (половина max_ms): ветер на опорной высоте волны другой. Пороги — эвристик подветренной зоны и волн (до поля), подобраны под прежний профиль. Решение К2: у порогов нет источника (качественные проверки) — порог по факту с запасом: ротор ≥ 0,45·u, линза ≥ 0,45·max_ms, с комментарием «профиль притока откалиброван (Б2), эвристика не подгоняется»; коэффициенты эвристик не менялись. Обе — зелёные.

После C2 v5: контракты 26/26 (контрольные E/F в тесте), GPU test_air_ — 58/58, headless test_lee_rotor, test_wave, test_air_place, test_weather_day, test_atmosphere, test_determinism, test_air_model_atmo, test_wind_field — зелёные.

ТКЭ Askervein с λ/h 0,0158 (решение К2 п. 2)

Метод AM-09б (askervein_fields.py → turb_askervein.gd → fit_s3.py), поля 12,5 / 25 / 50 м с профилем притока Б1 (α 0,242, z0 0,05, max_profile по z_sat) и λ/h 0,0158 — tools/research/b2/tke_recheck.py, out/tke/ (≈ 1,3 мин GPU).

AM-09б (λ/h 0,25, α 0,17)Б2 (λ/h 0,0158, α 0,242)
χ² ТКЭ/ТКЭ(RS), пороги AM-08, σ данных ⊕ σ сетки (13 точек)33,0254,8
по группам: наветренная / вершина / подветренная20,0 / 10,3 / 2,8183,0 / 41,0 / 30,7
χ² только с σ данных42973889
медиана модель/данные2,683,12

Рост χ² — в основном от σ сетки: с λ = 40 м поля 12,5 и 25 м ближе друг к другу, |Δ| меньше (ASW10: ± 0,08), и прежнее завышение ТКЭ моделью (модель/данные ≈ 2–3 на наветренной стороне и вершине, и раньше) перестало прятаться в σ сетки. С одной σ данных χ² даже чуть меньше (4297 → 3889). Завышение ТКЭ над разогнанным склоном — прежняя граница модели (нет быстрого искажения, docs/guide/air-model.md → «Болтанка над вершиной и на наветренном склоне завышена»); общий источник λ у масштаба 3 оставлен (решение К2).

7. Вопросы координатору — решены (К2, feature/air-model 3cd6a0c)

  1. E/F — вариант (а), C2 v5 (§ 4). 2. λ масштаба 3 — общий источник; ТКЭ перепроверена (§ 6). 3. Пороги test_lee_rotor, test_wave — по факту с запасом (источника нет). 4–5. Кромка «ниже 7000 футов» и ночь по солнцу < 8° — приняты, строки в docs/guide/air-model.md → «Границы модели». 6. pilot_check.py — подпись исправлена (am09 — явное 0,25).

GPU test_air_ после v5 — 58/58 (out/bench/b2v5_gpu_tests.log); итерации дневных случаев те же (загрузка с окнами [230, 190], окна [40, 40], [50, 50]) — bench не повторялся.