Начальный коммит: 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
+191
View File
@@ -0,0 +1,191 @@
# Оркестратор модели 2×+SDF: держит persistent-буферы, собирает один шаг из компонентов и гоняет цикл.
# Один шаг = замкнутая step() над фикс. буферами без host→device (готова к захвату в CUDA-граф).
import math
import backend as B
import lattice as L
import geometry as G
import equilibrium as E
import collision as Coll
import streaming as Strm
import boundary as Bnd
import forces as Fz
import forces_stress as FzS
import amr as Amr
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE; to_cpu = B.to_cpu
CYa = L.CYa
def _capture(step_fn):
"""Захват одного шага в CUDA-граф. При сбое гарантированно выводит поток из режима записи."""
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
return s.end_capture()
class Solver:
def __init__(self, cfg):
self.cfg = cfg
self.collide = Coll.collide # KBC-N1 (единственный оператор столкновения)
self.force = Fz.force_gmem # галилей-инвариантный обмен импульсом
self.geo = G.build(cfg)
self.P1 = self.geo.P1
self.beta1 = cfg.beta1
if cfg.sponge_len:
# губка: τ(x) = τ0 + (mult−1)·ν·s(x)/cs², s — smoothstep 0→1 в последних sponge_len
# столбцах. β становится ПОЛЕМ (Nx,) → broadcast в kbc_collide; патч L1 губки не видит
# (гарантировано assert'ом в config), там остаётся скалярный beta1.
xs = cp.arange(cfg.Nx, dtype=DTYPE)
s = cp.clip((xs - (cfg.Nx - 1 - cfg.sponge_len)) / float(cfg.sponge_len), 0.0, 1.0)
s = s*s*(3.0 - 2.0*s)
tau_x = cfg.tau0 + (cfg.sponge_nu_mult - 1.0) * cfg.nu / (1.0/3.0) * s
self.beta0 = (1.0 / (2.0 * tau_x)).astype(DTYPE)[None, :] # (1,Nx) → broadcast по y
else:
self.beta0 = cfg.beta0
self.R01 = cfg.R01; self.Rf01 = cfg.Rf01
self.refine = cfg.refine
# ЗОНД скорости в следе: физическая точка (px,py) в координатах L0.
# Берём её с ТОНКОЙ сетки L1 (меньше численного размытия вихрей → чище St / rms u_y),
# если точка внутри патча; иначе fallback на L0. Индексы фиксированы → graph-safe.
self.probe_l1 = (cfg.ax1 <= cfg.px <= cfg.bx1) and (cfg.ay1 <= cfg.py <= cfg.by1)
if self.probe_l1:
self.pfx = (cfg.px - cfg.ax1) * cfg.refine
self.pfy = (cfg.py - cfg.ay1) * cfg.refine
else:
self.pfx = cfg.px; self.pfy = cfg.py
# ГУ (SDF/Bouzidi) на L0 (цилиндр+стенки) и L1 (цилиндр)
self.BC0 = Bnd.build_bc(self.geo.solid0, self.geo.phi0, self.geo.cyl0)
self.BC1 = Bnd.build_bc(self.geo.solid1, self.geo.phi1, self.geo.solid1)
self.fl1 = self.geo.fl1
# persistent-состояние
Ny, Nx = cfg.Ny, cfg.Nx; Nfy, Nfx = self.P1["Nfy"], self.P1["Nfx"]
self.F0 = E.feq(xp.ones((Ny, Nx), DTYPE), xp.zeros((2, Ny, Nx), DTYPE)).copy()
self.F1 = E.feq(xp.ones((Nfy, Nfx), DTYPE), xp.zeros((2, Nfy, Nfx), DTYPE)).copy()
self.onecol = xp.ones((Ny, 1), DTYPE)
self.uin = xp.zeros((2, Ny, 1), DTYPE) # вход; обновляется СНАРУЖИ захвата
self.Fx_s = xp.zeros((), DTYPE); self.Fy_s = xp.zeros((), DTYPE); self.uy_s = xp.zeros((), DTYPE)
self.Tz_s = xp.zeros((), DTYPE)
# координаты узлов L1 относительно центра тела — плечи торка (прекомпьют вне захвата)
ccx1 = (cfg.cx - cfg.ax1) * cfg.refine; ccy1 = (cfg.cy - cfg.ay1) * cfg.refine
Yf, Xf = cp.meshgrid(cp.arange(Nfy), cp.arange(Nfx), indexing="ij")
self.rxy1 = ((Xf - ccx1).astype(DTYPE), (Yf - ccy1).astype(DTYPE))
# контур кросс-чека силы σ·n (forces_stress; считается редко, вне CUDA-графа)
self.sprobe = FzS.build_probe(cfg) if cfg.stress_every else None
# разгон входа на device: U*smoothstep(t/ramp)
ts = cp.minimum(cp.arange(cfg.steps + 1) / cfg.ramp, 1.0)
self.ramp_u = (cfg.U * (ts*ts*(3 - 2*ts))).astype(DTYPE)
# стартовое возмущение: поперечный sin-импульс u_y входа в окне [ramp, ramp+pert_dur] (триггер схода)
uyp = cp.zeros(cfg.steps + 1, DTYPE)
if cfg.pert_dur > 0 and cfg.pert_amp != 0.0:
k = cp.arange(cfg.steps + 1)
inw = (k >= cfg.ramp) & (k < cfg.ramp + cfg.pert_dur)
ph = (k - cfg.ramp).astype(DTYPE) / float(cfg.pert_dur)
uyp = cp.where(inw, (cfg.pert_amp * cfg.U) * cp.sin(math.pi * ph), 0.0).astype(DTYPE)
self.ramp_uy = uyp
# ⟨ρ⟩ — диагностика дрейфа массы. Считать ТОЛЬКО по жидкости: узлы внутри цилиндра —
# фиктивные (их популяции не имеют физического смысла, ГУ их не трогает), а их плотность
# уезжает независимо. При D=12 на 120×60 это 1.6% узлов и смещение ⟨ρ⟩ на −5.4e-4 —
# ровно тот разряд, в котором ⟨ρ⟩ и цитируется в отчётах (1.0000 / 1.001).
self.fluid0 = (~self.geo.solid0).astype(DTYPE)
self._ncells = float(self.fluid0.sum())
def step(self):
F0 = self.F0; F1 = self.F1; P1 = self.P1
feq = E.feq; macros = E.macros; stream = Strm.stream
# ----- уровень 0 -----
rho, u0 = macros(F0)
pre = F0.copy()
post0 = self.collide(F0, feq(rho, u0), self.beta0)
f0 = stream(post0); f0 = Bnd.apply_bc(f0, post0, self.BC0)
# порядок важен: стенки снимают y-заворот roll, затем Zou-He закрывает x-заворот НА ВЕСЬ
# столбец, включая угловые узлы (иначе вход и выход связаны напрямую — см. boundary.py)
f0 = Bnd.free_slip_walls(f0) # верх/низ — скользящие стенки (specular)
f0 = Bnd.channel_bc(f0, self.uin, outlet_uy=self.cfg.outlet_uy) # Zou-He вход/выход
# ----- уровень 1: refine подшагов по времени с временно́й интерполяцией ghost -----
g1o = Amr.ghost(pre, P1, self.R01); g1n = Amr.ghost(f0, P1, self.R01)
f1 = F1
# сила/момент: меряем на КАЖДОМ подшаге L1 и усредняем (анти-алиасинг; раньше — мгновенная
# на последнем подшаге). Средний Cd не меняется, ряды становятся глаже.
fxa = B.ZERO; fya = B.ZERO; tza = B.ZERO
for s1 in range(self.refine):
r1, u1 = macros(f1); post1 = self.collide(f1, feq(r1, u1), self.beta1)
f1 = stream(post1); f1 = Bnd.apply_bc(f1, post1, self.BC1)
fx, fy, tz = self.force(post1, f1, self.BC1, self.rxy1)
fxa = fxa + fx; fya = fya + fy; tza = tza + tz
w = (s1 + 1) / self.refine # временна́я интерполяция ghost: o→n
f1 = Amr.fill(f1, (1 - w)*g1o + w*g1n)
self.Fx_s[...] = fxa / self.refine; self.Fy_s[...] = fya / self.refine
self.Tz_s[...] = tza / self.refine
f0 = Amr.restrict(f1, f0, P1, self.Rf01, self.fl1)
F1[...] = f1
# зонд скорости в следе (с тонкой сетки L1, если точка внутри патча) + запись состояния
psrc = f1 if self.probe_l1 else f0
col = psrc[:, self.pfy, self.pfx]; self.uy_s[...] = (CYa*col).sum() / col.sum()
F0[...] = f0
def run(self, progress=None):
cfg = self.cfg
Fx = xp.zeros(cfg.steps + 1, DTYPE); Fy = xp.zeros(cfg.steps + 1, DTYPE); uy = xp.zeros(cfg.steps + 1, DTYPE)
Tz = xp.zeros(cfg.steps + 1, DTYPE) # момент (торк) вокруг центра тела
rho = xp.zeros(cfg.steps + 1, DTYPE) # ⟨ρ⟩(t) — диагностика дрейфа массы
# кросс-чек σ·n: редкая выборка (каждые stress_every шагов), вне CUDA-графа
nst = (cfg.steps // cfg.stress_every + 1) if cfg.stress_every else 0
Fx_st = xp.zeros(nst, DTYPE); Fy_st = xp.zeros(nst, DTYPE); Tz_st = xp.zeros(nst, DTYPE)
kst = 0
frames = []
graph = None; graph_failed = False
def snap(): return (self.F0.copy(), self.F1.copy())
def restore(s): self.F0[...] = s[0]; self.F1[...] = s[1]
def maxdiff(s): return max(float(xp.abs(s[0] - self.F0).max()), float(xp.abs(s[1] - self.F1).max()))
for t in range(cfg.steps + 1):
self.uin[0, :, 0] = self.ramp_u[t]
self.uin[1, :, 0] = self.ramp_uy[t] # поперечный импульс-триггер (0 вне окна возмущения)
did = False
if t == 1 and cfg.use_cuda_graph and not graph_failed:
# захват + РАНТАЙМ-САМОПРОВЕРКА (граф vs eager на одном шаге); при расхождении — откат
try:
graph = _capture(self.step)
before = snap(); graph.launch(); gres = snap()
restore(before); self.step()
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
except Exception as e:
print(f"\n[graph] недоступны ({type(e).__name__}: {e}) — работаю eager")
import traceback as _tb; _tb.print_exc()
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:
self.step()
Fx[t] = self.Fx_s; Fy[t] = self.Fy_s; Tz[t] = self.Tz_s; uy[t] = self.uy_s
rho[t] = (self.F0.sum(0) * self.fluid0).sum() / self._ncells # ⟨ρ⟩ по жидкости (дрейф массы)
if cfg.stress_every and t % cfg.stress_every == 0:
# eager-вычисление на текущем F1 после launch/step — упорядочено на стриме,
# сам захваченный граф не трогаем (паттерн как у rho[t] выше)
sfx, sfy, stz = FzS.force_stress(self.F1, self.sprobe, cfg.tau1)
Fx_st[kst] = sfx; Fy_st[kst] = sfy; Tz_st[kst] = stz; kst += 1
if progress is not None:
progress(t, cfg.steps)
if t % 500 == 0 and not bool(xp.isfinite(self.F0).all()):
print("BLEW UP", t)
Fx = Fx[:t]; Fy = Fy[:t]; Tz = Tz[:t]; uy = uy[:t]; rho = rho[:t]
Fx_st = Fx_st[:kst]; Fy_st = Fy_st[:kst]; Tz_st = Tz_st[:kst]
break
if cfg.frame_every and t % cfg.frame_every == 0:
frames.append((t, to_cpu(E.macros(self.F0)[1]), to_cpu(E.macros(self.F1)[1])))
return dict(Fx=Fx, Fy=Fy, Tz=Tz, uy=uy, rho=rho, frames=frames, DL=cfg.DL,
Fx_st=Fx_st, Fy_st=Fy_st, Tz_st=Tz_st, stress_every=cfg.stress_every)