Начальный коммит: 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:
@@ -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)")
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user