Files
NotBigGhostandClaude Opus 5 11ff7b79b4 Начальный коммит: 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>
2026-08-14 16:37:38 +03:00

192 lines
12 KiB
Python
Raw Permalink 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.
# Оркестратор модели 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)