Files
CFDManager/docs/theory/2d_solver/bench/gen_scenarios.py
T
NotBigGhostandClaude Opus 5 8d17bc8014 Валидационная кампания на 115 прогонов и Docker-образ для сервера
bench/ — список сценариев (порождается gen_scenarios.py, а не правится
руками), драйверы под Linux и Windows, предполётная проверка и описание.
Девять групп: эталоны первоисточников, цилиндр против литературы, модели
стенки и субсеточность, профили крыла, сложная и множественная геометрия,
границы домена, старт и время жизни, внутренние инварианты, сверхмелкие
сетки до 4096×2048. Стоимость планируется в обновлениях узлов —
единственной переносимой между машинами мере; в часы драйвер переводит её
по фактическим MLUPS, которые замеряет на месте (--calibrate). Итого
3.76e14 обновлений, ≈90 часов на 4070 Ti.

Гифки пишутся на всю длительность прогона в реальном времени, 10 кадр/с.
Число кадров этим задано жёстко, поэтому размер регулируется только кадром:
замерено 0.103 байта на пиксель после LZW, отсюда бюджет кадры×пиксели с
нижней границей по ширине. Итог 4.4 ГБ, самый тяжёлый файл 226 МБ.

preflight.py гоняет каждый сценарий на два шага. Окупился сразу: поймал,
что вся группа сверхмелких сеток падала на пределе GPU, а девять прогонов
группы F передавали --body-x дважды. Оба отказа проявились бы только на
сервере, часов через двадцать после старта кампании.

Docker: двухстадийная сборка, в образе libvulkan1 и vulkan-tools.
NVIDIA Container Toolkit подкладывает Vulkan-ICD только при graphics в
NVIDIA_DRIVER_CAPABILITIES — без него wgpu не увидит карту, поэтому
capabilities прописаны в образе, а vulkaninfo лежит внутрь для проверки.
ENTRYPOINT — драйвер кампании, CMD по умолчанию --dry-run, чтобы случайный
docker run не запустил сточасовую задачу. Проверено локально: образ
собирается, смоук проходит с монтированием результатов на хост, физика
совпадает с хостовой до последней цифры, --backend gpu без карты отказывает
явно. GPU-путь в контейнере проверяется только на машине с картой.

В README поправлено разделение вкладов в продольную акустику: измерением
2×2 показано, что 74.9% → 1.1% даёт ОДНОРОДНЫЙ СТАРТ, причём при полностью
выключенной губке, а губка снимает лишь остаток (1.12% → 0.83% при
утроении длины). При старте из покоя губка не помогает совсем — у стоячей
четвертьволновой моды там узел давления, где вязкость её не трогает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:36:40 +03:00

519 lines
34 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 # растянуть все длительности вдвое
#
# Стоимость меряется в ОБНОВЛЕНИЯХ УЗЛОВ (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 МБ
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)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--scale", type=float, default=2.0,
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):
g(args.scale)
doc = {
"meta": {
"note": "Стоимость в обновлениях узлов — единственная переносимая между машинами "
"мера. Часы драйвер получает, поделив её на фактические MLUPS, которые "
"замеряет на месте (--calibrate).",
"gif_policy": "вся длительность прогона, реальная скорость, 10 кадр/с; кадр "
"прореживается до ширины не больше 1280 пикселей",
"scale": args.scale,
"assumed_gpu_mlups": 1200,
"assumed_cpu_mlups": 22,
},
"runs": RUNS,
}
with open(args.out, "w", encoding="utf-8") 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")
if __name__ == "__main__":
main()