Files
CFDManager/docs/theory/demos_gpu/checks_gpu.py
T
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

171 lines
10 KiB
Python
Raw 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.
# Самопроверки математики НА КОМПОНЕНТАХ ФИНАЛЬНОЙ МОДЕЛИ (solver_2x_sdf): решётка,
# энтропийное равновесие, KBC-проектор, предел γ=2 ≡ BGK. Результаты → out/checks.txt
# (ноутбук цитирует их в markdown). Запуск: python checks_gpu.py [AMR_FP64=1 — жёстче пороги].
import numpy as np
import _common as cm
cp = cm.cp; xp = cm.xp; L = cm.L; E = cm.E; Coll = cm.Coll
Q = cm.Q; CS2 = cm.CS2; TOL = cm.TOL_EXACT
lines = [f"backend={cm.B.BACKEND}; порог точных тождеств TOL={TOL:g}"]
def check(name, value, tol):
ok = value < tol
lines.append(f"{'PASS' if ok else 'FAIL'} {name}: {value:.3e} (tol {tol:g})")
print(lines[-1])
assert ok, name
return ok
def check_gt(name, value, floor):
"""Для величин, которые обязаны быть НЕ малы (иначе модель выродилась)."""
ok = value > floor
lines.append(f"{'PASS' if ok else 'FAIL'} {name}: {value:.3e} (должно быть > {floor:g})")
print(lines[-1])
assert ok, name
return ok
# --- 1. Решётка D2Q9: тождества весов и изотропии ---------------------------------------------
# Допуск по типу: Wa хранится в DTYPE, и в float32 уже само Σw отличается от 1 на ~7e-9,
# так что жёсткий 1e-12 здесь означал бы падение проверки в режиме по умолчанию.
TOL_W = 1e-12 if cm.FP64 else 1e-6
C = np.array(list(zip(L.Cx, L.Cy)), float)
W = cm.to_cpu(L.Wa).astype(float)
check("Σw − 1", abs(W.sum() - 1.0), TOL_W)
check("|Σ w·c|", np.abs((W[:, None]*C).sum(0)).max(), TOL_W)
check("|Σ w·c⊗c − cs²·I|",
np.abs((W[:, None, None]*C[:, :, None]*C[:, None, :]).sum(0) - CS2*np.eye(2)).max(), TOL_W)
assert np.all(C[list(L.OPP)] == -C), "OPP: c_opp != -c"
lines.append("PASS OPP: c_opp = −c (точно)")
# --- 2. Энтропийное равновесие (product-form) точно воспроизводит ρ и ρu -----------------------
rng = np.random.default_rng(0)
rho_t = xp.asarray(1.0 + 0.1*rng.standard_normal((16, 16)), cm.DTYPE)
u_t = xp.asarray(0.06*rng.standard_normal((2, 16, 16)), cm.DTYPE)
fe = E.feq(rho_t, u_t)
rho_m, u_m = E.macros(fe)
check("feq: |Σf − ρ|", float(xp.abs(rho_m - rho_t).max()), TOL)
check("feq: |u(f) − u|", float(xp.abs(u_m - u_t).max()), TOL)
Pxx = (L.CXa[:, None, None]**2 * fe).sum(0)
check("feq: |Σc_x²f − ρ(cs²+u_x²)| (мал при малом Ma)",
float(xp.abs(Pxx - rho_t*(CS2 + u_t[0]**2)).max()), 5e-3)
# --- 3. KBC-проектор Ps: идемпотентность и состав сдвиг-моментов --------------------------------
Ps = cm.to_cpu(L.Ps).astype(float)
check("Ps идемпотентен: |Ps·Ps − Ps|", np.abs(Ps @ Ps - Ps).max(), 1e-5 if not cm.FP64 else 1e-12)
# моментная матрица (как в lattice._build_projector): Δs несёт ТОЛЬКО {cx²−cy², cx·cy}
cx, cy = C[:, 0], C[:, 1]
M = np.zeros((Q, Q))
M[0] = 1.0; M[1] = cx; M[2] = cy; M[3] = 3*(cx**2 + cy**2) - 2
M[4] = cx**2 - cy**2; M[5] = cx*cy
M[6] = cx**2*cy; M[7] = cx*cy**2; M[8] = cx**2*cy**2
dfn = rng.standard_normal((Q, 5, 7))
ds = np.einsum("ij,jab->iab", Ps, dfn)
mds = np.einsum("ai,ijk->ajk", M, ds)
other = [0, 1, 2, 3, 6, 7, 8]
check("Ps: |моменты Δs вне {N, Πxy}|", np.abs(mds[other]).max(), 1e-4 if not cm.FP64 else 1e-10)
lines.append("PASS состав KBC-N1: s = {Πxx−Πyy, Πxy}, h = остальное (k отщеплён точно)")
# --- 4. Предел γ=2: KBC-форма с γ=2 совпадает с BGK (через РЕАЛЬНЫЙ проектор модели) ------------
f0 = fe + xp.asarray(0.01*rng.standard_normal((Q, 16, 16)), cm.DTYPE)
r0, u0 = E.macros(f0)
fe0 = E.feq(r0, u0)
beta = 0.7
dfn0 = f0 - fe0
ds0 = Coll._project_shift(dfn0)
fk2 = f0 - beta*(2.0*ds0 + 2.0*(dfn0 - ds0)) # KBC с γ=2
fbgk = cm.bgk_collide(f0, fe0, beta)
check("KBC(γ=2) ≡ BGK", float(xp.abs(fk2 - fbgk).max()), TOL)
# --- 5. kbc_collide сохраняет ρ и импульс -------------------------------------------------------
fpost = Coll.kbc_collide(f0, fe0, beta)
r1, u1 = E.macros(fpost)
check("KBC: |Δρ| за столкновение", float(xp.abs(r1 - r0).max()), TOL)
check("KBC: |Δ(ρu)| за столкновение", float(xp.abs(u1*r1[None] - u0*r0[None]).max()), TOL)
# --- 6. Стабилизатор γ ДЕЙСТВИТЕЛЬНО считается на физическом поле ------------------------------
# Регрессия на дефект от 2026-08-14: абсолютный порог знаменателя (1e-6 в fp32) отправлял в откат
# γ←2 77–99% узлов, т.е. модель молча вырождалась в LBGK. Проверяем на РАЗВИТОМ поле (сдвиговый
# слой после прогрева), что откат не срабатывает и γ не залипает на 2.
Strm = cm.Strm
Nsl = 64; u0sl = 0.04; beta_sl = cm.nu_to_beta(u0sl*Nsl/5000.0)
yy = xp.arange(Nsl, dtype=cm.DTYPE)[:, None] * xp.ones((1, Nsl), cm.DTYPE)
xx = xp.ones((Nsl, 1), cm.DTYPE) * xp.arange(Nsl, dtype=cm.DTYPE)[None, :]
uxsl = xp.where(yy <= Nsl/2, u0sl*xp.tanh(40.0*(yy/Nsl - 0.25)), u0sl*xp.tanh(40.0*(0.75 - yy/Nsl)))
uysl = 0.05*u0sl*xp.sin(2.0*np.pi*(xx/Nsl + 0.25))
fsl = E.feq(xp.ones((Nsl, Nsl), cm.DTYPE), cm.B.pack((uxsl, uysl)))
for _ in range(400):
rsl, usl = E.macros(fsl)
fsl = Strm.stream(Coll.kbc_collide(fsl, E.feq(rsl, usl), beta_sl))
rsl, usl = E.macros(fsl); fesl = E.feq(rsl, usl)
dfs = fsl - fesl; dss = Coll._project_shift(dfs); dhs = dfs - dss; invs = 1.0/fesl
den_s = (dhs*dhs*invs).sum(0); nrm_s = (dfs*dfs*invs).sum(0)
frac_fb = float((den_s <= cm.B.GREL*nrm_s).mean())
check("γ: доля узлов в откате γ←2 на развитом поле", frac_fb, 1e-3)
gam = cm.kbc_gamma(fsl, fesl, beta_sl)
# при полном вырождении в LBGK эта величина строго 0, при частичном (90% узлов) — ~0.01,
# наблюдаемое значение ~0.13, поэтому порог 0.05 ловит регресс с запасом и не флапает
check_gt("γ: ⟨|γ−2|⟩ — отличие от LBGK", float(xp.abs(gam - 2.0).mean()), 0.05)
lines.append(f"INFO γ на развитом поле: ⟨γ⟩={float(gam.mean()):.4f} "
f"[{float(gam.min()):.3f}, {float(gam.max()):.3f}], min den/‖Δ‖²="
f"{float((den_s/xp.maximum(nrm_s, 1e-30)).min()):.2e} (порог {cm.B.GREL:g})")
print(lines[-1])
# --- 7. Замкнутая формула γ ур.(17)/(39) против корня точного ур.(15) --------------------------
# Берём один узел развитого поля и решаем Σ Δh·ln(1 + ((1−βγ)Δh − (2β−1)Δs)/feq) = 0 бисекцией.
iy, ix = Nsl//3, Nsl//3
fe_n = cm.to_cpu(fesl[:, iy, ix]).astype(float)
ds_n = cm.to_cpu(dss[:, iy, ix]).astype(float); dh_n = cm.to_cpu(dhs[:, iy, ix]).astype(float)
g_formula = float(cm.to_cpu(gam[iy, ix]))
def _crit(g):
return float((dh_n*np.log(1.0 + ((1.0 - beta_sl*g)*dh_n - (2.0*beta_sl - 1.0)*ds_n)/fe_n)).sum())
lo, hi = g_formula - 2.0, g_formula + 2.0
while _crit(lo)*_crit(hi) > 0 and hi - lo < 40:
lo -= 1.0; hi += 1.0
for _ in range(200):
mid = 0.5*(lo + hi)
if _crit(lo)*_crit(mid) <= 0: hi = mid
else: lo = mid
check("γ: |формула − точный корень ур.(15)|", abs(g_formula - 0.5*(lo + hi)), 5e-2)
# --- 8. Гидродинамический предел: ν по затуханию сдвиговой волны --------------------------------
# Единственная проверка, которая ловит подмену оператора: LBGK и KBC дают одну ν, но неверная
# релаксация сдвиг-моментов сразу видна здесь.
Nw = 48; kw = 2.0*np.pi/Nw; NSW = 1500
sinw = xp.sin(kw*xp.arange(Nw, dtype=cm.DTYPE))
for tau_w in (0.6, 1.0):
beta_w = 1.0/(2.0*tau_w); nu_th = CS2*(tau_w - 0.5)
# амплитуда 1e-2 (Ma≈0.017, режим ещё линейный): в float32 при 1e-4 сигнал тонет в округлении
uxw = 0.01*sinw[:, None]*xp.ones((1, 4), cm.DTYPE)
fw = E.feq(xp.ones((Nw, 4), cm.DTYPE), cm.B.pack((uxw, xp.zeros_like(uxw))))
amps = []
for _ in range(NSW + 1):
rw, uw = E.macros(fw)
amps.append(abs(float((uw[0][:, 0]*sinw).sum()))) # проекция на моду, без abs поэлементно
fw = Strm.stream(Coll.kbc_collide(fw, E.feq(rw, uw), beta_w))
a = np.array(amps); i0 = NSW//3
rate = -np.polyfit(np.arange(i0, NSW + 1), np.log(a[i0:]), 1)[0]
# допуск 1e-2 покрывает решёточную дисперсию O(k²) (k=0.131 → ~3e-3)
check(f"ν(τ={tau_w}): отн. ошибка затухания сдвиговой волны", abs(rate/(kw*kw)/nu_th - 1.0), 1e-2)
# --- 9. ГУ канала: вход и выход не связаны заворотом roll (включая углы) ------------------------
# Регрессия на дефект от 2026-08-14: Zou-He стоял на срезе 1:-1, и в 4 углах stream() через roll
# сносил популяции прямо из столбца выхода в столбец входа.
import boundary as Bnd
Ny_b, Nx_b = 24, 32
fb = E.feq(xp.ones((Ny_b, Nx_b), cm.DTYPE), 0.02*xp.ones((2, Ny_b, Nx_b), cm.DTYPE))
fb = fb*(1.0 + xp.asarray(0.05*rng.standard_normal(fb.shape), cm.DTYPE))
post_b = fb.copy()
uin_b = xp.zeros((2, Ny_b, 1), cm.DTYPE); uin_b[0] = 0.05
fs_b = Bnd.channel_bc(Bnd.free_slip_walls(Strm.stream(post_b)), uin_b, outlet_uy="zero")
r_in = fs_b[:, :, 0].sum(0)
ux_in = (L.CXa[:, None]*fs_b[:, :, 0]).sum(0)/r_in
check("ГУ: |u_x(вход) − заданной| по ВСЕМ рядам, вкл. углы", float(xp.abs(ux_in - 0.05).max()), TOL)
check("ГУ: |ρ(выход) − 1| по ВСЕМ рядам, вкл. углы",
float(xp.abs(fs_b[:, :, -1].sum(0) - 1.0).max()), TOL)
leak = min(float(xp.abs(fs_b[i, row, 0] - post_b[i, row, -1]).max())
for i in (1, 5, 8) for row in (0, -1))
check_gt("ГУ: расхождение углового f с популяцией выхода (заворот подавлен)", leak, 1e-6)
lines.append("=== Все самопроверки пройдены ===")
print(lines[-1])
cm.write_out("checks.txt", lines)