Пристеночная функция 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
+48 -10
View File
@@ -1,10 +1,10 @@
# Валидационная кампания
115 прогонов, ≈90 машинных часов: 108 на GPU (≈87 часов на RTX 4070 Ti) и 7 на CPU (≈3 часа —
исследования сходимости группы A, где нужен f64). Проверяет решатель по трём независимым линиям:
205 прогонов в одиннадцати группах, ≈101 машинный час: 198 на GPU (≈98 часов при 1200 MLUPS)
и 7 на CPU (≈3 часа — исследования сходимости группы A, где нужен f64). Проверяет решатель по трём независимым линиям:
эталонам из статей авторов метода, литературе по обтеканию тел и внутренним инвариантам самой
схемы. Каждый прогон кладёт в собственную папку `cmd.txt`, `log.txt`, `report.txt`, `series.csv`
и `summary.json`; гифку пишут **59 прогонов из 115** — остальным `--gif` не передаётся.
и `summary.json`; гифку пишут **59 прогонов из 205** — остальным `--gif` не передаётся.
## Пять долей: чужие ПК под Windows / WSL2
@@ -44,11 +44,11 @@ Desktop с бэкендом WSL2 (так по умолчанию) либо Docke
| доля | прогонов | ≈ часов |
|---|---|---|
| 1 | 22 | 24.0 |
| 2 | 23 | 24.0 |
| 3 | 23 | 24.0 |
| 4 | 23 | 24.0 |
| 5 | 24 | 24.0 |
| 1 | 40 | 26.7 |
| 2 | 40 | 26.7 |
| 3 | 41 | 26.7 |
| 4 | 42 | 26.7 |
| 5 | 42 | 26.7 |
Часы — оценка при 1200 MLUPS на крупной сетке **с поправкой на размер сетки**: на dzn мелкие
сетки считаются медленнее на узел, а сверхмелкие из группы I идут через раздельные привязки
@@ -66,7 +66,7 @@ Desktop с бэкендом WSL2 (так по умолчанию) либо Docke
## Быстрый старт на сервере
Образ опубликован, собирать ничего не нужно: **`notbigghost/kbc2d:1.3.0`**. Исходники на
Образ опубликован, собирать ничего не нужно: **`notbigghost/kbc2d:1.4.0`**. Исходники на
сервере тоже не нужны — переносится один файл `docker-compose.server.yml`.
```sh
@@ -75,7 +75,7 @@ mkdir -p ~/kbc2d && cd ~/kbc2d # сюда же ляжет ./out с ре
C=docker-compose.server.yml
docker compose -f $C --profile check run --rm vulkan # 1. карта видна из контейнера?
docker compose -f $C --profile check run --rm preflight # 2. все 115 сценариев стартуют?
docker compose -f $C --profile check run --rm preflight # 2. все 205 сценариев стартуют?
docker compose -f $C --profile check run --rm calibrate # 3. сколько MLUPS на этой машине?
docker compose -f $C --profile check run --rm plan # 4. смета в часах по замеренному
docker compose -f $C up -d # 5. кампания
@@ -277,6 +277,38 @@ out/<id>/xt_*.csv x–t диаграммы (группа G)
| **G** | 14 | старт, акустика, время жизни: x–t диаграммы, губки, предел по Re, прогон на 10⁷ шагов |
| **H** | 9 | инварианты: симметрия, зеркальность, зависимость от числа Маха, расхождение f32 против f64 |
| **I** | 9 | сверхмелкие сетки 4096×2048 по всем формам и композиции из трёх тел |
| **J** | 65 | схемы стенки с пристеночной функцией: Bouzidi без неё против Bouzidi, Grad и HRR со Spalding — цилиндр Re=150 / 2000 (D=8…64) и 5000 (D=32, 64), NACA 0012 α=4° Re=2000 (хорда 24…96) и 10⁴ (48, 96); эталон каждой серии — Bouzidi при удвоенном разрешении |
| **K** | 25 | внешний домен: грубые буферы до тела, после и по бокам, переход разрешения скачком ×4 / ×8 против ступеней ×2, длина тонкого следа, ширина промежуточного уровня, боковые губки — против однородной тонкой сетки на домене 30D+60D, ±30D |
### Группы J и K: как читать
Обе группы отвечают на практические вопросы, и обе меряются против эталона внутри группы, а не
против литературы — для турбулентных Re и для домена её просто нет.
**J** — что точнее: Bouzidi (лучшая геометрия) или моментные схемы с пристеночной функцией
Spalding. Bouzidi + Spalding добавлен, чтобы отделить вклад функции от вклада схемы. Сетка
однородная, разрешение задано числом клеток на тело. Re выбраны с оглядкой на устойчивость:
предел здесь задаёт не модель стенки, а τ − ½ = 3uD/Re — при τ − ½ < 6·10⁻⁴ счёт
разваливается у всех схем одинаково (замерено: цилиндр Re = 10⁴ при D = 16 и 32), и такой
прогон сравнивал бы не схемы, а предел устойчивости. Отсюда Re = 5000 вместо 10⁴ у цилиндра и
хорды от 48 у профиля при Re = 10⁴. В каждой сводке — y⁺ первого узла: при y⁺ < 5 функция
обязана почти совпасть с прилипанием, разница возможна только там, где первая клетка дальше.
**K** — как строить внешний домен (ключ `--levels` решателя). Тело везде одно: цилиндр с
D = 32 на самом тонком уровне. База — L0 в 4 раза грубее тела, домен 20D до тела, 40D после,
±20D, две ступени ×2. Серии меняют по одному параметру. Кроме St, ⟨Cd⟩ и rms Cl сравнивается
`rho_probe_rms` — пульсация плотности в зонде, мера паразитной акустики от отражений на границах
и стыках уровней, — и стоимость: конфигурации K обходятся в 1–2 % стоимости эталона.
Сводка обеих групп:
```sh
python compare.py # Markdown-таблицы против эталонов, читает out/
python compare.py --out ../all_out --csv jk.csv # по сведённым результатам пяти долей
```
Не посчитанный прогон помечается «нет», развалившийся — «развал»; под каждой серией J — какая
схема ближе всех к эталону на каждом разрешении (по |ΔCd| у цилиндра, по |ΔCl| у профиля).
## Стоимость и время
@@ -298,6 +330,12 @@ python gen_scenarios.py --scale 3 # все прогоны в полтора
группы A идут на CPU — это указано в самих сценариях.
- **Развалы ожидаемы** в группе G (предел по Re) и у прогона `A09_shear_n512_lbgk`: LBGK при
Re=3·10⁴ обязан развалиться там, где KBC доживает — это и есть проверяемое утверждение.
Длинные прогоны канала показали предел около τ − ½ ≈ 6·10⁻⁴ (τ − ½ = 3uD/Re), а не
Re ≈ 5·10⁴ при D = 16, как оценивалось раньше по коротким прогонам: при этом пределе
`G11_limit_re10000` (τ − ½ = 7.2·10⁻⁴) на грани, а `G12` и `G13` обязаны развалиться.
- **`F09_refine3` до версии 1.4.0 считался на GPU с заниженным в 1.5 раза Cd** — сила третьего
подшага терялась за краем буфера. Исправлено; результаты этого прогона из образов до 1.4.0
не использовать.
- **Гифки** пишутся на всю длительность прогона, в реальном времени, 10 кадр/с. Число кадров
этим задано жёстко (у самого длинного прогона их 16 666), поэтому единственный рычаг —
размер кадра. Замерено: 0.103 байта на пиксель после LZW; отсюда бюджет
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Сводные таблицы групп J и K против их эталонов.
python compare.py # Markdown в консоль, читает out/
python compare.py --out ../out_all # другой каталог результатов (например, сведённый
# из пяти долей)
python compare.py --csv compare.csv # те же строки в CSV
Группа J — схемы стенки. Каждая серия (тело, Re) имеет эталон: Bouzidi без пристеночной
функции при удвоенном разрешении. Для каждого прогона печатается отклонение St, ⟨Cd⟩,
rms Cl (у профиля — ещё ⟨Cl⟩) от эталона и y⁺ первого узла; в конце серии — какая схема
ближе к эталону на каждом разрешении.
Группа K — внешний домен. Эталон каждого Re — однородная тонкая сетка на самом большом
домене (K…_ref). Для каждой конфигурации — отклонения тех же величин, пульсация плотности в
зонде (паразитная акустика) и стоимость (обновлений узлов и фактические секунды).
Прогон без summary.json (не посчитан) или развалившийся показывается строкой с пометкой —
дыры в таблице видны сразу, а не теряются.
"""
import argparse
import csv
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
def load(out_dir, scen_path):
runs = json.load(open(scen_path, encoding="utf-8"))["runs"]
res = {}
for r in runs:
p = os.path.join(out_dir, r["id"], "summary.json")
if os.path.exists(p):
try:
res[r["id"]] = json.load(open(p, encoding="utf-8"))
except json.JSONDecodeError:
res[r["id"]] = None
return runs, res
def dev(v, ref):
"""Относительное отклонение в процентах; None, если сравнивать нечего."""
if v is None or ref is None or ref == 0:
return None
return 100.0 * (v - ref) / abs(ref)
def fmt(v, spec="{:+.2f}"):
return "—" if v is None else spec.format(v)
def status(s):
if s is None:
return "нет"
return "развал" if s.get("blew_up") else "ok"
# ── J ────────────────────────────────────────────────────────────────────────
J_RE = re.compile(r"^J\d+_(cyl|naca)_re(\d+)_(?:(?:d|c)(\d+)_(\w+)|ref_(?:d|c)(\d+))$")
def table_j(runs, res):
series = {}
for r in runs:
m = J_RE.match(r["id"])
if not m:
continue
body, re_, n, scheme, nref = m.groups()
key = (body, int(re_))
s = series.setdefault(key, {"ref": None, "rows": []})
if nref:
s["ref"] = r["id"]
else:
s["rows"].append((int(n), scheme, r["id"]))
rows, lines = [], []
for (body, re_), s in sorted(series.items()):
ref = res.get(s["ref"]) if s["ref"] else None
name = "цилиндр" if body == "cyl" else "NACA 0012, α=4°"
unit = "D" if body == "cyl" else "хорда"
lines.append(f"\n### J: {name}, Re = {re_}\n")
lines.append(f"Эталон `{s['ref']}`: " + (
"не посчитан" if ref is None else
f"St {fmt(ref.get('strouhal'), '{:.4f}')}, Cd {fmt(ref.get('cd'), '{:.4f}')}, "
f"Cl {fmt(ref.get('cl_mean'), '{:.4f}')}, rms Cl {fmt(ref.get('cl_rms'), '{:.4f}')}"))
lines.append("")
lines.append(f"| {unit} | схема | статус | ΔSt % | ΔCd % | ΔCl % | Δrms Cl % | y⁺ ср | y⁺ макс |")
lines.append("|---|---|---|---|---|---|---|---|---|")
best = {}
for n, scheme, rid in sorted(s["rows"]):
v = res.get(rid)
ok = v is not None and not v.get("blew_up")
g = (lambda k: v.get(k)) if ok else (lambda k: None)
rf = (lambda k: ref.get(k)) if ref else (lambda k: None)
d_st, d_cd = dev(g("strouhal"), rf("strouhal")), dev(g("cd"), rf("cd"))
d_cl, d_clr = dev(g("cl_mean"), rf("cl_mean")), dev(g("cl_rms"), rf("cl_rms"))
lines.append(
f"| {n} | {scheme} | {status(v)} | {fmt(d_st)} | {fmt(d_cd)} | "
f"{fmt(d_cl if body == 'naca' else None)} | {fmt(d_clr)} | "
f"{fmt(g('yplus_mean'), '{:.2f}')} | {fmt(g('yplus_max'), '{:.1f}')} |")
rows.append({"group": "J", "body": body, "re": re_, "n": n, "scheme": scheme,
"id": rid, "status": status(v), "d_st": d_st, "d_cd": d_cd,
"d_cl": d_cl, "d_cl_rms": d_clr, "yplus_mean": g("yplus_mean"),
"yplus_max": g("yplus_max")})
# для профиля главное — подъёмная сила, для цилиндра — сопротивление
score = d_cl if body == "naca" else d_cd
if score is not None:
b = best.get(n)
if b is None or abs(score) < abs(b[1]):
best[n] = (scheme, score)
if best:
what = "|ΔCl|" if body == "naca" else "|ΔCd|"
lines.append("")
lines.append(f"Ближе всех к эталону по {what}: " + "; ".join(
f"{unit} {n} — **{sc}** ({sv:+.2f} %)" for n, (sc, sv) in sorted(best.items())))
return rows, lines
# ── K ────────────────────────────────────────────────────────────────────────
K_RE = re.compile(r"^K\d+_re(\d+)_(\w+)$")
def table_k(runs, res):
by_re = {}
meta = {r["id"]: r for r in runs}
for r in runs:
m = K_RE.match(r["id"])
if m:
by_re.setdefault(int(m.group(1)), []).append((m.group(2), r["id"]))
rows, lines = [], []
for re_, items in sorted(by_re.items()):
ref_id = next((rid for tag, rid in items if tag == "ref"), None)
ref = res.get(ref_id) if ref_id else None
lines.append(f"\n### K: внешний домен, Re = {re_}\n")
lines.append(f"Эталон `{ref_id}`: " + (
"не посчитан" if ref is None else
f"St {fmt(ref.get('strouhal'), '{:.4f}')}, Cd {fmt(ref.get('cd'), '{:.4f}')}, "
f"rms Cl {fmt(ref.get('cl_rms'), '{:.4f}')}, ρ′ {fmt(ref.get('rho_probe_rms'), '{:.2e}')}"))
lines.append("")
lines.append("| конфигурация | статус | ΔSt % | ΔCd % | Δrms Cl % | ρ′ зонда | "
"стоимость, отн. эталона | время, с |")
lines.append("|---|---|---|---|---|---|---|---|")
ref_cost = meta[ref_id]["cost"] if ref_id else None
for tag, rid in items:
if tag == "ref":
continue
v = res.get(rid)
ok = v is not None and not v.get("blew_up")
g = (lambda k: v.get(k)) if ok else (lambda k: None)
rf = (lambda k: ref.get(k)) if ref else (lambda k: None)
d_st, d_cd = dev(g("strouhal"), rf("strouhal")), dev(g("cd"), rf("cd"))
d_clr = dev(g("cl_rms"), rf("cl_rms"))
cost = meta[rid]["cost"] / ref_cost if ref_cost else None
lines.append(
f"| {tag} | {status(v)} | {fmt(d_st)} | {fmt(d_cd)} | {fmt(d_clr)} | "
f"{fmt(g('rho_probe_rms'), '{:.2e}')} | {fmt(cost, '{:.3f}')} | "
f"{fmt(g('wall_time_s'), '{:.0f}')} |")
rows.append({"group": "K", "re": re_, "config": tag, "id": rid,
"status": status(v), "d_st": d_st, "d_cd": d_cd, "d_cl_rms": d_clr,
"rho_probe_rms": g("rho_probe_rms"), "cost_rel": cost,
"wall_time_s": g("wall_time_s")})
return rows, lines
def main():
ap = argparse.ArgumentParser(description="Сводка групп J и K против эталонов")
ap.add_argument("--out", default=os.path.join(HERE, "out"), help="каталог результатов")
ap.add_argument("--scenarios", default=os.path.join(HERE, "scenarios.json"))
ap.add_argument("--csv", help="записать строки таблиц в CSV")
args = ap.parse_args()
runs, res = load(args.out, args.scenarios)
rj, lj = table_j(runs, res)
rk, lk = table_k(runs, res)
print("\n".join(["# Сравнение схем стенки (J) и конфигураций домена (K)"] + lj + lk))
if args.csv:
keys = sorted({k for row in rj + rk for k in row})
with open(args.csv, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=keys)
w.writeheader()
for row in rj + rk:
w.writerow(row)
print(f"\nCSV: {args.csv}", file=sys.stderr)
if __name__ == "__main__":
main()
+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)
+20
View File
@@ -79,6 +79,26 @@ CASES = [
"degenerate_frac": 5.0},
"report": ["strouhal"],
},
# Пристеночная функция переписана в WGSL отдельно от math.rs (закон Spalding, Ньютон в
# f32), поэтому сверяется отдельно. При Re=20 y⁺ < 1 и функция почти не меняет
# результат — проверяется именно совпадение двух реализаций, а не физика.
{
"name": "цилиндр Re=20, Град + Spalding, 8000 шагов",
"args": ["--shape", "cylinder", "--size", "16", "--nx", "320", "--ny", "192",
"--re", "20", "--refine", "1", "--steps", "8000",
"--wall", "grad", "--wall-function", "spalding"],
"fields": {"cd": 1.0, "rho_mean": 0.05, "yplus_mean": 1.0},
"report": ["strouhal"],
},
# Три уровня: рекурсивная связка уровней и сквозная нумерация подшагов силы на GPU.
{
"name": "цилиндр Re=20, три уровня ×2×2, 3000 шагов",
"args": ["--shape", "cylinder", "--size", "8", "--nx", "240", "--ny", "160",
"--re", "20", "--levels", "2:4,10,4; 2:1.5,4,1.5", "--wall", "bouzidi",
"--steps", "3000"],
"fields": {"cd": 1.0, "rho_mean": 0.05},
"report": ["strouhal"],
},
]
File diff suppressed because it is too large Load Diff