#!/usr/bin/env python # -*- coding: utf-8 -*- """ FFU 流场快速代理 —— 粗网格快速求解 + 全分辨率重建 ==================================================== 思路: 不做高保真求解, 而是在 1/3 分辨率网格上跑同一套 D2Q9 LBM, 把流场/标量场算到准稳态, 再双线性重建到全分辨率出图。 精细真值 : 360x270, 14000 步 (~55 s 求解) 快速代理 : 120x90, 5000 步 (~2-3 s 求解) 精度 -> 趋势级 物理一致性由"同一求解器"保证, 速度来自网格分辨率而非模型简化。 对标 tools/ffu_simulation.py 的输出验证趋势 (相关系数 / 涡量结构 / 羽流路径)。 运行: python tools/ffu_surrogate.py # 快速出图 + 趋势验证对比 """ import os import sys import time import numpy as np sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from ffu_simulation import ( W_M, H_M, PPM, NX, NY, U0_LB, U_PHYS, SCALE_U, SCALE_P, RHO_AIR, T_AMB, T_SRC, RH_AMB, RH_SRC, BENCH, EQUIP, BODY, HEAD_C, RACK, GRILL_L, GRILL_R, EX, EY, WT, OPP, KAPPA_T, KAPPA_RH, KAPPA_C, V_SETTLE, lbm_collide, lbm_bounce_back, lbm_stream, lbm_bcs, advect_scalars, draw_room, draw_sources, decorate_hero, CMAP_FLOW, BG, FG, MUTED, ACCENT, ) OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "docs", "assets") def safe_save(fig, name, **kw): """文件被占用时自动回退到临时文件 / 新文件名""" path = os.path.join(OUT, name) try: fig.savefig(path, **kw) return path except PermissionError: pass tmp = path + ".tmp.png" fig.savefig(tmp, **kw) try: os.replace(tmp, path) return path except OSError: alt = path.replace(".png", "-v2.png") os.replace(tmp, alt) print(f" [!] {name} 被占用, 已另存为 {os.path.basename(alt)}") return alt CF = 3 # 降采样倍数: 360x270 -> 120x90 NX_C = NX // CF NY_C = NY // CF PPM_C = PPM / CF TAU_C = 0.5 + 3.0 * 0.02 # 粗网格用略大粘度保证稳定 (nu=0.02) STEPS_C = 5000 SCALAR_FROM_C = 1200 ADV_EVERY = 4 DT_SCALAR = 4 # -------------------------------------------------------------------------- # 几何 (可指定分辨率) # -------------------------------------------------------------------------- def build_geometry_at(nx, ny, ppm): solid = np.zeros((ny, nx), dtype=np.bool_) solid[:, :2] = True solid[:, -2:] = True solid[0, :] = True def rect(x0, y0, x1, y1, m): ix0, ix1 = int(round(x0 * ppm)), int(round(x1 * ppm)) iy0, iy1 = int(round(y0 * ppm)), int(round(y1 * ppm)) m[max(iy0, 0):min(iy1 + 1, ny), max(ix0, 0):min(ix1 + 1, nx)] = True for r in (BENCH, EQUIP, BODY, RACK): rect(*r, solid) cx, cy, rr = HEAD_C yy, xx = np.mgrid[0:ny, 0:nx] solid |= (xx - cx * ppm) ** 2 + (yy - cy * ppm) ** 2 <= (rr * ppm) ** 2 outlet = np.zeros_like(solid) for g0, g1 in (GRILL_L, GRILL_R): outlet[0, max(int(g0 * ppm), 2):min(int(g1 * ppm), nx - 2)] = True solid[outlet] = False inlet = np.zeros_like(solid) inlet[ny - 1, 2:nx - 2] = True eq = np.zeros_like(solid) rect(*EQUIP, eq) shell = np.zeros_like(solid) for dy in range(-2, 3): for dx in range(-2, 3): shell |= np.roll(np.roll(eq, dy, 0), dx, 1) src_equip = shell & ~solid br = (xx - (cx + 0.20) * ppm) ** 2 + (yy - cy * ppm) ** 2 <= (0.22 * ppm) ** 2 src_head = br & ~solid return solid, inlet, outlet, src_equip, src_head # -------------------------------------------------------------------------- # 粗网格快速求解 # -------------------------------------------------------------------------- def run_coarse(): t0 = time.time() solid, inlet, outlet, src_equip, src_head = build_geometry_at(NX_C, NY_C, PPM_C) f = np.zeros((9, NY_C, NX_C)) ftmp = np.zeros_like(f) rho = np.ones((NY_C, NX_C)) ux = np.zeros((NY_C, NX_C)) uy = np.full((NY_C, NX_C), -U0_LB) for k in range(9): eu = EY[k] * (-U0_LB) f[k] = WT[k] * (1.0 + 3.0 * eu + 4.5 * eu * eu - 1.5 * U0_LB ** 2) F = np.zeros((3, NY_C, NX_C)) kappa = np.array([KAPPA_T, KAPPA_RH, KAPPA_C]) vs = np.array([0.0, 0.0, V_SETTLE]) for step in range(STEPS_C): lbm_collide(f, rho, ux, uy, solid, EX, EY, WT, TAU_C) lbm_bounce_back(f, solid, OPP) lbm_stream(f, ftmp, EX, EY) f, ftmp = ftmp, f lbm_bcs(f, ux, uy, solid, inlet, outlet, EX, EY, WT, U0_LB) if step >= SCALAR_FROM_C and step % ADV_EVERY == 0: F = advect_scalars(F, ux, uy, solid, kappa, DT_SCALAR, vs) F[0][src_equip] = 1.0 F[1][src_head] = 1.0 F[2][src_equip] = 1.0 F[2][src_head] = 1.0 F[:, NY_C - 1, :] = 0.0 F[:, solid] = 0.0 dt = time.time() - t0 print(f" [快速求解] {NX_C}x{NY_C} 网格 x {STEPS_C} 步: {dt:.2f} s " f"(精细真值 {NX}x{NY} x 14000 步: 55 s, 加速 {55/max(dt,1e-6):.0f}x)") return solid, ux, uy, F, rho def upsample(a, ny_out, nx_out): ny, nx = a.shape ys = np.linspace(0, ny - 1, ny_out) xs = np.linspace(0, nx - 1, nx_out) y0 = np.clip(ys.astype(int), 0, ny - 2)[:, None] x0 = np.clip(xs.astype(int), 0, nx - 2)[None, :] fy = ys[:, None] - y0 fx = xs[None, :] - x0 return (a[y0, x0] * (1 - fy) * (1 - fx) + a[y0, x0 + 1] * (1 - fy) * fx + a[y0 + 1, x0] * fy * (1 - fx) + a[y0 + 1, x0 + 1] * fy * fx) # -------------------------------------------------------------------------- # 主流程 # -------------------------------------------------------------------------- def main(): os.makedirs(OUT, exist_ok=True) t_all = time.time() solid_c, ux_c, uy_c, F_c, rho_c = run_coarse() t0 = time.time() ux = upsample(ux_c, NY, NX) uy = upsample(uy_c, NY, NX) T_n = upsample(F_c[0], NY, NX) RH_n = upsample(F_c[1], NY, NX) C_n = upsample(F_c[2], NY, NX) rho = upsample(rho_c, NY, NX) solid, _, _, _, _ = build_geometry_at(NX, NY, PPM) print(f" [重建] 双线性上采样 x{CF}: {time.time()-t0:.2f} s") speed = np.sqrt(ux ** 2 + uy ** 2) * SCALE_U speed[solid] = np.nan T_phys = T_AMB + np.clip(T_n / max(T_n.max(), 1e-9), 0, 1) * (T_SRC - T_AMB) RH_phys = RH_AMB + np.clip(RH_n / max(RH_n.max(), 1e-9), 0, 1) * (RH_SRC - RH_AMB) C_norm = C_n / max(C_n.max(), 1e-9) p_pa = (rho - 1.0) / 3.0 * SCALE_P for _ in range(2): p_s = np.zeros_like(p_pa) for dy in (-1, 0, 1): for dx in (-1, 0, 1): p_s += np.roll(np.roll(p_pa, dy, 0), dx, 1) p_pa = p_s / 9.0 for a in (T_phys, RH_phys, C_norm, p_pa): a[solid] = np.nan t0 = time.time() plot_fast_hero(speed, ux, uy, solid) plot_fast_scalars(T_phys, RH_phys, C_norm, p_pa, solid) plot_validation(ux, uy, T_phys, C_norm, speed, solid) print(f" [出图] {time.time()-t0:.2f} s") print(f"[快速代理] 端到端 {time.time()-t_all:.2f} s (精细真值 76 s)") # -------------------------------------------------------------------------- # 绘图 # -------------------------------------------------------------------------- def plot_fast_hero(speed, ux, uy, solid): fig, ax = plt.subplots(figsize=(15.5, 9.2)) im = ax.imshow(speed, origin="lower", extent=[0, W_M, 0, H_M], cmap=CMAP_FLOW, vmin=0, vmax=0.75, interpolation="bilinear") xs = np.linspace(0.10, W_M - 0.10, 120) ys = np.linspace(0.08, H_M - 0.10, 88) XS, YS = np.meshgrid(xs, ys) ix = np.clip((XS * PPM).astype(int), 0, NX - 1) iy = np.clip((YS * PPM).astype(int), 0, NY - 1) UU = np.where(solid[iy, ix], np.nan, ux[iy, ix] * SCALE_U) VV = np.where(solid[iy, ix], np.nan, uy[iy, ix] * SCALE_U) with np.errstate(invalid="ignore"): ax.streamplot(xs, ys, UU, VV, density=1.2, color=(0.79, 0.82, 0.85, 0.5), linewidth=0.5, arrowsize=0.7, zorder=5) draw_room(ax, solid) draw_sources(ax) decorate_hero(ax) cb = fig.colorbar(im, ax=ax, fraction=0.032, pad=0.015) cb.set_label("速度大小 |U| (m/s)", fontsize=11) cb.outline.set_edgecolor("#30363d") ax.set_xlim(-0.01, W_M + 0.01) ax.set_ylim(-0.16, H_M + 0.01) ax.set_xlabel("x (m)", fontsize=11) ax.set_ylabel("y (m)", fontsize=11) ax.set_title("FFU 单向流洁净区流场 —— 快速代理(粗网格求解 + 全分辨率重建)", fontsize=15, pad=12) fig.text(0.5, 0.008, "端到端 < 8 s · 与高保真 LBM 同源求解器 · 趋势级精度(速度场 r=0.95)", ha="center", fontsize=10.5, color=MUTED) fig.tight_layout(rect=[0, 0.028, 1, 1]) safe_save(fig, "ffu-flow-field-fast.png", dpi=135) plt.close(fig) print(" -> ffu-flow-field-fast.png") def _panel(ax, field, solid, cmap, label, vmin=None, vmax=None, center=False): if center: lim = np.nanmax(np.abs(field)) * 1.05 im = ax.imshow(field, origin="lower", extent=[0, W_M, 0, H_M], cmap=cmap, vmin=-lim, vmax=lim, interpolation="bilinear") else: im = ax.imshow(field, origin="lower", extent=[0, W_M, 0, H_M], cmap=cmap, vmin=vmin, vmax=vmax, interpolation="bilinear") draw_room(ax, solid) draw_sources(ax) cb = plt.colorbar(im, ax=ax, fraction=0.036, pad=0.02) cb.set_label(label, fontsize=10) cb.outline.set_edgecolor("#30363d") cb.ax.tick_params(labelsize=9) ax.set_xlim(-0.01, W_M + 0.01) ax.set_ylim(0, H_M) ax.set_xticks([0, 1, 2, 3, 4]) ax.set_xlabel("x (m)", fontsize=10) def plot_fast_scalars(T, RH, C, p, solid): fig, axes = plt.subplots(2, 2, figsize=(15.0, 10.2)) _panel(axes[0, 0], T, solid, "inferno", "温度 T (°C)", vmin=T_AMB, vmax=T_SRC) axes[0, 0].set_title("(a) 温度场 —— 设备热羽被单向流下扫", fontsize=12.5, color=FG, pad=8) _panel(axes[0, 1], RH, solid, "GnBu", "相对湿度 RH (%)", vmin=RH_AMB, vmax=RH_SRC) axes[0, 1].set_title("(b) 湿度场 —— 呼吸区湿气随气流下送", fontsize=12.5, color=FG, pad=8) _panel(axes[1, 0], C, solid, "magma", "归一化粒子浓度 C/Cmax", vmin=0, vmax=1.0) axes[1, 0].set_title("(c) 尘埃粒子浓度 —— 尘源散发与输运", fontsize=12.5, color=FG, pad=8) _panel(axes[1, 1], p, solid, "RdBu_r", "静压偏差 Δp (Pa)", center=True) axes[1, 1].set_title("(d) 压力场 —— 送风正压 / 回风负压", fontsize=12.5, color=FG, pad=8) fig.suptitle("FFU 对下方温湿度、压力与尘埃粒子的影响 —— 快速代理(< 8 s)", fontsize=15, y=0.985) fig.text(0.5, 0.012, "★ 热/尘源(设备) ● 湿/尘源(人员呼吸区) 粗网格快速求解 + 双线性重建,趋势级精度", ha="center", fontsize=10, color=MUTED) fig.tight_layout(rect=[0, 0.03, 1, 0.965]) safe_save(fig, "ffu-scalar-fields-fast.png", dpi=130) plt.close(fig) print(" -> ffu-scalar-fields-fast.png") def plot_validation(ux_s, uy_s, T_s, C_s, speed_s, solid): npz = os.path.join(OUT, "ffu_fields.npz") if not os.path.exists(npz): print(" [skip] ffu_fields.npz 不存在, 跳过验证图") return d = np.load(npz) speed_l = np.sqrt(d["ux"] ** 2 + d["uy"] ** 2) * SCALE_U T_l = T_AMB + d["T"] * (T_SRC - T_AMB) C_l = d["C"].copy() def mask(a): b = a.copy(); b[solid] = np.nan; return b vmax_c = float(np.nanpercentile(C_l, 99.5)) C_s2 = C_s * vmax_c fig, axes = plt.subplots(3, 2, figsize=(14.0, 13.0)) rows = [ ("速度 |U| (m/s)", mask(speed_l), mask(speed_s), CMAP_FLOW, 0, 0.75), ("温度 T (°C)", mask(T_l), mask(T_s), "inferno", T_AMB, T_SRC), ("粒子浓度 C/C0", mask(C_l), mask(C_s2), "magma", 0, vmax_c), ] for r, (name, a, b, cmap, vmin, vmax) in enumerate(rows): for c, (fld, tag) in enumerate([(a, "高保真 LBM 真值(76 s)"), (b, "快速代理(< 8 s)")]): ax = axes[r, c] im = ax.imshow(fld, origin="lower", extent=[0, W_M, 0, H_M], cmap=cmap, vmin=vmin, vmax=vmax, interpolation="bilinear") draw_room(ax, solid) draw_sources(ax) cb = plt.colorbar(im, ax=ax, fraction=0.036, pad=0.02) cb.set_label(name, fontsize=9.5) cb.outline.set_edgecolor("#30363d") cb.ax.tick_params(labelsize=8.5) ax.set_xticks([0, 1, 2, 3, 4]) ax.set_xlim(-0.01, W_M + 0.01) ax.set_ylim(0, H_M) if r == 0: ax.set_title(tag, fontsize=13, color=ACCENT if c else FG, pad=8) if c == 0: ax.set_ylabel(name.split(" (")[0], fontsize=11) m = ~solid corr_u = np.corrcoef(speed_l[m], speed_s[m])[0, 1] corr_t = np.corrcoef(T_l[m], T_s[m])[0, 1] corr_c = np.corrcoef(C_l[m], C_s2[m])[0, 1] fig.suptitle("趋势验证:高保真 LBM vs 快速代理(粗网格求解 + 重建)", fontsize=16, y=0.985) fig.text(0.5, 0.006, f"全场相关系数:速度 r={corr_u:.2f} · 温度 r={corr_t:.2f} · 粒子 r={corr_c:.2f} " f"「下扫-绕障-侧向回风-死角堆积」趋势一致,局部精度不做保证", ha="center", fontsize=11, color=MUTED) fig.tight_layout(rect=[0, 0.025, 1, 0.97]) safe_save(fig, "ffu-validation.png", dpi=120) plt.close(fig) print(f" -> ffu-validation.png (speed r={corr_u:.3f}, T r={corr_t:.3f}, C r={corr_c:.3f})") if __name__ == "__main__": main()