Начальный коммит: Vulkan-редактор SimVulcan + исследование KBC-LBM

Состояние на момент заведения репозитория.

C++ приложение (src/, shaders/, tests/) — минимальный редактор 3D-моделей
на Vulkan 1.3: орбитальная камера, три опорные сетки через начало координат,
загрузка .obj с режимами отображения. Весь Vulkan изолирован в src/vk/.

Исследование (docs/) — оригинальные статьи по KBC (docs/origins) и
Python-решатель D2Q9 KBC-N1 с AMR 2x и SDF+Bouzidi (docs/theory).

В решателе перед коммитом исправлены дефекты, найденные сверкой с
первоисточниками: относительный порог знаменателя энтропийного стабилизатора
(абсолютный вырождал KBC в LBGK на 77-99% узлов), заворот вход/выход в углах
домена, диагностика средней плотности по фиктивным узлам тела, зашитый
refine=2. Подробности — docs/theory/solver_2x_sdf/README.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 16:37:38 +03:00
co-authored by Claude Opus 5
commit 11ff7b79b4
139 changed files with 88747 additions and 0 deletions
@@ -0,0 +1,36 @@
# ОПТИМИЗИРОВАННЫЙ GPU-решатель (без SDF): цилиндр, три версии (без AMR / 2× / вложенный 2×+4×).
# То же, что amr_cylinder_gpu.py, но: GPU-only ядро _opt (фикс силового зонда + ускорение),
# прогресс-бар на каждый прогон, гифки для ВСЕХ трёх режимов (none/amr2x/nested).
# Запуск: python amr_cylinder_gpu_opt.py
# AMR_FP64=1 ... (двойная точность)
# AMR_STEPS=2000 ... (короткий прогон)
# AMR_CUDAGRAPH=0 ... (выключить CUDA Graphs; по умолчанию ВКЛ, с рантайм-самопроверкой)
import os, time, numpy as np
import amr_gpu_core_opt as core
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM = os.path.join(HERE, "anim"); os.makedirs(ANIM, exist_ok=True)
FIGS = os.path.join(HERE, "figures"); os.makedirs(FIGS, exist_ok=True)
print(f"[ОПТ · без SDF] backend={core.BACKEND}; домен {core.Nx}x{core.Ny}; steps={core.STEPS}; "
f"graphs={'on' if os.environ.get('AMR_CUDAGRAPH','1') != '0' else 'off'}; "
f"L1(2×) {core.P1['Nfx']}x{core.P1['Nfy']}; L2(4×) {core.P2['Nfx']}x{core.P2['Nfy']}")
t0 = time.perf_counter()
R0 = core.run("none", False, core.make_progress("none")); print(" none :", round(time.perf_counter()-t0, 1), "s")
R1 = core.run("amr2x", False, core.make_progress("amr2x")); print(" amr2x :", round(time.perf_counter()-t0, 1), "s")
R2 = core.run("nested", False, core.make_progress("nested")); print(" nested :", round(time.perf_counter()-t0, 1), "s")
core.save_gif(R0, "none_cyl.gif", ANIM, " (без SDF)")
core.save_gif(R1, "amr2x_cyl.gif", ANIM, " (без SDF)")
core.save_gif(R2, "amr_nested_cyl.gif", ANIM, " (без SDF)")
AS = [core.analyze(R0), core.analyze(R1), core.analyze(R2)]
core.print_table(AS, "БЕЗ SDF — сравнение на реально собранных данных (установившийся режим)")
np.savez(os.path.join(HERE, "_amr_probe_data.npz"),
St=np.array([a["St"] for a in AS]), Cd=np.array([a["Cd"] for a in AS]),
Clrms=np.array([a["Clrms"] for a in AS]), uyrms=np.array([a["uyrms"] for a in AS]),
Cl0=AS[0]["Cl_s"], Cl1=AS[1]["Cl_s"], Cl2=AS[2]["Cl_s"],
uy0=AS[0]["uy"], uy1=AS[1]["uy"], uy2=AS[2]["uy"])
core.probe_figure(AS, R2, os.path.join(FIGS, "11e_amr_probe_comparison.png"),
"AMR на цилиндре (без SDF): сравнение трёх версий на данных зондов")
print("DONE (без SDF)")
@@ -0,0 +1,63 @@
# ОПТИМИЗИРОВАННЫЙ GPU-решатель (с SDF + Bouzidi): цилиндр, три версии (без AMR / 2× / 2×+4×).
# То же, что amr_cylinder_sdf_gpu.py, но: GPU-only ядро _opt (ускорение), прогресс-бар на
# каждый прогон, гифки для ВСЕХ трёх режимов (none/amr2x/nested). В конце — сводное сравнение с без-SDF.
# Запуск: python amr_cylinder_sdf_gpu_opt.py (после amr_cylinder_gpu_opt.py — для сводного сравнения)
# AMR_CUDAGRAPH=0 ... (выключить CUDA Graphs; по умолчанию ВКЛ, с рантайм-самопроверкой)
import os, time, numpy as np
import amr_gpu_core_opt as core
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM = os.path.join(HERE, "anim"); os.makedirs(ANIM, exist_ok=True)
FIGS = os.path.join(HERE, "figures"); os.makedirs(FIGS, exist_ok=True)
print(f"[ОПТ · SDF+Bouzidi] backend={core.BACKEND}; домен {core.Nx}x{core.Ny}; steps={core.STEPS}; "
f"graphs={'on' if os.environ.get('AMR_CUDAGRAPH','1') != '0' else 'off'}; "
f"L1(2×) {core.P1['Nfx']}x{core.P1['Nfy']}; L2(4×) {core.P2['Nfx']}x{core.P2['Nfy']}")
t0 = time.perf_counter()
R0 = core.run("none", True, core.make_progress("none")); print(" none :", round(time.perf_counter()-t0, 1), "s")
R1 = core.run("amr2x", True, core.make_progress("amr2x")); print(" amr2x :", round(time.perf_counter()-t0, 1), "s")
R2 = core.run("nested", True, core.make_progress("nested")); print(" nested :", round(time.perf_counter()-t0, 1), "s")
core.save_gif(R0, "none_cyl_sdf.gif", ANIM, " + SDF (Bouzidi)")
core.save_gif(R1, "amr2x_cyl_sdf.gif", ANIM, " + SDF (Bouzidi)")
core.save_gif(R2, "amr_nested_cyl_sdf.gif", ANIM, " + SDF (Bouzidi)")
AS = [core.analyze(R0), core.analyze(R1), core.analyze(R2)]
core.print_table(AS, "С SDF — сравнение на реально собранных данных (установившийся режим)")
np.savez(os.path.join(HERE, "_amr_probe_data_sdf.npz"),
St=np.array([a["St"] for a in AS]), Cd=np.array([a["Cd"] for a in AS]),
Clrms=np.array([a["Clrms"] for a in AS]), uyrms=np.array([a["uyrms"] for a in AS]),
Cl0=AS[0]["Cl_s"], Cl1=AS[1]["Cl_s"], Cl2=AS[2]["Cl_s"],
uy0=AS[0]["uy"], uy1=AS[1]["uy"], uy2=AS[2]["uy"])
core.probe_figure(AS, R2, os.path.join(FIGS, "11f_amr_sdf_probe_comparison.png"),
"AMR на цилиндре (SDF+Bouzidi): сравнение трёх версий на данных зондов")
# ---- сводное сравнение: SDF против без-SDF (если есть свежие данные без SDF) ----
nosdf = os.path.join(HERE, "_amr_probe_data.npz")
if os.path.exists(nosdf) and ("St" in np.load(nosdf).files):
import matplotlib; matplotlib.use("Agg"); import matplotlib.pyplot as plt
N = np.load(nosdf); Stn, Cdn = N["St"], N["Cd"]
Sts = np.array([a["St"] for a in AS]); Cds = np.array([a["Cd"] for a in AS])
print("\n=== СВОДНО: SDF vs без SDF (Cd, St) ===")
print(f"{'версия':14}{'St(noSDF)':>11}{'St(SDF)':>9}{'Cd(noSDF)':>11}{'Cd(SDF)':>9}")
for k, lab in enumerate(["без AMR", "2×", "2×+4×"]):
print(f"{lab:14}{Stn[k]:>11.3f}{Sts[k]:>9.3f}{Cdn[k]:>11.3f}{Cds[k]:>9.3f}")
print(f"{'лит. Re≈150':14}{0.183:>11.3f}{0.183:>9.3f}{1.330:>11.3f}{1.330:>9.3f}")
x = np.arange(3); w = 0.38
fig, axs = plt.subplots(1, 2, figsize=(13.5, 4.6))
axs[0].bar(x-w/2, Cdn, w, color="#9e9e9e", label="без SDF (ступенч.)")
axs[0].bar(x+w/2, Cds, w, color="#1f6feb", label="с SDF (Bouzidi)")
axs[0].axhline(1.33, color="k", ls="--", alpha=0.6, label="лит. Cd≈1.33")
axs[0].set_xticks(x); axs[0].set_xticklabels(["без AMR", "2×", "2×+4×"]); axs[0].set_ylabel("<Cd>")
axs[0].set_title("Сопротивление: SDF убирает завышение от ступенчатой границы")
axs[0].legend(fontsize=8); axs[0].grid(alpha=0.3, axis="y")
axs[1].bar(x-w/2, Stn, w, color="#9e9e9e", label="без SDF"); axs[1].bar(x+w/2, Sts, w, color="#1f6feb", label="с SDF")
axs[1].axhline(0.183, color="k", ls="--", alpha=0.6, label="лит. St≈0.18")
axs[1].set_xticks(x); axs[1].set_xticklabels(["без AMR", "2×", "2×+4×"]); axs[1].set_ylabel("St")
axs[1].set_title("Число Струхаля"); axs[1].legend(fontsize=8); axs[1].grid(alpha=0.3, axis="y")
fig.suptitle("Цилиндр: SDF (Bouzidi) против ступенчатого bounce-back — на реальных данных", fontweight="bold")
fig.savefig(os.path.join(FIGS, "11g_sdf_vs_nosdf.png"), dpi=110, bbox_inches="tight"); plt.close(fig)
print("saved 11g_sdf_vs_nosdf.png")
else:
print("\n(нет свежего _amr_probe_data.npz — сначала запусти amr_cylinder_gpu_opt.py)")
print("DONE (SDF)")
+448
View File
@@ -0,0 +1,448 @@
# Оптимизированное GPU-ЯДРО (D2Q9 KBC) для AMR-решателей на цилиндре. GPU-only (CuPy).
# Отличия от amr_gpu_core.py:
# * УБРАН numpy-фолбэк: backend всегда CuPy/GPU (иначе жёсткая ошибка). numpy остаётся
# только для host-задач (маски/SDF/базис один раз, FFT в analyze, matplotlib/PIL).
# * ФИКС силового зонда force_on: второй член обмена импульсом берётся из поля ПОСЛЕ
# стриминга (как в корректном force_bc), а не из post-collision. Раньше это давало
# нефизичный отрицательный Cd и крошечный rms Cl.
# * feq без pow (показатели c∈{-1,0,1}) + cp.fuse; фьюзинг хвоста collide.
# * Предвычисленные маски линков для силы (без per-step roll статических масок).
# * run(): один шаг = замкнутая _step() над persistent-буферами; опционально CUDA Graphs
# (AMR_CUDAGRAPH=1, по умолчанию вкл) с автоматическим откатом на eager.
# * Кадры для всех трёх режимов (none/amr2x/nested); save_gif рисует и режим none.
# * make_progress — общий прогресс-бар в ядре.
# Запуск через раннеры: amr_cylinder_gpu_opt.py (без SDF) и amr_cylinder_sdf_gpu_opt.py (SDF).
import os, io as _io, numpy as np
# ---- backend: ТОЛЬКО CuPy/GPU (фолбэк удалён) ----
import cupy as cp
try:
_ = (cp.zeros(2) + 1).sum(); cp.cuda.Device().synchronize() # реальная операция на device
except Exception as e:
raise RuntimeError("Этому решателю нужна рабочая GPU + CuPy: numpy-фолбэк удалён.") from e
xp = cp; GPU = True
def to_cpu(a):
return cp.asnumpy(a)
DTYPE = cp.float64 if os.environ.get("AMR_FP64") else cp.float32
GREL = 1e-8 # ОТНОСИТЕЛЬНЫЙ порог вырожденности знаменателя γ (доля от ‖Δ‖²); см. solver_2x_sdf/backend.py.
# Прежний абсолютный порог (1e-6 в fp32) срабатывал на большинстве узлов и подменял KBC на LBGK.
BACKEND = f"CuPy/GPU dtype={'float64' if DTYPE == cp.float64 else 'float32'}"
# ---- решётка D2Q9 ----
Cx = [0, 1, 0, -1, 0, 1, -1, -1, 1] # python-инты для roll/индексации
Cy = [0, 0, 1, 0, -1, 1, 1, -1, -1]
OPP = [0, 3, 4, 1, 2, 7, 8, 5, 6]
Q = 9
_W = np.array([4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36])
_CXn = np.array(Cx, float); _CYn = np.array(Cy, float)
# моментный базис и проектор на сдвиг {cx²−cy², cxcy} (KBC-N1) — считаем в numpy, грузим на device
_M = np.zeros((Q, Q)); _M[0] = 1; _M[1] = _CXn; _M[2] = _CYn; _M[3] = 3*(_CXn**2+_CYn**2)-2
_M[4] = _CXn**2-_CYn**2; _M[5] = _CXn*_CYn; _M[6] = _CXn**2*_CYn; _M[7] = _CXn*_CYn**2; _M[8] = _CXn**2*_CYn**2
_Dm = np.zeros((Q, Q)); _Dm[4, 4] = _Dm[5, 5] = 1.0
_Psn = np.linalg.inv(_M) @ _Dm @ _M
# device-константы
Wa = xp.asarray(_W, dtype=DTYPE)
CXa = xp.asarray(_CXn, dtype=DTYPE); CYa = xp.asarray(_CYn, dtype=DTYPE)
Ps = xp.asarray(_Psn, dtype=DTYPE)
CS2 = 1.0/3.0
# device-скаляры (чтобы не делать host→device asarray на горячем пути и не ломать graph-capture)
_Z0 = xp.zeros((), DTYPE); _ONE = xp.asarray(1.0, DTYPE); _TWO = xp.asarray(2.0, DTYPE)
# ---- ядро LBM/KBC (математика идентична; feq без pow) ----
def _feq9_body(rho, ux, uy):
# равновесие product-form для D2Q9: показатели c∈{-1,0,1} → используем qx/iqx вместо pow.
# clip входит в ТЕЛО ядра через min/max c литералами (запекаются в кернел → нет H2D при capture).
ux = cp.minimum(cp.maximum(ux, -0.95), 0.95); uy = cp.minimum(cp.maximum(uy, -0.95), 0.95)
sx = cp.sqrt(1.0 + 3.0*ux*ux); sy = cp.sqrt(1.0 + 3.0*uy*uy)
base = rho*(2.0 - sx)*(2.0 - sy)
qx = (2.0*ux + sx)/(1.0 - ux); qy = (2.0*uy + sy)/(1.0 - uy)
ix = 1.0/qx; iy = 1.0/qy
return (base*(4.0/9.0), # ( 0, 0)
base*(1.0/9.0)*qx, base*(1.0/9.0)*qy, # (+1,0) (0,+1)
base*(1.0/9.0)*ix, base*(1.0/9.0)*iy, # (-1,0) (0,-1)
base*(1.0/36.0)*qx*qy, base*(1.0/36.0)*ix*qy, # (+1,+1) (-1,+1)
base*(1.0/36.0)*ix*iy, base*(1.0/36.0)*qx*iy) # (-1,-1) (+1,-1)
_feq9 = cp.fuse()(_feq9_body) # одно слитное ядро (если cp.fuse доступен)
_FUSE_OK = True
def feq(rho, u):
global _FUSE_OK
if _FUSE_OK:
try:
return xp.stack(_feq9(rho, u[0], u[1]), axis=0)
except Exception as e: # откат на не-fused (тоже без pow)
print(f"[feq] cp.fuse отключён ({type(e).__name__}: {e}); обычный путь без pow")
_FUSE_OK = False
return xp.stack(_feq9_body(rho, u[0], u[1]), axis=0)
def macros(f):
r = f.sum(0)
return r, xp.stack([(CXa[:, None, None]*f).sum(0)/r, (CYa[:, None, None]*f).sum(0)/r])
def collide(f, fe, beta):
dfn = f - fe
# проектор на сдвиг БЕЗ cuBLAS (gemm при capture тащит alpha/beta host→device → H2D):
dfn2d = dfn.reshape(Q, -1) # (Q, N)
ds = (Ps[:, :, None] * dfn2d[None]).sum(1).reshape(dfn.shape) # Σ_k Ps[i,k]·dfn[k] — чистые ядра
dh = dfn - ds; inv = 1.0/fe
num = (ds*dh*inv).sum(0); den = (dh*dh*inv).sum(0)
nrm = (dfn*dfn*inv).sum(0) # ‖Δ‖² — масштаб неравновесия узла
ok = den > GREL*nrm
den_safe = xp.where(ok, den, _ONE) # избегаем 0/0
binv = 1.0/beta
g = xp.where(ok, binv - (2.0 - binv)*num/den_safe, _TWO)
return f - beta*(2.0*ds + g[None]*dh)
def stream(f):
fs = xp.empty_like(f)
for i in range(Q):
fs[i] = xp.roll(f[i], (Cy[i], Cx[i]), (0, 1))
return fs
# ---- AMR-связка ----
def patch(pNx, pNy, ax, bx, ay, by, r):
Wx, Wy = bx-ax, by-ay; Nfx, Nfy = r*Wx+1, r*Wy+1
FX, FY = np.meshgrid(ax+np.arange(Nfx)/r, ay+np.arange(Nfy)/r)
x0 = np.floor(FX).astype(np.int64); y0 = np.floor(FY).astype(np.int64)
x1 = np.minimum(x0+1, pNx-1); y1 = np.minimum(y0+1, pNy-1)
return dict(Nfx=Nfx, Nfy=Nfy, ax=ax, bx=bx, ay=ay, by=by, r=r,
x0=xp.asarray(x0), y0=xp.asarray(y0), x1=xp.asarray(x1), y1=xp.asarray(y1),
tx=xp.asarray(FX-x0, DTYPE), ty=xp.asarray(FY-y0, DTYPE),
slx=slice(r, r*Wx, r), sly=slice(r, r*Wy, r))
def pint(fld, P):
return (fld[..., P["y0"], P["x0"]]*(1-P["tx"])*(1-P["ty"]) + fld[..., P["y0"], P["x1"]]*P["tx"]*(1-P["ty"])
+ fld[..., P["y1"], P["x0"]]*(1-P["tx"])*P["ty"] + fld[..., P["y1"], P["x1"]]*P["tx"]*P["ty"])
def ghost(pf, P, Rcf):
r, u = macros(pf); neq = pf - feq(r, u)
return feq(pint(r, P), xp.stack([pint(u[0], P), pint(u[1], P)])) + Rcf*pint(neq, P)
def fill(cf, gh):
cf[:, 0, :] = gh[:, 0, :]; cf[:, -1, :] = gh[:, -1, :]; cf[:, :, 0] = gh[:, :, 0]; cf[:, :, -1] = gh[:, :, -1]
return cf
def restrict(cf, pf, P, Rfc, fluid):
r, u = macros(cf); neq = cf - feq(r, u); slx, sly = P["slx"], P["sly"]
nv = feq(r[sly, slx], u[:, sly, slx]) + Rfc*neq[:, sly, slx]
cur = pf[:, P["ay"]+1:P["by"], P["ax"]+1:P["bx"]]
pf[:, P["ay"]+1:P["by"], P["ax"]+1:P["bx"]] = xp.where(fluid[None], nv, cur)
return pf
# ---- граница: (1) узловой bounce-back (без SDF); (2) SDF+Bouzidi ----
def bb_set(post, pre, solid): # узлы тела <- развёрнутые до-столкновения
for i in range(Q):
post[i] = xp.where(solid, pre[OPP[i]], post[i])
return post
def build_links(solid_np, body_np): # предвычисление масок линков жидкость->тело (статичны)
fluid = ~solid_np; links = []
for i in range(1, Q):
nb = np.roll(body_np, (-Cy[i], -Cx[i]), (0, 1)); mask = fluid & nb
links.append((i, OPP[i], Cx[i], Cy[i], xp.asarray(mask, DTYPE)))
return links
def force_on(fpost, fnew, links): # Mei (узловая граница): F=Σ c_i (f_i^после-столкн. + f_ī^после-стриминга)
Fx = _Z0; Fy = _Z0
for (i, ib, cx_i, cy_i, maskf) in links:
s = (maskf*(fpost[i] + fnew[ib])).sum()
Fx = Fx + cx_i*s; Fy = Fy + cy_i*s
return Fx, Fy
def build_bc(solid_np, phi_np, cyl_np): # SDF-границу строим на host один раз, переносим на device
fluid = ~solid_np; bc = []
for i in range(1, Q):
nb_solid = np.roll(solid_np, (-Cy[i], -Cx[i]), (0, 1)); mask = fluid & nb_solid
cl = mask & np.roll(cyl_np, (-Cy[i], -Cx[i]), (0, 1))
phinb = np.roll(phi_np, (-Cy[i], -Cx[i]), (0, 1))
with np.errstate(divide="ignore", invalid="ignore"):
qq = phi_np/(phi_np - phinb)
q = np.where(mask, np.clip(qq, 0.02, 0.98), 0.5)
xff = mask & (~np.roll(solid_np, (Cy[i], Cx[i]), (0, 1)))
bc.append((i, OPP[i], xp.asarray(mask), xp.asarray(q, DTYPE), xp.asarray(xff), xp.asarray(cl)))
return bc
def apply_bc(f, fpost, bc): # Bouzidi (линейный), полностью через where (GPU)
for (i, ib, mask, q, xff, cl) in bc:
fi = fpost[i]; fib = fpost[ib]; fiback = xp.roll(fpost[i], (Cy[i], Cx[i]), (0, 1))
near = mask & (q < 0.5) & xff; bad = mask & (q < 0.5) & (~xff); far = mask & (q >= 0.5)
new = f[ib]
new = xp.where(near, 2*q*fi + (1-2*q)*fiback, new)
new = xp.where(far, (1/(2*q))*fi + (1-1/(2*q))*fib, new)
new = xp.where(bad, fi, new)
f[ib] = new
return f
def force_bc(fpost, f, bc): # Mei для интерполированной границы (уже корректно: f[ib] — после стриминга)
Fx = _Z0; Fy = _Z0
for (i, ib, mask, q, xff, cl) in bc:
s = xp.where(cl, fpost[i] + f[ib], _Z0).sum()
Fx = Fx + Cx[i]*s; Fy = Fy + Cy[i]*s
return Fx, Fy
# ---- геометрия (ИДЕНТИЧНА базовым версиям) ----
Nx, Ny, D, cx, cy = 150, 72, 16, 40, 36
U, Re, STEPS, ramp, FE = 0.07, 150.0, int(os.environ.get("AMR_STEPS", 8000)), 1000, 34
D0 = D; nu = U*D/Re; tau0 = nu/CS2 + 0.5
ax1, bx1, ay1, by1 = 16, 118, 8, 64
cx2a, cx2b, cy2a, cy2b = 26, 64, 18, 54
px, py = cx + 3*D, cy
def _cmask(NX, NY, ccx, ccy, R):
Y, X = np.meshgrid(np.arange(NY), np.arange(NX), indexing="ij"); return (X-ccx)**2 + (Y-ccy)**2 <= R*R
def _csdf(NX, NY, ccx, ccy, R):
Y, X = np.meshgrid(np.arange(NY), np.arange(NX), indexing="ij"); return np.sqrt((X-ccx)**2.0 + (Y-ccy)**2.0) - R
# host-маски/SDF
_walls = np.zeros((Ny, Nx), bool); _walls[0, :] = True; _walls[-1, :] = True
cyl0_np = _cmask(Nx, Ny, cx, cy, D/2); solid0_np = cyl0_np | _walls
_Yg = np.arange(Ny)[:, None]*np.ones((1, Nx))
phi0_np = np.minimum(_csdf(Nx, Ny, cx, cy, D/2), np.minimum(_Yg-0.5, (Ny-1.5)-_Yg))
P1 = patch(Nx, Ny, ax1, bx1, ay1, by1, 2); P2 = patch(P1["Nfx"], P1["Nfy"], (cx2a-ax1)*2, (cx2b-ax1)*2, (cy2a-ay1)*2, (cy2b-ay1)*2, 2)
solid1_np = _cmask(P1["Nfx"], P1["Nfy"], (cx-ax1)*2, (cy-ay1)*2, D); phi1_np = _csdf(P1["Nfx"], P1["Nfy"], (cx-ax1)*2, (cy-ay1)*2, D)
solid2_np = _cmask(P2["Nfx"], P2["Nfy"], (cx-cx2a)*4, (cy-cy2a)*4, 2*D); phi2_np = _csdf(P2["Nfx"], P2["Nfy"], (cx-cx2a)*4, (cy-cy2a)*4, 2*D)
# device-маски (без SDF)
g_solid0 = xp.asarray(solid0_np); g_cyl0 = xp.asarray(cyl0_np); g_fl0 = ~g_solid0
g_solid1 = xp.asarray(solid1_np); g_fl1 = ~g_solid1
g_solid2 = xp.asarray(solid2_np); g_fl2 = ~g_solid2
# предвычисленные маски линков для силы (узловая граница)
L0_links = build_links(solid0_np, cyl0_np)
L1_links = build_links(solid1_np, solid1_np)
L2_links = build_links(solid2_np, solid2_np)
# SDF-ГУ
BC0 = build_bc(solid0_np, phi0_np, cyl0_np); BC1 = build_bc(solid1_np, phi1_np, solid1_np); BC2 = build_bc(solid2_np, phi2_np, solid2_np)
# рестрикция: жидкие узлы
fl1 = xp.asarray(~solid0_np[ay1+1:by1, ax1+1:bx1])
fl2 = xp.ones((P2["by"]-P2["ay"]-1, P2["bx"]-P2["ax"]-1), bool); fl2 = xp.asarray(fl2)
# τ / масштабы
t1 = 2*tau0 - 0.5; t2 = 2*t1 - 0.5
b0 = 1/(2*tau0); b1 = 1/(2*t1); b2 = 1/(2*t2)
R01 = t1/(2*tau0); Rf01 = 1/R01; R12 = t2/(2*t1); Rf12 = 1/R12
# ---- прогресс-бар (ASCII-шкала по выполненным шагам из заданных) ----
import time as _time
def make_progress(label, width=32, every=0.1):
st = {"t0": _time.perf_counter(), "last": 0.0}
def cb(t, total):
now = _time.perf_counter(); done = (t >= total)
if not done and (now - st["last"] < every): # троттлинг, чтобы не спамить консоль
return
st["last"] = now; frac = (t + 1) / (total + 1)
filled = int(round(width * frac)); bar = "#" * filled + "-" * (width - filled)
el = now - st["t0"]; eta = (el / frac - el) if frac > 0 else 0.0
print(f"\r {label:7}[{bar}] {frac*100:5.1f}% ({t}/{total}) {el:5.1f}s ETA {eta:5.1f}s",
end=("\n" if done else ""), flush=True)
return cb
# ---- захват CUDA-графа одного шага ----
def _capture(step_fn):
cp.cuda.Device().synchronize()
s = cp.cuda.Stream(non_blocking=True)
with s:
s.begin_capture()
try:
step_fn()
except Exception:
try: s.end_capture()
except Exception: pass
raise
g = s.end_capture()
return g
def run(mode, use_sdf, progress=None):
"""mode: none|amr2x|nested. Возвращает {Fx,Fy,uy (host), frames (host), DL, mode}.
progress(t, STEPS) — необязательный колбэк. CUDA Graphs: env AMR_CUDAGRAPH (по умолч. вкл)."""
use1 = mode in ("amr2x", "nested"); use2 = (mode == "nested")
DL = {"none": D0, "amr2x": 2*D0, "nested": 4*D0}[mode]
# ---- persistent-состояние (фиксированные буферы для replay графа) ----
F0 = feq(xp.ones((Ny, Nx), DTYPE), xp.zeros((2, Ny, Nx), DTYPE)).copy()
F1 = feq(xp.ones((P1["Nfy"], P1["Nfx"]), DTYPE), xp.zeros((2, P1["Nfy"], P1["Nfx"]), DTYPE)).copy() if use1 else None
F2 = feq(xp.ones((P2["Nfy"], P2["Nfx"]), DTYPE), xp.zeros((2, P2["Nfy"], P2["Nfx"]), DTYPE)).copy() if use2 else None
onecol = xp.ones((Ny, 1), DTYPE)
uin_dev = xp.zeros((2, Ny, 1), DTYPE) # вход; обновляется СНАРУЖИ захвата
Fx_s = xp.zeros((), DTYPE); Fy_s = xp.zeros((), DTYPE); uy_s = xp.zeros((), DTYPE)
# разгон входа на device: U*smoothstep(t/ramp)
_ts = np.minimum(np.arange(STEPS+1)/ramp, 1.0)
ramp_u = xp.asarray(U*(_ts*_ts*(3-2*_ts)), DTYPE)
Fx = xp.zeros(STEPS+1, DTYPE); Fy = xp.zeros(STEPS+1, DTYPE); uy = xp.zeros(STEPS+1, DTYPE)
frames = []
def _step():
# ----- уровень 0 -----
rho, u0 = macros(F0)
pre = F0.copy()
post0 = collide(F0, feq(rho, u0), b0)
if not use_sdf:
f0 = stream(bb_set(post0.copy(), pre, g_solid0))
else:
f0 = stream(post0); f0 = apply_bc(f0, post0, BC0)
if mode == "none":
fx, fy = (force_bc(post0, f0, BC0) if use_sdf else force_on(post0, f0, L0_links))
Fx_s[...] = fx; Fy_s[...] = fy
# вход/выход (разгон читаем из uin_dev)
f0[:, 1:-1, 0] = feq(onecol, uin_dev)[:, 1:-1, 0]; f0[:, 1:-1, -1] = f0[:, 1:-1, -2]
# ----- уровень 1 -----
if use1:
g1o = ghost(pre, P1, R01); g1n = ghost(f0, P1, R01)
f1 = F1
for s1 in range(2):
pre1 = f1.copy(); r1, u1 = macros(f1); post1 = collide(f1, feq(r1, u1), b1)
if not use_sdf:
f1 = stream(bb_set(post1.copy(), pre1, g_solid1))
else:
f1 = stream(post1); f1 = apply_bc(f1, post1, BC1)
if mode == "amr2x" and s1 == 1:
fx, fy = (force_bc(post1, f1, BC1) if use_sdf else force_on(post1, f1, L1_links))
Fx_s[...] = fx; Fy_s[...] = fy
f1 = fill(f1, (1-(s1+1)/2)*g1o + ((s1+1)/2)*g1n)
# ----- уровень 2 -----
if use2:
g2o = ghost(pre1, P2, R12); g2n = ghost(f1, P2, R12)
f2 = F2
for s2 in range(2):
pre2 = f2.copy(); r2, u2 = macros(f2); post2 = collide(f2, feq(r2, u2), b2)
if not use_sdf:
f2 = stream(bb_set(post2.copy(), pre2, g_solid2))
else:
f2 = stream(post2); f2 = apply_bc(f2, post2, BC2)
if s1 == 1 and s2 == 1:
fx, fy = (force_bc(post2, f2, BC2) if use_sdf else force_on(post2, f2, L2_links))
Fx_s[...] = fx; Fy_s[...] = fy
f2 = fill(f2, (1-(s2+1)/2)*g2o + ((s2+1)/2)*g2n)
f1 = restrict(f2, f1, P2, Rf12, fl2)
F2[...] = f2
f0 = restrict(f1, f0, P1, Rf01, fl1)
F1[...] = f1
# зонд скорости в следе + запись состояния
col = f0[:, py, px]; uy_s[...] = (CYa*col).sum()/col.sum()
F0[...] = f0
use_graph = (os.environ.get("AMR_CUDAGRAPH", "1") != "0") # CUDA Graphs по умолчанию ВКЛ (AMR_CUDAGRAPH=0 — выкл)
graph = None; graph_failed = False
def _snap():
return (F0.copy(), F1.copy() if use1 else None, F2.copy() if use2 else None)
def _restore(s):
F0[...] = s[0]
if use1: F1[...] = s[1]
if use2: F2[...] = s[2]
def _maxdiff(s):
d = float(xp.abs(s[0]-F0).max())
if use1: d = max(d, float(xp.abs(s[1]-F1).max()))
if use2: d = max(d, float(xp.abs(s[2]-F2).max()))
return d
for t in range(STEPS+1):
uin_dev[0, :, 0] = ramp_u[t]
did = False
if t == 1 and use_graph and not graph_failed:
# захват графа + РАНТАЙМ-САМОПРОВЕРКА: один шаг графом vs eager; при расхождении — откат
try:
graph = _capture(_step) # запись (без исполнения)
before = _snap() # состояние после t=0
graph.launch() # граф -> результат t=1
gres = _snap()
_restore(before); _step() # eager -> доверенный результат t=1
d = _maxdiff(gres)
if d > 1e-3:
print(f"\n[graph] self-check разошёлся (Δ={d:.2e}) — отключаю графы, работаю eager")
graph = None; graph_failed = True
else:
print(f"\n[graph] self-check OK (Δ={d:.2e}) — CUDA graphs активны")
did = True # шаг t=1 уже выполнен (eager-результат — доверенный)
except Exception as e:
print(f"\n[graph] недоступны ({type(e).__name__}: {e}) — работаю eager")
import traceback as _tb; _tb.print_exc() # точная строка H2D/несовместимости
graph = None; graph_failed = True
try: cp.cuda.Device().synchronize()
except Exception: pass
if not did:
if graph is not None and t >= 1:
graph.launch()
else:
_step()
Fx[t] = Fx_s; Fy[t] = Fy_s; uy[t] = uy_s
if progress is not None:
progress(t, STEPS)
if t % 500 == 0 and not bool(xp.isfinite(F0).all()):
print("BLEW UP", mode, t); Fx = Fx[:t]; Fy = Fy[:t]; uy = uy[:t]; break
if t % FE == 0:
frames.append((t, to_cpu(macros(F0)[1]),
to_cpu(macros(F1)[1]) if use1 else None,
to_cpu(macros(F2)[1]) if use2 else None))
return dict(mode=mode, DL=DL, Fx=to_cpu(Fx), Fy=to_cpu(Fy), uy=to_cpu(uy), frames=frames)
# ---- анализ (на host: FFT малых рядов) ----
def analyze(res):
Fx, Fy, uy, DL = res["Fx"], res["Fy"], res["uy"], res["DL"]
n = len(uy); h = slice(n//2, n); Cd = 2*Fx/(U**2*DL); Cl = 2*Fy/(U**2*DL)
sig = uy[h] - uy[h].mean(); npad = 8192
freqs = np.fft.rfftfreq(npad, 1.0); amp = np.abs(np.fft.rfft(sig, n=npad))
St = (freqs[1+int(np.argmax(amp[1:]))] if len(amp) > 2 else 0.0)*D0/U
return dict(Cd=float(Cd[h].mean()), Clrms=float(Cl[h].std()), St=float(St), uyrms=float(uy[h].std()),
Cl_s=Cl, uy=uy, freqs=freqs, amp=amp, h0=n//2)
# ---- визуализация (matplotlib на CPU; кадры уже host) ----
def _vort_np(u):
return (np.roll(u[1], -1, 1)-np.roll(u[1], 1, 1))*0.5 - (np.roll(u[0], -1, 0)-np.roll(u[0], 1, 0))*0.5
def save_gif(res, name, anim_dir, sdf_tag, fps=24):
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt, matplotlib.patches as mpatches
from PIL import Image
nested = (res["mode"] == "nested"); has1 = res["mode"] in ("amr2x", "nested"); frames = res["frames"]
vm = np.nanpercentile(np.abs(np.where(solid0_np, np.nan, _vort_np(frames[len(frames)//2][1]))), 99.0)
cm = plt.get_cmap("inferno").copy(); cm.set_bad("white"); pil = []
for (t, u0, u1, u2) in frames:
fig = plt.figure(figsize=(11.2, 5.7), dpi=96); ax = fig.add_subplot(111)
ax.imshow(np.abs(np.where(solid0_np, np.nan, _vort_np(u0))), origin="lower", cmap=cm, vmin=0, vmax=vm,
extent=(0, Nx, 0, Ny), interpolation="nearest")
if has1 and u1 is not None:
ax.imshow(np.abs(np.where(solid1_np, np.nan, _vort_np(u1)))[1:-1, 1:-1], origin="lower", cmap=cm, vmin=0, vmax=vm,
extent=(ax1+0.5, bx1-0.5, ay1+0.5, by1-0.5), interpolation="nearest")
if nested and u2 is not None:
ax.imshow(np.abs(np.where(solid2_np, np.nan, _vort_np(u2)))[1:-1, 1:-1], origin="lower", cmap=cm, vmin=0, vmax=vm,
extent=(cx2a+0.25, cx2b-0.25, cy2a+0.25, cy2b-0.25), interpolation="nearest")
ax.add_patch(mpatches.Rectangle((0.2, 0.2), Nx-0.4, Ny-0.4, fill=False, edgecolor="#4fc3f7", lw=1.6))
ax.text(1.5, Ny-4, "уровень 0: Δx (крупный)", color="#4fc3f7", fontsize=9, weight="bold")
if has1:
ax.add_patch(mpatches.Rectangle((ax1, ay1), bx1-ax1, by1-ay1, fill=False, edgecolor="#aed581", lw=2.2))
ax.text(ax1+1, by1-3.5, "уровень 1: Δx/2 (тело+след)", color="#aed581", fontsize=9, weight="bold")
if nested:
ax.add_patch(mpatches.Rectangle((cx2a, cy2a), cx2b-cx2a, cy2b-cy2a, fill=False, edgecolor="#ff8a65", lw=2.2))
ax.text(cx2a+1, cy2b-3.5, "уровень 2: Δx/4 (у тела)", color="#ff8a65", fontsize=9, weight="bold")
base = ("Вложенный AMR" if nested else "AMR (одиночный блок 2×)") if has1 else "Без AMR (D=16)"
ax.set_title(f"{base}{sdf_tag} · поверх $|\\omega|$ · t={t}", fontsize=11)
ax.set_xlabel("x [lu]"); ax.set_ylabel("y [lu]"); ax.set_xlim(0, Nx); ax.set_ylim(0, Ny)
buf = _io.BytesIO(); fig.savefig(buf, format="png", bbox_inches="tight"); buf.seek(0); plt.close(fig)
pil.append(Image.open(buf).convert("RGB").convert("P", palette=Image.ADAPTIVE))
path = os.path.join(anim_dir, name)
pil[0].save(path, save_all=True, append_images=pil[1:], duration=int(1000/fps), loop=0, optimize=True)
print("saved", name, len(frames), "кадров")
LAB = ["без AMR (D=16)", "AMR 2× (D=32)", "AMR 2×+4× (D=64)"]
def print_table(AS, header):
print(f"\n=== {header} ===")
print(f"{'версия':22}{'D у тела':>9}{'St':>8}{'<Cd>':>9}{'rms Cl':>9}{'rms u_y':>9}")
for lab, DL, a in zip(LAB, [16, 32, 64], AS):
print(f"{lab:22}{DL:>9}{a['St']:>8.3f}{a['Cd']:>9.3f}{a['Clrms']:>9.4f}{a['uyrms']:>9.4f}")
print(f"{'литература Re≈150':22}{'—':>9}{0.183:>8.3f}{1.330:>9.3f}{'~0.3':>9}{'':>9}")
print("(лит.: St≈0.18, Cd≈1.3 для цилиндра при Re≈150; Williamson 1996, Henderson 1995)")
def probe_figure(AS, R2, path, suptitle):
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt, matplotlib.patches as mpatches
cols = ["#9e9e9e", "#1f6feb", "#d1495b"]
fig = plt.figure(figsize=(13.5, 8.2)); gs = fig.add_gridspec(2, 2, hspace=0.32, wspace=0.22)
axA = fig.add_subplot(gs[0, 0]); mid = R2["frames"][len(R2["frames"])//2]
vmf = np.nanpercentile(np.abs(np.where(solid0_np, np.nan, _vort_np(mid[1]))), 99)
cmf = plt.get_cmap("inferno").copy(); cmf.set_bad("white")
axA.imshow(np.abs(np.where(solid0_np, np.nan, _vort_np(mid[1]))), origin="lower", cmap=cmf, vmin=0, vmax=vmf,
extent=(0, Nx, 0, Ny), interpolation="nearest")
axA.add_patch(mpatches.Rectangle((ax1, ay1), bx1-ax1, by1-ay1, fill=False, edgecolor="#aed581", lw=1.8))
axA.add_patch(mpatches.Rectangle((cx2a, cy2a), cx2b-cx2a, cy2b-cy2a, fill=False, edgecolor="#ff8a65", lw=1.8))
axA.set_title("$|\\omega|$ + рамки зон 2×/4×"); axA.set_xlabel("x [lu]"); axA.set_ylabel("y [lu]")
axB = fig.add_subplot(gs[0, 1])
for a, lab, c in zip(AS, LAB, cols): axB.plot(a["Cl_s"][a["h0"]:], lw=0.8, color=c, label=lab)
axB.set_title("Подъёмная сила $C_l(t)$ (зонд силы)"); axB.set_xlabel("шаг"); axB.set_ylabel("$C_l$")
axB.legend(fontsize=8); axB.grid(alpha=0.3)
axC = fig.add_subplot(gs[1, 0])
for a, lab, c in zip(AS, LAB, cols):
sp = a["amp"]/a["amp"][1:].max(); axC.plot(a["freqs"]*D0/U, sp, lw=1.0, color=c, label=lab)
axC.axvline(0.183, color="k", ls="--", alpha=0.6, label="лит. St≈0.18"); axC.set_xlim(0, 0.6)
axC.set_title("Спектр $u_y$ в следе → St"); axC.set_xlabel("St = f·D/U"); axC.set_ylabel("норм. ампл.")
axC.legend(fontsize=8); axC.grid(alpha=0.3)
axD = fig.add_subplot(gs[1, 1]); x = np.arange(3); w = 0.35
axD.bar(x-w/2, [a["St"] for a in AS], w, color="#1f6feb", label="St")
axD.bar(x+w/2, [a["Cd"] for a in AS], w, color="#fb8c00", label="<Cd>")
axD.axhline(0.183, color="#1f6feb", ls="--", alpha=0.6); axD.axhline(1.33, color="#fb8c00", ls="--", alpha=0.6)
axD.set_xticks(x); axD.set_xticklabels(["без AMR", "2×", "2×+4×"]); axD.set_title("St и <Cd> (пунктир — лит.)")
axD.legend(fontsize=8); axD.grid(alpha=0.3, axis="y")
fig.suptitle(suptitle, fontweight="bold"); fig.savefig(path, dpi=110, bbox_inches="tight"); plt.close(fig)
print("saved", os.path.basename(path))