Files
CFDManager/docs/theory/2d_solver/bench/gen_scenarios.py
T
NotBigGhostandClaude Opus 5.5 86e9c7139d Пристеночная функция Spalding, многоуровневый домен, группы J и K
Решатель:
- --wall-function spalding поверх bouzidi/grad/hrr: моментные схемы берут
  модельную скорость узла и u_τ²/ν в тензор давлений, Bouzidi — скольжение
  стенки, согласованное по напряжению (в вязком подслое тождественно ноль);
- --levels: цепочка вложенных уровней с рекурсивным шагом на CPU и GPU,
  одиночный патч — её частный случай, прежние постановки побитово те же;
- --sponge-side, β по узлам; в сводке y⁺, cl_mean, rho_probe_rms.
- Исправлено: на GPU при --refine >= 3 сила лишних подшагов терялась за
  краем буфера, Cd занижался в r/2 раза.

Кампания: группы J (65, схемы стенки с функцией) и K (25, внешний домен),
205 прогонов на 5 долей по ≈27 ч; bench/compare.py сводит J и K против
эталонов; parity.py сверяет Spalding и три уровня. Образ 1.4.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:25:43 +03:00

777 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# Генератор списка прогонов кампании -> scenarios.json
#
# Список порождается кодом, а не правится руками: серии по Re, по разрешению и по углу атаки
# получаются циклами, а стоимость каждого прогона считается тут же и суммируется. Запуск:
# python gen_scenarios.py # перезаписать scenarios.json и напечатать смету
# python gen_scenarios.py --scale 2 # растянуть все длительности вдвое
# python gen_scenarios.py --shards 5 # на сколько равных по времени долей делить кампанию
#
# Стоимость меряется в ОБНОВЛЕНИЯХ УЗЛОВ (nodes_per_step * steps) — единственная переносимая
# между машинами мера. Часы драйвер получает из неё, поделив на фактические MLUPS, которые
# замеряет на месте (--calibrate).
import argparse
import json
import math
import os
RUNS = []
U_LAT = 0.05 # решёточная скорость по умолчанию
DX = 0.1 # размер клетки, м (умолчание решателя)
U_PHYS = 30.0 # скорость потока, м/с (умолчание решателя)
GIF_MAX_W = 1280 # предельная ширина кадра после прореживания
GIF_MIN_W = 480 # ниже не опускаемся: нечитаемая гифка бесполезнее большой
GIF_PX_BUDGET = 1.5e9 # кадры×пиксели на гифку; при 0.103 байта на пиксель это ~150 МБ
# Доли кампании: прогоны раскладываются по N контейнерам так, чтобы те считали примерно
# одинаковое ВРЕМЯ, а не одинаковое число узлов. Время на узел зависит от размера сетки:
# на мелкой накладные расходы на отправку в очередь дороже самого счёта. Кривая — замер
# dzn в контейнере (Intel Iris Xe, bench/README.md, «Чего трансляция стоит по скорости»),
# потому что доли рассчитаны на чужие Windows/WSL-машины, где путь к карте — именно dzn.
# Важна только ФОРМА кривой: абсолют нормируется к ASSUMED_GPU_MLUPS.
ASSUMED_GPU_MLUPS = 1200
ASSUMED_CPU_MLUPS = 22
DZN_MLUPS = [(61_440, 44.5), (460_800, 86.0), (1_843_200, 98.7)]
SPLIT_NODES = 3_730_000 # выше — раздельные привязки популяций, у них своя скорость
SPLIT_MLUPS = 67.4 # замер на 2048×2048
def conv_steps(d_cells, n_conv, u_lat=U_LAT):
"""Сколько шагов нужно на n_conv конвективных времён D/U."""
return int(round(n_conv * d_cells / u_lat))
def add(rid, group, title, expect, args, nodes, steps, backend="gpu",
gif=None, nx=None, extra_out=()):
"""Записать прогон. `nodes` — обновлений узлов за шаг, `steps` — сколько шагов."""
a = list(args) + ["--steps", str(steps), "--backend", backend]
# ряды прореживаем так, чтобы их осталось порядка 200 тысяч записей
if steps > 400_000:
a += ["--series-every", str(max(1, steps // 200_000))]
if "case_csv" in extra_out:
a += ["--case-csv", f"{rid}_case.csv"]
if gif:
# Гифка идёт на всю длительность прогона в реальном времени, 10 кадр/с — число кадров
# задано физикой прогона и не обсуждается. Единственный рычаг — пиксели в кадре, и он
# нужен: замерено 0.103 байта на пиксель после LZW, так что прогон на 16 тысяч кадров
# при 960×576 весит 900 МБ, а такой файл уже нечем открыть. Поэтому кроме ограничения
# по ширине действует бюджет ПРОИЗВЕДЕНИЯ кадры×пиксели, но ширина не опускается ниже
# GIF_MIN_W: нечитаемая гифка бесполезнее большой.
ny = max(1, int(nodes) // max(1, nx or 1))
u_lat = float(a[a.index("--u-lat") + 1]) if "--u-lat" in a else U_LAT
frames = max(1, int(steps * (u_lat * DX / U_PHYS) * 10))
down = max(1, math.ceil((nx or 0) / GIF_MAX_W))
while nx and frames * (nx // down) * (ny // down) > GIF_PX_BUDGET \
and nx // (down + 1) >= GIF_MIN_W:
down += 1
a += ["--gif", gif, "--gif-field", "vorticity", "--gif-every", "auto",
"--gif-fps", "10", "--gif-speed", "1",
"--gif-downsample", str(down), "--gif-scale", "1"]
RUNS.append({
"id": rid, "group": group, "title": title, "expect": expect,
"backend": backend, "args": a,
"nodes_per_step": int(nodes), "steps": int(steps),
"cost": int(nodes) * int(steps),
"outputs": list(extra_out),
})
# ══════════════════════════════════════════════════════════════════════════════
# A. Эталоны первоисточников: периодические течения, тела и ГУ нет вовсе
# ══════════════════════════════════════════════════════════════════════════════
def group_a(scale):
NU = 0.0192
for n in (64, 128, 256, 512):
# диффузионное измельчение: nu фиксирована, u0 ~ 1/N, поэтому Re сохраняется, а
# число Маха падает вместе с сеткой — только так виден второй порядок целиком
u0 = 0.03 * 64 / n
tc = int(round(math.log(2.0) / (NU * (2 * math.pi / n) ** 2 * 17)))
add(f"A{len(RUNS)+1:02d}_tg_diff_n{n}", "A",
f"Тейлор–Грин, диффузионное измельчение, N={n} (CPU/f64)",
"порядок сходимости 2 по ряду N=64..512; ошибка на N=512 ниже 2e-4",
["--case", "taylor-green", "--nx", str(n), "--ny", str(n), "--refine", "1",
"--u-lat", f"{u0:.6f}", "--re", "100", "--case-every", str(max(1, tc // 20))],
n * n, tc, backend="cpu", extra_out=("case_csv",))
for n in (128, 256):
# при ФИКСИРОВАННОМ u0 ошибка упирается в полку O(Ma^2) и от сетки не зависит
tc = int(round(math.log(2.0) / (NU * (2 * math.pi / n) ** 2 * 17)))
add(f"A{len(RUNS)+1:02d}_tg_fixed_n{n}", "A",
f"Тейлор–Грин при фиксированном u₀, N={n} — демонстрация полки O(Ma²)",
"ошибка перестаёт падать с ростом N: это свойство слабо-сжимаемого метода",
["--case", "taylor-green", "--nx", str(n), "--ny", str(n), "--refine", "1",
"--u-lat", "0.03", "--re", f"{0.03 * n / NU:.1f}",
"--case-every", str(max(1, tc // 20))],
n * n, tc, backend="cpu", extra_out=("case_csv",))
for n in (512,):
for op, km, tag in (("kbc", "n1", "kbc_n1"), ("kbc", "n2", "kbc_n2"), ("bgk", "n1", "lbgk")):
steps = int(4 * n / 0.04 * scale)
add(f"A{len(RUNS)+1:02d}_shear_n{n}_{tag}", "A",
f"Сдвиговый слой Re=3·10⁴, N={n}, {tag}",
"KBC доживает до конца, LBGK обязан развалиться (разд. VII статьи)",
["--case", "shear-layer", "--nx", str(n), "--ny", str(n), "--refine", "1",
"--u-lat", "0.04", "--re", "30000", "--collision", op, "--kbc-model", km,
"--case-every", str(max(1, steps // 200))],
n * n, steps, gif=f"shear_{tag}.gif", nx=n, extra_out=("case_csv",))
for n, tag, ops in ((2048, "n2048", ("kbc", "bgk")), (4096, "n4096", ("kbc",))):
for op in ops:
steps = int(200_000 * scale * (2048 / n))
add(f"A{len(RUNS)+1:02d}_turb_{tag}_{op}", "A",
f"Затухающая турбулентность N={n}, {op}",
"энстрофия и палинстрофия падают монотонно; KBC держится там, где LBGK нет",
["--case", "decaying-turbulence", "--nx", str(n), "--ny", str(n), "--refine", "1",
"--u-lat", "0.02", "--re", f"{0.02 * n / 8.16e-5:.0f}", "--collision", op,
"--case-every", str(max(1, steps // 300))],
n * n, steps, gif=f"turb_{tag}_{op}.gif", nx=n, extra_out=("case_csv",))
# ══════════════════════════════════════════════════════════════════════════════
# B. Цилиндр против литературы
# ══════════════════════════════════════════════════════════════════════════════
def flatten(d):
"""Словарь ключей в плоский argv. Именно словарь, а не список: clap отвергает
повторённый ключ, а сценарии сплошь и рядом переопределяют умолчания сборщика."""
a = []
for k, v in d.items():
a += [k, str(v)]
return a
def cyl_args(d, re, nx, ny, **kw):
a = {"--shape": "cylinder", "--size": d, "--nx": nx, "--ny": ny,
"--body-x": nx // 5, "--re": re, "--refine": 1}
a.update({"--" + k.replace("_", "-"): v for k, v in kw.items()})
return flatten(a)
def group_b(scale):
# стационарные режимы: дорожки нет, Cd — одно число
for re in (20, 40):
d, nx, ny = 48, 960, 576
st = int(conv_steps(d, 60) * scale)
add(f"B{len(RUNS)-len(RUNS)+len([r for r in RUNS if r['group']=='B'])+1:02d}_cyl_re{re}",
"B", f"Цилиндр Re={re}, стационар, D=48",
"Cd выходит на постоянную; сравнение с литературой по стационарному обтеканию",
cyl_args(d, re, nx, ny, pert_amp=0), nx * ny, st,
gif=f"cyl_re{re}.gif", nx=nx)
# дорожка Кармана: длинное осреднение, это главный заход на литературу
for re in (100, 150, 200, 300):
d, nx, ny = 64, 1280, 768
st = int(conv_steps(d, 1500) * scale)
add(f"B{len([r for r in RUNS if r['group']=='B'])+1:02d}_cyl_re{re}", "B",
f"Цилиндр Re={re}, дорожка Кармана, D=64, 1500 конв. времён",
"St≈0.183 и ⟨Cd⟩≈1.33 при Re=150 после поправки на блокировку",
cyl_args(d, re, nx, ny), nx * ny, st, gif=f"cyl_re{re}.gif", nx=nx)
# разрешение тела: сходимость Cd
for d in (16, 32, 64, 128):
nx, ny = 20 * d, 12 * d
st = int(conv_steps(d, 800) * scale)
add(f"B{len([r for r in RUNS if r['group']=='B'])+1:02d}_cyl_d{d}", "B",
f"Цилиндр Re=150, разрешение D={d}",
"Cd сходится по D; экстраполяция даёт сеточно-независимое значение",
cyl_args(d, 150, nx, ny), nx * ny, st, gif=f"cyl_d{d}.gif", nx=nx)
# блокировка канала: экстраполяция к бесконечной среде — прямой заход на нерешённые 12%
for k in (6, 8, 12, 16, 24, 32):
d, nx, ny = 48, 960, k * 48
st = int(conv_steps(d, 1000) * scale)
add(f"B{len([r for r in RUNS if r['group']=='B'])+1:02d}_cyl_block{k}", "B",
f"Цилиндр Re=150, блокировка Ny/D={k}",
"экстраполяция Cd и St к нулевой блокировке; ожидание — сходимость к 1.33 и 0.183",
cyl_args(d, 150, nx, ny), nx * ny, st, gif=None, nx=nx)
# ══════════════════════════════════════════════════════════════════════════════
# C. Модели стенки и субсеточность
# ══════════════════════════════════════════════════════════════════════════════
def group_c(scale):
for wall in ("hrr", "grad", "bouzidi", "staircase"):
for re in (150, 2000):
d, nx, ny = 32, 640, 384
st = int(conv_steps(d, 400 if re > 20 else 60) * scale)
add(f"C{len([r for r in RUNS if r['group']=='C'])+1:02d}_wall_{wall}_re{re}", "C",
f"Модель стенки {wall}, Re={re}",
"все субсеточные модели обязаны сойтись к одному пределу; staircase — база",
cyl_args(d, re, nx, ny, wall=wall), nx * ny, st,
gif=f"wall_{wall}_re{re}.gif" if re == 150 else None, nx=nx)
# Чувствительность к положению тела ВНУТРИ клетки — прямая проверка субсеточности, и
# единственное измерение, которое вообще разделяет модели стенки. Двух положений для этого
# мало: по двум точкам не отличить систематический разброс от совпадения, поэтому берём
# четверти, и все четыре модели. Прогоны короткие (стационар при Re=20), вся серия — минуты.
for wall in ("hrr", "grad", "bouzidi", "staircase"):
for off in (0.0, 0.25, 0.5):
d, nx, ny = 16, 320, 192
st = int(conv_steps(d, 60) * scale)
add(f"C{len([r for r in RUNS if r['group']=='C'])+1:02d}_sub_{wall}_{int(off*100):02d}",
"C", f"Субклеточный сдвиг тела {off} клетки, стенка {wall}",
"разброс Cd по сдвигам: чем субсеточнее модель, тем он меньше",
cyl_args(d, 20, nx, ny, body_y=96 + off, wall=wall, pert_amp=0),
nx * ny, st)
# ══════════════════════════════════════════════════════════════════════════════
# D. Профили крыла
# ══════════════════════════════════════════════════════════════════════════════
def foil_args(chord, alpha, re, nx, ny, naca="0012", **kw):
a = {"--shape": "naca", "--naca": naca, "--size": chord,
"--body-angle": alpha, "--nx": nx, "--ny": ny,
"--body-x": nx // 4, "--re": re, "--refine": 1}
a.update({"--" + k.replace("_", "-"): v for k, v in kw.items()})
return flatten(a)
def group_d(scale):
# поляра: наклон Cl(alpha) и положение сваливания
for al in (0, 4, 8, 12, 16):
c, nx, ny = 128, 2048, 1024
st = int(conv_steps(c, 400) * scale)
add(f"D{len([r for r in RUNS if r['group']=='D'])+1:02d}_naca0012_a{al:02d}", "D",
f"NACA 0012, α={al}°, Re=1000, хорда 128",
"линейный участок Cl(α) и срыв; сравнение с низкорейнольдсовой литературой",
foil_args(c, al, 1000, nx, ny), nx * ny, st,
gif=f"naca0012_a{al:02d}.gif", nx=nx)
for re in (500, 2000, 10000):
c, nx, ny = 128, 2048, 1024
st = int(conv_steps(c, 400) * scale)
add(f"D{len([r for r in RUNS if r['group']=='D'])+1:02d}_naca0012_re{re}", "D",
f"NACA 0012, α=8°, Re={re}",
"перестройка следа с ростом Re; проверка устойчивости на профиле",
foil_args(c, 8, re, nx, ny), nx * ny, st,
gif=f"naca0012_re{re}.gif", nx=nx)
for al in (0, 6, 12):
c, nx, ny = 128, 2048, 1024
st = int(conv_steps(c, 400) * scale)
add(f"D{len([r for r in RUNS if r['group']=='D'])+1:02d}_naca4412_a{al:02d}", "D",
f"NACA 4412 (с изгибом), α={al}°, Re=1000",
"ненулевой Cl при α=0 — прямая проверка средней линии профиля",
foil_args(c, al, 1000, nx, ny, naca="4412"), nx * ny, st,
gif=f"naca4412_a{al:02d}.gif", nx=nx)
for c in (48, 96, 192):
nx, ny = 16 * c, 8 * c
st = int(conv_steps(c, 300) * scale)
add(f"D{len([r for r in RUNS if r['group']=='D'])+1:02d}_naca_chord{c}", "D",
f"NACA 0012, α=8°, сходимость по хорде {c}",
"Cl и Cd сходятся по разрешению хорды",
foil_args(c, 8, 1000, nx, ny), nx * ny, st, nx=nx)
# ══════════════════════════════════════════════════════════════════════════════
# E. Сложная и множественная геометрия
# ══════════════════════════════════════════════════════════════════════════════
def group_e(scale):
# тандем: литература даёт переключение режимов около L/D 3.5–4
for ld in (1.5, 3.0, 5.0):
d, nx, ny = 48, 1440, 720
gap = int(ld * d)
bodies = f"cylinder:d={d},x=360,y=360; cylinder:d={d},x={360 + gap},y=360"
st = int(conv_steps(d, 800) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_tandem_ld{int(ld*10)}", "E",
f"Тандем цилиндров, зазор L/D={ld}",
"при малом зазоре у заднего тела ОТРИЦАТЕЛЬНОЕ сопротивление; переход около 3.5–4",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "150", "--refine", "1"],
nx * ny, st, gif=f"tandem_ld{int(ld*10)}.gif", nx=nx)
# решётка как пористая среда
for step_d, tag in ((3, "sparse"), (2, "dense")):
d, nx, ny = 24, 1440, 720
bodies = "; ".join(
f"cylinder:d={d},x={360 + i * step_d * d},y={360 + (j - 1.5) * step_d * d}"
for i in range(4) for j in range(4))
st = int(conv_steps(d, 600) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_array_{tag}", "E",
f"Решётка 4×4 цилиндров, шаг {step_d}D — пористая среда",
"суммарное сопротивление и структура течения сквозь набор тел",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "150", "--refine", "1"],
nx * ny, st, gif=f"array_{tag}.gif", nx=nx)
# перфорированная пластина: набор коротких пластин со щелями
for open_frac, tag in ((0.2, "open20"), (0.5, "open50")):
nx, ny = 1200, 600
seg, gap = 40, int(40 * open_frac / (1 - open_frac))
bodies = "; ".join(
f"plate:d={seg},t=0.12,a=90,x=300,y={60 + k * (seg + gap)}"
for k in range(max(1, (ny - 120) // (seg + gap))))
st = int(conv_steps(seg, 500) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_perf_{tag}", "E",
f"Перфорированная пластина, скважность {open_frac}",
"струи в щелях и общее сопротивление; проверка множества тонких тел",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "500", "--refine", "1"],
nx * ny, st, gif=f"perf_{tag}.gif", nx=nx)
# многоэлементный профиль: основной + предкрылок
nx, ny = 2048, 1024
bodies = ("naca:naca=4412,d=200,a=6,x=560,y=512; "
"naca:naca=2412,d=70,a=18,x=430,y=470")
st = int(conv_steps(200, 300) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_multielem", "E",
"Многоэлементный профиль: основной 4412 плюс предкрылок",
"щель между элементами и раздельные Cl/Cd по телам",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "2000", "--refine", "1"],
nx * ny, st, gif="multielem.gif", nx=nx)
# сверхтонкая пластина: предел субсеточности, тело тоньше клетки
for t, tag in ((0.02, "t05"), (0.01, "t025")):
nx, ny = 1200, 600
st = int(conv_steps(50, 500) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_thin_{tag}", "E",
f"Сверхтонкая пластина, толщина {t*50:.2f} клетки",
"предел субсеточной границы: тело тоньше клетки; ждём откаты на простой отскок",
["--shape", "plate", "--size", "50", "--thickness", str(t), "--body-angle", "20",
"--nx", str(nx), "--ny", str(ny), "--re", "500", "--refine", "1"],
nx * ny, st, gif=f"thin_{tag}.gif", nx=nx)
# клин и зазубренная кромка — субклеточная деталь на многоугольнике
nx, ny = 1200, 600
st = int(conv_steps(60, 500) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_wedge", "E",
"Клин остриём против потока", "острая кромка: особая точка геометрии",
["--shape", "polygon", "--poly=-30,-18;36,0;-30,18", "--size", "60",
"--nx", str(nx), "--ny", str(ny), "--re", "500", "--refine", "1"],
nx * ny, st, gif="wedge.gif", nx=nx)
saw = ";".join(f"{-30 + 12 * k},{(-1) ** k * 6}" for k in range(6)) + ";30,-18;-30,-18"
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_serrated", "E",
"Зазубренная задняя кромка", "субклеточные зубцы: как их видит граница",
["--shape", "polygon", f"--poly={saw}", "--size", "60",
"--nx", str(nx), "--ny", str(ny), "--re", "500", "--refine", "1"],
nx * ny, st, gif="serrated.gif", nx=nx)
# ══════════════════════════════════════════════════════════════════════════════
# F. Поле влияния и границы домена
# ══════════════════════════════════════════════════════════════════════════════
def group_f(scale):
d = 48
for up in (2, 6, 16):
nx, ny = int((up + 20) * d), 12 * d
st = int(conv_steps(d, 600) * scale)
add(f"F{len([r for r in RUNS if r['group']=='F'])+1:02d}_inlet{up}d", "F",
f"Отступ до входа {up}D",
"с какого отступа результат перестаёт зависеть от положения входа",
cyl_args(d, 150, nx, ny, body_x=up * d), nx * ny, st)
for down in (5, 15, 40):
nx, ny = int((6 + down) * d), 12 * d
st = int(conv_steps(d, 600) * scale)
add(f"F{len([r for r in RUNS if r['group']=='F'])+1:02d}_outlet{down}d", "F",
f"Отступ до выхода {down}D",
"с какой длины следа выход перестаёт влиять на силы",
cyl_args(d, 150, nx, ny, body_x=6 * d), nx * ny, st)
for r in (1, 2, 3):
d, nx, ny = 32, 640, 384
st = int(conv_steps(d, 600) * scale)
nodes = nx * ny
if r > 1:
ax, bx = int(nx // 5 - 1.5 * d), int(nx // 5 + 6.25 * d)
ay, by = int(ny / 2 - 2 * d), int(ny / 2 + 2 * d)
nodes += r * (r * (bx - ax) + 1) * (r * (by - ay) + 1)
add(f"F{len([r_ for r_ in RUNS if r_['group']=='F'])+1:02d}_refine{r}", "F",
f"Вложенный патч ×{r}",
"вносит ли связка уровней систематику: Cd и St обязаны совпасть",
cyl_args(d, 150, nx, ny, refine=r), nodes, st)
# ══════════════════════════════════════════════════════════════════════════════
# G. Старт, акустика, время жизни
# ══════════════════════════════════════════════════════════════════════════════
def group_g(scale):
d, nx, ny = 48, 960, 576
for re in (150, 2000, 20000):
for init in ("uniform", "rest"):
st = int(conv_steps(d, 300) * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_xt_re{re}_{init}", "G",
f"x–t диаграмма, Re={re}, старт {init}",
"откуда идёт стартовое возмущение и раскачивается ли продольная мода",
cyl_args(d, re, nx, ny, init=init, sponge_len=0,
xt=f"xt_re{re}_{init}.csv", xt_every=200),
nx * ny, st, gif=f"xt_re{re}_{init}.gif", nx=nx, extra_out=("xt",))
for sp in (0, 40):
st = int(conv_steps(d, 400) * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_sponge{sp}", "G",
f"Губка выхода {sp} столбцов при Re=5000",
"губка — единственное, что реально ест продольную моду",
cyl_args(d, 5000, nx, ny, sponge_len=sp), nx * ny, st)
for sp in (0, 40):
st = int(conv_steps(d, 400) * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_spongein{sp}", "G",
f"Губка входа {sp} столбцов при Re=5000",
"помогает ли гасить волну до отражения от входа",
cyl_args(d, 5000, nx, ny, sponge_in=sp), nx * ny, st)
for re in (10000, 50000, 200000):
st = int(conv_steps(d, 200) * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_limit_re{re}", "G",
f"Предел устойчивости, Re={re}",
"где именно гибнет счёт; часть этих прогонов обязана развалиться",
cyl_args(d, re, nx, ny), nx * ny, st)
# тест на время жизни: один очень длинный прогон
st = int(5_000_000 * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_lifetime", "G",
"Тест на время жизни: 5·10⁶ шагов",
"не уплывает ли ⟨ρ⟩ и не деградирует ли решение на очень длинной дистанции",
cyl_args(d, 150, nx, ny), nx * ny, st, gif="lifetime.gif", nx=nx)
# ══════════════════════════════════════════════════════════════════════════════
# H. Инварианты и точность
# ══════════════════════════════════════════════════════════════════════════════
def group_h(scale):
d, nx, ny = 32, 640, 384
st = int(conv_steps(d, 400) * scale)
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_sym_cyl", "H",
"Симметрия: цилиндр при α=0 без возмущения",
"Cl и Cm обязаны остаться ≈0 — это внутреннее свойство схемы, не литература",
cyl_args(d, 40, nx, ny, pert_amp=0), nx * ny, st)
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_sym_foil", "H",
"Симметрия: NACA 0012 при α=0 без возмущения",
"Cl ≈ 0 у симметричного профиля под нулевым углом",
foil_args(64, 0, 500, 1024, 512, pert_amp=0), 1024 * 512,
int(conv_steps(64, 300) * scale))
for al, tag in ((8, "plus"), (-8, "minus")):
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_mirror_{tag}", "H",
f"Зеркальность: NACA 0012 при α={al}°",
"ряды обязаны зеркалиться: Cl меняет знак, Cd совпадает",
foil_args(64, al, 500, 1024, 512), 1024 * 512,
int(conv_steps(64, 300) * scale))
for ul in (0.02, 0.05, 0.1):
st2 = int(conv_steps(d, 400, ul) * scale)
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_mach{int(ul*100):02d}", "H",
f"Зависимость от числа Маха, u_lat={ul}",
"результат обязан сходиться при Ma→0; расхождение и есть сжимаемостная ошибка",
cyl_args(d, 150, nx, ny, u_lat=ul), nx * ny, st2)
# прямой замер расхождения f32 и f64 от длины прогона
for bk in ("cpu", "gpu"):
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_prec_{bk}", "H",
f"Точность: одинаковая постановка на {bk}",
"расхождение f32 против f64 в зависимости от длины прогона",
cyl_args(d, 150, nx, ny), nx * ny, int(conv_steps(d, 800) * scale), backend=bk)
# ══════════════════════════════════════════════════════════════════════════════
# I. Сверхмелкие сетки по всем формам
# ══════════════════════════════════════════════════════════════════════════════
def group_i(scale):
shapes = [
("cylinder", ["--shape", "cylinder"]),
("square", ["--shape", "square"]),
("diamond", ["--shape", "diamond"]),
("ellipse", ["--shape", "ellipse", "--thickness", "0.35"]),
("naca", ["--shape", "naca", "--naca", "0012", "--body-angle", "8"]),
("triangle", ["--shape", "triangle"]),
("plate", ["--shape", "plate", "--thickness", "0.08", "--body-angle", "25"]),
("wedge", ["--shape", "polygon", "--poly=-128,-72;154,0;-128,72"]),
]
d, nx, ny = 256, 4096, 2048
st = int(conv_steps(d, 300) * scale)
for tag, extra in shapes:
add(f"I{len([r for r in RUNS if r['group']=='I'])+1:02d}_fine_{tag}", "I",
f"Сверхмелкая сетка 4096×2048, {tag}, D=256",
"держится ли субсеточная граница на предельном разрешении для этой формы",
extra + ["--size", str(d), "--nx", str(nx), "--ny", str(ny),
"--body-x", "820", "--re", "1000", "--refine", "1"],
nx * ny, st, gif=f"fine_{tag}.gif", nx=nx)
bodies = ("cylinder:d=200,x=800,y=1024; cylinder:d=200,x=1400,y=1024; "
"naca:naca=4412,d=300,a=10,x=2200,y=1024")
add(f"I{len([r for r in RUNS if r['group']=='I'])+1:02d}_fine_multi", "I",
"Сверхмелкая сетка, композиция из трёх тел",
"взаимодействие следов на предельном разрешении",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "1000", "--refine", "1"],
nx * ny, st, gif="fine_multi.gif", nx=nx)
# ══════════════════════════════════════════════════════════════════════════════
# J. Схемы стенки с пристеночной функцией
# ══════════════════════════════════════════════════════════════════════════════
# Главный практический вопрос: что точнее — Bouzidi (лучшая геометрия, без пристеночной
# функции) или моментные схемы с пристеночной функцией Spalding, и как это меняется с Re и
# разрешением. Bouzidi + Spalding добавлен, чтобы отделить вклад функции от вклада схемы.
# Сетка однородная (--refine 1): разрешение задаётся прямо числом клеток на тело, без
# вклада связки уровней. Эталон каждой серии — Bouzidi без функции при удвоенном разрешении.
J_SCHEMES = [
("bouzidi", "none"),
("bouzidi", "spalding"),
("grad", "spalding"),
("hrr", "spalding"),
]
def jid():
return f"J{len([r for r in RUNS if r['group'] == 'J']) + 1:02d}"
def wf_tag(wall, wf):
return wall if wf == "none" else f"{wall}_wf"
# Разрешения по Re. Предел устойчивости здесь задаёт не модель стенки, а τ − ½ = 3·u·D/Re на
# грубейшем уровне: замерено на цилиндре 20D×12D (обычный Bouzidi, длинные прогоны), что при
# τ − ½ = 2.4·10⁻⁴ и 4.8·10⁻⁴ (Re = 10⁴, D = 16 и 32) счёт разваливается — на 13 и 27 тысячах
# шагов, у HRR со Spalding так же, — а при 6·10⁻⁴ и выше (Re = 2000 при D = 8, Re = 5000 при
# D = 32, Re = 10⁴ при D = 64) держится. Поэтому большой Re цилиндра — 5000, а разрешения
# выбраны так, чтобы τ − ½ ≥ 6·10⁻⁴. Прогоны ниже предела схемы бы не сравнивали, а лишь
# повторяли предел устойчивости, который меряет группа G.
J_CYL_D = {150: (8, 16, 32, 64), 2000: (8, 16, 32, 64), 5000: (32, 64)}
J_FOIL_C = {2000: (24, 48, 96), 10000: (48, 96)}
def group_j(scale):
# цилиндр: домен 20D × 12D, как сходимость по D в группе B
for re in (150, 2000, 5000):
for d in J_CYL_D[re]:
nx, ny = 20 * d, 12 * d
st = int(conv_steps(d, 300) * scale)
for wall, wf in J_SCHEMES:
add(f"{jid()}_cyl_re{re}_d{d}_{wf_tag(wall, wf)}", "J",
f"Цилиндр Re={re}, D={d}, стенка {wall}, пристеночная функция {wf}",
"отклонение St, ⟨Cd⟩, rms Cl от эталона серии (Bouzidi при D=128); y⁺ в сводке",
cyl_args(d, re, nx, ny, wall=wall, wall_function=wf), nx * ny, st)
d = 128
nx, ny = 20 * d, 12 * d
add(f"{jid()}_cyl_re{re}_ref_d{d}", "J",
f"Цилиндр Re={re}, ЭТАЛОН серии: Bouzidi без функции, D={d}",
"сеточно сошедшееся значение, к которому меряются схемы при D=8…64",
cyl_args(d, re, nx, ny, wall="bouzidi", wall_function="none"), nx * ny,
int(conv_steps(d, 300) * scale))
# профиль: NACA 0012 под углом 4°, домен 12c × 6c
for re in (2000, 10000):
for c in J_FOIL_C[re]:
nx, ny = 12 * c, 6 * c
st = int(conv_steps(c, 200) * scale)
for wall, wf in J_SCHEMES:
add(f"{jid()}_naca_re{re}_c{c}_{wf_tag(wall, wf)}", "J",
f"NACA 0012, α=4°, Re={re}, хорда {c}, стенка {wall}, функция {wf}",
"отклонение Cl, Cd от эталона серии (Bouzidi при хорде 192); y⁺ в сводке",
foil_args(c, 4, re, nx, ny, wall=wall, wall_function=wf), nx * ny, st)
c = 192
nx, ny = 12 * c, 6 * c
add(f"{jid()}_naca_re{re}_ref_c{c}", "J",
f"NACA 0012, α=4°, Re={re}, ЭТАЛОН серии: Bouzidi без функции, хорда {c}",
"сеточно сошедшееся значение для серии по хорде",
foil_args(c, 4, re, nx, ny, wall="bouzidi", wall_function="none"), nx * ny,
int(conv_steps(c, 200) * scale))
# ══════════════════════════════════════════════════════════════════════════════
# K. Внешний домен: грубые буферы, переходы разрешения, губки
# ══════════════════════════════════════════════════════════════════════════════
# Тело одно и то же во всех прогонах — цилиндр с D = 32 клетки на САМОМ ТОНКОМ уровне.
# Меняется только то, что вокруг: размеры домена до тела, после и по бокам; как сетка
# грубеет к краю (одним скачком или ступенями ×2); сколько тонкой сетки остаётся в следе;
# есть ли губки на боковых кромках. Эталон — однородная тонкая сетка на самом большом
# домене. Метрики: St, ⟨Cd⟩, rms Cl против эталона, пульсация плотности в зонде
# (rho_probe_rms — паразитная акустика) и стоимость.
K_D = 32 # клеток на тело на самом тонком уровне
K_CONV = 300 # конвективных времён D/U до умножения на scale
def _rround(x):
"""Округление как у Rust f64::round — половина от нуля, а не к чётному."""
return math.floor(x + 0.5) if x >= 0 else -math.floor(-x + 0.5)
def level_chain(levels, cx, cy, d, nx, ny):
"""Тот же расчёт границ уровней, что `parse_levels` в main.rs. Возвращает обновлений
узлов за шаг L0 по всем уровням и строку для --levels."""
pnx, pny, scale, ox, oy = nx, ny, 1.0, 0.0, 0.0
total, sub = nx * ny, 1
for r, up, down, side in levels:
def to(x, o):
return _rround((x - o) * scale)
ax = int(max(to(cx - up * d, ox), 2.0))
bx = int(max(min(to(cx + down * d, ox), pnx - 2.0), 0.0))
ay = int(max(to(cy - side * d, oy), 1.0))
by = int(max(min(to(cy + side * d, oy), pny - 2.0), 0.0))
assert ax + 1 < bx and ay + 1 < by, (levels, ax, bx, ay, by)
ox += ax / scale
oy += ay / scale
scale *= r
pnx, pny = r * (bx - ax) + 1, r * (by - ay) + 1
sub *= r
total += sub * pnx * pny
spec = "; ".join(f"{r}:{up:g},{down:g},{side:g}" for r, up, down, side in levels)
return total, spec
def kid():
return f"K{len([r for r in RUNS if r['group'] == 'K']) + 1:02d}"
def k_run(tag, title, expect, re, scale, up, down, side, levels, sponge_side_d=0.0):
"""Прогон группы K. `up`, `down`, `side` — размеры домена L0 в диаметрах; `levels` —
цепочка [(r, up, down, side), …] от внешнего уровня к внутреннему, отступы в D.
Пустая цепочка — однородная тонкая сетка."""
s = 1
for lv in levels:
s *= lv[0]
d0 = K_D / s # тело в клетках L0
nx = int(round((up + down) * d0))
ny = int(round(2 * side * d0))
cx, cy = up * d0, ny / 2
args = {"--shape": "cylinder", "--size": f"{d0:g}", "--nx": nx, "--ny": ny,
"--body-x": f"{cx:g}", "--re": re, "--wall": "bouzidi"}
if levels:
nodes, spec = level_chain(levels, cx, cy, d0, nx, ny)
args["--levels"] = spec
else:
nodes = nx * ny
args["--refine"] = 1
if sponge_side_d > 0:
args["--sponge-side"] = int(round(sponge_side_d * d0))
# шагов L0 на одно и то же физическое время: тело на L0 в s раз мельче
st = int(conv_steps(d0, K_CONV) * scale)
add(f"{kid()}_re{re}_{tag}", "K", title, expect, flatten(args), nodes, st)
# Базовая конфигурация: L0 в 4 раза грубее тела, к телу две ступени ×2. Промежуточный
# уровень — 4D вверх, 15D вниз, 4D в стороны; тонкий — 1.5D вверх, 10D следа, 2D в стороны.
K_MID = (2, 4, 15, 4)
def k_fine(wake=10):
return (2, 1.5, wake, 2)
def group_k(scale):
for re in (150, 2000):
k_run("ref", f"Re={re}, ЭТАЛОН: однородная тонкая сетка 30D+60D, ±30D",
"значения, к которым меряются все конфигурации буферов", re, scale,
30, 60, 30, [])
k_run("base", f"Re={re}, база: домен 20D+40D, ±20D, ступени ×2×2",
"отклонение от эталона при умеренных буферах", re, scale,
20, 40, 20, [K_MID, k_fine()])
# переход: одно и то же суммарное огрубление — скачком или ступенями
k_run("jump4", f"Re={re}, огрубление ×4 одним скачком",
"скачок разрешения против ступеней ×2×2 (база)", re, scale,
20, 40, 20, [(4, 1.5, 10, 2)])
k_run("jump8", f"Re={re}, огрубление ×8 одним скачком",
"скачок ×8 против цепочки ×2×2×2", re, scale,
20, 40, 20, [(8, 1.5, 10, 2)])
k_run("chain8", f"Re={re}, огрубление ×8 цепочкой ×2×2×2",
"плавный переход при сильном огрублении", re, scale,
20, 40, 20, [(2, 8, 25, 8), K_MID, k_fine()])
k_run("sponge5", f"Re={re}, боковые губки 5D",
"гасят ли губки отражения от боковых стенок", re, scale,
20, 40, 20, [K_MID, k_fine()], sponge_side_d=5)
if re != 150:
continue
# остальные серии — только Re=150: там есть литература и чистая дорожка
for side in (5, 10, 30):
k_run(f"side{side}", f"Re={re}, боковой буфер ±{side}D",
"с какого размера бока перестают влиять (блокировка + отражения)", re, scale,
20, 40, side, [K_MID, k_fine()])
for up in (5, 10, 30):
k_run(f"up{up}", f"Re={re}, буфер до тела {up}D",
"с какого отступа вход перестаёт влиять", re, scale,
up, 40, 20, [(2, min(4, up - 1), 15, 4), k_fine()])
for down in (15, 30, 60):
k_run(f"down{down}", f"Re={re}, буфер после тела {down}D",
"с какой длины выход перестаёт влиять", re, scale,
20, down, 20, [(2, 4, min(15, down - 2), 4), k_fine(min(10, down - 4))])
for wake in (5, 20):
k_run(f"wake{wake}", f"Re={re}, тонкий след {wake}D за телом",
"сколько тонкой сетки нужно в следе до первого огрубления", re, scale,
20, 40, 20, [(2, 4, max(15, wake + 5), 4), k_fine(wake)])
k_run("midwide", f"Re={re}, широкий промежуточный уровень (8D вверх, 25D вниз, 8D вбок)",
"влияет ли ширина ступени перехода", re, scale,
20, 40, 20, [(2, 8, 25, 8), k_fine()])
k_run("sponge2", f"Re={re}, боковые губки 2D",
"ширина губки: 2D против 5D", re, scale,
20, 40, 20, [K_MID, k_fine()], sponge_side_d=2)
def est_hours(r):
"""Оценка времени прогона в часах для раскладки по долям (см. DZN_MLUPS)."""
if r["backend"] == "cpu":
return r["cost"] / (ASSUMED_CPU_MLUPS * 1e6) / 3600
n = r["nodes_per_step"]
if n > SPLIT_NODES:
m = SPLIT_MLUPS
elif n <= DZN_MLUPS[0][0]:
m = DZN_MLUPS[0][1]
elif n >= DZN_MLUPS[-1][0]:
m = DZN_MLUPS[-1][1]
else:
for (n0, m0), (n1, m1) in zip(DZN_MLUPS, DZN_MLUPS[1:]):
if n <= n1:
t = math.log(n / n0) / math.log(n1 / n0)
m = m0 + t * (m1 - m0)
break
rel = m / DZN_MLUPS[-1][1]
return r["cost"] / (ASSUMED_GPU_MLUPS * 1e6 * rel) / 3600
def assign_shards(runs, n):
"""Разложить прогоны по n долям жадно (LPT): самый долгий — в самую лёгкую долю.
Порядок прогонов внутри доли остаётся порядком групп. Возвращает часы по долям."""
load = [0.0] * n
for r in sorted(runs, key=lambda r: (-est_hours(r), r["id"])):
k = min(range(n), key=lambda i: load[i])
r["shard"] = k + 1
load[k] += est_hours(r)
return load
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--scale", type=float, default=2.0,
help="общий множитель длительности всех прогонов")
ap.add_argument("--shards", type=int, default=5,
help="на сколько равных по времени долей делить кампанию")
ap.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "scenarios.json"))
args = ap.parse_args()
for g in (group_a, group_b, group_c, group_d, group_e, group_f, group_g, group_h, group_i,
group_j, group_k):
g(args.scale)
shard_hours = assign_shards(RUNS, args.shards)
doc = {
"meta": {
"note": "Стоимость в обновлениях узлов — единственная переносимая между машинами "
"мера. Часы драйвер получает, поделив её на фактические MLUPS, которые "
"замеряет на месте (--calibrate).",
"gif_policy": "вся длительность прогона, реальная скорость, 10 кадр/с; кадр "
"прореживается до ширины не больше 1280 пикселей",
"scale": args.scale,
"assumed_gpu_mlups": ASSUMED_GPU_MLUPS,
"assumed_cpu_mlups": ASSUMED_CPU_MLUPS,
"shards": args.shards,
"shard_hours": [round(h, 2) for h in shard_hours],
},
"runs": RUNS,
}
with open(args.out, "w", encoding="utf-8", newline="\n") as f:
json.dump(doc, f, ensure_ascii=False, indent=1)
print(f"прогонов: {len(RUNS)} файл: {args.out}\n")
print(f"{'гр':>3}{'прогонов':>10}{'обновлений узлов':>20}{'часов GPU':>12}{'часов CPU':>12}")
tot_gpu = tot_cpu = 0.0
for g in sorted({r["group"] for r in RUNS}):
rs = [r for r in RUNS if r["group"] == g]
cost = sum(r["cost"] for r in rs)
hg = sum(r["cost"] / 1.2e9 / 3600 for r in rs if r["backend"] == "gpu")
hc = sum(r["cost"] / 2.2e7 / 3600 for r in rs if r["backend"] == "cpu")
tot_gpu += hg
tot_cpu += hc
print(f"{g:>3}{len(rs):>10}{cost:>20.3e}{hg:>12.1f}{hc:>12.1f}")
print(f"{'—':>3}{len(RUNS):>10}{sum(r['cost'] for r in RUNS):>20.3e}"
f"{tot_gpu:>12.1f}{tot_cpu:>12.1f}")
print(f"\nвсего ≈ {tot_gpu + tot_cpu:.1f} ч при 1200 MLUPS на GPU и 22 на CPU")
print("\nдоли (часы с поправкой на размер сетки, см. DZN_MLUPS):")
for k, h in enumerate(shard_hours, 1):
n = sum(1 for r in RUNS if r["shard"] == k)
print(f" доля {k}: {n:>3} прогонов ≈ {h:5.1f} ч")
print(f" разброс: {(max(shard_hours) / min(shard_hours) - 1) * 100:.1f} %")
if __name__ == "__main__":
main()