Начальный коммит: 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
+241
View File
@@ -0,0 +1,241 @@
# AMR на цилиндре: полный домен 150x72, три версии (без AMR / 2x / вложенный 2x+4x).
# Встроенные зонды: сила на теле (обмен импульсом) -> Cd/Cl; u_y в следе -> Струхаль.
# Визуализация: inferno по |omega|, ТОЛЬКО рамки зон (без линий сетки), подписи осей.
# После прогона — анализ и сравнение трёх версий на реально собранных данных.
import sys, os, io as _io, numpy as np, matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt, matplotlib.patches as mpatches
from PIL import Image
try: sys.stdout.reconfigure(encoding="utf-8")
except Exception: pass
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)
C=np.array([[0,0],[1,0],[0,1],[-1,0],[0,-1],[1,1],[-1,1],[-1,-1],[1,-1]],float)
W=np.array([4/9,1/9,1/9,1/9,1/9,1/36,1/36,1/36,1/36]); CS2=1/3; Q=9
CX,CY=C[:,0],C[:,1]; OPP=np.array([0,3,4,1,2,7,8,5,6])
M=np.zeros((Q,Q)); M[0]=1;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
Dm=np.zeros((Q,Q)); Dm[4,4]=Dm[5,5]=1.0; Ps=np.linalg.inv(M)@Dm@M
def feq(rho,u):
ux=np.clip(u[0],-0.95,0.95); uy=np.clip(u[1],-0.95,0.95)
sx=np.sqrt(1+3*ux**2); sy=np.sqrt(1+3*uy**2); base=rho*(2-sx)*(2-sy)
Bx=((2*ux+sx)/(1-ux))[None]**CX[:,None,None]; By=((2*uy+sy)/(1-uy))[None]**CY[:,None,None]
return W[:,None,None]*base[None]*Bx*By
def macros(f):
r=f.sum(0); return r,np.stack([(CX[:,None,None]*f).sum(0)/r,(CY[:,None,None]*f).sum(0)/r])
def collide(f,fe,beta):
dfn=f-fe; ds=np.einsum("ij,jab->iab",Ps,dfn); dh=dfn-ds; inv=1.0/fe
num=(ds*dh*inv).sum(0); den=(dh*dh*inv).sum(0)
with np.errstate(divide="ignore",invalid="ignore"):
g=np.where(den>1e-14,1/beta-(2-1/beta)*num/den,2.0)
return f-beta*(2*ds+g[None]*dh)
def stream(f):
fs=np.empty_like(f)
for i in range(Q): fs[i]=np.roll(f[i],(int(CY[i]),int(CX[i])),(0,1))
return fs
def smoothstep(x): x=min(max(x,0.0),1.0); return x*x*(3-2*x)
def tauc(tp,r): return r*tp-(r-1)/2
def vort(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 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(int); y0=np.floor(FY).astype(int)
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=x0,y0=y0,x1=x1,y1=y1,
tx=FX-x0,ty=FY-y0,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),np.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"]]=np.where(fluid[None],nv,cur); return pf
def bb(pre,f,solid):
for i in range(Q): f[i,solid]=pre[OPP[i],solid]
return f
def cyl(NX,NY,ccx,ccy,rad,walls=True):
Y,X=np.mgrid[0:NY,0:NX]; m=(X-ccx)**2+(Y-ccy)**2<=rad*rad
if walls: m[0,:]=True; m[-1,:]=True
return m
def force_on(fpost,solid_body):
# обмен импульсом (Mei et al. 2002): по линкам жидкий-узел -> твёрдый сосед,
# F = sum c_i (f_i + f_ī)^post — корректная передача импульса на стенку.
fluid=~solid_body; Fx=0.0; Fy=0.0
for i in range(1,Q):
nb=np.roll(solid_body,(-int(CY[i]),-int(CX[i])),(0,1)) # nb[x]=solid[x+c_i]
link=fluid & nb; s=(fpost[i]+fpost[OPP[i]])[link].sum()
Fx+=CX[i]*s; Fy+=CY[i]*s
return Fx,Fy
# --- полный домен 150x72 (вся сетка в кадре, измельчение внутри) ---
Nx,Ny=150,72; D=16; cx,cy=40,36; U=0.07; Re=150.0; STEPS=8000; ramp=1000; FE=34
D0=D; nu=U*D/Re; tau0=nu/CS2+0.5; b0=1/(2*tau0)
px,py=cx+3*D,cy # зонд в следе
solid0=cyl(Nx,Ny,cx,cy,D/2) # коарс: цилиндр + стенки
solid0_cyl=cyl(Nx,Ny,cx,cy,D/2,walls=False)
# уровень1 (2×, зелёный): тело+след
ax1,bx1,ay1,by1=16,118,8,64; P1=patch(Nx,Ny,ax1,bx1,ay1,by1,2)
t1=tauc(tau0,2); b1=1/(2*t1); R01=t1/(2*tau0); Rf01=1/R01
solid1=cyl(P1["Nfx"],P1["Nfy"],(cx-ax1)*2,(cy-ay1)*2,D,walls=False) # D_L=32
fl1=~solid0[ay1+1:by1,ax1+1:bx1]
# уровень2 (4×, оранжевый): у тела (в коарс-координатах [26:64]x[18:54])
cx2a,cx2b,cy2a,cy2b=26,64,18,54
P2=patch(P1["Nfx"],P1["Nfy"],(cx2a-ax1)*2,(cx2b-ax1)*2,(cy2a-ay1)*2,(cy2b-ay1)*2,2)
t2=tauc(t1,2); b2=1/(2*t2); R12=t2/(2*t1); Rf12=1/R12
solid2=cyl(P2["Nfx"],P2["Nfy"],(cx-cx2a)*4,(cy-cy2a)*4,2*D,walls=False) # D_L=64
fl2=np.ones((P2["by"]-P2["ay"]-1,P2["bx"]-P2["ax"]-1),bool)
onecol=np.ones((Ny,1))
print(f"домен {Nx}x{Ny}; L1(2×) {P1['Nfx']}x{P1['Nfy']}; L2(4×) {P2['Nfx']}x{P2['Nfy']}; "
f"Re={Re} D0={D0} steps={STEPS}")
def run(mode): # "none" | "amr2x" | "nested"
use1=mode in ("amr2x","nested"); use2=(mode=="nested")
DL={"none":D0,"amr2x":2*D0,"nested":4*D0}[mode]
f0=feq(np.ones((Ny,Nx)),np.zeros((2,Ny,Nx)))
f1=feq(np.ones((P1["Nfy"],P1["Nfx"])),np.zeros((2,P1["Nfy"],P1["Nfx"]))) if use1 else None
f2=feq(np.ones((P2["Nfy"],P2["Nfx"])),np.zeros((2,P2["Nfy"],P2["Nfx"]))) if use2 else None
Fx=np.zeros(STEPS+1); Fy=np.zeros(STEPS+1); uy=np.zeros(STEPS+1); frames=[]
for t in range(STEPS+1):
rho,u0=macros(f0)
if not np.all(np.isfinite(u0)): print("BLEW UP",mode,t); Fx=Fx[:t];Fy=Fy[:t];uy=uy[:t]; break
pre=f0.copy(); post0=collide(f0,feq(rho,u0),b0)
if mode=="none": Fx[t],Fy[t]=force_on(post0,solid0_cyl)
f0=stream(bb(pre,post0,solid0))
uin=np.zeros((2,Ny,1)); uin[0,:,0]=U*smoothstep(t/ramp)
f0[:,1:-1,0]=feq(onecol,uin)[:,1:-1,0]; f0[:,1:-1,-1]=f0[:,1:-1,-2]
if use1:
g1o=ghost(pre,P1,R01); g1n=ghost(f0,P1,R01)
for s1 in range(2):
pre1=f1.copy(); r1,u1=macros(f1); post1=collide(f1,feq(r1,u1),b1)
if mode=="amr2x" and s1==1: Fx[t],Fy[t]=force_on(post1,solid1)
f1=stream(bb(pre1,post1,solid1)); f1=fill(f1,(1-(s1+1)/2)*g1o+((s1+1)/2)*g1n)
if use2:
g2o=ghost(pre1,P2,R12); g2n=ghost(f1,P2,R12)
for s2 in range(2):
pre2=f2.copy(); r2,u2=macros(f2); post2=collide(f2,feq(r2,u2),b2)
if s1==1 and s2==1: Fx[t],Fy[t]=force_on(post2,solid2)
f2=stream(bb(pre2,post2,solid2)); f2=fill(f2,(1-(s2+1)/2)*g2o+((s2+1)/2)*g2n)
f1=restrict(f2,f1,P2,Rf12,fl2)
f0=restrict(f1,f0,P1,Rf01,fl1)
uc=macros(f0)[1]; uy[t]=uc[1,py,px]
if use1 and t%FE==0:
frames.append((t,uc.copy(),macros(f1)[1].copy(),(macros(f2)[1].copy() if use2 else None)))
return dict(mode=mode,DL=DL,Fx=Fx,Fy=Fy,uy=uy,frames=frames)
def save_gif(res,name,fps=24):
nested=(res["mode"]=="nested"); frames=res["frames"]
mid=frames[len(frames)//2][1]
vm=np.nanpercentile(np.abs(np.where(solid0,np.nan,vort(mid))),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.nan,vort(u0))),origin="lower",cmap=cm,vmin=0,vmax=vm,
extent=(0,Nx,0,Ny),interpolation="nearest")
o1=np.abs(np.where(solid1,np.nan,vort(u1)))[1:-1,1:-1]
ax.imshow(o1,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:
o2=np.abs(np.where(solid2,np.nan,vort(u2)))[1:-1,1:-1]
ax.imshow(o2,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")
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")
ttl="Вложенный AMR на цилиндре: 2× (след) + 4× (у тела)" if nested else "AMR на цилиндре: одиночный блок 2×"
ax.set_title(f"{ttl} · поверх $|\\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,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),"кадров")
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))
fpk=freqs[1+int(np.argmax(amp[1:]))] if len(amp)>2 else 0.0
St=fpk*D0/U
return dict(Cd=float(Cd[h].mean()),Clrms=float(Cl[h].std()),St=float(St),
uyrms=float(uy[h].std()),Cd_s=Cd,Cl_s=Cl,uy=uy,freqs=freqs,amp=amp,h0=n//2)
# ===== прогон трёх версий =====
print("=== none ==="); R0=run("none")
print("=== amr2x ==="); R1=run("amr2x")
print("=== nested ==="); R2=run("nested")
save_gif(R1,"amr2x_cyl.gif"); save_gif(R2,"amr_nested_cyl.gif")
A0,A1,A2=analyze(R0),analyze(R1),analyze(R2)
LABELS=["без AMR (D=16)","AMR 2× (D=32)","AMR 2×+4× (D=64)"]
AS=[A0,A1,A2]
print("\n=== СРАВНЕНИЕ НА СОБРАННЫХ ДАННЫХ (установившийся режим) ===")
print(f"{'версия':22}{'D у тела':>9}{'St':>8}{'<Cd>':>9}{'rms Cl':>9}{'rms u_y':>9}")
for lab,DL,a in zip(LABELS,[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.33:>9.3f}{'~0.3':>9}")
print("(лит.: St≈0.18, Cd≈1.3 для цилиндра при Re≈150; Williamson 1996, Henderson 1995)")
# ===== фигура сравнения =====
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]),
Cd0=A0['Cd_s'],Cd1=A1['Cd_s'],Cd2=A2['Cd_s'],
Cl0=A0['Cl_s'],Cl1=A1['Cl_s'],Cl2=A2['Cl_s'],
uy0=R0['uy'],uy1=R1['uy'],uy2=R2['uy'])
cols=["#9e9e9e","#1f6feb","#d1495b"]
fig=plt.figure(figsize=(13.5,8.2))
gs=fig.add_gridspec(2,2,hspace=0.32,wspace=0.22)
# поле |omega| вложенного AMR
axA=fig.add_subplot(gs[0,0])
mid=R2["frames"][len(R2["frames"])//2]; vmf=np.nanpercentile(np.abs(np.where(solid0,np.nan,vort(mid[1]))),99)
cmf=plt.get_cmap("inferno").copy(); cmf.set_bad("white")
axA.imshow(np.abs(np.where(solid0,np.nan,vort(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("Вложенный AMR: $|\\omega|$ + рамки зон 2×/4×"); axA.set_xlabel("x [lu]"); axA.set_ylabel("y [lu]")
# Cl(t)
axB=fig.add_subplot(gs[0,1])
for a,lab,c in zip(AS,LABELS,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)
# спектры u_y
axC=fig.add_subplot(gs[1,0])
for a,lab,c in zip(AS,LABELS,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$ в следе → число Струхаля")
axC.set_xlabel("St = f·D/U"); axC.set_ylabel("нормир. амплитуда"); axC.legend(fontsize=8); axC.grid(alpha=0.3)
# столбики St и Cd
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×"],fontsize=9)
axD.set_title("St и <Cd> vs разрешение у тела (пунктир — лит.)"); axD.legend(fontsize=8); axD.grid(alpha=0.3,axis="y")
for i,a in enumerate(AS):
axD.text(i-w/2,a['St']+0.005,f"{a['St']:.3f}",ha="center",fontsize=7)
axD.text(i+w/2,a['Cd']+0.02,f"{a['Cd']:.2f}",ha="center",fontsize=7)
fig.suptitle("AMR на цилиндре: сравнение трёх версий на реально собранных данных зондов",fontweight="bold")
fig.savefig(os.path.join(FIGS,"11e_amr_probe_comparison.png"),dpi=110,bbox_inches="tight"); plt.close(fig)
print("saved figures/11e_amr_probe_comparison.png")
print("DONE")
+244
View File
@@ -0,0 +1,244 @@
# СЛЕДУЮЩАЯ ИТЕРАЦИЯ решателя: тот же AMR (без / 2× / вложенный 2×+4×), но граница тела
# задана ПОЛНОЦЕННЫМ SDF (знаковое поле расстояний) + интерполированное отражение Bouzidi
# (Bouzidi-Firdaouss-Lallemand 2001) — сглаживает ступенчатость криволинейной границы.
# Геометрия/Re/шаги ИДЕНТИЧНЫ _amr_anims.py — отличие ТОЛЬКО в граничном условии, поэтому
# результаты прямо сравнимы. В конце — сводное сравнение с версиями без SDF.
import sys, os, io as _io, numpy as np, matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt, matplotlib.patches as mpatches
from PIL import Image
try: sys.stdout.reconfigure(encoding="utf-8")
except Exception: pass
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)
# ===== проверенное ядро D2Q9 KBC (идентично _amr_anims.py) =====
C=np.array([[0,0],[1,0],[0,1],[-1,0],[0,-1],[1,1],[-1,1],[-1,-1],[1,-1]],float)
W=np.array([4/9,1/9,1/9,1/9,1/9,1/36,1/36,1/36,1/36]); CS2=1/3; Q=9
CX,CY=C[:,0],C[:,1]; OPP=np.array([0,3,4,1,2,7,8,5,6])
M=np.zeros((Q,Q)); M[0]=1;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
Dm=np.zeros((Q,Q)); Dm[4,4]=Dm[5,5]=1.0; Ps=np.linalg.inv(M)@Dm@M
def feq(rho,u):
ux=np.clip(u[0],-0.95,0.95); uy=np.clip(u[1],-0.95,0.95)
sx=np.sqrt(1+3*ux**2); sy=np.sqrt(1+3*uy**2); base=rho*(2-sx)*(2-sy)
Bx=((2*ux+sx)/(1-ux))[None]**CX[:,None,None]; By=((2*uy+sy)/(1-uy))[None]**CY[:,None,None]
return W[:,None,None]*base[None]*Bx*By
def macros(f):
r=f.sum(0); return r,np.stack([(CX[:,None,None]*f).sum(0)/r,(CY[:,None,None]*f).sum(0)/r])
def collide(f,fe,beta):
dfn=f-fe; ds=np.einsum("ij,jab->iab",Ps,dfn); dh=dfn-ds; inv=1.0/fe
num=(ds*dh*inv).sum(0); den=(dh*dh*inv).sum(0)
with np.errstate(divide="ignore",invalid="ignore"):
g=np.where(den>1e-14,1/beta-(2-1/beta)*num/den,2.0)
return f-beta*(2*ds+g[None]*dh)
def stream(f):
fs=np.empty_like(f)
for i in range(Q): fs[i]=np.roll(f[i],(int(CY[i]),int(CX[i])),(0,1))
return fs
def smoothstep(x): x=min(max(x,0.0),1.0); return x*x*(3-2*x)
def tauc(tp,r): return r*tp-(r-1)/2
def vort(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 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(int); y0=np.floor(FY).astype(int)
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=x0,y0=y0,x1=x1,y1=y1,
tx=FX-x0,ty=FY-y0,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),np.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"]]=np.where(fluid[None],nv,cur); return pf
# ===== ПОЛНОЦЕННЫЙ SDF + интерполированное отражение Bouzidi =====
def sdf_circle(NX,NY,ccx,ccy,R):
# знаковое расстояние до окружности: >0 жидкость, <0 внутри тела
Y,X=np.mgrid[0:NY,0:NX]; return np.sqrt((X-ccx)**2+(Y-ccy)**2)-R
def sdf_from_mask(mask):
# общий SDF из произвольной бинарной маски (для не-круглых тел), через EDT
from scipy import ndimage
din=ndimage.distance_transform_edt(mask); dout=ndimage.distance_transform_edt(~mask)
return dout-din-0.5 # >0 жидкость, <0 тело; -0.5 — поверхность между узлами
def add_walls(phi):
NY=phi.shape[0]; Y=np.arange(NY)[:,None]*np.ones((1,phi.shape[1]))
return np.minimum(phi, np.minimum(Y-0.5, (NY-1.5)-Y)) # стенки: поверхность на y=0.5 и Ny-1.5
def build_bc(solid, phi, cyl_mask):
# для каждого направления: линки жидкий->твёрдый, доля стенки q (по SDF),
# есть ли второй жидкий узел (для интерполяции), и принадлежит ли сосед телу (для силы)
fluid=~solid; bc=[]
for i in range(1,Q):
cy_,cx_=int(CY[i]),int(CX[i])
nb_solid=np.roll(solid,(-cy_,-cx_),(0,1)) # solid[x+c_i]
mask=fluid & nb_solid
cl=mask & np.roll(cyl_mask,(-cy_,-cx_),(0,1)) # сосед — тело (не стенка)
phinb=np.roll(phi,(-cy_,-cx_),(0,1))
with np.errstate(divide="ignore",invalid="ignore"):
qq=phi/(phi-phinb) # доля линка до стенки (phi=0)
q=np.where(mask, np.clip(qq,0.02,0.98), 0.5)
xff=mask & (~np.roll(solid,(cy_,cx_),(0,1))) # x_f - c_i — жидкий?
bc.append((i,int(OPP[i]),mask,q,xff,cl))
return bc
def apply_bc(f, fpost, bc):
# Bouzidi (линейный): пристеночный жидкий узел получает корректно отражённую популяцию
for (i,ib,mask,q,xff,cl) in bc:
cy_,cx_=int(CY[i]),int(CX[i])
fi=fpost[i]; fib=fpost[ib]; fiback=np.roll(fpost[i],(cy_,cx_),(0,1)) # f_i в x_f-c_i
near=mask&(q<0.5)&xff; bad=mask&(q<0.5)&(~xff); far=mask&(q>=0.5)
qn=q[near]; f[ib][near]=2*qn*fi[near]+(1-2*qn)*fiback[near]
f[ib][bad]=fi[bad] # запас: нет второго узла -> halfway
qf=q[far]; f[ib][far]=(1/(2*qf))*fi[far]+(1-1/(2*qf))*fib[far]
return f
def force_bc(fpost, f, bc):
# обмен импульсом (Mei et al. 2002) для интерполированной границы: F=sum c_i (f_i^post + f_ī^bc)
Fx=0.0; Fy=0.0
for (i,ib,mask,q,xff,cl) in bc:
s=(fpost[i]+f[ib])[cl].sum(); Fx+=CX[i]*s; Fy+=CY[i]*s
return Fx,Fy
# ===== геометрия (идентична версии без SDF) =====
Nx,Ny=150,72; D=16; cx,cy=40,36; U=0.07; Re=150.0; STEPS=8000; ramp=1000; FE=34
D0=D; nu=U*D/Re; tau0=nu/CS2+0.5; b0=1/(2*tau0); px,py=cx+3*D,cy
# коарс: цилиндр (SDF) + стенки
phi0=add_walls(sdf_circle(Nx,Ny,cx,cy,D/2)); solid0=phi0<0
cyl0=sdf_circle(Nx,Ny,cx,cy,D/2)<0
bc0=build_bc(solid0,phi0,cyl0)
# уровень1 (2×): тело+след
ax1,bx1,ay1,by1=16,118,8,64; P1=patch(Nx,Ny,ax1,bx1,ay1,by1,2)
t1=tauc(tau0,2); b1=1/(2*t1); R01=t1/(2*tau0); Rf01=1/R01
phi1=sdf_circle(P1["Nfx"],P1["Nfy"],(cx-ax1)*2,(cy-ay1)*2,D); solid1=phi1<0
bc1=build_bc(solid1,phi1,solid1); fl1=~solid0[ay1+1:by1,ax1+1:bx1]
# уровень2 (4×): у тела
cx2a,cx2b,cy2a,cy2b=26,64,18,54
P2=patch(P1["Nfx"],P1["Nfy"],(cx2a-ax1)*2,(cx2b-ax1)*2,(cy2a-ay1)*2,(cy2b-ay1)*2,2)
t2=tauc(t1,2); b2=1/(2*t2); R12=t2/(2*t1); Rf12=1/R12
phi2=sdf_circle(P2["Nfx"],P2["Nfy"],(cx-cx2a)*4,(cy-cy2a)*4,2*D); solid2=phi2<0
bc2=build_bc(solid2,phi2,solid2); fl2=np.ones((P2["by"]-P2["ay"]-1,P2["bx"]-P2["ax"]-1),bool)
onecol=np.ones((Ny,1))
print(f"[SDF] домен {Nx}x{Ny}; L1(2×) {P1['Nfx']}x{P1['Nfy']}; L2(4×) {P2['Nfx']}x{P2['Nfy']}; "
f"Re={Re} D0={D0} steps={STEPS}")
def run(mode): # "none" | "amr2x" | "nested"
use1=mode in ("amr2x","nested"); use2=(mode=="nested")
f0=feq(np.ones((Ny,Nx)),np.zeros((2,Ny,Nx)))
f1=feq(np.ones((P1["Nfy"],P1["Nfx"])),np.zeros((2,P1["Nfy"],P1["Nfx"]))) if use1 else None
f2=feq(np.ones((P2["Nfy"],P2["Nfx"])),np.zeros((2,P2["Nfy"],P2["Nfx"]))) if use2 else None
Fx=np.zeros(STEPS+1); Fy=np.zeros(STEPS+1); uy=np.zeros(STEPS+1); frames=[]
for t in range(STEPS+1):
rho,u0=macros(f0)
if not np.all(np.isfinite(u0)): print("BLEW UP",mode,t); Fx=Fx[:t];Fy=Fy[:t];uy=uy[:t]; break
pre=f0.copy(); post0=collide(f0,feq(rho,u0),b0); f0=stream(post0); f0=apply_bc(f0,post0,bc0)
if mode=="none": Fx[t],Fy[t]=force_bc(post0,f0,bc0)
uin=np.zeros((2,Ny,1)); uin[0,:,0]=U*smoothstep(t/ramp)
f0[:,1:-1,0]=feq(onecol,uin)[:,1:-1,0]; f0[:,1:-1,-1]=f0[:,1:-1,-2]
if use1:
g1o=ghost(pre,P1,R01); g1n=ghost(f0,P1,R01)
for s1 in range(2):
pre1=f1.copy(); r1,u1=macros(f1); post1=collide(f1,feq(r1,u1),b1)
f1=stream(post1); f1=apply_bc(f1,post1,bc1)
if mode=="amr2x" and s1==1: Fx[t],Fy[t]=force_bc(post1,f1,bc1)
f1=fill(f1,(1-(s1+1)/2)*g1o+((s1+1)/2)*g1n)
if use2:
g2o=ghost(pre1,P2,R12); g2n=ghost(f1,P2,R12)
for s2 in range(2):
pre2=f2.copy(); r2,u2=macros(f2); post2=collide(f2,feq(r2,u2),b2)
f2=stream(post2); f2=apply_bc(f2,post2,bc2)
if s1==1 and s2==1: Fx[t],Fy[t]=force_bc(post2,f2,bc2)
f2=fill(f2,(1-(s2+1)/2)*g2o+((s2+1)/2)*g2n)
f1=restrict(f2,f1,P2,Rf12,fl2)
f0=restrict(f1,f0,P1,Rf01,fl1)
uc=macros(f0)[1]; uy[t]=uc[1,py,px]
if use1 and t%FE==0:
frames.append((t,uc.copy(),macros(f1)[1].copy(),(macros(f2)[1].copy() if use2 else None)))
return dict(mode=mode,Fx=Fx,Fy=Fy,uy=uy,frames=frames,DL={"none":D0,"amr2x":2*D0,"nested":4*D0}[mode])
def save_gif(res,name,fps=24):
nested=(res["mode"]=="nested"); frames=res["frames"]
vm=np.nanpercentile(np.abs(np.where(solid0,np.nan,vort(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.nan,vort(u0))),origin="lower",cmap=cm,vmin=0,vmax=vm,
extent=(0,Nx,0,Ny),interpolation="nearest")
ax.imshow(np.abs(np.where(solid1,np.nan,vort(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.nan,vort(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")
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")
ttl="Вложенный AMR + SDF: 2×(след)+4×(тело)" if nested else "AMR + SDF: одиночный блок 2×"
ax.set_title(f"{ttl} · граница тела по SDF (Bouzidi) · поверх $|\\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))
pil[0].save(os.path.join(ANIM,name),save_all=True,append_images=pil[1:],duration=int(1000/fps),loop=0,optimize=True)
print("saved",name,len(frames),"кадров")
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)
print("=== SDF none ==="); R0=run("none")
print("=== SDF amr2x ==="); R1=run("amr2x")
print("=== SDF nested ==="); R2=run("nested")
save_gif(R1,"amr2x_cyl_sdf.gif"); save_gif(R2,"amr_nested_cyl_sdf.gif")
AS=[analyze(R0),analyze(R1),analyze(R2)]; LAB=["без AMR (D=16)","AMR 2× (D=32)","AMR 2×+4× (D=64)"]
print("\n=== SDF: СРАВНЕНИЕ НА СОБРАННЫХ ДАННЫХ ===")
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}")
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]))
# ===== сводное сравнение SDF vs без SDF (если есть данные без SDF) =====
nosdf_path=os.path.join(HERE,"_amr_probe_data.npz")
if os.path.exists(nosdf_path) and ("St" in np.load(nosdf_path).files):
N=np.load(nosdf_path); St_n,Cd_n,Cl_n=N["St"],N["Cd"],N["Clrms"]
St_s=np.array([a['St'] for a in AS]); Cd_s=np.array([a['Cd'] for a in AS]); Cl_s=np.array([a['Clrms'] for a in AS])
print("\n=== СВОДНО: SDF vs без SDF ===")
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}{St_n[k]:>11.3f}{St_s[k]:>9.3f}{Cd_n[k]:>11.3f}{Cd_s[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,Cd_n,w,color="#9e9e9e",label="без SDF (ступенч.)")
axs[0].bar(x+w/2,Cd_s,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,St_n,w,color="#9e9e9e",label="без SDF")
axs[1].bar(x+w/2,St_s,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,"11f_sdf_vs_nosdf.png"),dpi=110,bbox_inches="tight"); plt.close(fig)
print("saved figures/11f_sdf_vs_nosdf.png")
else:
print("\n(нет свежего _amr_probe_data.npz со скалярами — сначала запусти _amr_anims.py)")
print("DONE")
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
# ОСНОВНОЙ GPU-решатель (без SDF): цилиндр, три версии (без AMR / 2× / вложенный 2×+4×).
# Математика идентична CPU-прототипу _amr_anims.py; backend — CuPy (GPU) с откатом на numpy.
# Запуск: python amr_cylinder_gpu.py (fp32 по умолчанию)
# AMR_FP64=1 python amr_cylinder_gpu.py (двойная точность)
# AMR_STEPS=2000 python amr_cylinder_gpu.py (короткий прогон)
import os, time, numpy as np
import amr_gpu_core 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"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); print(" none :", round(time.perf_counter()-t0, 1), "s")
R1 = core.run("amr2x", False); print(" amr2x :", round(time.perf_counter()-t0, 1), "s")
R2 = core.run("nested", False); print(" nested :", round(time.perf_counter()-t0, 1), "s")
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)")
+75
View File
@@ -0,0 +1,75 @@
# ОСНОВНОЙ GPU-решатель (с SDF + Bouzidi): цилиндр, три версии (без AMR / 2× / 2×+4×).
# Граница тела — по знаковому полю расстояний + интерполированное отражение (Bouzidi);
# стенки канала — тот же механизм с q=0.5 (обычный halfway). Математика идентична _amr_anims_sdf.py.
# backend — CuPy (GPU) с откатом на numpy. В конце — сводное сравнение с версией без SDF.
# Запуск: python amr_cylinder_sdf_gpu.py (после amr_cylinder_gpu.py — для сводного сравнения)
import os, time, numpy as np
import amr_gpu_core 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"L1(2×) {core.P1['Nfx']}x{core.P1['Nfy']}; L2(4×) {core.P2['Nfx']}x{core.P2['Nfy']}")
def make_progress(label, width=32, every=0.1):
"""Колбэк-индикатор: рисует ASCII-шкалу по выполненным шагам (t из STEPS), обновляется на месте."""
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
t0 = time.perf_counter()
R0 = core.run("none", True, make_progress("none")); print(" none :", round(time.perf_counter()-t0, 1), "s")
R1 = core.run("amr2x", True, make_progress("amr2x")); print(" amr2x :", round(time.perf_counter()-t0, 1), "s")
R2 = core.run("nested", True, make_progress("nested")); print(" nested :", round(time.perf_counter()-t0, 1), "s")
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.py)")
print("DONE (SDF)")
+309
View File
@@ -0,0 +1,309 @@
# Общее GPU-ядро для основных AMR-решателей (D2Q9 KBC) на цилиндре.
# Математика ДОСЛОВНО та же, что в проверенных CPU-прототипах (_amr_anims*.py):
# - энтропийное равновесие (product-form), KBC-N1 столкновение f<-f-β(2Δs+γΔh),
# - AMR-связка τ_f=2τ_c-½, R_cf=τ_f/(2τ_c), m подшагов, временна́я интерполяция,
# - сила по Mei (обмен импульсом), вход/выход/стенки, опционально SDF+Bouzidi.
# Оптимизация под GPU (CuPy): всё векторно на device; проектор через matmul; ГУ и силы
# через where (без gather/scatter); БЕЗ пер-шаговых синхронизаций host↔device.
import os, io as _io, numpy as np
# ---- backend: CuPy (GPU) с откатом на numpy (CPU) ----
try:
import cupy as cp
_ = (cp.zeros(2) + 1).sum() # реальная операция на device — ловит "cupy есть, GPU нет"
xp = cp; GPU = True
except Exception:
import numpy as cp # noqa
xp = np; GPU = False
def to_cpu(a):
return cp.asnumpy(a) if GPU else np.asarray(a)
DTYPE = xp.float64 if os.environ.get("AMR_FP64") else xp.float32
GREL = 1e-8 # ОТНОСИТЕЛЬНЫЙ порог вырожденности знаменателя γ (доля от ‖Δ‖²); см. solver_2x_sdf/backend.py.
# Прежний абсолютный порог (1e-6 в fp32) срабатывал на большинстве узлов и подменял KBC на LBGK.
BACKEND = f"{'CuPy/GPU' if GPU else 'numpy/CPU'} dtype={'float64' if DTYPE == xp.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
# ---- ядро LBM/KBC (идентичная математика) ----
def feq(rho, u):
ux = xp.clip(u[0], -0.95, 0.95); uy = xp.clip(u[1], -0.95, 0.95)
sx = xp.sqrt(1 + 3*ux*ux); sy = xp.sqrt(1 + 3*uy*uy); base = rho*(2-sx)*(2-sy)
Bx = ((2*ux+sx)/(1-ux))[None]**CXa[:, None, None]
By = ((2*uy+sy)/(1-uy))[None]**CYa[:, None, None]
return Wa[:, None, None]*base[None]*Bx*By
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
ds = (Ps @ dfn.reshape(Q, -1)).reshape(dfn.shape) # проектор на сдвиг (matmul, GPU-friendly)
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, xp.asarray(1.0, DTYPE)) # избегаем 0/0 (обе ветки where считаются)
g = xp.where(ok, 1/beta - (2 - 1/beta)*num/den_safe, xp.asarray(2.0, DTYPE))
return f - beta*(2*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): # узлы тела <- развёрнутые до-столкновения (как в CPU-версии)
for i in range(Q):
post[i] = xp.where(solid, pre[OPP[i]], post[i])
return post
def force_on(fpost, cyl, fluid): # Mei для узловой границы: F=Σ c_i(f_i+f_ī) по линкам жидк.->тело
Fx = 0.0; Fy = 0.0
for i in range(1, Q):
nb = xp.roll(cyl, (-Cy[i], -Cx[i]), (0, 1)); link = fluid & nb
s = xp.where(link, fpost[i] + fpost[OPP[i]], xp.asarray(0.0, DTYPE)).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 для интерполированной границы
Fx = 0.0; Fy = 0.0
for (i, ib, mask, q, xff, cl) in bc:
s = xp.where(cl, fpost[i] + f[ib], xp.asarray(0.0, DTYPE)).sum()
Fx = Fx + Cx[i]*s; Fy = Fy + Cy[i]*s
return Fx, Fy
# ---- геометрия (ИДЕНТИЧНА CPU-версиям) ----
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
# 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
def smoothstep(x):
x = min(max(x, 0.0), 1.0); return x*x*(3-2*x)
def run(mode, use_sdf, progress=None):
"""mode: none|amr2x|nested. Возвращает {Fx,Fy,uy (на host), frames (host), DL, mode}.
progress(t, STEPS) — необязательный колбэк прогресса (вызывается каждый шаг)."""
use1 = mode in ("amr2x", "nested"); use2 = (mode == "nested")
DL = {"none": D0, "amr2x": 2*D0, "nested": 4*D0}[mode]
f0 = feq(xp.ones((Ny, Nx), DTYPE), xp.zeros((2, Ny, Nx), DTYPE))
f1 = feq(xp.ones((P1["Nfy"], P1["Nfx"]), DTYPE), xp.zeros((2, P1["Nfy"], P1["Nfx"]), DTYPE)) if use1 else None
f2 = feq(xp.ones((P2["Nfy"], P2["Nfx"]), DTYPE), xp.zeros((2, P2["Nfy"], P2["Nfx"]), DTYPE)) if use2 else None
Fx = xp.zeros(STEPS+1, DTYPE); Fy = xp.zeros(STEPS+1, DTYPE); uy = xp.zeros(STEPS+1, DTYPE)
onecol = xp.ones((Ny, 1), DTYPE); frames = []
for t in range(STEPS+1):
rho, u0 = macros(f0)
if t % 500 == 0 and not bool(xp.isfinite(rho).all()):
print("BLEW UP", mode, t); Fx = Fx[:t]; Fy = Fy[:t]; uy = uy[:t]; break
pre = f0.copy(); post0 = collide(f0, feq(rho, u0), b0)
if not use_sdf:
post0b = bb_set(post0.copy(), pre, g_solid0); f0 = stream(post0b)
else:
f0 = stream(post0); f0 = apply_bc(f0, post0, BC0)
if mode == "none":
Fx[t], Fy[t] = (force_bc(post0, f0, BC0) if use_sdf else force_on(post0, g_cyl0, g_fl0))
uin = xp.zeros((2, Ny, 1), DTYPE); uin[0, :, 0] = U*smoothstep(t/ramp)
f0[:, 1:-1, 0] = feq(onecol, uin)[:, 1:-1, 0]; f0[:, 1:-1, -1] = f0[:, 1:-1, -2]
if use1:
g1o = ghost(pre, P1, R01); g1n = ghost(f0, P1, R01)
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[t], Fy[t] = (force_bc(post1, f1, BC1) if use_sdf else force_on(post1, g_solid1, g_fl1))
f1 = fill(f1, (1-(s1+1)/2)*g1o + ((s1+1)/2)*g1n)
if use2:
g2o = ghost(pre1, P2, R12); g2n = ghost(f1, P2, R12)
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[t], Fy[t] = (force_bc(post2, f2, BC2) if use_sdf else force_on(post2, g_solid2, g_fl2))
f2 = fill(f2, (1-(s2+1)/2)*g2o + ((s2+1)/2)*g2n)
f1 = restrict(f2, f1, P2, Rf12, fl2)
f0 = restrict(f1, f0, P1, Rf01, fl1)
col = f0[:, py, px]; uy[t] = (CYa*col).sum()/col.sum() # зонд следа (без full-macros)
if progress is not None: progress(t, STEPS)
if use1 and t % FE == 0:
frames.append((t, to_cpu(macros(f0)[1]), to_cpu(macros(f1)[1]),
(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"); 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")
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")
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×)"
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))
@@ -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))
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 MiB

