Files
CFDManager/docs/theory/amr_gpu_core.py
T
NotBigGhostandClaude Opus 5 11ff7b79b4 Начальный коммит: Vulkan-редактор SimVulcan + исследование KBC-LBM
Состояние на момент заведения репозитория.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 16:37:38 +03:00

310 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Общее 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))