Пристеночная функция 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>
This commit is contained in:
2026-10-02 12:25:43 +03:00
co-authored by Claude Opus 5.5
parent 1d495e3e89
commit 86e9c7139d
14 changed files with 5574 additions and 596 deletions
+203 -1
View File
@@ -485,6 +485,207 @@ def group_i(scale):
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":
@@ -526,7 +727,8 @@ def main():
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):
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)