+36
View File
@@ -0,0 +1,36 @@
# demos_gpu — GPU-прогоны демо для ноутбука kbc_lbm.ipynb
Ноутбук **не исполняет код** — он встраивает готовые артефакты (png/gif) и цитирует числа.
Все симуляции здесь, **только GPU/CuPy** (CPU-фолбэка нет). Физика не дублируется: решётка,
равновесие, KBC-столкновение, перенос и AMR-связка импортируются из `../solver_2x_sdf` —
демо тем самым гоняют **те же компоненты, что и финальная модель**.
## Запуск (на GPU-сервере, из этой папки)
```sh
python checks_gpu.py # самопроверки математики → out/checks.txt
python tgv_gpu.py # Тейлор–Грин: вязкость, H-теорема → figures/07*, anim/tgv_decay.gif
python shear_gpu.py # сдвиговый слой BGK vs KBC → figures/08*, anim/shear_bgk_vs_kbc.gif
python cavity_gpu.py # каверна Re=1000 vs Ghia → figures/09*, anim/cavity_transient.gif
python obstacle_gpu.py # 4 тела, дорожки Кармана → figures/10*, anim/obstacle_shapes.gif
python amr_demo_gpu.py # AMR: демо + бенчмарк + вложенный → figures/11d*, anim/amr_{cylinder,nested}.gif
python figures_static.py # стенсили/блок-схема/схема AMR → figures/01,06,11c,11_d3q27 (после obstacle!)
```
`figures_static.py` — единственный скрипт без GPU (чистый matplotlib, можно гонять локально);
схему `11c` он перерисовывает по фону из `out/obstacle_circle_field.npz`, который пишет
`obstacle_gpu.py` — поэтому его запускать **после** obstacle.
Env: `AMR_FP64=1` — двойная точность (жёстче пороги самопроверок в checks_gpu).
| Скрипт | Время (примерно, совр. GPU) |
|---|---|
| checks_gpu | секунды |
| tgv_gpu | ~1 мин (10k шагов, 96²) |
| shear_gpu | ~1–2 мин (2×8k, 128²) |
| cavity_gpu | ~1–2 мин (до 14k, 110²) |
| obstacle_gpu | ~3–5 мин (4×9.6k, 150×72) |
| amr_demo_gpu | ~5–10 мин (демо + 6 бенчмарков + анимация) |
Артефакты идут в общие каталоги `../figures` и `../anim` под **теми же именами**, что
исторически использовал ноутбук; числовые сводки — в `out/*.txt|npz`.
+128
View File
@@ -0,0 +1,128 @@
# Общая инфраструктура GPU-демо (ноутбук kbc_lbm.ipynb код не исполняет — только встраивает
# артефакты, которые генерят эти скрипты на GPU-сервере).
#
# Физика НЕ дублируется: решётка/равновесие/столкновение/перенос импортируются из
# ../solver_2x_sdf (те же модули, что и в финальной модели) — демо тем самым доказывают,
# что КОМПОНЕНТЫ финальной модели воспроизводят классические бенчмарки.
# Здесь только то, чего в модели нет намеренно: полиномиальное равновесие и BGK
# (для сравнений «BGK vs KBC»), H-функция, γ-поле для визуализации, гифки.
import os
import sys
import io as _io
HERE = os.path.dirname(os.path.abspath(__file__))
THEORY = os.path.dirname(HERE)
sys.path.insert(0, os.path.join(THEORY, "solver_2x_sdf"))
import backend as B # GPU-only: жёсткая ошибка без CuPy (CPU-фолбэка нет намеренно)
import lattice as L
import equilibrium as E
import collision as Coll
import streaming as Strm
import visualization as Viz # make_progress (ASCII-бар)
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE; to_cpu = B.to_cpu
Q = L.Q; CS2 = L.CS2; OPP = L.OPP; Cx = L.Cx; Cy = L.Cy
import numpy as np
# пути артефактов: фигуры/гифки — общие каталоги theory, числа — demos_gpu/out
FIGDIR = os.path.join(THEORY, "figures"); os.makedirs(FIGDIR, exist_ok=True)
ANIMDIR = os.path.join(THEORY, "anim"); os.makedirs(ANIMDIR, exist_ok=True)
OUTDIR = os.path.join(HERE, "out"); os.makedirs(OUTDIR, exist_ok=True)
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
try:
sys.stdout.reconfigure(encoding="utf-8") # кириллица/греческие в print при cp1251-консоли
except Exception:
pass
plt.rcParams.update({
"figure.dpi": 110, "savefig.dpi": 120, "font.size": 11,
"axes.titlesize": 13, "axes.titleweight": "bold",
"axes.grid": True, "grid.alpha": 0.25, "axes.axisbelow": True,
"figure.facecolor": "white", "axes.facecolor": "#fbfbfd",
})
# пороги самопроверок под точность бэкенда (демо обычно гоняются в float32)
FP64 = (DTYPE == cp.float64)
TOL_EXACT = 1e-12 if FP64 else 2e-5 # «точные» тождества (моменты равновесия и т.п.)
def finish(fig, name):
"""Сохранить фигуру в figures/ под тем же именем, что использовал ноутбук."""
fig.savefig(os.path.join(FIGDIR, name), bbox_inches="tight")
plt.close(fig)
print("PNG:", name)
def write_out(name, lines):
"""Дописать результаты в demos_gpu/out/<name> (ноутбук цитирует эти числа в markdown)."""
with open(os.path.join(OUTDIR, name), "w", encoding="utf-8") as fh:
fh.write("\n".join(lines) + "\n")
print("OUT:", name)
# ----- дополнения к модели (нужны только демо) ------------------------------------------------
def feq_poly(rho, u):
"""Полиномиальное равновесие O(u²) — стандарт BGK (в модели НЕ используется; здесь —
для демонстрации «BGK+poly разрушается там, где KBC+entropic держит»)."""
CXa = L.CXa[:, None, None]; CYa = L.CYa[:, None, None]; Wa = L.Wa[:, None, None]
cu = CXa*u[0] + CYa*u[1]
usq = u[0]**2 + u[1]**2
return Wa * rho * (1.0 + cu/CS2 + cu**2/(2.0*CS2*CS2) - usq/(2.0*CS2))
def bgk_collide(f, fe, beta):
"""Классический BGK: f − ω(f−feq), ω=2β. Эквивалент KBC при γ=2."""
return f - 2.0*beta*(f - fe)
def kbc_gamma(f, fe, beta):
"""Поле энтропийного стабилизатора γ (для визуализации; сам оператор — collision.kbc_collide,
формула та же: γ = 1/β − (2−1/β)·⟨Δs|Δh⟩/⟨Δh|Δh⟩)."""
dfn = f - fe
ds = Coll._project_shift(dfn)
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) # тот же относительный порог, что в collision.py
ok = den > B.GREL*nrm
return xp.where(ok, 1.0/beta - (2.0 - 1.0/beta)*num/xp.where(ok, den, B.ONE), B.TWO)
def nu_to_beta(nu):
return 1.0 / (2.0 * (nu / CS2 + 0.5))
def H_function(f):
"""Дискретная H-функция H = Σ f_i ln(f_i/w_i) (поле)."""
fp = xp.maximum(f, 1e-30)
return (fp * xp.log(fp / L.Wa[:, None, None])).sum(0)
def smoothstep(x):
x = min(max(x, 0.0), 1.0)
return x*x*(3.0 - 2.0*x)
# ----- host-постобработка (numpy: кадры уже перенесены на хост) --------------------------------
def vorticity(u):
"""ω = ∂x u_y − ∂y u_x центральными разностями (host, по кадру)."""
duy_dx = (np.roll(u[1], -1, 1) - np.roll(u[1], 1, 1)) * 0.5
dux_dy = (np.roll(u[0], -1, 0) - np.roll(u[0], 1, 0)) * 0.5
return duy_dx - dux_dy
def render_gif(n_frames, draw_fn, name, figsize, fps=18, dpi=80):
"""Отрисовать n_frames кадров (draw_fn(fig, i)) и сохранить зацикленный GIF в anim/."""
from PIL import Image
path = os.path.join(ANIMDIR, name)
fig = plt.figure(figsize=figsize, dpi=dpi)
pil = []
for i in range(n_frames):
fig.clf()
draw_fn(fig, i)
buf = _io.BytesIO()
fig.savefig(buf, format="png")
buf.seek(0)
pil.append(Image.open(buf).convert("RGB").convert("P", palette=Image.ADAPTIVE))
plt.close(fig)
pil[0].save(path, save_all=True, append_images=pil[1:],
duration=int(1000 / fps), loop=0, optimize=True)
print("GIF:", name, f"({n_frames} кадров)")
return path
+265
View File
@@ -0,0 +1,265 @@
# Демо-5: блочное измельчение (AMR) на компонентах solver_2x_sdf (amr.patch/ghost/fill/restrict —
# ТЕ ЖЕ функции, что в финальной модели). Три части:
# 1) минимальный 2-уровневый AMR на цилиндре (без временно́й интерполяции) → anim/amr_cylinder.gif
# 2) бенчмарк 5 вариантов против эталона uniform-fine 4× (тест-вихрь) → figures/11d_amr_comparison.png
# 3) анимация полного вложенного 2×+4× с временно́й интерполяцией → anim/amr_nested.gif
# Числа → out/amr_bench.txt|npz. Запуск: python amr_demo_gpu.py
import os
import time as _time
import numpy as np
import _common as cm
import amr as Amr # из solver_2x_sdf (sys.path настроен в _common)
cp = cm.cp; xp = cm.xp; E = cm.E; Coll = cm.Coll; Strm = cm.Strm
plt = cm.plt; vorticity = cm.vorticity; OPP = cm.OPP; Q = cm.Q; CS2 = cm.CS2
import matplotlib.patches as mpatches
def _sync():
cp.cuda.Device().synchronize()
# ===== часть 1: минимальный 2-уровневый AMR на цилиндре =========================================
def run_amr_cylinder(Nx=120, Ny=60, D=10, U=0.06, Re=120.0, steps=5400,
ramp=1000, frame_every=22):
cx, cy = Nx//4, Ny//2
nu = U*D/Re
tau_c = nu/CS2 + 0.5; beta_c = cm.nu_to_beta(nu)
tau_f = 2*tau_c - 0.5; beta_f = 1.0/(2*tau_f)
Rcf = tau_f/(2*tau_c); Rfc = 1.0/Rcf
bx0 = max(cx - 2*D, 2); bx1 = min(cx + 3*D, Nx - 2)
by0 = max(cy - 2*D, 2); by1 = min(cy + 2*D, Ny - 2)
P = Amr.patch(Nx, Ny, bx0, bx1, by0, by1, 2)
Nfx, Nfy = P["Nfx"], P["Nfy"]
# маски тела (host → device); стенки канала входят в solid_c
Yc_, Xc_ = np.mgrid[0:Ny, 0:Nx]
solid_c_np = (Xc_ - cx)**2 + (Yc_ - cy)**2 <= (D/2)**2
solid_c_np[0, :] = True; solid_c_np[-1, :] = True
fcx, fcy = 2*(cx - bx0), 2*(cy - by0)
Yf_, Xf_ = np.mgrid[0:Nfy, 0:Nfx]
solid_f_np = (Xf_ - fcx)**2 + (Yf_ - fcy)**2 <= float(D)**2
solid_c = cp.asarray(solid_c_np); solid_f = cp.asarray(solid_f_np)
block_fluid = cp.asarray(~solid_c_np[by0+1:by1, bx0+1:bx1])
onecol = cp.ones((Ny, 1), cm.DTYPE)
fc = E.feq(cp.ones((Ny, Nx), cm.DTYPE), cp.zeros((2, Ny, Nx), cm.DTYPE))
ff = E.feq(cp.ones((Nfy, Nfx), cm.DTYPE), cp.zeros((2, Nfy, Nfx), cm.DTYPE))
frames = []
prog = cm.Viz.make_progress("amr-cyl")
for t in range(steps + 1):
rho, u = E.macros(fc)
pre = fc.copy()
fc = Coll.kbc_collide(fc, E.feq(rho, u), beta_c)
for i in range(Q):
fc[i, solid_c] = pre[OPP[i], solid_c]
fc = Strm.stream(fc)
uin = cp.zeros((2, Ny, 1), cm.DTYPE); uin[0, :, 0] = U*cm.smoothstep(t/ramp)
fc[:, 1:-1, 0] = E.feq(onecol, uin)[:, 1:-1, 0]
fc[:, 1:-1, -1] = fc[:, 1:-1, -2]
# МИНИМАЛЬНЫЙ AMR: один ghost на оба подшага (без временно́й интерполяции)
gh = Amr.ghost(fc, P, Rcf)
for _ in range(2):
rf, uf = E.macros(ff); pref = ff.copy()
ff = Coll.kbc_collide(ff, E.feq(rf, uf), beta_f)
for i in range(Q):
ff[i, solid_f] = pref[OPP[i], solid_f]
ff = Strm.stream(ff)
ff = Amr.fill(ff, gh)
fc = Amr.restrict(ff, fc, P, Rfc, block_fluid)
if t % frame_every == 0:
frames.append((t, cm.to_cpu(E.macros(fc)[1]), cm.to_cpu(E.macros(ff)[1])))
prog(t, steps)
return dict(frames=frames, solid_c=solid_c_np, solid_f=solid_f_np, Nx=Nx, Ny=Ny,
bx0=bx0, bx1=bx1, by0=by0, by1=by1, tau_c=tau_c, tau_f=tau_f, Rcf=Rcf)
amr = run_amr_cylinder()
print(f"AMR-цилиндр: крупная {amr['Nx']}x{amr['Ny']} + мелкий блок 2x, "
f"tau_c={amr['tau_c']:.3f} tau_f={amr['tau_f']:.3f} Rcf={amr['Rcf']:.3f}, "
f"кадров={len(amr['frames'])} (устойчиво)")
_af = amr["frames"]
_vmaxA = np.nanpercentile(np.abs(np.where(amr["solid_c"], np.nan,
vorticity(_af[len(_af)//2][1]))), 99.0)
_cmapA = plt.get_cmap("RdBu_r").copy(); _cmapA.set_bad("#1b1b1b")
def _draw_amr(fig, i):
t, uc, uf = _af[i]
ax = fig.add_subplot(111)
ax.imshow(np.where(amr["solid_c"], np.nan, vorticity(uc)), origin="lower",
cmap=_cmapA, vmin=-_vmaxA, vmax=_vmaxA, extent=(0, amr["Nx"], 0, amr["Ny"]),
interpolation="nearest")
omf = np.where(amr["solid_f"], np.nan, vorticity(uf))[1:-1, 1:-1]
ax.imshow(omf, origin="lower", cmap=_cmapA, vmin=-_vmaxA, vmax=_vmaxA,
extent=(amr["bx0"]+0.5, amr["bx1"]-0.5, amr["by0"]+0.5, amr["by1"]-0.5),
interpolation="nearest")
ax.add_patch(mpatches.Rectangle((amr["bx0"], amr["by0"]),
amr["bx1"]-amr["bx0"], amr["by1"]-amr["by0"],
fill=False, edgecolor="#ffe600", lw=3.0))
ax.text(amr["bx0"]+1, amr["by1"]-4, "мелкий блок 2×", color="#ffe600", fontsize=9, weight="bold")
ax.set_title(f"AMR: крупная сетка + мелкий блок 2× (жёлтая рамка) · t={t}", fontsize=10)
ax.set_xticks([]); ax.set_yticks([])
fig.tight_layout()
cm.render_gif(len(_af), _draw_amr, "amr_cylinder.gif", (9.2, 4.7), fps=24)
# ===== часть 2: бенчмарк точность/работа (тест-вихрь, без тел) ==================================
_Nc = 48; _xc = _yc = 24.0; _sig = 1.4; _A = 0.115
_nu_c = 0.008; _tau_c = _nu_c/CS2 + 0.5; _BSTEPS = 400
def _vfield(X, Y):
rr = (X - _xc)**2 + (Y - _yc)**2; e = cp.exp(-rr/(2*_sig**2))
return cm.B.pack(((-_A*(Y - _yc)/_sig**2*e).astype(cm.DTYPE),
(_A*(X - _xc)/_sig**2*e).astype(cm.DTYPE)))
def _grid_v(N, h):
xs = cp.arange(N, dtype=cm.DTYPE)*h
Xg, Yg = cp.meshgrid(xs, xs)
return _vfield(Xg, Yg)
def _adv(f, beta):
r, u = E.macros(f)
return Strm.stream(Coll.kbc_collide(f, E.feq(r, u), beta))
def _sq_patch(pN, a, b, r):
return Amr.patch(pN, pN, a, b, a, b, r)
def _allfluid(P):
return cp.ones((P["by"]-P["ay"]-1, P["bx"]-P["ax"]-1), bool)
def _tauc(tp, r): return r*tp - (r - 1)/2
def _init_patch(P, a_phys, h_par):
pos = a_phys + cp.arange(P["Nfx"], dtype=cm.DTYPE)*(h_par/P["r"])
X, Y = cp.meshgrid(pos, pos)
return E.feq(cp.ones((P["Nfy"], P["Nfx"]), cm.DTYPE), _vfield(X, Y))
def _run_uniform(N, h, tau, nsteps):
beta = 1/(2*tau); f = E.feq(cp.ones((N, N), cm.DTYPE), _grid_v(N, h))
_sync(); t0 = _time.perf_counter()
for _ in range(nsteps):
f = _adv(f, beta)
_sync()
return E.macros(f)[1], _time.perf_counter() - t0, N*N*nsteps
def _run_single(r, a, b, temporal):
P = _sq_patch(_Nc, a, b, r); tau_f = _tauc(_tau_c, r)
b0, bf = 1/(2*_tau_c), 1/(2*tau_f); Rcf = tau_f/(r*_tau_c); Rfc = 1/Rcf
fl = _allfluid(P)
f0 = E.feq(cp.ones((_Nc, _Nc), cm.DTYPE), _grid_v(_Nc, 1.0)); ff = _init_patch(P, a, 1.0)
_sync(); t0 = _time.perf_counter()
for t in range(_BSTEPS):
f0o = f0.copy(); f0 = _adv(f0, b0)
gho = Amr.ghost(f0o, P, Rcf); ghn = Amr.ghost(f0, P, Rcf)
for s in range(r):
ff = _adv(ff, bf)
w = (s + 1)/r
ff = Amr.fill(ff, (1 - w)*gho + w*ghn if temporal else ghn)
f0 = Amr.restrict(ff, f0, P, Rfc, fl)
_sync()
return E.macros(f0)[1], _time.perf_counter() - t0, _Nc*_Nc*_BSTEPS + P["Nfx"]**2*r*_BSTEPS
def _run_nested(temporal, steps=_BSTEPS, frame_every=0):
P1 = _sq_patch(_Nc, 8, 40, 2); P2 = _sq_patch(P1["Nfx"], 16, 48, 2)
t1 = _tauc(_tau_c, 2); t2 = _tauc(t1, 2)
b0, b1, b2 = 1/(2*_tau_c), 1/(2*t1), 1/(2*t2)
R01 = t1/(2*_tau_c); Rf01 = 1/R01; R12 = t2/(2*t1); Rf12 = 1/R12
fl1 = _allfluid(P1); fl2 = _allfluid(P2)
f0 = E.feq(cp.ones((_Nc, _Nc), cm.DTYPE), _grid_v(_Nc, 1.0))
f1 = _init_patch(P1, 8, 1.0); f2 = _init_patch(P2, 16.0, 0.5)
frames = []
_sync(); t0 = _time.perf_counter()
for t in range(steps + (1 if frame_every else 0)):
f0o = f0.copy(); f0 = _adv(f0, b0)
g1o = Amr.ghost(f0o, P1, R01); g1n = Amr.ghost(f0, P1, R01)
for s1 in range(2):
f1o = f1.copy(); f1 = _adv(f1, b1)
w1 = (s1 + 1)/2
f1 = Amr.fill(f1, (1 - w1)*g1o + w1*g1n if temporal else g1n)
g2o = Amr.ghost(f1o, P2, R12); g2n = Amr.ghost(f1, P2, R12)
for s2 in range(2):
f2 = _adv(f2, b2)
w2 = (s2 + 1)/2
f2 = Amr.fill(f2, (1 - w2)*g2o + w2*g2n if temporal else g2n)
f1 = Amr.restrict(f2, f1, P2, Rf12, fl2)
f0 = Amr.restrict(f1, f0, P1, Rf01, fl1)
if frame_every and t % frame_every == 0:
frames.append((t, cm.to_cpu(E.macros(f0)[1]), cm.to_cpu(E.macros(f1)[1]),
cm.to_cpu(E.macros(f2)[1])))
_sync()
cells = _Nc*_Nc*steps + P1["Nfx"]**2*2*steps + P2["Nfx"]**2*4*steps
if frame_every:
return frames
return E.macros(f0)[1], _time.perf_counter() - t0, cells
print("\nБенчмарк AMR (эталон uniform-fine 4×, физ. время одинаково)...")
_uref, _tref, _cref = _run_uniform(_Nc*4, 0.25, _tauc(_tau_c, 4), 4*_BSTEPS)
_uref = _uref[:, ::4, ::4]
_rms = float(cp.sqrt((_uref[0]**2 + _uref[1]**2).mean()))
def _err(u):
return float(cp.sqrt(((u[0] - _uref[0])**2 + (u[1] - _uref[1])**2).mean()))/_rms
_bench = []
u, t, c = _run_uniform(_Nc, 1.0, _tau_c, _BSTEPS); _bench.append(("uniform-coarse", _err(u), t, c))
u, t, c = _run_single(2, 12, 36, False); _bench.append(("AMR 2x (мин., без вр.инт.)", _err(u), t, c))
u, t, c = _run_single(4, 12, 36, True); _bench.append(("AMR 4x single (вр.инт.)", _err(u), t, c))
u, t, c = _run_nested(False); _bench.append(("AMR 2x+4x nested (без вр.инт.)", _err(u), t, c))
u, t, c = _run_nested(True); _bench.append(("AMR 2x+4x nested ПОЛНЫЙ", _err(u), t, c))
out_lines = [f"reference uniform-fine 4x (192^2): time={_tref:.2f}s cell-updates={_cref:.3e}"]
print(f"Эталон uniform-fine 4× (192²): время={_tref:.1f}с, cell-updates={_cref:.2e}")
print(f"{'метод':32}{'ε,%':>8}{'cell-upd':>11}{'η=1/(εW)':>10}{'×fine(работа)':>14}")
for name, e, t, c in _bench:
eta = 1.0/(e*c/1e6)
print(f"{name:32}{e*100:>8.2f}{c:>11.2e}{eta:>10.2f}{_cref/c:>14.1f}")
out_lines.append(f"{name}: eps_pct={e*100:.3f} cells={c:.3e} eta={eta:.2f} work_vs_fine={_cref/c:.1f}")
np.savez(os.path.join(cm.OUTDIR, "amr_bench.npz"),
names=np.array([b[0] for b in _bench]),
eps=np.array([b[1] for b in _bench]),
wall=np.array([b[2] for b in _bench]),
cells=np.array([b[3] for b in _bench]),
cref=_cref, tref=_tref)
# фигура: ошибка по методам + фронт «точность–работа»
names = [b[0] for b in _bench]; errs = np.array([b[1]*100 for b in _bench])
cells = np.array([b[3] for b in _bench]); cols = ["#9e9e9e", "#1f6feb", "#2e7d32", "#fb8c00", "#d1495b"]
fig, axs = plt.subplots(1, 2, figsize=(13.5, 4.4))
axs[0].barh(range(len(names)), errs, color=cols)
axs[0].set_yticks(range(len(names))); axs[0].set_yticklabels(names, fontsize=8); axs[0].invert_yaxis()
axs[0].set_xlabel("отн. L2-ошибка к эталону, %"); axs[0].set_title("Точность (меньше — лучше)")
for i, e in enumerate(errs):
axs[0].text(e + 0.05, i, f"{e:.2f}", va="center", fontsize=8)
axs[1].scatter(cells, errs, c=cols, s=90, zorder=3, edgecolors="k")
for n, c2, e in zip(names, cells, errs):
axs[1].annotate(n, (c2, e), fontsize=7, xytext=(5, 4), textcoords="offset points")
axs[1].axvline(_cref, color="#d1495b", ls="--", alpha=0.6)
axs[1].text(_cref, errs.max()*0.9, " работа эталона", color="#d1495b", fontsize=8)
axs[1].set_xscale("log"); axs[1].set_xlabel("cell-updates (работа, лог)"); axs[1].set_ylabel("ошибка, %")
axs[1].set_title("Фронт «точность–работа»: AMR ближе к низ-лево (точно и дёшево)")
fig.suptitle("AMR: численное сравнение точности и производительности (KBC, тест-вихрь)", fontweight="bold")
cm.finish(fig, "11d_amr_comparison.png")
# ===== часть 3: анимация полного вложенного 2×+4× ===============================================
_naf = _run_nested(True, steps=750, frame_every=3)
_vmaxN = np.nanpercentile(np.abs(vorticity(_naf[0][1])), 99.0)
_cN = plt.get_cmap("RdBu_r").copy(); _cN.set_bad("#1b1b1b")
def _draw_nested(fig, i):
t, u0, u1, u2 = _naf[i]
ax = fig.add_subplot(111)
ax.imshow(vorticity(u0), origin="lower", cmap=_cN, vmin=-_vmaxN, vmax=_vmaxN,
extent=(0, _Nc, 0, _Nc), interpolation="nearest")
ax.imshow(vorticity(u1)[1:-1, 1:-1], origin="lower", cmap=_cN, vmin=-_vmaxN, vmax=_vmaxN,
extent=(8.5, 39.5, 8.5, 39.5), interpolation="nearest")
ax.imshow(vorticity(u2)[1:-1, 1:-1], origin="lower", cmap=_cN, vmin=-_vmaxN, vmax=_vmaxN,
extent=(16.25, 31.75, 16.25, 31.75), interpolation="nearest")
ax.add_patch(mpatches.Rectangle((8, 8), 32, 32, fill=False, edgecolor="#7CFC00", lw=2.5))
ax.add_patch(mpatches.Rectangle((16, 16), 16, 16, fill=False, edgecolor="#ff8a00", lw=2.5))
ax.text(8.3, 40.4, "2× блок", color="#7CFC00", fontsize=9, weight="bold")
ax.text(16.3, 32.4, "4× блок", color="#ff8a00", fontsize=9, weight="bold")
ax.set_title(f"Полный вложенный AMR: коарс + 2× + 4× (видны размеры ячеек) · t={t}", fontsize=10)
ax.set_xticks([]); ax.set_yticks([]); fig.tight_layout()
cm.render_gif(len(_naf), _draw_nested, "amr_nested.gif", (6.2, 6.2), fps=24)
out_lines.append("PASS AMR: вложенный 2x+4x точнее и дешевле; временная интерполяция снижает ошибку")
cm.write_out("amr_bench.txt", out_lines)
print("DONE (amr demo)")
+122
View File
@@ -0,0 +1,122 @@
# Демо-3: каверна с движущейся крышкой (Re=1000) против эталона Ghia, Ghia & Shin (1982).
# Стенки — bounce-back; крышка — bounce-back с поправкой импульса −2wᵢρ(cᵢ·u_w)/cs².
# Сетка намеренно грубая, BB первого порядка → совпадение качественное.
# Артефакты: figures/09_cavity_ghia.png, anim/cavity_transient.gif, out/cavity.txt|npz.
# Запуск: python cavity_gpu.py
import numpy as np
import _common as cm
cp = cm.cp; xp = cm.xp; E = cm.E; Coll = cm.Coll; Strm = cm.Strm
plt = cm.plt; OPP = cm.OPP; Q = cm.Q; CS2 = cm.CS2
UP = [2, 5, 6] # c_iy = +1
DOWN = [4, 7, 8] # c_iy = -1
RIGHT = [1, 5, 8] # c_ix = +1
LEFT = [3, 6, 7] # c_ix = -1
def run_cavity(N=110, Re=1000.0, U=0.05, steps=14000, tol=1e-7, check=500, frame_every=58):
nu = U*N/Re
beta = cm.nu_to_beta(nu)
rho = cp.ones((N, N), cm.DTYPE)
u = cp.zeros((2, N, N), cm.DTYPE)
f = E.feq(rho, u)
W = cm.to_cpu(cm.L.Wa).astype(float)
prev = None
frames = []
prog = cm.Viz.make_progress("cavity")
t = 0
for t in range(steps):
rho, u = E.macros(f)
if t % frame_every == 0:
frames.append((t, cm.to_cpu(u)))
fcol = Coll.kbc_collide(f, E.feq(rho, u), beta)
f = Strm.stream(fcol)
# bounce-back: на каждой стенке достраиваем входящие в область направления
for i in UP: # низ (y=0)
f[i, 0, :] = fcol[OPP[i], 0, :]
for i in RIGHT: # лево (x=0)
f[i, :, 0] = fcol[OPP[i], :, 0]
for i in LEFT: # право (x=N-1)
f[i, :, -1] = fcol[OPP[i], :, -1]
for i in DOWN: # верх (крышка) + поправка подвижной стенки
io = OPP[i]
corr = 2.0*W[io]*(cm.Cx[io]*U)/CS2 # u_w=(U,0): c_y-член равен нулю
f[i, -1, :] = fcol[io, -1, :] - corr
if t % check == 0 and t > 0:
cur = u.copy()
if prev is not None and float(xp.abs(cur - prev).max()) < tol:
print(f"\ncavity: сошлось по полю скорости на шаге {t}")
break
prev = cur
prog(t, steps - 1)
rho, u = E.macros(f)
return dict(N=N, Re=Re, U=U, u=cm.to_cpu(u), steps=t, frames=frames)
cav = run_cavity()
print(f"Каверна: Re={cav['Re']:.0f}, N={cav['N']}, остановлено на шаге {cav['steps']}")
# Эталон Ghia, Ghia & Shin (1982), Re=1000.
ghia_y = np.array([0.0000, 0.0547, 0.0625, 0.0703, 0.1016, 0.1719, 0.2813, 0.4531,
0.5000, 0.6172, 0.7344, 0.8516, 0.9531, 0.9609, 0.9688, 0.9766, 1.0000])
ghia_u = np.array([0.0000, -0.18109, -0.20196, -0.22220, -0.29730, -0.38289, -0.27805,
-0.10648, -0.06080, 0.05702, 0.18719, 0.33304, 0.46604, 0.51117,
0.57492, 0.65928, 1.0000])
ghia_x = np.array([0.0000, 0.0625, 0.0703, 0.0781, 0.0938, 0.1563, 0.2266, 0.2344,
0.5000, 0.8047, 0.8594, 0.9063, 0.9453, 0.9531, 0.9609, 0.9688, 1.0000])
ghia_v = np.array([0.0000, 0.27485, 0.29012, 0.30353, 0.32627, 0.37095, 0.33075, 0.32235,
0.02526, -0.31966, -0.42665, -0.51550, -0.39188, -0.33714, -0.27669,
-0.21388, 0.0000])
Ncav = cav["N"]
yc = (np.arange(Ncav) + 0.5)/Ncav
xc = (np.arange(Ncav) + 0.5)/Ncav
u_centerline = cav["u"][0, :, Ncav//2]/cav["U"]
v_centerline = cav["u"][1, Ncav//2, :]/cav["U"]
fig, axs = plt.subplots(1, 3, figsize=(14, 4.4))
spd = np.sqrt(cav["u"][0]**2 + cav["u"][1]**2)/cav["U"]
axs[0].imshow(spd, origin="lower", cmap="viridis", extent=[0, 1, 0, 1])
sx = np.linspace(0, 1, Ncav)
axs[0].streamplot(sx, sx, cav["u"][0], cav["u"][1], density=1.1, color="white", linewidth=0.6)
axs[0].set_title("Линии тока и $|\\mathbf{u}|/U$"); axs[0].set_xlabel("$x/L$"); axs[0].set_ylabel("$y/L$")
axs[1].plot(u_centerline, yc, "-", color="#1f6feb", label="KBC")
axs[1].plot(ghia_u, ghia_y, "o", ms=5, mfc="none", color="#d1495b", label="Ghia 1982")
axs[1].set_title("$u_x/U$ по вертикали $x=0.5$"); axs[1].set_xlabel("$u_x/U$")
axs[1].set_ylabel("$y/L$"); axs[1].legend()
axs[2].plot(xc, v_centerline, "-", color="#1f6feb", label="KBC")
axs[2].plot(ghia_x, ghia_v, "o", ms=5, mfc="none", color="#d1495b", label="Ghia 1982")
axs[2].set_title("$u_y/U$ по горизонтали $y=0.5$"); axs[2].set_xlabel("$x/L$")
axs[2].set_ylabel("$u_y/U$"); axs[2].legend()
fig.suptitle("Демо-3: каверна с движущейся крышкой, Re=1000 (KBC vs Ghia)",
y=1.04, fontweight="bold")
cm.finish(fig, "09_cavity_ghia.png")
u_interp = np.interp(ghia_y, yc, u_centerline)
rms = float(np.sqrt(np.mean((u_interp - ghia_u)**2)))
print(f"Каверна: RMS-отклонение u_x от Ghia ~ {rms:.3f} (грубое BB → допустимо)")
# --- гифка становления течения -----------------------------------------------------------------
_cf = cav["frames"]
_vmax = max(np.sqrt(u[0]**2 + u[1]**2).max() for _, u in _cf)/cav["U"]
def _draw(fig, i):
t, u = _cf[i]
ax = fig.add_subplot(111)
sp = np.sqrt(u[0]**2 + u[1]**2)/cav["U"]
ax.imshow(sp, origin="lower", cmap="viridis", vmin=0, vmax=_vmax, extent=[0, 1, 0, 1])
ax.set_title(f"Каверна Re=1000 · t={t} · $|\\mathbf{{u}}|/U$", fontsize=10)
ax.set_xlabel("$x/L$"); ax.set_ylabel("$y/L$")
fig.tight_layout()
cm.render_gif(len(_cf), _draw, "cavity_transient.gif", (4.9, 4.5), fps=24)
np.savez(__import__("os").path.join(cm.OUTDIR, "cavity.npz"),
yc=yc, xc=xc, u_centerline=u_centerline, v_centerline=v_centerline,
ghia_y=ghia_y, ghia_u=ghia_u, ghia_x=ghia_x, ghia_v=ghia_v, rms=rms,
Re=cav["Re"], N=cav["N"], steps=cav["steps"])
cm.write_out("cavity.txt", [
f"N={cav['N']} Re={cav['Re']:.0f} U={cav['U']} stopped_at={cav['steps']}",
f"rms_ux_vs_Ghia={rms:.4f}",
"PASS каверна: качественное совпадение с Ghia (BB первого порядка)",
])
print("DONE (cavity)")
+170
View File
@@ -0,0 +1,170 @@
# Самопроверки математики НА КОМПОНЕНТАХ ФИНАЛЬНОЙ МОДЕЛИ (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)
+153
View File
@@ -0,0 +1,153 @@
# Статические иллюстрации (НЕ симуляции): стенсили D2Q9/D3Q27, блок-схема шага KBC,
# схема блочного измельчения. Чистый numpy+matplotlib — НЕ импортирует CuPy и потому
# исполним и локально (единственный скрипт demos_gpu без GPU).
# Схема измельчения использует фон |ω| из out/obstacle_circle_field.npz (его пишет
# obstacle_gpu.py на сервере); без него рисуется нейтральный фон.
# Артефакты: figures/01_stencil_d2q9.png, 06_algorithm_flow.png, 11c_refinement_schematic.png,
# 11_d3q27.png. Запуск: python figures_static.py
import os
import sys
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.lines import Line2D
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
HERE = os.path.dirname(os.path.abspath(__file__))
THEORY = os.path.dirname(HERE)
FIGDIR = os.path.join(THEORY, "figures"); os.makedirs(FIGDIR, exist_ok=True)
OUTDIR = os.path.join(HERE, "out")
plt.rcParams.update({
"figure.dpi": 110, "savefig.dpi": 120, "font.size": 11,
"axes.titlesize": 13, "axes.titleweight": "bold",
"axes.grid": True, "grid.alpha": 0.25, "axes.axisbelow": True,
"figure.facecolor": "white", "axes.facecolor": "#fbfbfd",
})
def finish(fig, name):
fig.savefig(os.path.join(FIGDIR, name), bbox_inches="tight")
plt.close(fig)
print("PNG:", name)
# D2Q9 (та же нумерация, что в solver_2x_sdf/lattice.py)
CX = np.array([0, 1, 0, -1, 0, 1, -1, -1, 1], float)
CY = np.array([0, 0, 1, 0, -1, 1, 1, -1, -1], float)
W = np.array([4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36])
CS2 = 1.0/3.0
# --- 1. Стенсиль D2Q9 ---------------------------------------------------------------------------
fig, ax = plt.subplots(figsize=(5.2, 5.2))
tier_color = {4/9: "#d1495b", 1/9: "#2e7d32", 1/36: "#1f6feb"}
for i in range(9):
col = tier_color[W[i]]
if i == 0:
ax.plot(0, 0, "o", ms=18, color=col, zorder=3)
ax.annotate(f"$w_0={W[i]:.3f}$", (0, 0), textcoords="offset points",
xytext=(8, 8), fontsize=9)
else:
ax.annotate("", xy=(CX[i], CY[i]), xytext=(0, 0),
arrowprops=dict(arrowstyle="-|>", lw=1.5 + 40*W[i], color=col))
ax.annotate(f"$c_{{{i}}}$", (CX[i], CY[i]), textcoords="offset points",
xytext=(6*np.sign(CX[i] + 0.1), 6*np.sign(CY[i] + 0.1)), fontsize=9)
ax.set_xlim(-1.6, 1.6); ax.set_ylim(-1.6, 1.6); ax.set_aspect("equal")
ax.set_title("Решётка D2Q9: дискретные скорости $\\mathbf{c}_i$ и веса $w_i$")
ax.set_xlabel("$c_x$ [lu/ts]"); ax.set_ylabel("$c_y$ [lu/ts]")
ax.legend(handles=[Line2D([0], [0], color=c, lw=3, label=f"$w={k:.4f}$")
for k, c in tier_color.items()], loc="upper right", fontsize=8)
finish(fig, "01_stencil_d2q9.png")
# --- 2. Блок-схема шага KBC ---------------------------------------------------------------------
fig, ax = plt.subplots(figsize=(4.3, 6.2))
ax.set_axis_off()
steps_txt = [
("Макро-моменты\n$\\rho=\\sum f_i,\\ \\rho\\mathbf{u}=\\sum \\mathbf{c}_i f_i$", "#e8f0fe"),
("Равновесие $f_i^{eq}$\n(энтропийное)", "#e6f4ea"),
("Неравновесие\n$\\Delta s=P_s(f-f^{eq}),\\ \\Delta h=(f-f^{eq})-\\Delta s$", "#fff4e5"),
("Стабилизатор $\\gamma$\n$=1/\\beta-(2-1/\\beta)\\frac{\\langle\\Delta s|\\Delta h\\rangle}{\\langle\\Delta h|\\Delta h\\rangle}$", "#fde7ef"),
("Столкновение\n$f\\leftarrow f-\\beta(2\\Delta s+\\gamma\\Delta h)$", "#f3e8fd"),
("Перенос\n$f_i(\\mathbf{x}+\\mathbf{c}_i)\\leftarrow f_i(\\mathbf{x})$", "#e8f0fe"),
("Граничные условия", "#eceff1"),
]
y = 0.95
for txt, col in steps_txt:
ax.add_patch(plt.Rectangle((0.05, y - 0.1), 0.9, 0.085, transform=ax.transAxes,
facecolor=col, edgecolor="#555", lw=1.2))
ax.text(0.5, y - 0.057, txt, transform=ax.transAxes, ha="center", va="center", fontsize=8.5)
if y > 0.2:
ax.annotate("", xy=(0.5, y - 0.118), xytext=(0.5, y - 0.1), xycoords="axes fraction",
arrowprops=dict(arrowstyle="-|>", color="#555"))
y -= 0.13
ax.set_title("Шаг KBC")
finish(fig, "06_algorithm_flow.png")
# --- 3. Схема блочного измельчения (фон — |ω| цилиндра, если есть артефакт) ----------------------
def _vorticity(u):
duy_dx = (np.roll(u[1], -1, 1) - np.roll(u[1], 1, 1))*0.5
dux_dy = (np.roll(u[0], -1, 0) - np.roll(u[0], 1, 0))*0.5
return duy_dx - dux_dy
field_path = os.path.join(OUTDIR, "obstacle_circle_field.npz")
schematic_path = os.path.join(FIGDIR, "11c_refinement_schematic.png")
if not os.path.exists(field_path) and os.path.exists(schematic_path):
# без серверного фона не затираем уже существующую схему с |ω|-подложкой
print("(нет out/obstacle_circle_field.npz — оставляю существующий 11c_refinement_schematic.png; "
"запустите obstacle_gpu.py на сервере и повторите)")
else:
fig, ax = plt.subplots(figsize=(11, 5.6))
if os.path.exists(field_path):
d = np.load(field_path)
u = d["u"]; solid = d["solid"]
Nx, Ny = int(d["Nx"]), int(d["Ny"]); cx, cy, D = int(d["cx"]), int(d["cy"]), int(d["D"])
vmag = np.where(solid, np.nan, np.abs(_vorticity(u)))
ax.imshow(vmag, origin="lower", cmap="inferno", vmax=np.nanpercentile(vmag, 99))
else:
Nx, Ny, cx, cy, D = 150, 72, 37, 36, 14
ax.add_patch(plt.Circle((cx, cy), D/2, color="#37474f"))
ax.set_facecolor("#10101a")
def _grid_block(x0, y0, w, h, step, color, label):
ax.add_patch(mpatches.Rectangle((x0, y0), w, h, fill=False, edgecolor=color, lw=2.2))
for xx in np.arange(x0, x0 + w + 0.1, step):
ax.plot([xx, xx], [y0, y0 + h], color=color, lw=0.4, alpha=0.45)
for yy in np.arange(y0, y0 + h + 0.1, step):
ax.plot([x0, x0 + w], [yy, yy], color=color, lw=0.4, alpha=0.45)
ax.text(x0 + 1, y0 + h - 4, label, color=color, fontsize=8, weight="bold")
_grid_block(0, 0, Nx - 1, Ny - 1, 16, "#4fc3f7", "уровень 0: Δx (крупный)")
_grid_block(cx - 1.5*D, cy - 1.9*D, 6.5*D, 3.8*D, 8, "#aed581", "уровень 1: Δx/2 (тело+след)")
_grid_block(cx - 1.1*D, cy - 1.3*D, 2.6*D, 2.6*D, 4, "#ff8a65", "уровень 2: Δx/4 (у тела)")
ax.set_xlim(0, Nx - 1); ax.set_ylim(0, Ny - 1)
ax.set_title("Схема блочного измельчения вокруг тела (поверх $|\\omega|$): "
"сетка мельче там, где градиенты резче")
ax.set_xlabel("x [lu]"); ax.set_ylabel("y [lu]")
finish(fig, "11c_refinement_schematic.png")
# --- 4. Стенсиль D3Q27 --------------------------------------------------------------------------
C3 = np.array([[x, y, z] for x in (-1, 0, 1) for y in (-1, 0, 1) for z in (-1, 0, 1)], dtype=float)
nz = (C3 != 0).sum(1)
W3 = np.select([nz == 0, nz == 1, nz == 2, nz == 3], [8/27, 2/27, 1/54, 1/216])
assert np.isclose(W3.sum(), 1.0)
assert np.allclose((W3[:, None, None]*C3[:, :, None]*C3[:, None, :]).sum(0), CS2*np.eye(3))
print("D3Q27: Σw=1 и Σw c⊗c = cs²·I — OK; направлений:", len(C3))
fig = plt.figure(figsize=(6.4, 5.6))
ax = fig.add_subplot(111, projection="3d")
tier3 = {8/27: ("#d1495b", "покой"), 2/27: ("#2e7d32", "оси (×6)"),
1/54: ("#1f6feb", "рёбра (×12)"), 1/216: ("#9c27b0", "углы (×8)")}
for wv, (col, lab) in tier3.items():
m = np.isclose(W3, wv)
ax.scatter(C3[m, 0], C3[m, 1], C3[m, 2], s=70 + 5000*wv, color=col,
label=f"{lab}, w={wv:.4f}", depthshade=True, edgecolors="k", linewidths=0.4)
ax.set_xlabel("$c_x$"); ax.set_ylabel("$c_y$"); ax.set_zlabel("$c_z$")
ax.set_title("Решётка D3Q27 (цель реализации)")
ax.legend(loc="upper left", fontsize=7, bbox_to_anchor=(0.0, 1.0))
finish(fig, "11_d3q27.png")
print("DONE (static figures)")
+118
View File
@@ -0,0 +1,118 @@
# Демо-4: обтекание тел разной формы (цилиндр/квадрат/треугольник/профиль) при Re≈150 —
# вихревые дорожки Кармана на грубой сетке (KBC держит устойчиво). Ступенчатый bounce-back
# (узловой) — именно его «лесенку» позже лечит SDF+Bouzidi в финальной модели.
# Артефакты: figures/10_obstacle_shapes.png, figures/10b_obstacle_probe.png,
# anim/obstacle_shapes.gif, out/obstacle.txt, out/obstacle_circle_field.npz (фон для схемы AMR).
# Запуск: python obstacle_gpu.py
import os
import numpy as np
import _common as cm
cp = cm.cp; xp = cm.xp; E = cm.E; Coll = cm.Coll; Strm = cm.Strm
plt = cm.plt; vorticity = cm.vorticity; OPP = cm.OPP; Q = cm.Q
def solid_mask(Nx, Ny, shape, cx, cy, D):
"""Маска тела + стенки канала (host numpy → device bool)."""
Y, X = np.mgrid[0:Ny, 0:Nx]
if shape == "circle":
m = (X - cx)**2 + (Y - cy)**2 <= (D/2)**2
elif shape == "square":
m = (np.abs(X - cx) <= D/2) & (np.abs(Y - cy) <= D/2)
elif shape == "triangle": # вершиной против потока
tt = (X - (cx - D/2))/D
m = (tt >= 0) & (tt <= 1) & (np.abs(Y - cy) <= tt*(D/2))
elif shape == "airfoil": # наклонный эллипс (угол атаки 18°)
aoa = np.deg2rad(18.0); a = D*0.95; b = D*0.26
xr = (X - cx)*np.cos(aoa) + (Y - cy)*np.sin(aoa)
yr = -(X - cx)*np.sin(aoa) + (Y - cy)*np.cos(aoa)
m = (xr/a)**2 + (yr/b)**2 <= 1
else:
m = np.zeros((Ny, Nx), bool)
m[0, :] = True; m[-1, :] = True # стенки канала (верх/низ)
return m
def run_obstacle(shape, Nx=150, Ny=72, D=14, U=0.06, Re=150.0, steps=9600,
ramp=1000, frame_every=40):
cx, cy = Nx//4, Ny//2
solid_np = solid_mask(Nx, Ny, shape, cx, cy, D)
solid = cp.asarray(solid_np)
beta = cm.nu_to_beta(U*D/Re)
f = E.feq(cp.ones((Ny, Nx), cm.DTYPE), cp.zeros((2, Ny, Nx), cm.DTYPE))
frames, probe = [], []
px, py = cx + 3*D, cy
ones_col = cp.ones((Ny, 1), cm.DTYPE)
prog = cm.Viz.make_progress(shape)
for t in range(steps + 1):
rho, u = E.macros(f)
fcol = Coll.kbc_collide(f, E.feq(rho, u), beta)
for i in range(Q):
fcol[i, solid] = f[OPP[i], solid] # узловой bounce-back (тело + стенки)
f = Strm.stream(fcol)
Uin = U*cm.smoothstep(t/ramp) # плавный разгон входа
uin = cp.zeros((2, Ny, 1), cm.DTYPE); uin[0, :, 0] = Uin
f[:, 1:-1, 0] = E.feq(ones_col, uin)[:, 1:-1, 0] # вток (Дирихле)
f[:, 1:-1, -1] = f[:, 1:-1, -2] # отток (нуль-градиент)
if t % frame_every == 0:
_, uu = E.macros(f)
frames.append((t, cm.to_cpu(uu)))
probe.append(float(uu[1, py, px]))
prog(t, steps)
return dict(shape=shape, solid=solid_np, frames=frames, probe=np.array(probe),
Nx=Nx, Ny=Ny, U=U, Re=Re, D=D, cx=cx, cy=cy)
SHAPES = [("circle", "цилиндр"), ("square", "квадрат"),
("triangle", "треугольник"), ("airfoil", "профиль")]
obs = {key: run_obstacle(key) for key, _ in SHAPES}
out_lines = ["Nx=150 Ny=72 D=14 U=0.06 Re=150 steps=9600"]
for key, name in SHAPES:
p = obs[key]["probe"]; half = p[len(p)//2:]
out_lines.append(f"{key}: max|u_y(probe)|={np.abs(half).max():.4f}")
print(f"{name:12}: max|u_y(зонд)|={np.abs(half).max():.4f} (колебания → срыв вихрей)")
# --- гифка-монтаж всех четырёх тел -------------------------------------------------------------
_keys = [k for k, _ in SHAPES]
_titles = {k: n for k, n in SHAPES}
_nf = min(len(obs[k]["frames"]) for k in _keys)
_vmax = max(np.percentile(np.abs(vorticity(obs[k]["frames"][_nf//2][1])), 99.0) for k in _keys)
_ocmap = plt.get_cmap("RdBu_r").copy(); _ocmap.set_bad("#1b1b1b")
def _draw(fig, i):
for j, k in enumerate(_keys):
ax = fig.add_subplot(2, 2, j + 1)
t, u = obs[k]["frames"][i]
vort = np.where(obs[k]["solid"], np.nan, vorticity(u))
ax.imshow(vort, origin="lower", cmap=_ocmap, vmin=-_vmax, vmax=_vmax)
ax.set_title(f"{_titles[k]} · t={t}", fontsize=9)
ax.set_xticks([]); ax.set_yticks([])
fig.suptitle("Обтекание тел (KBC, Re≈150): завихренность", fontsize=11)
fig.tight_layout(rect=(0, 0, 1, 0.96))
cm.render_gif(_nf, _draw, "obstacle_shapes.gif", (11, 6.2), fps=24)
# --- статические панели ------------------------------------------------------------------------
fig, axs = plt.subplots(2, 2, figsize=(12, 5.6), constrained_layout=True)
for ax, k in zip(axs.ravel(), _keys):
t, u = obs[k]["frames"][-1]
vort = np.where(obs[k]["solid"], np.nan, vorticity(u))
ax.imshow(vort, origin="lower", cmap=_ocmap, vmin=-_vmax, vmax=_vmax)
ax.set_title(f"{_titles[k]} (t={t})", fontsize=10)
ax.set_xticks([]); ax.set_yticks([])
fig.suptitle("Обтекание тел: поле завихренности в конце прогона", fontweight="bold")
cm.finish(fig, "10_obstacle_shapes.png")
fig, ax = plt.subplots(figsize=(9.5, 3.4))
for k in _keys:
ax.plot(np.arange(len(obs[k]["probe"])), obs[k]["probe"], label=_titles[k])
ax.set_title("Поперечная скорость в следе (зонд за телом): колебания = срыв вихрей")
ax.set_xlabel("кадр"); ax.set_ylabel("$u_y$ в зонде [lu/ts]"); ax.legend(fontsize=8, ncol=4)
cm.finish(fig, "10b_obstacle_probe.png")
# фон для схемы блочного измельчения (figures_static.py): |ω| цилиндра в конце прогона
rc = obs["circle"]
np.savez(os.path.join(cm.OUTDIR, "obstacle_circle_field.npz"),
u=rc["frames"][-1][1], solid=rc["solid"],
Nx=rc["Nx"], Ny=rc["Ny"], cx=rc["cx"], cy=rc["cy"], D=rc["D"])
out_lines.append("PASS дорожки Кармана на всех четырёх телах")
cm.write_out("obstacle.txt", out_lines)
print("DONE (obstacle)")
+109
View File
@@ -0,0 +1,109 @@
# Демо-2: двойной сдвиговый слой (Minion–Brown) на недоразрешённой сетке — устойчивость
# BGK (poly-равновесие) против KBC (entropic + γ): BGK разрушается, KBC держит (implicit LES).
# Артефакты: figures/08_shear_stability.png, anim/shear_bgk_vs_kbc.gif, out/shear.txt.
# Запуск: python shear_gpu.py
import math
import numpy as np
import _common as cm
cp = cm.cp; xp = cm.xp; E = cm.E; Coll = cm.Coll; Strm = cm.Strm
plt = cm.plt; vorticity = cm.vorticity
def init_shear(N, U0=0.04, delta=0.05, kappa=80.0):
xs = cp.arange(N, dtype=cm.DTYPE)/N
X, Yc = cp.meshgrid(xs, xs)
ux = cp.where(Yc <= 0.5, U0*cp.tanh(kappa*(Yc - 0.25)), U0*cp.tanh(kappa*(0.75 - Yc)))
uy = delta*U0*cp.sin(2.0*math.pi*(X + 0.25))
return cm.B.pack((ux.astype(cm.DTYPE), uy.astype(cm.DTYPE)))
def run_shear(scheme, N=128, U0=0.04, nu=1.7e-4, steps=8000, rec=100, frame_every=33):
u0 = init_shear(N, U0=U0)
rho = cp.ones((N, N), cm.DTYPE)
beta = cm.nu_to_beta(nu)
f = E.feq(rho, u0) if scheme == "kbc" else cm.feq_poly(rho, u0)
ts, umax, frames = [], [], []
blew_up_at = None
last_u = cm.to_cpu(u0)
prog = cm.Viz.make_progress(scheme)
for t in range(steps + 1):
rho, u = E.macros(f)
um = float(xp.abs(u).max())
if not math.isfinite(um) or um > 0.5:
blew_up_at = t
print(f"\n{scheme}: разрушился на шаге {t}")
break
if t % frame_every == 0:
frames.append((t, cm.to_cpu(u)))
if scheme == "kbc":
f = Coll.kbc_collide(f, E.feq(rho, u), beta)
else:
f = cm.bgk_collide(f, cm.feq_poly(rho, u), beta)
f = Strm.stream(f)
last_u = cm.to_cpu(u)
if t % rec == 0:
ts.append(t); umax.append(um)
prog(t, steps)
gamma = None
if scheme == "kbc": # поле γ на финальном состоянии — для панели
r, u = E.macros(f)
gamma = cm.to_cpu(cm.kbc_gamma(f, E.feq(r, u), beta))
return dict(scheme=scheme, ts=np.array(ts), umax=np.array(umax), frames=frames,
u=last_u, gamma=gamma, blew_up_at=blew_up_at, N=N, U0=U0, nu=nu)
Re_shear = 0.04*128/1.7e-4
print(f"Сдвиговый слой: Re ~ {Re_shear:.0f}, β ~ {cm.nu_to_beta(1.7e-4):.4f}")
res_bgk = run_shear("bgk")
res_kbc = run_shear("kbc")
print("BGK:", (f"разрушился на t={res_bgk['blew_up_at']}" if res_bgk["blew_up_at"] else "устойчив"))
print("KBC:", (f"разрушился на t={res_kbc['blew_up_at']}" if res_kbc["blew_up_at"] else "устойчив"))
assert res_kbc["blew_up_at"] is None, "KBC должен оставаться устойчивым"
# --- панель: ω(KBC), поле γ−2, max|u|(t) -------------------------------------------------------
fig, axs = plt.subplots(1, 3, figsize=(13.5, 4.0))
om_k = vorticity(res_kbc["u"])
im0 = axs[0].imshow(om_k, origin="lower", cmap="RdBu_r")
axs[0].set_title("KBC: завихренность (устойчиво)")
axs[0].set_xlabel("$x$ [lu]"); axs[0].set_ylabel("$y$ [lu]")
plt.colorbar(im0, ax=axs[0], fraction=0.046)
im1 = axs[1].imshow(res_kbc["gamma"] - 2.0, origin="lower", cmap="magma")
axs[1].set_title("Поле стабилизатора $\\gamma-2$")
axs[1].set_xlabel("$x$ [lu]"); axs[1].set_ylabel("$y$ [lu]")
plt.colorbar(im1, ax=axs[1], fraction=0.046)
axs[2].plot(res_kbc["ts"], res_kbc["umax"], color="#2e7d32", label="KBC")
axs[2].plot(res_bgk["ts"], res_bgk["umax"], color="#d1495b", label="BGK")
if res_bgk["blew_up_at"]:
axs[2].axvline(res_bgk["blew_up_at"], color="#d1495b", ls="--", alpha=0.6)
axs[2].set_title("$\\max|\\mathbf{u}|$ во времени")
axs[2].set_xlabel("$t$ [ts]"); axs[2].set_ylabel("$\\max|\\mathbf{u}|$ [lu/ts]"); axs[2].legend()
fig.suptitle(f"Демо-2: сдвиговый слой, Re~{Re_shear:.0f} (BGK разрушается, KBC держит)",
y=1.04, fontweight="bold")
cm.finish(fig, "08_shear_stability.png")
# --- гифка: BGK (слева, замирает с пометкой) vs KBC (справа) -----------------------------------
_kf, _bf = res_kbc["frames"], res_bgk["frames"]
_om0s = np.percentile(np.abs(vorticity(_kf[len(_kf)//2][1])), 99.5)
def _draw(fig, i):
ax1 = fig.add_subplot(1, 2, 1)
ax2 = fig.add_subplot(1, 2, 2)
dead = i >= len(_bf)
tb, ub = _bf[min(i, len(_bf) - 1)]
ax1.imshow(vorticity(ub), origin="lower", cmap="RdBu_r", vmin=-_om0s, vmax=_om0s)
ax1.set_title("BGK — разрушился" if dead else f"BGK · t={tb}",
color="red" if dead else "black", fontsize=10)
tk, uk = _kf[i]
ax2.imshow(vorticity(uk), origin="lower", cmap="RdBu_r", vmin=-_om0s, vmax=_om0s)
ax2.set_title(f"KBC · t={tk}", fontsize=10)
for a in (ax1, ax2):
a.set_xticks([]); a.set_yticks([])
fig.tight_layout()
cm.render_gif(len(_kf), _draw, "shear_bgk_vs_kbc.gif", (8.4, 4.3), fps=24)
cm.write_out("shear.txt", [
f"N=128 U0=0.04 nu=1.7e-4 Re~{Re_shear:.0f} steps=8000",
f"BGK_blew_up_at={res_bgk['blew_up_at']}",
f"KBC_stable={res_kbc['blew_up_at'] is None}",
"PASS KBC устойчив там, где BGK разрушается",
])
print("DONE (shear)")
+108
View File
@@ -0,0 +1,108 @@
# Демо-1: вихрь Тейлора–Грина (валидация вязкости и H-теоремы) на компонентах solver_2x_sdf.
# Аналитика: E(t) = E0·exp(−4νk²t). Меряем ν по наклону ln E(t) — должен совпасть с заданным.
# Артефакты: figures/07_taylor_green.png, figures/07b_tgv_decay_slices.png, anim/tgv_decay.gif,
# out/tgv.txt. Запуск: python tgv_gpu.py
import math
import numpy as np
import _common as cm
cp = cm.cp; xp = cm.xp; E = cm.E; Coll = cm.Coll; Strm = cm.Strm
plt = cm.plt; vorticity = cm.vorticity
def run_tgv(N=96, U0=0.04, nu=0.0125, steps=10000, rec=50, n_snap=10, frame_every=42):
k = 2.0*math.pi/N
xs = cp.arange(N, dtype=cm.DTYPE)
X, Yc = cp.meshgrid(xs, xs) # X — ось 1, Yc — ось 0
ux = -U0*cp.cos(k*X)*cp.sin(k*Yc)
uy = U0*cp.sin(k*X)*cp.cos(k*Yc)
rho = cp.ones((N, N), cm.DTYPE)
f = E.feq(rho, cm.B.pack((ux.astype(cm.DTYPE), uy.astype(cm.DTYPE))))
beta = cm.nu_to_beta(nu)
snap_at = sorted(set(int(round(v)) for v in np.linspace(0, steps, n_snap)))
snaps, frames, ts, KE, Hs = [], [], [], [], []
prog = cm.Viz.make_progress("tgv")
for t in range(steps + 1):
rho, u = E.macros(f)
f = Coll.kbc_collide(f, E.feq(rho, u), beta)
f = Strm.stream(f) # периодический перенос — ровно случай TGV
if (t in snap_at) or (t % rec == 0) or (t % frame_every == 0):
_, uu = E.macros(f)
if t in snap_at:
snaps.append((t, cm.to_cpu(uu)))
if t % frame_every == 0:
frames.append((t, cm.to_cpu(uu)))
if t % rec == 0:
ts.append(t)
KE.append(float((0.5*(uu[0]**2 + uu[1]**2)).mean()))
Hs.append(float(cm.H_function(f).mean()))
prog(t, steps)
return dict(N=N, k=k, nu=nu, U0=U0, ts=np.array(ts), KE=np.array(KE),
H=np.array(Hs), u=cm.to_cpu(E.macros(f)[1]), snaps=snaps, frames=frames)
tgv = run_tgv()
mask = tgv["KE"] > 0
slope = np.polyfit(tgv["ts"][mask], np.log(tgv["KE"][mask]), 1)[0]
nu_meas = -slope/(4.0*tgv["k"]**2)
rel_err = abs(nu_meas - tgv["nu"])/tgv["nu"]
print(f"TGV: ν задано={tgv['nu']:.5f}, ν измерено={nu_meas:.5f}, отн. ошибка={rel_err*100:.2f}%")
assert rel_err < 0.06, "TGV: измеренная вязкость отклонилась слишком сильно"
dH = np.diff(tgv["H"])
print(f"TGV: H-функция монотонно невозрастает: max(ΔH)={dH.max():.2e}")
# --- статическая панель: ω, затухание энергии, H(t) -------------------------------------------
fig, axs = plt.subplots(1, 3, figsize=(13.5, 4.0))
om = vorticity(tgv["u"])
im = axs[0].imshow(om, origin="lower", cmap="RdBu_r")
axs[0].set_title("Завихренность $\\omega$ (TGV, срез)")
axs[0].set_xlabel("$x$ [lu]"); axs[0].set_ylabel("$y$ [lu]")
plt.colorbar(im, ax=axs[0], fraction=0.046)
axs[1].semilogy(tgv["ts"], tgv["KE"], "o", ms=3, label="KBC (измерено)")
axs[1].semilogy(tgv["ts"], tgv["KE"][0]*np.exp(-4*tgv["nu"]*tgv["k"]**2*tgv["ts"]),
"-", color="#d1495b", label="$E_0 e^{-4\\nu k^2 t}$ (аналитика)")
axs[1].set_title("Затухание кин. энергии"); axs[1].set_xlabel("$t$ [ts]")
axs[1].set_ylabel("$E(t)$"); axs[1].legend()
axs[2].plot(tgv["ts"], tgv["H"], color="#2e7d32")
axs[2].set_title("H-функция (энтропия) во времени")
axs[2].set_xlabel("$t$ [ts]"); axs[2].set_ylabel("$\\langle H\\rangle$")
fig.suptitle("Демо-1: вихрь Тейлора–Грина (KBC, D2Q9)", y=1.04, fontweight="bold")
cm.finish(fig, "07_taylor_green.png")
# --- 10 срезов распада с единой шкалой цвета ---------------------------------------------------
snaps = tgv["snaps"]
om0 = np.abs(vorticity(snaps[0][1])).max()
KE0 = 0.5*np.mean(snaps[0][1][0]**2 + snaps[0][1][1]**2)
fig, axs = plt.subplots(2, 5, figsize=(15, 6.3), constrained_layout=True)
im = None
for ax, (tt, uu) in zip(axs.ravel(), snaps):
im = ax.imshow(vorticity(uu), origin="lower", cmap="RdBu_r", vmin=-om0, vmax=om0)
KEf = 0.5*np.mean(uu[0]**2 + uu[1]**2)/KE0
ax.set_title(f"t={tt} · $E/E_0$={KEf:.2f}", fontsize=10)
ax.set_xticks([]); ax.set_yticks([])
fig.colorbar(im, ax=axs, fraction=0.025, pad=0.01, label="завихренность $\\omega$ [1/ts]")
fig.suptitle("Распад вихря Тейлора–Грина: завихренность на равных интервалах времени "
"(единая шкала цвета)", fontweight="bold")
cm.finish(fig, "07b_tgv_decay_slices.png")
# --- гифка затухания ---------------------------------------------------------------------------
_fr = tgv["frames"]
_om0 = np.abs(vorticity(_fr[0][1])).max()
_KE0 = 0.5*np.mean(_fr[0][1][0]**2 + _fr[0][1][1]**2)
def _draw(fig, i):
t, u = _fr[i]
ax = fig.add_subplot(111)
ax.imshow(vorticity(u), origin="lower", cmap="RdBu_r", vmin=-_om0, vmax=_om0)
ke = 0.5*np.mean(u[0]**2 + u[1]**2)/_KE0
ax.set_title(f"Тейлор–Грин · t={t} · $E/E_0$={ke:.2f}", fontsize=10)
ax.set_xticks([]); ax.set_yticks([])
fig.tight_layout()
cm.render_gif(len(_fr), _draw, "tgv_decay.gif", (4.6, 4.4), fps=24)
cm.write_out("tgv.txt", [
f"N={tgv['N']} U0={tgv['U0']} steps=10000",
f"nu_given={tgv['nu']:.5f} nu_measured={nu_meas:.5f} rel_err_pct={rel_err*100:.2f}",
f"H_monotone_max_dH={dH.max():.3e}",
"PASS TGV: вязкость по затуханию энергии и H-теорема",
])
print("DONE (tgv)")
+7
View File
@@ -0,0 +1,7 @@
when,name,steps,Nx,Ny,cx,in_D,out_D,outlet_uy,beta,St,St_corr,Cd,Clrms,Cm,Cd_st,Clrms_st,dCd_pct,dCl_pct,rho_last,cd_win_std,cl_win_std,wall_s,desc
2026-06-10 01:22:17,B0_baseline,100000,174,90,40,2.5,8.31,zero,0.1778,0.2207,0.1814,1.7987,0.7051,-2.9e-05,1.8165,0.732,0.99,3.8,0.99956,0.0244,0.0263,276.8,"база 174x90: вход 2.5D, выход 8.3D, uy=0 (контроль; кросс-чек уже с конвективным членом)"
2026-06-10 01:27:05,F1_outlet_far,100000,300,90,40,2.5,16.19,zero,0.1778,0.1977,0.1625,2.8379,6.7939,-0.000322,2.8839,7.4148,1.62,9.14,1.66349,0.2246,0.7482,287.7,выход 8.3D -> 16.2D (вход 2.5D как в базе) — изолирует близость выхода
2026-06-10 01:31:48,F2_inlet_far,100000,230,90,96,6.0,8.31,zero,0.1778,0.1676,0.1378,2.2287,6.1198,0.000547,2.0134,6.845,9.66,11.85,1.65477,0.3062,3.3794,282.9,вход 2.5D -> 6.0D (выход 8.3D как в базе) — изолирует близость входа
2026-06-10 01:36:32,F3_inout_far,100000,390,90,96,6.0,18.31,zero,0.1778,0.1944,0.1599,2.5772,5.3482,-0.000739,2.5247,5.6976,2.03,6.53,1.65972,0.2693,0.8575,284.0,вход 6.0D + выход 18.3D — оба продольных фактора вместе
2026-06-10 01:41:11,F4_outlet_uy,100000,174,90,40,2.5,8.31,extrapolate,0.1778,0.2223,0.1828,1.7539,0.5572,-3e-05,1.7642,0.5658,0.59,1.54,0.99985,0.0226,0.0429,279.3,мягкий выход: uy нуль-градиент вместо uy=0 (геометрия базовая) — изолирует отражение вихрей
2026-06-10 01:41:25,F5_combo,100000,390,90,96,6.0,18.31,extrapolate,0.1778,nan,nan,nan,nan,nan,nan,nan,nan,nan,nan,nan,nan,13.7,вход 6.0D + выход 18.3D + мягкий uy-выход — всё вместе
1 when name steps Nx Ny cx in_D out_D outlet_uy beta St St_corr Cd Clrms Cm Cd_st Clrms_st dCd_pct dCl_pct rho_last cd_win_std cl_win_std wall_s desc
2 2026-06-10 01:22:17 B0_baseline 100000 174 90 40 2.5 8.31 zero 0.1778 0.2207 0.1814 1.7987 0.7051 -2.9e-05 1.8165 0.732 0.99 3.8 0.99956 0.0244 0.0263 276.8 база 174x90: вход 2.5D, выход 8.3D, uy=0 (контроль; кросс-чек уже с конвективным членом)
3 2026-06-10 01:27:05 F1_outlet_far 100000 300 90 40 2.5 16.19 zero 0.1778 0.1977 0.1625 2.8379 6.7939 -0.000322 2.8839 7.4148 1.62 9.14 1.66349 0.2246 0.7482 287.7 выход 8.3D -> 16.2D (вход 2.5D как в базе) — изолирует близость выхода
4 2026-06-10 01:31:48 F2_inlet_far 100000 230 90 96 6.0 8.31 zero 0.1778 0.1676 0.1378 2.2287 6.1198 0.000547 2.0134 6.845 9.66 11.85 1.65477 0.3062 3.3794 282.9 вход 2.5D -> 6.0D (выход 8.3D как в базе) — изолирует близость входа
5 2026-06-10 01:36:32 F3_inout_far 100000 390 90 96 6.0 18.31 zero 0.1778 0.1944 0.1599 2.5772 5.3482 -0.000739 2.5247 5.6976 2.03 6.53 1.65972 0.2693 0.8575 284.0 вход 6.0D + выход 18.3D — оба продольных фактора вместе
6 2026-06-10 01:41:11 F4_outlet_uy 100000 174 90 40 2.5 8.31 extrapolate 0.1778 0.2223 0.1828 1.7539 0.5572 -3e-05 1.7642 0.5658 0.59 1.54 0.99985 0.0226 0.0429 279.3 мягкий выход: uy нуль-градиент вместо uy=0 (геометрия базовая) — изолирует отражение вихрей
7 2026-06-10 01:41:25 F5_combo 100000 390 90 96 6.0 18.31 extrapolate 0.1778 nan nan nan nan nan nan nan nan nan nan nan nan 13.7 вход 6.0D + выход 18.3D + мягкий uy-выход — всё вместе
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+886
View File
@@ -0,0 +1,886 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "ea656dfb",
"metadata": {},
"source": [
"# Метод KBC (энтропийный Lattice Boltzmann): от формул к валидированной GPU-модели\n",
"\n",
"**Karlin–Bösch–Chikatamarla (KBC)** — энтропийная multi-relaxation-time формулировка\n",
"решёточного метода Больцмана (LBM), дающая **безусловную нелинейную устойчивость** при больших\n",
"числах Рейнольдса без явной подсеточной модели турбулентности (работает как *implicit LES*).\n",
"\n",
"Этот документ — рабочий разбор метода и **история эволюции 2D-макета**: от формул и\n",
"самопроверок через классические бенчмарки (Тейлора–Грина, сдвиговый слой, каверна),\n",
"блочное измельчение сетки (AMR) и границу SDF+Bouzidi — к **финальной валидированной\n",
"GPU-модели** обтекания цилиндра `solver_2x_sdf` (D2Q9, KBC-N1, L0+L1, CUDA-графы) с\n",
"двумя независимыми способами считывания силы.\n",
"\n",
"> **Как устроен документ.** Ноутбук **не исполняет код** — только теория и готовые\n",
"> артефакты (картинки, гифки, числа). Весь код живёт рядом и запускается на GPU-сервере:\n",
">\n",
"> | Где | Что |\n",
"> |---|---|\n",
"> | `demos_gpu/` | GPU-прогоны всех демо и самопроверок (см. `demos_gpu/README.md`) |\n",
"> | `solver_2x_sdf/` | финальная модель (см. `solver_2x_sdf/README.md`) |\n",
"> | `figures/`, `anim/`, `*/out/` | артефакты, встроенные ниже |\n",
">\n",
"> Демо гоняются **теми же модулями** (решётка, равновесие, столкновение, перенос,\n",
"> AMR-связка), что и финальная модель, — каждый бенчмарк ниже одновременно валидирует\n",
"> компонент финальной модели."
]
},
{
"cell_type": "markdown",
"id": "23513c20",
"metadata": {},
"source": [
"## Сводная таблица обозначений\n",
"\n",
"Везде **решёточные единицы**: шаг сетки $\\Delta x = 1$ (lu), шаг по времени $\\Delta t = 1$ (ts).\n",
"\n",
"| Символ | Название | Смысл / формула | Диапазон |\n",
"|---|---|---|---|\n",
"| $f_i$ | популяция | плотность частиц со скоростью $\\mathbf c_i$ | $f_i>0$ |\n",
"| $\\mathbf c_i$ | дискретная скорость | направление переноса $i$ | компоненты $\\in\\{-1,0,1\\}$ |\n",
"| $w_i$ | вес квадратуры | $\\sum_i w_i=1$ | $\\{4/9,1/9,1/36\\}$ (D2Q9) |\n",
"| $c_s$ | скорость звука решётки | $c_s^2=1/3$ | $c_s=1/\\sqrt3$ |\n",
"| $\\rho$, $\\mathbf u$ | плотность, скорость | $\\rho=\\sum_i f_i$, $\\rho\\mathbf u=\\sum_i \\mathbf c_i f_i$ | $\\rho\\approx1$, $|\\mathbf u|\\ll c_s$ |\n",
"| $\\tau$, $\\beta$ | релаксация | $\\nu=c_s^2(\\tau-\\tfrac12)$, $\\beta=1/(2\\tau)$ | $\\tau>1/2$ |\n",
"| $\\gamma$ | **энтропийный стабилизатор** | релаксация высших мод | $\\approx2$ |\n",
"| $f_i^{\\mathrm{eq}}$ | равновесие | максимум энтропии при $\\rho,\\mathbf u$ | $>0$ |\n",
"| $k_i,s_i,h_i$ | разложение | кинематич./сдвиг/высшие моды | $k+s+h=f$ |\n",
"| $\\Delta s_i,\\Delta h_i$ | неравновесие | $P_s(f{-}f^{eq})$, остальное | малы |\n",
"| $H$ | H-функция | $H=\\sum_i f_i\\ln(f_i/w_i)$ | не растёт |\n",
"| $\\langle X|Y\\rangle$ | энтропийное скал. произв. | $\\sum_i X_iY_i/f_i^{eq}$ | — |\n",
"| $T,N,\\Pi_{xy}$ | натуральные 2-е моменты | $\\Pi_{xx}{+}\\Pi_{yy}$, $\\Pi_{xx}{-}\\Pi_{yy}$, сдвиг | — |\n",
"| $\\Pi^{neq}_{\\alpha\\beta}$ | неравновесный тензор | $\\sum_i c_{i\\alpha}c_{i\\beta}(f_i-f_i^{eq})$ | — |\n",
"| $\\sigma_{\\alpha\\beta}$ | тензор напряжений | $-p'\\delta_{\\alpha\\beta}-(1-\\tfrac{1}{2\\tau})\\Pi^{neq,dev}_{\\alpha\\beta}$ | — |\n",
"| $q$ | доля стенки (Bouzidi) | положение стенки на линке из SDF | $(0,1)$ |\n",
"| $C_d, C_l, C_m$ | коэффициенты | $\\tfrac{2F_x}{U^2 D}$, $\\tfrac{2F_y}{U^2 D}$, $\\tfrac{2T_z}{U^2 D^2}$ | — |\n",
"| $\\beta_{бл}$ | блокировка канала | $D/N_y$ (стеснение стенками) | $\\to0$ — безграничный |\n",
"| $\\mathrm{Re}$, $\\mathrm{Ma}$, $\\mathrm{St}$ | подобие | $UL/\\nu$, $|\\mathbf u|/c_s$, $fD/U$ | — |"
]
},
{
"cell_type": "markdown",
"id": "02c48319",
"metadata": {},
"source": [
"---\n",
"# Часть I. Теория\n",
"\n",
"## 1. Основы LBM и решётка D2Q9\n",
"\n",
"LBM отслеживает **популяции** $f_i(\\mathbf x, t)$ — плотность фиктивных частиц, движущихся в\n",
"узле $\\mathbf x$ со скоростью $\\mathbf c_i$. Динамика — чередование **переноса** (streaming) и\n",
"**столкновения** (collision):\n",
"$$ f_i(\\mathbf x + \\mathbf c_i\\,\\Delta t,\\; t+\\Delta t) = f_i(\\mathbf x,t) + \\Omega_i. $$\n",
"\n",
"**Решётка D2Q9**: покоящаяся частица, 4 осевые и 4 диагональные скорости; $c_s^2=1/3$;\n",
"веса $w_0=4/9$, осевые $1/9$, диагональные $1/36$ дают изотропию 4-го порядка.\n",
"Макромоменты: $\\rho=\\sum_i f_i$, $\\rho\\mathbf u=\\sum_i \\mathbf c_i f_i$.\n",
"\n",
"![Стенсиль D2Q9](figures/01_stencil_d2q9.png)\n",
"\n",
"**Самопроверка** (`demos_gpu/checks_gpu.py`, исполняется на компонентах финальной модели —\n",
"`solver_2x_sdf/lattice.py`): $\\sum w_i=1$, $\\sum w_i\\mathbf c_i=0$,\n",
"$\\sum w_i c_{i\\alpha}c_{i\\beta}=c_s^2\\delta_{\\alpha\\beta}$, $\\mathbf c_{\\bar i}=-\\mathbf c_i$ —\n",
"все тождества проходят (см. `demos_gpu/out/checks.txt`)."
]
},
{
"cell_type": "markdown",
"id": "8a74a375",
"metadata": {},
"source": [
"## 2. Равновесие: полиномиальное и энтропийное\n",
"\n",
"**Полиномиальное** (разложение максвеллиана до $O(u^2)$, стандарт BGK):\n",
"$$ f_i^{\\mathrm{eq,poly}} = w_i\\,\\rho\\left[1 + \\frac{\\mathbf c_i\\cdot\\mathbf u}{c_s^2}\n",
" + \\frac{(\\mathbf c_i\\cdot\\mathbf u)^2}{2c_s^4} - \\frac{\\mathbf u^2}{2c_s^2}\\right]. $$\n",
"\n",
"**Энтропийное** (точный максимум дискретной H-функции при связях $\\rho$, $\\rho\\mathbf u$) для\n",
"D2Q9/D3Q27 имеет замкнутую **product-form**:\n",
"$$ f_i^{\\mathrm{eq}} = \\rho\\,w_i\\prod_{\\alpha\\in\\{x,y\\}}\n",
" A(u_\\alpha)\\,B(u_\\alpha)^{\\,c_{i\\alpha}},\\qquad\n",
" A(u) = 2-\\sqrt{1+3u^2},\\qquad B(u) = \\frac{2u+\\sqrt{1+3u^2}}{1-u}. $$\n",
"\n",
"✓ **Сверено с первоисточником**: форма и коэффициенты в точности совпадают с уравнениями\n",
"(7)–(11) Bösch, Chikatamarla, Karlin, *Phys. Rev. E* **92**, 043309 (2015)\n",
"(восходит к Ansumali, Karlin, Öttinger, *Europhys. Lett.* **63**, 798, 2003).\n",
"Реализация — `solver_2x_sdf/equilibrium.py` (показатели $c_{i\\alpha}\\in\\{-1,0,1\\}$, поэтому\n",
"вместо `pow` берётся $B$ или $1/B$ — быстрее и совместимо с CUDA-графами).\n",
"\n",
"**Самопроверка** (`checks_gpu.py`): энтропийное равновесие воспроизводит $\\rho$ и\n",
"$\\rho\\mathbf u$ **точно** (до машинного нуля выбранной точности), а второй момент при малом\n",
"$\\mathrm{Ma}$ близок к $\\rho(c_s^2\\delta_{\\alpha\\beta}+u_\\alpha u_\\beta)$ — тензору давления\n",
"Навье–Стокса. В отличие от полинома, product-form гарантирует положительность и H-теорему."
]
},
{
"cell_type": "markdown",
"id": "c6abf6ca",
"metadata": {},
"source": [
"## 3. Разложение популяций $f = k + s + h$\n",
"\n",
"Сердце KBC — разбиение 9-вектора популяций по **моментным подпространствам**. Натуральные\n",
"вторые моменты D2Q9: след $T=\\Pi_{xx}{+}\\Pi_{yy}$, разность нормальных напряжений\n",
"$N=\\Pi_{xx}{-}\\Pi_{yy}$, сдвиг $\\Pi_{xy}$; плюс третьи ($Q_{xxy},Q_{xyy}$) и четвёртый\n",
"($\\Pi_{xxyy}$) моменты:\n",
"\n",
"- $k_i$ — **кинематическая** часть: только сохраняющиеся моменты ($\\rho, j_x, j_y$);\n",
"- $s_i$ — **сдвиговая** часть: девиаторный тензор напряжений ($N$ и $\\Pi_{xy}$);\n",
"- $h_i$ — **высшие** («ghost») моды: $T$, третьи и четвёртый моменты.\n",
"\n",
"Строится моментная матрица $M_{ai}=\\phi^{(a)}(\\mathbf c_i)$ из 9 независимых мономов; проектор\n",
"на группу моментов $G$: $P_G = M^{-1} D_G M$. По построению $P_k+P_s+P_h=I$, разложение точное.\n",
"\n",
"✓ **Сверено с первоисточником**: назначение $s$ задаёт *вариант* KBC; вариант с\n",
"$s=\\{N,\\Pi_{xy}\\}$ (только девиатор) — канонический «KBC-d» / **N1**\n",
"(Karlin, Bösch, Chikatamarla, *PRE* **90**, 031302(R), 2014; Bösch et al., *PRE* **92**,\n",
"043309, 2015 — там же семейство $s\\in\\{d,\\,d{+}t,\\,d{+}q,\\,d{+}t{+}q\\}$). Именно он\n",
"реализован в `solver_2x_sdf/lattice.py` (проектор `Ps` на строки $\\{c_x^2{-}c_y^2,\\,c_xc_y\\}$).\n",
"\n",
"**Самопроверка** (`checks_gpu.py`): `Ps` идемпотентен, $\\Delta s$ несёт **только** моменты\n",
"$\\{N,\\Pi_{xy}\\}$ (остальные строки $M\\Delta s$ — нули)."
]
},
{
"cell_type": "markdown",
"id": "3fe4992c",
"metadata": {},
"source": [
"## 4. Оператор столкновений KBC и стабилизатор $\\gamma$\n",
"\n",
"Так как $f$ и $f^{eq}$ имеют одинаковые сохраняющиеся моменты, $\\Delta k=0$ и\n",
"$f-f^{eq}=\\Delta s+\\Delta h$, где $\\Delta s = P_s(f-f^{eq})$.\n",
"\n",
"**Столкновение KBC** (через «зеркальное состояние» $f^{mirr}$):\n",
"$$ f_i' = (1-\\beta)\\,f_i + \\beta\\,f_i^{mirr}\n",
" \\;\\;\\Longleftrightarrow\\;\\;\n",
" \\boxed{\\,f_i' = f_i - \\beta\\,\\bigl(2\\,\\Delta s_i + \\gamma\\,\\Delta h_i\\bigr)\\,},\\qquad\n",
" \\nu = c_s^2\\Bigl(\\frac{1}{2\\beta}-\\frac12\\Bigr)\\;\\Rightarrow\\;\\beta=\\frac{1}{2\\tau}. $$\n",
"Множитель **2** перед $\\Delta s$ фиксирует сдвиговую вязкость; высшие моды релаксируются\n",
"отдельным $\\gamma$.\n",
"\n",
"**Стабилизатор** $\\gamma$ выбирается локально (в каждом узле на каждом шаге) из условия\n",
"неувеличения дискретной энтропии; аналитическое приближение корня:\n",
"$$ \\gamma^\\* = \\frac{1}{\\beta} - \\Bigl(2-\\frac{1}{\\beta}\\Bigr)\n",
" \\frac{\\langle \\Delta s\\,|\\,\\Delta h\\rangle}{\\langle \\Delta h\\,|\\,\\Delta h\\rangle},\n",
" \\qquad \\langle X|Y\\rangle = \\sum_i \\frac{X_i Y_i}{f_i^{eq}}. $$\n",
"\n",
"✓ **Сверено с первоисточником**: обе формулы (включая зеркальную форму и связь\n",
"$\\nu(\\beta)$) дословно совпадают с Karlin, Bösch, Chikatamarla, *PRE* **90**, 031302(R)\n",
"(2014) и Bösch et al., *PRE* **92**, 043309 (2015), ур. (24)–(25). Реализация —\n",
"`solver_2x_sdf/collision.py` (с регуляризацией $\\gamma\\to2$ при\n",
"$\\langle\\Delta h|\\Delta h\\rangle\\to0$).\n",
"\n",
"При $\\gamma=2$: $2\\Delta s+2\\Delta h=2(f-f^{eq})$ — KBC **сводится к BGK** с $\\omega=2\\beta$.\n",
"**Самопроверка** (`checks_gpu.py`): KBC$(\\gamma{=}2)\\equiv$ BGK до машинного нуля; столкновение\n",
"сохраняет $\\rho$ и $\\rho\\mathbf u$.\n",
"\n",
"**Популярно.** Разложение $k+s+h$ — три «слоя» движения: перенос массы/импульса, вязкий сдвиг\n",
"(трение слоёв — задаёт вязкость и Re) и мелкая «рябь» высших мод, где на грубой сетке копится\n",
"неустойчивость. KBC гасит сдвиг фиксированным темпом $2\\beta$ (физика), а рябь — адаптивным\n",
"$\\gamma\\beta$: в спокойных зонах $\\gamma\\approx2$ (как BGK), у резких градиентов $\\gamma$\n",
"отклоняется и добавляет ровно столько диссипации, сколько разрешает энтропия. Это «локальный\n",
"термостат потока» — встроенная, без подгоночных констант, модель мелких масштабов\n",
"(*implicit LES*)."
]
},
{
"cell_type": "markdown",
"id": "9eba837e",
"metadata": {},
"source": [
"## 5. Перенос и связь вязкости с $\\beta$; H-функция\n",
"\n",
"Перенос — сдвиг каждой популяции на её $\\mathbf c_i$ (pull-схема, `solver_2x_sdf/streaming.py`).\n",
"Вязкость: $\\nu = c_s^2(\\tfrac{1}{2\\beta}-\\tfrac12)$, т.е. $\\tau=\\nu/c_s^2+\\tfrac12$.\n",
"Число Рейнольдса $\\mathrm{Re}=UL/\\nu$; Маха $\\mathrm{Ma}=U/c_s\\lesssim0.1$ (ошибка\n",
"сжимаемости $\\propto \\mathrm{Ma}^2$).\n",
"\n",
"Дискретная энтропия $H=\\sum_i f_i\\ln(f_i/w_i)$ — «физический компас» устойчивости: из условия\n",
"«$H$ не растёт» выведен $\\gamma$; в демо Тейлора–Грина ниже $\\langle H\\rangle(t)$ монотонно\n",
"не возрастает.\n",
"\n",
"## 6. Алгоритм одного шага KBC\n",
"\n",
"```\n",
"для каждого узла:\n",
" 1) макро-моменты: rho = sum f_i, rho*u = sum c_i f_i\n",
" 2) равновесие: f_i^eq (энтропийное product-form)\n",
" 3) неравновесие: Ds = P_s (f - f^eq), Dh = (f - f^eq) - Ds\n",
" 4) стабилизатор: gamma = 1/beta - (2 - 1/beta) <Ds|Dh> / <Dh|Dh>\n",
" 5) столкновение: f <- f - beta (2 Ds + gamma Dh)\n",
" 6) перенос: f_i(x + c_i) <- f_i(x)\n",
" 7) граничные условия (bounce-back / Bouzidi / Zou-He / free-slip)\n",
"```\n",
"\n",
"![Блок-схема шага KBC](figures/06_algorithm_flow.png)"
]
},
{
"cell_type": "markdown",
"id": "a27e6548",
"metadata": {},
"source": [
"---\n",
"# Часть II. Валидационные демо\n",
"\n",
"Четыре классических теста; каждый гоняется скриптом из `demos_gpu/` **на тех же модулях, что\n",
"финальная модель** (решётка/равновесие/KBC/перенос из `solver_2x_sdf`). Числа ниже цитируются\n",
"из `demos_gpu/out/*.txt` — перегенерируются при каждом прогоне на GPU-сервере.\n",
"\n",
"## 7. Демо-1: вихрь Тейлора–Грина — валидация вязкости и H-теоремы\n",
"\n",
"Точное решение несжимаемого Навье–Стокса в периодической области:\n",
"$u_x=-U_0\\cos(kx)\\sin(ky)$, $u_y=U_0\\sin(kx)\\cos(ky)$, $k=2\\pi/N$; кинетическая энергия\n",
"затухает как $E(t)=E_0\\,e^{-4\\nu k^2 t}$. Прогоняем KBC ($N=96$, $\\nu=0.0125$, 10 000 шагов)\n",
"и **измеряем** $\\nu$ по наклону $\\ln E(t)$ — критерий приёмки: отклонение $<6\\%$\n",
"(фактически $\\lesssim1\\%$, см. `demos_gpu/out/tgv.txt`). $\\langle H\\rangle(t)$ монотонно\n",
"не возрастает — H-теорема выполняется.\n",
"\n",
"![TGV: завихренность, затухание энергии, H-функция](figures/07_taylor_green.png)\n",
"\n",
"![Распад вихря: 10 срезов на единой шкале цвета](figures/07b_tgv_decay_slices.png)\n",
"\n",
"![Анимация затухания](anim/tgv_decay.gif)\n",
"\n",
"*Скрипт: `demos_gpu/tgv_gpu.py`.*"
]
},
{
"cell_type": "markdown",
"id": "0f68444b",
"metadata": {},
"source": [
"## 8. Демо-2: устойчивость — двойной сдвиговый слой (BGK против KBC)\n",
"\n",
"Классический тест Minion–Brown: тонкие сдвиговые слои на **недоразрешённой** сетке\n",
"($N=128$, $\\nu=1.7\\cdot10^{-4}$, $\\mathrm{Re}\\sim3\\cdot10^4$). Стандартный BGK\n",
"(+полиномиальное равновесие) при малой вязкости разрушается (отрицательные популяции → NaN),\n",
"а KBC за счёт адаптивного $\\gamma$ остаётся устойчивым и гладко сворачивает вихри — это и есть\n",
"*implicit LES*. На средней панели — поле $\\gamma-2$: стабилизатор отклоняется от BGK-предела\n",
"именно там, где градиенты резкие.\n",
"\n",
"![Сдвиговый слой: KBC устойчив, BGK разрушается](figures/08_shear_stability.png)\n",
"\n",
"![BGK (слева, замирает в момент разрушения) против KBC (справа)](anim/shear_bgk_vs_kbc.gif)\n",
"\n",
"*Скрипт: `demos_gpu/shear_gpu.py`; момент разрушения BGK — в `demos_gpu/out/shear.txt`.*"
]
},
{
"cell_type": "markdown",
"id": "d8ebf6c3",
"metadata": {},
"source": [
"## 9. Демо-3: каверна с движущейся крышкой против эталона Ghia (Re=1000)\n",
"\n",
"Квадратная каверна, верхняя стенка движется со скоростью $U$. На неподвижных стенках —\n",
"**bounce-back**; на крышке — bounce-back с поправкой импульса подвижной стенки\n",
"$-2w_i\\rho\\,(\\mathbf c_i\\cdot\\mathbf u_w)/c_s^2$. Профили скорости по центральным линиям\n",
"сравниваются с эталоном **Ghia, Ghia & Shin (1982)**. Сетка намеренно грубая ($N=110$) и\n",
"bounce-back первого порядка, поэтому совпадение **качественное** (RMS-отклонение —\n",
"в `demos_gpu/out/cavity.txt`); оно улучшается измельчением и ГУ второго порядка.\n",
"\n",
"![Каверна: линии тока и сравнение с Ghia](figures/09_cavity_ghia.png)\n",
"\n",
"![Становление течения в каверне](anim/cavity_transient.gif)\n",
"\n",
"*Скрипт: `demos_gpu/cavity_gpu.py`; профили сохраняются в `demos_gpu/out/cavity.npz`.*"
]
},
{
"cell_type": "markdown",
"id": "761a536f",
"metadata": {},
"source": [
"## 10. Демо-4: обтекание тел разной формы — дорожки Кармана\n",
"\n",
"Канал с втоком слева (Дирихле + плавный разгон), нуль-градиентным оттоком и стенками.\n",
"Тела (цилиндр, квадрат, треугольник, профиль под углом атаки) — **узловой bounce-back** по\n",
"маске. При $\\mathrm{Re}\\approx150$ за телами формируется **вихревая дорожка Кармана**;\n",
"KBC держит грубую сетку устойчиво. Колебания $u_y$ в зонде за телом — мерило частоты срыва.\n",
"\n",
"Именно «лесенка» ступенчатой маски — мотивация перехода к **SDF + Bouzidi** в части IV.\n",
"\n",
"![Четыре тела: поле завихренности в конце прогона](figures/10_obstacle_shapes.png)\n",
"\n",
"![Зонд в следе: колебания = срыв вихрей](figures/10b_obstacle_probe.png)\n",
"\n",
"![Анимация всех четырёх тел](anim/obstacle_shapes.gif)\n",
"\n",
"*Скрипт: `demos_gpu/obstacle_gpu.py`.*"
]
},
{
"cell_type": "markdown",
"id": "2056c378",
"metadata": {},
"source": [
"---\n",
"# Часть III. Измельчение сетки (AMR)\n",
"\n",
"## 11. Блочное измельчение: зачем и как\n",
"\n",
"Чистый LBM живёт на **равномерной** решётке: перенос требует соседа ровно на $\\mathbf c_i$.\n",
"Неоднородное разрешение делают **вложенными равномерными блоками** разного шага: крупный\n",
"вдали, мелкий у тела и в следе. На стыке блоков ($\\Delta x\\to\\Delta x/2$,\n",
"$\\Delta t\\to\\Delta t/2$ — акустическое масштабирование, $c_s$ сохраняется):\n",
"\n",
"- **время релаксации**: $\\beta_f = \\dfrac{1}{1+r\\,(1/\\beta_c-1)}$, что при $r=2$ эквивалентно\n",
" $\\boxed{\\tau_f = 2\\tau_c - \\tfrac12}$ (вязкость $\\nu$ непрерывна через стык);\n",
"- **неравновесная часть** масштабируется: $f^{neq}_f = \\dfrac{\\beta_c}{r\\,\\beta_f}\\,f^{neq}_c\n",
" = \\dfrac{\\tau_f}{2\\tau_c}\\,f^{neq}_c$ (обратно — множитель $\\tfrac{2\\tau_c}{\\tau_f}$);\n",
"- **ghost-рамка** тонкого блока заполняется равновесием по интерполированным $\\rho,\\mathbf u$\n",
" плюс масштабированным $f^{neq}$; между подшагами тонкого уровня граница **линейно\n",
" интерполируется по времени** между состояниями родителя в $t$ и $t+\\Delta t$;\n",
"- **рестрикция**: внутренние узлы родителя обновляются с тонкого блока (каждый $r$-й узел)\n",
" с обратным масштабом $f^{neq}$.\n",
"\n",
"✓ **Сверено с первоисточниками**: формулы $\\beta_f$ и множитель $\\beta_c/(r\\beta_f)$ —\n",
"Dorschner, Frapolli, Chikatamarla, Karlin, *PRE* **94**, 053311 (2016), ур. (34), (40)\n",
"(KBC полностью совместим с измельчением — $\\gamma$ поузловой на каждом уровне); временна́я\n",
"интерполяция границы между подшагами — Lagrava, Malaspinas, Latt, Chopard, *J. Comput. Phys.*\n",
"**231**, 4808 (2012). Реализация — `solver_2x_sdf/amr.py` (`patch/ghost/fill/restrict`),\n",
"`R01 = τ_f/(2τ_c)` в `config.py`.\n",
"\n",
"![Схема блочного измельчения поверх реального поля |ω|](figures/11c_refinement_schematic.png)"
]
},
{
"cell_type": "markdown",
"id": "6645f68a",
"metadata": {},
"source": [
"## 12. Минимальный 2-уровневый AMR на цилиндре\n",
"\n",
"Рабочая связка «крупная сетка + мелкий блок 2× вокруг тела» (то же тело bounce-back на обоих\n",
"уровнях): на 1 крупный шаг — 2 мелких подшага, ghost из крупного уровня, рестрикция только по\n",
"жидким узлам. В этой *минимальной* версии граница блока держится постоянной в течение\n",
"подшагов (без временно́й интерполяции) — её эффект виден в бенчмарке ниже.\n",
"\n",
"![Минимальный AMR: крупное поле + мелкий блок 2× (жёлтая рамка)](anim/amr_cylinder.gif)\n",
"\n",
"*Скрипт: `demos_gpu/amr_demo_gpu.py` (часть 1) — на `amr.patch/ghost/fill/restrict`\n",
"финальной модели.*\n",
"\n",
"## 13. Полный вложенный 2×+4× и численное сравнение\n",
"\n",
"Три уровня (коарс → 2× → 4×), рекурсивный sub-cycling 1:2:4, временна́я интерполяция границ.\n",
"Контролируемый тест — локализованный резкий вихрь; эталон — равномерная мелкая 4× сетка.\n",
"Работа меряется в **cell-updates** (аппаратно-независимо).\n",
"\n",
"![AMR: точность и фронт «точность–работа»](figures/11d_amr_comparison.png)\n",
"\n",
"Выводы (точные числа — `demos_gpu/out/amr_bench.txt`):\n",
"- **вложенность эффективна**: полный 2×+4× точнее одиночного 4× при заметно меньшей работе;\n",
"- **временна́я интерполяция** снижает ошибку при той же работе;\n",
"- полный AMR даёт точность уровня мелкой сетки за **в разы меньшую работу**;\n",
"- крупная сетка дёшева, но её ошибка принципиально не лечится без измельчения.\n",
"\n",
"![Вложенный AMR: видны три размера ячеек](anim/amr_nested.gif)\n",
"\n",
"## 14. AMR на большой области (исторические прогоны)\n",
"\n",
"Та же связка на большой области (150×72, длинный прогон) — видны грубая сетка и учащённые\n",
"зоны: **2× (тело+след)** и **4× (у тела)**:\n",
"\n",
"![Одиночный 2× AMR на цилиндре](anim/amr2x_cyl.gif)\n",
"\n",
"![Вложенный 2×+4× AMR на цилиндре](anim/amr_nested_cyl.gif)"
]
},
{
"cell_type": "markdown",
"id": "ed665c26",
"metadata": {},
"source": [
"---\n",
"# Часть IV. Граница SDF + Bouzidi\n",
"\n",
"## 15. Каталог граничных условий\n",
"\n",
"KBC задаёт только оператор столкновения; полному решателю нужны ГУ:\n",
"\n",
"- **Bounce-back** — no-slip стенка «на полпути между узлами»: $f_{\\bar i}=f_i$. Первый\n",
" порядок; для движущейся стенки — поправка $-2w_i\\rho(\\mathbf c_i\\cdot\\mathbf u_w)/c_s^2$.\n",
"- **Интерполированный bounce-back (Bouzidi)** — для **кривых** стенок: доля стенки на линке\n",
" $q\\in(0,1)$ из SDF; две ветви:\n",
" $$ q<\\tfrac12:\\;\\; f_{\\bar i}(\\mathbf x_f) = 2q\\,f_i^{post}(\\mathbf x_f) + (1-2q)\\,f_i^{post}(\\mathbf x_f-\\mathbf c_i);\\qquad\n",
" q\\ge\\tfrac12:\\;\\; f_{\\bar i}(\\mathbf x_f) = \\tfrac{1}{2q}\\,f_i^{post}(\\mathbf x_f) + \\bigl(1-\\tfrac{1}{2q}\\bigr)f_{\\bar i}^{post}(\\mathbf x_f). $$\n",
" ✓ Bouzidi, Firdaouss, Lallemand, *Phys. Fluids* **13**, 3452 (2001); реализация —\n",
" `solver_2x_sdf/boundary.py` (`build_bc`/`apply_bc`).\n",
"- **Zou–He** — заданные скорость/давление на входе/выходе (восстановление недостающих\n",
" популяций из $\\rho,\\mathbf u$ + bounce-back неравновесной части; второй порядок).\n",
" ✓ Zou & He, *Phys. Fluids* **9**, 1591 (1997); реализация — `channel_bc`. На давление-выходе\n",
" поперечная скорость задаётся режимом `outlet_uy`: классическое жёсткое $u_y=0$ либо\n",
" нуль-градиент (мягкий выход — не отражает вихри дорожки; см. факторное исследование,\n",
" раздел 24).\n",
"- **Free-slip (specular)** — скользящая стенка: касательный импульс сохраняется, нормальный\n",
" отражается; массу не дрейфует. Реализация — `free_slip_walls`.\n",
"- **Sponge / absorbing-слой** — плавный рост вязкости перед выходом; гасит вихри и акустику до\n",
" отражающей границы. В финальной модели **добавлен** по итогам факторного исследования\n",
" (раздел 25): без него длинные домены с парой «вход-скорость / выход-давление» работают как\n",
" недодемпфированный акустический резонатор. Использовался и в прежней 3D-реализации.\n",
"- **Grad / регуляризованные** — реконструкция $f^{neq}$ через $\\Pi^{neq}$; в 2D-модели\n",
" не понадобились.\n",
"\n",
"## 16. Зачем SDF: лесенка против гладкой границы\n",
"\n",
"Ступенчатая маска (часть II, демо-4) приближает окружность «лесенкой»: положение стенки\n",
"скачет на $O(\\Delta x)$, что шумит в силе и завышает $C_d$. **SDF** (signed distance field,\n",
"$\\varphi<0$ в теле) даёт точную долю пересечения каждого линка $q=\\varphi_f/(\\varphi_f-\\varphi_s)$,\n",
"и Bouzidi восстанавливает отражение **в правильной точке** — граница эффективно гладкая\n",
"при том же разрешении.\n",
"\n",
"Сравнение реализаций без SDF (bounce-back) и с SDF+Bouzidi на одинаковой сетке:\n",
"\n",
"![Зонды: AMR с SDF](figures/11f_amr_sdf_probe_comparison.png)\n",
"\n",
"![Сводное сравнение SDF vs ступенчатая граница](figures/11g_sdf_vs_nosdf.png)\n",
"\n",
"![Цилиндр 2× AMR + SDF](anim/amr2x_cyl_sdf.gif)"
]
},
{
"cell_type": "markdown",
"id": "9d235a12",
"metadata": {},
"source": [
"---\n",
"# Часть V. Эволюция GPU-макета\n",
"\n",
"## 17. Поколения\n",
"\n",
"| Поколение | Файлы | Что нового | Известные проблемы (чинились в следующем) |\n",
"|---|---|---|---|\n",
"| 0. CPU-прототипы | `_amr_anims.py`, `_amr_anims_sdf.py` | первая полная связка: KBC + AMR (none/2×/2×+4×) на цилиндре; ветка с SDF+Bouzidi | numpy-CPU медленный; сила в BB-ветке считалась из post-collision |\n",
"| 1. Базовый GPU | `amr_gpu_core.py` + `amr_cylinder_gpu.py`, `amr_cylinder_sdf_gpu.py` | перенос на CuPy (~100× быстрее), оба типа границы в одном ядре | **сила `force_on`: оба члена из post-collision → Cd отрицательный** |\n",
"| 2. Оптимизированный GPU | `amr_opt/*` | **фикс силы** (второй член из post-streaming); `feq` без `pow`; `cp.fuse`; **CUDA-графы** с рантайм-самопроверкой; GPU-only | монолитные скрипты, параметры захардкожены |\n",
"| 3. Финальная модель | `solver_2x_sdf/*` | модульная архитектура; **Zou-He вход + давление-выход**; **free-slip стенки**; GMEM-сила с торком; **кросс-чек ∮σ·n ds**; серия по блокировке | — |\n",
"\n",
"## 18. Хронология багов и фиксов (чему научил макет)\n",
"\n",
"1. **Сила: второй член — из поля ПОСЛЕ стриминга.** В обмене импульсом\n",
" $F=\\sum c_i(f_i^{post}+f_{\\bar i})$ популяция $f_{\\bar i}$ — та, что *вернулась* в жидкий\n",
" узел после отражения. Если взять её из post-collision поля (как в поколении 1), ведущий\n",
" симметричный член $\\sim2\\rho w_i$ сокращается: $C_d$ выходил **отрицательным** и\n",
" схлопывался к нулю при измельчении. Урок: считывание силы валидируется так же строго,\n",
" как сам решатель.\n",
"2. **Дрейф массы из-за ГУ выхода.** Грубый zero-gradient выход не выпускал поток: масса и\n",
" противодавление копились ($\\langle\\rho\\rangle\\uparrow$), поток глох, $C_d$ и rms $C_l$\n",
" уползали к нулю за 100k шагов. Вскрыто **диагностикой по окнам времени** (тренды\n",
" $\\langle\\rho\\rangle$, $C_d/\\rho$). Фикс: **Zou-He скоростной вход + давление-выход** —\n",
" масса заякорена ($\\langle\\rho\\rangle\\approx1.000$).\n",
"3. **Free-slip стенки канала** (specular) вместо no-slip: нет паразитного пограничного слоя\n",
" на искусственных стенках, блокировка уменьшается; цилиндр остаётся no-slip (Bouzidi).\n",
"4. **Стартовое возмущение** — короткий поперечный sin-импульс входа после разгона: вихревая\n",
" дорожка запускается за единицы периодов вместо тысяч шагов симметричного «застоя».\n",
"5. **Зонд скорости — на тонком уровне L1** (меньше численного размытия вихрей) и **оценка St\n",
" с параболической интерполяцией пика FFT** (иначе St «застревает» на шаге сетки частот).\n",
"6. **CUDA-графы**: захват шага требовал убрать все host→device на горячем пути —\n",
" `cupy.stack` заменён преаллокацией (`backend.pack`), проектор KBC переписан без cuBLAS\n",
" (gemm тащит alpha/beta с хоста), границы clip запечены литералами в ядро. На шаге $t=1$ —\n",
" **рантайм-самопроверка** граф vs eager с автооткатом.\n",
"7. **Осреднение силы по подшагам L1 и момент $C_m$** — финальная ревизия считывания\n",
" (часть VI): сила меряется на каждом подшаге тонкого уровня, торк — с точкой приложения\n",
" на пересечении линка со стенкой."
]
},
{
"cell_type": "markdown",
"id": "e7b57ba6",
"metadata": {},
"source": [
"---\n",
"# Часть VI. Финальная модель `solver_2x_sdf`\n",
"\n",
"## 19. Архитектура\n",
"\n",
"Обтекание цилиндра: грубый уровень **L0** (174×90, $D=16$, $\\mathrm{Re}=150$, $U=0.07$) +\n",
"вложенный патч **L1** (×2 — тело и ближний след). Граница цилиндра — SDF+Bouzidi (no-slip);\n",
"вход/выход — Zou-He (скорость/давление); верх/низ — free-slip. Только GPU/CuPy, шаг целиком\n",
"захватывается в CUDA-граф.\n",
"\n",
"| Модуль | Ответственность |\n",
"|---|---|\n",
"| `config.py` | параметры + производные ($\\tau$, $\\beta$, патч из $N_y$); env-переопределения |\n",
"| `lattice.py` | D2Q9 + KBC-проектор $P_s$ |\n",
"| `equilibrium.py` | product-form $f^{eq}$, макромоменты |\n",
"| `collision.py` | KBC-N1 (единственный оператор) |\n",
"| `streaming.py` | pull-перенос |\n",
"| `geometry.py` | маски/SDF для L0 и L1 |\n",
"| `boundary.py` | Bouzidi + Zou-He + free-slip |\n",
"| `forces.py` | GMEM-сила и момент (считывание №1) |\n",
"| `forces_stress.py` | ∮σ·n ds по контуру (считывание №2, кросс-чек) |\n",
"| `amr.py` | связка L0↔L1 (часть III) |\n",
"| `solver.py` | оркестратор: persistent-буферы, CUDA-граф с самопроверкой |\n",
"| `diagnostics.py` | St (параболический пик), таблицы, кросс-чек, фиты серии по $\\beta_{бл}$ |\n",
"| `run.py` / `run_blockage.py` / `run_factors.py` | одиночный прогон / серия по блокировке / факторное исследование (реестр `out/factors.csv`) |\n",
"\n",
"## 20. Считывание силы №1: галилей-инвариантный обмен импульсом (GMEM)\n",
"\n",
"Для каждого линка жидкость→тело (Bouzidi-маска):\n",
"$$ \\Delta\\mathbf F = (\\mathbf c_i - \\mathbf u_w)\\,f_i^{post}(\\mathbf x_f)\n",
" - (\\mathbf c_{\\bar i} - \\mathbf u_w)\\,f_{\\bar i}^{stream}(\\mathbf x_f), $$\n",
"где $f_i^{post}$ — популяция, уходящая в стенку (после столкновения), $f_{\\bar i}^{stream}$ —\n",
"вернувшаяся (после стриминга/Bouzidi), $\\mathbf u_w$ — скорость стенки (для неподвижного\n",
"цилиндра $0$, но члены с $\\mathbf u_w$ делают оценку **галилей-инвариантной** и готовой к\n",
"подвижным телам — цель SimV4). ✓ Wen, Zhang, Tu, Wang, Fang, *J. Comput. Phys.* **266**,\n",
"161 (2014).\n",
"\n",
"**Момент (торк)**: $T_z=\\sum_{links} (\\mathbf r_w\\times\\Delta\\mathbf F)_z$ с точкой приложения\n",
"на **пересечении линка со стенкой** $\\mathbf r_w=\\mathbf r_f+q\\,\\mathbf c_i$ ($q$ уже лежит в\n",
"структуре Bouzidi). $C_m = 2T_z/(U^2D_{L1}^2)$; для кругового цилиндра $\\langle C_m\\rangle\\approx0$ —\n",
"встроенный **контроль симметрии** считывания.\n",
"\n",
"Сила меряется на **каждом** подшаге L1 и усредняется (анти-алиасинг быстрых мод).\n",
"\n",
"## 21. Считывание силы №2: баланс импульса контрольного объёма (кросс-чек)\n",
"\n",
"Независимая оценка по замкнутому контуру (окружность $R+\\delta$, $\\delta=2$ тонкие ячейки —\n",
"вне зоны Bouzidi-реконструкции). Полный баланс импульса контрольного объёма:\n",
"$$ \\mathbf F_{тела} = \\oint \\bigl[\\sigma\\cdot\\mathbf n\n",
" - \\rho\\,\\mathbf u\\,(\\mathbf u\\cdot\\mathbf n)\\bigr]\\, ds\n",
" \\;-\\;\\underbrace{\\frac{d}{dt}\\!\\int_{кольцо}\\rho\\mathbf u\\,dV}_{\\to\\,0\\ в\\ среднем},\\qquad\n",
" \\sigma_{\\alpha\\beta} = -p'\\,\\delta_{\\alpha\\beta}\n",
" - \\Bigl(1-\\frac{1}{2\\tau_1}\\Bigr)\\,\\Pi^{neq,dev}_{\\alpha\\beta}. $$\n",
"Тонкости:\n",
"- давление без константы ($\\oint p_0\\mathbf n\\,ds\\equiv0$);\n",
"- строго **девиаторная** проекция $\\Pi^{neq}=\\sum_i \\mathbf c_i\\mathbf c_i(f_i-f_i^{eq})$ —\n",
" в KBC след релаксирует со стабилизатором $\\gamma\\beta$, а не $1/\\tau$, тогда как\n",
" сдвиг-моменты — с точным $2\\beta=1/\\tau$;\n",
"- **конвективный член** $-\\rho\\mathbf u(\\mathbf u\\cdot\\mathbf n)$ зануляется только при\n",
" $\\delta\\to0$ (no-slip); в первой версии кросс-чека он был опущен, что давало\n",
" **систематический сдвиг $+4\\%$ по $\\langle C_d\\rangle$ на всех сетках** (см. таблицы\n",
" разделов 22–23) — диагностическая ценность: смещение одинаково при любом $N_y$, т.е. это\n",
" свойство эстиматора, а не течения. Добавлен в `forces_stress.py`;\n",
"- сравнение — по средним ($\\langle C_d\\rangle$, rms $C_l$): мгновенные ряды сдвинуты по фазе\n",
" инерцией кольца $R..R{+}\\delta$ (член $d/dt$).\n",
"\n",
"**Согласие двух независимых методов = считывание силы корректно** — расхождение с\n",
"литературой тогда лежит в физике постановки, что и проверяют эксперименты разделов 23–24."
]
},
{
"cell_type": "markdown",
"id": "ccac6a82",
"metadata": {},
"source": [
"## 22. Результаты основного прогона (174×90, 100k шагов, 2026-06-09)\n",
"\n",
"Прогон на GPU-сервере: CuPy float32, CUDA-графы активны (self-check $\\Delta=0$), 271 с на\n",
"100k шагов L0 (вместе с L1-подшагами и гифкой).\n",
"\n",
"![Финальная модель: |ω| с патчем L1](solver_2x_sdf/out/cyl_2x_sdf.gif)\n",
"\n",
"![Ряды Cd/Cl/Cm, кросс-чек, спектр, ⟨ρ⟩(t)](solver_2x_sdf/out/series_2x_sdf.png)\n",
"\n",
"**Валидация** ($\\beta_{бл}=D/N_y=0.178$, осреднение по второй половине ряда):\n",
"\n",
"| | raw | ×(1−β)$^k$ | лит. безгранич. (Re≈150) |\n",
"|---|---|---|---|\n",
"| St | 0.221 | **0.181** | **0.183** ✓ (1%) |\n",
"| $C_d$ | 1.799 | 1.216 | 1.330 |\n",
"| rms $C_l$ | 0.7051 | 0.4767 | ~0.3 |\n",
"| $\\langle C_m\\rangle$ | **−0.00003** | | 0 ✓ |\n",
"\n",
"**Кросс-чек считывания** (версия ещё без конвективного члена — см. раздел 21):\n",
"\n",
"| метод | $\\langle C_d\\rangle$ | rms $C_l$ | $\\langle C_m\\rangle$ |\n",
"|---|---|---|---|\n",
"| GMEM (обмен импульсом) | 1.799 | 0.7051 | −0.00003 |\n",
"| ∮σ·n ds (контур R+δ) | 1.872 | 0.7252 | −0.00003 |\n",
"| расхождение | **4.1%** | 2.8% | — |\n",
"\n",
"Диагностика сходимости: $\\langle\\rho\\rangle = 1.001$ стабильна (масса заякорена); оконные\n",
"средние $C_d$ в окнах 6–10: 1.797, 1.813, 1.795, 1.797, 1.791 — плато; rms $C_l$ насыщен.\n",
"\n",
"**Выводы по считыванию** (всё подтверждается):\n",
"1. Два независимых метода согласуются в пределах 4% (систематика +4% объяснена опущенным\n",
" конвективным членом и закрыта — раздел 21).\n",
"2. $\\langle C_m\\rangle\\approx-3\\cdot10^{-5}$ при масштабе $C_l\\sim0.7$ — симметрия в норме.\n",
"3. $C_d=1.799$ воспроизводит предыдущую итерацию бит-в-бит: осреднение силы по подшагам L1\n",
" не сместило среднее (регрессии нет, ряды стали глаже).\n",
"4. Следовательно $C_d\\approx1.8$ — **истинная сила в смоделированной постановке**; вопрос\n",
" к расхождению с литературой — вопрос к *постановке*, а не к считыванию.\n",
"\n",
"## 23. Эксперимент №0: серия по боковой блокировке — гипотеза ОПРОВЕРГНУТА\n",
"\n",
"**Гипотеза**: завышение $C_d$/rms $C_l$ объясняется боковым стеснением канала; тогда при\n",
"$D=16$ фикс. и $N_y\\uparrow$ величины должны монотонно стремиться к безграничной литературе,\n",
"а экстраполяция $\\beta\\to0$ — попасть в $C_d\\approx1.33$, rms $C_l\\approx0.3$.\n",
"\n",
"**Постановка**: $N_y\\in\\{90,128,180\\}$ → $\\beta\\in\\{0.178,0.125,0.089\\}$; центр и патч\n",
"выводятся из $N_y$; всё остальное фиксировано. 3×100k шагов (по ~282 с).\n",
"\n",
"**Результат** (`python run_blockage.py`, 2026-06-09):\n",
"\n",
"| $N_y$ | β | St | St(1−β) | $\\langle C_d\\rangle$ | rms $C_l$ | $\\langle C_m\\rangle$ | $C_d$ σ·n | dCd |\n",
"|---|---|---|---|---|---|---|---|---|\n",
"| 90 | 0.178 | 0.221 | 0.181 | 1.799 | 0.7051 | −0.00003 | 1.872 | 4.1% |\n",
"| 128 | 0.125 | 0.196 | 0.171 | 1.771 | 0.6734 | −0.00007 | 1.838 | 3.8% |\n",
"| 180 | 0.089 | 0.193 | 0.176 | **1.870** | **0.8354** | +0.00043 | 1.937 | 3.6% |\n",
"\n",
"Экстраполяция $\\beta\\to0$ (интерсепт линейного / квадратичного фита, спред = неопределённость):\n",
"\n",
"| | лин. | квадр. | спред | лит. |\n",
"|---|---|---|---|---|\n",
"| $C_d(0)$ | 1.905 | 1.855 | 0.050 | 1.33 |\n",
"| rms $C_l(0)$ | 0.910 | 0.818 | 0.091 | ~0.3 |\n",
"| St(0) | 0.161 | **0.181** | 0.020 | **0.183** |\n",
"\n",
"![Серия по блокировке: Cd(β), rms Cl(β), St(β) с экстраполяцией](solver_2x_sdf/out/blockage_extrapolation.png)\n",
"\n",
"**Анализ:**\n",
"1. $C_d(\\beta)$ **немонотонен** (1.80 → 1.77 → 1.87), rms $C_l$ при самом широком канале даже\n",
" вырос — экстраполяция «$C_d(0)\\approx1.9$» физически абсурдна и означает одно: **β — не\n",
" управляющий параметр**. Поправка $(1-\\beta)^2$ из предыдущей итерации была совпадением на\n",
" одной точке ($N_y=90$).\n",
"2. Это согласуется с классической теорией продувок: поправки Аллена–Винченти\n",
" (solid + wake blockage) при $\\beta=0.178$ дают завышение лишь ~15–18%, при $\\beta=0.089$ —\n",
" ~10%. Боковое стеснение объясняло максимум половину превышения даже на исходной сетке.\n",
"3. **St — единственная величина с правильным поведением**: raw 0.221→0.196→0.193 монотонно\n",
" стремится к 0.183 сверху; квадратичная экстраполяция даёт **0.181 ≈ лит. 0.183**.\n",
" Кинематика вихреобразования (частота, эффективное Re) верна; завышены именно силовые\n",
" (давленческие) амплитуды.\n",
"4. **Улика — поведение $N_y=180$**: низкочастотная модуляция с размахом оконных $C_d$\n",
" 1.68–1.98 и rms $C_l$ 0.58–0.96 (период ~20–30k шагов; на 50k установившегося режима —\n",
" всего 2–3 цикла, статистика среднего не набрана; $\\langle C_m\\rangle$ на порядок больше,\n",
" чем на узких каналах). Чем шире канал, тем свободнее меандрирует дорожка — и тем сильнее\n",
" она бьётся о **жёсткое условие $u_y=0$ на выходе**, порождая отражения и обратную связь.\n",
"5. **Диагноз**: доминируют **продольные ограничения**, которые серия держала константой:\n",
" вход 2.5D до тела (жёсткий равномерный профиль), выход 8.3D (Zou-He давление + $u_y=0$ —\n",
" отражает вихри), стык L1→L0 на 6.25D. Их изолирует эксперимент №1.\n",
"\n",
"## 24. Эксперимент №1: факторное исследование продольных границ\n",
"\n",
"**Метод**: каждый подозреваемый фактор варьируется **отдельно** при фиксированной боковой\n",
"блокировке ($N_y=90$, $\\beta=0.178$) и 100k шагах — изоляция вкладов входа, выхода и\n",
"выходного ГУ. Реализация: `config.py` выводит x-границы патча из `cx` (патч едет за телом),\n",
"`boundary.channel_bc` получил режим `outlet_uy`:\n",
"- `\"zero\"` — классический Zou-He: $u_y=0$ жёстко (как во всех прогонах выше);\n",
"- `\"extrapolate\"` — $u_y$ нуль-градиент (берётся из столбца $x{=}-2$), якорь $\\rho=1$\n",
" сохраняется; алгебраически: $f_6 \\mathrel{+}= \\tfrac12\\rho u_y$, $f_7 \\mathrel{-}= \\tfrac12\\rho u_y$\n",
" при тех же $f_3$ и $u_x$ (балансы массы и импульса выполняются точно).\n",
"\n",
"| Фактор | Nx | cx | вход | выход | $u_y$-выход | что изолирует |\n",
"|---|---|---|---|---|---|---|\n",
"| B0_baseline | 174 | 40 | 2.5D | 8.3D | zero | контроль (+ кросс-чек с конвективным членом) |\n",
"| F1_outlet_far | 300 | 40 | 2.5D | 16.2D | zero | близость выхода |\n",
"| F2_inlet_far | 230 | 96 | 6.0D | 8.3D | zero | близость входа |\n",
"| F3_inout_far | 390 | 96 | 6.0D | 18.3D | zero | оба продольных расстояния |\n",
"| F4_outlet_uy | 174 | 40 | 2.5D | 8.3D | extrapolate | кинематическое отражение вихрей ГУ |\n",
"| F5_combo | 390 | 96 | 6.0D | 18.3D | extrapolate | «чистая» постановка |\n",
"\n",
"Метрики каждого прогона: St, $\\langle C_d\\rangle$, rms $C_l$, $\\langle C_m\\rangle$, кросс-чек\n",
"dCd (уже с конвективным членом), **std оконных средних** (мера НЧ-модуляции), $\\langle\\rho\\rangle$.\n",
"Каждый прогон фиксируется строкой в накопительном реестре `solver_2x_sdf/out/factors.csv`\n",
"(полная конфигурация + результаты + время) — документация всех изменений постановки.\n",
"\n",
"**Результаты** (`python run_factors.py`, 2026-06-10, 6×100k шагов по ~280 с):\n",
"\n",
"| Фактор | вход | выход | $u_y$-выход | St(1−β) | $\\langle C_d\\rangle$ | rms $C_l$ | dCd кросс | $\\langle\\rho\\rangle_{фин}$ | модуляция | статус |\n",
"|---|---|---|---|---|---|---|---|---|---|---|\n",
"| B0_baseline | 2.5D | 8.3D | zero | 0.181 | 1.799 | 0.705 | **1.0%** | 1.000 | 0.024 | ok |\n",
"| F1_outlet_far | 2.5D | 16.2D | zero | 0.163 | 2.838 | 6.794 | 1.6% | **1.663** | 0.225 | режим смещён |\n",
"| F2_inlet_far | 6.0D | 8.3D | zero | 0.138 | 2.229 | 6.120 | 9.7% | **1.655** | 0.306 | режим смещён (срыв на ~75k) |\n",
"| F3_inout_far | 6.0D | 18.3D | zero | 0.160 | 2.577 | 5.348 | 2.0% | **1.660** | 0.269 | режим смещён (срыв на ~20k) |\n",
"| **F4_outlet_uy** | 2.5D | 8.3D | extrapolate | **0.183** | **1.754** | **0.557** | **0.6%** | **1.0000** | **0.023** | **ok ✓** |\n",
"| F5_combo | 6.0D | 18.3D | extrapolate | — | — | — | — | NaN | — | **взрыв на 4.5k** |\n",
"\n",
"![Факторное исследование: Cd, rms Cl и НЧ-модуляция по факторам](solver_2x_sdf/out/factors_summary.png)\n",
"\n",
"**Анализ:**\n",
"1. **Конвективный член подтверждён**: кросс-чек B0 сжался с 4.1% до **1.0%** (F4 — 0.6%) при\n",
" неизменной физике. Считывание силы теперь согласовано двумя методами на уровне ~1%.\n",
"2. **F4 (мягкий $u_y$-выход) — выигрыш по всем метрикам**: rms $C_l$ 0.705→0.557 (−21%),\n",
" $\\langle C_d\\rangle$ 1.799→1.754, $\\langle\\rho\\rangle=1.0000$ (точнее базы), нулевая\n",
" НЧ-модуляция (окна 6–10: $C_d$ 1.753–1.755, идеальное плато) и **St·(1−β)=0.1828 ≈ лит.\n",
" 0.183 точно**. Жёсткое $u_y=0$ действительно кинематически отражало вихри.\n",
"3. **Главное открытие — длинные домены без демпфера невалидны.** Все три (F1/F2/F3, $u_y=0$)\n",
" уходят на смещённую ветвь $\\langle\\rho\\rangle\\approx1.66$ (F1 и F3 — за ~20k шагов, F2 —\n",
" на ~75k), где $C_d$, $C_l$ бессмысленны для сравнения (у F2 разваливается и кросс-чек:\n",
" 9.7% — поле у контура нестационарно «звенит»). Механизм: канал «вход-скорость +\n",
" выход-давление» — **недодемпфированный акустический резонатор**; затухание продольной моды\n",
" $\\propto\\nu(\\pi/N_x)^2$ падает в ~5 раз при $N_x$ 174→390. Стартовый транзиент раскачивает\n",
" моду, вход с фиксированной скоростью качает массу $\\propto\\rho_{in}$ (положительная обратная\n",
" связь) — система садится на новую ветвь равновесия потоков массы. **F5** — чистая картина\n",
" роста: $\\langle\\rho\\rangle$ по окнам осциллирует 1.04→0.93→1.05 с периодом\n",
" $\\approx 2N_x/c_s\\approx1350$ шагов (точно акустический период туда-обратно) → NaN на 4.5k.\n",
"4. При этом rms $u_y$ зонда нормальный (0.033–0.046) во **всех** прогонах — гидродинамика\n",
" дорожки жива; ломаются акустика и массовый баланс. Это ретроспективно объясняет и «загадку»\n",
" эксперимента №0: $N_y=180$ был зачатком той же моды (НЧ-модуляция), но поперечное расширение\n",
" менее опасно продольного.\n",
"\n",
"**Урок**: продольное удлинение требует **поглощающего слоя** — стандартное лекарство из\n",
"каталога ГУ (раздел 15), упоминавшееся ещё в прежней 3D-реализации.\n",
"\n",
"## 25. Эксперимент №2: губка (absorbing layer) перед выходом\n",
"\n",
"Реализация: в последних `sponge_len` столбцах L0 вязкость плавно (smoothstep) растёт до\n",
"$\\nu\\cdot$`sponge_nu_mult` (по умолчанию ×30 — локальное Re падает до ~5, вихри и акустика\n",
"диссипируют до прихода на выход). Технически $\\beta$ на L0 становится **полем** $\\beta(x)$\n",
"(KBC это допускает: $\\gamma$ и так поузловой; CUDA-граф не страдает — поле предвычислено);\n",
"патч L1 губку не видит (гарантировано assert'ом). Ручки: `AMR_SPONGE_LEN`, `AMR_SPONGE_NU`.\n",
"\n",
"| Фактор | геометрия | $u_y$-выход | губка | что проверяет |\n",
"|---|---|---|---|---|\n",
"| F6_sponge | 174×90 (база) | extrapolate | 32 | валидация губки против F4 (не портит ли короткий домен) |\n",
"| F7_long_sponge | 390×90, вход 6D / выход 18.3D | extrapolate | 32 | **целевая «чистая» постановка** |\n",
"| F8_long_sp_uy0 | 390×90 | zero | 32 | изоляция: достаточно ли губки без мягкого $u_y$ |\n",
"\n",
"> ⏳ *Числа появятся после `python run_factors.py` с факторами F6–F8 (3×100k ≈ 15 мин).*\n",
"\n",
"**Критерии**: у всех $\\langle\\rho\\rangle\\approx1.000$ и нет НЧ-модуляции; F7 — ожидание\n",
"$\\langle C_d\\rangle\\approx1.5$–1.6 (остаётся боковая блокировка β=0.178: по Аллену–Винченти\n",
"+15–18% к безграничному 1.33) и rms $C_l<0.45$. Если критерии выполнены — **эксперимент №3**:\n",
"повторная серия по β ($N_y=90/128/180$) на конфигурации F7 → финальная экстраполяция к\n",
"безграничному пределу. Остаточное расхождение после неё — фактор разрешения (D-рефайнмент)."
]
},
{
"cell_type": "markdown",
"id": "0afa973f",
"metadata": {},
"source": [
"---\n",
"# Часть VII. Перенос на D3Q27 и связь с целевой реализацией\n",
"\n",
"Вся математика KBC переносится в 3D **без изменений по сути**: решётка D3Q27 (веса\n",
"$8/27, 2/27, 1/54, 1/216$ — изотропия 4-го порядка, предпочтительна для KBC/турбулентности),\n",
"моментный базис 27×27, сдвиговая часть $s$ — девиаторный тензор (5 независимых компонент).\n",
"Формула $\\gamma$ и оператор $f\\leftarrow f-\\beta(2\\Delta s+\\gamma\\Delta h)$ идентичны.\n",
"\n",
"![Решётка D3Q27](figures/11_d3q27.png)\n",
"\n",
"Чем настоящий KBC отличается от прежнего подхода в 3D-движке (poly eq + TRT + α-лимитер +\n",
"Smagorinsky):\n",
"\n",
"| Аспект | Настоящий KBC | Прежний подход |\n",
"|---|---|---|\n",
"| Равновесие | энтропийное product-form | полиномиальное $O(u^2)$ |\n",
"| Столкновение | энтропийный MRT, $k/s/h$ | TRT (симм./антисимм.) |\n",
"| Стабилизация | $\\gamma$ из энтропии | α-лимитер положительности |\n",
"| H-теорема | встроена в вывод $\\gamma$ | контролируется отдельно |\n",
"| Турбулентность | implicit LES (параметр-free) | явный Smagorinsky |\n",
"| Решётка | D3Q27 | D3Q27 (совпадает) |\n",
"\n",
"**Дорожная карта переноса** (всё отработано на 2D-макете): (1) product-form $f^{eq}$;\n",
"(2) проекторы $P_s$ для D3Q27 (предвычислить); (3) per-node $\\gamma$; (4) столкновение;\n",
"(5) SDF+Bouzidi и GMEM-сила с $\\mathbf u_w$ на линке (подвижные тела) + кросс-чек ∮σ·n dA по\n",
"SDF-изоповерхности; (6) AMR-связка $\\tau_f=2\\tau_c-\\tfrac12$ с временно́й интерполяцией."
]
},
{
"cell_type": "markdown",
"id": "d0691787",
"metadata": {},
"source": [
"---\n",
"# Часть VIII. Сверка формул с первоисточниками\n",
"\n",
"Все формулы, ранее помеченные «⚠ сверить с оригиналом», проверены по первоисточникам\n",
"(плюс численная самопроверка каждой в `demos_gpu/checks_gpu.py`):\n",
"\n",
"| Формула | Источник | Статус |\n",
"|---|---|---|\n",
"| Product-form $f^{eq}$: $A(u)=2-\\sqrt{1+3u^2}$, $B(u)=\\frac{2u+\\sqrt{1+3u^2}}{1-u}$ | Bösch/Chikatamarla/Karlin, PRE **92**, 043309 (2015), ур. (7)–(11); Ansumali/Karlin/Öttinger, EPL **63**, 798 (2003) | ✓ дословно |\n",
"| $f' = f-\\beta(2\\Delta s+\\gamma\\Delta h)$, $\\beta=1/(2\\tau)$, mirror-форма | Karlin/Bösch/Chikatamarla, PRE **90**, 031302(R) (2014) | ✓ дословно |\n",
"| $\\gamma^\\*=\\frac1\\beta-(2-\\frac1\\beta)\\frac{\\langle\\Delta s|\\Delta h\\rangle}{\\langle\\Delta h|\\Delta h\\rangle}$, $\\langle X|Y\\rangle=\\sum X_iY_i/f_i^{eq}$ | там же; PRE **92**, ур. (24)–(25) | ✓ дословно |\n",
"| Состав $s=\\{N,\\Pi_{xy}\\}$ (девиатор; «KBC-d»/N1) | PRE **92** (семейство $s\\in\\{d,d{+}t,d{+}q,d{+}t{+}q\\}$); Dorschner et al., JFM **801**, 623 (2016) | ✓ |\n",
"| AMR: $\\beta_f=\\frac{1}{1+r(1/\\beta_c-1)}$ ⇔ $\\tau_f=2\\tau_c-\\frac12$; $f^{neq}\\cdot\\frac{\\beta_c}{r\\beta_f}=\\frac{\\tau_f}{2\\tau_c}f^{neq}$ | Dorschner/Frapolli/Chikatamarla/Karlin, PRE **94**, 053311 (2016), ур. (34), (40) | ✓ дословно |\n",
"| Временна́я интерполяция ghost-границы | Lagrava/Malaspinas/Latt/Chopard, JCP **231**, 4808 (2012) | ✓ |\n",
"| Bouzidi: ветви $q<\\frac12$ / $q\\ge\\frac12$ | Bouzidi/Firdaouss/Lallemand, Phys. Fluids **13**, 3452 (2001) | ✓ |\n",
"| Zou-He: скоростной вход / давление-выход | Zou & He, Phys. Fluids **9**, 1591 (1997) | ✓ |\n",
"| GMEM: $(\\mathbf c_i-\\mathbf u_w)f_i^{post}-(\\mathbf c_{\\bar i}-\\mathbf u_w)f_{\\bar i}^{stream}$ | Wen/Zhang/Tu/Wang/Fang, JCP **266**, 161 (2014) | ✓ |\n",
"| $\\sigma^v=-(1-\\frac{1}{2\\tau})\\Pi^{neq}$ (Чепмен–Энског) | стандарт (Krüger et al., *The Lattice Boltzmann Method*, 2017) | ✓ |\n",
"\n",
"**Литература:**\n",
"- I. V. Karlin, F. Bösch, S. S. Chikatamarla, *Gibbs' principle for the lattice-kinetic theory\n",
" of fluid dynamics*, Phys. Rev. E **90**, 031302(R) (2014).\n",
"- F. Bösch, S. S. Chikatamarla, I. V. Karlin, *Entropic multirelaxation lattice Boltzmann\n",
" models for turbulent flows*, Phys. Rev. E **92**, 043309 (2015) (arXiv:1507.02518).\n",
"- S. Ansumali, I. V. Karlin, H. C. Öttinger, *Minimal entropic kinetic models for\n",
" hydrodynamics*, Europhys. Lett. **63**, 798 (2003).\n",
"- B. Dorschner, F. Bösch, S. S. Chikatamarla, K. Boulouchos, I. V. Karlin, *Entropic\n",
" multi-relaxation time lattice Boltzmann model for complex flows*, J. Fluid Mech. **801**,\n",
" 623–651 (2016).\n",
"- B. Dorschner, N. Frapolli, S. S. Chikatamarla, I. V. Karlin, *Grid refinement for entropic\n",
" lattice Boltzmann models*, Phys. Rev. E **94**, 053311 (2016) (arXiv:1608.06915).\n",
"- D. Lagrava, O. Malaspinas, J. Latt, B. Chopard, *Advances in multi-domain lattice Boltzmann\n",
" grid refinement*, J. Comput. Phys. **231**, 4808 (2012).\n",
"- M. Bouzidi, M. Firdaouss, P. Lallemand, *Momentum transfer of a Boltzmann-lattice fluid with\n",
" boundaries*, Phys. Fluids **13**, 3452 (2001).\n",
"- Q. Zou, X. He, *On pressure and velocity boundary conditions for the lattice Boltzmann BGK\n",
" model*, Phys. Fluids **9**, 1591 (1997).\n",
"- B. Wen, C. Zhang, Y. Tu, C. Wang, H. Fang, *Galilean invariant fluid–solid interfacial\n",
" dynamics in lattice Boltzmann simulations*, J. Comput. Phys. **266**, 161 (2014).\n",
"- Z. Guo, C. Zheng, B. Shi, *Discrete lattice effects on the forcing term in the lattice\n",
" Boltzmann method*, Phys. Rev. E **65**, 046308 (2002).\n",
"- U. Ghia, K. N. Ghia, C. T. Shin, *High-Re solutions for incompressible flow using the\n",
" Navier-Stokes equations and a multigrid method*, J. Comput. Phys. **48**, 387 (1982)."
]
},
{
"cell_type": "markdown",
"id": "a8cc8c0d",
"metadata": {},
"source": [
"---\n",
"# Часть IX. Итоги\n",
"\n",
"- **KBC** = энтропийный MRT-LBM: сохраняющиеся моды не трогаем, сдвиг релаксируем фиксированным\n",
" $2\\beta$ (вязкость), высшие моды — адаптивным $\\gamma$ из условия неувеличения энтропии.\n",
" При $\\gamma=2$ — в точности BGK. Все формулы сверены с первоисточниками и самопроверены\n",
" численно.\n",
"- **Бенчмарки** (на компонентах финальной модели): вязкость по TGV (<6%, факт ≲1%), H-теорема,\n",
" устойчивость на сдвиговом слое (BGK разрушается — KBC держит), каверна ≈ Ghia, дорожки\n",
" Кармана на четырёх телах.\n",
"- **Измельчение сетки**: связка $\\tau_f=2\\tau_c-\\tfrac12$ + масштабирование $f^{neq}$ +\n",
" временна́я интерполяция ghost; вложенный 2×+4× даёт точность мелкой сетки за в разы меньшую\n",
" работу.\n",
"- **Финальная модель** `solver_2x_sdf`: KBC-N1 + SDF/Bouzidi + Zou-He + free-slip + CUDA-графы.\n",
" Главные уроки эволюции: второй член силы — из post-streaming; дрейф массы лечится\n",
" давлением-выходом; зонд и сила — на тонком уровне; графы требуют чистоты горячего пути.\n",
"- **Считывание сил ДОКАЗАНО корректным** (разделы 22, 24): два независимых метода — GMEM с\n",
" торком и баланс импульса ∮[σ·n−ρu(u·n)]ds — после добавления конвективного члена согласуются\n",
" на уровне **0.6–1.0%** по $\\langle C_d\\rangle$; $\\langle C_m\\rangle\\approx0$; масса стабильна.\n",
"- **Исследование факторов постановки** (разделы 23–25):\n",
" - эксперимент №0 (серия по β) **опроверг** гипотезу бокового стеснения: $C_d(\\beta)$\n",
" немонотонен; при этом **St(β→0)=0.181 ≈ лит. 0.183** — кинематика верна;\n",
" - эксперимент №1 (6 факторов): **мягкий $u_y$-выход (F4) улучшил всё** (rms $C_l$ −21%,\n",
" St·(1−β)=0.183 точно, идеальное плато) — жёсткое $u_y=0$ отражало вихри. **Длинные домены\n",
" без демпфера невалидны**: пара «вход-скорость / выход-давление» — недодемпфированный\n",
" акустический резонатор (затухание ∝ν(π/Nx)²); масса накачивается до смещённой ветви\n",
" ⟨ρ⟩≈1.66 либо взрыв (F5, период осцилляций ⟨ρ⟩ ≈ 2Nx/c_s — прямая улика);\n",
" - эксперимент №2 (в работе): **губка перед выходом** (β(x)-поле на L0) — F6/F7/F8; затем\n",
" эксперимент №3 — серия по β на чистой постановке F7 → финальная экстраполяция.\n",
"- **Реестр всех прогонов** — `solver_2x_sdf/out/factors.csv`: полная конфигурация + метрики\n",
" каждого изменения постановки (готовый материал для отдельного исследования факторов).\n",
"- **Дальше**: эксперименты №2–3 → финальные числа; при остаточном превышении — фактор\n",
" разрешения (D-рефайнмент). Затем перенос отработанной схемы на D3Q27/GPU в основной движок\n",
" (часть VII)."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+160
View File
@@ -0,0 +1,160 @@
# solver_2x_sdf — модель цилиндра 2×+SDF, разложенная по компонентам
Самодостаточный LBM-решатель (D2Q9, KBC) для обтекания цилиндра: грубый уровень **L0**
плюс один вложенный патч **L1** (измельчение ×2). Граница цилиндра — **SDF + Bouzidi (no-slip)**;
вход/выход — **Zou-He** (скорость/давление); верх/низ канала — **free-slip (specular)**.
Только GPU/CuPy — **CPU-фолбэка нет**. Запуск — из этой папки: `python run.py`
(серия по блокировке: `python run_blockage.py`).
## Карта файлов (каждый компонент изолирован и легко меняется)
| Файл | Ответственность |
|---|---|
| `backend.py` | жёсткое требование CuPy/GPU, `DTYPE`, device-скаляры, `to_cpu` |
| `config.py` | все параметры модели + производные (τ, β, R, DL, cy/патч из Ny); `from_env()` |
| `lattice.py` | D2Q9 (c_i, w_i, OPP) + KBC-проектор `Ps` на сдвиг-моменты |
| `equilibrium.py` | `feq` (product-form, без `pow`), `macros` (ρ, u) |
| `collision.py` | **столкновение**: `kbc_collide` (энтропийный KBC-N1) — единственный оператор |
| `streaming.py` | **перенос**: `stream` (пул-схема через roll) |
| `geometry.py` | маски тела/стенок и SDF для L0 и L1 |
| `boundary.py` | `build_bc`/`apply_bc` (SDF+Bouzidi) + `channel_bc` (Zou-He) + `free_slip_walls` |
| `forces.py` | **силы**: галилей-инвариантный обмен импульсом `force_gmem` (Fx, Fy, **торк Tz**) + `coefficients` (Cd, Cl, **Cm**) |
| `forces_stress.py` | **кросс-чек силы**: ∮σ·n ds по контуру R+δ вокруг тела (независимый эстиматор) |
| `amr.py` | связка L0↔L1: `patch`/`ghost`/`fill`/`restrict` |
| `solver.py` | оркестратор: persistent-буферы, `step()`, `run()` (+ CUDA-граф) |
| `diagnostics.py` | `analyze`, St (параболическая интерполяция пика), кросс-чек, фиты серии по β |
| `visualization.py` | гифка |ω|, png-панель рядов (`series_2x_sdf.png`), прогресс-бар |
| `run.py` | точка входа (один прогон) |
| `run_blockage.py` | серия по блокировке Ny↑ → β↓, экстраполяция β→0, сводный npz+png |
| `run_factors.py` | факторное исследование постановки (вход/выход/uy-выход); реестр `out/factors.csv` |
## Где какая математика (для ревизии)
- **Столкновение** — `collision.py`. KBC-N1: `f ← f − β(2Δs + γΔh)`, где `Δs = Ps·(f−feq)` —
проекция на сдвиг-моменты, `γ` — энтропийный лимитер. Чтобы поэкспериментировать:
переключить `cfg.collision="bgk"`, либо добавить TRT новой функцией и зарегистрировать в `get`.
- **Перенос** — `streaming.py`. Чистая пул-схема; на физику влияет только корректность сдвигов.
- **Силы** — `forces.py`. Обмен импульсом по линкам тела:
`F = Σ_links c_i (f_i^{после столкн.} + f_ī^{после стриминга})`. Второй член — из поля ПОСЛЕ
стриминга (вернувшаяся Bouzidi-популяция); иначе ведущий симметричный член сокращается и Cd врёт.
Сила/момент меряются на **каждом** подшаге L1 и усредняются (анти-алиасинг; средний Cd не меняется).
**Торк** `Tz` — вокруг центра тела, точка приложения — пересечение линка со стенкой `r_f + q·c_i`
(Bouzidi-доля q уже лежит в BC); `Cm = 2Tz/(U²·DL²)`, для цилиндра ⟨Cm⟩≈0 — контроль симметрии.
- **Кросс-чек силы** — `forces_stress.py`. Независимый эстиматор — баланс импульса
контрольного объёма: `F = ∮[σ·n − ρu(u·n)] ds` по окружности R+δ (δ=2 тонкие ячейки);
`σ = −p′·I − (1−1/(2τ₁))·Π^neq_dev` (строго девиаторная проекция: след Π^neq в KBC
релаксирует с γβ, а не 1/τ). Конвективный член −ρu(u·n) обязателен при δ>0 — без него
систематика +4% по ⟨Cd⟩. Считается раз в `stress_every` шагов **вне CUDA-графа**; сравнение
по средним (⟨Cd⟩, rms Cl) — мгновенные ряды сдвинуты по фазе кольцом R..R+δ.
Согласие двух методов = считывание силы корректно.
- **Серия по блокировке** — `run_blockage.py`. D фиксирован, Ny ∈ {90,128,180} → β ∈ {0.178,
0.125, 0.089}; `cy` и y-границы патча выводятся из Ny автоматически (`config.__post_init__`).
Экстраполяция к β→0 двумя фитами (a+b·β и a+b·β²; спред интерсептов — неопределённость) —
прямое сравнение с безграничной литературой без ручной поправки (1−β)².
## ⚠ Сверка с первоисточниками (2026-08-14): найден дефект, все числа ниже требуют перепрогона
Сверка реализации со статьями в `docs/origins` (Karlin/Bösch/Chikatamarla, PRE **90**, 031302(R)
2014 — принцип; Bösch/Chikatamarla/Karlin, PRE **92**, 043309 2015 — алгоритм; arXiv:1507.02509 —
2D-реализация). **Ядро KBC-N1 подтверждено точным**: проектор `Ps` совпадает с аналитической
s-частью из ур.(10) 2D-статьи (ранг 2, идемпотентен, несёт ровно {N, Π_xy}); равновесие —
точный энтропийный максимизатор (сходится с численной максимизацией S при фикс. ρ, ρu);
γ по замкнутой формуле ур.(17)/(39) совпадает с корнем точного ур.(15); сдвиг-моменты
релаксируют ровно с 2β при любом γ; вязкость по затуханию сдвиговой волны воспроизводится
с ошибкой <0.1%.
**Дефект — порог `GEPS` в `kbc_collide`.** Он был АБСОЛЮТНЫМ (1e-6 в float32, режим по умолчанию),
тогда как `den = ⟨Δh|Δh⟩` квадратична по неравновесию и при разрешённой физике равна ~1e-7…1e-9.
Замерено на боевой конфигурации: откат `γ←2` срабатывал на **77–99% узлов**, то есть решатель
считал **чистый LBGK**, а не KBC. На эталоне 2D-статьи (двойной периодический сдвиговый слой,
Re=30000, N=128, разд. VII) это давало **+9.3%** по энстрофии на t=t_c (0.6599 против эталонных
0.6035); после исправления fp32 даёт 0.6038. Порог сделан относительным (`B.GREL`, доля от ‖Δ‖²);
на тех же полях он теперь не срабатывает нигде (min den/‖Δ‖² ≈ 1.5e-3 против порога 1e-8).
**Следствие: все количественные результаты ниже (Cd, rms Cl, St, серия по блокировке,
факторное исследование №0/№1) получены на дефектной ветке и подлежат перепрогону.**
Качественные выводы про ГУ (мода акустического резонатора, роль конвективного члена
в кросс-чеке) дефектом не затрагиваются.
Исправлено там же:
- **углы домена**: `stream()` на `roll` периодичен по обеим осям, а Zou-He стоял на срезе `1:-1` —
в 4 угловых узлах вход был напрямую связан с выходом (`f[1,0,0] == post[1,0,-1]`, величина ~0.12).
Порядок изменён на `free_slip_walls` → `channel_bc` по всему столбцу; заворот закрыт полностью.
- **диагностика ⟨ρ⟩** считалась по всему домену, включая фиктивные узлы внутри цилиндра
(смещение −5.4e-4 — ровно тот разряд, в котором ⟨ρ⟩ и цитируется). Теперь только по жидкости.
- **`refine` был зашит как 2** в `tau1`, `R01` и радиусе тела на L1/контуре σ·n. Обобщено
(при r=2 значения бит-в-бит прежние; при r=3 вязкость уровней теперь согласована).
**Отдельно — не дефект, а следствие выбора модели.** В KBC-N1 след тензора напряжений T отнесён
к h-части, поэтому объёмная вязкость плавает: ξ = c_s²(1/(γβ) − ½) (ур.36 статьи 2015, ур.57
2D-статьи). Замер на боевой конфигурации: γ ∈ [−4.1, 4.6], медиана ξ/ν ≈ 17, и на **2.1% узлов
ξ < 0** — акустическое АНТИдемпфирование. Это прямо релевантно диагнозу «недодемпфированный
акустический резонатор» ниже: KBC-N2 (T в s-части) даёт ξ = ν фиксированно и является
каноническим средством именно от этой проблемы. Модель не менялась — решение за автором.
## Статус: считывание силы валидировано; идёт факторное исследование постановки
Оператор столкновения зафиксирован — **KBC-N1 с энтропийным стабилизатором** (канонический
Karlin/Bösch/Chikatamarla 2014), без замен (никаких BGK/TRT).
**Полная документация исследования — в ноутбуке `../kbc_lbm.ipynb` (части VI, разделы 22–24).**
**Считывание силы доказано корректным** (прогон 100k, 174×90, 2026-06-09):
- кросс-чек GMEM vs ∮σ·n: dCd=4.1% (систематика объяснена конвективным членом −ρu(u·n),
добавлен в `forces_stress.py` — ожидаемое расхождение ≲1%);
- ⟨Cm⟩=−0.00003 (≈0 — симметрия), ⟨ρ⟩=1.001 стабильна, Cd=1.799 воспроизводит прежнюю
итерацию бит-в-бит (осреднение по подшагам не сместило среднее).
**Эксперимент №0 (серия по блокировке) — гипотеза бокового стеснения ОПРОВЕРГНУТА:**
Cd(β) немонотонен: 1.799 / 1.771 / **1.870** при Ny=90/128/180; rms Cl на широком канале
вырос (0.835), НЧ-модуляция (окна Cd 1.68–1.98). При этом **St(β→0)=0.181 ≈ лит. 0.183** —
кинематика верна, завышены давленческие амплитуды. Диагноз: продольные границы
(вход 2.5D с жёстким профилем; выход 8.3D Zou-He с жёстким uy=0 — отражает вихри).
**Эксперимент №1 (прогнан 2026-06-10) — факторное исследование** `run_factors.py`, 6 прогонов
при Ny=90 фикс. Итоги (полный разбор — ноутбук, раздел 24; реестр — `out/factors.csv`):
- конвективный член подтверждён: кросс-чек B0 4.1%→**1.0%**, F4 — **0.6%**;
- **F4 (мягкий uy-выход) — лучший по всем метрикам**: Cd 1.799→1.754, rms Cl 0.705→**0.557**,
St·(1−β)=**0.183** (точно лит.), ⟨ρ⟩=1.0000, нулевая НЧ-модуляция;
- **длинные домены без демпфера НЕвалидны** (F1/F2/F3 → смещённый режим ⟨ρ⟩≈1.66; F5 → NaN):
пара «вход-скорость / выход-давление» — недодемпфированный акустический резонатор
(затухание моды ∝ν(π/Nx)²; период осцилляций ⟨ρ⟩ у F5 ≈ 2Nx/c_s — прямая улика).
**Эксперимент №2 (в работе) — губка перед выходом**: плавный рост ν (×30, smoothstep) в
последних `sponge_len` столбцах L0; β(x) — поле (graph-safe), патч L1 губки не видит.
Факторы: F6_sponge (база+uy-extrap+губка 32 — валидация против F4), F7_long_sponge
(390×90, вход 6D/выход 18.3D — целевая постановка), F8_long_sp_uy0 (изоляция вклада губки).
Критерии: ⟨ρ⟩≈1.000 у всех; F7: Cd≈1.5–1.6, rms Cl<0.45. Затем эксперимент №3 — серия по β
на конфигурации F7.
| прогон | команда | артефакты в `out/` |
|---|---|---|
| смоук | `AMR_STEPS=2000 python run.py` | graph self-check OK, Cm≈0, кросс-чек ≲1% (с конв. членом) |
| основной | `python run.py` | `cyl_2x_sdf.gif`, `series_2x_sdf.png`, `probe_2x_sdf.npz` |
| серия по β | `python run_blockage.py` | `probe_blockage_Ny*.npz`, `blockage_summary.npz`, `blockage_extrapolation.png` |
| **факторы №2** | `AMR_FACTORS=F6_sponge,F7_long_sponge,F8_long_sp_uy0 python run_factors.py` | `factor_*.npz`, **`factors.csv`** (реестр; при смене схемы старый → `factors_vN.csv`), `factors_summary.png` |
Env-ручки: `AMR_NX`, `AMR_CX` (продольная геометрия; x-границы патча L1 выводятся из cx),
`AMR_OUTLET_UY=zero|extrapolate`, `AMR_SPONGE_LEN`/`AMR_SPONGE_NU` (губка),
`AMR_FACTORS=...` (подмножество факторов).
**Что привело сюда (хронология фиксов):**
1. Зонд скорости → L1; оценка St с параболической интерполяцией пика.
2. Сила → галилей-инвариантный `force_gmem` (Wen 2014), готов к подвижным телам.
3. Стартовое возмущение (поперечный sin-импульс входа) — запуск дорожки за единицы циклов.
4. Диагностика по окнам (`convergence_report`, трекинг ⟨ρ⟩) — вскрыла дрейф массы.
5. **Ключевой фикс:** грубое ГУ выхода (zero-gradient) копило массу → поток глох (Cd/rms→0 за 100k).
Заменено на **Zou-He скоростной вход + давление-выход** (`boundary.channel_bc`) — масса заякорена.
6. **Free-slip стенки** (`boundary.free_slip_walls`, specular) + умеренно расширенный домен (меньше
блокировка). Цилиндр остаётся no-slip.
7. Cm + осреднение по подшагам + кросс-чек ∮σ·n ds + серия по блокировке (текущая итерация).
**Прямой raw-матч на одной сетке непрактичен** (нужен `β<0.03 → Ny≈500+`); серия из трёх умеренных
Ny с экстраполяцией даёт безпоправочное сравнение за ~3 стоимости одного прогона (патч L1 —
основная работа — от Ny не зависит).
## CUDA Graphs
По умолчанию ВКЛ (`cfg.use_cuda_graph`, env `AMR_CUDAGRAPH=0` — выкл). **Захватываются успешно** (после
замены `cupy.stack`→`backend.pack`, отказа от cuBLAS в проекторе KBC и clip-литералов в ядре —
всё это убрало host→device при capture). На шаге t=1 — рантайм-самопроверка (граф vs eager); при
расхождении/сбое — откат на eager с печатью трейсбэка.
+54
View File
@@ -0,0 +1,54 @@
# AMR-связка L0 (грубый) ↔ L1 (тонкий, измельчение r).
#
# Идея: на тонком патче считаем r подшагов по времени на каждый шаг L0. Границы патча («ghost»)
# заполняем интерполяцией с грубого уровня (по пространству + по времени между «до» и «после» шага L0),
# а внутренность патча после подшагов проецируем обратно на грубый уровень (рестрикция).
# Неравновесная часть масштабируется множителями Rcf (грубый→тонкий) и Rfc (тонкий→грубый),
# т.к. при смене τ масштаб неравновесия меняется.
import backend as B
import lattice as L
from equilibrium import feq, macros
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE
def patch(pNx, pNy, ax, bx, ay, by, r):
"""Описание патча: размеры (Nfy,Nfx), индексы/веса билинейной интерполяции с грубого уровня,
срезы для рестрикции каждого r-го узла."""
Wx, Wy = bx - ax, by - ay
Nfx, Nfy = r*Wx + 1, r*Wy + 1
FX, FY = cp.meshgrid(ax + cp.arange(Nfx)/r, ay + cp.arange(Nfy)/r) # координаты тонких узлов в L0
x0 = cp.floor(FX).astype(cp.int64); y0 = cp.floor(FY).astype(cp.int64)
x1 = cp.minimum(x0 + 1, pNx - 1); y1 = cp.minimum(y0 + 1, pNy - 1)
return dict(Nfx=int(Nfx), Nfy=int(Nfy), ax=ax, bx=bx, ay=ay, by=by, r=r,
x0=x0, y0=y0, x1=x1, y1=y1,
tx=(FX - x0).astype(DTYPE), ty=(FY - y0).astype(DTYPE),
slx=slice(r, r*Wx, r), sly=slice(r, r*Wy, r))
def pint(fld, P):
"""Билинейная интерполяция поля fld (грубого) в узлы тонкого патча."""
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):
"""Граничные значения тонкого уровня из грубого поля pf: равновесие по интерполированным ρ,u
плюс масштабированная неравновесная часть Rcf·neq.
u в feq передаём кортежем (без cupy.stack) — H2D ломает CUDA-graph capture."""
r, u = macros(pf); neq = pf - feq(r, u)
return feq(pint(r, P), (pint(u[0], P), pint(u[1], P))) + Rcf*pint(neq, P)
def fill(cf, gh):
"""Записать ghost-значения gh в рамку (1 ряд) тонкого поля cf."""
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):
"""Спроецировать тонкое поле cf обратно на грубое pf (каждый r-й узел), масштабируя neq на 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
+40
View File
@@ -0,0 +1,40 @@
# Бэкенд: ТОЛЬКО GPU/CuPy. CPU-фолбэка нет (по требованию). Если CuPy/GPU недоступны — жёсткая ошибка.
# Здесь же — общие device-константы (DTYPE, скаляры 0/1/2) и помощник переноса на host для вывода.
import os
import cupy as cp
try:
_probe = (cp.zeros(2) + 1).sum(); cp.cuda.Device().synchronize() # реальная операция на device
except Exception as e:
raise RuntimeError("solver_2x_sdf требует рабочую GPU + CuPy (CPU-фолбэк удалён намеренно).") from e
xp = cp # единый алиас вычислительного бэкенда
DTYPE = cp.float64 if os.environ.get("AMR_FP64") else cp.float32
# Порог вырожденности знаменателя γ. ОТНОСИТЕЛЬНЫЙ (доля от ‖Δ‖² = ⟨Δ|Δ⟩), а НЕ абсолютный.
# Почему: den = ⟨Δh|Δh⟩ — квадратичная по неравновесию величина, у развитого следа она
# ~1e-7…1e-9 при вполне разрешённой физике. Прежний абсолютный порог 1e-6 (fp32) срабатывал
# на 77–99% узлов и молча подменял γ на 2, т.е. гнал ЧИСТЫЙ LBGK вместо KBC (замерено:
# энстрофия сдвигового слоя Re=3e4 уходила на +9%). den — сумма НЕотрицательных слагаемых,
# её относительная точность ~eps типа, поэтому дробный порог одинаков для fp32/fp64.
# 1e-8 на шесть порядков ниже наблюдаемого минимума den/‖Δ‖² (~5e-4) — срабатывает только на
# истинном вырождении (Δh ≡ 0), где γ всё равно ни на что не влияет, т.к. умножается на Δh.
GREL = 1e-8
BACKEND = f"CuPy/GPU dtype={'float64' if DTYPE == cp.float64 else 'float32'}"
# device-скаляры — не создаём host→device на горячем пути (важно для CUDA-graph capture)
ZERO = cp.zeros((), DTYPE)
ONE = cp.asarray(1.0, DTYPE)
TWO = cp.asarray(2.0, DTYPE)
def to_cpu(a):
"""Перенос на host (numpy) — только для диагностики/визуализации, не в солвере."""
return cp.asnumpy(a)
def pack(comps):
"""Собрать кортеж одинаковых по форме массивов в один (len,*shape) БЕЗ cupy.stack.
cupy.stack/concatenate грузит на device host-массив указателей (H2D) → запрещено при CUDA-graph
capture. Здесь: преаллокация + присваивание срезов — только device→device."""
out = xp.empty((len(comps),) + comps[0].shape, dtype=comps[0].dtype)
for i, c in enumerate(comps):
out[i] = c
return out
+104
View File
@@ -0,0 +1,104 @@
# Граничные условия: (1) тело/стенки — SDF + интерполированный отскок Bouzidi; (2) вход/выход канала.
#
# Bouzidi (линейный): для линка из жидкого узла x_f в твёрдый по направлению i вводится
# доля пересечения q = |x_f→стенка| / |x_f→x_solid| ∈ (0,1):
# q < 1/2 (есть «дальний» жидкий сосед x_f − c_i): f_ī = 2q·f_i^post + (1−2q)·f_i^post(дальний)
# q ≥ 1/2: f_ī = (1/2q)·f_i^post + (1−1/2q)·f_ī^post
# Здесь f_i^post — после столкновения, f_ī — то, что вернётся в x_f после переноса.
import backend as B
import lattice as L
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE
Q = L.Q; Cx = L.Cx; Cy = L.Cy; OPP = L.OPP
def build_bc(solid, phi, body):
"""Сборка структуры ГУ (один раз). Для каждого направления i (1..8):
mask — жидкий узел, чей сосед по +c_i твёрдый;
q — доля пересечения по SDF (клип [0.02,0.98]);
xff — есть ли «дальний» жидкий сосед (для линейной формулы при q<1/2);
cl — подмаска линков именно ТЕЛА body (для расчёта силы)."""
fluid = ~solid; bc = []
for i in range(1, Q):
nb_solid = cp.roll(solid, (-Cy[i], -Cx[i]), (0, 1)); mask = fluid & nb_solid
cl = mask & cp.roll(body, (-Cy[i], -Cx[i]), (0, 1))
phinb = cp.roll(phi, (-Cy[i], -Cx[i]), (0, 1))
qq = phi / (phi - phinb) # деление; nan/inf маскируются ниже
q = xp.where(mask, cp.clip(qq, 0.02, 0.98), cp.asarray(0.5, DTYPE))
xff = mask & (~cp.roll(solid, (Cy[i], Cx[i]), (0, 1)))
bc.append((i, OPP[i], mask, q.astype(DTYPE), xff, cl))
return bc
def apply_bc(f, fpost, bc):
"""Применить Bouzidi ко всем линкам (полностью через where, GPU-friendly).
f — поле ПОСЛЕ стриминга (его и правим); fpost — поле ПОСЛЕ столкновения (до стриминга)."""
for (i, ib, mask, q, xff, cl) in bc:
fi = fpost[i]; fib = fpost[ib]
fiback = cp.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 channel_bc(f, uin, rho_out=1.0, outlet_uy="zero"):
"""Корректные ГУ канала (Zou & He 1997), нумерация D2Q9: 1=E,2=N,3=W,4=S,5=NE,6=NW,7=SW,8=SE.
ВХОД (запад, x=0): скоростной Zou-He — задаём u=(ux,uy) из uin, плотность ρ ПЛАВАЕТ; достраиваем
приходящие извне популяции 1,5,8 из известных 0,2,3,4,6,7.
ВЫХОД (восток, x=-1): давление Zou-He — задаём ρ=rho_out, скорость ux плавает; достраиваем 3,6,7.
Поперечная скорость на выходе — по outlet_uy:
"zero" — классический Zou-He: uy=0 (жёстко). Вихри дорожки приходят на выход с
uy~±0.3U → принудительное зануление отражает их назад к телу (завышает
rms Cl и модулирует Cd — см. факторное исследование в ноутбуке);
"extrapolate" — uy берётся из соседнего столбца x=-2 (нуль-градиент поперечной скорости);
ρ-якорь (масса) сохраняется, кинематическое отражение снимается.
Зачем давление-выход вообще: грубый zero-gradient по f не выпускал поток → масса/противодавление
копились, поток глох (⟨ρ⟩↑, Cd/rmsCl→0). Давление-выход якорит массу, скоростной вход её не
пересоздаёт → дрейфа нет. Всё на срезах (graph-safe, без host→device).
ПОРЯДОК ВЫЗОВА: строго ПОСЛЕ free_slip_walls и покрывая ВЕСЬ столбец (включая ряды 0 и Ny−1).
Причина: stream() сделан через roll и потому периодичен по ОБЕИМ осям. Заворот по y снимают
стенки, заворот по x — этот ГУ. Если оставить углы (ряды 0 и Ny−1) без Zou-He, в них популяции
с cx=+1 на входе приходят прямо из столбца выхода (замерено: f[1,0,0] == post[1,0,-1], величина
~0.12) — вход и выход оказываются физически связаны в 4 узлах. Известные популяции, по которым
здесь считаются ρ (вход) и ux (выход), x-заворота не содержат ни в одном ряду, а те, что
заворот содержат, — это ровно неизвестные, которые ГУ и перезаписывает; поэтому такой порядок
закрывает углы полностью."""
# --- вход (запад): скоростной Zou-He ---
ux = uin[0, :, 0]; uy = uin[1, :, 0]
f0 = f[0, :, 0]; f2 = f[2, :, 0]; f3 = f[3, :, 0]
f4 = f[4, :, 0]; f6 = f[6, :, 0]; f7 = f[7, :, 0]
rho = (f0 + f2 + f4 + 2.0*(f3 + f6 + f7)) / (1.0 - ux)
f[1, :, 0] = f3 + (2.0/3.0)*rho*ux
f[5, :, 0] = f7 - 0.5*(f2 - f4) + (1.0/6.0)*rho*ux + 0.5*rho*uy
f[8, :, 0] = f6 + 0.5*(f2 - f4) + (1.0/6.0)*rho*ux - 0.5*rho*uy
# --- выход (восток): давление Zou-He ---
g0 = f[0, :, -1]; g1 = f[1, :, -1]; g2 = f[2, :, -1]
g4 = f[4, :, -1]; g5 = f[5, :, -1]; g8 = f[8, :, -1]
uxo = (g0 + g2 + g4 + 2.0*(g1 + g5 + g8)) / rho_out - 1.0
if outlet_uy == "extrapolate":
# нуль-градиент поперечной скорости: uy с предвыходного столбца (он полностью известен)
col = f[:, :, -2]
uyo = ((col[2] + col[5] + col[6]) - (col[4] + col[7] + col[8])) / col.sum(0)
else:
uyo = 0.0
f[3, :, -1] = g1 - (2.0/3.0)*rho_out*uxo
f[6, :, -1] = g8 - 0.5*(g2 - g4) - (1.0/6.0)*rho_out*uxo + 0.5*rho_out*uyo
f[7, :, -1] = g5 + 0.5*(g2 - g4) - (1.0/6.0)*rho_out*uxo - 0.5*rho_out*uyo
return f
def free_slip_walls(f):
"""FREE-SLIP (скользящие) стенки канала: верх/низ — зеркальное (specular) отражение.
Сохраняет касательный (x) импульс (нет трения, нет пограничного слоя) и нормальный заворачивает;
масса сохраняется (копирование популяций) → дрейфа не вносит. Применять ПОСЛЕ стриминга.
Нумерация: 2=(0,+1) 4=(0,-1) 5=(+1,+1) 6=(-1,+1) 7=(-1,-1) 8=(+1,-1).
Нижняя стенка (row 0): достраиваем вверх-идущие из вниз-идущих (зеркало по y, x сохранён).
Верхняя стенка (row -1): достраиваем вниз-идущие из вверх-идущих.
ВНИМАНИЕ: это только искусственные стенки канала; цилиндр остаётся no-slip (Bouzidi).
ПОРЯДОК: вызывать ПЕРЕД channel_bc (см. его docstring — так закрываются углы домена)."""
f[2, 0, :] = f[4, 0, :]; f[5, 0, :] = f[8, 0, :]; f[6, 0, :] = f[7, 0, :]
f[4, -1, :] = f[2, -1, :]; f[7, -1, :] = f[6, -1, :]; f[8, -1, :] = f[5, -1, :]
return f
+42
View File
@@ -0,0 +1,42 @@
# Оператор СТОЛКНОВЕНИЯ — KBC-N1 (энтропийный), единственный оператор модели.
#
# KBC-N1 (Karlin/Bösch/Chikatamarla 2014):
# Δ = f − feq (неравновесная часть)
# Δs = Ps·Δ (проекция на сдвиг-моменты {cx²−cy², cxcy})
# Δh = Δ − Δs (остальная часть)
# γ = 1/β − (2 − 1/β)·⟨Δs,Δh⟩ / ⟨Δh,Δh⟩, ⟨a,b⟩ = Σ_i a_i b_i / feq_i (H-теорема)
# f_post = f − β(2Δs + γΔh), β = 1/(2τ)
# γ — энтропийный стабилизатор: подстраивает диссипацию неhydro-мод под энтропийное ограничение
# (поэтому KBC устойчив при τ→1/2). Сдвиг-моменты (вязкость) релаксируют с правильным 2β = 1/τ,
# то есть физическая вязкость задаётся только τ — стабилизатор её не меняет.
#
# Порядок шагов дословно совпадает с алгоритмом статьи (Bösch/Chikatamarla/Karlin, PRE 92,
# 043309 (2015), разд. III, листинг после ур.39): ρ,u → feq → Δs → Δh = f − feq − Δs → γ → релаксация.
# Вырожденный случай ⟨Δh|Δh⟩→0 отсекается ОТНОСИТЕЛЬНЫМ порогом B.GREL (см. backend.py):
# абсолютный порог здесь недопустим — den квадратична по неравновесию и физически мала.
import backend as B
import lattice as L
cp = B.cp; xp = B.xp
Q = L.Q; Ps = L.Ps; GREL = B.GREL; ONE = B.ONE; TWO = B.TWO
def _project_shift(dfn):
"""Δs = Ps·Δ без cuBLAS (gemm при CUDA-graph capture тащит alpha/beta host→device).
Реализовано как Σ_k Ps[i,k]·Δ[k] через broadcast+редукцию — только обычные ядра."""
dfn2d = dfn.reshape(Q, -1) # (Q, N)
return (Ps[:, :, None] * dfn2d[None]).sum(1).reshape(dfn.shape)
def kbc_collide(f, fe, beta):
dfn = f - fe
ds = _project_shift(dfn)
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 (обе ветки where считаются)
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)
# единственный оператор столкновения модели
collide = kbc_collide
+138
View File
@@ -0,0 +1,138 @@
# Конфигурация модели 2×+SDF. Всё в одном месте, чтобы математику/режимы было легко менять.
# Единицы — решёточные (lu — длина воксель, ts — шаг по времени).
import os
from dataclasses import dataclass
from typing import Optional
@dataclass
class Config:
# --- сетка / геометрия L0 (умеренно расширены: меньше блокировка + больше следа) ---
Nx: int = 174 # домен по x (цилиндр на 40 → ~8.4D вниз по потоку до выхода)
Ny: int = 90 # домен по y (стенки free-slip; блокировка β=D/Ny — варьируется серией)
D: int = 16 # диаметр цилиндра на грубом уровне L0
cx: int = 40
cy: Optional[int] = None # центр по вертикали; по умолчанию Ny//2 (см. __post_init__)
# --- физика ---
U: float = 0.07 # скорость набегающего потока
Re: float = 150.0 # число Рейнольдса по D и U
# --- время ---
steps: int = 100000
ramp: int = 1000 # smoothstep-разгон входной скорости (гасит импульсный старт)
frame_every: int = 34 # период сохранения кадров для гифки
# --- патч уровня L1 (в координатах L0); refine=2 → разрешение Δx/2 ---
ax1: Optional[int] = None # по умолчанию cx − 1.5D (запас перед телом; см. __post_init__)
bx1: Optional[int] = None # по умолчанию cx + 6.25D (расширен в след)
ay1: Optional[int] = None # по умолчанию cy − 2D (центрирован по cy)
by1: Optional[int] = None # по умолчанию cy + 2D
refine: int = 2 # коэффициент измельчения L1 (модель «2×» рассчитана на 2)
# --- зонд скорости в следе: px = cx + probe_dx*D, py = cy (берётся с тонкой сетки L1) ---
probe_dx: int = 3
# --- стартовое возмущение (триггер вихревой дорожки): поперечный sin-импульс входа ---
pert_amp: float = 0.1 # амплитуда u_y входа, доля от U (0 — выключить)
pert_dur: int = 300 # длительность импульса в шагах; окно [ramp, ramp+pert_dur]
# --- кросс-чек силы: интеграл тензора напряжений σ·n по окружности R1+δ на L1 (forces_stress.py) ---
stress_every: int = 50 # период выборки в шагах L0 (0 — выключить кросс-чек)
stress_ntheta: int = 256 # число точек контура по углу
stress_delta: float = 2.0 # отступ контура от поверхности (в тонких ячейках L1);
# δ=2 → билинейный стенсиль не трогает Bouzidi-зону (q_max≈0.98)
# --- ГУ выхода: режим поперечной скорости на Zou-He давление-выходе ---
# "zero" — классика: uy=0 на выходе (жёстко; отражает вихри дорожки)
# "extrapolate" — uy берётся из соседнего столбца (нуль-градиент); ρ-якорь сохраняется
outlet_uy: str = "zero"
# --- губка (absorbing layer) перед выходом: плавный рост вязкости в последних sponge_len
# столбцах L0 (smoothstep, ν → ν·sponge_nu_mult). Гасит вихри И акустику до прихода на
# давление-выход. Зачем: канал «вход-скорость + выход-давление» — недодемпфированный
# акустический резонатор (затухание моды ∝ ν(π/Nx)²); на длинных доменах мода раскачивается
# стартовым транзиентом → смещённый режим ⟨ρ⟩≈1.66 или взрыв (факторное исследование,
# эксперимент №1: F1/F2/F3/F5). 0 — выключена.
sponge_len: int = 0
sponge_nu_mult: float = 30.0
# --- исполнение ---
use_cuda_graph: bool = True
def __post_init__(self):
# производная геометрия по вертикали: центр и патч следуют за Ny (для серии по блокировке)
if self.cy is None:
self.cy = self.Ny // 2
if self.ay1 is None:
self.ay1 = self.cy - 2 * self.D
if self.by1 is None:
self.by1 = self.cy + 2 * self.D
# производная геометрия по горизонтали: патч следует за cx (для факторов вход/выход)
if self.ax1 is None:
self.ax1 = self.cx - (3 * self.D) // 2
if self.bx1 is None:
self.bx1 = self.cx + (25 * self.D) // 4
assert 1 <= self.ay1 < self.by1 <= self.Ny - 2, (
f"патч L1 [{self.ay1},{self.by1}] должен лежать строго внутри (0,{self.Ny-1}) — "
f"specular-ряды стенок не накрывать; минимально допустимое Ny ≈ {4*self.D + 6}")
assert 2 <= self.ax1 < self.cx < self.bx1 <= self.Nx - 2, (
f"патч L1 [{self.ax1},{self.bx1}] должен лежать строго внутри (1,{self.Nx-1}) "
f"и накрывать тело (cx={self.cx})")
assert self.outlet_uy in ("zero", "extrapolate"), self.outlet_uy
if self.sponge_len:
assert self.Nx - 1 - self.sponge_len > self.bx1, (
f"губка (последние {self.sponge_len} столбцов) не должна накрывать патч L1 "
f"(bx1={self.bx1}, Nx={self.Nx})")
# ---------- производные величины ----------
@property
def nu(self): return self.U * self.D / self.Re # кинематическая вязкость (lu²/ts)
@property
def tau0(self): return self.nu / (1.0/3.0) + 0.5 # время релаксации на L0 (cs²=1/3)
@property
def tau1(self): # связка τ при измельчении в r раз: ν одна и та же ⇒ τ_f = r(τ_c − ½) + ½
return self.refine * (self.tau0 - 0.5) + 0.5
@property
def beta0(self): return 1.0 / (2.0 * self.tau0) # β = 1/(2τ) для KBC/BGK на L0
@property
def beta1(self): return 1.0 / (2.0 * self.tau1) # β на L1
@property
def R01(self): # масштаб неравновесной части грубый→тонкий: f^neq ∝ τ·δt ⇒ (τ_f/τ_c)/r
return self.tau1 / (self.refine * self.tau0)
@property
def Rf01(self): return 1.0 / self.R01 # тонкий→грубый
@property
def DL(self): return self.refine * self.D # эффективный диаметр у тела (на L1)
@property
def px(self): return self.cx + self.probe_dx * self.D
@property
def py(self): return self.cy
def from_env(**overrides):
"""Создать Config с учётом переменных окружения и явных overrides.
Геометрия (AMR_NY/AMR_NX/AMR_CX) попадает в overrides ДО конструктора —
производные cy/ay1/by1/ax1/bx1 строятся в __post_init__."""
if "Ny" not in overrides and os.environ.get("AMR_NY"):
overrides["Ny"] = int(os.environ["AMR_NY"])
if "Nx" not in overrides and os.environ.get("AMR_NX"):
overrides["Nx"] = int(os.environ["AMR_NX"])
if "cx" not in overrides and os.environ.get("AMR_CX"):
overrides["cx"] = int(os.environ["AMR_CX"])
if "outlet_uy" not in overrides and os.environ.get("AMR_OUTLET_UY"):
overrides["outlet_uy"] = os.environ["AMR_OUTLET_UY"]
if "sponge_len" not in overrides and os.environ.get("AMR_SPONGE_LEN"):
overrides["sponge_len"] = int(os.environ["AMR_SPONGE_LEN"])
if "sponge_nu_mult" not in overrides and os.environ.get("AMR_SPONGE_NU"):
overrides["sponge_nu_mult"] = float(os.environ["AMR_SPONGE_NU"])
cfg = Config(**overrides)
if os.environ.get("AMR_STEPS"):
cfg.steps = int(os.environ["AMR_STEPS"])
if "AMR_CUDAGRAPH" in os.environ:
cfg.use_cuda_graph = (os.environ["AMR_CUDAGRAPH"] != "0")
if os.environ.get("AMR_STRESS_EVERY"):
cfg.stress_every = int(os.environ["AMR_STRESS_EVERY"])
if os.environ.get("AMR_STRESS_NTHETA"):
cfg.stress_ntheta = int(os.environ["AMR_STRESS_NTHETA"])
if os.environ.get("AMR_STRESS_DELTA"):
cfg.stress_delta = float(os.environ["AMR_STRESS_DELTA"])
return cfg
+140
View File
@@ -0,0 +1,140 @@
# Диагностика по собранным рядам сил/скорости. numpy здесь — только host-постобработка малых рядов
# (FFT/статистика), это НЕ вычислительный бэкенд солвера.
import numpy as np
import backend as B
def strouhal(uy_host, D, U, pad=8192):
"""Число Струхаля из ряда поперечной скорости в следе.
УЛУЧШЕНО: параболическая (3-точечная) интерполяция пика спектра → суб-биновая точность.
(Раньше брался ближайший FFT-бин: шаг по St ≈ D/(U·pad) ≈ 0.028, из-за чего St 'застревал'.)"""
sig = uy_host[len(uy_host)//2:] # установившийся режим — вторая половина
sig = sig - sig.mean()
amp = np.abs(np.fft.rfft(sig, n=pad))
if len(amp) <= 3:
return 0.0, np.fft.rfftfreq(pad, 1.0), amp
k = 1 + int(np.argmax(amp[1:])) # индекс пика (без DC)
if 1 <= k < len(amp) - 1: # параболическая интерполяция вершины
a0, a1, a2 = amp[k-1], amp[k], amp[k+1]
denom = (a0 - 2*a1 + a2)
delta = 0.5*(a0 - a2)/denom if denom != 0 else 0.0
else:
delta = 0.0
f_peak = (k + delta) / pad
return f_peak * D / U, np.fft.rfftfreq(pad, 1.0), amp
def analyze(res, cfg):
Fx = B.to_cpu(res["Fx"]); Fy = B.to_cpu(res["Fy"]); uy = B.to_cpu(res["uy"])
DL, U, D = res["DL"], cfg.U, cfg.D
n = len(uy); h = slice(n//2, n)
Cd = 2.0*Fx/(U**2*DL); Cl = 2.0*Fy/(U**2*DL)
St, freqs, amp = strouhal(uy, D, U)
a = dict(Cd=float(Cd[h].mean()), Clrms=float(Cl[h].std()), St=float(St),
uyrms=float(uy[h].std()), Cd_s=Cd, Cl_s=Cl, uy=uy, freqs=freqs, amp=amp, h0=n//2)
if "Tz" in res: # момент: Cm = 2Tz/(U²·DL²); для цилиндра ⟨Cm⟩≈0
Cm = 2.0*B.to_cpu(res["Tz"])/(U**2*DL*DL)
a["Cm"] = float(Cm[h].mean()); a["Cmrms"] = float(Cm[h].std()); a["Cm_s"] = Cm
K = int(res.get("stress_every", 0) or 0) # кросс-чек σ·n (редкая сетка по времени)
if K and len(res.get("Fx_st", ())) > 3:
Fxs = B.to_cpu(res["Fx_st"]); Fys = B.to_cpu(res["Fy_st"]); Tzs = B.to_cpu(res["Tz_st"])
m = len(Fxs); hs = slice(m//2, m)
Cds = 2.0*Fxs/(U**2*DL); Cls = 2.0*Fys/(U**2*DL); Cms = 2.0*Tzs/(U**2*DL*DL)
a.update(Cd_st=float(Cds[hs].mean()), Clrms_st=float(Cls[hs].std()),
Cm_st=float(Cms[hs].mean()), Cd_st_s=Cds, Cl_st_s=Cls, stress_every=K)
return a
def print_table(a, cfg):
# поправка на блокировку канала (приведение к скорости в зазоре U/(1−β)):
# St — кинематическая → ×(1−β); Cd, rms Cl ~ скорость² → ×(1−β)²
beta = cfg.D / cfg.Ny
St_c = a['St'] * (1 - beta)
Cd_c = a['Cd'] * (1 - beta)**2
Cl_c = a['Clrms'] * (1 - beta)**2
cm = a.get('Cm')
cms = f"{cm:>10.5f}" if cm is not None else f"{'—':>10}"
print("\n=== Модель 2×+SDF — установившийся режим ===")
print(f"{'версия':18}{'D у тела':>9}{'St':>8}{'<Cd>':>9}{'rms Cl':>9}{'<Cm>':>10}{'rms u_y':>9}")
print(f"{'2×+SDF (raw)':18}{cfg.DL:>9}{a['St']:>8.3f}{a['Cd']:>9.3f}{a['Clrms']:>9.4f}{cms}{a['uyrms']:>9.4f}")
print(f"{'2×+SDF (×блок.)':18}{cfg.DL:>9}{St_c:>8.3f}{Cd_c:>9.3f}{Cl_c:>9.4f}{'—':>10}{'—':>9}")
print(f"{'лит. Re≈150':18}{'—':>9}{0.183:>8.3f}{1.330:>9.3f}{'~0.3':>9}{0.0:>10.1f}{'':>9}")
print(f"(блокировка β=D/Ny={beta:.3f}; поправка: St×(1−β), Cd/rmsCl×(1−β)². "
"Лит. — безграничный цилиндр; ⟨Cm⟩≈0 — контроль симметрии; см. README)")
def print_crosscheck(a):
"""Сравнение двух НЕЗАВИСИМЫХ считываний силы: GMEM (обмен импульсом по линкам) vs ∮σ·n ds
(интеграл тензора напряжений по контуру R+δ). Сходство — прямое свидетельство корректности
считывания; сравниваем средние (мгновенные ряды σ·n сдвинуты по фазе кольцом R..R+δ)."""
if "Cd_st" not in a:
print("\n[кросс-чек σ·n: выключен (stress_every=0) или слишком мало точек]")
return
dcd = abs(a["Cd_st"] - a["Cd"]) / max(abs(a["Cd"]), 1e-12)
dcl = abs(a["Clrms_st"] - a["Clrms"]) / max(abs(a["Clrms"]), 1e-12)
cm = a.get("Cm", float("nan"))
print("\n=== Кросс-чек считывания силы: GMEM vs ∮σ·n ds (установившийся режим) ===")
print(f"{'метод':26}{'<Cd>':>9}{'rms Cl':>9}{'<Cm>':>10}")
print(f"{'GMEM (обмен импульсом)':26}{a['Cd']:>9.3f}{a['Clrms']:>9.4f}{cm:>10.5f}")
print(f"{'∮σ·n ds (контур R+δ)':26}{a['Cd_st']:>9.3f}{a['Clrms_st']:>9.4f}{a['Cm_st']:>10.5f}")
verdict = "СОГЛАСОВАНО" if (dcd < 0.05 and dcl < 0.10) else "ПРОВЕРИТЬ"
print(f"расхождение: dCd={dcd*100:.1f}% (порог 5%), d(rmsCl)={dcl*100:.1f}% (порог 10%) → {verdict}")
def fit_blockage(betas, vals):
"""Экстраполяция величины серии к β→0 двумя моделями: val ≈ a + b·β и val ≈ a + b·β².
(Теория solid-blockage даёт ведущий член O(β²), практика поправки (1−β)² содержит и линейный.)
Спред интерсептов |a_lin − a_quad| — оценка модельной неопределённости экстраполяции."""
b = np.asarray(betas, float); v = np.asarray(vals, float)
sl, al = np.polyfit(b, v, 1)
sq, aq = np.polyfit(b**2, v, 1)
return dict(lin=(float(al), float(sl)), quad=(float(aq), float(sq)),
spread=float(abs(al - aq)))
def print_blockage_table(rows, fits):
"""Сводка серии по блокировке. rows — список dict(Ny, beta, St, Cd, Clrms, Cm, Cd_st);
fits — dict('Cd'/'Clrms'/'St' → результат fit_blockage)."""
print("\n=== Серия по блокировке: D фикс., Ny варьируется ===")
print(f"{'Ny':>5}{'β=D/Ny':>9}{'St':>8}{'St(1−β)':>9}{'<Cd>':>9}{'rms Cl':>9}{'<Cm>':>10}{'Cd σ·n':>9}")
for r in rows:
cdst = f"{r['Cd_st']:>9.3f}" if r.get("Cd_st") is not None else f"{'—':>9}"
print(f"{r['Ny']:>5}{r['beta']:>9.3f}{r['St']:>8.3f}{r['St']*(1-r['beta']):>9.3f}"
f"{r['Cd']:>9.3f}{r['Clrms']:>9.4f}{r['Cm']:>10.5f}{cdst}")
print("экстраполяция β→0 (интерсепт лин. фита / квадр. фита; спред — неопределённость):")
print(f" Cd(0) = {fits['Cd']['lin'][0]:.3f} / {fits['Cd']['quad'][0]:.3f} "
f"(спред {fits['Cd']['spread']:.3f}; лит. безгранич. 1.33)")
print(f" rms Cl(0) = {fits['Clrms']['lin'][0]:.3f} / {fits['Clrms']['quad'][0]:.3f} "
f"(спред {fits['Clrms']['spread']:.3f}; лит. ~0.3)")
print(f" St(0) = {fits['St']['lin'][0]:.3f} / {fits['St']['quad'][0]:.3f} "
f"(спред {fits['St']['spread']:.3f}; лит. 0.183)")
def convergence_report(res, cfg, nwin=10):
"""Эволюция по окнам времени: видно, НАСЫЩЕНО ли решение или ДРЕЙФУЕТ.
Ключевое: ⟨ρ⟩ (средняя плотность) и Cd, нормированный на реальную ⟨ρ⟩ (дрейф-устойчивый Cd).
Если ⟨ρ⟩ уплывает от 1 — это дрейф массы из-за ГУ входа/выхода, а Cd_raw искажён нормировкой на ρ=1."""
Fx = B.to_cpu(res["Fx"]); Fy = B.to_cpu(res["Fy"]); uy = B.to_cpu(res["uy"]); rho = B.to_cpu(res["rho"])
n = len(Fx); U = cfg.U; DL = res["DL"]
if n < nwin * 2:
return
Cd = 2.0*Fx/(U**2*DL); Cl = 2.0*Fy/(U**2*DL)
w = n // nwin
print("\n=== Сходимость по времени / дрейф (по окнам) ===")
print(f"{'окно':>4}{'шаги':>16}{'<ρ>':>8}{'<Cd>raw':>9}{'<Cd>/ρ':>9}{'rms Cl':>9}{'rms u_y':>9}")
rows = []
for k in range(nwin):
s = slice(k*w, (k+1)*w if k < nwin-1 else n)
rm = float(rho[s].mean()); cdr = float(Cd[s].mean())
cdn = cdr / rm if rm != 0 else float("nan")
clr = float(Cl[s].std()); uyr = float(uy[s].std())
rows.append((rm, cdr, cdn, clr, uyr))
print(f"{k+1:>4}{f'{k*w}-{(k+1)*w}':>16}{rm:>8.3f}{cdr:>9.3f}{cdn:>9.3f}{clr:>9.4f}{uyr:>9.4f}")
# вердикт по последним двум окнам
(rm1, cdr1, cdn1, clr1, _), (rm0, _, _, clr0, _) = rows[-1], rows[-2]
drho = rm1 - rm0; dcl = clr1 - clr0
verdict = []
if abs(rm1 - 1.0) > 0.02:
# «стабильна, но не там»: система села на смещённую ветвь (акустическая накачка массы;
# ⟨Cd⟩/Cl на ней НЕвалидны для сравнения с литературой) — см. факторное исследование
verdict.append(f"плотность: ⟨ρ⟩={rm1:.3f} → РЕЖИМ СМЕЩЁН (⟨ρ⟩ далеко от 1; прогон невалиден)")
else:
verdict.append(f"плотность: ⟨ρ⟩={rm1:.3f}, Δ за окно={drho:+.4f} → "
+ ("ДРЕЙФ массы (правь ГУ выхода: давление/Zou-He)" if abs(drho) > 1e-3 else "стабильна"))
verdict.append(f"rms Cl: Δ за окно={dcl:+.4f} → "
+ ("ещё растёт (не насыщено)" if dcl > 1e-3 else "насыщено/стабильно"))
verdict.append(f"дрейф-устойчивый Cd (⟨Cd⟩/ρ) в последнем окне = {cdn1:.3f}")
print("вердикт: " + "; ".join(verdict))
+45
View File
@@ -0,0 +1,45 @@
# Равновесие и макропеременные.
#
# Используется ЭНТРОПИЙНОЕ равновесие в product-form (как в KBC), а не усечённый полином:
# feq_i = w_i · ρ · Π_d (2 − sqrt(1+3 u_d²)) · ((2 u_d + sqrt(1+3 u_d²))/(1 − u_d))^{c_{i,d}}
# Показатели c_{i,d} ∈ {−1,0,+1}, поэтому степень НЕ нужна — берём q или 1/q (быстрее и совместимо
# с CUDA-graph: нет pow и нет host→device для границ clip).
import backend as B
import lattice as L
cp = B.cp; xp = B.xp
CXa = L.CXa; CYa = L.CYa
def _feq9_body(rho, ux, uy):
ux = cp.minimum(cp.maximum(ux, -0.95), 0.95) # clip скоростей литералами (запекается в кернел)
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):
"""Равновесные популяции (Q,Ny,Nx) по плотности rho и скорости u (индексируется [0],[1]).
Сборка через B.pack (без cupy.stack) — иначе H2D ломает CUDA-graph capture."""
if _FUSE_OK[0]:
try:
return B.pack(_feq9(rho, u[0], u[1]))
except Exception as e: # откат на не-fused (тоже без pow), один раз
print(f"[equilibrium] cp.fuse отключён ({type(e).__name__}: {e}); обычный путь")
_FUSE_OK[0] = False
return B.pack(_feq9_body(rho, u[0], u[1]))
def macros(f):
"""Макропеременные: ρ = Σ_i f_i, u = (Σ_i c_i f_i)/ρ. Возвращает (rho, u=(2,Ny,Nx))."""
r = f.sum(0)
ux = (CXa[:, None, None] * f).sum(0) / r
uy = (CYa[:, None, None] * f).sum(0) / r
return r, B.pack((ux, uy))
+52
View File
@@ -0,0 +1,52 @@
# СИЛА и МОМЕНТ на теле — галилей-инвариантный обмен импульсом (GMEM, Wen et al. 2014).
#
# Для каждого линка жидкость→тело по направлению i (маска cl):
# ΔF = (c_i − u_w)·f_i^{после столкновения}(x_f) − (c_ī − u_w)·f_ī^{после стриминга}(x_f)
# где u_w — скорость стенки на линке (для неподвижного цилиндра = 0). Т.к. c_ī = −c_i, по компонентам:
# F_x += (Cx_i − u_wx)·f_i + (Cx_i + u_wx)·f_ī
# F_y += (Cy_i − u_wy)·f_i + (Cy_i + u_wy)·f_ī
#
# МОМЕНТ (торк) вокруг центра тела: T_z = Σ_links (r_w × ΔF)_z, где точка приложения —
# ПЕРЕСЕЧЕНИЕ линка со стенкой r_w = r_f + q·c_i (q — Bouzidi-доля, уже лежит в bc).
# Узел жидкости дал бы ошибку плеча до 1 ячейки; q бесплатен. Для кругового цилиндра
# ⟨Cm⟩ ≈ 0 — это контроль симметрии считывания; для подвижных/вращающихся тел (цель SimV4) —
# рабочая величина.
#
# Свойства:
# * f_i — популяция, уходящая в стенку (после столкновения); f_ī — вернувшаяся (после стриминга/Bouzidi).
# f_ī берётся из поля ПОСЛЕ стриминга — иначе ведущий симметричный член ~2ρw_i сокращается и Cd врёт.
# * При u_w=0 сводится к классическому Σ c_i (f_i + f_ī); члены с u_w делают оценку галилей-инвариантной
# и ГОТОВОЙ к подвижным/вращающимся телам (цель проекта SimV4) — туда передаётся u_w на линке.
#
# Независимый кросс-чек этой оценки — интегрирование тензора напряжений по контуру вокруг тела:
# см. forces_stress.py (σ = −p′·I + σ^v из неравновесных моментов; вызывается редко, вне CUDA-графа).
import backend as B
import lattice as L
xp = B.xp; ZERO = B.ZERO
Cx = L.Cx; Cy = L.Cy
def force_gmem(fpost, fnew, bc, rxy=None, u_wall=(0.0, 0.0)):
"""GMEM по линкам тела (маска cl). Возвращает (Fx, Fy, Tz) — device-скаляры.
rxy=(rx,ry) — координаты узлов решётки относительно центра тела (для торка); None → Tz=0.
u_wall — скорость стенки. Graph-safe: только where/арифметика с литералами."""
uwx, uwy = u_wall
Fx = ZERO; Fy = ZERO; Tz = ZERO
if rxy is not None:
rx, ry = rxy
for (i, ib, mask, q, xff, cl) in bc:
fi = fpost[i]; fib = fnew[ib]
dFx = xp.where(cl, (Cx[i] - uwx)*fi + (Cx[i] + uwx)*fib, ZERO)
dFy = xp.where(cl, (Cy[i] - uwy)*fi + (Cy[i] + uwy)*fib, ZERO)
Fx = Fx + dFx.sum(); Fy = Fy + dFy.sum()
if rxy is not None: # плечо до точки пересечения линка со стенкой
Tz = Tz + ((rx + q*Cx[i])*dFy - (ry + q*Cy[i])*dFx).sum()
return Fx, Fy, Tz
def coefficients(Fx, Fy, U, DL, Tz=None):
"""Безразмерные коэффициенты: Cd = 2Fx/(U²·DL), Cl = 2Fy/(U²·DL), Cm = 2Tz/(U²·DL²).
DL — диаметр у тела (на L1); сила/плечо тоже в единицах L1, так что нормировка согласована."""
Cd = 2.0*Fx/(U**2*DL); Cl = 2.0*Fy/(U**2*DL)
if Tz is None:
return Cd, Cl
return Cd, Cl, 2.0*Tz/(U**2*DL*DL)
@@ -0,0 +1,80 @@
# НЕЗАВИСИМЫЙ КРОСС-ЧЕК силы/момента: баланс импульса контрольного объёма, ограниченного
# замкнутым контуром (окружностью) вокруг тела на тонком уровне L1.
#
# F_тела = ∮ [σ·n − ρ·u·(u·n)] ds − d/dt ∫_кольцо ρu dV, σ = −p′·I + σ^v
#
# * давление: p = ρ·c_s²; интегрируем p′ = (ρ − ⟨ρ⟩_контур)·c_s² — константа давления по замкнутому
# контуру даёт ∮ p0·n ds ≡ 0, а вычитание убирает катастрофическое сокращение в float32;
# * вязкая часть из неравновесных моментов (Чепмен–Энског): σ^v_αβ = −(1 − 1/(2τ))·Π^neq_αβ,
# Π^neq_αβ = Σ_i c_iα c_iβ (f_i − f_i^eq).
# ВАЖНО (KBC): след Π^neq релаксирует не с 1/τ, а с γβ (поле-зависимый стабилизатор), поэтому
# берём строго ДЕВИАТОРНУЮ проекцию (след исключён) — сдвиг-моменты в KBC релаксируют с точным
# 2β = 1/τ, для них префактор корректен. Bulk-часть в силу трения и не входит.
# * КОНВЕКТИВНЫЙ член −ρu(u·n): поток импульса через контур. Зануляется только при δ→0 (no-slip
# на поверхности); при δ=2 скорость на контуре уже заметна, и без этого члена кросс-чек давал
# систематический сдвиг +4% по ⟨Cd⟩ на всех сетках (факторное исследование, эксперимент №0).
# * член d/dt ∫ρu dV по кольцу R..R1+δ НЕ считается: в среднем по времени он зануляется,
# на мгновенных рядах даёт малый сдвиг фазы → кросс-чек ведётся по средним (⟨Cd⟩, rms Cl).
# * контур — окружность радиуса R1+δ (δ = cfg.stress_delta тонких ячеек, по умолчанию 2):
# билинейный стенсиль выборки не задевает первое жидкое кольцо у стенки, где популяции
# реконструированы Bouzidi (q_max ≈ 0.98 < 1).
#
# Вызывается РЕДКО (каждые cfg.stress_every шагов) в Solver.run() ВНЕ step()/CUDA-графа.
import math
import backend as B
import lattice as L
import equilibrium as E
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE
CS2 = L.CS2
def build_probe(cfg):
"""Прекомпьют контура (один раз, вне захвата): N=stress_ntheta точек на окружности R1+δ
вокруг центра тела на L1; индексы/веса билинейной интерполяции, нормали, плечи, элемент дуги."""
r = cfg.refine
R1 = r * float(cfg.D) / 2.0 # радиус цилиндра на L1 (тонкие ячейки)
Rp = R1 + float(cfg.stress_delta)
ccx = (cfg.cx - cfg.ax1) * r; ccy = (cfg.cy - cfg.ay1) * r
N = int(cfg.stress_ntheta)
th = (2.0 * math.pi / N) * cp.arange(N)
nx = cp.cos(th).astype(DTYPE); ny = cp.sin(th).astype(DTYPE)
px = ccx + Rp * nx; py = ccy + Rp * ny
x0 = cp.floor(px).astype(cp.int64); y0 = cp.floor(py).astype(cp.int64)
# контур обязан лежать внутри патча L1 (он центрирован вокруг тела — выполняется с запасом)
Nfx = (cfg.bx1 - cfg.ax1) * r + 1; Nfy = (cfg.by1 - cfg.ay1) * r + 1
assert int(x0.min()) >= 0 and int(x0.max()) + 1 <= Nfx - 1, "контур σ·n вышел за патч L1 по x"
assert int(y0.min()) >= 0 and int(y0.max()) + 1 <= Nfy - 1, "контур σ·n вышел за патч L1 по y"
return dict(x0=x0, y0=y0, x1=x0 + 1, y1=y0 + 1,
tx=(px - x0).astype(DTYPE), ty=(py - y0).astype(DTYPE),
nx=nx, ny=ny, rx=(Rp * nx), ry=(Rp * ny), ds=2.0 * math.pi * Rp / N)
def _sample(fld, pr):
"""Билинейная выборка 2D-поля в точках контура (аналог amr.pint, но по 1D-набору точек)."""
return (fld[pr["y0"], pr["x0"]] * (1 - pr["tx"]) * (1 - pr["ty"])
+ fld[pr["y0"], pr["x1"]] * pr["tx"] * (1 - pr["ty"])
+ fld[pr["y1"], pr["x0"]] * (1 - pr["tx"]) * pr["ty"]
+ fld[pr["y1"], pr["x1"]] * pr["tx"] * pr["ty"])
def force_stress(F1, pr, tau1):
"""Сила/момент на тело из баланса импульса ∮[σ·n − ρu(u·n)]ds по контуру pr на состоянии F1
(L1, после стриминга). Возвращает (Fx, Fy, Tz) — device-скаляры. tau1 — релаксация на L1."""
rho, u = E.macros(F1)
fneq = F1 - E.feq(rho, u)
CX = L.CXa[:, None, None]; CY = L.CYa[:, None, None]
Pxx = (CX * CX * fneq).sum(0)
Pyy = (CY * CY * fneq).sum(0)
Pxy = (CX * CY * fneq).sum(0)
rho_s = _sample(rho, pr)
ux_s = _sample(u[0], pr); uy_s = _sample(u[1], pr)
Pxx_s = _sample(Pxx, pr); Pyy_s = _sample(Pyy, pr); Pxy_s = _sample(Pxy, pr)
p = (rho_s - rho_s.mean()) * CS2 # p′: давление без константы (см. шапку)
pref = 1.0 - 1.0 / (2.0 * tau1)
tr = 0.5 * (Pxx_s + Pyy_s) # девиаторная проекция (след исключён)
sxx = -pref * (Pxx_s - tr); syy = -pref * (Pyy_s - tr); sxy = -pref * Pxy_s
un = ux_s * pr["nx"] + uy_s * pr["ny"] # нормальная скорость на контуре
flux = rho_s * un # ρ(u·n): конвективный вынос импульса
tx = (-p + sxx) * pr["nx"] + sxy * pr["ny"] - flux * ux_s # t = σ·n − ρu(u·n)
ty = sxy * pr["nx"] + (-p + syy) * pr["ny"] - flux * uy_s
Fx = tx.sum() * pr["ds"]; Fy = ty.sum() * pr["ds"]
Tz = (pr["rx"] * ty - pr["ry"] * tx).sum() * pr["ds"] # давление в торк не входит (n ∥ r)
return Fx, Fy, Tz
+47
View File
@@ -0,0 +1,47 @@
# Геометрия: маски тела/стенок и знаковое поле расстояний (SDF) для L0 и патча L1.
# Чистый CuPy. Соглашение по SDF: phi>0 в жидкости, phi<0 в теле; |phi| ≈ расстоянию до границы.
import backend as B
import lattice as L
import amr
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE
def _cmask(NX, NY, ccx, ccy, R):
"""Булева маска круга радиуса R (центр ccx,ccy) на сетке (NY,NX)."""
Y, X = cp.meshgrid(cp.arange(NY), cp.arange(NX), indexing="ij")
return (X - ccx)**2 + (Y - ccy)**2 <= R*R
def _csdf(NX, NY, ccx, ccy, R):
"""SDF круга: sqrt((x−ccx)²+(y−ccy)²) − R (отрицательно внутри)."""
Y, X = cp.meshgrid(cp.arange(NY), cp.arange(NX), indexing="ij")
return cp.sqrt((X - ccx).astype(DTYPE)**2 + (Y - ccy).astype(DTYPE)**2) - R
class Geometry:
"""Контейнер масок/SDF/патча. Поля *_np — host-копии для визуализации."""
pass
def build(cfg):
Nx, Ny, D, cx, cy = cfg.Nx, cfg.Ny, cfg.D, cfg.cx, cfg.cy
r = cfg.refine
ax1, bx1, ay1, by1 = cfg.ax1, cfg.bx1, cfg.ay1, cfg.by1
g = Geometry()
# --- L0: только цилиндр как твёрдое тело (стенки канала верх/низ — free-slip, не solid) ---
g.cyl0 = _cmask(Nx, Ny, cx, cy, D/2.0)
g.solid0 = g.cyl0 # стенки обрабатываются specular-отражением, см. boundary.free_slip_walls
g.phi0 = _csdf(Nx, Ny, cx, cy, D/2.0) # SDF только цилиндра (для Bouzidi тела)
# --- патч L1 (измельчение r) над областью [ax1,bx1]×[ay1,by1] ---
g.P1 = amr.patch(Nx, Ny, ax1, bx1, ay1, by1, r)
Nfx, Nfy = g.P1["Nfx"], g.P1["Nfy"]
# цилиндр на L1: центр и радиус масштабируются на r (диаметр r·D → радиус r·D/2)
R1 = r * D / 2.0
g.solid1 = _cmask(Nfx, Nfy, (cx - ax1)*r, (cy - ay1)*r, R1)
g.phi1 = _csdf(Nfx, Nfy, (cx - ax1)*r, (cy - ay1)*r, R1)
# маска жидких узлов для рестрикции L1→L0 (внутренняя часть патча на L0)
g.fl1 = ~g.solid0[ay1+1:by1, ax1+1:bx1]
# host-копии для визуализации
g.solid0_np = B.to_cpu(g.solid0); g.solid1_np = B.to_cpu(g.solid1)
return g
+34
View File
@@ -0,0 +1,34 @@
# Решётка D2Q9 и KBC-проектор. Чистый CuPy; всё считается один раз при импорте.
#
# Скорости e_i и веса w_i (стандартная нумерация D2Q9):
# 0:( 0, 0) 1:(+1,0) 2:(0,+1) 3:(-1,0) 4:(0,-1)
# 5:(+1,+1) 6:(-1,+1) 7:(-1,-1) 8:(+1,-1)
# OPP[i] — индекс противоположного направления.
import backend as B
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE
Q = 9
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]
_Wlist = [4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36]
CS2 = 1.0 / 3.0 # квадрат скорости звука решётки
# device-векторы
Wa = cp.asarray(_Wlist, DTYPE)
CXa = cp.asarray(Cx, DTYPE)
CYa = cp.asarray(Cy, DTYPE)
def _build_projector():
"""Проектор Ps на сдвиг-моменты {cx²−cy², cx·cy} (ядро KBC-N1).
Ps = M⁻¹ · diag(оставить только эти 2 момента) · M, где M — моментный базис."""
cxv = cp.asarray(Cx, cp.float64); cyv = cp.asarray(Cy, cp.float64)
M = cp.zeros((Q, Q), cp.float64)
M[0] = 1.0; M[1] = cxv; M[2] = cyv; M[3] = 3.0*(cxv**2 + cyv**2) - 2.0
M[4] = cxv**2 - cyv**2; M[5] = cxv*cyv
M[6] = cxv**2*cyv; M[7] = cxv*cyv**2; M[8] = cxv**2*cyv**2
Dm = cp.zeros((Q, Q), cp.float64); Dm[4, 4] = Dm[5, 5] = 1.0
return (cp.linalg.inv(M) @ Dm @ M)
Ps = _build_projector().astype(DTYPE) # (Q,Q) проектор на сдвиг
+53
View File
@@ -0,0 +1,53 @@
# Точка входа модели 2×+SDF. Запуск из этой папки:
# python run.py
# AMR_STEPS=2000 python run.py # короткий прогон
# AMR_CUDAGRAPH=0 python run.py # без CUDA-графов
# AMR_FP64=1 python run.py # двойная точность
import os, time
import config as C
import backend as B
import solver as S
import diagnostics as Diag
import visualization as Viz
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "out"); os.makedirs(OUT, exist_ok=True)
def main():
cfg = C.from_env()
print(f"[2×+SDF] backend={B.BACKEND}; домен {cfg.Nx}x{cfg.Ny}; D={cfg.D}; steps={cfg.steps}; "
f"collision=kbc; force=gmem; "
f"graphs={'on' if cfg.use_cuda_graph else 'off'}; "
f"L1 {cfg.refine}× = {(cfg.bx1-cfg.ax1)*cfg.refine+1}x{(cfg.by1-cfg.ay1)*cfg.refine+1}; "
f"вход {cfg.cx/cfg.D:.1f}D / выход {(cfg.Nx-1-cfg.cx)/cfg.D:.1f}D; outlet_uy={cfg.outlet_uy}")
t0 = time.perf_counter()
sim = S.Solver(cfg)
res = sim.run(Viz.make_progress("2x+sdf"))
print(" время:", round(time.perf_counter() - t0, 1), "s")
a = Diag.analyze(res, cfg)
Diag.print_table(a, cfg)
Diag.print_crosscheck(a)
Diag.convergence_report(res, cfg)
Viz.save_gif(res, sim.geo, cfg, os.path.join(OUT, "cyl_2x_sdf.gif"))
Viz.save_series_panel(res, a, cfg, os.path.join(OUT, "series_2x_sdf.png"))
import numpy as np
np.savez(os.path.join(OUT, "probe_2x_sdf.npz"),
# сводные числа (самоописываемость для ноутбука: он только читает артефакты)
St=a["St"], Cd=a["Cd"], Clrms=a["Clrms"], uyrms=a["uyrms"],
Cm=a.get("Cm", float("nan")),
Cd_st=a.get("Cd_st", float("nan")), Clrms_st=a.get("Clrms_st", float("nan")),
Cm_st=a.get("Cm_st", float("nan")),
Ny=cfg.Ny, beta=cfg.D/cfg.Ny, D=cfg.D, U=cfg.U, Re=cfg.Re, steps=cfg.steps,
Nx=cfg.Nx, cx=cfg.cx, outlet_uy=cfg.outlet_uy,
stress_every=res["stress_every"],
# ряды
Cl=a["Cl_s"], uy=a["uy"], Fx=B.to_cpu(res["Fx"]), Fy=B.to_cpu(res["Fy"]),
Tz=B.to_cpu(res["Tz"]), rho=B.to_cpu(res["rho"]),
Fx_st=B.to_cpu(res["Fx_st"]), Fy_st=B.to_cpu(res["Fy_st"]), Tz_st=B.to_cpu(res["Tz_st"]))
print("DONE (2×+SDF)")
if __name__ == "__main__":
main()
+95
View File
@@ -0,0 +1,95 @@
# Серия по блокировке: D фиксирован, Ny растёт → β=D/Ny падает; экстраполяция β→0 даёт
# прямое сравнение с БЕЗГРАНИЧНОЙ литературой (Re=150: Cd≈1.33, St≈0.183, rms Cl≈0.3)
# без ручной поправки (1−β)². Запуск из этой папки:
# python run_blockage.py
# AMR_NY_LIST=90,128,180 python run_blockage.py # свой список Ny
# AMR_STEPS=20000 AMR_NY_LIST=128 python run_blockage.py # смоук
# Стоимость прогона ≈ как у run.py: патч L1 (основная работа) от Ny не зависит (высота 4D фикс.).
import os, time
import numpy as np
import config as C
import backend as B
import solver as S
import diagnostics as Diag
import visualization as Viz
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "out"); os.makedirs(OUT, exist_ok=True)
LIT = dict(Cd=1.33, Clrms=0.3, St=0.183) # безграничный цилиндр, Re≈150
def run_one(Ny):
cfg = C.from_env(Ny=Ny, frame_every=0) # серия без кадров/гифок (только ряды)
print(f"\n[блокировка] Ny={cfg.Ny} (β={cfg.D/cfg.Ny:.3f}); домен {cfg.Nx}x{cfg.Ny}; "
f"патч y=[{cfg.ay1},{cfg.by1}]; steps={cfg.steps}")
t0 = time.perf_counter()
sim = S.Solver(cfg)
res = sim.run(Viz.make_progress(f"Ny={cfg.Ny}"))
print(" время:", round(time.perf_counter() - t0, 1), "s")
a = Diag.analyze(res, cfg)
Diag.print_table(a, cfg)
Diag.print_crosscheck(a)
Diag.convergence_report(res, cfg)
np.savez(os.path.join(OUT, f"probe_blockage_Ny{cfg.Ny}.npz"),
St=a["St"], Cd=a["Cd"], Clrms=a["Clrms"], uyrms=a["uyrms"],
Cm=a.get("Cm", float("nan")),
Cd_st=a.get("Cd_st", float("nan")), Clrms_st=a.get("Clrms_st", float("nan")),
Ny=cfg.Ny, beta=cfg.D/cfg.Ny, D=cfg.D, U=cfg.U, Re=cfg.Re, steps=cfg.steps,
Cl=a["Cl_s"], uy=a["uy"], rho=B.to_cpu(res["rho"]),
Fx=B.to_cpu(res["Fx"]), Fy=B.to_cpu(res["Fy"]), Tz=B.to_cpu(res["Tz"]))
row = dict(Ny=cfg.Ny, beta=cfg.D/cfg.Ny, St=a["St"], Cd=a["Cd"], Clrms=a["Clrms"],
Cm=a.get("Cm", float("nan")), Cd_st=a.get("Cd_st"))
# освободить VRAM перед следующим прогоном (буферы, BC, захваченный CUDA-граф)
del sim, res, a
B.cp.get_default_memory_pool().free_all_blocks()
return row
def save_extrapolation_figure(rows, fits, path):
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
betas = np.array([r["beta"] for r in rows]); bb = np.linspace(0, betas.max()*1.05, 100)
panels = [("Cd", "⟨Cd⟩", LIT["Cd"], "лит. 1.33"),
("Clrms", "rms Cl", LIT["Clrms"], "лит. ~0.3"),
("St", "St", LIT["St"], "лит. 0.183")]
fig, axs = plt.subplots(1, 3, figsize=(13.5, 4.2), dpi=110)
for ax, (key, lab, lit, litlab) in zip(axs, panels):
vals = np.array([r[key] for r in rows]); ft = fits[key]
ax.plot(betas, vals, "o", ms=7, color="#d32f2f", label="серия (raw)")
al, sl = ft["lin"]; aq, sq = ft["quad"]
ax.plot(bb, al + sl*bb, "-", lw=1.2, color="#1976d2", label=f"a+b·β → {al:.3f}")
ax.plot(bb, aq + sq*bb**2, "--", lw=1.2, color="#388e3c", label=f"a+b·β² → {aq:.3f}")
ax.axhline(lit, color="gray", lw=1.0, ls=":", label=litlab)
if key == "St": # для St полезен и кинематически поправленный вид
ax.plot(betas, vals*(1-betas), "s", ms=6, mfc="none", color="#7b1fa2", label="St·(1−β)")
ax.set_xlabel("β = D/Ny"); ax.set_ylabel(lab); ax.set_xlim(0, bb[-1])
ax.legend(fontsize=8); ax.set_title(f"{lab}(β): экстраполяция β→0", fontsize=10)
fig.suptitle("Серия по блокировке: стеснение каналом — свойство ПОСТАНОВКИ, не считывания силы",
fontsize=11)
fig.tight_layout(rect=(0, 0, 1, 0.93))
fig.savefig(path, bbox_inches="tight"); plt.close(fig)
print("saved", os.path.basename(path))
def main():
ny_list = [int(s) for s in os.environ.get("AMR_NY_LIST", "90,128,180").split(",")]
print(f"[серия по блокировке] backend={B.BACKEND}; Ny={ny_list}")
rows = [run_one(Ny) for Ny in ny_list]
if len(rows) < 2:
print("\n(одна точка — фит/экстраполяция пропущены)")
return
betas = [r["beta"] for r in rows]
fits = {key: Diag.fit_blockage(betas, [r[key] for r in rows]) for key in ("Cd", "Clrms", "St")}
Diag.print_blockage_table(rows, fits)
np.savez(os.path.join(OUT, "blockage_summary.npz"),
Ny=np.array([r["Ny"] for r in rows]), beta=np.array(betas),
St=np.array([r["St"] for r in rows]), Cd=np.array([r["Cd"] for r in rows]),
Clrms=np.array([r["Clrms"] for r in rows]), Cm=np.array([r["Cm"] for r in rows]),
Cd_st=np.array([r["Cd_st"] if r["Cd_st"] is not None else np.nan for r in rows]),
Cd0_lin=fits["Cd"]["lin"][0], Cd0_quad=fits["Cd"]["quad"][0],
Cl0_lin=fits["Clrms"]["lin"][0], Cl0_quad=fits["Clrms"]["quad"][0],
St0_lin=fits["St"]["lin"][0], St0_quad=fits["St"]["quad"][0],
lit_Cd=LIT["Cd"], lit_Clrms=LIT["Clrms"], lit_St=LIT["St"])
save_extrapolation_figure(rows, fits, os.path.join(OUT, "blockage_extrapolation.png"))
print("DONE (серия по блокировке)")
if __name__ == "__main__":
main()
+184
View File
@@ -0,0 +1,184 @@
# ФАКТОРНОЕ ИССЛЕДОВАНИЕ постановки: какие границы расчётной области отвечают за завышение
# Cd / rms Cl относительно безграничной литературы (Re=150: Cd≈1.33, rms Cl≈0.3, St≈0.183).
#
# Контекст (эксперимент №0 — серия по боковой блокировке, см. ноутбук, раздел 23): Cd(β)
# немонотонен (1.80 → 1.77 → 1.87 при β = 0.178 → 0.125 → 0.089) → боковое стеснение НЕ
# доминирующий фактор. Подозреваемые — продольные границы, которые серия держала константой:
# вход 2.5D (жёсткий равномерный профиль), выход 8.3D (Zou-He давление с жёстким uy=0,
# отражает вихри дорожки), и их связка.
#
# Здесь каждый фактор варьируется ОТДЕЛЬНО при фиксированной боковой блокировке (Ny=90):
# изоляция вклада входа, выхода и выходного ГУ. Все результаты накапливаются в out/factors.csv
# (одна строка на прогон, с полной конфигурацией) — реестр для документирования исследования.
#
# Запуск: python run_factors.py
# AMR_FACTORS=B0_baseline,F4_outlet_uy python run_factors.py # подмножество факторов
# AMR_STEPS=20000 python run_factors.py # смоук
import os, csv, time
import numpy as np
import config as C
import backend as B
import solver as S
import diagnostics as Diag
import visualization as Viz
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "out"); os.makedirs(OUT, exist_ok=True)
LIT = dict(Cd=1.33, Clrms=0.3, St=0.183)
# (имя, overrides конфига, что изолирует)
# Эксперимент №1 (B0–F5, прогнан 2026-06-10): итоги — мягкий uy-выход (F4) выигрывает по всем
# метрикам; ВСЕ длинные домены без демпфера уходят в смещённый режим ⟨ρ⟩≈1.66 (акустический
# резонатор «вход-скорость/выход-давление» + накачка массы входом) или взрываются (F5).
# Эксперимент №2 (F6–F8): губка перед выходом — лечит акустику, открывает длинные домены.
FACTORS = [
("B0_baseline", dict(),
"база 174x90: вход 2.5D, выход 8.3D, uy=0 (контроль; кросс-чек уже с конвективным членом)"),
("F1_outlet_far", dict(Nx=300),
"выход 8.3D -> 16.2D (вход 2.5D как в базе) — изолирует близость выхода"),
("F2_inlet_far", dict(Nx=230, cx=96),
"вход 2.5D -> 6.0D (выход 8.3D как в базе) — изолирует близость входа"),
("F3_inout_far", dict(Nx=390, cx=96),
"вход 6.0D + выход 18.3D — оба продольных фактора вместе"),
("F4_outlet_uy", dict(outlet_uy="extrapolate"),
"мягкий выход: uy нуль-градиент вместо uy=0 (геометрия базовая) — изолирует отражение вихрей"),
("F5_combo", dict(Nx=390, cx=96, outlet_uy="extrapolate"),
"вход 6.0D + выход 18.3D + мягкий uy-выход — всё вместе"),
# --- эксперимент №2: губка (absorbing layer) ---
("F6_sponge", dict(outlet_uy="extrapolate", sponge_len=32),
"база 174x90 + мягкий uy + ГУБКА 32 столбца (nu x30) — валидация губки против F4"),
("F7_long_sponge", dict(Nx=390, cx=96, outlet_uy="extrapolate", sponge_len=32),
"вход 6D + выход 18.3D + мягкий uy + губка — целевая «чистая» постановка"),
("F8_long_sp_uy0", dict(Nx=390, cx=96, sponge_len=32),
"длинный домен + губка, но ЖЁСТКОЕ uy=0 — изолирует вклад губки от uy-режима"),
]
CSV_FIELDS = ["when", "name", "status", "steps", "Nx", "Ny", "cx", "in_D", "out_D", "outlet_uy",
"sponge_len", "beta", "St", "St_corr", "Cd", "Clrms", "Cm", "Cd_st", "Clrms_st",
"dCd_pct", "dCl_pct", "rho_last", "cd_win_std", "cl_win_std", "wall_s", "desc"]
def window_stats(res, cfg, nwin=10):
"""Срединная изменчивость: std оконных средних Cd и оконных rms Cl (окна 2..nwin,
первое окно с разгоном пропускается). Метрика низкочастотной модуляции дорожки."""
Fx = B.to_cpu(res["Fx"]); Fy = B.to_cpu(res["Fy"])
Cd = 2.0*Fx/(cfg.U**2*res["DL"]); Cl = 2.0*Fy/(cfg.U**2*res["DL"])
n = len(Cd); w = n // nwin
cds = [float(Cd[k*w:(k+1)*w].mean()) for k in range(1, nwin)]
cls = [float(Cl[k*w:(k+1)*w].std()) for k in range(1, nwin)]
return float(np.std(cds)), float(np.std(cls))
def run_factor(name, overrides, desc):
cfg = C.from_env(frame_every=0, **overrides)
in_D = cfg.cx / cfg.D; out_D = (cfg.Nx - 1 - cfg.cx) / cfg.D
print(f"\n=== Фактор {name}: {desc}")
print(f" домен {cfg.Nx}x{cfg.Ny}; вход {in_D:.1f}D / выход {out_D:.1f}D; "
f"outlet_uy={cfg.outlet_uy}; губка={cfg.sponge_len or '—'}; "
f"патч x=[{cfg.ax1},{cfg.bx1}] y=[{cfg.ay1},{cfg.by1}]; steps={cfg.steps}")
t0 = time.perf_counter()
sim = S.Solver(cfg)
res = sim.run(Viz.make_progress(name[:8]))
wall = time.perf_counter() - t0
print(" время:", round(wall, 1), "s")
status = "ok" if len(res["Fx"]) == cfg.steps + 1 else f"BLEW_UP@{len(res['Fx'])}"
a = Diag.analyze(res, cfg)
Diag.print_table(a, cfg)
Diag.print_crosscheck(a)
Diag.convergence_report(res, cfg)
cd_ws, cl_ws = window_stats(res, cfg)
rho_last = float(B.to_cpu(res["rho"])[-1])
np.savez(os.path.join(OUT, f"factor_{name}.npz"),
St=a["St"], Cd=a["Cd"], Clrms=a["Clrms"], uyrms=a["uyrms"],
Cm=a.get("Cm", float("nan")),
Cd_st=a.get("Cd_st", float("nan")), Clrms_st=a.get("Clrms_st", float("nan")),
Nx=cfg.Nx, Ny=cfg.Ny, cx=cfg.cx, D=cfg.D, U=cfg.U, Re=cfg.Re, steps=cfg.steps,
outlet_uy=cfg.outlet_uy, beta=cfg.D/cfg.Ny,
Cl=a["Cl_s"], uy=a["uy"], rho=B.to_cpu(res["rho"]),
Fx=B.to_cpu(res["Fx"]), Fy=B.to_cpu(res["Fy"]), Tz=B.to_cpu(res["Tz"]))
beta = cfg.D / cfg.Ny
dcd = abs(a.get("Cd_st", float("nan")) - a["Cd"]) / a["Cd"] * 100.0
dcl = abs(a.get("Clrms_st", float("nan")) - a["Clrms"]) / max(a["Clrms"], 1e-12) * 100.0
row = dict(when=time.strftime("%Y-%m-%d %H:%M:%S"), name=name, status=status, steps=cfg.steps,
Nx=cfg.Nx, Ny=cfg.Ny, cx=cfg.cx, in_D=round(in_D, 2), out_D=round(out_D, 2),
outlet_uy=cfg.outlet_uy, sponge_len=cfg.sponge_len, beta=round(beta, 4),
St=round(a["St"], 4), St_corr=round(a["St"]*(1-beta), 4),
Cd=round(a["Cd"], 4), Clrms=round(a["Clrms"], 4),
Cm=round(a.get("Cm", float("nan")), 6),
Cd_st=round(a.get("Cd_st", float("nan")), 4),
Clrms_st=round(a.get("Clrms_st", float("nan")), 4),
dCd_pct=round(dcd, 2), dCl_pct=round(dcl, 2),
rho_last=round(rho_last, 5), cd_win_std=round(cd_ws, 4),
cl_win_std=round(cl_ws, 4), wall_s=round(wall, 1), desc=desc)
del sim, res, a
B.cp.get_default_memory_pool().free_all_blocks()
return row
def append_csv(rows):
path = os.path.join(OUT, "factors.csv")
if os.path.exists(path):
with open(path, encoding="utf-8") as fh:
header = fh.readline().strip()
if header != ",".join(CSV_FIELDS):
# схема реестра изменилась — старый файл сохраняем (история не теряется)
k = 1
while os.path.exists(os.path.join(OUT, f"factors_v{k}.csv")):
k += 1
os.rename(path, os.path.join(OUT, f"factors_v{k}.csv"))
print(f"[реестр] схема CSV изменилась — прежний файл сохранён как factors_v{k}.csv")
new = not os.path.exists(path)
with open(path, "a", newline="", encoding="utf-8") as fh:
wr = csv.DictWriter(fh, fieldnames=CSV_FIELDS)
if new:
wr.writeheader()
for r in rows:
wr.writerow(r)
print("CSV:", path, f"(+{len(rows)} строк)")
def save_summary_figure(rows, path):
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
names = [r["name"] for r in rows]
xs = np.arange(len(rows))
fig, axs = plt.subplots(1, 3, figsize=(15, 4.6))
ax = axs[0]
ax.bar(xs, [r["Cd"] for r in rows], color="#1f6feb", label="⟨Cd⟩ GMEM")
ax.plot(xs, [r["Cd_st"] for r in rows], "o", color="#ff8f00", label="⟨Cd⟩ ∮[σ·n−ρu(u·n)]ds")
ax.axhline(LIT["Cd"], color="#d1495b", ls="--", label=f"лит. безгранич. {LIT['Cd']}")
ax.set_ylabel("⟨Cd⟩"); ax.set_title("Сопротивление по факторам")
ax = axs[1]
ax.bar(xs, [r["Clrms"] for r in rows], color="#2e7d32")
ax.axhline(LIT["Clrms"], color="#d1495b", ls="--", label=f"лит. ~{LIT['Clrms']}")
ax.set_ylabel("rms Cl"); ax.set_title("Амплитуда подъёмной силы")
ax = axs[2]
ax.bar(xs, [r["cd_win_std"] for r in rows], color="#7b1fa2")
ax.set_ylabel("std оконных ⟨Cd⟩"); ax.set_title("НЧ-модуляция (меньше — стабильнее)")
for ax in axs:
ax.set_xticks(xs)
ax.set_xticklabels([f"{r['name']}\nвх {r['in_D']}D / вых {r['out_D']}D\n"
f"uy:{r['outlet_uy']} губ:{r['sponge_len']}" for r in rows], fontsize=7)
ax.legend(fontsize=8)
fig.suptitle("Факторное исследование постановки (Ny=90, β=0.178 фикс., 100k шагов)", fontsize=12)
fig.tight_layout(rect=(0, 0, 1, 0.94))
fig.savefig(path, bbox_inches="tight"); plt.close(fig)
print("saved", os.path.basename(path))
def main():
sel = os.environ.get("AMR_FACTORS")
todo = FACTORS if not sel else [f for f in FACTORS if f[0] in sel.split(",")]
print(f"[факторы] backend={B.BACKEND}; прогонов: {len(todo)}")
rows = [run_factor(*f) for f in todo]
print("\n=== Сводка факторного исследования ===")
hdr = (f"{'фактор':15}{'вх,D':>6}{'вых,D':>7}{'uy':>12}{'губка':>6}{'St':>7}{'<Cd>':>8}"
f"{'rms Cl':>8}{'Cd σ·n':>8}{'dCd%':>6}{'<ρ>фин':>8}{'модул.':>8} статус")
print(hdr)
for r in rows:
print(f"{r['name']:15}{r['in_D']:>6}{r['out_D']:>7}{r['outlet_uy']:>12}{r['sponge_len']:>6}"
f"{r['St']:>7.3f}{r['Cd']:>8.3f}{r['Clrms']:>8.4f}{r['Cd_st']:>8.3f}{r['dCd_pct']:>6.1f}"
f"{r['rho_last']:>8.4f}{r['cd_win_std']:>8.4f} {r['status']}")
print(f"{'лит. безгранич.':15}{'':>6}{'':>7}{'':>12}{'':>6}{LIT['St']:>7.3f}{LIT['Cd']:>8.3f}{LIT['Clrms']:>8.4f}")
append_csv(rows)
if len(rows) > 1:
save_summary_figure(rows, os.path.join(OUT, "factors_summary.png"))
print("DONE (факторы)")
if __name__ == "__main__":
main()
+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)
+17
View File
@@ -0,0 +1,17 @@
# ПЕРЕНОС (streaming): f_i(x, t+1) = f_i(x − c_i, t).
# Реализован пул-схемой через cp.roll по осям (y, x). 9 сдвигов в новый буфер.
#
# Заметка для ревизии: roll создаёт по ядру на направление (9 ядер). Это НЕ влияет на физику,
# только на число запусков ядер. Главный способ убрать накладные расходы — CUDA-graph (см. solver.py),
# а не переписывание переноса. Альтернатива (один gather-кернел по предвыч. индексам) — точка расширения.
import backend as B
import lattice as L
xp = B.xp
Q = L.Q; Cx = L.Cx; Cy = L.Cy
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
@@ -0,0 +1,92 @@
# Визуализация (host): гифка |ω| с наложением тонкого патча L1 и прогресс-бар.
# matplotlib/PIL работают только с host-массивами — кадры уже перенесены на host в solver.run().
import os
import time as _time
import numpy as np
def make_progress(label, width=32, every=0.1):
"""ASCII-шкала прогресса по выполненным шагам из заданных (обновление на месте через \\r)."""
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:8}[{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
def _vort(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_series_panel(res, a, cfg, path):
"""Готовая png-панель для ноутбука (он код не исполняет — только вставляет картинки):
(1) ряды Cd(t)/Cl(t) + редкие точки кросс-чека ∮σ·n ds; (2) Cm(t) — контроль симметрии;
(3) спектр зонда u_y с пиком St; (4) ⟨ρ⟩(t) — контроль дрейфа массы."""
import backend as B
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
Cd = a["Cd_s"]; Cl = a["Cl_s"]; n = len(Cd); h0 = a["h0"]; t = np.arange(n)
fig, axs = plt.subplots(2, 2, figsize=(12.5, 7.2), dpi=110)
ax = axs[0, 0]
ax.plot(t, Cd, lw=0.7, color="#d32f2f", label=f"Cd (GMEM), ⟨Cd⟩={a['Cd']:.3f}")
ax.plot(t, Cl, lw=0.7, color="#1976d2", label=f"Cl (GMEM), rms={a['Clrms']:.3f}")
if "Cd_st_s" in a:
K = a["stress_every"]; ts = np.arange(len(a["Cd_st_s"])) * K
ax.plot(ts, a["Cd_st_s"], ".", ms=3, color="#ff8f00", label=f"Cd ∮σ·n, ⟨⟩={a['Cd_st']:.3f}")
ax.plot(ts, a["Cl_st_s"], ".", ms=3, color="#4fc3f7", label="Cl ∮σ·n")
ax.axvline(h0, color="gray", lw=0.8, ls="--")
ax.set_xlabel("шаг L0"); ax.set_ylabel("Cd, Cl"); ax.legend(fontsize=8, loc="upper right")
ax.set_title("Сила: GMEM (линии) vs ∮σ·n ds (точки)", fontsize=10)
ax = axs[0, 1]
if "Cm_s" in a:
ax.plot(t, a["Cm_s"], lw=0.7, color="#388e3c")
ax.axhline(0.0, color="gray", lw=0.8)
ax.set_title(f"Cm(t): ⟨Cm⟩={a['Cm']:.5f} (контроль симметрии, ожид. ≈0)", fontsize=10)
ax.set_xlabel("шаг L0"); ax.set_ylabel("Cm")
ax = axs[1, 0]
ax.plot(a["freqs"]*cfg.D/cfg.U, a["amp"], lw=0.9, color="#7b1fa2")
ax.axvline(a["St"], color="#d32f2f", lw=1.0, ls="--", label=f"St={a['St']:.3f}")
ax.set_xlim(0, 0.6); ax.set_xlabel("St = f·D/U"); ax.set_ylabel("|FFT(u_y)|")
ax.legend(fontsize=9); ax.set_title("Спектр зонда в следе (вторая половина ряда)", fontsize=10)
ax = axs[1, 1]
rho = B.to_cpu(res["rho"]) if not isinstance(res["rho"], np.ndarray) else res["rho"]
ax.plot(np.arange(len(rho)), rho, lw=0.9, color="#5d4037")
ax.axhline(1.0, color="gray", lw=0.8)
ax.set_xlabel("шаг L0"); ax.set_ylabel("⟨ρ⟩")
ax.set_title(f"⟨ρ⟩(t): дрейф массы (финально {float(rho[-1]):.4f})", fontsize=10)
fig.suptitle(f"2×+SDF · Ny={cfg.Ny} (β={cfg.D/cfg.Ny:.3f}) · Re={cfg.Re:g} · {len(rho)-1} шагов",
fontsize=11)
fig.tight_layout(rect=(0, 0, 1, 0.96))
os.makedirs(os.path.dirname(path), exist_ok=True)
fig.savefig(path, bbox_inches="tight"); plt.close(fig)
print("saved", os.path.basename(path))
def save_gif(res, geo, cfg, path, fps=24):
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt, matplotlib.patches as mpatches
from PIL import Image
s0 = geo.solid0_np; s1 = geo.solid1_np
Nx, Ny = cfg.Nx, cfg.Ny; ax1, bx1, ay1, by1 = cfg.ax1, cfg.bx1, cfg.ay1, cfg.by1
frames = res["frames"]
vm = np.nanpercentile(np.abs(np.where(s0, np.nan, _vort(frames[len(frames)//2][1]))), 99.0)
cm = plt.get_cmap("inferno").copy(); cm.set_bad("white"); pil = []
for (t, u0, u1) in frames:
fig = plt.figure(figsize=(11.2, 5.7), dpi=96); ax = fig.add_subplot(111)
ax.imshow(np.abs(np.where(s0, np.nan, _vort(u0))), origin="lower", cmap=cm, vmin=0, vmax=vm,
extent=(0, Nx, 0, Ny), interpolation="nearest")
ax.imshow(np.abs(np.where(s1, np.nan, _vort(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")
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, "L0: Δx", color="#4fc3f7", fontsize=9, weight="bold")
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, "L1: Δx/2 (тело+след)", color="#aed581", fontsize=9, weight="bold")
ax.set_title(f"2×+SDF · |\\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 = __import__("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))
os.makedirs(os.path.dirname(path), exist_ok=True)
pil[0].save(path, save_all=True, append_images=pil[1:], duration=int(1000/fps), loop=0, optimize=True)
print("saved", os.path.basename(path), len(frames), "кадров")